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
92 changes: 92 additions & 0 deletions apps/ui/src/compose-boot.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
39 changes: 39 additions & 0 deletions apps/ui/src/compose-boot.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
13 changes: 3 additions & 10 deletions apps/ui/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down
Loading