diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts new file mode 100644 index 00000000000..ab151821789 --- /dev/null +++ b/apps/server/src/telemetry/Identify.test.ts @@ -0,0 +1,172 @@ +import * as NodeCrypto from "node:crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Path from "effect/Path"; +import * as References from "effect/References"; + +import * as ServerConfig from "../config.ts"; +import * as Identify from "./Identify.ts"; + +interface CapturedLog { + readonly message: unknown; + readonly annotations: Readonly>; +} + +const sha256 = (value: string) => + NodeCrypto.createHash("sha256").update(value, "utf8").digest("hex"); + +const makeCaptureLogger = (logs: CapturedLog[]) => + Logger.make(({ fiber, message }) => { + logs.push({ + message, + annotations: fiber.getRef(References.CurrentLogAnnotations), + }); + }); + +const findIdentityLog = ( + logs: ReadonlyArray, + source: Identify.TelemetryIdentitySource, + errorTag: string, +) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag); + +it("preserves exact telemetry identity causes without deriving messages from them", () => { + const decodeCause = new Error("private nested decode details"); + const decodeError = new Identify.TelemetryIdentityDecodeError({ + source: "codex", + filePath: "/tmp/auth.json", + cause: decodeCause, + }); + const readCause = new Error("private nested read details"); + const readError = new Identify.TelemetryIdentityReadError({ + source: "anonymous", + filePath: "/tmp/anonymous-id", + cause: readCause, + }); + + assert.strictEqual(decodeError.cause, decodeCause); + assert.strictEqual(readError.cause, readCause); + assert.notInclude(decodeError.message, decodeCause.message); + assert.notInclude(readError.message, readCause.message); +}); + +it.layer(NodeServices.layer)("telemetry identity", (it) => { + it.effect("uses the persisted anonymous id when provider identities are absent", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const anonymousId = "persisted-anonymous-id"; + + yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); + + const identifier = yield* Identify.getTelemetryIdentifierForHome( + path.join(config.baseDir, "home"), + ); + + assert.equal(identifier, sha256(anonymousId)); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-identify-anonymous-", + }), + ), + ), + ); + + it.effect("logs structured decode context and falls back from malformed Codex auth", () => { + const logs: CapturedLog[] = []; + const logger = makeCaptureLogger(logs); + + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDirectory = path.join(config.baseDir, "home"); + const codexAuthPath = path.join(homeDirectory, ".codex", "auth.json"); + const anonymousId = "decode-fallback-anonymous-id"; + const privateAccessToken = "private-codex-access-token"; + + yield* fileSystem.makeDirectory(path.dirname(codexAuthPath), { recursive: true }); + yield* fileSystem.writeFileString( + codexAuthPath, + `{"tokens":{"access_token":"${privateAccessToken}"}}`, + ); + yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId); + + const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); + + assert.equal(identifier, sha256(anonymousId)); + const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError"); + assert.isDefined(decodeLog); + assert.equal( + decodeLog?.message, + `Failed to decode codex telemetry identity at '${codexAuthPath}'.`, + ); + + assert.equal(decodeLog?.annotations.filePath, codexAuthPath); + assert.equal(decodeLog?.annotations.causeKind, "schema"); + assert.notProperty(decodeLog?.annotations ?? {}, "cause"); + const errorStack = decodeLog?.annotations.errorStack; + assert.isString(errorStack); + assert.include(errorStack, "Failed to decode codex telemetry identity"); + const annotations = Object.values(decodeLog?.annotations ?? {}) + .map(String) + .join("\n"); + assert.notInclude(annotations, privateAccessToken); + }).pipe( + Effect.provide( + Layer.merge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-identify-decode-", + }), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); + + it.effect("does not overwrite the anonymous id path after a non-NotFound read failure", () => { + const logs: CapturedLog[] = []; + const logger = makeCaptureLogger(logs); + + return Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homeDirectory = path.join(config.baseDir, "home"); + + yield* fileSystem.makeDirectory(config.anonymousIdPath); + + const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory); + + assert.isNull(identifier); + assert.deepEqual(yield* fileSystem.readDirectory(config.anonymousIdPath), []); + + const readLog = findIdentityLog(logs, "anonymous", "TelemetryIdentityReadError"); + assert.isDefined(readLog); + assert.equal(readLog?.annotations.filePath, config.anonymousIdPath); + assert.equal(readLog?.annotations.causeKind, "platform"); + assert.notEqual(readLog?.annotations.platformReason, "NotFound"); + assert.notProperty(readLog?.annotations ?? {}, "cause"); + const errorStack = readLog?.annotations.errorStack; + assert.isString(errorStack); + assert.include(errorStack, "Failed to read anonymous telemetry identity"); + assert.isUndefined( + findIdentityLog(logs, "anonymous", "TelemetryAnonymousIdPersistenceError"), + ); + }).pipe( + Effect.provide( + Layer.merge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-identify-read-", + }), + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); +}); diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index f7458bcd8c8..b6c3d0066df 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -3,7 +3,9 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; @@ -18,66 +20,225 @@ const ClaudeJsonSchema = Schema.Struct({ userID: Schema.String, }); -class IdentifyUserError extends Schema.TaggedErrorClass()("IdentifyUserError", { - operation: Schema.Literal("hash_identifier"), - algorithm: Schema.Literal("SHA-256"), - cause: Schema.Defect(), -}) { +export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); +export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; + +export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( + "TelemetryIdentityReadError", + { + source: TelemetryIdentitySource, + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read ${this.source} telemetry identity at '${this.filePath}'.`; + } +} + +export class TelemetryIdentityDecodeError extends Schema.TaggedErrorClass()( + "TelemetryIdentityDecodeError", + { + source: Schema.Literals(["codex", "claude"]), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { override get message(): string { - return `Failed to hash telemetry identifier with ${this.algorithm}.`; + return `Failed to decode ${this.source} telemetry identity at '${this.filePath}'.`; } } -const hash = (value: string) => +export class TelemetryAnonymousIdGenerationError extends Schema.TaggedErrorClass()( + "TelemetryAnonymousIdGenerationError", + { + source: Schema.Literal("anonymous"), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to generate anonymous telemetry identity for '${this.filePath}'.`; + } +} + +export class TelemetryAnonymousIdPersistenceError extends Schema.TaggedErrorClass()( + "TelemetryAnonymousIdPersistenceError", + { + source: Schema.Literal("anonymous"), + filePath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to persist anonymous telemetry identity at '${this.filePath}'.`; + } +} + +export class TelemetryIdentityHashError extends Schema.TaggedErrorClass()( + "TelemetryIdentityHashError", + { + source: TelemetryIdentitySource, + algorithm: Schema.Literal("SHA-256"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to hash ${this.source} telemetry identity with ${this.algorithm}.`; + } +} + +type TelemetryIdentityError = + | TelemetryIdentityReadError + | TelemetryIdentityDecodeError + | TelemetryAnonymousIdGenerationError + | TelemetryAnonymousIdPersistenceError + | TelemetryIdentityHashError; + +const decodeCodexAuthJson = Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)); +const decodeClaudeJson = Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)); + +function isNotFoundError(error: PlatformError.PlatformError): boolean { + return error.reason._tag === "NotFound"; +} + +const getTelemetryIdentityCauseAnnotations = (cause: unknown) => { + if (cause instanceof PlatformError.PlatformError) { + return { + causeKind: "platform", + platformReason: cause.reason._tag, + }; + } + if (cause instanceof Schema.SchemaError) { + return { causeKind: "schema" }; + } + return { causeKind: "other" }; +}; + +const logTelemetryIdentityError = (error: TelemetryIdentityError) => + Effect.logWarning(error.message).pipe( + Effect.annotateLogs({ + errorTag: error._tag, + source: error.source, + ...("filePath" in error ? { filePath: error.filePath } : {}), + ...getTelemetryIdentityCauseAnnotations(error.cause), + ...(error.stack === undefined ? {} : { errorStack: error.stack }), + }), + ); + +const readIdentityFile = ( + fileSystem: FileSystem.FileSystem, + source: TelemetryIdentitySource, + filePath: string, +) => + fileSystem.readFileString(filePath).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (cause) => + isNotFoundError(cause) + ? Effect.succeed(Option.none()) + : Effect.fail( + new TelemetryIdentityReadError({ + source, + filePath, + cause, + }), + ), + }), + ); + +const hash = (source: TelemetryIdentitySource, value: string) => Crypto.Crypto.pipe( Effect.flatMap((crypto) => crypto.digest("SHA-256", new TextEncoder().encode(value))), Effect.map(Encoding.encodeHex), Effect.mapError( (cause) => - new IdentifyUserError({ - operation: "hash_identifier", + new TelemetryIdentityHashError({ + source, algorithm: "SHA-256", cause, }), ), ); -const getCodexAccountId = Effect.gen(function* () { +const getCodexAccountId = Effect.fn("TelemetryIdentity.getCodexAccountId")(function* ( + homeDirectory: string, +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const authJsonPath = path.join(NodeOS.homedir(), ".codex", "auth.json"); - const authJson = yield* Effect.flatMap( - fileSystem.readFileString(authJsonPath), - Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)), + const authJsonPath = path.join(homeDirectory, ".codex", "auth.json"); + const encoded = yield* readIdentityFile(fileSystem, "codex", authJsonPath); + if (Option.isNone(encoded)) { + return Option.none(); + } + const authJson = yield* decodeCodexAuthJson(encoded.value).pipe( + Effect.mapError( + (cause) => + new TelemetryIdentityDecodeError({ + source: "codex", + filePath: authJsonPath, + cause, + }), + ), ); - return authJson.tokens.account_id; + return Option.some(authJson.tokens.account_id); }); -const getClaudeUserId = Effect.gen(function* () { +const getClaudeUserId = Effect.fn("TelemetryIdentity.getClaudeUserId")(function* ( + homeDirectory: string, +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const claudeJsonPath = path.join(NodeOS.homedir(), ".claude.json"); - const claudeJson = yield* Effect.flatMap( - fileSystem.readFileString(claudeJsonPath), - Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)), + const claudeJsonPath = path.join(homeDirectory, ".claude.json"); + const encoded = yield* readIdentityFile(fileSystem, "claude", claudeJsonPath); + if (Option.isNone(encoded)) { + return Option.none(); + } + const claudeJson = yield* decodeClaudeJson(encoded.value).pipe( + Effect.mapError( + (cause) => + new TelemetryIdentityDecodeError({ + source: "claude", + filePath: claudeJsonPath, + cause, + }), + ), ); - return claudeJson.userID; + return Option.some(claudeJson.userID); }); const upsertAnonymousId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const { anonymousIdPath } = yield* ServerConfig.ServerConfig; - const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe( - Effect.catch(() => - Crypto.Crypto.pipe( - Effect.flatMap((crypto) => crypto.randomUUIDv4), - Effect.tap((randomId) => fileSystem.writeFileString(anonymousIdPath, randomId)), - ), + const existing = yield* readIdentityFile(fileSystem, "anonymous", anonymousIdPath); + if (Option.isSome(existing)) { + return existing.value; + } + + const anonymousId = yield* Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4), + Effect.mapError( + (cause) => + new TelemetryAnonymousIdGenerationError({ + source: "anonymous", + filePath: anonymousIdPath, + cause, + }), + ), + ); + yield* fileSystem.writeFileString(anonymousIdPath, anonymousId).pipe( + Effect.mapError( + (cause) => + new TelemetryAnonymousIdPersistenceError({ + source: "anonymous", + filePath: anonymousIdPath, + cause, + }), ), ); @@ -90,24 +251,53 @@ const upsertAnonymousId = Effect.gen(function* () { * 2. ~/.claude.json userID * 3. ~/.t3/telemetry/anonymous-id */ -export const getTelemetryIdentifier = Effect.gen(function* () { - const codexAccountId = yield* Effect.result(getCodexAccountId); - if (codexAccountId._tag === "Success") { - return yield* hash(codexAccountId.success); - } +export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierForHome")( + function* (homeDirectory: string) { + const codexAccountId = yield* getCodexAccountId(homeDirectory).pipe( + Effect.catchTags({ + TelemetryIdentityReadError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryIdentityDecodeError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isSome(codexAccountId)) { + return yield* hash("codex", codexAccountId.value); + } - const claudeUserId = yield* Effect.result(getClaudeUserId); - if (claudeUserId._tag === "Success") { - return yield* hash(claudeUserId.success); - } + const claudeUserId = yield* getClaudeUserId(homeDirectory).pipe( + Effect.catchTags({ + TelemetryIdentityReadError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryIdentityDecodeError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isSome(claudeUserId)) { + return yield* hash("claude", claudeUserId.value); + } - const anonymousId = yield* Effect.result(upsertAnonymousId); - if (anonymousId._tag === "Success") { - return yield* hash(anonymousId.success); - } + const anonymousId = yield* upsertAnonymousId.pipe( + Effect.map(Option.some), + Effect.catchTags({ + TelemetryIdentityReadError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryAnonymousIdGenerationError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + TelemetryAnonymousIdPersistenceError: (error) => + logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), + }), + ); + if (Option.isSome(anonymousId)) { + return yield* hash("anonymous", anonymousId.value); + } - return null; -}).pipe( - Effect.tapError((error) => Effect.logWarning("Failed to get identifier", { cause: error })), + return null; + }, + Effect.tapError(logTelemetryIdentityError), Effect.orElseSucceed(() => null), ); + +export const getTelemetryIdentifier = Effect.suspend(() => + getTelemetryIdentifierForHome(NodeOS.homedir()), +);