Skip to content

feat: identify the logged-in user from the main process (setUser) - #13

Merged
Fiona2016 merged 3 commits into
publishfrom
feat/main-process-set-user
Aug 7, 2026
Merged

feat: identify the logged-in user from the main process (setUser)#13
Fiona2016 merged 3 commits into
publishfrom
feat/main-process-set-user

Conversation

@Fiona2016

Copy link
Copy Markdown
Collaborator

Adds setUser / getUser / clearUser to the main process. Until now the SDK only knew the device — through usr.anonymous_id (PR#11) — so a logged-in session could not be attributed to a person and Electron UV could only ever be anonymous UV.

Branched from publish after #11 merged. Does not overlap #12 semantically: the four files both touch (src/index.ts, e2e/app/src/main.ts, e2e/app/src/preload.ts, e2e/lib/mainPage.ts) are edited in different regions.

The API

setUser({ id: 'user-123', name: 'Alice', email: 'alice@example.com' });
getUser();   // { id: 'user-123', name: 'Alice', email: 'alice@example.com' }
clearUser(); // on logout

Why setUser and not upstream's setUserInfo. @flashcatcloud/browser-rum — the SDK the renderer of the same application uses, one file away — exposes setUser / getUser / clearUser and has no setUserInfo at all. Upstream @datadog/electron-sdk 0.7.0 uses setUserInfo, a name Datadog's own browser SDK has since dropped, and we do not track that fork. Consistency inside the product beats parity with a fork we are not following.

Scope is set/get/clear. No extraInfo, no account APIs — nothing needs them yet.

The invariants this had to not break

  • usr.anonymous_id is untouched by all three. The two identifiers coexist so unique users are counted off COALESCE(NULLIF(usr_anonymous_id, ''), NULLIF(usr_id, '')), which is stable across a login.

  • usr.id is still never backfilled. Before the first setUser it is absent, not the anonymous id. The existing Object.keys(usr) === ['anonymous_id'] test is kept and extended, not relaxed.

  • clearUser removes usr.id, never blanks it. NULLIF(usr_id, '') treats an absent field and an empty string as different rows.

  • setUser cannot reach anonymous_id. Only id/name/email are copied out of the caller's object, and the hook writes anonymous_id last so the guarantee does not rest on the sanitizer alone.

    Upstream's ContextManager.filterReservedKeys excludes only the standard fields from its extraInfo bag, so addUserExtraInfo({ anonymous_id }) there overwrites the device id. Recorded in a code comment so a future extraInfo reserves the key.

An invalid call is rejected whole rather than half-applied, and does not notify renderers — a partly-applied identity is harder to notice than none.

Two questions, two stores

get() answers "who is logged in now" from a plain field, so clearUser() takes effect in the millisecond it happens. Event assembly instead asks find(startTime), backed by a DiskValueHistory like ViewContext's, because a main-process event is not always assembled in the moment it describes — a native crash is parsed on the next startup carrying the crash's own timestamp, and addError accepts a caller-supplied startTime. Resolving those at assembly time would hand one user's crash to another.

set() closes the previous entry and opens the next from a single clock read — the millisecond race ViewCollection.createNewView avoids the same way.

The identity is persisted to userData in plain text, beside the anonymous id and the session file. Called out in docs/ARCHITECTURE.md.

Renderers: replacement, not a merge

Renderer events are stamped in Assembly.assembleRendererRumEvent, which needs no Browser SDK change — the Browser SDK's bridge contract has no user getter. DatadogEventBridge.getUser() and the datadog:bridge-identity push exist for what a renderer uploads itself (Session Replay segments), and are what a future Browser SDK would read.

The main process wins, and it replaces the identity rather than merging it. combine merges per key and skips undefined, so merging a main-process { id, name } over a renderer's { id, name, email } would emit one person's id and name beside another's email — an identity belonging to nobody, and worse than either source alone. The deciding reason for precedence is clearUser(): a stale renderer identity surviving a logout would leave the user's name and email on everything that window kept reporting, so a logout has to be enforceable from one place. usr.anonymous_id survives the replacement, and when no user is set nothing is touched — applications that only call flashcatRum.setUser() in their renderers are unaffected.

Two things only the dev run caught

Both passed every unit test before they were found.

1. Views must resolve at emit time, not at their start time. A view is an interval, re-reported as it grows (_dd.document_version), and the backend takes the session's identity from the last view row (buildSessionViewUpdates reads lastView.UserID). The main process emits exactly one synthetic view per session, and setUser cannot run before init — so resolving that view at its start time left t_sessions.usr_id empty for the whole session while the error events beside it carried the full identity. Observed in Doris before the fix.

2. A restored history has to be closed at startup. Quitting is not logging out, so a previous run normally leaves its entry open on disk; restoring it still-active attributed this process's events to whoever used the machine last. Closing it at exactly now was not enough — find treats endTime as inclusive and the first main-process view is created in the same millisecond as init, so a second run stamped its opening view with the first run's user. Closed one millisecond earlier, which is also the truthful bound: the previous run ended strictly before this one began.

Testing

  • yarn test:unit657 (600 on publish)
  • yarn test:e2e46 (37 on publish)
  • yarn test:integration24, unchanged
  • yarn typecheck, yarn format:check, yarn test:e2e:typecheck clean. yarn lint still reports only the two pre-existing errors on publish (OperationCollection.ts indexed-object-style; the playground/src/renderer.ts parse error that needs the playground installed).

Mutation-verified. Every behaviour above was reverted one at a time — 24 reverts, each run against its guard spec, all 24 caught. Two guards were rewritten after the harness showed them passing against a broken implementation: the clock-race test did not exercise the race (no integer timestamp fell in the gap it created), and the restored-history test masked the inclusive boundary with a tick().

Verified against dev (appid mKESnRV4wGs5nwcbTwotmW, staging intake):

  • t_sessions carries usr_id and usr_anonymous_id together
  • t_errors carries usr_id, usr_name, usr_email alongside usr_anonymous_id
  • before the first setUser, usr_id is empty while usr_anonymous_id is populated
  • a second run on the same userData profile no longer inherits the first run's identity

Not in scope

  • addUserExtraInfo and the four account APIs upstream ships.
  • Renderer-side consumption of getUser() — that is a Browser SDK change.

🤖 Generated with Claude Code

Fiona2016 and others added 3 commits August 6, 2026 00:57
Adds `setUser` / `getUser` / `clearUser`. Until now the SDK only knew the
device, through the anonymous id, so a logged-in session could not be
attributed to a person and Electron UV could only ever be anonymous UV.

The names match `flashcatRum.setUser()` in `@flashcatcloud/browser-rum`,
which is what customers already call one file away in the same
application. Upstream's `@datadog/electron-sdk` names it `setUserInfo`, a
name Datadog's own browser SDK has since dropped, and we do not track
that fork — consistency inside the product wins over parity with a fork
we are not following.

Scope is set/get/clear. No `extraInfo`, no account APIs: nothing needs
them yet.

`usr.anonymous_id` is untouched, and `usr.id` is still never backfilled
with it. The two coexist so unique users can be counted off
`COALESCE(NULLIF(usr_anonymous_id, ''), NULLIF(usr_id, ''))` across a
login. `clearUser` removes `usr.id` rather than blanking it, because
`NULLIF(usr_id, '')` treats an absent field and an empty string as
different rows. The existing `Object.keys(usr) === ['anonymous_id']`
guard is kept and extended rather than relaxed.

Only `id`, `name` and `email` are copied out of the caller's object,
which is what makes `setUser({ anonymous_id })` structurally impossible;
the hook also writes `anonymous_id` last so the guarantee does not rest
on the sanitizer alone. Upstream's `filterReservedKeys` excludes only the
standard fields from its `extraInfo` bag, so `addUserExtraInfo({
anonymous_id })` there overwrites the device id — recorded in a comment
so a later `extraInfo` reserves the key.

Two questions, two stores. `get()` answers "who is logged in now" from a
plain field, so `clearUser()` takes effect in the millisecond it happens.
Assembly asks `find(startTime)`, backed by a `DiskValueHistory` like
`ViewContext`'s, because a main-process event is not always assembled in
the moment it describes: a native crash is parsed on the next startup
carrying the crash's own timestamp. `set()` closes the previous entry and
opens the next from a single clock read, the millisecond race
`ViewCollection.createNewView` avoids the same way.

Renderer events are stamped in `Assembly.assembleRendererRumEvent`, which
needs no Browser SDK change; the bridge's `getUser()` and the identity
push exist for what a renderer uploads itself. The main process wins and
**replaces** rather than merges: `combine` merges per key and skips
`undefined`, so merging `{ id, name }` over `{ id, name, email }` would
emit one person's id beside another's email. The deciding reason is
`clearUser()` — a stale renderer identity surviving a logout would leave
the user's name and email on everything that window kept reporting.
`usr.anonymous_id` survives the replacement, and with no user set nothing
is touched, so renderer-only `setUser` callers are unaffected.

Two things dev verification caught, neither visible from unit tests:

- **Views resolve at emit time, not at their start time.** A view is an
  interval, re-reported as it grows, and the backend takes the session's
  identity from the last view row (`lastView.UserID`). The main process
  emits one synthetic view per session and `setUser` cannot run before
  `init`, so resolving at the view start kept `t_sessions.usr_id` empty
  for the whole session while the errors beside it carried the identity.

- **A restored history is closed at startup.** Quitting is not logging
  out, so the previous run's entry is still open on disk; restoring it
  active attributed this process's events to whoever used the machine
  last. Closing at exactly `now` was not enough — `find` treats `endTime`
  as inclusive and the first view is created in the same millisecond as
  `init`, so a second run stamped its opening view with the first run's
  user. Closed one millisecond earlier, which is also the truthful bound.

Testing: `yarn test:unit` 657, `yarn test:e2e` 46, `yarn test:integration`
24. Every behaviour above has a guard that fails without it — 24 reverts
applied one at a time, all caught. `yarn typecheck`, `format:check`,
`test:e2e:typecheck` clean; `lint` still reports only the two pre-existing
errors on `publish`.

Verified against dev (appid `mKESnRV4wGs5nwcbTwotmW`): `t_sessions` shows
`usr_id` and `usr_anonymous_id` populated together, `t_errors` carries id,
name and email, and a second run on the same profile no longer inherits
the first run's identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bridge was rewritten under this branch, so the conflicts are all in the
same place: the configuration channel this branch adds a user to.

`IdentityUpdate` is gone. The push now carries the whole `BridgeConfig`
rather than a session id, so a user field added to the configuration reaches
renderers on its own — the second payload this branch had to extend no longer
exists, and `pushConfig` needed nothing.

The preload gained an `apply()` that takes a configuration wholesale, so the
user is set there with everything else instead of by hand beside the session
id, and the `identityPushed` flag folds into the `configPushed` one.

`BridgeHandler`'s renderer set is gone too — it pushes to every live
`webContents` — so the tests move from registering senders through a
configuration request to declaring which renderers are alive. The case
covering a configuration request without a sender goes with it: the handler
no longer reads the sender, so it could not fail.
`commonContext` and `Assembly` reach `UserContext` for the identity in force,
and it imports `electron` at module load for the path its history file lives
at. Nothing in either suite touches that history, but the import alone is
enough: without an Electron binary the module throws on load and takes both
suites with it.

That is what CI has, and why these two were the only ones failing there while
the other thirty-five passed — they were the only ones reaching `electron`
without mocking it.

Reproduced by removing `node_modules/electron/path.txt`, which is what the
module checks: the same error, and gone with the mock.
@Fiona2016
Fiona2016 marked this pull request as ready for review August 7, 2026 12:08
@Fiona2016
Fiona2016 merged commit dfccc71 into publish Aug 7, 2026
2 checks passed
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.

1 participant