diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 66057407c62637514e7485707354cb4d7ec12d30..90197f009cd0e29755d43384637f27a6721d2571 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -40,6 +40,7 @@ import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService. import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; import { ProviderService } from "../src/provider/Services/ProviderService.ts"; +import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts"; import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; @@ -252,10 +253,22 @@ export const makeOrchestrationIntegrationHarness = ( ? makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(realCodexRegistry), + Layer.provide( + Layer.succeed(AnalyticsService, { + record: () => Effect.void, + flush: Effect.void, + }), + ), ) : makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(fakeRegistry!), + Layer.provide( + Layer.succeed(AnalyticsService, { + record: () => Effect.void, + flush: Effect.void, + }), + ), ); const runtimeServicesLayer = Layer.mergeAll( diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 5c1362f231a51e9072595b0460d9f35ce7ba4045..7c0aceebd8fca0fa9a5251e0129ccba0cd7ed98c 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -12,6 +12,7 @@ import { ProviderService, type ProviderServiceShape, } from "../src/provider/Services/ProviderService.ts"; +import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; @@ -59,6 +60,10 @@ const makeIntegrationFixture = Effect.gen(function* () { const shared = Layer.mergeAll( directoryLayer, Layer.succeed(ProviderAdapterRegistry, registry), + Layer.succeed(AnalyticsService, { + record: () => Effect.void, + flush: Effect.void, + }), ).pipe(Layer.provide(SqlitePersistenceMemory)); const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6eb26602cab91bb1954d6f8b933ef4342877c0ca..bcdca3eebaab9b20d4097ed05fdf9c92fe5e5a30 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -9,13 +9,17 @@ import { Command } from "effect/unstable/cli"; import { version } from "../package.json" with { type: "json" }; import { ServerLive } from "./wsServer"; import { NetService } from "@t3tools/shared/Net"; +import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; const RuntimeLayer = Layer.empty.pipe( Layer.provideMerge(CliConfig.layer), + Layer.provideMerge(AnalyticsServiceLayerLive), Layer.provideMerge(ServerLive), Layer.provideMerge(OpenLive), Layer.provideMerge(NetService.layer), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), ); Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 0a36a4ca1b45a44dbae3a95f9d692d60494e5d6d..58c6050a2581780b593f3e7dbe881e1e51f191c4 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -11,6 +11,7 @@ import { NetService } from "@t3tools/shared/Net"; import { CliConfig, t3Cli, type CliConfigShape } from "./main"; import { ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { Server, type ServerShape } from "./wsServer"; const start = vi.fn(() => undefined); @@ -47,6 +48,10 @@ const testLayer = Layer.mergeAll( openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, } satisfies OpenShape), + Layer.succeed(AnalyticsService, { + record: () => Effect.void, + flush: Effect.void, + }), NodeServices.layer, ); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 1b9b87e9f267f0011f191551e05ffd654f537fc3..e3a2fb1ec310fe1355ae388546ac66df88ac4c40 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -42,6 +42,7 @@ import { makeSqlitePersistenceLive, SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; +import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); @@ -214,6 +215,11 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { const sleep = (ms: number) => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); +const noopAnalyticsLayer = Layer.succeed(AnalyticsService, { + record: () => Effect.void, + flush: Effect.void, +}); + function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); const registry: typeof ProviderAdapterRegistry.Service = { @@ -235,8 +241,10 @@ function makeProviderServiceLayer() { makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), + Layer.provideMerge(noopAnalyticsLayer), ), directoryLayer, + runtimeRepositoryLayer, NodeServices.layer, ), @@ -280,6 +288,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const providerLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), + Layer.provide(noopAnalyticsLayer), ); yield* Effect.gen(function* () { @@ -338,6 +347,7 @@ it.effect( const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), + Layer.provide(noopAnalyticsLayer), ); const startedSession = yield* Effect.gen(function* () { @@ -380,6 +390,7 @@ it.effect( const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), + Layer.provide(noopAnalyticsLayer), ); secondCodex.startSession.mockClear(); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 3681d5b8fe2fd786c138fda38a9dcd56c84bd1a3..704fa3c9a35346822bc138facc52b02221c52a5a 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -31,6 +31,7 @@ import { type ProviderRuntimeBinding, } from "../Services/ProviderSessionDirectory.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; export interface ProviderServiceLiveOptions { readonly canonicalEventLogPath?: string; @@ -108,6 +109,7 @@ function readPersistedCwd( const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.gen(function* () { + const analytics = yield* Effect.service(AnalyticsService); const canonicalEventLogger = options?.canonicalEventLogger ?? (options?.canonicalEventLogPath !== undefined @@ -178,6 +180,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => const existing = activeSessions.find((session) => session.threadId === input.binding.threadId); if (existing) { yield* upsertSessionBinding(existing, input.binding.threadId); + yield* analytics.record("provider.session.recovered", { + provider: existing.provider, + strategy: "adopt-existing", + hasResumeCursor: existing.resumeCursor !== undefined, + }); return { adapter, session: existing } as const; } } @@ -206,6 +213,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } yield* upsertSessionBinding(resumed, input.binding.threadId); + yield* analytics.record("provider.session.recovered", { + provider: resumed.provider, + strategy: "resume-thread", + hasResumeCursor: resumed.resumeCursor !== undefined, + }); return { adapter, session: resumed } as const; }); @@ -262,6 +274,13 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => } yield* upsertSessionBinding(session, threadId); + yield* analytics.record("provider.session.started", { + provider: session.provider, + hasResumeCursor: session.resumeCursor !== undefined, + hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0, + approvalPolicy: input.approvalPolicy, + sandboxMode: input.sandboxMode, + }); return session; }); @@ -301,6 +320,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => lastRuntimeEventAt: new Date().toISOString(), }, }); + yield* analytics.record("provider.turn.sent", { + provider: routed.adapter.provider, + attachmentCount: input.attachments.length, + hasInput: typeof input.input === "string" && input.input.trim().length > 0, + }); return turn; }); @@ -317,6 +341,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => allowRecovery: true, }); yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); + yield* analytics.record("provider.turn.interrupted", { + provider: routed.adapter.provider, + hasTurnId: input.turnId !== undefined, + }); }); const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => @@ -332,6 +360,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => allowRecovery: true, }); yield* routed.adapter.respondToRequest(routed.threadId, input.requestId, input.decision); + yield* analytics.record("provider.request.responded", { + provider: routed.adapter.provider, + decision: input.decision, + }); }); const respondToUserInput: ProviderServiceShape["respondToUserInput"] = (rawInput) => @@ -347,6 +379,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => allowRecovery: true, }); yield* routed.adapter.respondToUserInput(routed.threadId, input.requestId, input.answers); + yield* analytics.record("provider.user_input.responded", { + provider: routed.adapter.provider, + }); }); const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => @@ -365,6 +400,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => yield* routed.adapter.stopSession(routed.threadId); } yield* directory.remove(input.threadId); + yield* analytics.record("provider.session.stopped", { + provider: routed.adapter.provider, + wasActive: routed.isActive, + }); }); const listSessions: ProviderServiceShape["listSessions"] = () => @@ -429,6 +468,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => allowRecovery: true, }); yield* routed.adapter.rollbackThread(routed.threadId, input.numTurns); + yield* analytics.record("provider.conversation.rolled_back", { + provider: routed.adapter.provider, + turns: input.numTurns, + }); }); const stopAll: ProviderServiceShape["stopAll"] = () => @@ -451,6 +494,10 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ), ), ).pipe(Effect.asVoid); + yield* analytics.record("provider.sessions.stopped_all", { + sessionCount: threadIds.length, + }); + yield* analytics.flush; }); return { diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index cc4f139577b40e51374d7a5748d7947fbf3bee60..b0630a55b9542edb0f0966a8a0c14686a60445fa 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -34,11 +34,12 @@ import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; import { GitServiceLive } from "./git/Layers/GitService"; import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; export function makeServerProviderLayer(): Layer.Layer< ProviderService, ProviderUnsupportedError, - SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem + SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService > { return Effect.gen(function* () { const { stateDir } = yield* ServerConfig; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec5c927d5be86990e1b7154a88bd052641dc1598 --- /dev/null +++ b/apps/server/src/telemetry/Identify.ts @@ -0,0 +1,77 @@ +import { Effect, FileSystem, Path, Random, Schema } from "effect"; +import * as Crypto from "node:crypto"; +import { homedir } from "node:os"; + +const CodexAuthJsonSchema = Schema.Struct({ + tokens: Schema.Struct({ + account_id: Schema.String, + }), +}); + +class IdentifyUserError extends Schema.TaggedErrorClass()("IdentifyUserError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +const hash = (value: string) => + Effect.try({ + try: () => Crypto.createHash("sha256").update(value).digest("hex"), + catch: (error) => + new IdentifyUserError({ + message: "Failed to hash identifier", + cause: error, + }), + }); + +const getCodexAccountId = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const authJsonPath = path.join(homedir(), ".codex", "auth.json"); + const authJson = yield* Effect.flatMap( + fileSystem.readFileString(authJsonPath), + Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), + ); + + return authJson.tokens.account_id; +}); + +const upsertAnonymousId = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const anonymousIdPath = path.join(homedir(), ".t3", "telemetry", "anonymous-id"); + const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe( + Effect.catch(() => + Effect.gen(function* () { + const randomId = yield* Random.nextUUIDv4; + yield* fileSystem.writeFileString(anonymousIdPath, randomId); + return randomId; + }), + ), + ); + + return anonymousId; +}); + +/** + * getTelemetryIdentifier - Users are "identified" by finding the first match of the following, then hashing the value. + * 1. ~/.codex/auth.json tokens.account_id + * 2. ~/.t3/telemetry/anonymous-id + */ +export const getTelemetryIdentifier = Effect.gen(function* () { + const codexAccountId = yield* getCodexAccountId; + if (codexAccountId) { + return yield* hash(codexAccountId); + } + + const anonymousId = yield* upsertAnonymousId; + if (anonymousId) { + return yield* hash(anonymousId); + } + + return null; +}).pipe( + Effect.tapError((error) => Effect.logWarning("Failed to get identifier", { cause: error })), + Effect.orElseSucceed(() => null), +); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts new file mode 100644 index 0000000000000000000000000000000000000000..e295daa5bc45d69d140fa8240bff8bb735f2f6fb --- /dev/null +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -0,0 +1,137 @@ +/** + * AnalyticsServiceLive - Anonymous PostHog telemetry layer. + * + * Persists a random installation-scoped anonymous id to state dir, buffers + * events in memory, and flushes batches to PostHog over Effect HttpClient. + * + * @module AnalyticsServiceLive + */ + +import { Config, DateTime, Effect, Layer, Ref } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; +import { getTelemetryIdentifier } from "../Identify.ts"; + +interface BufferedAnalyticsEvent { + readonly event: string; + readonly properties?: Readonly>; + readonly capturedAt: string; +} + +const TelemetryEnvConfig = Config.all({ + posthogKey: Config.string("T3CODE_POSTHOG_KEY").pipe( + Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), + ), + posthogHost: Config.string("T3CODE_POSTHOG_HOST").pipe( + Config.withDefault("https://us.i.posthog.com"), + ), + enabled: Config.boolean("T3CODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)), + flushBatchSize: Config.number("T3CODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)), + maxBufferedEvents: Config.number("T3CODE_TELEMETRY_MAX_BUFFERED_EVENTS").pipe( + Config.withDefault(1_000), + ), +}); + +const makeAnalyticsService = Effect.gen(function* () { + const telemetryConfig = yield* TelemetryEnvConfig.asEffect(); + const httpClient = yield* HttpClient.HttpClient; + const identifier = yield* getTelemetryIdentifier; + const bufferRef = yield* Ref.make>([]); + + const enqueueBufferedEvent = (event: string, properties?: Readonly>) => + Effect.flatMap(DateTime.now, (now) => + Ref.modify(bufferRef, (current) => { + const appended = [ + ...current, + { + event, + ...(properties ? { properties } : {}), + capturedAt: DateTime.formatIso(now), + } satisfies BufferedAnalyticsEvent, + ]; + + const next = + appended.length > telemetryConfig.maxBufferedEvents + ? appended.slice(appended.length - telemetryConfig.maxBufferedEvents) + : appended; + + return [ + { + size: next.length, + dropped: next.length !== appended.length, + } as const, + next, + ] as const; + }), + ); + + const sendBatch = (events: ReadonlyArray) => + Effect.gen(function* () { + if (!telemetryConfig.enabled || !identifier) return; + + const payload = { + api_key: telemetryConfig.posthogKey, + batch: events.map((event) => ({ + event: event.event, + distinct_id: identifier, + properties: { + ...event.properties, + $process_person_profile: false, + $lib: "t3code-server", + }, + timestamp: event.capturedAt, + })), + }; + + yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe( + HttpClientRequest.bodyJson(payload), + Effect.flatMap(httpClient.execute), + Effect.flatMap(HttpClientResponse.filterStatusOk), + ); + }); + + const flush: AnalyticsServiceShape["flush"] = Effect.gen(function* () { + const batch = yield* Ref.modify(bufferRef, (current) => { + if (current.length === 0) { + return [[] as ReadonlyArray, current] as const; + } + const nextBatch = current.slice(0, telemetryConfig.flushBatchSize); + const remaining = current.slice(nextBatch.length); + return [nextBatch, remaining] as const; + }); + + if (batch.length === 0) { + return; + } + + yield* sendBatch(batch).pipe( + Effect.catch((error) => + Ref.update(bufferRef, (current) => [...batch, ...current]).pipe( + Effect.flatMap(() => Effect.fail(error)), + ), + ), + ); + }).pipe(Effect.catch((cause) => Effect.logError("Failed to flush telemetry", { cause }))); + + const record: AnalyticsServiceShape["record"] = Effect.fnUntraced(function* (event, properties) { + if (!telemetryConfig.enabled || !identifier) return; + + const enqueueResult = yield* enqueueBufferedEvent(event, properties); + if (enqueueResult.dropped) { + yield* Effect.logDebug("analytics buffer full; dropping oldest event", { + size: enqueueResult.size, + event, + }); + } + }); + + yield* Effect.forever(flush, { disableYield: true }).pipe(Effect.forkDetach); + + return { + record, + flush, + } satisfies AnalyticsServiceShape; +}); + +export const AnalyticsServiceLayerLive = Layer.effect(AnalyticsService, makeAnalyticsService); diff --git a/apps/server/src/telemetry/Services/AnalyticsService.ts b/apps/server/src/telemetry/Services/AnalyticsService.ts new file mode 100644 index 0000000000000000000000000000000000000000..d556daad1567b1442840ff726acb3eb79fa3f6ec --- /dev/null +++ b/apps/server/src/telemetry/Services/AnalyticsService.ts @@ -0,0 +1,28 @@ +/** + * AnalyticsService - Anonymous telemetry capture contract. + * + * Provides a best-effort event API for runtime telemetry and a strict + * `captureImmediate` method for call sites that need explicit error handling. + * + * @module AnalyticsService + */ +import { Effect, ServiceMap } from "effect"; + +export interface AnalyticsServiceShape { + /** + * Capture an event immediately; returns typed failure when capture fails. + */ + readonly record: ( + event: string, + properties?: Readonly>, + ) => Effect.Effect; + + /** + * Flush queued telemetry. + */ + readonly flush: Effect.Effect; +} + +export class AnalyticsService extends ServiceMap.Service()( + "t3/telemetry/Services/AnalyticsService", +) {} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 50e2561c59ccbb93402b39da2872dfc7b9f115b7..12829e5d660cdcc52d22abff630f5d4ca6a2c8df 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -50,6 +50,8 @@ import type { GitCoreShape } from "./git/Services/GitCore.ts"; import { GitCore } from "./git/Services/GitCore.ts"; import { GitCommandError, GitManagerError } from "./git/Errors.ts"; import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; +import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; interface PendingMessages { queue: unknown[]; @@ -393,10 +395,7 @@ describe("WebSocket Server", () => { providerHealth?: ProviderHealthShape; open?: OpenShape; gitManager?: GitManagerShape; - gitCore?: Pick< - GitCoreShape, - "listBranches" | "initRepo" | "pullCurrentBranch" - >; + gitCore?: Pick; terminalManager?: TerminalManagerShape; } = {}, ): Promise { @@ -449,7 +448,9 @@ describe("WebSocket Server", () => { Layer.provideMerge(providerHealthLayer), Layer.provideMerge(openLayer), Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(AnalyticsServiceLayerLive), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(NodeHttpClient.layerUndici), ); const runtimeServices = await Effect.runPromise( Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), @@ -1620,7 +1621,6 @@ describe("WebSocket Server", () => { expect(pullResponse.result).toBeUndefined(); expect(pullResponse.error?.message).toContain("No upstream configured"); expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); - }); it("supports git.status over websocket", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index cd5e842732980431f964b3e0662fbfa7136891e2..0cb29cb9b8b1b51b1aa6bd6fd46a8f3e4d3269ec 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -71,6 +71,7 @@ import { resolveAttachmentPathById, } from "./attachmentStore.ts"; import { parseBase64DataUrl } from "./imageMime.ts"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; /** * ServerShape - Service API for server lifecycle control. @@ -207,7 +208,8 @@ export type ServerRuntimeServices = | GitCore | TerminalManager | Keybindings - | Open; + | Open + | AnalyticsService; export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError",