From 6248fd2b99719d255035ab0e30d7d0ca921dedd8 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Mon, 20 Jul 2026 19:03:17 +0300 Subject: [PATCH] Route telemetry through ScientFactory gateway --- apps/server/src/telemetry/Identify.ts | 80 +++---------------- .../telemetry/Layers/AnalyticsService.test.ts | 43 +++++++--- .../src/telemetry/Layers/AnalyticsService.ts | 29 ++++--- 3 files changed, 57 insertions(+), 95 deletions(-) diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index 0f9703f80..4935ae531 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -1,59 +1,6 @@ -import { Effect, FileSystem, Path, Random, Schema } from "effect"; -import * as Crypto from "node:crypto"; -import { homedir } from "node:os"; +import { Effect, FileSystem, Random } from "effect"; 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; @@ -68,29 +15,22 @@ const upsertAnonymousId = Effect.gen(function* () { ), ); - return anonymousId; + const trimmed = anonymousId.trim(); + if (trimmed.length > 0) return trimmed; + + const randomId = yield* Random.nextUUIDv4; + yield* fileSystem.writeFileString(anonymousIdPath, randomId); + return randomId; }); /** - * 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. SYNARA_HOME anonymous-id file (for example ~/.synara/userdata/anonymous-id) + * Returns a random installation-scoped identifier stored in Scient's state directory. + * It never reads or derives identity from connected AI-provider accounts. */ 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 `installation:${anonymousId.success}`; } return null; diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts index ebcf2efde..9159ffb71 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts @@ -14,8 +14,14 @@ import { AnalyticsServiceLayerLive } from "./AnalyticsService.ts"; interface RecordedBatchRequest { readonly path: string; readonly body: { - readonly batch?: ReadonlyArray<{ - readonly event?: string; + readonly schema_version?: number; + readonly source?: string; + readonly events?: ReadonlyArray<{ + readonly id?: string; + readonly name?: string; + readonly distinct_id?: string; + readonly occurred_at?: string; + readonly privacy_level?: string; readonly properties?: { readonly index?: number; readonly clientType?: string; @@ -25,8 +31,13 @@ interface RecordedBatchRequest { } interface RecordedBatchBody { - readonly batch: ReadonlyArray<{ - readonly event?: string; + readonly schema_version: number; + readonly source: string; + readonly events: ReadonlyArray<{ + readonly id?: string; + readonly name?: string; + readonly distinct_id?: string; + readonly privacy_level?: string; readonly properties?: { readonly index?: number; readonly clientType?: string; @@ -46,8 +57,7 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { const configLayer = ConfigProvider.layer( ConfigProvider.fromUnknown({ SYNARA_TELEMETRY_ENABLED: true, - SYNARA_POSTHOG_KEY: "phc_test_key", - SYNARA_POSTHOG_HOST: "", + SYNARA_TELEMETRY_ENDPOINT: "/v1/events", SYNARA_TELEMETRY_FLUSH_BATCH_SIZE: 20, }), ); @@ -88,16 +98,16 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { const batchRequests = capturedRequests.filter( (request): request is RecordedBatchRequest & { readonly body: RecordedBatchBody } => - Array.isArray(request.body?.batch), + Array.isArray(request.body?.events), ); assert.equal(batchRequests.length, 3); assert.equal( - batchRequests.every((request) => request.path === "/batch/" || request.path === "/batch"), + batchRequests.every((request) => request.path === "/v1/events"), true, ); const deliveredIndexes = batchRequests.flatMap((request) => - request.body.batch - .filter((event) => event.event === "test.flush.drain") + request.body.events + .filter((event) => event.name === "test.flush.drain") .map((event) => event.properties?.index) .filter((index): index is number => typeof index === "number"), ); @@ -109,8 +119,17 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { Array.from({ length: 45 }, (_, index) => index), ); assert.equal( - batchRequests.every((request) => - request.body.batch.every((event) => event.properties?.clientType === "cli-web-client"), + batchRequests.every( + (request) => + request.body.schema_version === 1 && + request.body.source === "desktop" && + request.body.events.every( + (event) => + event.properties?.clientType === "cli-web-client" && + event.privacy_level === "product" && + event.distinct_id?.startsWith("installation:") === true && + typeof event.id === "string", + ), ), true, ); diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.ts b/apps/server/src/telemetry/Layers/AnalyticsService.ts index 035c050ee..95e5adb8d 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.ts @@ -1,14 +1,15 @@ /** - * AnalyticsServiceLive - Anonymous PostHog telemetry layer. + * AnalyticsServiceLive - First-party ScientFactory telemetry layer. * * Persists a random installation-scoped anonymous id to state dir, buffers - * events in memory, and flushes batches to PostHog over Effect HttpClient. + * events in memory, and flushes batches to the ScientFactory event gateway. * * @module AnalyticsServiceLive */ import { Config, DateTime, Effect, Layer, Ref } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { randomUUID } from "node:crypto"; import { ServerConfig } from "../../config.ts"; import { AnalyticsService, type AnalyticsServiceShape } from "../Services/AnalyticsService.ts"; @@ -16,17 +17,15 @@ import { getTelemetryIdentifier } from "../Identify.ts"; import { version } from "../../../package.json" with { type: "json" }; interface BufferedAnalyticsEvent { + readonly id: string; readonly event: string; readonly properties?: Readonly>; readonly capturedAt: string; } const TelemetryEnvConfig = Config.all({ - posthogKey: Config.string("SYNARA_POSTHOG_KEY").pipe( - Config.withDefault("phc_XOWci4oZP4VvLiEyrFqkFjP4CZn55mjYYBMREK5Wd6m"), - ), - posthogHost: Config.string("SYNARA_POSTHOG_HOST").pipe( - Config.withDefault("https://us.i.posthog.com"), + endpoint: Config.string("SYNARA_TELEMETRY_ENDPOINT").pipe( + Config.withDefault("https://events.scientfactory.com/v1/events"), ), enabled: Config.boolean("SYNARA_TELEMETRY_ENABLED").pipe(Config.withDefault(true)), flushBatchSize: Config.number("SYNARA_TELEMETRY_FLUSH_BATCH_SIZE").pipe(Config.withDefault(20)), @@ -49,6 +48,7 @@ const makeAnalyticsService = Effect.gen(function* () { const appended = [ ...current, { + id: randomUUID(), event, ...(properties ? { properties } : {}), capturedAt: DateTime.formatIso(now), @@ -75,24 +75,27 @@ const makeAnalyticsService = Effect.gen(function* () { if (!telemetryConfig.enabled || !identifier) return; const payload = { - api_key: telemetryConfig.posthogKey, - batch: events.map((event) => ({ - event: event.event, + schema_version: 1, + source: "desktop", + sent_at: new Date().toISOString(), + events: events.map((event) => ({ + id: event.id, + name: event.event, distinct_id: identifier, + occurred_at: event.capturedAt, + privacy_level: "product", properties: { ...event.properties, - $process_person_profile: false, platform: process.platform, wsl: process.env.WSL_DISTRO_NAME, arch: process.arch, synaraCodeVersion: version, clientType, }, - timestamp: event.capturedAt, })), }; - yield* HttpClientRequest.post(`${telemetryConfig.posthogHost}/batch/`).pipe( + yield* HttpClientRequest.post(telemetryConfig.endpoint).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.flatMap(HttpClientResponse.filterStatusOk),