diff --git a/apps/marketing/app/layout.tsx b/apps/marketing/app/layout.tsx index a113b0a4c..d22b29f5f 100644 --- a/apps/marketing/app/layout.tsx +++ b/apps/marketing/app/layout.tsx @@ -1,7 +1,6 @@ import type React from "react"; import type { Metadata } from "next"; import { Nunito } from "next/font/google"; -import { Analytics } from "@vercel/analytics/next"; import "./globals.css"; const nunito = Nunito({ subsets: ["latin"], variable: "--font-nunito" }); @@ -60,10 +59,7 @@ export default function RootLayout({ }>) { return ( - - {children} - - + {children} ); } diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 6e303bee5..882effbd3 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -41,7 +41,6 @@ "@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-tooltip": "1.1.6", - "@vercel/analytics": "1.3.1", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index f0d266923..b59bfa6de 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -43,7 +43,6 @@ 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"; @@ -284,12 +283,10 @@ export const makeOrchestrationIntegrationHarness = ( ? makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(realCodexRegistry), - Layer.provide(AnalyticsService.layerTest), ) : makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), Layer.provide(fakeRegistry!), - Layer.provide(AnalyticsService.layerTest), ); const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 7ef61f4ac..7576896ba 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -12,7 +12,6 @@ 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"; @@ -60,7 +59,6 @@ const makeIntegrationFixture = Effect.gen(function* () { const shared = Layer.mergeAll( directoryLayer, Layer.succeed(ProviderAdapterRegistry, registry), - AnalyticsService.layerTest, ).pipe(Layer.provide(SqlitePersistenceMemory)); const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index caeb58625..0cdc05cb3 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -1,7 +1,6 @@ import * as Http from "node:http"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it, vi } from "@effect/vitest"; -import type { OrchestrationReadModel } from "@okcode/contracts"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -10,11 +9,9 @@ import { FetchHttpClient } from "effect/unstable/http"; import { beforeEach } from "vitest"; import { NetService } from "@okcode/shared/Net"; -import { CliConfig, recordStartupHeartbeat, okcodeCli, type CliConfigShape } from "./main"; +import { CliConfig, okcodeCli, type CliConfigShape } from "./main"; import { ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { Server, type ServerShape } from "./wsServer"; const start = vi.fn(() => undefined); @@ -53,7 +50,6 @@ const testLayer = Layer.mergeAll( openInFileManager: () => Effect.void, revealInFileManager: () => Effect.void, } satisfies OpenShape), - AnalyticsService.layerTest, FetchHttpClient.layer, NodeServices.layer, ); @@ -238,43 +234,6 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); - it.effect("records a startup heartbeat with thread/project counts", () => - Effect.gen(function* () { - const recordTelemetry = vi.fn( - (_event: string, _properties?: Readonly>) => Effect.void, - ); - const getSnapshot = vi.fn(() => - Effect.succeed({ - snapshotSequence: 2, - projects: [{} as OrchestrationReadModel["projects"][number]], - threads: [ - {} as OrchestrationReadModel["threads"][number], - {} as OrchestrationReadModel["threads"][number], - ], - updatedAt: new Date(1).toISOString(), - } satisfies OrchestrationReadModel), - ); - - yield* recordStartupHeartbeat.pipe( - Effect.provideService(ProjectionSnapshotQuery, { - getSnapshot, - }), - Effect.provideService(AnalyticsService, { - record: recordTelemetry, - flush: Effect.void, - }), - ); - - assert.deepEqual(recordTelemetry.mock.calls[0], [ - "server.boot.heartbeat", - { - threadCount: 2, - projectCount: 1, - }, - ]); - }), - ); - it.effect("does not start server for invalid --mode values", () => Effect.gen(function* () { yield* runCli(["--mode", "invalid"]); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 27efcfb6f..a48ac4b58 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -21,12 +21,9 @@ import { fixPath, resolveBaseDir } from "./os-jank"; import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { Server } from "./wsServer"; import { ServerLoggerLive } from "./serverLogger"; -import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; import { doctorCmd } from "./doctor"; export class StartupError extends Data.TaggedError("StartupError")<{ @@ -199,7 +196,6 @@ const LayerLive = (input: CliInput) => Layer.provideMerge(ProviderHealthLive), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerLoggerLive), - Layer.provideMerge(AnalyticsServiceLayerLive), Layer.provideMerge(ServerConfigLive(input)), ); @@ -209,31 +205,6 @@ const isWildcardHost = (host: string | undefined): boolean => const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; -export const recordStartupHeartbeat = Effect.gen(function* () { - const analytics = yield* AnalyticsService; - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - - const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe( - Effect.map((snapshot) => ({ - threadCount: snapshot.threads.length, - projectCount: snapshot.projects.length, - })), - Effect.catch((cause) => - Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( - Effect.as({ - threadCount: 0, - projectCount: 0, - }), - ), - ), - ); - - yield* analytics.record("server.boot.heartbeat", { - threadCount, - projectCount, - }); -}); - const makeServerProgram = (input: CliInput) => Effect.gen(function* () { const cliConfig = yield* CliConfig; @@ -253,7 +224,6 @@ const makeServerProgram = (input: CliInput) => } yield* start; - yield* Effect.forkChild(recordStartupHeartbeat); const localUrl = `http://localhost:${config.port}`; const bindUrl = diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 2f241715e..a639b956c 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -42,7 +42,6 @@ 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); @@ -251,7 +250,6 @@ function makeProviderServiceLayer() { makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provideMerge(AnalyticsService.layerTest), ), directoryLayer, @@ -299,7 +297,6 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const providerLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), - Layer.provide(AnalyticsService.layerTest), ); yield* Effect.gen(function* () { @@ -358,7 +355,6 @@ it.effect( const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), - Layer.provide(AnalyticsService.layerTest), ); const updatedResumeCursor = { threadId: asThreadId("thread-1"), @@ -409,7 +405,6 @@ it.effect( const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), - Layer.provide(AnalyticsService.layerTest), ); secondCodex.startSession.mockClear(); @@ -765,7 +760,6 @@ routing.layer("ProviderServiceLive routing", (it) => { const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), - Layer.provide(AnalyticsService.layerTest), ); const initial = yield* Effect.gen(function* () { @@ -797,7 +791,6 @@ routing.layer("ProviderServiceLive routing", (it) => { const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), - Layer.provide(AnalyticsService.layerTest), ); secondClaude.startSession.mockClear(); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 67deff5e6..ec1855f29 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -31,7 +31,6 @@ 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; @@ -145,7 +144,6 @@ function readPersistedCwd( const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.gen(function* () { - const analytics = yield* Effect.service(AnalyticsService); const canonicalEventLogger = options?.canonicalEventLogger ?? (options?.canonicalEventLogPath !== undefined @@ -221,11 +219,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ); 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; } } @@ -258,11 +251,6 @@ 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; }); @@ -331,13 +319,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => modelOptions: input.modelOptions, providerOptions: input.providerOptions, }); - yield* analytics.record("provider.session.started", { - provider: session.provider, - runtimeMode: input.runtimeMode, - hasResumeCursor: session.resumeCursor !== undefined, - hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0, - hasModel: typeof input.model === "string" && input.model.trim().length > 0, - }); return session; }); @@ -377,13 +358,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => lastRuntimeEventAt: new Date().toISOString(), }, }); - yield* analytics.record("provider.turn.sent", { - provider: routed.adapter.provider, - model: input.model, - interactionMode: input.interactionMode, - attachmentCount: input.attachments.length, - hasInput: typeof input.input === "string" && input.input.trim().length > 0, - }); return turn; }); @@ -400,9 +374,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => allowRecovery: true, }); yield* routed.adapter.interruptTurn(routed.threadId, input.turnId); - yield* analytics.record("provider.turn.interrupted", { - provider: routed.adapter.provider, - }); }); const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => @@ -418,10 +389,6 @@ 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) => @@ -455,9 +422,6 @@ 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, - }); }); const listSessions: ProviderServiceShape["listSessions"] = () => @@ -526,10 +490,6 @@ 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 runStopAll = () => @@ -563,10 +523,6 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ), ), ).pipe(Effect.asVoid); - yield* analytics.record("provider.sessions.stopped_all", { - sessionCount: threadIds.length, - }); - yield* analytics.flush; }); yield* Effect.addFinalizer(() => diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 245779315..3036b65ce 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -39,7 +39,6 @@ import { WorkflowEngineLive } from "./prReview/Layers/WorkflowEngine"; import { MergeConflictResolverLive } from "./prReview/Layers/MergeConflictResolver"; import { PrReviewLive } from "./prReview/Layers/PrReview"; import { PtyAdapter } from "./terminal/Services/PTY"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; type RuntimePtyAdapterLoader = { layer: Layer.Layer; @@ -61,7 +60,7 @@ const makeRuntimePtyAdapterLayer = () => export function makeServerProviderLayer(): Layer.Layer< ProviderService, ProviderUnsupportedError, - SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService + SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem > { return Effect.gen(function* () { const { providerEventLogPath } = yield* ServerConfig; diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts deleted file mode 100644 index 15b87adbc..000000000 --- a/apps/server/src/telemetry/Identify.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Effect, FileSystem, Path, Random, Schema } from "effect"; -import * as Crypto from "node:crypto"; -import { homedir } from "node:os"; -import { ServerConfig } from "../config"; - -const CodexAuthJsonSchema = Schema.Struct({ - tokens: Schema.Struct({ - account_id: Schema.String, - }), -}); - -const ClaudeJsonSchema = Schema.Struct({ - userID: 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 getClaudeUserId = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const claudeJsonPath = path.join(homedir(), ".claude.json"); - const claudeJson = yield* Effect.flatMap( - fileSystem.readFileString(claudeJsonPath), - Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), - ); - - return claudeJson.userID; -}); - -const upsertAnonymousId = Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const { anonymousIdPath } = yield* ServerConfig; - - 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. ~/.claude.json userID - * 3. ~/.okcode/telemetry/anonymous-id - */ -export const getTelemetryIdentifier = Effect.gen(function* () { - const codexAccountId = yield* Effect.result(getCodexAccountId); - if (codexAccountId._tag === "Success") { - return yield* hash(codexAccountId.success); - } - - const claudeUserId = yield* Effect.result(getClaudeUserId); - if (claudeUserId._tag === "Success") { - return yield* hash(claudeUserId.success); - } - - const anonymousId = yield* Effect.result(upsertAnonymousId); - if (anonymousId._tag === "Success") { - return yield* hash(anonymousId.success); - } - - 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.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts deleted file mode 100644 index 80caae5a2..000000000 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Layer } from "effect"; -import * as HttpServer from "effect/unstable/http/HttpServer"; -import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; -import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; - -import { ServerConfig } from "../../config.ts"; -import { getTelemetryIdentifier } from "../Identify.ts"; -import { AnalyticsService } from "../Services/AnalyticsService.ts"; -import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts"; - -interface RecordedBatchRequest { - readonly path: string; - readonly body: { - readonly batch?: ReadonlyArray<{ - readonly event?: string; - readonly properties?: { - readonly index?: number; - readonly clientType?: string; - }; - }>; - } | null; -} - -interface RecordedBatchBody { - readonly batch: ReadonlyArray<{ - readonly event?: string; - readonly properties?: { - readonly index?: number; - readonly clientType?: string; - }; - }>; -} - -it.layer(NodeServices.layer)("AnalyticsService test", (it) => { - it.effect("flush drains all buffered events across multiple batches", () => - Effect.gen(function* () { - const capturedRequests: Array = []; - const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-telemetry-base-", - }); - - const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer)); - const configLayer = ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OKCODE_TELEMETRY_ENABLED: true, - OKCODE_POSTHOG_KEY: "phc_test_key", - OKCODE_POSTHOG_HOST: "", - OKCODE_TELEMETRY_FLUSH_BATCH_SIZE: 20, - }), - ); - const batchServerLayer = HttpServer.serve( - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - if (request.method !== "POST") { - return HttpServerResponse.empty({ status: 404 }); - } - - const payload = yield* request.json.pipe( - Effect.map((body) => body as RecordedBatchRequest["body"]), - Effect.catch(() => Effect.succeed(null)), - ); - - capturedRequests.push({ path: request.url, body: payload }); - - return HttpServerResponse.jsonUnsafe({}); - }), - ); - const runtimeLayer = telemetryLayer.pipe( - Layer.provide(configLayer), - Layer.provideMerge(NodeHttpServer.layerTest), - ); - - yield* Effect.gen(function* () { - yield* Layer.launch(batchServerLayer).pipe(Effect.forkScoped); - const telemetryIdentifier = yield* getTelemetryIdentifier; - assert.equal(telemetryIdentifier !== null, true); - const analytics = yield* AnalyticsService; - - for (let index = 0; index < 45; index += 1) { - yield* analytics.record("test.flush.drain", { index }); - } - - yield* analytics.flush; - }).pipe(Effect.provide(runtimeLayer)); - - const batchRequests = capturedRequests.filter( - (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => - Array.isArray(request.body?.batch), - ); - assert.equal(batchRequests.length, 3); - assert.equal( - batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"), - true, - ); - const deliveredIndexes = batchRequests.flatMap((request) => - request.body.batch - .filter((event) => event.event === "test.flush.drain") - .map((event) => event.properties?.index) - .filter((index): index is number => typeof index === "number"), - ); - - const sorted = deliveredIndexes.toSorted((a, b) => a - b); - assert.equal(sorted.length, 45); - assert.deepEqual( - sorted, - Array.from({ length: 45 }, (_, index) => index), - ); - assert.equal( - batchRequests.every((request) => - request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"), - ), - true, - ); - }), - ); -}); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts deleted file mode 100644 index f6b2ee425..000000000 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * 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 { ServerConfig } from "../../config.ts"; -import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; -import { getTelemetryIdentifier } from "../Identify.ts"; -import { version } from "../../../package.json" with { type: "json" }; - -interface BufferedAnalyticsEvent { - readonly event: string; - readonly properties?: Readonly>; - readonly capturedAt: string; -} - -const TelemetryEnvConfig = Config.all({ - posthogKey: Config.string("OKCODE_POSTHOG_KEY").pipe( - Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), - ), - posthogHost: Config.string("OKCODE_POSTHOG_HOST").pipe( - Config.withDefault("https://us.i.posthog.com"), - ), - enabled: Config.boolean("OKCODE_TELEMETRY_ENABLED").pipe(Config.withDefault(true)), - flushBatchSize: Config.number("OKCODE_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)), - maxBufferedEvents: Config.number("OKCODE_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 serverConfig = yield* ServerConfig; - const identifier = yield* getTelemetryIdentifier; - const bufferRef = yield* Ref.make>([]); - const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; - - 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, - platform: process.platform, - wsl: process.env.WSL_DISTRO_NAME, - arch: process.arch, - okCodeVersion: version, - clientType, - }, - 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* () { - while (true) { - 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(Effect.sleep(1000).pipe(Effect.flatMap(() => flush)), { - disableYield: true, - }).pipe(Effect.forkScoped); - - yield* Effect.addFinalizer(() => flush); - - 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 deleted file mode 100644 index 74f672626..000000000 --- a/apps/server/src/telemetry/Services/AnalyticsService.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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, Layer, 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()( - "okcode/telemetry/Services/AnalyticsService", -) { - static readonly layerTest = Layer.succeed(AnalyticsService, { - record: () => Effect.void, - flush: Effect.void, - }); -} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 43bb2bd84..2a76010b5 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -54,7 +54,6 @@ import type { GitCoreShape } from "./git/Services/GitCore.ts"; import { GitCore } from "./git/Services/GitCore.ts"; import { GitActionExecutionError, GitCommandError } from "./git/Errors.ts"; import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); @@ -550,7 +549,6 @@ describe("WebSocket Server", () => { Layer.provideMerge(providerHealthLayer), Layer.provideMerge(openLayer), Layer.provideMerge(serverConfigLayer), - Layer.provideMerge(AnalyticsService.layerTest), Layer.provideMerge(NodeServices.layer), ); const runtimeServices = await Effect.runPromise( diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 367fc5d53..610df1adb 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -78,7 +78,6 @@ import { } from "./attachmentStore.ts"; import { parseBase64DataUrl } from "./imageMime.ts"; import { extractTextAttachmentContents } from "./attachmentText.ts"; -import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import { expandHomePath } from "./os-jank.ts"; import { makeServerPushBus } from "./wsServer/pushBus.ts"; import { makeServerReadiness } from "./wsServer/readiness.ts"; @@ -289,7 +288,6 @@ export type ServerRuntimeServices = | Keybindings | SkillService | Open - | AnalyticsService | EnvironmentVariables; export class ServerLifecycleError extends Schema.TaggedErrorClass()( diff --git a/bun.lock b/bun.lock index 17780673f..1ff4b1133 100644 --- a/bun.lock +++ b/bun.lock @@ -67,7 +67,6 @@ "@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-tooltip": "1.1.6", - "@vercel/analytics": "1.3.1", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -1160,8 +1159,6 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - "@vercel/analytics": ["@vercel/analytics@1.3.1", "", { "dependencies": { "server-only": "^0.0.1" }, "peerDependencies": { "next": ">= 13", "react": "^18 || ^19" }, "optionalPeers": ["next", "react"] }, "sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="], @@ -2104,8 +2101,6 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],