Linking telemetry to an account
observability.setEndUser('cmg2x8k4a0000abcd1234efgh'); // signed in, opted in
observability.setEndUser(null); // signed out, or opted outOtelZone.setEndUser(String? id) stamps the semantic convention's enduser.id on every span that starts and every log record that is emitted from then on, until it is called again. null stops it, and so does an empty string, because an attribute may not be empty. endUserId reads back what the next span and record will carry.
The package never sets it by itself. An app that does not call setEndUser exports no enduser.id anywhere, and whether, when and for whom to call it is the app's decision — a consent, a sign-in — not something this package asks.
From the next one, and no further back
A span is stamped as it starts and a record as it is emitted, so the identity on telemetry is the one that was current when it was made:
- A span already running when the id is set does not gain it, and one running when it is cleared keeps it. A span that wraps a sign-in, started before it, stays unlinked.
- Nothing already emitted, queued or spooled is rewritten. Clearing the id stops linking; it does not recall.
- Recovered native crash reports are not stamped. They describe the previous run, and whoever is signed in now is not necessarily who was then.
The id is exempt from redact
On a span the id is passed through whole while every other attribute is scrubbed as before; a log record's is added after the bridge has scrubbed the record, so redact never meets it. Only the exact key enduser.id is exempt.
The exemption is there because masking happens. Over 200,000 random ids of each kind, the unanchored \d{9} used as the README's example masks about 1 in 10,000 cuids and 1 in 33 UUIDs; an anchored ^\+?\d{9,12}$ masks neither. A masked id unlinks its span with nothing to say so.
Pass an opaque account id
The price of the exemption is that it trusts the app. Pass an opaque account id — never a phone number, an e-mail address or a name, which redact would otherwise have caught.
It never throws, and any zone may call it
It is one field assignment, with no lock, no timer and no I/O. The id lives on the OtelZone and in no Zone, so it does not matter which zone sets it or which later starts a span; the last call wins. It belongs to the isolate, like the rest of Dart's state: a background isolate has its own OtelZone and its own id.
It is safe at any time, before start() included. Before the SDK is up there is nothing to stamp, so the call has no visible effect; the id is kept, and the first span and record after a successful start() carry it. If the SDK never starts, nothing is ever stamped. Stamping itself never throws into the SDK either: the SDK calls its processors without awaiting them, and a failure there would otherwise surface as an uncaught error in the app's own handler. It fails towards an unlinked span.
How it is wired
The order is not arbitrary.
- Spans are stamped by
EndUserSpanProcessorinonStart, appended behind the trace pipeline. A span is exported at its end, after every processor has seen it start, so appending is early enough. - Log records are stamped by
EndUserLogRecordProcessorinonEmit, and that one has to be first: the batch processor queues a clone of each record, and a stamp made after it lands on an original nobody exports.addLogRecordProcessoronly appends, sostart()hands the stamper toOTel.initializeand builds the rest of the logs pipeline behind it, with the SDK's ownLogsConfigurationfunction and the argumentsOTel.initializewould have passed — soOTEL_LOGS_EXPORTER, theOTEL_EXPORTER_OTLP_*headers and the spool behave as they did. Anenduser.ida record already carries is replaced, not repeated.
On sign-out
Clear the id and the spool (see Where the spool lives): a batch spooled while the id was set keeps it, and is delivered with it on the next launch.