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";
33import * as Effect from "effect/Effect";
44import * as Encoding from "effect/Encoding";
55import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
67import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
79import * as Schema from "effect/Schema";
810
911import * as ServerConfig from "../config.ts";
......@@ -18,66 +20,225 @@ const ClaudeJsonSchema = Schema.Struct({
1820 userID: Schema.String,
1921});
2022
21class IdentifyUserError extends Schema.TaggedErrorClass<IdentifyUserError>()("IdentifyUserError", {
22 operation: Schema.Literal("hash_identifier"),
23 algorithm: Schema.Literal("SHA-256"),
24 cause: Schema.Defect(),
25}) {
23export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]);
24export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type;
25
26export class TelemetryIdentityReadError extends Schema.TaggedErrorClass<TelemetryIdentityReadError>()(
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) {
2647 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}'.`;
2849 }
2950}
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) =>
32151 Crypto.Crypto.pipe(
33152 Effect.flatMap((crypto) => crypto.digest("SHA-256", new TextEncoder().encode(value))),
34153 Effect.map(Encoding.encodeHex),
35154 Effect.mapError(
36155 (cause) =>
37 new IdentifyUserError({
38 operation: "hash_identifier",
156 new TelemetryIdentityHashError({
157 source,
39158 algorithm: "SHA-256",
40159 cause,
41160 }),
42161 ),
43162 );
44163
45const getCodexAccountId = Effect.gen(function* () {
164const getCodexAccountId = Effect.fn("TelemetryIdentity.getCodexAccountId")(function* (
165 homeDirectory: string,
166) {
46167 const fileSystem = yield* FileSystem.FileSystem;
47168 const path = yield* Path.Path;
48169
49 const authJsonPath = path.join(NodeOS.homedir(), ".codex", "auth.json");
50 const authJson = yield* Effect.flatMap(
51 fileSystem.readFileString(authJsonPath),
52 Schema.decodeEffect(Schema.fromJsonString(CodexAuthJsonSchema)),
170 const authJsonPath = path.join(homeDirectory, ".codex", "auth.json");
171 const encoded = yield* readIdentityFile(fileSystem, "codex", authJsonPath);
172 if (Option.isNone(encoded)) {
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 ),
53184 );
54185
55 return authJson.tokens.account_id;
186 return Option.some(authJson.tokens.account_id);
56187});
57188
58const getClaudeUserId = Effect.gen(function* () {
189const getClaudeUserId = Effect.fn("TelemetryIdentity.getClaudeUserId")(function* (
190 homeDirectory: string,
191) {
59192 const fileSystem = yield* FileSystem.FileSystem;
60193 const path = yield* Path.Path;
61194
62 const claudeJsonPath = path.join(NodeOS.homedir(), ".claude.json");
63 const claudeJson = yield* Effect.flatMap(
64 fileSystem.readFileString(claudeJsonPath),
65 Schema.decodeEffect(Schema.fromJsonString(ClaudeJsonSchema)),
195 const claudeJsonPath = path.join(homeDirectory, ".claude.json");
196 const encoded = yield* readIdentityFile(fileSystem, "claude", claudeJsonPath);
197 if (Option.isNone(encoded)) {
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 ),
66209 );
67210
68 return claudeJson.userID;
211 return Option.some(claudeJson.userID);
69212});
70213
71214const upsertAnonymousId = Effect.gen(function* () {
72215 const fileSystem = yield* FileSystem.FileSystem;
73216 const { anonymousIdPath } = yield* ServerConfig.ServerConfig;
74217
75 const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe(
76 Effect.catch(() =>
77 Crypto.Crypto.pipe(
78 Effect.flatMap((crypto) => crypto.randomUUIDv4),
79 Effect.tap((randomId) => fileSystem.writeFileString(anonymousIdPath, randomId)),
80 ),
218 const existing = yield* readIdentityFile(fileSystem, "anonymous", anonymousIdPath);
219 if (Option.isSome(existing)) {
220 return existing.value;
221 }
222
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 }),
81242 ),
82243 );
83244
......@@ -90,24 +251,53 @@ const upsertAnonymousId = Effect.gen(function* () {
90251 * 2. ~/.claude.json userID
91252 * 3. ~/.t3/telemetry/anonymous-id
92253 */
93export const getTelemetryIdentifier = Effect.gen(function* () {
94 const codexAccountId = yield* Effect.result(getCodexAccountId);
95 if (codexAccountId._tag === "Success") {
96 return yield* hash(codexAccountId.success);
97 }
254export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierForHome")(
255 function* (homeDirectory: string) {
256 const codexAccountId = yield* getCodexAccountId(homeDirectory).pipe(
257 Effect.catchTags({
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);
100 if (claudeUserId._tag === "Success") {
101 return yield* hash(claudeUserId.success);
102 }
268 const claudeUserId = yield* getClaudeUserId(homeDirectory).pipe(
269 Effect.catchTags({
270 TelemetryIdentityReadError: (error) =>
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);
105 if (anonymousId._tag === "Success") {
106 return yield* hash(anonymousId.success);
107 }
280 const anonymousId = yield* upsertAnonymousId.pipe(
281 Effect.map(Option.some),
282 Effect.catchTags({
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;
110}).pipe(
111 Effect.tapError((error) => Effect.logWarning("Failed to get identifier", { cause: error })),
295 return null;
296 },
297 Effect.tapError(logTelemetryIdentityError),
112298 Effect.orElseSucceed(() => null),
113299);
300
301export const getTelemetryIdentifier = Effect.suspend(() =>
302 getTelemetryIdentifierForHome(NodeOS.homedir()),
303);