authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-21 21:04:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 21:04:46-07:00
log11f051373e79b38fa16f3ec1af825f5164907c1b
treeb10d0efb3fa0d0fe2d85e16595f51161e4dada8c
parent035058a23e651ea5f407c9ab73cf5329cc40c102
signature Signed by PGP key B5690EEEBB952194

feat(analytics): threads and turns now know which client started them (#7774)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

22 files changed, 485 insertions(+), 22 deletions(-)

apps/mobile/src/connection/platform.ts+2-1
......@@ -21,6 +21,7 @@ import * as Layer from "effect/Layer";
2121import * as Option from "effect/Option";
2222import * as Queue from "effect/Queue";
2323import * as Stream from "effect/Stream";
24import Constants from "expo-constants";
2425import * as Network from "expo-network";
2526import { AppState } from "react-native";
2627
......@@ -166,7 +167,7 @@ const capabilitiesLayer = Layer.effectContext(
166167 Context.add(
167168 ClientPresentation,
168169 ClientPresentation.of({
169 metadata: authClientMetadata(),
170 metadata: authClientMetadata(Constants.expoConfig?.version),
170171 scopes: AuthStandardClientScopes,
171172 }),
172173 ),
apps/mobile/src/lib/authClientMetadata.ts+3-1
......@@ -1,10 +1,12 @@
11import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
22import { Platform } from "react-native";
33
4export function authClientMetadata(): AuthClientPresentationMetadata {
4export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata {
55 return {
66 label: "T3 Code Mobile",
77 deviceType: "mobile",
88 ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}),
9 surface: "mobile",
10 ...(appVersion ? { appVersion } : {}),
911 };
1012}
apps/mobile/src/lib/connection.test.ts+8
......@@ -26,6 +26,14 @@ describe("mobile remote connection records", () => {
2626 label: "T3 Code Mobile",
2727 deviceType: "mobile",
2828 os: "iOS",
29 surface: "mobile",
30 });
31 });
32
33 it("includes the mobile app version when the client provides it", () => {
34 expect(authClientMetadata("1.2.3")).toMatchObject({
35 surface: "mobile",
36 appVersion: "1.2.3",
2937 });
3038 });
3139
apps/server/src/auth/SessionStore.test.ts+33
......@@ -4,6 +4,7 @@ import * as Duration from "effect/Duration";
44import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
66import * as TestClock from "effect/testing/TestClock";
7import * as SqlClient from "effect/unstable/sql/SqlClient";
78
89import * as ServerConfig from "../config.ts";
910import { PersistenceSqlError } from "../persistence/Errors.ts";
......@@ -47,6 +48,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi
4748 revoke: () => Effect.fail(repositoryFailure),
4849 revokeAllExcept: () => Effect.fail(repositoryFailure),
4950 setLastConnectedAt: () => Effect.void,
51 setClientConnection: () => Effect.void,
5052});
5153
5254const failingSessionLookupCredentialLayer = Layer.effect(
......@@ -315,4 +317,35 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
315317 expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());
316318 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
317319 );
320 it.effect("records client connection metadata without clearing prior values", () =>
321 Effect.gen(function* () {
322 const sessions = yield* SessionStore.SessionStore;
323 const sql = yield* SqlClient.SqlClient;
324 const issued = yield* sessions.issue({
325 subject: "client-connection-test",
326 method: "bearer-access-token",
327 });
328 const readRow = sql<{
329 readonly surface: string | null;
330 readonly appVersion: string | null;
331 }>`
332 SELECT client_surface AS "surface", client_app_version AS "appVersion"
333 FROM auth_sessions
334 WHERE session_id = ${issued.sessionId}
335 `;
336
337 yield* sessions.recordClientConnection(issued.sessionId, {
338 surface: "mobile",
339 appVersion: "1.2.0",
340 });
341 expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.2.0" });
342
343 // A partial report (old or minimal client) must not null out stored data.
344 yield* sessions.recordClientConnection(issued.sessionId, { appVersion: "1.3.0" });
345 expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" });
346
347 yield* sessions.recordClientConnection(issued.sessionId, {});
348 expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" });
349 }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))),
350 );
318351});
apps/server/src/auth/SessionStore.ts+31
......@@ -5,6 +5,7 @@ import {
55 type AuthClientMetadata,
66 type AuthClientSession,
77 type AuthEnvironmentScope,
8 type ClientSurface,
89 type ServerAuthSessionMethod,
910} from "@t3tools/contracts";
1011import * as Context from "effect/Context";
......@@ -396,6 +397,13 @@ export class SessionStore extends Context.Service<
396397 ) => Effect.Effect<number, SessionCredentialInternalError>;
397398 readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
398399 readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
400 readonly recordClientConnection: (
401 sessionId: AuthSessionId,
402 client: {
403 readonly surface?: ClientSurface | undefined;
404 readonly appVersion?: string | undefined;
405 },
406 ) => Effect.Effect<void, never>;
399407 }
400408>()("t3/auth/SessionStore") {}
401409
......@@ -544,6 +552,28 @@ export const make = Effect.gen(function* () {
544552 Effect.withSpan("SessionStore.markConnected"),
545553 );
546554
555 // Best-effort: connection metadata must never block or fail a connect.
556 const recordClientConnection: SessionStore["Service"]["recordClientConnection"] = (
557 sessionId,
558 client,
559 ) =>
560 client.surface === undefined && client.appVersion === undefined
561 ? Effect.void
562 : authSessions
563 .setClientConnection({
564 sessionId,
565 surface: client.surface ?? null,
566 appVersion: client.appVersion ?? null,
567 })
568 .pipe(
569 Effect.catchCause((cause) =>
570 Effect.logWarning("Failed to record session client connection metadata.").pipe(
571 Effect.annotateLogs({ sessionId, cause }),
572 ),
573 ),
574 Effect.withSpan("SessionStore.recordClientConnection"),
575 );
576
547577 const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) =>
548578 Ref.update(connectedSessionsRef, (current) => {
549579 const next = new Map(current);
......@@ -912,6 +942,7 @@ export const make = Effect.gen(function* () {
912942 revokeAllExcept,
913943 markConnected,
914944 markDisconnected,
945 recordClientConnection,
915946 });
916947});
917948
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+51
......@@ -1379,4 +1379,55 @@ describe("OrchestrationEngine", () => {
13791379
13801380 await system.dispose();
13811381 });
1382
1383 it("stamps the dispatching client's origin onto persisted event metadata", async () => {
1384 const createdAt = now();
1385 const system = await createOrchestrationSystem();
1386 const { engine } = system;
1387
1388 await system.run(
1389 engine.dispatch(
1390 {
1391 type: "project.create",
1392 commandId: CommandId.make("cmd-origin-project-create"),
1393 projectId: asProjectId("project-origin"),
1394 title: "Origin Project",
1395 workspaceRoot: "/tmp/project-origin",
1396 defaultModelSelection: {
1397 instanceId: ProviderInstanceId.make("codex"),
1398 model: "gpt-5-codex",
1399 },
1400 createdAt,
1401 },
1402 { origin: { surface: "mobile", appVersion: "1.2.3" } },
1403 ),
1404 );
1405 await system.run(
1406 engine.dispatch({
1407 type: "project.create",
1408 commandId: CommandId.make("cmd-no-origin-project-create"),
1409 projectId: asProjectId("project-no-origin"),
1410 title: "No Origin Project",
1411 workspaceRoot: "/tmp/project-no-origin",
1412 defaultModelSelection: {
1413 instanceId: ProviderInstanceId.make("codex"),
1414 model: "gpt-5-codex",
1415 },
1416 createdAt,
1417 }),
1418 );
1419
1420 const events = await system.run(
1421 Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))),
1422 );
1423 const withOrigin = events.find((event) => event.commandId === "cmd-origin-project-create");
1424 const withoutOrigin = events.find(
1425 (event) => event.commandId === "cmd-no-origin-project-create",
1426 );
1427
1428 expect(withOrigin?.metadata.origin).toEqual({ surface: "mobile", appVersion: "1.2.3" });
1429 expect(withoutOrigin?.metadata.origin).toBeUndefined();
1430
1431 await system.dispose();
1432 });
13821433});
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+14-2
......@@ -1,4 +1,5 @@
11import type {
2 OrchestrationClientOrigin,
23 OrchestrationEvent,
34 OrchestrationReadModel,
45 ProjectId,
......@@ -54,6 +55,7 @@ const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvar
5455
5556interface CommandEnvelope {
5657 command: OrchestrationCommand;
58 origin: OrchestrationClientOrigin | undefined;
5759 result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>;
5860 startedAtMs: number;
5961}
......@@ -182,7 +184,16 @@ const makeOrchestrationEngine = Effect.gen(function* () {
182184 }),
183185 ),
184186 );
185 const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase];
187 const plannedEvents = Array.isArray(eventBase) ? eventBase : [eventBase];
188 // Stamp the dispatching client's origin onto every event the command
189 // produced. The decider stays pure; attribution is an engine concern.
190 const eventBases =
191 envelope.origin === undefined
192 ? plannedEvents
193 : plannedEvents.map((planned) => ({
194 ...planned,
195 metadata: { ...planned.metadata, origin: envelope.origin },
196 }));
186197 const committedCommand = yield* sql
187198 .withTransaction(
188199 Effect.gen(function* () {
......@@ -329,11 +340,12 @@ const makeOrchestrationEngine = Effect.gen(function* () {
329340 const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) =>
330341 eventStore.readFromSequence(fromSequenceExclusive, limit);
331342
332 const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>
343 const dispatch: OrchestrationEngineShape["dispatch"] = (command, options) =>
333344 Effect.gen(function* () {
334345 const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>();
335346 yield* Queue.offer(commandQueue, {
336347 command,
348 origin: options?.origin,
337349 result,
338350 startedAtMs: yield* Clock.currentTimeMillis,
339351 });
apps/server/src/orchestration/Services/OrchestrationEngine.ts+8-1
......@@ -10,7 +10,11 @@
1010 *
1111 * @module OrchestrationEngineService
1212 */
13import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts";
13import type {
14 OrchestrationClientOrigin,
15 OrchestrationCommand,
16 OrchestrationEvent,
17} from "@t3tools/contracts";
1418import * as Context from "effect/Context";
1519import type * as Effect from "effect/Effect";
1620import type * as Stream from "effect/Stream";
......@@ -41,6 +45,8 @@ export interface OrchestrationEngineShape {
4145 * Dispatch a validated orchestration command.
4246 *
4347 * @param command - Valid orchestration command.
48 * @param options - Optional client origin (surface/app version) stamped into
49 * the metadata of every event the command produces.
4450 * @returns Effect containing the sequence of the persisted event.
4551 *
4652 * Dispatch is serialized through an internal queue and deduplicated via
......@@ -48,6 +54,7 @@ export interface OrchestrationEngineShape {
4854 */
4955 readonly dispatch: (
5056 command: OrchestrationCommand,
57 options?: { readonly origin?: OrchestrationClientOrigin },
5158 ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>;
5259
5360 /**
apps/server/src/persistence/AuthSessions.ts+37
......@@ -10,6 +10,7 @@ import {
1010 AuthClientMetadataDeviceType,
1111 AuthEnvironmentScopes,
1212 AuthSessionId,
13 ClientSurface,
1314 ServerAuthSessionMethod,
1415} from "@t3tools/contracts";
1516
......@@ -82,6 +83,13 @@ export const SetAuthSessionLastConnectedAtInput = Schema.Struct({
8283});
8384export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type;
8485
86export const SetAuthSessionClientConnectionInput = Schema.Struct({
87 sessionId: AuthSessionId,
88 surface: Schema.NullOr(ClientSurface),
89 appVersion: Schema.NullOr(Schema.String),
90});
91export type SetAuthSessionClientConnectionInput = typeof SetAuthSessionClientConnectionInput.Type;
92
8593export class AuthSessionRepository extends Context.Service<
8694 AuthSessionRepository,
8795 {
......@@ -103,6 +111,9 @@ export class AuthSessionRepository extends Context.Service<
103111 readonly setLastConnectedAt: (
104112 input: SetAuthSessionLastConnectedAtInput,
105113 ) => Effect.Effect<void, AuthSessionRepositoryError>;
114 readonly setClientConnection: (
115 input: SetAuthSessionClientConnectionInput,
116 ) => Effect.Effect<void, AuthSessionRepositoryError>;
106117 }
107118>()("t3/persistence/AuthSessions/AuthSessionRepository") {}
108119
......@@ -281,6 +292,20 @@ export const make = Effect.gen(function* () {
281292 `,
282293 });
283294
295 // COALESCE keeps the previous value when a client reports only one field, so
296 // a partial report never nulls out data a fuller client stored earlier.
297 const setClientConnectionRow = SqlSchema.void({
298 Request: SetAuthSessionClientConnectionInput,
299 execute: ({ sessionId, surface, appVersion }) =>
300 sql`
301 UPDATE auth_sessions
302 SET client_surface = COALESCE(${surface}, client_surface),
303 client_app_version = COALESCE(${appVersion}, client_app_version)
304 WHERE session_id = ${sessionId}
305 AND revoked_at IS NULL
306 `,
307 });
308
284309 const revokeSessionRows = SqlSchema.findAll({
285310 Request: RevokeAuthSessionInput,
286311 Result: Schema.Struct({ sessionId: AuthSessionId }),
......@@ -404,6 +429,17 @@ export const make = Effect.gen(function* () {
404429 ),
405430 );
406431
432 const setClientConnection: AuthSessionRepository["Service"]["setClientConnection"] = (input) =>
433 setClientConnectionRow(input).pipe(
434 Effect.mapError(
435 toPersistenceSqlOrDecodeError(
436 "AuthSessionRepository.setClientConnection:query",
437 "AuthSessionRepository.setClientConnection:encodeRequest",
438 { sessionId: input.sessionId },
439 ),
440 ),
441 );
442
407443 return {
408444 create,
409445 getById,
......@@ -411,6 +447,7 @@ export const make = Effect.gen(function* () {
411447 revoke,
412448 revokeAllExcept,
413449 setLastConnectedAt,
450 setClientConnection,
414451 } satisfies AuthSessionRepository["Service"];
415452});
416453
apps/server/src/persistence/Migrations.ts+2
......@@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts";
5353import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts";
5454import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts";
5555import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts";
56import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
5657
5758/**
5859 * Migration loader with all migrations defined inline.
......@@ -105,6 +106,7 @@ export const migrationEntries = [
105106 [38, "ProjectionThreadsPinOrderKey", Migration0038],
106107 [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039],
107108 [40, "ProjectionProjectFaviconPath", Migration0040],
109 [41, "AuthSessionClientConnection", Migration0041],
108110] as const;
109111
110112export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts created+31
......@@ -0,0 +1,31 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";
8
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
10
11layer("041_AuthSessionClientConnection", (it) => {
12 it.effect("adds nullable client surface and app version columns to auth sessions", () =>
13 Effect.gen(function* () {
14 const sql = yield* SqlClient.SqlClient;
15
16 yield* runMigrations({ toMigrationInclusive: 40 });
17 yield* runMigrations({ toMigrationInclusive: 41 });
18
19 const columns = yield* sql<{ readonly name: string; readonly notnull: number }>`
20 PRAGMA table_info(auth_sessions)
21 `;
22 const surface = columns.find((column) => column.name === "client_surface");
23 const appVersion = columns.find((column) => column.name === "client_app_version");
24
25 assert.equal(surface?.name, "client_surface");
26 assert.equal(surface?.notnull, 0);
27 assert.equal(appVersion?.name, "client_app_version");
28 assert.equal(appVersion?.notnull, 0);
29 }),
30 );
31});
apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts created+26
......@@ -0,0 +1,26 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4// Client-declared surface (web/desktop/mobile) and app version, refreshed on
5// every WebSocket connect so the row tracks the client's current build instead
6// of freezing at session issuance. Nullable: old clients never report them.
7export default Effect.gen(function* () {
8 const sql = yield* SqlClient.SqlClient;
9 const columns = yield* sql<{ readonly name: string }>`
10 PRAGMA table_info(auth_sessions)
11 `;
12
13 if (!columns.some((column) => column.name === "client_surface")) {
14 yield* sql`
15 ALTER TABLE auth_sessions
16 ADD COLUMN client_surface TEXT
17 `;
18 }
19
20 if (!columns.some((column) => column.name === "client_app_version")) {
21 yield* sql`
22 ALTER TABLE auth_sessions
23 ADD COLUMN client_app_version TEXT
24 `;
25 }
26});
apps/server/src/server.test.ts+96
......@@ -151,6 +151,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien
151151import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts";
152152import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
153153import * as UsageService from "./usage/UsageService.ts";
154import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
154155import * as Data from "effect/Data";
155156
156157import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts";
......@@ -402,6 +403,7 @@ const buildAppUnderTest = (options?: {
402403 >;
403404 terminalManager?: Partial<TerminalManager.TerminalManager["Service"]>;
404405 orchestrationEngine?: Partial<OrchestrationEngine.OrchestrationEngineService["Service"]>;
406 analyticsService?: Partial<AnalyticsService.AnalyticsService["Service"]>;
405407 projectionSnapshotQuery?: Partial<ProjectionSnapshotQuery.ProjectionSnapshotQuery["Service"]>;
406408 checkpointDiffQuery?: Partial<CheckpointDiffQuery.CheckpointDiffQuery["Service"]>;
407409 browserTraceCollector?: Partial<BrowserTraceCollector.BrowserTraceCollector["Service"]>;
......@@ -833,6 +835,13 @@ const buildAppUnderTest = (options?: {
833835 const appLayer = servedRoutesLayer.pipe(
834836 Layer.provide(resourceTelemetryLayer),
835837 Layer.provide(UsageService.layerTest),
838 Layer.provide(
839 Layer.mock(AnalyticsService.AnalyticsService)({
840 record: () => Effect.void,
841 flush: Effect.void,
842 ...options?.layers?.analyticsService,
843 }),
844 ),
836845 Layer.provide(
837846 Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({
838847 record: () => Effect.void,
......@@ -5035,6 +5044,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
50355044 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
50365045 );
50375046
5047 it.effect("records thread analytics only after a client command succeeds", () =>
5048 Effect.gen(function* () {
5049 const effects: string[] = [];
5050 const analyticsProperties: Array<Readonly<Record<string, unknown>> | undefined> = [];
5051 const failedCommandId = CommandId.make("cmd-thread-create-failed");
5052
5053 yield* buildAppUnderTest({
5054 layers: {
5055 analyticsService: {
5056 record: (event, properties) =>
5057 Effect.sync(() => {
5058 effects.push(`analytics:${event}`);
5059 analyticsProperties.push(properties);
5060 }),
5061 },
5062 orchestrationEngine: {
5063 dispatch: (command) =>
5064 Effect.sync(() => effects.push(`dispatch:${command.commandId}`)).pipe(
5065 Effect.flatMap(() =>
5066 command.commandId === failedCommandId
5067 ? Effect.fail(
5068 new OrchestrationListenerCallbackError({
5069 listener: "domain-event",
5070 detail: "thread creation failed",
5071 }),
5072 )
5073 : Effect.succeed({ sequence: 1 }),
5074 ),
5075 ),
5076 },
5077 },
5078 });
5079
5080 const createThreadCommand = (commandId: CommandId, threadId: ThreadId) =>
5081 ({
5082 type: "thread.create",
5083 commandId,
5084 threadId,
5085 projectId: defaultProjectId,
5086 title: "Analytics test",
5087 modelSelection: defaultModelSelection,
5088 runtimeMode: "full-access",
5089 interactionMode: "default",
5090 branch: null,
5091 worktreePath: null,
5092 createdAt: "2026-01-01T00:00:00.000Z",
5093 }) as const;
5094
5095 const wsUrl = yield* getWsServerUrl("/ws?clientSurface=mobile&clientAppVersion=1.2.3");
5096 yield* Effect.scoped(
5097 withWsRpcClient(wsUrl, (client) =>
5098 Effect.gen(function* () {
5099 const failed = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand](
5100 createThreadCommand(failedCommandId, ThreadId.make("thread-create-failed")),
5101 ).pipe(Effect.result);
5102
5103 assert.equal(failed._tag, "Failure");
5104 assert.deepEqual(effects, [
5105 "analytics:client.connected",
5106 "dispatch:cmd-thread-create-failed",
5107 ]);
5108
5109 const succeeded = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand](
5110 createThreadCommand(
5111 CommandId.make("cmd-thread-create-succeeded"),
5112 ThreadId.make("thread-create-succeeded"),
5113 ),
5114 );
5115
5116 assert.equal(succeeded.sequence, 1);
5117 }),
5118 ),
5119 );
5120
5121 assert.deepEqual(effects, [
5122 "analytics:client.connected",
5123 "dispatch:cmd-thread-create-failed",
5124 "dispatch:cmd-thread-create-succeeded",
5125 "analytics:client.thread.started",
5126 ]);
5127 assert.deepEqual(analyticsProperties, [
5128 { surface: "mobile", appVersion: "1.2.3" },
5129 { surface: "mobile", appVersion: "1.2.3" },
5130 ]);
5131 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
5132 );
5133
50385134 it.effect("routes websocket rpc projects.writeFile errors", () =>
50395135 Effect.gen(function* () {
50405136 const fs = yield* FileSystem.FileSystem;
apps/server/src/ws.ts+77-13
......@@ -15,9 +15,11 @@ import {
1515 type AuthAccessStreamEvent,
1616 type AuthEnvironmentScope,
1717 AuthSessionId,
18 ClientSurface,
1819 CommandId,
1920 type DiscoveredLocalServerList,
2021 EventId,
22 type OrchestrationClientOrigin,
2123 type OrchestrationCommand,
2224 type GitActionProgressEvent,
2325 type GitManagerServiceError,
......@@ -106,6 +108,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts";
106108import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
107109import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts";
108110import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts";
111import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
109112import * as UsageService from "./usage/UsageService.ts";
110113import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts";
111114import * as PullRequestService from "./pullRequest/PullRequestService.ts";
......@@ -349,8 +352,37 @@ function toAuthAccessStreamEvent(
349352 }
350353}
351354
355const isClientSurface = Schema.is(ClientSurface);
356const MAX_CLIENT_APP_VERSION_LENGTH = 64;
357
358// Optional client identity announced on the /ws upgrade URL next to wsTicket.
359// Lenient by design: absent or malformed values degrade to {} so a connection
360// never fails over attribution metadata.
361function readClientConnectionOrigin(
362 request: HttpServerRequest.HttpServerRequest,
363): OrchestrationClientOrigin {
364 const url = HttpServerRequest.toURL(request);
365 if (Option.isNone(url)) {
366 return {};
367 }
368 const surface = url.value.searchParams.get("clientSurface");
369 const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? "";
370 return {
371 ...(isClientSurface(surface) ? { surface } : {}),
372 ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH
373 ? { appVersion }
374 : {}),
375 };
376}
377
378const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({
379 ...(origin.surface !== undefined ? { surface: origin.surface } : {}),
380 ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}),
381});
382
352383const makeWsRpcLayer = (
353384 currentSession: EnvironmentAuth.AuthenticatedSession,
385 clientOrigin: OrchestrationClientOrigin,
354386 previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"],
355387) =>
356388 WsRpcGroup.toLayer(
......@@ -359,6 +391,35 @@ const makeWsRpcLayer = (
359391 const crypto = yield* Crypto.Crypto;
360392 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
361393 const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService;
394 const analytics = yield* AnalyticsService.AnalyticsService;
395 // Every command dispatched on this connection carries the connecting
396 // client's origin, including server-generated bootstrap sub-commands:
397 // the client's request caused them.
398 const hasClientOrigin =
399 clientOrigin.surface !== undefined || clientOrigin.appVersion !== undefined;
400 const dispatchFromClient: OrchestrationEngine.OrchestrationEngineShape["dispatch"] = (
401 command,
402 ) =>
403 orchestrationEngine.dispatch(
404 command,
405 hasClientOrigin ? { origin: clientOrigin } : undefined,
406 );
407 const originProps = clientOriginAnalyticsProps(clientOrigin);
408 const recordClientCommandAnalytics = (command: OrchestrationCommand) => {
409 switch (command.type) {
410 case "thread.create":
411 return analytics.record("client.thread.started", originProps);
412 case "thread.turn.start":
413 return command.bootstrap?.createThread
414 ? Effect.andThen(
415 analytics.record("client.thread.started", originProps),
416 analytics.record("client.turn.requested", originProps),
417 )
418 : analytics.record("client.turn.requested", originProps);
419 default:
420 return Effect.void;
421 }
422 };
362423 const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;
363424 const keybindings = yield* Keybindings.Keybindings;
364425 const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
......@@ -514,7 +575,7 @@ const makeWsRpcLayer = (
514575 activityId: serverEventId,
515576 }).pipe(
516577 Effect.flatMap(({ commandId, activityId }) =>
517 orchestrationEngine.dispatch({
578 dispatchFromClient({
518579 type: "thread.activity.append",
519580 commandId,
520581 threadId: input.threadId,
......@@ -769,7 +830,7 @@ const makeWsRpcLayer = (
769830 createdThread
770831 ? serverCommandId("bootstrap-thread-delete").pipe(
771832 Effect.flatMap((commandId) =>
772 orchestrationEngine.dispatch({
833 dispatchFromClient({
773834 type: "thread.delete",
774835 commandId,
775836 threadId: command.threadId,
......@@ -896,7 +957,7 @@ const makeWsRpcLayer = (
896957
897958 const bootstrapProgram = Effect.gen(function* () {
898959 if (bootstrap?.createThread) {
899 yield* orchestrationEngine.dispatch({
960 yield* dispatchFromClient({
900961 type: "thread.create",
901962 commandId: yield* serverCommandId("bootstrap-thread-create"),
902963 threadId: command.threadId,
......@@ -943,7 +1004,7 @@ const makeWsRpcLayer = (
9431004 path: null,
9441005 });
9451006 targetWorktreePath = worktree.worktree.path;
946 yield* orchestrationEngine.dispatch({
1007 yield* dispatchFromClient({
9471008 type: "thread.meta.update",
9481009 commandId: yield* serverCommandId("bootstrap-thread-meta-update"),
9491010 threadId: command.threadId,
......@@ -955,7 +1016,7 @@ const makeWsRpcLayer = (
9551016
9561017 yield* runSetupProgram();
9571018
958 return yield* orchestrationEngine.dispatch(finalTurnStartCommand);
1019 return yield* dispatchFromClient(finalTurnStartCommand);
9591020 });
9601021
9611022 return yield* bootstrapProgram.pipe(
......@@ -995,13 +1056,11 @@ const makeWsRpcLayer = (
9951056 const dispatchEffect =
9961057 normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap
9971058 ? dispatchBootstrapTurnStart(normalizedCommand)
998 : orchestrationEngine
999 .dispatch(normalizedCommand)
1000 .pipe(
1001 Effect.mapError((cause) =>
1002 toDispatchCommandError(cause, "Failed to dispatch orchestration command"),
1003 ),
1004 );
1059 : dispatchFromClient(normalizedCommand).pipe(
1060 Effect.mapError((cause) =>
1061 toDispatchCommandError(cause, "Failed to dispatch orchestration command"),
1062 ),
1063 );
10051064
10061065 return startup
10071066 .enqueueCommand(dispatchEffect)
......@@ -1098,6 +1157,7 @@ const makeWsRpcLayer = (
10981157 )
10991158 : false;
11001159 const result = yield* dispatchNormalizedCommand(normalizedCommand);
1160 yield* recordClientCommandAnalytics(normalizedCommand);
11011161 if (parkingCommand) {
11021162 const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle";
11031163 if (shouldStopSessionAfterCommand) {
......@@ -2330,6 +2390,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
23302390 const request = yield* HttpServerRequest.HttpServerRequest;
23312391 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
23322392 const sessions = yield* SessionStore.SessionStore;
2393 const analytics = yield* AnalyticsService.AnalyticsService;
23332394 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
23342395 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
23352396 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
......@@ -2338,11 +2399,14 @@ export const websocketRpcRouteLayer = Layer.unwrap(
23382399 failEnvironmentInternal("internal_error", error),
23392400 ),
23402401 );
2402 const clientOrigin = readClientConnectionOrigin(request);
2403 yield* sessions.recordClientConnection(session.sessionId, clientOrigin);
2404 yield* analytics.record("client.connected", clientOriginAnalyticsProps(clientOrigin));
23412405 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, {
23422406 disableTracing: true,
23432407 }).pipe(
23442408 Effect.provide(
2345 makeWsRpcLayer(session, previewAutomationBroker).pipe(
2409 makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe(
23462410 Layer.provideMerge(RpcSerialization.layerJson),
23472411 Layer.provide(ProviderMaintenanceRunner.layer),
23482412 Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)),
apps/web/src/connection/platform.ts+3
......@@ -42,6 +42,7 @@ import * as Ref from "effect/Ref";
4242import * as Stream from "effect/Stream";
4343import { FetchHttpClient } from "effect/unstable/http";
4444
45import { APP_VERSION } from "../branding";
4546import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth";
4647import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer";
4748import {
......@@ -120,6 +121,8 @@ function clientMetadata() {
120121 label: desktop ? "T3 Code Desktop" : "T3 Code Web",
121122 deviceType: "desktop" as const,
122123 ...(platform === "" ? {} : { os: platform }),
124 surface: desktop ? ("desktop" as const) : ("web" as const),
125 ...(APP_VERSION === "0.0.0" ? {} : { appVersion: APP_VERSION }),
123126 };
124127}
125128
packages/client-runtime/src/authorization/remote.ts+18
......@@ -32,6 +32,20 @@ const clientMetadataTokenExchangeFields = (
3232 ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}),
3333});
3434
35// The server reads these off the /ws upgrade URL next to wsTicket. Optional on
36// both ends: old servers ignore unknown params, old clients never send them.
37export const appendClientConnectionParams = (
38 url: URL,
39 clientMetadata: AuthClientPresentationMetadata | undefined,
40): void => {
41 if (clientMetadata?.surface) {
42 url.searchParams.set("clientSurface", clientMetadata.surface);
43 }
44 if (clientMetadata?.appVersion) {
45 url.searchParams.set("clientAppVersion", clientMetadata.appVersion);
46 }
47};
48
3549export const exchangeRemoteDpopAccessToken = Effect.fn(
3650 "clientRuntime.authorization.exchangeRemoteDpopAccessToken",
3751)(function* (input: {
......@@ -174,6 +188,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
174188 readonly wsBaseUrl: string;
175189 readonly httpBaseUrl: string;
176190 readonly bearerToken: string;
191 readonly clientMetadata?: AuthClientPresentationMetadata;
177192 readonly timeoutMs?: number;
178193}) {
179194 const issued = yield* issueRemoteWebSocketTicket({
......@@ -187,6 +202,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn(
187202 url.pathname = "/ws";
188203 }
189204 url.searchParams.set("wsTicket", issued.ticket);
205 appendClientConnectionParams(url, input.clientMetadata);
190206 return url.toString();
191207});
192208
......@@ -197,6 +213,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(
197213 readonly httpBaseUrl: string;
198214 readonly accessToken: string;
199215 readonly dpopProof: string;
216 readonly clientMetadata?: AuthClientPresentationMetadata;
200217 readonly timeoutMs?: number;
201218}) {
202219 const issued = yield* issueRemoteDpopWebSocketTicket({
......@@ -210,5 +227,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn(
210227 url.pathname = "/ws";
211228 }
212229 url.searchParams.set("wsTicket", issued.ticket);
230 appendClientConnectionParams(url, input.clientMetadata);
213231 return url.toString();
214232});
packages/client-runtime/src/authorization/service.ts+2
......@@ -131,6 +131,7 @@ export const make = Effect.gen(function* () {
131131 wsBaseUrl: input.wsBaseUrl,
132132 httpBaseUrl: input.httpBaseUrl,
133133 bearerToken: input.bearerToken,
134 clientMetadata: presentation.metadata,
134135 }).pipe(
135136 Effect.mapError(mapRemoteEnvironmentError),
136137 Effect.provideService(HttpClient.HttpClient, httpClient),
......@@ -170,6 +171,7 @@ export const make = Effect.gen(function* () {
170171 httpBaseUrl: token.endpoint.httpBaseUrl,
171172 accessToken: token.accessToken,
172173 dpopProof: ticketProof,
174 clientMetadata: presentation.metadata,
173175 ...(timeoutMs === undefined ? {} : { timeoutMs }),
174176 }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient));
175177 },
packages/client-runtime/src/connection/resolver.test.ts+8-1
......@@ -174,6 +174,13 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o
174174 bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)),
175175 }),
176176 ),
177 Layer.succeed(
178 ClientCapabilities.ClientPresentation,
179 ClientCapabilities.ClientPresentation.of({
180 metadata: { label: "Test Client", deviceType: "desktop", surface: "web" },
181 scopes: [],
182 }),
183 ),
177184 Layer.succeed(
178185 ClientCapabilities.RelayDeviceIdentity,
179186 ClientCapabilities.RelayDeviceIdentity.of({
......@@ -216,7 +223,7 @@ describe("ConnectionResolver", () => {
216223 environmentId: ENVIRONMENT_ID,
217224 label: "Primary",
218225 httpBaseUrl: "http://127.0.0.1:3777",
219 socketUrl: "ws://127.0.0.1:3777/ws",
226 socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web",
220227 httpAuthorization: null,
221228 target,
222229 });
packages/client-runtime/src/connection/resolver.ts+9-2
......@@ -1,3 +1,4 @@
1import type { AuthClientPresentationMetadata } from "@t3tools/contracts";
12import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay";
23import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
34import * as Context from "effect/Context";
......@@ -6,6 +7,7 @@ import * as Layer from "effect/Layer";
67import * as Option from "effect/Option";
78import * as Schema from "effect/Schema";
89
10import { appendClientConnectionParams } from "../authorization/remote.ts";
911import * as RemoteEnvironmentAuthorization from "../authorization/service.ts";
1012import * as ManagedRelay from "../relay/managedRelay.ts";
1113import * as ClientCapabilities from "../platform/capabilities.ts";
......@@ -46,16 +48,21 @@ const isBearerProfile = Schema.is(BearerConnectionProfile);
4648const isSshProfile = Schema.is(SshConnectionProfile);
4749const isBearerCredential = Schema.is(BearerConnectionCredential);
4850
49function primarySocketUrl(target: PrimaryConnectionTarget): string {
51function primarySocketUrl(
52 target: PrimaryConnectionTarget,
53 clientMetadata: AuthClientPresentationMetadata | undefined,
54): string {
5055 const url = new URL(target.wsBaseUrl);
5156 if (url.pathname === "" || url.pathname === "/") {
5257 url.pathname = "/ws";
5358 }
59 appendClientConnectionParams(url, clientMetadata);
5460 return url.toString();
5561}
5662
5763const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () {
5864 const auth = yield* ClientCapabilities.PrimaryEnvironmentAuth;
65 const presentation = yield* ClientCapabilities.ClientPresentation;
5966 const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization;
6067
6168 return Effect.fn("clientRuntime.connection.broker.primary")(function* (
......@@ -67,7 +74,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary
6774 environmentId: target.environmentId,
6875 label: target.label,
6976 httpBaseUrl: target.httpBaseUrl,
70 socketUrl: primarySocketUrl(target),
77 socketUrl: primarySocketUrl(target, presentation.metadata),
7178 httpAuthorization: null,
7279 target,
7380 } satisfies PreparedConnection;
packages/contracts/src/auth.ts+3-1
......@@ -1,7 +1,7 @@
11import * as Schema from "effect/Schema";
22import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema";
33
4import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import { AuthSessionId, ClientSurface, TrimmedNonEmptyString } from "./baseSchemas.ts";
55
66/**
77 * Declares the server's overall authentication posture.
......@@ -169,6 +169,8 @@ export const AuthClientPresentationMetadata = Schema.Struct({
169169 label: Schema.optionalKey(TrimmedNonEmptyString),
170170 deviceType: Schema.optionalKey(AuthClientMetadataDeviceType),
171171 os: Schema.optionalKey(TrimmedNonEmptyString),
172 surface: Schema.optionalKey(ClientSurface),
173 appVersion: Schema.optionalKey(TrimmedNonEmptyString),
172174});
173175export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type;
174176
packages/contracts/src/baseSchemas.ts+9
......@@ -71,6 +71,15 @@ export type AuthSessionId = typeof AuthSessionId.Type;
7171export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId"));
7272export type RpcClientId = typeof RpcClientId.Type;
7373
74/**
75 * Which client app a connection comes from. Unlike
76 * `AuthClientMetadataDeviceType` (a UA-style device class where web and
77 * desktop are both "desktop"), this names the actual product surface.
78 * Optional everywhere it appears: old clients never send it.
79 */
80export const ClientSurface = Schema.Literals(["web", "desktop", "mobile"]);
81export type ClientSurface = typeof ClientSurface.Type;
82
7483export const ProviderItemId = makeEntityId("ProviderItemId");
7584export type ProviderItemId = typeof ProviderItemId.Type;
7685export const RuntimeSessionId = makeEntityId("RuntimeSessionId");
packages/contracts/src/orchestration.ts+14
......@@ -8,6 +8,7 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts";
88import {
99 ApprovalRequestId,
1010 CheckpointRef,
11 ClientSurface,
1112 CommandId,
1213 EventId,
1314 IsoDateTime,
......@@ -1319,12 +1320,25 @@ export const ThreadActivityAppendedPayload = Schema.Struct({
13191320 activity: OrchestrationThreadActivity,
13201321});
13211322
1323/**
1324 * Which client connection dispatched the command that produced an event.
1325 * Stamped by the orchestration engine on client-dispatched commands; absent on
1326 * provider/server-originated events and on commands from clients too old to
1327 * report it.
1328 */
1329export const OrchestrationClientOrigin = Schema.Struct({
1330 surface: Schema.optional(ClientSurface),
1331 appVersion: Schema.optional(TrimmedNonEmptyString),
1332});
1333export type OrchestrationClientOrigin = typeof OrchestrationClientOrigin.Type;
1334
13221335export const OrchestrationEventMetadata = Schema.Struct({
13231336 providerTurnId: Schema.optional(TrimmedNonEmptyString),
13241337 providerItemId: Schema.optional(ProviderItemId),
13251338 adapterKey: Schema.optional(TrimmedNonEmptyString),
13261339 requestId: Schema.optional(ApprovalRequestId),
13271340 ingestedAt: Schema.optional(IsoDateTime),
1341 origin: Schema.optional(OrchestrationClientOrigin),
13281342});
13291343export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type;
13301344