Skip to content

Offline persistence

A train goes into a tunnel, a laptop lid closes mid-export, a collector restarts. Without persistence the telemetry of exactly those moments - often the interesting ones - is lost. With it, an export request that fails with a network error or a retryable status is kept on the device and sent again later: in localStorage in the browser, and in files in Blazor Hybrid and on servers.

What is kept, and when

Persistence works on whole export requests, already serialized, rather than on records: what is stored is exactly the body that could not be delivered, so it is sent again unchanged. A request is kept when it fails with a network error, or with one of the statuses a receiver uses to say "later":

408 Request Timeout 429 Too Many Requests 439 (Azure daily quota) 500 502 503 504

Before a request is stored it is first retried in place a few times, with backoff that honours Retry-After. Any other status - a 400 for a malformed body, a 401 for a wrong key - is a permanent answer, and the request is dropped rather than replayed forever. An Azure Monitor 206 partial success retries only the items the response lists as retryable.

Persistence needs two things at once:

  • options.Persistence.Enabled - on by default - which is the operator's switch.
  • The user's consent for the Persistence feature. Keeping data on someone's device is a decision of its own under GDPR, so it is a feature bit of its own, and nothing is written before it is granted.
builder.Services.AddBitBelemetryServices(options =>
{
    options.Persistence.Enabled = true;                          // the default; consent still applies
    options.Persistence.BrowserMaxBytes = 1024 * 1024;           // localStorage is small: 1 MB
    options.Persistence.MaxBytes = 20 * 1024 * 1024;             // the file store (Hybrid, server): 20 MB
    options.Persistence.MaxAge = TimeSpan.FromHours(48);         // discarded unsent after this
    options.Persistence.RetryInterval = TimeSpan.FromMinutes(1); // how often stored requests are retried (both sides)
    // options.Persistence.Directory = "...";                    // defaults to LocalApplicationData
});

Where it is kept

HostStoreSize capRetried
Browser (every render mode) localStorage key bit-belemetry-queue BrowserMaxBytes (1 MB) Every RetryInterval (1 minute) while the page is open, when the script starts (the next page load), when the browser fires online, and when Persistence is granted
WebAssembly .NET pipeline Handed to the script, which stores it in the same localStorage queue BrowserMaxBytes As above - the script owns the retry, and can also send with keepalive during unload
Blazor Hybrid, and the server pipeline Files under Directory (default {LocalApplicationData}/bit-belemetry/{service.name}) MaxBytes (20 MB) Every RetryInterval (1 minute)

Past the size cap the oldest requests are evicted first, and a request older than MaxAge (48 hours) is discarded unsent - telemetry from two days ago rarely answers a question anyone is still asking, and a device should not fill up with it. Neither store keeps request headers - an API key has no business sitting in localStorage or in a file - so they are added again from the exporter's current configuration when a request is resent (a rotated key applies to the backlog too). Credentials that expire, such as an Entra ID token for Azure Monitor, are never stored on either side: the exporter adds a fresh one at send time.

Each entry of the browser queue has this shape:

Look at the queue

This panel reads localStorage['bit-belemetry-queue'] directly. On a healthy connection it is empty - nothing failed, so nothing was kept. To fill it, open your browser's developer tools, set the Network panel to Offline, press Track an event, and read the queue again. Then go back online: the script hears the online event, resends, and the queue empties.

Livebit-belemetry-queue
Persistence consent
unknown
bit-belemetry-queue
not read yet

Track an event needs the Events feature granted, and the queue only fills with Persistence granted. Revoking Persistence clears the queue at once: read it again after pressing Revoke Persistence while it holds something.

Revoking clears it

Withdrawing consent has to reach data already stored, not only data still to come. Revoking Persistence - from the banner, from IBelemetryConsent.RevokeAsync or from BitBelemetry.setConsent in the browser - removes the bit-belemetry-queue key in the browser and deletes the stored files on the .NET side. Revoking any other feature purges that feature's records still queued in memory, so nothing it collected leaves the device afterwards.

Hybrid apps

In MAUI, WPF and WinForms the browser script relays its records to .NET, and the .NET pipeline exports everything - so the file store is the one that matters. A phone is suspended long before it loses its files, so flush when the app goes to the background; what does not get through is stored and retried on the next RetryInterval after the app resumes.

API

BelemetryPersistenceOptions Class

Offline persistence: export requests that fail with a network error or a retryable status are kept on the device and retried later, as long as the user granted BelemetryFeatures.Persistence.

Name Type and default Description
Enabled bool= true Whether persistence is used at all. Defaults to true (still subject to consent).
Directory string The folder the .NET side stores requests in (Hybrid and Server). Defaults to {LocalApplicationData}/bit-belemetry/{service.name}. In WebAssembly the browser storage is used.
MaxBytes long= 20971520 The maximum size of the .NET store; the oldest requests are evicted past it. Defaults to 20 MB.
BrowserMaxBytes long= 1048576 The maximum size of the browser store (localStorage is small). Defaults to 1 MB.
MaxAge TimeSpan= 2.00:00:00 How long a stored request is kept before it is discarded unsent. Defaults to 48 hours.
RetryInterval TimeSpan= 00:01:00 How often stored requests are retried. Defaults to 1 minute.
An unhandled error has occurred. Reload 🗙