From 8d124a451704420844229925b8c066847a6489a7 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 1 Jul 2026 17:27:54 -0700 Subject: [PATCH 1/4] feat(analytics): spans telemetry surface + event parent_span_ids Add a generic Spans telemetry surface alongside events, written directly to ClickHouse (spans are siblings of events, not routed through ext-db-sync). - analytics_internal.spans table (ReplacingMergeTree, versioned) + default.spans view, isolated by a row policy and granted to limited_user exactly like default.events. - Span types: $session-replay and $session-replay-segment (per browser tab), written directly from the session-replays batch route and re-written per batch so their end advances; $refresh-token, derived in the view from the synced refresh_tokens dimension. - events.parent_span_ids: additive column holding the root-first ancestor span-id list ([rti, sri, srsi]), stamped on each event insert. Typed-id prefixes live only on span ids / parent_span_ids; scalar id columns stay raw so existing customer SQL keeps working (additive, backwards compatible). - SDK: EventTracker and SessionRecorder now share one per-tab session_replay_segment_id (each previously minted its own, so events could not be correlated to a replay recording). - Dashboard: Spans tab on the analytics tables page. - Tests: spans unit tests + e2e for session replays, analytics events batch, and the analytics query surface. --- apps/backend/scripts/clickhouse-migrations.ts | 92 ++++++++++ .../latest/analytics/events/batch/route.tsx | 11 ++ .../latest/session-replays/batch/route.tsx | 33 +++- apps/backend/src/lib/events.tsx | 6 + apps/backend/src/lib/spans.test.ts | 104 ++++++++++++ apps/backend/src/lib/spans.tsx | 160 ++++++++++++++++++ .../analytics/tables/page-client.tsx | 9 + .../api/v1/analytics-events-batch.test.ts | 46 +++++ .../endpoints/api/v1/analytics-query.test.ts | 7 + .../endpoints/api/v1/session-replays.test.ts | 60 +++++++ .../apps/implementations/client-app-impl.ts | 7 + .../apps/implementations/event-tracker.ts | 6 +- .../apps/implementations/session-replay.ts | 6 +- 13 files changed, 544 insertions(+), 3 deletions(-) create mode 100644 apps/backend/src/lib/spans.test.ts create mode 100644 apps/backend/src/lib/spans.tsx diff --git a/apps/backend/scripts/clickhouse-migrations.ts b/apps/backend/scripts/clickhouse-migrations.ts index 7791abee7..4d41f6d78 100644 --- a/apps/backend/scripts/clickhouse-migrations.ts +++ b/apps/backend/scripts/clickhouse-migrations.ts @@ -33,12 +33,14 @@ export async function runClickhouseMigrations() { client.command({ query: REFRESH_TOKENS_TABLE_BASE_SQL }), client.command({ query: CONNECTED_ACCOUNTS_TABLE_BASE_SQL }), client.command({ query: CLICKMAP_EVENTS_TABLE_SQL }), + client.command({ query: SPANS_TABLE_BASE_SQL }), ]); await client.command({ query: CLICKMAP_EVENTS_ADD_DEAD_COLUMN_SQL }); // Alter events table (must come before views that reference new columns) await client.command({ query: EVENTS_ADD_REPLAY_COLUMNS_SQL }); + await client.command({ query: EVENTS_ADD_SPAN_COLUMNS_SQL }); // Clickmap materialized view depends on the events table existing; create after the ALTER above // so the view sees the replay columns. IF NOT EXISTS makes this idempotent across reboots. @@ -59,6 +61,7 @@ export async function runClickhouseMigrations() { client.command({ query: NOTIFICATION_PREFERENCES_VIEW_SQL }), client.command({ query: REFRESH_TOKENS_VIEW_SQL }), client.command({ query: CONNECTED_ACCOUNTS_VIEW_SQL }), + client.command({ query: SPANS_VIEW_SQL }), ]); // Data migrations (mutations) @@ -73,6 +76,7 @@ export async function runClickhouseMigrations() { "events", "users", "contact_channels", "teams", "team_member_profiles", "team_permissions", "team_invitations", "email_outboxes", "project_permissions", "notification_preferences", "refresh_tokens", "connected_accounts", + "spans", ]; await Promise.all(tables.map(table => client.command({ @@ -248,6 +252,94 @@ WHERE event_type = '$token-refresh' AND data.refresh_token_id::Nullable(String) IS NOT NULL; `; +// Span-graph column on events (additive, backwards compatible — default.events is +// SELECT * so it surfaces automatically). parent_span_ids holds the deduped, root-first +// list of ancestor span ids as PREFIXED ids (e.g. [rti-, sri-, srsi-]), +// NOT raw uuids. The per-tab id is already carried in the legacy `session_replay_segment_id` +// column, so no separate tab-id column is added. +const EVENTS_ADD_SPAN_COLUMNS_SQL = ` +ALTER TABLE analytics_internal.events + ADD COLUMN IF NOT EXISTS parent_span_ids Array(String) DEFAULT []; +`; + +// Spans: telemetry siblings of events, written DIRECTLY to ClickHouse (never through +// ext-db-sync). Interval facts whose end advances over time, so ReplacingMergeTree(version) +// keeps the highest-version row per id (writers re-insert the full row with a bumped +// version + latest span_ended_at). `default.spans` reads FINAL, which collapses versions +// correctly even in the rare case a long-lived span straddles two monthly partitions. +// Scalar identity columns stay raw (String/UUID-as-String); prefixes live only on `id` +// and `parent_span_ids`. Session-replay/segment spans are written from the replay batch route; +// $refresh-token spans are derived in the view from the synced refresh_tokens dimension. +const SPANS_TABLE_BASE_SQL = ` +CREATE TABLE IF NOT EXISTS analytics_internal.spans ( + id String, + span_type LowCardinality(String), + span_started_at DateTime64(3, 'UTC'), + span_ended_at Nullable(DateTime64(3, 'UTC')), + parent_span_ids Array(String) DEFAULT [], + data String DEFAULT '{}', + project_id String, + branch_id String, + user_id Nullable(String), + team_id Nullable(String), + refresh_token_id Nullable(String), + session_replay_id Nullable(String), + session_replay_segment_id Nullable(String), + created_at DateTime64(3, 'UTC') DEFAULT now64(3), + version UInt64 +) +ENGINE ReplacingMergeTree(version) +PARTITION BY toYYYYMM(created_at) +ORDER BY (project_id, branch_id, id); +`; + +// Customer-facing spans surface. UNION ALL of: (1) the physical spans table +// ($session-replay + $session-replay-segment + any future custom spans), and (2) the +// $refresh-token spans derived from the already-synced refresh_tokens dimension (no +// separate write). Every branch casts to one identical column set. SECURITY DEFINER + +// the default.spans row policy give the same per-project isolation as default.events. +const SPANS_VIEW_SQL = ` +CREATE OR REPLACE VIEW default.spans +SQL SECURITY DEFINER +AS +SELECT + id, + span_type, + span_started_at, + span_ended_at, + parent_span_ids, + data, + project_id, + branch_id, + user_id, + team_id, + refresh_token_id, + session_replay_id, + session_replay_segment_id, + created_at +FROM analytics_internal.spans FINAL + +UNION ALL + +SELECT + concat('rti-', toString(id)) AS id, + CAST('$refresh-token', 'LowCardinality(String)') AS span_type, + created_at AS span_started_at, + expires_at AS span_ended_at, + CAST([], 'Array(String)') AS parent_span_ids, + CAST('{}', 'String') AS data, + project_id, + branch_id, + CAST(toString(user_id), 'Nullable(String)') AS user_id, + CAST(NULL, 'Nullable(String)') AS team_id, + CAST(toString(id), 'Nullable(String)') AS refresh_token_id, + CAST(NULL, 'Nullable(String)') AS session_replay_id, + CAST(NULL, 'Nullable(String)') AS session_replay_segment_id, + sync_created_at AS created_at +FROM analytics_internal.refresh_tokens FINAL +WHERE sync_is_deleted = 0; +`; + const CONTACT_CHANNELS_TABLE_BASE_SQL = ` CREATE TABLE IF NOT EXISTS analytics_internal.contact_channels ( project_id String, diff --git a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx index 4c02281b1..a9e063003 100644 --- a/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx +++ b/apps/backend/src/app/api/latest/analytics/events/batch/route.tsx @@ -1,6 +1,7 @@ import { getClickhouseAdminClient } from "@/lib/clickhouse"; import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements"; import { findRecentSessionReplay } from "@/lib/session-replays"; +import { buildEventSpanFields } from "@/lib/spans"; import { getHexclaveServerApp } from "@/hexclave"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -134,6 +135,15 @@ export const POST = createSmartRouteHandler({ const clickhouseClient = getClickhouseAdminClient(); + // Point each event at its ancestor spans (root-first: refresh-token, replay, + // then the per-tab span when a replay exists). The per-tab id itself already + // lives in the session_replay_segment_id column — see lib/spans.tsx. + const eventSpanFields = buildEventSpanFields({ + sessionReplayId: recentSession?.id ?? null, + sessionReplaySegmentId: body.session_replay_segment_id, + refreshTokenId, + }); + const rows = body.events.map((event) => ({ event_type: event.event_type, event_at: new Date(event.event_at_ms), @@ -145,6 +155,7 @@ export const POST = createSmartRouteHandler({ refresh_token_id: refreshTokenId, session_replay_id: recentSession?.id ?? null, session_replay_segment_id: body.session_replay_segment_id, + ...eventSpanFields, })); await clickhouseClient.insert({ diff --git a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx index 2c5955384..dca7fc390 100644 --- a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx @@ -2,13 +2,15 @@ import { getPrismaClientForTenancy } from "@/prisma-client"; import { uploadBytes } from "@/s3"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { Prisma } from "@/generated/prisma/client"; +import { getClickhouseAdminClient } from "@/lib/clickhouse"; import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements"; import { findRecentSessionReplay } from "@/lib/session-replays"; +import { insertSessionReplaySpans } from "@/lib/spans"; import { getHexclaveServerApp } from "@/hexclave"; import { KnownErrors } from "@hexclave/shared"; import { ITEM_IDS } from "@hexclave/shared/dist/plans"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; -import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import { captureError, StatusError } from "@hexclave/shared/dist/utils/errors"; import { randomUUID } from "node:crypto"; import { promisify } from "node:util"; import { gzip as gzipCb, gunzipSync } from "node:zlib"; @@ -248,6 +250,35 @@ export const POST = createSmartRouteHandler({ data: { shouldUpdateSequenceId: true }, }); + // Additionally mirror this replay into the spans telemetry surface, written + // directly to ClickHouse (the same way events are). Purely additive to the + // Postgres/S3 replay storage above — a $session-replay span for the whole + // replay and a $session-replay-segment span for this recording segment (per + // tab), re-written on every non-deduped batch so their end advances. Never fail + // the upload if ClickHouse is unavailable. + try { + const segmentBounds = await prisma.sessionReplayChunk.aggregate({ + where: { tenancyId, sessionReplayId: replayId, sessionReplaySegmentId }, + _min: { firstEventAt: true }, + _max: { lastEventAt: true }, + }); + await insertSessionReplaySpans(getClickhouseAdminClient(), { + projectId, + branchId, + replayId, + sessionReplaySegmentId, + projectUserId, + refreshTokenId, + replayStartedAt: new Date(newStartedAtMs), + replayLastEventAt: new Date(newLastEventAtMs), + segmentStartedAt: segmentBounds._min.firstEventAt ?? new Date(firstMs), + segmentLastEventAt: segmentBounds._max.lastEventAt ?? new Date(lastMs), + version: Date.now(), + }); + } catch (error) { + captureError("session-replay-spans-insert", error); + } + return { statusCode: 200, bodyType: "json", diff --git a/apps/backend/src/lib/events.tsx b/apps/backend/src/lib/events.tsx index 979bf9698..55ae96701 100644 --- a/apps/backend/src/lib/events.tsx +++ b/apps/backend/src/lib/events.tsx @@ -14,6 +14,7 @@ import { generateUuid } from "@hexclave/shared/dist/utils/uuids"; import * as yup from "yup"; import { getClickhouseAdminClient } from "./clickhouse"; import { getEndUserInfo } from "./end-users"; +import { buildEventSpanFields } from "./spans"; import { DEFAULT_BRANCH_ID } from "./tenancies"; export const endUserIpInfoSchema = yupObject({ @@ -390,6 +391,11 @@ export async function logEvent( refresh_token_id: resolvedRefreshTokenId ?? null, session_replay_id: options.sessionReplayId ?? null, session_replay_segment_id: options.sessionReplaySegmentId ?? null, + ...buildEventSpanFields({ + sessionReplayId: options.sessionReplayId ?? null, + sessionReplaySegmentId: options.sessionReplaySegmentId ?? null, + refreshTokenId: resolvedRefreshTokenId ?? null, + }), }], format: "JSONEachRow", clickhouse_settings: { diff --git a/apps/backend/src/lib/spans.test.ts b/apps/backend/src/lib/spans.test.ts new file mode 100644 index 000000000..45804fe57 --- /dev/null +++ b/apps/backend/src/lib/spans.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vitest"; +import { SPAN_ID_PREFIXES, buildEventSpanFields, insertSessionReplaySpans, toSpanId } from "./spans"; + +describe("toSpanId", () => { + it("prefixes raw ids without touching the raw value", () => { + expect(toSpanId(SPAN_ID_PREFIXES.sessionReplay, "abc")).toBe("sri-abc"); + expect(toSpanId(SPAN_ID_PREFIXES.sessionReplaySegment, "seg")).toBe("srsi-seg"); + expect(toSpanId(SPAN_ID_PREFIXES.refreshToken, "rt1")).toBe("rti-rt1"); + }); +}); + +describe("buildEventSpanFields", () => { + it("lists all known ancestors root-first: refresh-token, replay, segment", () => { + expect(buildEventSpanFields({ sessionReplayId: "s1", sessionReplaySegmentId: "seg1", refreshTokenId: "r1" })).toEqual({ + parent_span_ids: ["rti-r1", "sri-s1", "srsi-seg1"], + }); + }); + + it("omits the segment parent when there is no replay (segment spans only exist under a replay)", () => { + expect(buildEventSpanFields({ sessionReplayId: null, sessionReplaySegmentId: "seg1", refreshTokenId: "r1" })).toEqual({ + parent_span_ids: ["rti-r1"], + }); + }); + + it("includes refresh-token and replay when there is a replay but no segment", () => { + expect(buildEventSpanFields({ sessionReplayId: "s1", refreshTokenId: "r1" })).toEqual({ + parent_span_ids: ["rti-r1", "sri-s1"], + }); + }); + + it("emits an empty parent list when nothing is known", () => { + expect(buildEventSpanFields({})).toEqual({ parent_span_ids: [] }); + }); +}); + +describe("insertSessionReplaySpans", () => { + it("emits a replay span and a segment span in one insert with the right ids and parents", async () => { + const captured: any[] = []; + const client = { + insert: vi.fn(async (args: any) => { + captured.push(args); + }), + } as any; + + const replayStartedAt = new Date("2026-01-01T00:00:00.000Z"); + const replayLastEventAt = new Date("2026-01-01T00:05:00.000Z"); + const segmentStartedAt = new Date("2026-01-01T00:01:00.000Z"); + const segmentLastEventAt = new Date("2026-01-01T00:04:00.000Z"); + + await insertSessionReplaySpans(client, { + projectId: "p1", + branchId: "b1", + replayId: "replay1", + sessionReplaySegmentId: "seg1", + projectUserId: "user1", + refreshTokenId: "rt1", + replayStartedAt, + replayLastEventAt, + segmentStartedAt, + segmentLastEventAt, + version: 123, + }); + + expect(client.insert).toHaveBeenCalledTimes(1); + expect(captured[0].table).toBe("analytics_internal.spans"); + const rows = captured[0].values; + expect(rows).toHaveLength(2); + + const [replaySpan, segmentSpan] = rows; + + expect(replaySpan).toMatchObject({ + id: "sri-replay1", + span_type: "$session-replay", + parent_span_ids: ["rti-rt1"], + project_id: "p1", + branch_id: "b1", + user_id: "user1", + refresh_token_id: "rt1", + session_replay_id: "replay1", + session_replay_segment_id: null, + version: 123, + }); + expect(replaySpan.span_started_at).toBe(replayStartedAt); + expect(replaySpan.span_ended_at).toBe(replayLastEventAt); + + expect(segmentSpan).toMatchObject({ + id: "srsi-seg1", + span_type: "$session-replay-segment", + parent_span_ids: ["rti-rt1", "sri-replay1"], + session_replay_id: "replay1", + session_replay_segment_id: "seg1", + version: 123, + }); + expect(segmentSpan.span_started_at).toBe(segmentStartedAt); + expect(segmentSpan.span_ended_at).toBe(segmentLastEventAt); + }); + + it("does not call insert when given an empty row list (insertSpans guard)", async () => { + const client = { insert: vi.fn() } as any; + const { insertSpans } = await import("./spans"); + await insertSpans(client, []); + expect(client.insert).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/lib/spans.tsx b/apps/backend/src/lib/spans.tsx new file mode 100644 index 000000000..5692bf875 --- /dev/null +++ b/apps/backend/src/lib/spans.tsx @@ -0,0 +1,160 @@ +import type { ClickHouseClient } from "./clickhouse"; + +/** + * Spans are telemetry facts (time intervals) — the sibling of events. They are + * written DIRECTLY to ClickHouse (`analytics_internal.spans`), the same way + * events are, and never go through the ext-db-sync (that is a dimension + * replicator, not a telemetry pipe). See the plan/notes in + * `apps/backend/scripts/clickhouse-migrations.ts` for the table + `default.spans` view. + */ + +// Typed-id prefixes. Applied ONLY to a span's own `id` and to the values inside +// `parent_span_ids` — so a heterogeneous parent array is self-describing. They are +// NEVER applied to scalar identity columns (project_id, user_id, session_replay_id, …), +// which stay raw so existing customer SQL keeps working. +export const SPAN_ID_PREFIXES = { + sessionReplay: "sri-", + // The per-tab id. The SDK mints exactly one `session_replay_segment_id` per + // browser tab, so despite the "segment" name it IS the per-tab id — there is + // only this one level (there is no separate "tab"). Named "segment" to stay + // consistent with the pre-existing `session_replay_segment_id` column + SDK field. + sessionReplaySegment: "srsi-", + refreshToken: "rti-", + user: "ui-", + team: "ti-", + project: "pi-", + branch: "bi-", +} as const; + +export const SPAN_TYPES = { + sessionReplay: "$session-replay", + sessionReplaySegment: "$session-replay-segment", + refreshToken: "$refresh-token", +} as const; + +export function toSpanId(prefix: string, rawId: string): string { + return `${prefix}${rawId}`; +} + +/** + * The `parent_span_ids` an event insert stamps on each row — the full, deduped + * list of ancestor spans the server can name, root-first: refresh-token, then the + * session-replay span (via the server-resolved `session_replay_id`), then the + * per-tab `$session-replay-segment` span (only when a replay exists, since that span + * is written under a replay). Parent links are logical ancestry, not FK-guaranteed + * rows; the list always contains a higher-level ancestor even if the segment span was + * never written (recording off). The per-tab id is carried in `session_replay_segment_id`. + */ +export function buildEventSpanFields(opts: { + sessionReplayId?: string | null, + sessionReplaySegmentId?: string | null, + refreshTokenId?: string | null, +}): { parent_span_ids: string[] } { + const parentSpanIds: string[] = []; + if (opts.refreshTokenId) { + parentSpanIds.push(toSpanId(SPAN_ID_PREFIXES.refreshToken, opts.refreshTokenId)); + } + if (opts.sessionReplayId) { + parentSpanIds.push(toSpanId(SPAN_ID_PREFIXES.sessionReplay, opts.sessionReplayId)); + } + if (opts.sessionReplayId && opts.sessionReplaySegmentId) { + parentSpanIds.push(toSpanId(SPAN_ID_PREFIXES.sessionReplaySegment, opts.sessionReplaySegmentId)); + } + return { parent_span_ids: parentSpanIds }; +} + +/** + * One row of `analytics_internal.spans`. `created_at` is omitted (the table + * defaults it to now64(3) = ingested-at). `version` is a server-monotonic value + * per span id (write-time epoch ms): the ReplacingMergeTree keeps the highest + * version, so re-writing a span with a later `span_ended_at` advances its end. + */ +export type SpanInsertRow = { + id: string, + span_type: string, + span_started_at: Date, + span_ended_at: Date | null, + parent_span_ids: string[], + data: string, + project_id: string, + branch_id: string, + user_id: string | null, + team_id: string | null, + refresh_token_id: string | null, + session_replay_id: string | null, + session_replay_segment_id: string | null, + version: number, +}; + +export async function insertSpans(client: ClickHouseClient, rows: SpanInsertRow[]): Promise { + if (rows.length === 0) return; + await client.insert({ + table: "analytics_internal.spans", + values: rows, + format: "JSONEachRow", + clickhouse_settings: { + date_time_input_format: "best_effort", + async_insert: 1, + }, + }); +} + +/** + * Emits the two spans that describe a session replay from the replay batch route: + * the replay-level `$session-replay` span and the per-tab `$session-replay-segment` + * span. Re-written on every batch (with a fresh `version` and the latest bounds) + * so their `span_ended_at` advances as recording continues. The segment span uses + * the RECORDING's `sessionReplaySegmentId` — the per-tab id — as its identity. + */ +export async function insertSessionReplaySpans( + client: ClickHouseClient, + opts: { + projectId: string, + branchId: string, + replayId: string, + sessionReplaySegmentId: string, + projectUserId: string, + refreshTokenId: string, + replayStartedAt: Date, + replayLastEventAt: Date, + segmentStartedAt: Date, + segmentLastEventAt: Date, + version: number, + }, +): Promise { + const base = { + data: "{}", + project_id: opts.projectId, + branch_id: opts.branchId, + user_id: opts.projectUserId, + team_id: null, + refresh_token_id: opts.refreshTokenId, + session_replay_id: opts.replayId, + version: opts.version, + } as const; + + const replaySpan: SpanInsertRow = { + ...base, + id: toSpanId(SPAN_ID_PREFIXES.sessionReplay, opts.replayId), + span_type: SPAN_TYPES.sessionReplay, + span_started_at: opts.replayStartedAt, + span_ended_at: opts.replayLastEventAt, + parent_span_ids: [toSpanId(SPAN_ID_PREFIXES.refreshToken, opts.refreshTokenId)], + session_replay_segment_id: null, + }; + + const segmentSpan: SpanInsertRow = { + ...base, + id: toSpanId(SPAN_ID_PREFIXES.sessionReplaySegment, opts.sessionReplaySegmentId), + span_type: SPAN_TYPES.sessionReplaySegment, + span_started_at: opts.segmentStartedAt, + span_ended_at: opts.segmentLastEventAt, + parent_span_ids: [ + toSpanId(SPAN_ID_PREFIXES.refreshToken, opts.refreshTokenId), + toSpanId(SPAN_ID_PREFIXES.sessionReplay, opts.replayId), + ], + session_replay_segment_id: opts.sessionReplaySegmentId, + }; + + await insertSpans(client, [replaySpan, segmentSpan]); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx index a360dfb28..eb3ebb334 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/tables/page-client.tsx @@ -40,6 +40,15 @@ const AVAILABLE_TABLES = new Map([ defaultOrderDir: "desc", }, ], + [ + "spans", + { + displayName: "Spans", + baseQuery: "SELECT * FROM default.spans", + defaultOrderBy: "span_started_at", + defaultOrderDir: "desc", + }, + ], [ "users", { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts index 6401ddb96..bc6c9eb88 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-events-batch.test.ts @@ -592,6 +592,52 @@ it("inserted events are queryable via analytics query endpoint", async ({ expect `); }); +it("sets parent_span_ids on inserted events", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + await Auth.Otp.signIn(); + + const sessionReplaySegmentId = randomUUID(); + const now = Date.now(); + + await uploadEventBatch({ + sessionReplaySegmentId, + batchId: randomUUID(), + sentAtMs: now, + events: [ + { + event_type: "$page-view", + event_at_ms: now - 100, + data: { url: "https://example.com/spans", path: "/spans" }, + }, + ], + }); + + let queryRes; + for (let attempt = 0; attempt < 15; attempt++) { + await wait(500); + queryRes = await niceBackendFetch("/api/v1/internal/analytics/query", { + method: "POST", + accessType: "admin", + body: { + query: "SELECT parent_span_ids, session_replay_id FROM events WHERE session_replay_segment_id = {segId:String}", + params: { segId: sessionReplaySegmentId }, + }, + }); + if (queryRes.status === 200 && queryRes.body?.result?.length === 1) { + break; + } + } + + expect(queryRes?.status).toBe(200); + const row = (queryRes?.body as any).result[0]; + // No active session replay for this user, so the only ancestor span is the + // refresh-token span. The per-tab id itself lives in session_replay_segment_id. + expect(row.session_replay_id).toBeNull(); + expect(row.parent_span_ids).toHaveLength(1); + expect(row.parent_span_ids[0]).toMatch(/^rti-/); +}); + // ============================================================================ // Analytics event limit enforcement tests // ============================================================================ diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts index 9e6679620..95efc72ed 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts @@ -595,6 +595,7 @@ it("has limited grants", async ({ expect }) => { { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.notification_preferences TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.project_permissions TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.refresh_tokens TO limited_user" }, + { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.spans TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.team_invitations TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.team_member_profiles TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.team_permissions TO limited_user" }, @@ -665,6 +666,10 @@ it("can see only some tables", async ({ expect }) => { "database": "default", "name": "refresh_tokens", }, + { + "database": "default", + "name": "spans", + }, { "database": "default", "name": "team_invitations", @@ -709,6 +714,7 @@ it("SHOW TABLES should have the correct tables", async ({ expect }) => { { "name": "notification_preferences" }, { "name": "project_permissions" }, { "name": "refresh_tokens" }, + { "name": "spans" }, { "name": "team_invitations" }, { "name": "team_member_profiles" }, { "name": "team_permissions" }, @@ -1201,6 +1207,7 @@ it("shows grants", async ({ expect }) => { { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.notification_preferences TO limited_user" }, { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.project_permissions TO limited_user" }, { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.refresh_tokens TO limited_user" }, + { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.spans TO limited_user" }, { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.team_invitations TO limited_user" }, { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.team_member_profiles TO limited_user" }, { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.team_permissions TO limited_user" }, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts index abaccc8ad..ac451ad23 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts @@ -163,6 +163,66 @@ it("stores session replay batch metadata and dedupes by (session_replay_id, batc expect(second.body?.session_replay_id).toBe(recordingId); }); +it("emits $session-replay and $session-replay-segment spans queryable via the spans surface", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + await Auth.Otp.signIn(); + + const now = Date.now(); + const sessionReplaySegmentId = randomUUID(); + + const uploadRes = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + sessionReplaySegmentId, + startedAtMs: now, + sentAtMs: now + 500, + events: [ + { timestamp: now + 100, type: 2 }, + { timestamp: now + 200, type: 3 }, + ], + }); + expect(uploadRes.status).toBe(200); + const replayId = uploadRes.body?.session_replay_id as string; + + let queryRes; + for (let attempt = 0; attempt < 15; attempt++) { + await wait(500); + queryRes = await niceBackendFetch("/api/v1/internal/analytics/query", { + method: "POST", + accessType: "admin", + body: { + query: "SELECT span_type, id, parent_span_ids, session_replay_id, session_replay_segment_id FROM spans WHERE session_replay_id = {replayId:String} ORDER BY span_type", + params: { replayId }, + }, + }); + if (queryRes.status === 200 && queryRes.body?.result?.length === 2) { + break; + } + } + + expect(queryRes?.status).toBe(200); + const rows = (queryRes?.body as any).result; + expect(rows).toHaveLength(2); + + const replaySpan = rows.find((r: any) => r.span_type === "$session-replay"); + const segmentSpan = rows.find((r: any) => r.span_type === "$session-replay-segment"); + + // Replay-level span: id is prefixed, parented to the refresh-token span, no segment. + expect(replaySpan.id).toBe(`sri-${replayId}`); + expect(replaySpan.parent_span_ids).toHaveLength(1); + expect(replaySpan.parent_span_ids[0]).toMatch(/^rti-/); + expect(replaySpan.session_replay_segment_id).toBeNull(); + + // Segment span (per tab): ancestor list is root-first [refresh-token, replay]; + // its identity is the recording's per-tab session_replay_segment_id. + expect(segmentSpan.id).toBe(`srsi-${sessionReplaySegmentId}`); + expect(segmentSpan.parent_span_ids).toHaveLength(2); + expect(segmentSpan.parent_span_ids[0]).toMatch(/^rti-/); + expect(segmentSpan.parent_span_ids[1]).toBe(`sri-${replayId}`); + expect(segmentSpan.session_replay_segment_id).toBe(sessionReplaySegmentId); +}); + it("accepts a gzipped binary body (compressed large-payload encoding)", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true } }); await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts index b7f1b5da8..d02bff2de 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts @@ -720,12 +720,18 @@ export class _HexclaveClientAppImplIncomplete { return await this._interface.sendSessionReplayBatch(body, await getAnalyticsSession(), opts); }, + sessionReplaySegmentId, }, sessionReplayOptions); this._sessionRecorder.start(); } @@ -736,6 +742,7 @@ export class _HexclaveClientAppImplIncomplete { return await this._interface.sendAnalyticsEventBatch(body, await getAnalyticsSession(), opts); }, + sessionReplaySegmentId, }); this._eventTracker.start(); } diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts index 746939f25..d0c13924e 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts @@ -101,6 +101,10 @@ function isInsideHexclaveUiNode(node: Node | null): boolean { export type EventTrackerDeps = { projectId: string, sendBatch: (body: string, options: { keepalive: boolean }) => Promise>, + // Per-tab id shared with the SessionRecorder so analytics events and replay + // chunks from the same tab carry the same session_replay_segment_id. Falls + // back to a fresh uuid when constructed standalone (e.g. in tests). + sessionReplaySegmentId?: string, }; type TrackedEvent = { @@ -136,7 +140,7 @@ export class EventTracker { constructor(deps: EventTrackerDeps) { this._deps = deps; - this._sessionReplaySegmentId = generateUuid(); + this._sessionReplaySegmentId = deps.sessionReplaySegmentId ?? generateUuid(); } start() { diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts index db3294260..71b8f9f85 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts @@ -173,6 +173,10 @@ export function getOrRotateSession(options: { key: string, legacyKey?: string, n export type SessionRecorderDeps = { projectId: string, sendBatch: (body: string, options: { keepalive: boolean }) => Promise>, + // Per-tab id shared with the EventTracker so replay chunks and analytics + // events from the same tab carry the same session_replay_segment_id. Falls + // back to a fresh uuid when constructed standalone (e.g. in tests). + sessionReplaySegmentId?: string, }; export function isAnalyticsNotEnabledError(error: unknown): boolean { @@ -220,7 +224,7 @@ export class SessionRecorder { constructor(deps: SessionRecorderDeps, replayOptions: AnalyticsReplayOptions) { this._deps = deps; this._replayOptions = replayOptions; - this._sessionReplaySegmentId = generateUuid(); + this._sessionReplaySegmentId = deps.sessionReplaySegmentId ?? generateUuid(); this._storageKey = makeStorageKey(deps.projectId); this._legacyStorageKey = makeLegacyStorageKey(deps.projectId); } From e637d724659d7da412da5a26ef2b9b0fa8754898 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 1 Jul 2026 17:33:28 -0700 Subject: [PATCH 2/4] fix(docs): update email sending API description and parameters Enhanced the description for the "/emails/send-email" endpoint to clarify the options for sending emails, including user IDs, all users, and arbitrary email addresses. Additionally, modified the parameters to remove the requirement for "user_id" and added an optional "email" field. --- docs-mintlify/openapi/admin.json | 9 +++++---- docs-mintlify/openapi/server.json | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs-mintlify/openapi/admin.json b/docs-mintlify/openapi/admin.json index 01c2681f8..2e1e890eb 100644 --- a/docs-mintlify/openapi/admin.json +++ b/docs-mintlify/openapi/admin.json @@ -2974,7 +2974,7 @@ "/emails/send-email": { "post": { "summary": "Send email", - "description": "Send an email to a list of users. The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.", + "description": "Send an email to a list of users (user_ids), all users (all_users), or arbitrary email addresses (emails). The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.", "parameters": [], "tags": [ "Emails" @@ -2995,11 +2995,12 @@ "properties": { "user_id": { "type": "string" + }, + "email": { + "type": "string" } }, - "required": [ - "user_id" - ] + "required": [] } } }, diff --git a/docs-mintlify/openapi/server.json b/docs-mintlify/openapi/server.json index 3957962cd..d612e83dd 100644 --- a/docs-mintlify/openapi/server.json +++ b/docs-mintlify/openapi/server.json @@ -2934,7 +2934,7 @@ "/emails/send-email": { "post": { "summary": "Send email", - "description": "Send an email to a list of users. The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.", + "description": "Send an email to a list of users (user_ids), all users (all_users), or arbitrary email addresses (emails). The content field should contain either {html} for HTML emails, {template_id, variables} for template-based emails, or {draft_id} for a draft email.", "parameters": [], "tags": [ "Emails" @@ -2955,11 +2955,12 @@ "properties": { "user_id": { "type": "string" + }, + "email": { + "type": "string" } }, - "required": [ - "user_id" - ] + "required": [] } } }, From ddec2a4ac4e2618825987b32a2fce28f25e02420 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 2 Jul 2026 10:59:04 -0700 Subject: [PATCH 3/4] fix(analytics): segment-derived span versions, off-path span writes, sign-out segment rotation - span version is now the span's own end time (data-tied, never regresses) - replay/segment span inserts moved off the response path (waitUntil) - session_replay_segment_id rotates on sign-out to prevent cross-user correlation - new SessionReplayChunk index narrowing per-segment bounds aggregation --- .../migration.sql | 5 ++ apps/backend/prisma/schema.prisma | 3 ++ .../latest/session-replays/batch/route.tsx | 51 ++++++++++--------- apps/backend/src/lib/spans.test.ts | 7 +-- apps/backend/src/lib/spans.tsx | 22 +++++--- .../apps/implementations/client-app-impl.ts | 7 +++ .../apps/implementations/event-tracker.ts | 13 ++++- .../apps/implementations/session-replay.ts | 13 ++++- 8 files changed, 84 insertions(+), 37 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260701000000_add_session_replay_chunk_segment_index/migration.sql diff --git a/apps/backend/prisma/migrations/20260701000000_add_session_replay_chunk_segment_index/migration.sql b/apps/backend/prisma/migrations/20260701000000_add_session_replay_chunk_segment_index/migration.sql new file mode 100644 index 000000000..ee3657cf6 --- /dev/null +++ b/apps/backend/prisma/migrations/20260701000000_add_session_replay_chunk_segment_index/migration.sql @@ -0,0 +1,5 @@ +-- SPLIT_STATEMENT_SENTINEL +-- SINGLE_STATEMENT_SENTINEL +-- RUN_OUTSIDE_TRANSACTION_SENTINEL +CREATE INDEX CONCURRENTLY IF NOT EXISTS "SessionReplayChunk_tenancyId_sessionReplayId_segment_idx" + ON /* SCHEMA_NAME_SENTINEL */."SessionReplayChunk"("tenancyId", "sessionReplayId", "sessionReplaySegmentId"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 7d7f55b62..f7ad0d25f 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -460,6 +460,9 @@ model SessionReplayChunk { @@unique([tenancyId, sessionReplayId, batchId]) @@index([tenancyId, sessionReplayId, createdAt]) + // Narrows the per-batch span-bounds aggregate (min firstEventAt / max lastEventAt) + // to a single tab's segment instead of scanning the whole replay's chunks. + @@index([tenancyId, sessionReplayId, sessionReplaySegmentId], map: "SessionReplayChunk_tenancyId_sessionReplayId_segment_idx") @@map("SessionReplayChunk") } diff --git a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx index dca7fc390..42247348f 100644 --- a/apps/backend/src/app/api/latest/session-replays/batch/route.tsx +++ b/apps/backend/src/app/api/latest/session-replays/batch/route.tsx @@ -6,6 +6,7 @@ import { getClickhouseAdminClient } from "@/lib/clickhouse"; import { arePlanLimitsEnforced, getBillingTeamId } from "@/lib/plan-entitlements"; import { findRecentSessionReplay } from "@/lib/session-replays"; import { insertSessionReplaySpans } from "@/lib/spans"; +import { runAsynchronouslyAndWaitUntil } from "@/utils/background-tasks"; import { getHexclaveServerApp } from "@/hexclave"; import { KnownErrors } from "@hexclave/shared"; import { ITEM_IDS } from "@hexclave/shared/dist/plans"; @@ -254,30 +255,32 @@ export const POST = createSmartRouteHandler({ // directly to ClickHouse (the same way events are). Purely additive to the // Postgres/S3 replay storage above — a $session-replay span for the whole // replay and a $session-replay-segment span for this recording segment (per - // tab), re-written on every non-deduped batch so their end advances. Never fail - // the upload if ClickHouse is unavailable. - try { - const segmentBounds = await prisma.sessionReplayChunk.aggregate({ - where: { tenancyId, sessionReplayId: replayId, sessionReplaySegmentId }, - _min: { firstEventAt: true }, - _max: { lastEventAt: true }, - }); - await insertSessionReplaySpans(getClickhouseAdminClient(), { - projectId, - branchId, - replayId, - sessionReplaySegmentId, - projectUserId, - refreshTokenId, - replayStartedAt: new Date(newStartedAtMs), - replayLastEventAt: new Date(newLastEventAtMs), - segmentStartedAt: segmentBounds._min.firstEventAt ?? new Date(firstMs), - segmentLastEventAt: segmentBounds._max.lastEventAt ?? new Date(lastMs), - version: Date.now(), - }); - } catch (error) { - captureError("session-replay-spans-insert", error); - } + // tab), re-written on every non-deduped batch so their end advances. Runs off + // the response path (waitUntil) so a slow/unavailable ClickHouse — whose client + // has a 10-minute request timeout — never delays or fails the replay upload. + runAsynchronouslyAndWaitUntil(async () => { + try { + const segmentBounds = await prisma.sessionReplayChunk.aggregate({ + where: { tenancyId, sessionReplayId: replayId, sessionReplaySegmentId }, + _min: { firstEventAt: true }, + _max: { lastEventAt: true }, + }); + await insertSessionReplaySpans(getClickhouseAdminClient(), { + projectId, + branchId, + replayId, + sessionReplaySegmentId, + projectUserId, + refreshTokenId, + replayStartedAt: new Date(newStartedAtMs), + replayLastEventAt: new Date(newLastEventAtMs), + segmentStartedAt: segmentBounds._min.firstEventAt ?? new Date(firstMs), + segmentLastEventAt: segmentBounds._max.lastEventAt ?? new Date(lastMs), + }); + } catch (error) { + captureError("session-replay-spans-insert", error); + } + }); return { statusCode: 200, diff --git a/apps/backend/src/lib/spans.test.ts b/apps/backend/src/lib/spans.test.ts index 45804fe57..a61fa5931 100644 --- a/apps/backend/src/lib/spans.test.ts +++ b/apps/backend/src/lib/spans.test.ts @@ -58,7 +58,6 @@ describe("insertSessionReplaySpans", () => { replayLastEventAt, segmentStartedAt, segmentLastEventAt, - version: 123, }); expect(client.insert).toHaveBeenCalledTimes(1); @@ -78,7 +77,9 @@ describe("insertSessionReplaySpans", () => { refresh_token_id: "rt1", session_replay_id: "replay1", session_replay_segment_id: null, - version: 123, + // version is the span's own end (epoch ms) so the latest-end row wins in the + // ReplacingMergeTree regardless of insert order. + version: replayLastEventAt.getTime(), }); expect(replaySpan.span_started_at).toBe(replayStartedAt); expect(replaySpan.span_ended_at).toBe(replayLastEventAt); @@ -89,7 +90,7 @@ describe("insertSessionReplaySpans", () => { parent_span_ids: ["rti-rt1", "sri-replay1"], session_replay_id: "replay1", session_replay_segment_id: "seg1", - version: 123, + version: segmentLastEventAt.getTime(), }); expect(segmentSpan.span_started_at).toBe(segmentStartedAt); expect(segmentSpan.span_ended_at).toBe(segmentLastEventAt); diff --git a/apps/backend/src/lib/spans.tsx b/apps/backend/src/lib/spans.tsx index 5692bf875..639213e5a 100644 --- a/apps/backend/src/lib/spans.tsx +++ b/apps/backend/src/lib/spans.tsx @@ -65,9 +65,12 @@ export function buildEventSpanFields(opts: { /** * One row of `analytics_internal.spans`. `created_at` is omitted (the table - * defaults it to now64(3) = ingested-at). `version` is a server-monotonic value - * per span id (write-time epoch ms): the ReplacingMergeTree keeps the highest - * version, so re-writing a span with a later `span_ended_at` advances its end. + * defaults it to now64(3) = ingested-at). `version` is the span's own end time as + * epoch ms (see `insertSessionReplaySpans`): the ReplacingMergeTree keeps the + * highest version, so the row carrying the LATEST `span_ended_at` wins regardless + * of insert order. Tying the version to the data (not wall-clock) means a stale or + * partial re-write with an earlier end can never overwrite a later one — the span + * end advances monotonically and never regresses under concurrent batches. */ export type SpanInsertRow = { id: string, @@ -102,9 +105,12 @@ export async function insertSpans(client: ClickHouseClient, rows: SpanInsertRow[ /** * Emits the two spans that describe a session replay from the replay batch route: * the replay-level `$session-replay` span and the per-tab `$session-replay-segment` - * span. Re-written on every batch (with a fresh `version` and the latest bounds) - * so their `span_ended_at` advances as recording continues. The segment span uses - * the RECORDING's `sessionReplaySegmentId` — the per-tab id — as its identity. + * span. Re-written on every batch with the latest bounds so their `span_ended_at` + * advances as recording continues. Each span's `version` is its own `span_ended_at` + * (epoch ms), so the ReplacingMergeTree keeps the row with the latest end — the end + * never regresses even if batches insert out of order or a re-write raced on a + * partial view of the chunks (which self-heals on the next batch). The segment span + * uses the RECORDING's `sessionReplaySegmentId` — the per-tab id — as its identity. */ export async function insertSessionReplaySpans( client: ClickHouseClient, @@ -119,7 +125,6 @@ export async function insertSessionReplaySpans( replayLastEventAt: Date, segmentStartedAt: Date, segmentLastEventAt: Date, - version: number, }, ): Promise { const base = { @@ -130,7 +135,6 @@ export async function insertSessionReplaySpans( team_id: null, refresh_token_id: opts.refreshTokenId, session_replay_id: opts.replayId, - version: opts.version, } as const; const replaySpan: SpanInsertRow = { @@ -141,6 +145,7 @@ export async function insertSessionReplaySpans( span_ended_at: opts.replayLastEventAt, parent_span_ids: [toSpanId(SPAN_ID_PREFIXES.refreshToken, opts.refreshTokenId)], session_replay_segment_id: null, + version: opts.replayLastEventAt.getTime(), }; const segmentSpan: SpanInsertRow = { @@ -154,6 +159,7 @@ export async function insertSessionReplaySpans( toSpanId(SPAN_ID_PREFIXES.sessionReplay, opts.replayId), ], session_replay_segment_id: opts.sessionReplaySegmentId, + version: opts.segmentLastEventAt.getTime(), }; await insertSpans(client, [replaySpan, segmentSpan]); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts index d02bff2de..dd17b41f9 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/client-app-impl.ts @@ -3951,6 +3951,13 @@ export class _HexclaveClientAppImplIncomplete { await this._interface.signOut(session); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts index d0c13924e..b811468d7 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/event-tracker.ts @@ -122,7 +122,7 @@ export class EventTracker { private _events: TrackedEvent[] = []; private _approxBytes = 0; private _lastUrl: string | null = null; - private readonly _sessionReplaySegmentId: string; + private _sessionReplaySegmentId: string; private readonly _deps: EventTrackerDeps; private _originalPushState: History["pushState"] | null = null; @@ -181,6 +181,17 @@ export class EventTracker { this._unclassifiedClicks.clear(); } + /** + * Replaces the per-tab id shared with the SessionRecorder. Called on sign-out + * (paired with clearBuffer) so a subsequent same-tab sign-in as a different user + * does not reuse the previous user's session_replay_segment_id — which would let + * the two users' analytics be correlated. The app rotates both trackers to the + * SAME new id so they stay in sync. + */ + setSessionReplaySegmentId(id: string) { + this._sessionReplaySegmentId = id; + } + private _pushEvent(event: TrackedEvent) { if (this._disabled) return; this._events.push(event); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts index 71b8f9f85..1329520e2 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts @@ -214,7 +214,7 @@ export class SessionRecorder { private _lastBrowserSessionId: string | null = null; private _takingSnapshot = false; private _flushInProgress = false; - private readonly _sessionReplaySegmentId: string; + private _sessionReplaySegmentId: string; private readonly _storageKey: string; // Hexclave rebrand: legacy key used for dual-read fallback only. private readonly _legacyStorageKey: string; @@ -261,6 +261,17 @@ export class SessionRecorder { this._approxBytes = 0; } + /** + * Replaces the per-tab id shared with the EventTracker. Called on sign-out + * (paired with clearBuffer) so a subsequent same-tab sign-in as a different user + * does not reuse the previous user's session_replay_segment_id — which would let + * the two users' replays and events be correlated. The app rotates both trackers + * to the SAME new id so they stay in sync. + */ + setSessionReplaySegmentId(id: string) { + this._sessionReplaySegmentId = id; + } + private _persistActivity(nowMs: number): StoredSession { const stored = getOrRotateSession({ key: this._storageKey, legacyKey: this._legacyStorageKey, nowMs }); if (nowMs - this._lastPersistActivity < 5_000) return stored; From 69c6ec103119ec154a3ad6d65484789c3f2afdef Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 3 Jul 2026 16:22:45 -0700 Subject: [PATCH 4/4] test(session-replay): add test for session replay segment ID retention during flush - Introduced a new test to verify that all batches from a single flush retain the original session replay segment ID when a sign-out rotation occurs. - Updated the SessionRecorder class to ensure the segment ID is preserved during asynchronous multi-batch flush operations. --- .../implementations/session-replay.test.ts | 54 +++++++++++++++++++ .../apps/implementations/session-replay.ts | 6 ++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts index cc7231051..225dc4e5f 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.test.ts @@ -162,6 +162,60 @@ describe("SessionRecorder flush", () => { } }); + it("keeps all batches from one flush on the segment id captured before sign-out rotation", async () => { + vi.useFakeTimers(); + + const storageKey = `hexclave:session-replay:v1:test-project`; + localStorage.setItem(storageKey, JSON.stringify({ + session_id: "test-session", + created_at_ms: Date.now(), + last_activity_ms: Date.now(), + })); + + const sentBodies: string[] = []; + const recorder = new SessionRecorder( + { + projectId: "test-project", + sendBatch: async (body) => { + sentBodies.push(body); + recorder.setSessionReplaySegmentId("new-segment"); + return Result.ok(new Response("ok", { status: 200 })); + }, + sessionReplaySegmentId: "old-segment", + }, + {}, + ); + + try { + // Force two batches so the second payload is built after sendBatch's + // await boundary has had a chance to rotate the recorder id. + const largeData = "x".repeat(500_000); + const event1 = { type: 2, timestamp: Date.now(), data: largeData }; + const event2 = { type: 3, timestamp: Date.now(), data: largeData }; + const sizeOf = (e: unknown) => new TextEncoder().encode(JSON.stringify(e)).byteLength; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._events = [event1, event2]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._eventSizes = [sizeOf(event1), sizeOf(event2)]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (recorder as any)._approxBytes = sizeOf(event1) + sizeOf(event2); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + await (recorder as any)._flush({ keepalive: false }); + + expect(sentBodies).toHaveLength(2); + const batch1 = JSON.parse(sentBodies[0]); + const batch2 = JSON.parse(sentBodies[1]); + expect(batch1.session_replay_segment_id).toBe("old-segment"); + expect(batch2.session_replay_segment_id).toBe("old-segment"); + } finally { + recorder.stop(); + localStorage.removeItem(storageKey); + vi.useRealTimers(); + } + }); + it("sends a single oversized event alone without dropping it", async () => { vi.useFakeTimers(); diff --git a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts index 1329520e2..02fa69742 100644 --- a/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/hexclave-app/apps/implementations/session-replay.ts @@ -302,6 +302,10 @@ export class SessionRecorder { this._events = []; this._eventSizes = []; this._approxBytes = 0; + // The setter can rotate this id while an async multi-batch flush is in + // flight (for example during sign-out). Keep every event captured above on + // the segment it belonged to when the flush started. + const sessionReplaySegmentId = this._sessionReplaySegmentId; // Non-keepalive flushes gzip before sending, so a single event up to the // server's decompressed budget can be sent alone. Keepalive flushes @@ -342,7 +346,7 @@ export class SessionRecorder { const batchId = generateUuid(); const payload = { browser_session_id: stored.session_id, - session_replay_segment_id: this._sessionReplaySegmentId, + session_replay_segment_id: sessionReplaySegmentId, batch_id: batchId, started_at_ms: stored.created_at_ms, sent_at_ms: nowMs,