diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 852535d9d10b..8e699e4c24fd 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -21,6 +21,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import Constants from "expo-constants"; import * as Network from "expo-network"; import { AppState } from "react-native"; @@ -166,7 +167,7 @@ const capabilitiesLayer = Layer.effectContext( Context.add( ClientPresentation, ClientPresentation.of({ - metadata: authClientMetadata(), + metadata: authClientMetadata(Constants.expoConfig?.version), scopes: AuthStandardClientScopes, }), ), diff --git a/apps/mobile/src/lib/authClientMetadata.ts b/apps/mobile/src/lib/authClientMetadata.ts index 09897b6186e1..5189c34f5806 100644 --- a/apps/mobile/src/lib/authClientMetadata.ts +++ b/apps/mobile/src/lib/authClientMetadata.ts @@ -1,10 +1,12 @@ import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; import { Platform } from "react-native"; -export function authClientMetadata(): AuthClientPresentationMetadata { +export function authClientMetadata(appVersion?: string): AuthClientPresentationMetadata { return { label: "T3 Code Mobile", deviceType: "mobile", ...(Platform.OS === "ios" ? { os: "iOS" } : Platform.OS === "android" ? { os: "Android" } : {}), + surface: "mobile", + ...(appVersion ? { appVersion } : {}), }; } diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index f1f30b298b66..8ec0fb8bd892 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -26,6 +26,14 @@ describe("mobile remote connection records", () => { label: "T3 Code Mobile", deviceType: "mobile", os: "iOS", + surface: "mobile", + }); + }); + + it("includes the mobile app version when the client provides it", () => { + expect(authClientMetadata("1.2.3")).toMatchObject({ + surface: "mobile", + appVersion: "1.2.3", }); }); diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 334c24ef52fd..1fb01c1f0002 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -4,6 +4,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as TestClock from "effect/testing/TestClock"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerConfig from "../config.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; @@ -47,6 +48,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi revoke: () => Effect.fail(repositoryFailure), revokeAllExcept: () => Effect.fail(repositoryFailure), setLastConnectedAt: () => Effect.void, + setClientConnection: () => Effect.void, }); const failingSessionLookupCredentialLayer = Layer.effect( @@ -315,4 +317,35 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString()); }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); + it.effect("records client connection metadata without clearing prior values", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const sql = yield* SqlClient.SqlClient; + const issued = yield* sessions.issue({ + subject: "client-connection-test", + method: "bearer-access-token", + }); + const readRow = sql<{ + readonly surface: string | null; + readonly appVersion: string | null; + }>` + SELECT client_surface AS "surface", client_app_version AS "appVersion" + FROM auth_sessions + WHERE session_id = ${issued.sessionId} + `; + + yield* sessions.recordClientConnection(issued.sessionId, { + surface: "mobile", + appVersion: "1.2.0", + }); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.2.0" }); + + // A partial report (old or minimal client) must not null out stored data. + yield* sessions.recordClientConnection(issued.sessionId, { appVersion: "1.3.0" }); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); + + yield* sessions.recordClientConnection(issued.sessionId, {}); + expect((yield* readRow)[0]).toEqual({ surface: "mobile", appVersion: "1.3.0" }); + }).pipe(Effect.provide(Layer.mergeAll(makeSessionStoreLayer(), SqlitePersistenceMemory))), + ); }); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 40a1c43e0be7..cdcd4a1ac198 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -5,6 +5,7 @@ import { type AuthClientMetadata, type AuthClientSession, type AuthEnvironmentScope, + type ClientSurface, type ServerAuthSessionMethod, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -396,6 +397,13 @@ export class SessionStore extends Context.Service< ) => Effect.Effect; readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly recordClientConnection: ( + sessionId: AuthSessionId, + client: { + readonly surface?: ClientSurface | undefined; + readonly appVersion?: string | undefined; + }, + ) => Effect.Effect; } >()("t3/auth/SessionStore") {} @@ -544,6 +552,28 @@ export const make = Effect.gen(function* () { Effect.withSpan("SessionStore.markConnected"), ); + // Best-effort: connection metadata must never block or fail a connect. + const recordClientConnection: SessionStore["Service"]["recordClientConnection"] = ( + sessionId, + client, + ) => + client.surface === undefined && client.appVersion === undefined + ? Effect.void + : authSessions + .setClientConnection({ + sessionId, + surface: client.surface ?? null, + appVersion: client.appVersion ?? null, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to record session client connection metadata.").pipe( + Effect.annotateLogs({ sessionId, cause }), + ), + ), + Effect.withSpan("SessionStore.recordClientConnection"), + ); + const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); @@ -912,6 +942,7 @@ export const make = Effect.gen(function* () { revokeAllExcept, markConnected, markDisconnected, + recordClientConnection, }); }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 1b89d6d4d8a8..382c253fe60b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -1379,4 +1379,55 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + + it("stamps the dispatching client's origin onto persisted event metadata", async () => { + const createdAt = now(); + const system = await createOrchestrationSystem(); + const { engine } = system; + + await system.run( + engine.dispatch( + { + type: "project.create", + commandId: CommandId.make("cmd-origin-project-create"), + projectId: asProjectId("project-origin"), + title: "Origin Project", + workspaceRoot: "/tmp/project-origin", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }, + { origin: { surface: "mobile", appVersion: "1.2.3" } }, + ), + ); + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-no-origin-project-create"), + projectId: asProjectId("project-no-origin"), + title: "No Origin Project", + workspaceRoot: "/tmp/project-no-origin", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + + const events = await system.run( + Stream.runCollect(engine.readEvents(0)).pipe(Effect.map((chunk) => Array.from(chunk))), + ); + const withOrigin = events.find((event) => event.commandId === "cmd-origin-project-create"); + const withoutOrigin = events.find( + (event) => event.commandId === "cmd-no-origin-project-create", + ); + + expect(withOrigin?.metadata.origin).toEqual({ surface: "mobile", appVersion: "1.2.3" }); + expect(withoutOrigin?.metadata.origin).toBeUndefined(); + + await system.dispose(); + }); }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index da79b4395acb..423a44a6ff15 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,4 +1,5 @@ import type { + OrchestrationClientOrigin, OrchestrationEvent, OrchestrationReadModel, ProjectId, @@ -54,6 +55,7 @@ const isOrchestrationCommandInvariantError = Schema.is(OrchestrationCommandInvar interface CommandEnvelope { command: OrchestrationCommand; + origin: OrchestrationClientOrigin | undefined; result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; startedAtMs: number; } @@ -182,7 +184,16 @@ const makeOrchestrationEngine = Effect.gen(function* () { }), ), ); - const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; + const plannedEvents = Array.isArray(eventBase) ? eventBase : [eventBase]; + // Stamp the dispatching client's origin onto every event the command + // produced. The decider stays pure; attribution is an engine concern. + const eventBases = + envelope.origin === undefined + ? plannedEvents + : plannedEvents.map((planned) => ({ + ...planned, + metadata: { ...planned.metadata, origin: envelope.origin }, + })); const committedCommand = yield* sql .withTransaction( Effect.gen(function* () { @@ -329,11 +340,12 @@ const makeOrchestrationEngine = Effect.gen(function* () { const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) => eventStore.readFromSequence(fromSequenceExclusive, limit); - const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + const dispatch: OrchestrationEngineShape["dispatch"] = (command, options) => Effect.gen(function* () { const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); yield* Queue.offer(commandQueue, { command, + origin: options?.origin, result, startedAtMs: yield* Clock.currentTimeMillis, }); diff --git a/apps/server/src/orchestration/Services/OrchestrationEngine.ts b/apps/server/src/orchestration/Services/OrchestrationEngine.ts index f8bcfd76ac06..a32a45684014 100644 --- a/apps/server/src/orchestration/Services/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Services/OrchestrationEngine.ts @@ -10,7 +10,11 @@ * * @module OrchestrationEngineService */ -import type { OrchestrationCommand, OrchestrationEvent } from "@t3tools/contracts"; +import type { + OrchestrationClientOrigin, + OrchestrationCommand, + OrchestrationEvent, +} from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; @@ -41,6 +45,8 @@ export interface OrchestrationEngineShape { * Dispatch a validated orchestration command. * * @param command - Valid orchestration command. + * @param options - Optional client origin (surface/app version) stamped into + * the metadata of every event the command produces. * @returns Effect containing the sequence of the persisted event. * * Dispatch is serialized through an internal queue and deduplicated via @@ -48,6 +54,7 @@ export interface OrchestrationEngineShape { */ readonly dispatch: ( command: OrchestrationCommand, + options?: { readonly origin?: OrchestrationClientOrigin }, ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; /** diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 545688e38228..579d3a608190 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -10,6 +10,7 @@ import { AuthClientMetadataDeviceType, AuthEnvironmentScopes, AuthSessionId, + ClientSurface, ServerAuthSessionMethod, } from "@t3tools/contracts"; @@ -82,6 +83,13 @@ export const SetAuthSessionLastConnectedAtInput = Schema.Struct({ }); export type SetAuthSessionLastConnectedAtInput = typeof SetAuthSessionLastConnectedAtInput.Type; +export const SetAuthSessionClientConnectionInput = Schema.Struct({ + sessionId: AuthSessionId, + surface: Schema.NullOr(ClientSurface), + appVersion: Schema.NullOr(Schema.String), +}); +export type SetAuthSessionClientConnectionInput = typeof SetAuthSessionClientConnectionInput.Type; + export class AuthSessionRepository extends Context.Service< AuthSessionRepository, { @@ -103,6 +111,9 @@ export class AuthSessionRepository extends Context.Service< readonly setLastConnectedAt: ( input: SetAuthSessionLastConnectedAtInput, ) => Effect.Effect; + readonly setClientConnection: ( + input: SetAuthSessionClientConnectionInput, + ) => Effect.Effect; } >()("t3/persistence/AuthSessions/AuthSessionRepository") {} @@ -281,6 +292,20 @@ export const make = Effect.gen(function* () { `, }); + // COALESCE keeps the previous value when a client reports only one field, so + // a partial report never nulls out data a fuller client stored earlier. + const setClientConnectionRow = SqlSchema.void({ + Request: SetAuthSessionClientConnectionInput, + execute: ({ sessionId, surface, appVersion }) => + sql` + UPDATE auth_sessions + SET client_surface = COALESCE(${surface}, client_surface), + client_app_version = COALESCE(${appVersion}, client_app_version) + WHERE session_id = ${sessionId} + AND revoked_at IS NULL + `, + }); + const revokeSessionRows = SqlSchema.findAll({ Request: RevokeAuthSessionInput, Result: Schema.Struct({ sessionId: AuthSessionId }), @@ -404,6 +429,17 @@ export const make = Effect.gen(function* () { ), ); + const setClientConnection: AuthSessionRepository["Service"]["setClientConnection"] = (input) => + setClientConnectionRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "AuthSessionRepository.setClientConnection:query", + "AuthSessionRepository.setClientConnection:encodeRequest", + { sessionId: input.sessionId }, + ), + ), + ); + return { create, getById, @@ -411,6 +447,7 @@ export const make = Effect.gen(function* () { revoke, revokeAllExcept, setLastConnectedAt, + setClientConnection, } satisfies AuthSessionRepository["Service"]; }); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..170cb3992279 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "AuthSessionClientConnection", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts new file mode 100644 index 000000000000..178338b78318 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts @@ -0,0 +1,31 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("041_AuthSessionClientConnection", (it) => { + it.effect("adds nullable client surface and app version columns to auth sessions", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 40 }); + yield* runMigrations({ toMigrationInclusive: 41 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(auth_sessions) + `; + const surface = columns.find((column) => column.name === "client_surface"); + const appVersion = columns.find((column) => column.name === "client_app_version"); + + assert.equal(surface?.name, "client_surface"); + assert.equal(surface?.notnull, 0); + assert.equal(appVersion?.name, "client_app_version"); + assert.equal(appVersion?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts new file mode 100644 index 000000000000..2194c3cd0f14 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// Client-declared surface (web/desktop/mobile) and app version, refreshed on +// every WebSocket connect so the row tracks the client's current build instead +// of freezing at session issuance. Nullable: old clients never report them. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(auth_sessions) + `; + + if (!columns.some((column) => column.name === "client_surface")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_surface TEXT + `; + } + + if (!columns.some((column) => column.name === "client_app_version")) { + yield* sql` + ALTER TABLE auth_sessions + ADD COLUMN client_app_version TEXT + `; + } +}); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7cda53f25326..de3f5101f53e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -151,6 +151,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; @@ -402,6 +403,7 @@ const buildAppUnderTest = (options?: { >; terminalManager?: Partial; orchestrationEngine?: Partial; + analyticsService?: Partial; projectionSnapshotQuery?: Partial; checkpointDiffQuery?: Partial; browserTraceCollector?: Partial; @@ -833,6 +835,13 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), Layer.provide(UsageService.layerTest), + Layer.provide( + Layer.mock(AnalyticsService.AnalyticsService)({ + record: () => Effect.void, + flush: Effect.void, + ...options?.layers?.analyticsService, + }), + ), Layer.provide( Layer.mock(BrowserTraceCollector.BrowserTraceCollector)({ record: () => Effect.void, @@ -5035,6 +5044,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("records thread analytics only after a client command succeeds", () => + Effect.gen(function* () { + const effects: string[] = []; + const analyticsProperties: Array> | undefined> = []; + const failedCommandId = CommandId.make("cmd-thread-create-failed"); + + yield* buildAppUnderTest({ + layers: { + analyticsService: { + record: (event, properties) => + Effect.sync(() => { + effects.push(`analytics:${event}`); + analyticsProperties.push(properties); + }), + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => effects.push(`dispatch:${command.commandId}`)).pipe( + Effect.flatMap(() => + command.commandId === failedCommandId + ? Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "thread creation failed", + }), + ) + : Effect.succeed({ sequence: 1 }), + ), + ), + }, + }, + }); + + const createThreadCommand = (commandId: CommandId, threadId: ThreadId) => + ({ + type: "thread.create", + commandId, + threadId, + projectId: defaultProjectId, + title: "Analytics test", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + }) as const; + + const wsUrl = yield* getWsServerUrl("/ws?clientSurface=mobile&clientAppVersion=1.2.3"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const failed = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( + createThreadCommand(failedCommandId, ThreadId.make("thread-create-failed")), + ).pipe(Effect.result); + + assert.equal(failed._tag, "Failure"); + assert.deepEqual(effects, [ + "analytics:client.connected", + "dispatch:cmd-thread-create-failed", + ]); + + const succeeded = yield* client[ORCHESTRATION_WS_METHODS.dispatchCommand]( + createThreadCommand( + CommandId.make("cmd-thread-create-succeeded"), + ThreadId.make("thread-create-succeeded"), + ), + ); + + assert.equal(succeeded.sequence, 1); + }), + ), + ); + + assert.deepEqual(effects, [ + "analytics:client.connected", + "dispatch:cmd-thread-create-failed", + "dispatch:cmd-thread-create-succeeded", + "analytics:client.thread.started", + ]); + assert.deepEqual(analyticsProperties, [ + { surface: "mobile", appVersion: "1.2.3" }, + { surface: "mobile", appVersion: "1.2.3" }, + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile errors", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c5b7e50a8704..c3caea225704 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -15,9 +15,11 @@ import { type AuthAccessStreamEvent, type AuthEnvironmentScope, AuthSessionId, + ClientSurface, CommandId, type DiscoveredLocalServerList, EventId, + type OrchestrationClientOrigin, type OrchestrationCommand, type GitActionProgressEvent, type GitManagerServiceError, @@ -106,6 +108,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; @@ -349,8 +352,37 @@ function toAuthAccessStreamEvent( } } +const isClientSurface = Schema.is(ClientSurface); +const MAX_CLIENT_APP_VERSION_LENGTH = 64; + +// Optional client identity announced on the /ws upgrade URL next to wsTicket. +// Lenient by design: absent or malformed values degrade to {} so a connection +// never fails over attribution metadata. +function readClientConnectionOrigin( + request: HttpServerRequest.HttpServerRequest, +): OrchestrationClientOrigin { + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) { + return {}; + } + const surface = url.value.searchParams.get("clientSurface"); + const appVersion = url.value.searchParams.get("clientAppVersion")?.trim() ?? ""; + return { + ...(isClientSurface(surface) ? { surface } : {}), + ...(appVersion !== "" && appVersion.length <= MAX_CLIENT_APP_VERSION_LENGTH + ? { appVersion } + : {}), + }; +} + +const clientOriginAnalyticsProps = (origin: OrchestrationClientOrigin) => ({ + ...(origin.surface !== undefined ? { surface: origin.surface } : {}), + ...(origin.appVersion !== undefined ? { appVersion: origin.appVersion } : {}), +}); + const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, + clientOrigin: OrchestrationClientOrigin, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], ) => WsRpcGroup.toLayer( @@ -359,6 +391,35 @@ const makeWsRpcLayer = ( const crypto = yield* Crypto.Crypto; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const analytics = yield* AnalyticsService.AnalyticsService; + // Every command dispatched on this connection carries the connecting + // client's origin, including server-generated bootstrap sub-commands: + // the client's request caused them. + const hasClientOrigin = + clientOrigin.surface !== undefined || clientOrigin.appVersion !== undefined; + const dispatchFromClient: OrchestrationEngine.OrchestrationEngineShape["dispatch"] = ( + command, + ) => + orchestrationEngine.dispatch( + command, + hasClientOrigin ? { origin: clientOrigin } : undefined, + ); + const originProps = clientOriginAnalyticsProps(clientOrigin); + const recordClientCommandAnalytics = (command: OrchestrationCommand) => { + switch (command.type) { + case "thread.create": + return analytics.record("client.thread.started", originProps); + case "thread.turn.start": + return command.bootstrap?.createThread + ? Effect.andThen( + analytics.record("client.thread.started", originProps), + analytics.record("client.turn.requested", originProps), + ) + : analytics.record("client.turn.requested", originProps); + default: + return Effect.void; + } + }; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; @@ -514,7 +575,7 @@ const makeWsRpcLayer = ( activityId: serverEventId, }).pipe( Effect.flatMap(({ commandId, activityId }) => - orchestrationEngine.dispatch({ + dispatchFromClient({ type: "thread.activity.append", commandId, threadId: input.threadId, @@ -769,7 +830,7 @@ const makeWsRpcLayer = ( createdThread ? serverCommandId("bootstrap-thread-delete").pipe( Effect.flatMap((commandId) => - orchestrationEngine.dispatch({ + dispatchFromClient({ type: "thread.delete", commandId, threadId: command.threadId, @@ -896,7 +957,7 @@ const makeWsRpcLayer = ( const bootstrapProgram = Effect.gen(function* () { if (bootstrap?.createThread) { - yield* orchestrationEngine.dispatch({ + yield* dispatchFromClient({ type: "thread.create", commandId: yield* serverCommandId("bootstrap-thread-create"), threadId: command.threadId, @@ -943,7 +1004,7 @@ const makeWsRpcLayer = ( path: null, }); targetWorktreePath = worktree.worktree.path; - yield* orchestrationEngine.dispatch({ + yield* dispatchFromClient({ type: "thread.meta.update", commandId: yield* serverCommandId("bootstrap-thread-meta-update"), threadId: command.threadId, @@ -955,7 +1016,7 @@ const makeWsRpcLayer = ( yield* runSetupProgram(); - return yield* orchestrationEngine.dispatch(finalTurnStartCommand); + return yield* dispatchFromClient(finalTurnStartCommand); }); return yield* bootstrapProgram.pipe( @@ -995,13 +1056,11 @@ const makeWsRpcLayer = ( const dispatchEffect = normalizedCommand.type === "thread.turn.start" && normalizedCommand.bootstrap ? dispatchBootstrapTurnStart(normalizedCommand) - : orchestrationEngine - .dispatch(normalizedCommand) - .pipe( - Effect.mapError((cause) => - toDispatchCommandError(cause, "Failed to dispatch orchestration command"), - ), - ); + : dispatchFromClient(normalizedCommand).pipe( + Effect.mapError((cause) => + toDispatchCommandError(cause, "Failed to dispatch orchestration command"), + ), + ); return startup .enqueueCommand(dispatchEffect) @@ -1098,6 +1157,7 @@ const makeWsRpcLayer = ( ) : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); + yield* recordClientCommandAnalytics(normalizedCommand); if (parkingCommand) { const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; if (shouldStopSessionAfterCommand) { @@ -2330,6 +2390,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const analytics = yield* AnalyticsService.AnalyticsService; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), @@ -2338,11 +2399,14 @@ export const websocketRpcRouteLayer = Layer.unwrap( failEnvironmentInternal("internal_error", error), ), ); + const clientOrigin = readClientConnectionOrigin(request); + yield* sessions.recordClientConnection(session.sessionId, clientOrigin); + yield* analytics.record("client.connected", clientOriginAnalyticsProps(clientOrigin)); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, previewAutomationBroker).pipe( + makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index c7652136f541..daead0bc6308 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -42,6 +42,7 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { FetchHttpClient } from "effect/unstable/http"; +import { APP_VERSION } from "../branding"; import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { @@ -120,6 +121,8 @@ function clientMetadata() { label: desktop ? "T3 Code Desktop" : "T3 Code Web", deviceType: "desktop" as const, ...(platform === "" ? {} : { os: platform }), + surface: desktop ? ("desktop" as const) : ("web" as const), + ...(APP_VERSION === "0.0.0" ? {} : { appVersion: APP_VERSION }), }; } diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 69c157d0e50a..895fee836b3e 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -32,6 +32,20 @@ const clientMetadataTokenExchangeFields = ( ...(clientMetadata?.os ? { client_os: clientMetadata.os } : {}), }); +// The server reads these off the /ws upgrade URL next to wsTicket. Optional on +// both ends: old servers ignore unknown params, old clients never send them. +export const appendClientConnectionParams = ( + url: URL, + clientMetadata: AuthClientPresentationMetadata | undefined, +): void => { + if (clientMetadata?.surface) { + url.searchParams.set("clientSurface", clientMetadata.surface); + } + if (clientMetadata?.appVersion) { + url.searchParams.set("clientAppVersion", clientMetadata.appVersion); + } +}; + export const exchangeRemoteDpopAccessToken = Effect.fn( "clientRuntime.authorization.exchangeRemoteDpopAccessToken", )(function* (input: { @@ -174,6 +188,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( readonly wsBaseUrl: string; readonly httpBaseUrl: string; readonly bearerToken: string; + readonly clientMetadata?: AuthClientPresentationMetadata; readonly timeoutMs?: number; }) { const issued = yield* issueRemoteWebSocketTicket({ @@ -187,6 +202,7 @@ export const resolveRemoteWebSocketConnectionUrl = Effect.fn( url.pathname = "/ws"; } url.searchParams.set("wsTicket", issued.ticket); + appendClientConnectionParams(url, input.clientMetadata); return url.toString(); }); @@ -197,6 +213,7 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( readonly httpBaseUrl: string; readonly accessToken: string; readonly dpopProof: string; + readonly clientMetadata?: AuthClientPresentationMetadata; readonly timeoutMs?: number; }) { const issued = yield* issueRemoteDpopWebSocketTicket({ @@ -210,5 +227,6 @@ export const resolveRemoteDpopWebSocketConnectionUrl = Effect.fn( url.pathname = "/ws"; } url.searchParams.set("wsTicket", issued.ticket); + appendClientConnectionParams(url, input.clientMetadata); return url.toString(); }); diff --git a/packages/client-runtime/src/authorization/service.ts b/packages/client-runtime/src/authorization/service.ts index 410c7a39dc0a..fef8db274b3a 100644 --- a/packages/client-runtime/src/authorization/service.ts +++ b/packages/client-runtime/src/authorization/service.ts @@ -131,6 +131,7 @@ export const make = Effect.gen(function* () { wsBaseUrl: input.wsBaseUrl, httpBaseUrl: input.httpBaseUrl, bearerToken: input.bearerToken, + clientMetadata: presentation.metadata, }).pipe( Effect.mapError(mapRemoteEnvironmentError), Effect.provideService(HttpClient.HttpClient, httpClient), @@ -170,6 +171,7 @@ export const make = Effect.gen(function* () { httpBaseUrl: token.endpoint.httpBaseUrl, accessToken: token.accessToken, dpopProof: ticketProof, + clientMetadata: presentation.metadata, ...(timeoutMs === undefined ? {} : { timeoutMs }), }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); }, diff --git a/packages/client-runtime/src/connection/resolver.test.ts b/packages/client-runtime/src/connection/resolver.test.ts index d0375e555561..d71c0f9602bf 100644 --- a/packages/client-runtime/src/connection/resolver.test.ts +++ b/packages/client-runtime/src/connection/resolver.test.ts @@ -174,6 +174,13 @@ const makeDependencies = Effect.fn("TestConnectionResolver.makeDependencies")((o bearerToken: Effect.succeed(Option.fromNullishOr(options?.primaryBearerToken)), }), ), + Layer.succeed( + ClientCapabilities.ClientPresentation, + ClientCapabilities.ClientPresentation.of({ + metadata: { label: "Test Client", deviceType: "desktop", surface: "web" }, + scopes: [], + }), + ), Layer.succeed( ClientCapabilities.RelayDeviceIdentity, ClientCapabilities.RelayDeviceIdentity.of({ @@ -216,7 +223,7 @@ describe("ConnectionResolver", () => { environmentId: ENVIRONMENT_ID, label: "Primary", httpBaseUrl: "http://127.0.0.1:3777", - socketUrl: "ws://127.0.0.1:3777/ws", + socketUrl: "ws://127.0.0.1:3777/ws?clientSurface=web", httpAuthorization: null, target, }); diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index c219bde092cb..3a5d5437a4e6 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -1,3 +1,4 @@ +import type { AuthClientPresentationMetadata } from "@t3tools/contracts"; import { RelayEnvironmentConnectScope } from "@t3tools/contracts/relay"; import { withRelayClientTracing } from "@t3tools/shared/relayTracing"; import * as Context from "effect/Context"; @@ -6,6 +7,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import { appendClientConnectionParams } from "../authorization/remote.ts"; import * as RemoteEnvironmentAuthorization from "../authorization/service.ts"; import * as ManagedRelay from "../relay/managedRelay.ts"; import * as ClientCapabilities from "../platform/capabilities.ts"; @@ -46,16 +48,21 @@ const isBearerProfile = Schema.is(BearerConnectionProfile); const isSshProfile = Schema.is(SshConnectionProfile); const isBearerCredential = Schema.is(BearerConnectionCredential); -function primarySocketUrl(target: PrimaryConnectionTarget): string { +function primarySocketUrl( + target: PrimaryConnectionTarget, + clientMetadata: AuthClientPresentationMetadata | undefined, +): string { const url = new URL(target.wsBaseUrl); if (url.pathname === "" || url.pathname === "/") { url.pathname = "/ws"; } + appendClientConnectionParams(url, clientMetadata); return url.toString(); } const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary")(function* () { const auth = yield* ClientCapabilities.PrimaryEnvironmentAuth; + const presentation = yield* ClientCapabilities.ClientPresentation; const remote = yield* RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization; return Effect.fn("clientRuntime.connection.broker.primary")(function* ( @@ -67,7 +74,7 @@ const makePrimaryBroker = Effect.fn("clientRuntime.connection.broker.makePrimary environmentId: target.environmentId, label: target.label, httpBaseUrl: target.httpBaseUrl, - socketUrl: primarySocketUrl(target), + socketUrl: primarySocketUrl(target, presentation.metadata), httpAuthorization: null, target, } satisfies PreparedConnection; diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 70b2899757db..0af93dc0344a 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import * as HttpApiSchema from "effect/unstable/httpapi/HttpApiSchema"; -import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { AuthSessionId, ClientSurface, TrimmedNonEmptyString } from "./baseSchemas.ts"; /** * Declares the server's overall authentication posture. @@ -169,6 +169,8 @@ export const AuthClientPresentationMetadata = Schema.Struct({ label: Schema.optionalKey(TrimmedNonEmptyString), deviceType: Schema.optionalKey(AuthClientMetadataDeviceType), os: Schema.optionalKey(TrimmedNonEmptyString), + surface: Schema.optionalKey(ClientSurface), + appVersion: Schema.optionalKey(TrimmedNonEmptyString), }); export type AuthClientPresentationMetadata = typeof AuthClientPresentationMetadata.Type; diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 9a63f22c9ef2..e12bf3e975de 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -71,6 +71,15 @@ export type AuthSessionId = typeof AuthSessionId.Type; export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId")); export type RpcClientId = typeof RpcClientId.Type; +/** + * Which client app a connection comes from. Unlike + * `AuthClientMetadataDeviceType` (a UA-style device class where web and + * desktop are both "desktop"), this names the actual product surface. + * Optional everywhere it appears: old clients never send it. + */ +export const ClientSurface = Schema.Literals(["web", "desktop", "mobile"]); +export type ClientSurface = typeof ClientSurface.Type; + export const ProviderItemId = makeEntityId("ProviderItemId"); export type ProviderItemId = typeof ProviderItemId.Type; export const RuntimeSessionId = makeEntityId("RuntimeSessionId"); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index c7c63270c8b5..adb17879ff2f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -8,6 +8,7 @@ import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ApprovalRequestId, CheckpointRef, + ClientSurface, CommandId, EventId, IsoDateTime, @@ -1319,12 +1320,25 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ activity: OrchestrationThreadActivity, }); +/** + * Which client connection dispatched the command that produced an event. + * Stamped by the orchestration engine on client-dispatched commands; absent on + * provider/server-originated events and on commands from clients too old to + * report it. + */ +export const OrchestrationClientOrigin = Schema.Struct({ + surface: Schema.optional(ClientSurface), + appVersion: Schema.optional(TrimmedNonEmptyString), +}); +export type OrchestrationClientOrigin = typeof OrchestrationClientOrigin.Type; + export const OrchestrationEventMetadata = Schema.Struct({ providerTurnId: Schema.optional(TrimmedNonEmptyString), providerItemId: Schema.optional(ProviderItemId), adapterKey: Schema.optional(TrimmedNonEmptyString), requestId: Schema.optional(ApprovalRequestId), ingestedAt: Schema.optional(IsoDateTime), + origin: Schema.optional(OrchestrationClientOrigin), }); export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type;