Observability is the ability to tell, from the outside, what a running application is doing, without attaching a debugger to it. In an ordinary SPA the telemetry loads together with the app, and that is almost always enough. In a PWA a new layer appears: the worker stands in front of the app and decides what loads at all. So it can break the app before your telemetry has a chance to come up - and then the classic approach goes blind exactly when it is needed most.
Two things must be visible before anything else: which app version is running now and which worker, or more precisely which controller, served the page. The reason is in the incident statistics: a stuck old worker that keeps serving the previous code after a deploy is the most common offline-first failure. Without the version-controller pair you cannot tell a fresh bug in the new release from a user stuck on a build from two releases ago, and you will fix the wrong thing.
The naive solution is to send telemetry over the same brittle network path as the app itself. It breaks for an obvious reason: if the network is down - which is exactly the state you care about - the telemetry drowns along with everything else, and you learn nothing about offline behavior precisely when offline is the problem. A failure report must not depend on the same channel that is failing.
So you start with a diagnostic snapshot - a small set of fields that can be gathered locally at any moment without touching the network. It describes not business events but the state of the platform under the app: version, controller, display mode, a network hint, outbox depth, and a storage estimate.
Each field of the snapshot answers a specific question. A controller equal to null means no worker controls the page - the app is uncontrolled and has no offline right now. displayMode tells an installed app (standalone) from a browser tab - behavior and user expectations differ between the two. outboxDepth is the health metric of sync: a growing depth means the send is stuck and data is piling up on the client. storage.estimate gives quota and usage; usage approaching quota predicts a future eviction before it happens.
Gathering the snapshot is half the job; the other half is how to deliver it. Do not push telemetry over that same brittle path without a buffer. Minimal events go into a bounded queue and are sent later, when the network returns. The word bounded is the key one here: an unbounded log queue starts competing for the very quota you are monitoring with that log, and in the limit it hastens the eviction of useful data.
And a hard boundary: privacy over completeness. Never log the bodies of private requests. Offline metrics - counters, depths, versions, timings - are safe; the contents of requests and of the user's notes are not. The whole point of the offline archive was to keep private data local; telemetry must not become the very channel through which it leaks out.
What this adds up to as standing metrics: the fraction of loads served by the worker, the distribution of outbox depth, the ratio of successful to failed syncs, the headroom against quota, and the update-adoption lag - how long users stay on the old version. The concrete failure without it: the notes app quietly stops syncing the outbox, the queue depth grows, but it is not in telemetry - and you learn of the breakage from support tickets rather than a dashboard, days of lost mutations later.
const diagnostic = {
appVersion: APP_VERSION,
controller: navigator.serviceWorker.controller?.scriptURL ?? null,
displayMode: matchMedia('(display-mode: standalone)').matches
? 'standalone' : 'browser',
onlineHint: navigator.onLine,
outboxDepth: await outbox.count(),
storage: await navigator.storage.estimate()
};