Skip to content
Open
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
61 changes: 61 additions & 0 deletions apps/ui/src/analytics/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface FakePostHog {
capture: (...args: unknown[]) => void;
identify: (...args: unknown[]) => void;
reset: (...args: unknown[]) => void;
get_session_id: (...args: unknown[]) => string;
}

function makeFake(): FakePostHog {
Expand All @@ -29,6 +30,10 @@ function makeFake(): FakePostHog {
capture: record("capture"),
identify: record("identify"),
reset: record("reset"),
get_session_id: () => {
calls.push({ method: "get_session_id", args: [] });
return "";
},
};
}

Expand All @@ -46,6 +51,38 @@ describe("createAnalytics", () => {
expect(fake.calls).toHaveLength(0);
});

test("disabled sessionId returns undefined with ZERO posthog calls", () => {
const fake = makeFake();
const analytics = createAnalytics(undefined, {
posthog: fake as unknown as PostHog,
});

expect(analytics.sessionId()).toBeUndefined();
expect(fake.calls).toHaveLength(0);
});

test("enabled sessionId delegates to posthog", () => {
const fake = makeFake();
fake.get_session_id = () => "session-123";
const analytics = createAnalytics(
{ key: "phc_abc", host: "https://us.i.posthog.com" },
{ posthog: fake as unknown as PostHog },
);

expect(analytics.sessionId()).toBe("session-123");
});

test("enabled sessionId maps an empty posthog id to undefined", () => {
const fake = makeFake();
fake.get_session_id = () => "";
const analytics = createAnalytics(
{ key: "phc_abc", host: "https://us.i.posthog.com" },
{ posthog: fake as unknown as PostHog },
);

expect(analytics.sessionId()).toBeUndefined();
});

test("disabled with NO deps (the production shape) is a callable no-op", () => {
// index.tsx calls createAnalytics(analyticsConfigFromEnv()) with no deps,
// so the disabled production path is createAnalytics(undefined) — the real
Expand Down Expand Up @@ -109,6 +146,30 @@ describe("createAnalytics", () => {
expect(identifies).toHaveLength(1);
expect(identifies[0]?.args).toEqual(["acct-1"]);
});

// The session id ROTATES under the app (posthog mints a new one on idle and
// at max length), so the value must be re-read per call and never memoized.
// The interceptor side is pinned for this too, but a memo added HERE would
// defeat that: the interceptor would faithfully re-read a stale cache.
test("enabled sessionId re-reads posthog on every call, never memoizing", () => {
const fake = makeFake();
const ids = ["sess-1", "sess-2"];
let call = 0;
fake.get_session_id = () => {
fake.calls.push({ method: "get_session_id", args: [] });
return ids[call++] ?? "";
};
const analytics = createAnalytics(
{ key: "phc_abc", host: "https://us.i.posthog.com" },
{ posthog: fake as unknown as PostHog },
);

expect(analytics.sessionId()).toBe("sess-1");
expect(analytics.sessionId()).toBe("sess-2");
expect(
fake.calls.filter((c) => c.method === "get_session_id"),
).toHaveLength(2);
});
});

describe("$ai_trace_id stamping", () => {
Expand Down
16 changes: 14 additions & 2 deletions apps/ui/src/analytics/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface Analytics {
capture(event: string, props?: Record<string, unknown>): void;
/** Associate subsequent events with a stable distinct id (the caller). */
identify(distinctId: string): void;
/** Return the current PostHog session id, when one exists. */
sessionId(): string | undefined;
/** Tear down the identified session (logout / app teardown). */
shutdown(): void;
}
Expand All @@ -36,6 +38,9 @@ export interface Analytics {
class NoopAnalytics implements Analytics {
capture(): void {}
identify(): void {}
sessionId(): string | undefined {
return undefined;
}
shutdown(): void {}
}

Expand All @@ -45,8 +50,10 @@ class NoopAnalytics implements Analytics {
class PostHogAnalytics implements Analytics {
private readonly client: PostHog;
/** The trace-id source, read at CAPTURE time rather than construction time:
* the transport that records trace ids is built before this client exists,
* so a value read once at construction would always be undefined.
* boot builds analytics BEFORE the transport, so this getter closes over a
* `clients` binding that is not yet initialized — reading it at construction
* would throw a ReferenceError, while reading it at capture time is long
* after boot bound it.
*
* A getter, not the sink object, on purpose — analytics reads one string and
* has no business depending on compass-client's transport types, so the
Expand Down Expand Up @@ -106,6 +113,11 @@ class PostHogAnalytics implements Analytics {
this.client.identify(distinctId);
}

sessionId(): string | undefined {
const sessionId = this.client.get_session_id();
return sessionId === "" ? undefined : sessionId;
}

shutdown(): void {
// PostHog's de-identify: reset the distinct id and start a fresh
// anonymous session. The browser SDK batch-sends on its own; there is no
Expand Down
62 changes: 38 additions & 24 deletions apps/ui/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,39 +88,53 @@ async function main(
root: HTMLElement,
connection: ResolvedConnection,
): Promise<void> {
const clients = createLiveClients(connection);

const callerId = await bootCaller(root, () => resolveCaller(clients.compass));
// Undefined is bootCaller's stop signal — it already painted the WhoAmI
// failure screen, so the app must not come up (no caller to scope it).
if (!callerId) {
return;
}

// Product analytics, OFF by default: analyticsConfigFromEnv returns undefined
// unless a PostHog project key is configured, and createAnalytics then hands
// back a no-op that never touches posthog — an unconfigured deployment emits
// zero analytics. Identify the caller we just learned via WhoAmI so events
// attach to a stable distinct id.
// zero analytics.
//
// The inbound half of correlation is wired here: `clients.traceId` is the slot
// the transport records each reply's server trace id into, and analytics reads
// it at capture time. Reading through a getter is what makes the ordering work
// — the clients exist before this line, but the first trace id only lands once
// a call has returned.
// Built FIRST, before the clients, because correlation now runs in both
// directions and the outbound half needs a real analytics object to read
// from. Both directions are lazy getters, and they point opposite ways:
//
// Best-effort by construction, on two counts. The slot holds the LAST reply's
// trace id, so an event fired before any call has returned carries nothing,
// and one fired between calls carries the previous call's trace rather than
// its own. And the server sets `traceresponse` only on UNARY replies, and
// only when an OTel provider is installed — an unconfigured deployment
// (empty exporter endpoint ⇒ no span ⇒ no header) stamps nothing at all.
// inbound `clients.traceId` → analytics: the transport records each
// reply's server trace id into that slot, and analytics reads it
// at capture time. `clients` is a forward reference from inside
// this getter, which is safe because the getter only runs once
// an event is captured — long after the next statement binds it.
// outbound `analytics.sessionId()` → the transport: every request asks
// for the current PostHog session id and sends it as
// X-POSTHOG-SESSION-ID, so backend spans carry the same session
// the frontend recorded.
//
// The OUTBOUND half (sending the PostHog session id to the server so its
// spans carry it) is deliberately not wired here.
// The inbound half is best-effort by construction, on two counts. The slot
// holds the LAST reply's trace id, so an event fired before any call has
// returned carries nothing, and one fired between calls carries the previous
// call's trace rather than its own. And the server sets `traceresponse` only
// on UNARY replies, and only when an OTel provider is installed — an
// unconfigured deployment (empty exporter endpoint ⇒ no span ⇒ no header)
// stamps nothing at all.
//
// The outbound half is best-effort too: the getter returns undefined until a
// PostHog session exists, and the interceptor then sends no header and
// self-heals on the next request. Only the TLS network door reads the header.
const analytics = createAnalytics(analyticsConfigFromEnv(), {
traceId: () => clients.traceId.current,
});

const clients = createLiveClients(connection, {
sessionId: () => analytics.sessionId(),
});

const callerId = await bootCaller(root, () => resolveCaller(clients.compass));
// Undefined is bootCaller's stop signal — it already painted the WhoAmI
// failure screen, so the app must not come up (no caller to scope it).
if (!callerId) {
return;
}

// Identify the caller we just learned via WhoAmI so events attach to a stable
// distinct id. This stays AFTER bootCaller: the id is its output.
analytics.identify(callerId);

// One app-lifetime QueryClient — the server-state cache the query layer keys
Expand Down
21 changes: 21 additions & 0 deletions apps/ui/src/live/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ describe("createLiveClients (query record T1)", () => {
expect(compassTransport).toBe(clients.transport);
expect(commsTransport).toBe(compassTransport);
});

test("passes the session id getter through to the transport factory", () => {
const transportSpy = spyOn(compassClient, "createCompassWebTransport");
spies.push(transportSpy);
const sessionId = () => "session-id";

createLiveClients(conn, { sessionId });

const opts = transportSpy.mock.calls[0]?.[2];
expect(opts?.sessionId).toBe(sessionId);
});

test("omits the session id option when deps are omitted", () => {
const transportSpy = spyOn(compassClient, "createCompassWebTransport");
spies.push(transportSpy);

createLiveClients(conn);

const opts = transportSpy.mock.calls[0]?.[2];
expect(opts).not.toHaveProperty("sessionId");
});
});

describe("resolveCaller (WhoAmI boot probe)", () => {
Expand Down
12 changes: 8 additions & 4 deletions apps/ui/src/live/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ export interface LiveClients {
*
* It rides on LiveClients because this is the one place that owns transport
* construction — the sink is WRITTEN by the transport layer and READ above
* it (analytics stamps it on captured events), and boot builds the clients
* before analytics exists, so a shared mutable slot handed out here is what
* connects a writer and a reader that can never meet at construction. */
* it (analytics stamps it on captured events). A shared mutable slot is what
* connects them because the trace id arrives on a REPLY: the writer has no
* value to hand over at construction time, whatever order boot runs in. */
readonly traceId: TraceIdSink;
}

Expand All @@ -54,11 +54,15 @@ export interface LiveClients {
* cache-coherent with the clients' calls. `conn.fetchImpl` threads the resolved
* transport fetch through: undefined (browser dev) uses the platform fetch; a
* shell-provided fetch tunnels over IPC — the seam is invisible above here. */
export function createLiveClients(conn: ResolvedConnection): LiveClients {
export function createLiveClients(
conn: ResolvedConnection,
deps?: { sessionId?: () => string | undefined },
): LiveClients {
const traceId: TraceIdSink = { current: undefined };
const transport = createCompassWebTransport(conn.baseUrl, conn.token, {
fetch: conn.fetchImpl,
traceSink: traceId,
...(deps?.sessionId === undefined ? {} : { sessionId: deps.sessionId }),
});
return {
comms: createCommsClient(transport),
Expand Down
Loading
Loading