Skip to content

feat(replay): client-side deviceId override fixes NAT collisions properly - #318

Merged
ayushjhanwar-png merged 1 commit into
mainfrom
feat/replay-deviceid-override-via-localstorage
Jun 15, 2026
Merged

feat(replay): client-side deviceId override fixes NAT collisions properly#318
ayushjhanwar-png merged 1 commit into
mainfrom
feat/replay-deviceid-override-via-localstorage

Conversation

@ayushjhanwar-png

Copy link
Copy Markdown

Problem

Without a client-side identifier, the server derives `deviceId` and `sessionId` from `hash(salt + projectId + ip + user-agent)`. Every user behind the same NAT (office WiFi, home WiFi, mobile carrier NAT) hashes to the same id — their sessions and replay timelines get merged together.

Why our previous attempt didn't work

PR #315 (reverted in #317) had the SDK generate its own `session_id` and the server trust it. That bypassed the `sessions` table entirely — chunks landed in `session_replay_chunks` with an id that had no matching row in `sessions`, so the dashboard's "session details" page returned 404 Session not found.

This PR avoids that by keeping `session_id` server-issued and only changing the `deviceId` source.

The fix — three small changes

1. `packages/sdks/web/src/index.ts`

  • On init, read or create `_op_device_id` in localStorage and stash it as `this.deviceId`
  • Call `setGlobalProperties({ __deviceId: deviceId })` so every event carries the override
  • Server's track flow already honors `__deviceId` (line 146-155 of `track.controller.ts` — pre-existing code, not new)
  • Consumers can override with a stable identifier like a Firebase UID by calling `setGlobalProperties({ __deviceId: user.uid })` after auth resolves — better than the auto-generated UUID since it stitches the same human across browsers/devices

2. `packages/sdks/sdk/src/index.ts`

`fetchDeviceId()` now passes `this.deviceId` as a query param so the device-id lookup uses our override:

- const result = await this.api.fetch('/track/device-id', ...)
+ const qs = this.deviceId ? \`?deviceId=\${encodeURIComponent(this.deviceId)}\` : '';
+ const result = await this.api.fetch(\`/track/device-id\${qs}\`, ...)

3. `apps/api/src/controllers/track.controller.ts`

`fetchDeviceId` endpoint accepts optional `?deviceId=` query param. When supplied, skips IP+UA hash and looks up by the client-supplied id. When omitted, falls back to existing IP+UA derivation — fully backward compatible with older SDKs.

End-to-end flow

```

  1. Page loads → SDK reads localStorage._op_device_id → 'uuid-AAA'
  2. screen_view event → POST /track with __deviceId='uuid-AAA'
  3. Server uses 'uuid-AAA' as deviceId → creates session in sessions table:
    { id: 'S1', profile_id: 'uuid-AAA' }
  4. SDK polls /track/device-id?deviceId=uuid-AAA
    → server looks up session → returns sessionId='S1'
  5. Replay chunks sent with session_id='S1'
  6. Stored in session_replay_chunks under 'S1' ← matches sessions.id ✅

Office colleague: their browser has _op_device_id='uuid-BBB' (different)
→ different session 'S2' → no merging ✅
```

What this fixes

  • ✅ Office colleagues no longer share session/replay timelines
  • ✅ `sessions` table and `session_replay_chunks` reference the same `session_id` (both server-issued)
  • ✅ Dashboard "session details" page finds the row (no more "Session not found")
  • ✅ Track-event attribution is also corrected (was NAT-merged before; now per-browser / per-user)

What this doesn't change

  • Track ingestion HTTP path, controller logic, buffer flow — all UNCHANGED
  • Old SDKs without `__deviceId` still hit the IP+UA fallback — no regression
  • Replay handler (the part fix(replay): generate session_id client-side to avoid NAT collisions #315 broke and was reverted) — UNCHANGED
  • Historical events stay keyed by their old IP+UA hash; new events post-upgrade get the corrected key (expected migration behavior)

Backward compatibility

Scenario Behavior
Old SDK in production (1.1.2 or earlier) server falls back to IP+UA — same as today
New SDK 1.3.0+ sends `__deviceId` from localStorage → server uses it
Consumer overrides via `setGlobalProperties({ __deviceId: stableId })` server uses the stable id (best case)
Browser blocks localStorage SDK skips the override → server falls back to IP+UA

Deploy safety

The server change is purely additive — it adds support for `?deviceId=` query param, with the existing IP+UA derivation as the fallback. There's no path that breaks current behavior for any SDK version.

Bump

`@openpanel/web` 1.2.0-local → 1.3.0-local. Publishing as `@dashverse/openpanel-web@1.3.0` to GitHub Packages.

Frameo integration (separate PR)

After this lands and `@dashverse/openpanel-web@1.3.0` is published, frameo-pro adds:

```ts
// main.tsx
auth.onAuthStateChanged(user => {
if (user?.uid) {
op.setGlobalProperties({ __deviceId: user.uid });
op.identify({ profileId: user.uid });
}
});
```

Anonymous Frameo users get the SDK's localStorage UUID. Logged-in users get their stable Firebase UID — which is even better because it cross-stitches browsers/devices.

Test plan

  • Typecheck @openpanel/web and @openpanel/sdk — clean
  • Typecheck @openpanel/api — 18 pre-existing errors, no new
  • Test on dev cluster with local frameo-pro:
    • Verify `_op_device_id` UUID in localStorage
    • Verify `__deviceId` is in track event payloads
    • Verify `sessions.id` in CH matches `session_replay_chunks.session_id`
    • Verify dashboard "session details" page finds the session
    • Open 2 browsers on same WiFi → different deviceId UUIDs → different sessions

…erly

Without this, the server falls back to `hash(salt + projectId + ip + ua)`
to derive deviceId/sessionId. Every user behind the same NAT (office WiFi,
home WiFi, mobile carrier NAT) hashes to the same id — their sessions and
replay timelines get merged together.

Previous attempt (PR #315, reverted in #317) had the SDK generate its own
session_id and the server trust it. That bypassed the `sessions` table
entirely — chunks landed in `session_replay_chunks` with an id that had
no matching row in `sessions`, so the dashboard's "session details" page
returned 404. This PR avoids that by keeping session_id server-issued and
only changing the deviceId source.

Three small changes:

1. packages/sdks/web/src/index.ts
   - On init, read or create `_op_device_id` in localStorage and stash
     it on the base SDK as `this.deviceId`.
   - Call `setGlobalProperties({ __deviceId })` so every event carries
     the override. The server's track flow already honors this (line
     146-155 — pre-existing code path).
   - Consumers can override with a stable identifier (e.g. Firebase UID)
     by calling `setGlobalProperties({ __deviceId: user.uid })` after
     auth resolves. That's strictly better than the auto-generated UUID
     because it stitches the same human across browsers / devices.
   - Falls back to legacy IP+UA path if storage is unavailable
     (sandboxed iframe, quota exhausted, private mode quirks).

2. packages/sdks/sdk/src/index.ts
   - `fetchDeviceId()` now passes `this.deviceId` as `?deviceId=` query
     param so the server-side device-id lookup uses our override instead
     of recomputing from IP+UA. Returns the matching session id so replay
     chunks reference the correct row in `sessions`.

3. apps/api/src/controllers/track.controller.ts
   - `fetchDeviceId` endpoint accepts the optional `?deviceId=` query
     param. When supplied, skips the IP+UA hash and looks up the session
     directly by the client-supplied id. When omitted (old SDKs), keeps
     the existing IP+UA derivation — fully backward compatible.
   - Track ingest flow itself is UNCHANGED; the `__deviceId` override
     mechanism it uses (line 146-155) has been there all along.

What this fixes:
- Office colleagues no longer share session/replay timelines (each
  browser has its own localStorage UUID, or its own Firebase UID).
- Sessions table and session_replay_chunks now share the same
  session_id because both are server-issued (looked up via the same
  deviceId). Dashboard "session details" page will find the row this
  time.
- Track-event attribution is also corrected (was NAT-merged before;
  now per-browser / per-user).

What it doesn't change:
- Track ingestion HTTP path, controller logic, buffer flow — all
  unchanged.
- Old SDKs without `__deviceId` still hit the IP+UA fallback — no
  regression for clients that haven't upgraded.
- Historical events stay keyed by their old IP+UA hash. New events
  post-upgrade get the corrected key (expected migration behavior).

Bumps @openpanel/web 1.2.0-local -> 1.3.0-local for downstream publish.
@ayushjhanwar-png
ayushjhanwar-png merged commit 34cdf10 into main Jun 15, 2026
4 checks passed
ayushjhanwar-png added a commit that referenced this pull request Jun 15, 2026
Companion to #318. The replay handler still derived a session by
recomputing deviceId from IP+UA, which overrode the SDK-provided
session_id (and re-introduced NAT-collision behavior on the replay
storage path even when the SDK was using the deviceId override
correctly on the track path).

With #318 shipped, the SDK's session_id is no longer self-generated:
it comes from /track/device-id?deviceId=<our localStorage UUID or
stable user id>, which is the same id the track flow uses to create
sessions. So the SDK-supplied session_id is now a real row in the
`sessions` table and trusting it is safe — which fixes the
"Session not found" dashboard error from the earlier attempt
(#315 / #317).

Behavior:
- payload.session_id present (modern SDKs): trust it. Chunks land
  under the same session row that the track flow created. Dashboard
  "session details" works.
- payload.session_id missing (legacy SDKs): fall back to the IP+UA
  derivation that's been there all along. No regression.
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