Skip to content

feat: setEndUser stamps enduser.id on spans and log records - #51

Merged
stephane-segning merged 1 commit into
mainfrom
claude/set-end-user
Oct 8, 2026
Merged

stephane-segning merged 1 commit into
mainfrom
claude/set-end-user

Conversation

@stephane-segning

@stephane-segning stephane-segning commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds OtelZone.setEndUser(String? id). From the next span or log record on, every span started and every log record emitted carries the semantic convention's enduser.id. null (or an empty string) stops it. The id is exempt from redact; everything else is scrubbed exactly as before.

void setEndUser(String? id)   // plus: String? get endUserId

Intent

Ticket T12 of FEAT-44 (account-linked analytics, approved by the owner on 2026-10-08), which is EXT-27 in docs/core-revamp.md of vaam-apps/vaam-apps. The Vaam app links its spans to an account only while the account's consent row says yes, and needs this package to stamp and clear the id. Merging this cuts v0.6.0 through release-please, which the app's otel_zone bump (T15) waits on.

The package never sets the id by itself. A build that does not call setEndUser exports no enduser.id anywhere, so this changes nothing for an app that does not opt in.

Scope

  • lib/src/end-user.dart (new): EndUserSpanProcessor and EndUserLogRecordProcessor, and the enduser.id key (read from the SDK's generated semconv registry, pinned to its literal by a test).
  • lib/src/otel-zone.dart: setEndUser, endUserId, and the wiring in start().
  • lib/src/span-redaction.dart: the exemption for the exact key enduser.id.
  • README.md ("Linking telemetry to an account"), API docs on setEndUser, RedactingSpanExporter and OtelZoneConfig.redact.
  • Tests: six new files plus additions to two, and two small test-support helpers.
  • CHANGELOG.md is generated by release-please from this squash commit; it is not edited by hand.

Not in scope: the app side (T15), and anything in the native Android/iOS code.

How it works

  • Spans are stamped in onStart by a span processor appended after the pipeline. A span is exported at its end, after every processor has seen it start, so the order does not matter. It is installed only when a trace pipeline exists, so OTEL_SDK_DISABLED and OTEL_TRACES_EXPORTER=none still leave no processor behind.
  • Log records are stamped in onEmit, and that processor has to be first. BatchLogRecordProcessor.onEmit queues a clone of each record, so a stamp made after it lands on an original nobody exports, and the provider's processor list is append-only. start() therefore hands the stamper to OTel.initialize and builds the rest of the logs pipeline behind it with the SDK's own LogsConfiguration.configureLoggerProvider, with the arguments initialize would have passed. test/end-user-log-order_test.dart proves the constraint is real, and is written to fail the day the SDK stops cloning.
  • Redaction. A span's enduser.id is passed through whole by the span scrubber. A log record's is added after the bridge has scrubbed the record, so it never meets redact. Only the exact key is exempt (tested against enduser.pseudo.id, user.enduser.id, Enduser.id and others).

Behaviour worth knowing

  • From the next one only. A span already running when the id is set does not gain it, and one running when it is cleared keeps it. Nothing already emitted, queued or spooled is rewritten: clearing 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.
  • Never throws. setEndUser is one field assignment, with no lock, timer, microtask or zone value, so any zone may call it and the last call wins. Stamping never throws or fails a future into the SDK, which does not await its processors. State is per isolate.
  • Safe before start(). The ticket asked for a no-op while the SDK is not up. I made it keep the value instead of dropping it: it still cannot throw and nothing can be observed from it, but an id set a moment before start() completes is not silently lost. If the SDK never starts, nothing is ever stamped. This is a judgment call; say if a literal no-op is wanted.
  • The exemption trusts the app. Pass an opaque account id, never a phone number, an e-mail address or a name.

Verification

Run locally with Flutter 3.48.0-0.4.pre (the version ci.yml pins), after deleting both lockfiles, on top of #49 (the dartastic_opentelemetry_api cap, merged):

  • dart format --output=none --set-exit-if-changed .: 47 files, 0 changed.
  • flutter analyze: no issues.
  • flutter test: 249 passed, 0 failed (217 before this change, 32 new).
  • Wire tests go through OtelZone.start() and a real loopback collector, and decode the SDK's own protobuf: spans and logs carry the id from the call to the clear and not before or after, with a redact that masks nine digits in a row (and does mask the id's neighbours); the same through the spooling exporter and dartastic's own trace pipeline, with the id set before start(); OTEL_LOGS_EXPORTER=none still sends no logs.
  • Mutation checks, each reverted afterwards: removing the redaction exemption fails 6 tests; not installing the span stamper fails both wire tests; appending the log stamper instead of passing it to initialize fails both wire tests; dropping an id set before start() fails 2 tests.
  • The squash message parses with the repo's pinned @conventional-commits/parser, and a nested-paren control line correctly fails.
  • Cuid question from the ticket, measured over 200,000 random ids of each kind: an unanchored \d{9} (the README's own redact example) masks about 1 in 10,000 cuids (0.009%) and 1 in 33 UUID v4s (3.1%); an anchored ^\+?\d{9,12}$ masks neither. So a cuid can trip a phone rule, rarely, and that is what the exemption is for.

Not run here: the Kotlin, Swift and emulator jobs of CI (no native code changed), and a real device. android crash harness (API 34) has been red on main since 2026-10-02 because the emulator hangs partway through the run (evidence in the comment on #49); it is not affected by this change either way.

Risk Assessment

  • Privacy: this is the first thing in the package that links telemetry to a person, and it is opt-in by construction (no call, no attribute). The exemption means redact will not catch a phone number passed as the id; documented in the API docs and the README.
  • Behaviour change for existing apps: none, unless they call setEndUser. The logs pipeline is now built in two steps by the same SDK function; OTEL_LOGS_EXPORTER, the OTLP headers and the spool are covered by tests.
  • Upgrade: v0.6.0 also carries the dartastic_opentelemetry_api cap from fix: cap dartastic_opentelemetry_api below 1.0.0-rc.4 #49, so a fresh resolve gets rc.3.

Reviewer Focus

  • The two-step logs pipeline in OtelZone.start() and the comment above it.
  • _scrubAttributes: the exemption is by exact key, in span, event, link and scope attributes alike.
  • Whether remembering an id set before start() is what you want (see above).

🤖 Generated with Claude Code

https://claude.ai/code/session_01XLy7TiPAxtVjPKEwQ9iwBa


Generated by Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

OtelZone.setEndUser(String? id) links every span started and every log record
emitted from the next one on to an account, as the semantic convention's
`enduser.id`. null (or an empty string) stops it. The package never sets it
itself, so a build that does not call it exports no enduser.id anywhere.

Spans are stamped in onStart by a span processor appended after the pipeline:
a span is exported at its end, after every processor has seen it start, so the
order does not matter. Log records are stamped in onEmit by a log processor that
has to be first, because BatchLogRecordProcessor queues a clone of each record
and a stamp made after it lands on an original nobody exports; the provider's
processor list is append-only, so start() hands the stamper to OTel.initialize
and builds the rest of the logs pipeline behind it with the SDK's own
LogsConfiguration.configureLoggerProvider. A test pins the ordering constraint
so it fails the day the SDK stops cloning.

The id is exempt from `redact` on spans; a log record's is added after the
bridge has scrubbed the record, so it never meets the redactor. Measured over
200,000 random ids of each kind, an unanchored \d{9} (the README's own example)
masks about 1 in 10,000 cuids and 1 in 33 UUIDs. Every other attribute is
scrubbed exactly as before, and only the exact key is exempt.

Spans and records already made keep what they had: clearing stops linking, it
does not recall. Recovered native crash reports are not stamped, since they
describe the previous run. The call never throws, touches no timer, microtask
or zone value, and is safe before start(): the id is kept and applied from the
first span and record after a successful start. Stamping never throws or fails
a future into the SDK, which does not await its processors. Neither stamper is
installed when OTEL_SDK_DISABLED or OTEL_TRACES_EXPORTER=none builds no
pipeline.

This is ticket T12 of FEAT-44, and EXT-27 in the vaam-apps/vaam-apps
core-revamp list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLy7TiPAxtVjPKEwQ9iwBa

Copy link
Copy Markdown
Contributor Author

android crash harness (API 34) is red here, and the failing set is the same as main's last scheduled run (run 37764117153, 2026-10-08, job 113267429083). I compared the two logs case by case:

  • Both reach the same three commands and nothing past them. --kinds jvm,native,anr: 3 of 3 pass. --kinds jvm,native --upgrade-apk ...: 2 of 2 pass. --kinds native --repeat 20: 18 pass on main, 19 pass here, then the job dies.
  • Neither log has a FAIL assertion line. No case that touches spans, logs, the end-user attribute or the processor chain fails here.
  • Both die the same way, in the --repeat 20 command: detected a hanging thread 'QEMU2 main loop' and 'QEMU2 CPU0 thread' (15 to 19 s unresponsive), a few hundred crashpad ptrace: No such process lines (647 on main, 691 here), then Bad state: ... adb -s emulator-5554 shell ... exited 1 with the device offline. main's run died on a dumpsys activity exit-info call and this one on am force-stop, which are two different adb calls hitting the same dead emulator.

So the failing case is the --repeat 20 command on API 34, killed by the emulator hang, and it is the same on main. This PR got one iteration further than main did. The later commands (--relaunch-after, --release-refusal) did not run on API 34 in either run. They did run, and pass, on API 30 here.

Everything else is green on this commit: analyze and test, Super-linter, both conventional-commit checks, Trivy, Swift/iOS, Kotlin (on the one re-run, after a dl.google.com timeout in setup-android), and the API 30 harness in full. I'm merging on that basis. Details of the emulator hang are in my comment on #49.


Generated by Claude Code

@stephane-segning
stephane-segning marked this pull request as ready for review October 8, 2026 23:04
@stephane-segning
stephane-segning merged commit ba4bdbd into main Oct 8, 2026
16 of 18 checks passed
@vaam-apps vaam-apps Bot mentioned this pull request Oct 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants