Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ First FlashCat release. Forked from `@datadog/electron-sdk` v0.3.0 and rebranded

- New `normalizeStackPath` option: rewrite a frame's absolute path yourself, before the built-in normalization runs, for build layouts a single application root cannot express (e.g. emitting to `<app root>/public/dist` but uploading under `/dist`). Returning `undefined` falls through to `app:///`. It applies to main-process and renderer frames alike, and a callback that throws is reported as an SDK error and falls back to the built-in behaviour. See the README.

- New `setUser` / `getUser` / `clearUser`: identify the logged-in user from the main process. The identity is attached to main-process events and to the renderer events that arrive over the bridge, and is served to renderers through `DatadogEventBridge.getUser()` for what they upload themselves. `id` is required; only `id`, `name` and `email` are read. The names match `flashcatRum.setUser()` in `@flashcatcloud/browser-rum` so both processes of an application share one vocabulary. See the README.

`usr.anonymous_id` is untouched by all three, 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, since `NULLIF(usr_id, '')` distinguishes an absent field from an empty string.

An identity set in the main process takes precedence over one set in a renderer, and replaces it wholesale rather than merging field by field — a merge could emit one person's id beside another's email. Applications that only call `flashcatRum.setUser()` in their renderers are unaffected.

### 🐛 Bug Fixes

- Main-process HTTP calls no longer lose their `resource` events to a sibling request that never returns. dd-trace only exported a trace once every span in it had finished, so one hung request withheld the resource events of every other request made from the same `ipcMain.handle` invocation, for the rest of the process' life. Spans are now exported as they finish (`flushMinSpans: 1`).
Expand Down
64 changes: 60 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,16 +148,20 @@ retry.

##### Identifiers the bridge answers

`window.DatadogEventBridge` exposes the two identifiers the main process owns, so a renderer can
attribute anything it uploads itself to the same session and device:
`window.DatadogEventBridge` exposes the identifiers the main process owns, so a renderer can
attribute anything it uploads itself to the same session, device and user:

| Method | Returns |
| ------------------ | ----------------------------------------------------------------------------------------------------- |
| `getSessionId()` | Id of the session the main process considers active, or `''` while none is (expired, not renewed yet) |
| `getAnonymousId()` | Device-scoped id, generated once and kept under `app.getPath('userData')` across restarts |
| `getUser()` | Identity set through [`setUser`](#setuseruser-user-void) as JSON, or `'{}'` when nobody is logged in |

Both answer synchronously and without IPC: the anonymous id is delivered when the bridge is set up,
and the main process pushes every session change to open renderers.
All three answer synchronously and without IPC: the anonymous id is delivered when the bridge is set
up, and the main process pushes every session and identity change to open renderers.

Renderer events do not need `getUser()` — the main process stamps the identity on them as they pass
through. It is there for what a renderer uploads itself, such as Session Replay segments.

##### How to find your events

Expand Down Expand Up @@ -343,6 +347,58 @@ try {
}
```

### `setUser(user: User): void`

Identify the logged-in user. The identity is attached to every subsequent main-process event, and
to the renderer events that reach the main process over the bridge.

```ts
import { setUser, getUser, clearUser } from '@flashcatcloud/electron-sdk';

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

// Later, when the user logs out:
clearUser();
```

```ts
interface User {
/** Required. */
id: string;
name?: string;
email?: string;
}
```

`id` is required: a call without one is ignored with a warning, as is one whose `name` or `email` is
not a string — a half-applied identity is harder to notice than none at all. Only `id`, `name` and
`email` are read; any other property is dropped.

This does **not** touch `usr.anonymous_id`. The two identifiers coexist by design: the anonymous id
is device-scoped and stable across logins, and unique users are counted off it first. Before the
first `setUser`, events carry `usr.anonymous_id` and no `usr.id` at all — the SDK never backfills
one with the other.

The name matches `flashcatRum.setUser()` in `@flashcatcloud/browser-rum`, so both processes of the
same application use one vocabulary. When the main process has an identity, it takes precedence over
one set in a renderer, and it **replaces** it rather than merging field by field — see
`docs/ARCHITECTURE.md`. When it has none, renderer identities are left exactly as they arrive.

### `getUser(): User | undefined`

The identity currently set through `setUser`, or `undefined` when nobody is logged in. Returns a
copy — mutating it changes nothing.

### `clearUser(): void`

Forget the identity, for instance on logout. Subsequent events carry no `usr.id` **at all**, rather
than an empty one: unique users are counted off `NULLIF(usr_id, '')`, where an absent field and an
empty string are different rows. `usr.anonymous_id` is unaffected — the device is still the same
device.

Events already reported keep the identity they were reported with, and events describing a moment
before the logout still resolve to the user who was logged in then.

### `startOperation(name: string, options?: FeatureOperationOptions): void`

Start a RUM Operation step. Pair every `startOperation` with exactly one `succeedOperation` or `failOperation`. Use `options.operationKey` to distinguish parallel operations sharing the same `name`.
Expand Down
26 changes: 25 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,25 @@ The device-scoped anonymous id (`src/domain/AnonymousId.ts`) is generated once a

**`usr.id` is never backfilled with it.** Electron is counted off `COALESCE(NULLIF(usr_anonymous_id, ''), NULLIF(usr_id, ''))`, which reads the anonymous id first and is stable across a login. The browser SDK does backfill, because its count is `COUNT(DISTINCT usr_id)` and that is the only way it can see logged-out users; copying that here would buy nothing and would split one device into two people at login, when `usr.id` flips from the anonymous id to the real one. `src/assembly/commonContext.spec.ts` pins this down.

Renderer events are left alone: the renderer reads the same id off the bridge itself, so the main process must not stamp a second one over it.
Renderer events keep the `anonymous_id` they arrive with: the renderer reads the same id off the bridge itself, so the main process must not stamp a second one over it.

#### The logged-in identity (`setUser`)

`setUser` / `getUser` / `clearUser` (`src/domain/UserContext.ts`) record who the application says is logged in, next to — never instead of — the anonymous id. The names match `flashcatRum.setUser()` in `@flashcatcloud/browser-rum` so both processes of one application share a vocabulary; upstream's `@datadog/electron-sdk` calls it `setUserInfo`, a name Datadog's own browser SDK has since dropped, and we do not track that fork.

Only `id`, `name` and `email` are copied out of the caller's object, and `id` is required. Dropping everything else 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 `ContextManager.filterReservedKeys` excludes only the standard fields from its free-form `extraInfo` bag, so `addUserExtraInfo({ anonymous_id })` there overwrites the device id — **if `extraInfo` is ever added here, `anonymous_id` has to be reserved with it.**

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.

Persistence has a consequence worth knowing: the identity is written to `userData` in plain text, beside the anonymous id and the session file.

**View events are the exception: they resolve at the moment they are emitted.** A view is an interval, re-reported as it grows (`_dd.document_version`), and the backend derives the session's identity from the _last_ view row it receives (`buildSessionViewUpdates` in fc-rum reads `lastView.UserID`). The main process emits exactly one synthetic view per session, spanning the whole session, and `setUser` cannot run before `init` — so resolving that view at its start time would keep the identity off `t_sessions.usr_id` for the entire session. Verified against dev: without this the session row carried an empty `usr_id` while the error events beside it carried the full identity.

**A restored history is closed at startup, one millisecond before now.** Quitting is not logging out, so a previous run normally leaves its entry open; restoring it still-active attributed this process's events to whoever used the machine last. Closing it at exactly `now` is not enough, because `find` treats `endTime` as inclusive and the first main-process view is created in the same millisecond as `init` — observed against dev, where a second run stamped its opening view with the first run's user. Earlier timestamps still resolve to that user, which is what a crash from the previous run needs.

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

### Renderer identifiers

Expand All @@ -219,6 +237,12 @@ The renderer needs the main process's session id to attribute anything it upload
- `getAnonymousId()` — the id above. It never changes once the SDK is initialized.
- `getSessionId()` — the session the main process considers active, or `''` while none is. Sessions expire and renew, so the main process **pushes** a fresh configuration over `datadog:bridge-config-push` on every change; the preload caches it and answers from the cache. A synchronous IPC call per event would be far too slow.

- `getUser()` — the identity set through `setUser`, as JSON, or `'{}'` when nobody is logged in. A string rather than an object, matching `getCapabilities` and `getAllowedWebViewHosts`; it rides the same configuration push and the same preload cache as the session id.

**The bridge getter is not how bridged renderer events get their identity.** The Browser SDK's bridge contract has no user getter, so nothing would read it today; `Assembly.assembleRendererRumEvent` stamps the identity on renderer events as they pass through the main process instead, which needs no Browser SDK change. The getter is there so a renderer can attribute what it uploads _itself_ — Session Replay segments — to the same person, and is what a future Browser SDK would consume.

**The main process wins, and it replaces rather than merges.** `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 main-process precedence is `clearUser()`: if a stale renderer-side identity could survive it, logging out 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` is carried across the replacement untouched, and when no user is set nothing is touched at all — an application that only calls `flashcatRum.setUser()` in its renderers keeps the behaviour it had.

#### The renderer must never be able to outrun `init()`

The preload asks for its configuration over a **synchronous** channel, and Electron leaves a synchronous request that no `ipcMain` listener answers blocked forever — registering one afterwards does not release it. A renderer that starts before `BridgeHandler` exists would therefore hang before running a line of the page: a monitoring SDK bricking the application it monitors. `init()` is `async` and can be skipped entirely (it returns `false` on a configuration it rejects), so "the application initializes first" cannot be the thing that prevents this.
Expand Down
14 changes: 14 additions & 0 deletions e2e/app/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ import {
_generateTelemetryError,
_flushTransport,
stopSession,
setUser,
getUser,
clearUser,
startOperation,
succeedOperation,
failOperation,
type FailureReason,
type User,
type FeatureOperationOptions,
type InitConfiguration,
} from '@flashcatcloud/electron-sdk';
Expand Down Expand Up @@ -94,6 +98,16 @@ void app.whenReady().then(async () => {
stopSession();
});

ipcMain.handle('setUser', (_event, user: User) => {
setUser(user);
});

ipcMain.handle('getUser', () => getUser());

ipcMain.handle('clearUser', () => {
clearUser();
});

ipcMain.handle('generateUncaughtException', () => {
setTimeout(() => {
throw new Error('test uncaught exception');
Expand Down
3 changes: 3 additions & 0 deletions e2e/app/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ contextBridge.exposeInMainWorld('e2eConfig', { rumBrowserSdk: rumBrowserSdkConfi
contextBridge.exposeInMainWorld('electronAPI', {
generateTelemetryErrors: (count: number) => ipcRenderer.invoke('generateTelemetryErrors', count),
stopSession: () => ipcRenderer.invoke('stopSession'),
setUser: (user: Record<string, unknown>) => ipcRenderer.invoke('setUser', user),
getUser: () => ipcRenderer.invoke('getUser'),
clearUser: () => ipcRenderer.invoke('clearUser'),
generateUncaughtException: () => ipcRenderer.invoke('generateUncaughtException'),
generateUnhandledRejection: () => ipcRenderer.invoke('generateUnhandledRejection'),
generateManualError: (startTime?: number) => ipcRenderer.invoke('generateManualError', startTime),
Expand Down
6 changes: 6 additions & 0 deletions e2e/lib/bridgeWindowPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ interface BridgeWindow {
getCapabilities: () => string;
getSessionId: () => string;
getAnonymousId: () => string;
getUser: () => string;
};
}

Expand Down Expand Up @@ -46,6 +47,11 @@ export class BridgeWindowPage {
return await this.page.evaluate(() => (globalThis as unknown as BridgeWindow).DatadogEventBridge.getAnonymousId());
}

/** The identity the main process set, as the bridge hands it over: JSON, `'{}'` when none. */
async getUser(): Promise<string> {
return await this.page.evaluate(() => (globalThis as unknown as BridgeWindow).DatadogEventBridge.getUser());
}

async getCapabilities(): Promise<string> {
return await this.page.evaluate(() => (globalThis as unknown as BridgeWindow).DatadogEventBridge.getCapabilities());
}
Expand Down
17 changes: 16 additions & 1 deletion e2e/lib/mainPage.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { ElectronApplication, Page } from '@playwright/test';
import type { FailureReason, FeatureOperationOptions } from '@flashcatcloud/electron-sdk';
import type { FailureReason, FeatureOperationOptions, User } from '@flashcatcloud/electron-sdk';
import { BridgeWindowPage } from './bridgeWindowPage';

// declare exposed IPC methods called directly in tests
interface ElectronAppWindow {
electronAPI: {
generateTelemetryErrors: (count: number) => Promise<void>;
generateManualError: (startTime?: number) => Promise<void>;
setUser: (user: User) => Promise<void>;
getUser: () => Promise<User | undefined>;
clearUser: () => Promise<void>;
startOperation: (name: string, options?: FeatureOperationOptions) => Promise<void>;
succeedOperation: (name: string, options?: FeatureOperationOptions) => Promise<void>;
failOperation: (name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => Promise<void>;
Expand Down Expand Up @@ -74,6 +77,18 @@ export class MainPage {
);
}

async setUser(user: User) {
await this.page.evaluate((u) => (globalThis as unknown as ElectronAppWindow).electronAPI.setUser(u), user);
}

async getUser(): Promise<User | undefined> {
return this.page.evaluate(() => (globalThis as unknown as ElectronAppWindow).electronAPI.getUser());
}

async clearUser() {
await this.page.evaluate(() => (globalThis as unknown as ElectronAppWindow).electronAPI.clearUser());
}

async startOperation(name: string, options?: FeatureOperationOptions) {
await this.page.evaluate(
({ name, options }) => (globalThis as unknown as ElectronAppWindow).electronAPI.startOperation(name, options),
Expand Down
Loading
Loading