Crash reporting is the one piece of observability nearly every mobile app installs on day one, and it is also the one that most reliably ships personal data to a third party without anyone deciding to. The reason is that a crash report is a snapshot of memory and context at the worst possible moment, and nobody chose what was in memory.
Short answer
A crash report is not just a stack trace. Depending on configuration it can carry breadcrumbs, custom keys, recent log output, user identifiers and the contents of variables visible in the frames captured. Firebase Crashlytics and Apple's own crash reports and device logs both document what is collected, and the practical rule is that everything attached to a crash leaves the device and lands with whoever runs the service.
What is actually in a crash report
Five layers, and teams typically think of the first one only.
The stack trace. Function names, file paths and line numbers. Relatively harmless, and it does reveal internal structure, module names and sometimes the layout of your backend if the frames include networking code with descriptive names.
Device and app context. Model, operating system version, locale, free memory, whether the device is rooted or jailbroken. Mostly benign, and it can be identifying in combination when the population is small.
Breadcrumbs and custom keys. Whatever your team added to make debugging easier. This is where email addresses, account identifiers and screen names most often enter, because adding them is the obvious way to make a crash actionable.
Attached logs. Many reporters capture recent log output as context, which imports every problem described in a logging audit directly into the crash payload.
Exception messages and captured values. An exception message frequently contains the data that caused it. A parsing failure carries the string it failed to parse, which may be a response body, a token or a document.
That last layer is the one that surprises people, because it is not configured. It comes from the code that threw.
Where the data goes and what that means
A crash report is a transfer of personal data to a processor, and the fact that it is a debugging tool does not change that.
The service holds it under its own retention period, which is usually longer than you would choose and frequently not configurable on lower tiers. It may be stored in a jurisdiction you have not thought about. It is visible to everyone on your team with access to the dashboard, which is normally more people than have access to your production database.
That access asymmetry is the practical risk. A support engineer who could not query production user records can often read a crash report containing the same information, because crash dashboards are treated as engineering tooling rather than as a data store.
Apple's user privacy and data use guidance and Google's user data policy both require accurate disclosure of what your app collects, and a crash reporter collecting identifiers is collection whether or not you think of it as analytics. The MASVS privacy category covers the same ground from the engineering side.
Making crash reports safe without making them useless
The goal is a report that lets you fix the bug and does not describe the person who hit it.
Use a pseudonymous identifier. Set a stable, internal user reference rather than an email address. You can still map it back through your own systems when you need to, with the access controls that implies, and the crash service never holds the identity.
Curate breadcrumbs. Screen names, feature flags and the sequence of actions are useful and safe. Field contents are neither. A breadcrumb saying that the user opened the payment screen is helpful; one carrying the amount and the last four digits is not.
Do not attach raw logs. If your reporter supports capturing log output, decide deliberately rather than accepting the default, and if you keep it, the logging discipline that applies to device logs now applies to your crash payloads.
Sanitise exception messages at the throw site. This is the harder one, because it means not putting the failing payload into the message. A parse error that says which parser failed and how many bytes it saw is nearly as useful as one that includes the content.
Turn off automatic collection of anything you cannot describe. If you cannot write a sentence explaining why a field is collected, it should not be collected.
Checking what your reporter actually sends
Configuration describes intent. The payload describes behaviour, and the two diverge.
Trigger a crash deliberately in a release build, on a real device, in a flow that has handled user data. Then look at the report as it arrives in the dashboard, reading every field rather than the stack trace.
Do this again after adding an SDK, because SDKs add breadcrumbs and custom keys of their own, and after a major version upgrade of the reporter, because defaults change between versions.
Check what happens on a crash during a sensitive flow specifically: payment, login, document upload. Those are the paths where the memory contents at crash time are most sensitive, and they are the least likely to be tested this way.
The client-side configuration is visible in a built artifact, so an automated pass such as PTKD.com will show which reporting and analytics SDKs are present and how they are configured, which is a reasonable starting inventory when you inherit a codebase and do not know what is collecting what.
The consent and disclosure question
Crash reporting sits awkwardly between necessary operation and optional analytics, and the answer is not the same everywhere.
If the report contains no identifiers and no personal data, the argument that it is necessary for the service is reasonable in most places.
If it carries a user identifier, breadcrumbs describing behaviour or attached logs, then it is processing personal data and needs the same treatment as any other processing: a basis, a disclosure, a retention period and a place in your privacy documentation.
The disclosure has to match what actually happens, which is the part that fails audits. A privacy label declaring no data collection, on an app sending user identifiers to a crash service, is inaccurate regardless of intent, and both stores treat inaccurate disclosure as a policy problem.
The simplest defensible position is to keep crash payloads free of personal data so the answer is easy, and to accept the small amount of debugging friction that costs.
Inheriting an app you did not instrument
Most teams meet this problem on a codebase somebody else configured, which changes the first step.
Start with an inventory rather than a policy. List every SDK in the build that can send data off the device, which is usually more than the team believes: a crash reporter, an analytics library, a performance monitor, an attribution SDK and whatever a framework pulled in transitively.
Then find the initialisation for each and read the options actually passed, not the ones the documentation recommends. Defaults change between versions, and an SDK initialised three years ago is running with the defaults of three years ago plus whatever a version bump silently changed.
Then look at the dashboards. The fastest way to learn what an app collects is to read what has already arrived, and a few minutes in a crash console usually answers the question faster than a week of code reading.
Finally, compare all of that against the store privacy disclosure. A mismatch here is the finding, and it is common on inherited codebases because the disclosure was written once and the SDK list kept growing.
Retention, which nobody configures
Crash data accumulates, and the default retention of most services is longer than any team would choose deliberately.
Set it to the shortest period that still lets you investigate a regression, which for most products is weeks rather than months. Old crash reports are rarely reopened, and the value of keeping them is much lower than the value of not holding them.
Where the service does not allow configuring retention, that is a fact to record in your data documentation rather than to ignore, and it is a reasonable input when choosing between vendors.
And remember that exports leave the retention policy behind. A csv downloaded to investigate a spike lives on someone's laptop under no policy at all, which is the least controlled copy of the data you have.
When a crash report is the right place for detail
There is a version of this advice that goes too far, and it produces reports nobody can act on.
Detail that describes the program state is what you want: which screen, which feature flags, which network call was in flight, what the response status was, how many items were in the collection being iterated. None of that is personal data and all of it shortens an investigation.
The line to hold is between describing the state and reproducing the content. Knowing that a list had zero items when the code assumed one is enough. Knowing what was in the list is not necessary and is where the personal data lives.
Teams that internalise that distinction end up with better crash reports as well as safer ones, because the discipline forces people to attach the things that actually explain a failure rather than dumping whatever was nearby.
A short configuration checklist
Six settings, each a single decision, covering most of the exposure in a typical crash reporting setup.
Whether a user identifier is set, and whether it is a pseudonymous internal reference rather than an email address or a username.
Whether log capture is enabled, and if so, what your logging discipline puts in those logs.
Whether breadcrumbs carry field contents anywhere, which is worth a search for the breadcrumb call across the codebase rather than an assumption.
What retention is configured, and whether the plan you are on allows configuring it at all.
Who has access to the dashboard, reviewed against who has access to production data.
Whether the store privacy disclosure lists this SDK and the data it sends, in terms that match the five settings above.
Working through that list takes an afternoon on an unfamiliar codebase and considerably less on one you built. It is the cheapest privacy work available to a mobile team, and it addresses a data flow that exists in almost every app and is documented in almost none.
The one habit worth keeping
Add a step to your release checklist that says: crash a release build in a real flow and read the payload.
It takes five minutes, it needs no tooling beyond the dashboard you already pay for, and it is the only check that reflects what the current version of your app, with its current SDK versions and its current breadcrumbs, actually sends. Every other method here describes intent, and this one describes behaviour.
What to take away
A crash report is a memory snapshot with context attached, and nobody chose what was in memory when it fired.
The leaks come from breadcrumbs, custom keys, attached logs and exception messages far more often than from stack traces, and only the first two of those are things anyone configured on purpose.
Use a pseudonymous reference instead of an identity, curate breadcrumbs to actions rather than contents, and sanitise exception messages where they are thrown.
Verify by crashing a release build during a sensitive flow and reading the whole payload, then again whenever you add an SDK or upgrade the reporter, since both of those change what is collected without changing anything you wrote.
Review who can read the dashboard on the same cadence as you review database access, because in most teams the crash console is the more permissive of the two and holds overlapping data.
And make sure your store privacy disclosure describes what the crash reporter actually sends, because that is the version an audit compares against, and because a disclosure written before the last three SDKs were added is describing a different app.



