Introduction
A privacy-first, on-device product makes a promise that sounds simple: your data stays on your device, nothing phones home. Living up to that promise is one kind of engineering discipline. Debugging a product that lives up to it is a different one, and it is the one nobody warns you about.
Somewhere out there, on a phone you will never see, running an OS version and a chipset combination you did not test, something goes wrong with an on-device AI feature. In a conventional architecture, this is a Tuesday: a crash reporter phones home with a stack trace, a session replay tool shows the exact taps that preceded it, an analytics event tells you it happened to four hundred other people this week too. You get a dashboard. You get a number. You get evidence.
None of that exists here, on purpose. There is no crash SDK quietly attaching a device snapshot to a report. There is no session replay, because a session replay of a medication list is a data breach with a nicer name. There is no background analytics stream telling you what the model just did with someone's health information. The architecture that makes the product trustworthy is the same architecture that makes it hard to see inside from the outside. This article is about what fills that gap, not a workaround for the absence of telemetry, but a different discipline built for a system that was never going to have it.
Telemetry is a shortcut, not a requirement. It buys visibility by moving the trust boundary outward, letting a device tell a server what happened. A system that will not do that has to buy the same visibility a harder way, entirely on the device, and it can. It just has to be designed for, not assumed.
The Gap Between Private and Invisible
"We don't collect telemetry" and "we can't debug this" get conflated constantly, and they are not the same sentence. The first is a policy, chosen on purpose, for good reasons. The second is an operational failure that policy should never be allowed to cause. Treating them as the same thing is a category error, and it is a comfortable one, because it lets a team quietly lower its own diagnostic bar and call the result a privacy feature.
The honest framing is narrower and harder: a privacy-first architecture removes one tool, the ability to observe a live system from outside it, and leaves every other diagnostic discipline exactly as necessary as it always was. The bugs do not become rarer because you stopped watching for them. They become quieter. The work that a cloud dashboard used to do for free now has to be designed, deliberately, into the device.
"No telemetry" describes what leaves the device. It says nothing about what the device is allowed to know about itself. Those are two different design problems, and the second one does not go away just because you solved the first.
Four Kinds of Bugs Need Four Kinds of Evidence
The instinct behind most telemetry pipelines is to collect broadly and decide later what mattered. That instinct is exactly backwards for a system with a privacy boundary, because it treats every bug as if it needed the same evidence, when in practice the bugs that show up around on-device AI split into at least four categories, and they have almost nothing in common:
- Crash and hard-failure bugs. The process dies, an exception escapes, a native library aborts. What you need is a stack trace, an error code, and device context, none of which requires a single byte of the user's actual data.
- Correctness bugs. The model runs fine and produces an answer that looks wrong. What you would need to fully diagnose this is the exact input and output pair, which is simultaneously the single most useful piece of evidence and the single most sensitive one a health app can hold.
- Performance bugs. Inference is slow, memory climbs, the battery drains faster than it should. What you need is timing and resource traces, mostly free of content, but still not something that should leave a device by default.
- Environment-specific bugs. A feature fails only on one chipset, one OS version, one manufacturer's background-process policy. What you need here is device diversity, not user data at all, this is a coverage problem, not an observability one.
Lumping these four together into one "collect everything, sort it out later" stream is precisely the design that a privacy-first product cannot afford, and it is worth noticing that it was never actually the right design, it was just the convenient one. A generic crash-and-analytics SDK does not ask which category a given event belongs to before it uploads it. It invites exactly the over-collection this architecture exists to avoid, because the category that most needs restraint, correctness bugs carrying real content, is the one hardest to keep out of a stream built to catch everything else.
| Bug class | What actually diagnoses it | Where that evidence can safely live |
|---|---|---|
| Crash / hard failure | Stack trace, error code, device context | Local structured log, no content |
| Correctness (wrong output) | Exact input/output pair | Never leaves the device by default |
| Performance | Timing and resource traces | Local, coarse-grained, not auto-uploaded |
| Environment-specific | Chipset, OS, manufacturer coverage | A device lab, before release, not the field |
A Bounded, Local, Structured Log
Most of what a crash-reporting SDK gives you is not, in fact, off-limits. Model load time, a state-machine transition, an error code, a coarse device class, these are operational facts about the software, not facts about the person using it. The problem with conventional logging is not that logs are inherently unsafe, it is that they are usually unbounded, unstructured, and eventually contain whatever a developer typed into a debug statement at 2 a.m., which is exactly the moment nobody is thinking about what belongs in a permanent record.
The fix is a log designed with the same discipline as any other outbound-adjacent artifact: bounded, so it cannot quietly become a shadow database of everything the app has ever seen, a fixed-size ring buffer that overwrites its oldest entries is enough. Structured, typed events with a fixed shape, not free-text strings, so a field cannot silently start carrying a symptom name or a drug name because a string got interpolated into a debug message. And local by default, existing to answer "what state was this device in a moment ago," not to accumulate into anything that resembles a permanent history.
A ring buffer is a small, unglamorous data structure, but it does real design work here: its fixed size is a hard, structural guarantee that logging cannot drift into permanent storage as the codebase grows and more people add log lines. The constraint is enforced by the shape of the buffer, not by anyone remembering a policy.
What's Safe to Record, and What Never Is
A structured log still needs a firm line between fields that describe the software and fields that could describe the person. The test is not "does this feel sensitive," it is a simple, mechanical question: could this field, ever, under any circumstance, hold something the user typed or a value derived from their health data? If yes, it does not go in the log, full stop, not truncated, not summarised.
| Signal | Safe by default? | Example |
|---|---|---|
| Model load / inference timing | Yes | "model_ready: 812ms" |
| State-machine transitions | Yes | "AI_IDLE → AI_LOADING → AI_READY" |
| Error / exception codes | Yes, as codes only | "ERR_OOM_DURING_DECODE" |
| Coarse device / OS class | Yes | "Android 15, mid-tier NPU class" |
| Memory / battery pressure events | Yes | "TRIM_MEMORY_RUNNING_CRITICAL" |
| Model input or output content | No, never by default | a medication name, a symptom, a photo |
| Free-text or interpolated messages | No | any string built from user-entered data |
Hashing or truncating a content-shaped field feels like a safe compromise, and it is not. Health data is drawn from small, guessable vocabularies, there are only so many common medication names or symptom descriptions, which makes a hash of one trivial to reverse by brute force. The only reliable rule is that a field which could hold user content is excluded entirely, not obscured.
Determinism Is a Diagnostic Feature
The less raw evidence you are willing to collect, the more your ability to reproduce a bug depends on the software behaving the same way twice given the same shape of input. That is a deliberate case for determinism, not as a code-quality nicety but as a diagnostic requirement: if a feature's behavior is a predictable function of an explicit state machine, a bug report reading "it happened after backgrounding for over two minutes, on the third scan" is often enough to reproduce it, on your own device, with your own synthetic data, no field data required.
This is where a log built from typed state transitions earns its keep. It does not need to record what the user typed, only the shape of it, empty string, unusually long input, a duplicate entry, non-Latin characters, because the shape is usually what triggers the bug, not the specific content. A codebase held together by implicit control flow and ad-hoc callbacks cannot make this trade, its behavior is not a clean function of state, so nothing short of the original data will reproduce what happened. An explicit state machine can, and that is the diagnostic dividend of the lifecycle discipline this Journal has argued for elsewhere.
Self-Tests: Asking the Device to Check Itself
The other half of local diagnostics is not waiting for a bug report at all. A small suite of self-checks, run at safe, predictable moments, app start, returning from the background, immediately before invoking the model, can assert a handful of narrow invariants: is the model instance still valid, is available memory above the threshold this feature needs, did a resource that should have been released after the last use still show as held.
Each of these is a bounded yes-or-no question, and the answer is a code, not a document. That reframing matters. "Did anything go wrong" is an open-ended question that seems to demand rich, unbounded telemetry to answer well. "Is invariant #12 currently true" is not. A self-test suite turns an open diagnostic problem into a small, enumerable one, and every invariant you can name in advance is one less thing you need field data to discover after the fact.
Diagram source (Mermaid)
graph LR
T[Self-test runs] --> L[Bounded local log]
L --> A[Anomaly detected]
A --> U[User prompted]
U --> E[Consented export]
One Door for Diagnostics, Too
A privacy-first architecture should already treat every outbound path as a deliberate, singular pipeline rather than a collection of one-off exits, backups, reports, and shares each earning the same scrutiny at the same gateway. A diagnostic bundle is exactly this kind of exit, and it does not deserve a side door of its own just because an engineer is on the receiving end instead of a doctor or an insurer.
In practice that means a diagnostic export is assembled only on an explicit action, "something's wrong, help me report it," never automatically and never silently. It is built exclusively from the structured local log and self-test results, the same bounded, content-free record described above, never from raw model input or output. And critically, it is shown to the user before it is sent, in full, so "here is literally everything this report contains" is a sentence the interface can make true, not just claim.
Making the export button visible, and making its contents inspectable before sending, is not a UX nicety layered on top of the architecture. It is part of the trust model. A diagnostic pipeline a user cannot see into is just telemetry with extra steps.
Reproducing What You Can't See: Device Labs and Fuzzing
Restricted field visibility only works if more of the burden shifts left, into the time before a release goes out, because that is the one window where you still have full access to real hardware and real data you generated yourself. Three practices do most of that work.
A physical device lab, covering the chipset classes, OS versions, and RAM tiers that actually matter for the install base, exists to catch category four from the taxonomy above, the environment-specific bug, before it ever reaches a device you cannot inspect. This is precisely the class of bug a cloud crash dashboard would otherwise have surfaced cheaply and by accident; without one, it has to be found on purpose, in a lab, on hardware you own.
Property-based and fuzz testing of the state machine's input space stands in for category two, correctness bugs, which can never be reproduced from a field report containing real content because that content is exactly what must not be collected. Generating a wide, synthetic spread of inputs, empty, oversized, malformed, adversarial, against your own deterministic state machine finds the same class of failure a real user's odd input would have triggered, without ever needing that user's data.
Chaos-style tests in CI, simulated memory pressure, forced backgrounding, killed and resumed processes, are cheap to run before release and expensive to diagnose blind after one. Every scenario reproduced this way in a lab is one less mystery that has to be pieced together later from a handful of state codes and a cooperative user's willingness to help.
None of this replaces the field entirely. A user's own bug report remains a real, valuable signal, it just arrives as a qualitative account cross-checked against a local log the user can inspect and choose to share, rather than as a stream the device was already sending before anyone asked.
The Cost of Not Knowing
It would be dishonest to present any of this as free. There is a real class of bug, the one-in-fifty-thousand-devices issue, the interaction that only shows up after eleven days of uptime on one specific firmware build, that a cloud crash dashboard would have surfaced in aggregate by lunchtime. Without that aggregate view, the same bug can sit unnoticed for a long time, discovered only when a user happens to hit it, happens to notice, and happens to be willing to help reproduce it.
Local-first diagnostics trades early, aggregate, effortless visibility for keeping the same boundary the product exists to protect. That is a genuine cost, paid in slower discovery of long-tail bugs, and the honest response to it is not to pretend the trade doesn't exist. It is to keep shrinking that long tail on purpose, through determinism, self-tests, and device coverage, rather than reaching for the shortcut the architecture already ruled out.
A Note From Practice
This is a live constraint on FarmakoMed, a local-first health platform where medication data, documents, and AI-assisted features run primarily on the user's own device. There is no server-side copy of what the model was asked and no crash dashboard quietly filling in with a stranger's health data whenever something goes wrong. That absence is not a gap in the engineering, it is the point of the engineering, and it means the discipline in this article, bounded local logs, self-tests, deterministic reproduction, a consented export, is not an alternative to real diagnostics. It is what real diagnostics has to look like when the alternative was never actually on the table.
Key Takeaways
No telemetry is a policy, not an excuse. "We don't collect data" and "we can't diagnose this" are different sentences. The diagnostic work doesn't disappear when telemetry is off the table, it moves onto the device.
Different bugs need different evidence. Crashes, correctness issues, performance problems, and environment-specific failures have almost nothing in common. A single "collect everything" stream is the wrong shape for a system with a privacy boundary.
A bounded, structured, local log does most of the job. State codes and timing, never content, in a fixed-size ring buffer, cover far more of what a crash SDK would have told you than it first appears.
Determinism and self-tests substitute for raw data. A reproducible state machine plus a handful of on-device invariant checks turn "what happened" into "what state", which is safe to record and often enough to reproduce.
Diagnostics is an outbound path like any other. It deserves the same deliberate, consented, inspectable export as any other exit, not a side door of its own.
More in the Journal
This article pairs naturally with Every System Needs a Deliberate Exit Door, where the same reasoning is applied to exports, backups, and reports.
Browse all articlesJoin the conversation
How does your team debug systems it deliberately can't see into? Follow FarmakoMed on LinkedIn and share how you approach local-first diagnostics.
Follow on LinkedIn