authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:50:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:50:58-07:00
log46fdc769d90fc46efd69f4954765e64030353199
tree6d5a8af51daad83bb7846fd37d2cf81908d5490f
parent0cd0ed1a3dc3280cf0be6128b377abc095ba806f
signature Signed by PGP key B5690EEEBB952194

[codex] Structure telemetry identity errors (#3306)

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 405 insertions(+), 43 deletions(-)

apps/server/src/telemetry/Identify.test.ts created+172
...@@ -0,0 +1,172 @@
1import * as NodeCrypto from "node:crypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Logger from "effect/Logger";
8import * as Path from "effect/Path";
9import * as References from "effect/References";
10
11import * as ServerConfig from "../config.ts";
12import * as Identify from "./Identify.ts";
13
14interface CapturedLog {
15 readonly message: unknown;
16 readonly annotations: Readonly<Record<string, unknown>>;
17}
18
19const sha256 = (value: string) =>
20 NodeCrypto.createHash("sha256").update(value, "utf8").digest("hex");
21
22const makeCaptureLogger = (logs: CapturedLog[]) =>
23 Logger.make(({ fiber, message }) => {
24 logs.push({
25 message,
26 annotations: fiber.getRef(References.CurrentLogAnnotations),
27 });
28 });
29
30const findIdentityLog = (
31 logs: ReadonlyArray<CapturedLog>,
32 source: Identify.TelemetryIdentitySource,
33 errorTag: string,
34) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag);
35
36it("preserves exact telemetry identity causes without deriving messages from them", () => {
37 const decodeCause = new Error("private nested decode details");
38 const decodeError = new Identify.TelemetryIdentityDecodeError({
39 source: "codex",
40 filePath: "/tmp/auth.json",
41 cause: decodeCause,
42 });
43 const readCause = new Error("private nested read details");
44 const readError = new Identify.TelemetryIdentityReadError({
45 source: "anonymous",
46 filePath: "/tmp/anonymous-id",
47 cause: readCause,
48 });
49
50 assert.strictEqual(decodeError.cause, decodeCause);
51 assert.strictEqual(readError.cause, readCause);
52 assert.notInclude(decodeError.message, decodeCause.message);
53 assert.notInclude(readError.message, readCause.message);
54});
55
56it.layer(NodeServices.layer)("telemetry identity", (it) => {
57 it.effect("uses the persisted anonymous id when provider identities are absent", () =>
58 Effect.gen(function* () {
59 const config = yield* ServerConfig.ServerConfig;
60 const fileSystem = yield* FileSystem.FileSystem;
61 const path = yield* Path.Path;
62 const anonymousId = "persisted-anonymous-id";
63
64 yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId);
65
66 const identifier = yield* Identify.getTelemetryIdentifierForHome(
67 path.join(config.baseDir, "home"),
68 );
69
70 assert.equal(identifier, sha256(anonymousId));
71 }).pipe(
72 Effect.provide(
73 ServerConfig.layerTest(process.cwd(), {
74 prefix: "t3-telemetry-identify-anonymous-",
75 }),
76 ),
77 ),
78 );
79
80 it.effect("logs structured decode context and falls back from malformed Codex auth", () => {
81 const logs: CapturedLog[] = [];
82 const logger = makeCaptureLogger(logs);
83
84 return Effect.gen(function* () {
85 const config = yield* ServerConfig.ServerConfig;
86 const fileSystem = yield* FileSystem.FileSystem;
87 const path = yield* Path.Path;
88 const homeDirectory = path.join(config.baseDir, "home");
89 const codexAuthPath = path.join(homeDirectory, ".codex", "auth.json");
90 const anonymousId = "decode-fallback-anonymous-id";
91 const privateAccessToken = "private-codex-access-token";
92
93 yield* fileSystem.makeDirectory(path.dirname(codexAuthPath), { recursive: true });
94 yield* fileSystem.writeFileString(
95 codexAuthPath,
96 `{"tokens":{"access_token":"${privateAccessToken}"}}`,
97 );
98 yield* fileSystem.writeFileString(config.anonymousIdPath, anonymousId);
99
100 const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory);
101
102 assert.equal(identifier, sha256(anonymousId));
103 const decodeLog = findIdentityLog(logs, "codex", "TelemetryIdentityDecodeError");
104 assert.isDefined(decodeLog);
105 assert.equal(
106 decodeLog?.message,
107 `Failed to decode codex telemetry identity at '${codexAuthPath}'.`,
108 );
109
110 assert.equal(decodeLog?.annotations.filePath, codexAuthPath);
111 assert.equal(decodeLog?.annotations.causeKind, "schema");
112 assert.notProperty(decodeLog?.annotations ?? {}, "cause");
113 const errorStack = decodeLog?.annotations.errorStack;
114 assert.isString(errorStack);
115 assert.include(errorStack, "Failed to decode codex telemetry identity");
116 const annotations = Object.values(decodeLog?.annotations ?? {})
117 .map(String)
118 .join("\n");
119 assert.notInclude(annotations, privateAccessToken);
120 }).pipe(
121 Effect.provide(
122 Layer.merge(
123 ServerConfig.layerTest(process.cwd(), {
124 prefix: "t3-telemetry-identify-decode-",
125 }),
126 Logger.layer([logger], { mergeWithExisting: false }),
127 ),
128 ),
129 );
130 });
131
132 it.effect("does not overwrite the anonymous id path after a non-NotFound read failure", () => {
133 const logs: CapturedLog[] = [];
134 const logger = makeCaptureLogger(logs);
135
136 return Effect.gen(function* () {
137 const config = yield* ServerConfig.ServerConfig;
138 const fileSystem = yield* FileSystem.FileSystem;
139 const path = yield* Path.Path;
140 const homeDirectory = path.join(config.baseDir, "home");
141
142 yield* fileSystem.makeDirectory(config.anonymousIdPath);
143
144 const identifier = yield* Identify.getTelemetryIdentifierForHome(homeDirectory);
145
146 assert.isNull(identifier);
147 assert.deepEqual(yield* fileSystem.readDirectory(config.anonymousIdPath), []);
148
149 const readLog = findIdentityLog(logs, "anonymous", "TelemetryIdentityReadError");
150 assert.isDefined(readLog);
151 assert.equal(readLog?.annotations.filePath, config.anonymousIdPath);
152 assert.equal(readLog?.annotations.causeKind, "platform");
153 assert.notEqual(readLog?.annotations.platformReason, "NotFound");
154 assert.notProperty(readLog?.annotations ?? {}, "cause");
155 const errorStack = readLog?.annotations.errorStack;
156 assert.isString(errorStack);
157 assert.include(errorStack, "Failed to read anonymous telemetry identity");
158 assert.isUndefined(
159 findIdentityLog(logs, "anonymous", "TelemetryAnonymousIdPersistenceError"),
160 );
161 }).pipe(
162 Effect.provide(
163 Layer.merge(
164 ServerConfig.layerTest(process.cwd(), {
165 prefix: "t3-telemetry-identify-read-",
166 }),
167 Logger.layer([logger], { mergeWithExisting: false }),
168 ),
169 ),
170 );
171 });
172});
apps/server/src/telemetry/Identify.ts+233-43
...@@ -3,7 +3,9 @@ import * as Crypto from "effect/Crypto";...@@ -3,7 +3,9 @@ import * as Crypto from "effect/Crypto";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Encoding from "effect/Encoding";4import * as Encoding from "effect/Encoding";
5import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
6import * as Path from "effect/Path";7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
7import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
810
9import * as ServerConfig from "../config.ts";11import * as ServerConfig from "../config.ts";
...@@ -18,66 +20,225 @@ const ClaudeJsonSchema = Schema.Struct({...@@ -18,66 +20,225 @@ const ClaudeJsonSchema = Schema.Struct({
18 userID: Schema.String,20 userID: Schema.String,
19});21});
2022
21class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", {23export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]);
22 operation: Schema.Literal("hash_identifier"),24export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type;
23 algorithm: Schema.Literal("SHA-256"),25
24 cause: Schema.Defect(),26export class TelemetryIdentityReadError extends Schema.TaggedErrorClass<TelemetryIdentityReadError>()(
25}) {27 "TelemetryIdentityReadError",
28 {
29 source: TelemetryIdentitySource,
30 filePath: Schema.String,
31 cause: Schema.Defect(),
32 },
33) {
34 override get message(): string {
35 return `Failed to read ${this.source} telemetry identity at '${this.filePath}'.`;
36 }
37}
38
39export class TelemetryIdentityDecodeError extends Schema.TaggedErrorClass<TelemetryIdentityDecodeError>()(
40 "TelemetryIdentityDecodeError",
41 {
42 source: Schema.Literals(["codex", "claude"]),
43 filePath: Schema.String,
44 cause: Schema.Defect(),
45 },
46) {
26 override get message(): string {47 override get message(): string {
27 return `Failed to hash telemetry identifier with ${this.algorithm}.`;48 return `Failed to decode ${this.source} telemetry identity at '${this.filePath}'.`;
28 }49 }
29}50}
3051
31const hash = (value: string) =>52export class TelemetryAnonymousIdGenerationError extends Schema.TaggedErrorClass<TelemetryAnonymousIdGenerationError>()(
53 "TelemetryAnonymousIdGenerationError",
54 {
55 source: Schema.Literal("anonymous"),
56 filePath: Schema.String,
57 cause: Schema.Defect(),
58 },
59) {
60 override get message(): string {
61 return `Failed to generate anonymous telemetry identity for '${this.filePath}'.`;
62 }
63}
64
65export class TelemetryAnonymousIdPersistenceError extends Schema.TaggedErrorClass<TelemetryAnonymousIdPersistenceError>()(
66 "TelemetryAnonymousIdPersistenceError",
67 {
68 source: Schema.Literal("anonymous"),
69 filePath: Schema.String,
70 cause: Schema.Defect(),
71 },
72) {
73 override get message(): string {
74 return `Failed to persist anonymous telemetry identity at '${this.filePath}'.`;
75 }
76}
77
78export class TelemetryIdentityHashError extends Schema.TaggedErrorClass<TelemetryIdentityHashError>()(
79 "TelemetryIdentityHashError",
80 {
81 source: TelemetryIdentitySource,
82 algorithm: Schema.Literal("SHA-256"),
83 cause: Schema.Defect(),
84 },
85) {
86 override get message(): string {
87 return `Failed to hash ${this.source} telemetry identity with ${this.algorithm}.`;
88 }
89}
90
91type TelemetryIdentityError =
92 | TelemetryIdentityReadError
93 | TelemetryIdentityDecodeError
94 | TelemetryAnonymousIdGenerationError
95 | TelemetryAnonymousIdPersistenceError
96 | TelemetryIdentityHashError;
97
98const decodeCodexAuthJson = Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema));
99const decodeClaudeJson = Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema));
100
101function isNotFoundError(error: PlatformError.PlatformError): boolean {
102 return error.reason._tag === "NotFound";
103}
104
105const getTelemetryIdentityCauseAnnotations = (cause: unknown) => {
106 if (cause instanceof PlatformError.PlatformError) {
107 return {
108 causeKind: "platform",
109 platformReason: cause.reason._tag,
110 };
111 }
112 if (cause instanceof Schema.SchemaError) {
113 return { causeKind: "schema" };
114 }
115 return { causeKind: "other" };
116};
117
118const logTelemetryIdentityError = (error: TelemetryIdentityError) =>
119 Effect.logWarning(error.message).pipe(
120 Effect.annotateLogs({
121 errorTag: error._tag,
122 source: error.source,
123 ...("filePath" in error ? { filePath: error.filePath } : {}),
124 ...getTelemetryIdentityCauseAnnotations(error.cause),
125 ...(error.stack === undefined ? {} : { errorStack: error.stack }),
126 }),
127 );
128
129const readIdentityFile = (
130 fileSystem: FileSystem.FileSystem,
131 source: TelemetryIdentitySource,
132 filePath: string,
133) =>
134 fileSystem.readFileString(filePath).pipe(
135 Effect.map(Option.some),
136 Effect.catchTags({
137 PlatformError: (cause) =>
138 isNotFoundError(cause)
139 ? Effect.succeed(Option.none<string>())
140 : Effect.fail(
141 new TelemetryIdentityReadError({
142 source,
143 filePath,
144 cause,
145 }),
146 ),
147 }),
148 );
149
150const hash = (source: TelemetryIdentitySource, value: string) =>
32 Crypto.Crypto.pipe(151 Crypto.Crypto.pipe(
33 Effect.flatMap((crypto) => crypto.digest("SHA-256", new TextEncoder().encode(value))),152 Effect.flatMap((crypto) => crypto.digest("SHA-256", new TextEncoder().encode(value))),
34 Effect.map(Encoding.encodeHex),153 Effect.map(Encoding.encodeHex),
35 Effect.mapError(154 Effect.mapError(
36 (cause) =>155 (cause) =>
37 new IdentifyUserError({156 new TelemetryIdentityHashError({
38 operation: "hash_identifier",157 source,
39 algorithm: "SHA-256",158 algorithm: "SHA-256",
40 cause,159 cause,
41 }),160 }),
42 ),161 ),
43 );162 );
44163
45const getCodexAccountId = Effect.gen(function* () {164const getCodexAccountId = Effect.fn("TelemetryIdentity.getCodexAccountId")(function* (
165 homeDirectory: string,
166) {
46 const fileSystem = yield* FileSystem.FileSystem;167 const fileSystem = yield* FileSystem.FileSystem;
47 const path = yield* Path.Path;168 const path = yield* Path.Path;
48169
49 const authJsonPath = path.join(NodeOS.homedir(), ".codex", "auth.json");170 const authJsonPath = path.join(homeDirectory, ".codex", "auth.json");
50 const authJson = yield* Effect.flatMap(171 const encoded = yield* readIdentityFile(fileSystem, "codex", authJsonPath);
51 fileSystem.readFileString(authJsonPath),172 if (Option.isNone(encoded)) {
52 Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)),173 return Option.none<string>();
174 }
175 const authJson = yield* decodeCodexAuthJson(encoded.value).pipe(
176 Effect.mapError(
177 (cause) =>
178 new TelemetryIdentityDecodeError({
179 source: "codex",
180 filePath: authJsonPath,
181 cause,
182 }),
183 ),
53 );184 );
54185
55 return authJson.tokens.account_id;186 return Option.some(authJson.tokens.account_id);
56});187});
57188
58const getClaudeUserId = Effect.gen(function* () {189const getClaudeUserId = Effect.fn("TelemetryIdentity.getClaudeUserId")(function* (
190 homeDirectory: string,
191) {
59 const fileSystem = yield* FileSystem.FileSystem;192 const fileSystem = yield* FileSystem.FileSystem;
60 const path = yield* Path.Path;193 const path = yield* Path.Path;
61194
62 const claudeJsonPath = path.join(NodeOS.homedir(), ".claude.json");195 const claudeJsonPath = path.join(homeDirectory, ".claude.json");
63 const claudeJson = yield* Effect.flatMap(196 const encoded = yield* readIdentityFile(fileSystem, "claude", claudeJsonPath);
64 fileSystem.readFileString(claudeJsonPath),197 if (Option.isNone(encoded)) {
65 Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)),198 return Option.none<string>();
199 }
200 const claudeJson = yield* decodeClaudeJson(encoded.value).pipe(
201 Effect.mapError(
202 (cause) =>
203 new TelemetryIdentityDecodeError({
204 source: "claude",
205 filePath: claudeJsonPath,
206 cause,
207 }),
208 ),
66 );209 );
67210
68 return claudeJson.userID;211 return Option.some(claudeJson.userID);
69});212});
70213
71const upsertAnonymousId = Effect.gen(function* () {214const upsertAnonymousId = Effect.gen(function* () {
72 const fileSystem = yield* FileSystem.FileSystem;215 const fileSystem = yield* FileSystem.FileSystem;
73 const { anonymousIdPath } = yield* ServerConfig.ServerConfig;216 const { anonymousIdPath } = yield* ServerConfig.ServerConfig;
74217
75 const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe(218 const existing = yield* readIdentityFile(fileSystem, "anonymous", anonymousIdPath);
76 Effect.catch(() =>219 if (Option.isSome(existing)) {
77 Crypto.Crypto.pipe(220 return existing.value;
78 Effect.flatMap((crypto) => crypto.randomUUIDv4),221 }
79 Effect.tap((randomId) => fileSystem.writeFileString(anonymousIdPath, randomId)),222
80 ),223 const anonymousId = yield* Crypto.Crypto.pipe(
224 Effect.flatMap((crypto) => crypto.randomUUIDv4),
225 Effect.mapError(
226 (cause) =>
227 new TelemetryAnonymousIdGenerationError({
228 source: "anonymous",
229 filePath: anonymousIdPath,
230 cause,
231 }),
232 ),
233 );
234 yield* fileSystem.writeFileString(anonymousIdPath, anonymousId).pipe(
235 Effect.mapError(
236 (cause) =>
237 new TelemetryAnonymousIdPersistenceError({
238 source: "anonymous",
239 filePath: anonymousIdPath,
240 cause,
241 }),
81 ),242 ),
82 );243 );
83244
...@@ -90,24 +251,53 @@ const upsertAnonymousId = Effect.gen(function* () {...@@ -90,24 +251,53 @@ const upsertAnonymousId = Effect.gen(function* () {
90 * 2. ~/.claude.json userID251 * 2. ~/.claude.json userID
91 * 3. ~/.t3/telemetry/anonymous-id252 * 3. ~/.t3/telemetry/anonymous-id
92 */253 */
93export const getTelemetryIdentifier = Effect.gen(function* () {254export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierForHome")(
94 const codexAccountId = yield* Effect.result(getCodexAccountId);255 function* (homeDirectory: string) {
95 if (codexAccountId._tag === "Success") {256 const codexAccountId = yield* getCodexAccountId(homeDirectory).pipe(
96 return yield* hash(codexAccountId.success);257 Effect.catchTags({
97 }258 TelemetryIdentityReadError: (error) =>
259 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
260 TelemetryIdentityDecodeError: (error) =>
261 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
262 }),
263 );
264 if (Option.isSome(codexAccountId)) {
265 return yield* hash("codex", codexAccountId.value);
266 }
98267
99 const claudeUserId = yield* Effect.result(getClaudeUserId);268 const claudeUserId = yield* getClaudeUserId(homeDirectory).pipe(
100 if (claudeUserId._tag === "Success") {269 Effect.catchTags({
101 return yield* hash(claudeUserId.success);270 TelemetryIdentityReadError: (error) =>
102 }271 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
272 TelemetryIdentityDecodeError: (error) =>
273 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
274 }),
275 );
276 if (Option.isSome(claudeUserId)) {
277 return yield* hash("claude", claudeUserId.value);
278 }
103279
104 const anonymousId = yield* Effect.result(upsertAnonymousId);280 const anonymousId = yield* upsertAnonymousId.pipe(
105 if (anonymousId._tag === "Success") {281 Effect.map(Option.some),
106 return yield* hash(anonymousId.success);282 Effect.catchTags({
107 }283 TelemetryIdentityReadError: (error) =>
284 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
285 TelemetryAnonymousIdGenerationError: (error) =>
286 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
287 TelemetryAnonymousIdPersistenceError: (error) =>
288 logTelemetryIdentityError(error).pipe(Effect.as(Option.none<string>())),
289 }),
290 );
291 if (Option.isSome(anonymousId)) {
292 return yield* hash("anonymous", anonymousId.value);
293 }
108294
109 return null;295 return null;
110}).pipe(296 },
111 Effect.tapError((error) => Effect.logWarning("Failed to get identifier", { cause: error })),297 Effect.tapError(logTelemetryIdentityError),
112 Effect.orElseSucceed(() => null),298 Effect.orElseSucceed(() => null),
113);299);
300
301export const getTelemetryIdentifier = Effect.suspend(() =>
302 getTelemetryIdentifierForHome(NodeOS.homedir()),
303);