diff --git a/apps/ui/src/compose-boot.test.ts b/apps/ui/src/compose-boot.test.ts new file mode 100644 index 000000000..a6f9ef827 --- /dev/null +++ b/apps/ui/src/compose-boot.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { + createCommsClient, + createCompassClient, + createRouterTransport, + type TraceIdSink, + type Transport, +} from "@compass/client"; +import type { Analytics } from "./analytics/analytics"; +import { composeBoot } from "./compose-boot"; +import type { LiveClients } from "./live/client"; +import type { ResolvedConnection } from "./live/provider"; + +// composeBoot must build analytics BEFORE the clients: the analytics `traceId` +// getter is a forward reference to `clients`, safe only because it fires at +// capture time — after the clients bind. A wrong order still typechecks and +// renders; it only fails at the network door as a missing X-POSTHOG-SESSION-ID +// header, so the order is untestable in production. These fakes record the +// observable consequences of the order and the two lazy getters. + +const connection: ResolvedConnection = { + baseUrl: "https://compass.example:8443", + token: "tok", +}; + +/** A fully-implemented Analytics whose `sessionId` returns a known value, so the + * clients' injected `sessionId` getter can be proven to resolve through to it. */ +function fakeAnalytics(sessionId: string): Analytics { + return { + capture: () => {}, + identify: () => {}, + sessionId: () => sessionId, + shutdown: () => {}, + }; +} + +/** A real (in-memory) LiveClients with a scripted `traceId` slot, so the + * analytics `traceId` getter can be proven to resolve through to the clients' + * sink. Built over a router transport — a real client, never an `as` cast. */ +function fakeClients(traceIdCurrent: string): LiveClients { + const transport: Transport = createRouterTransport(() => {}); + const traceId: TraceIdSink = { current: traceIdCurrent }; + return { + comms: createCommsClient(transport), + compass: createCompassClient(transport), + transport, + traceId, + }; +} + +describe("composeBoot order + lazy correlation", () => { + test("builds analytics before clients and wires both lazy getters", () => { + let clientsBuilt = false; + let clientsExistedWhenAnalyticsBuilt = true; + let capturedTraceId: (() => string | undefined) | undefined; + let capturedSessionId: (() => string | undefined) | undefined; + + const analytics = fakeAnalytics("session-xyz"); + const clients = fakeClients("trace-abc"); + + const built = composeBoot({ + connection, + createAnalytics: (_config, deps) => { + // Observable order: at analytics construction the clients factory + // must not have run yet. Inverting the two lines flips this true. + clientsExistedWhenAnalyticsBuilt = clientsBuilt; + capturedTraceId = deps?.traceId; + return analytics; + }, + createLiveClients: (_conn, deps) => { + clientsBuilt = true; + capturedSessionId = deps?.sessionId; + return clients; + }, + }); + + // Order: clients did not exist when analytics was constructed. + expect(clientsExistedWhenAnalyticsBuilt).toBe(false); + + // composeBoot returns exactly the two built objects. + expect(built.analytics).toBe(analytics); + expect(built.clients).toBe(clients); + + // Outbound getter: the clients received a sessionId getter that resolves + // to the analytics session once both are built. + expect(capturedSessionId?.()).toBe("session-xyz"); + + // Inbound getter: the analytics received a traceId getter that resolves + // through to the clients' trace slot — the forward reference, live. + expect(capturedTraceId?.()).toBe("trace-abc"); + }); +}); diff --git a/apps/ui/src/compose-boot.ts b/apps/ui/src/compose-boot.ts new file mode 100644 index 000000000..a1fb76578 --- /dev/null +++ b/apps/ui/src/compose-boot.ts @@ -0,0 +1,39 @@ +import { type Analytics, createAnalytics } from "./analytics/analytics"; +import { + type AnalyticsConfig, + analyticsConfigFromEnv, +} from "./analytics/config"; +import { createLiveClients, type LiveClients } from "./live/client"; +import type { ResolvedConnection } from "./live/provider"; + +// The analytics+clients construction pair, lifted out of `index.tsx main()` +// behind injectable factories so the boot ORDER is testable. Production only +// fails a wrong order at the network door (a missing X-POSTHOG-SESSION-ID +// header), so recording fakes substituted here are the sole way to pin it — and +// this module is importable without the App/mount render graph that index.tsx +// drags in. The order and the lazy forward reference are load-bearing. +export interface ComposeBootDeps { + connection: ResolvedConnection; + createAnalytics?: typeof createAnalytics; + createLiveClients?: typeof createLiveClients; + analyticsConfig?: () => AnalyticsConfig | undefined; +} + +export function composeBoot(deps: ComposeBootDeps): { + analytics: Analytics; + clients: LiveClients; +} { + const buildAnalytics = deps.createAnalytics ?? createAnalytics; + const buildClients = deps.createLiveClients ?? createLiveClients; + const analyticsConfig = deps.analyticsConfig ?? analyticsConfigFromEnv; + + // Analytics FIRST: the `traceId` getter is a forward reference to `clients`, + // safe only because it runs at capture time, long after the next line binds + const analytics = buildAnalytics(analyticsConfig(), { + traceId: () => clients.traceId.current, + }); + const clients = buildClients(deps.connection, { + sessionId: () => analytics.sessionId(), + }); + return { analytics, clients }; +} diff --git a/apps/ui/src/index.tsx b/apps/ui/src/index.tsx index 65bfab74b..8f57761b3 100644 --- a/apps/ui/src/index.tsx +++ b/apps/ui/src/index.tsx @@ -1,9 +1,8 @@ import { createRoot } from "solid-js"; -import { createAnalytics } from "./analytics/analytics"; -import { analyticsConfigFromEnv } from "./analytics/config"; import { bootCaller, renderBootError } from "./boot"; import { bootForMode } from "./boot-mode"; -import { createLiveClients, resolveCaller } from "./live/client"; +import { composeBoot } from "./compose-boot"; +import { resolveCaller } from "./live/client"; import type { ResolvedConnection } from "./live/provider"; import { mountShell, newAppQueryClient } from "./mount"; import { shellMode } from "./shell-globals"; @@ -118,13 +117,7 @@ async function main( // 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 { analytics, clients } = composeBoot({ connection }); const callerId = await bootCaller(root, () => resolveCaller(clients.compass)); // Undefined is bootCaller's stop signal — it already painted the WhoAmI