diff --git a/README.md b/README.md index 7955171..00573e5 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Run a Codex turn, then open your Langfuse project to see the trace. | `LANGFUSE_SECRET_KEY` / `LANGFUSE_CODEX_SECRET_KEY` | Yes | — | Langfuse secret key (`sk-lf-...`) | | `LANGFUSE_BASE_URL` / `LANGFUSE_CODEX_BASE_URL` | No | `https://cloud.langfuse.com` | Langfuse host / data region | | `LANGFUSE_TRACING_ENVIRONMENT` / `LANGFUSE_CODEX_ENVIRONMENT` | No | — | Environment label for the traces (e.g. `production`) | -| `LANGFUSE_CODEX_USER_ID` | No | — | Attach a user id to all traces | +| `LANGFUSE_CODEX_USER_ID` | No | Codex auth email, if found | Attach a user id to all traces | | `LANGFUSE_CODEX_TAGS` | No | — | Tags for all traces (JSON array or comma-separated) | | `LANGFUSE_CODEX_METADATA` | No | — | JSON object of metadata to attach to all traces | | `LANGFUSE_CODEX_MAX_CHARS` | No | `20000` | Truncate inputs/outputs longer than this many characters | @@ -115,7 +115,7 @@ Run a Codex turn, then open your Langfuse project to see the trace. | `secret_key` | `LANGFUSE_SECRET_KEY` / `LANGFUSE_CODEX_SECRET_KEY` | — | Langfuse secret key | | `base_url` | `LANGFUSE_BASE_URL` / `LANGFUSE_CODEX_BASE_URL` | `https://cloud.langfuse.com` | Langfuse host | | `environment` | `LANGFUSE_TRACING_ENVIRONMENT` / `LANGFUSE_CODEX_ENVIRONMENT` | — | Environment label | -| `user_id` | `LANGFUSE_CODEX_USER_ID` | — | User id for all traces | +| `user_id` | `LANGFUSE_CODEX_USER_ID` | Codex auth email, if found | User id for all traces | | `tags` | `LANGFUSE_CODEX_TAGS` | — | Tags for all traces | | `metadata` | `LANGFUSE_CODEX_METADATA` | — | Metadata object for all traces | | `max_chars` | `LANGFUSE_CODEX_MAX_CHARS` | `20000` | Input/output truncation threshold | diff --git a/plugins/tracing/dist/index.mjs b/plugins/tracing/dist/index.mjs index dc9a650..0460e13 100644 --- a/plugins/tracing/dist/index.mjs +++ b/plugins/tracing/dist/index.mjs @@ -4286,6 +4286,7 @@ const DEFAULTS = { debug: false, fail_on_error: false }; +const CodexAuthSchema = object({ tokens: object({ id_token: string().optional() }).optional() }).passthrough(); function parseBoolean(value) { if (typeof value === "boolean") return value; if (typeof value !== "string") return void 0; @@ -4353,6 +4354,30 @@ async function readConfigFile(file) { return; } } +function readJwtPayload(token) { + const payload = token.split(".")[1]; + if (!payload) return void 0; + try { + const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")); + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0; + return parsed; + } catch { + return; + } +} +async function readCodexUserEmail(authFile) { + try { + const raw = JSON.parse(await fs.readFile(authFile, "utf-8")); + const token = CodexAuthSchema.parse(raw).tokens?.id_token; + if (!token) return void 0; + const email$1 = readJwtPayload(token)?.email; + if (typeof email$1 !== "string") return void 0; + const trimmed = email$1.trim(); + return trimmed.length > 0 ? trimmed : void 0; + } catch { + return; + } +} function getVar(suffix, env) { return env[`LANGFUSE_CODEX_${suffix}`] ?? env[`LANGFUSE_${suffix}`]; } @@ -4372,14 +4397,20 @@ function readEnvConfig(env) { })); } const getHomeDir = () => process.env.HOME ?? os$2.homedir(); +function getCodexAuthFile(home, env) { + const codexHome = env.CODEX_HOME?.trim(); + return codexHome ? path.join(codexHome, "auth.json") : path.join(home, ".codex", "auth.json"); +} async function getConfig(options) { const home = options?.home ?? getHomeDir(); const cwd = options?.cwd ?? process.cwd(); const env = options?.env ?? process.env; const [globalConfig$1, localConfig] = await Promise.all([readConfigFile(path.join(home, ".codex", "langfuse.json")), readConfigFile(path.join(cwd, ".codex", "langfuse.json"))]); const envConfig = readEnvConfig(env); + const codexUserId = globalConfig$1?.user_id ?? localConfig?.user_id ?? envConfig.user_id ? void 0 : await readCodexUserEmail(getCodexAuthFile(home, env)); return ConfigSchema.parse({ ...DEFAULTS, + ...codexUserId ? { user_id: codexUserId } : {}, ...globalConfig$1, ...localConfig, ...envConfig diff --git a/plugins/tracing/src/config.ts b/plugins/tracing/src/config.ts index 669398a..23844fd 100644 --- a/plugins/tracing/src/config.ts +++ b/plugins/tracing/src/config.ts @@ -51,6 +51,16 @@ const DEFAULTS: Pick | undefined } } +function readJwtPayload(token: string): Record | undefined { + const payload = token.split(".")[1]; + if (!payload) return undefined; + + try { + const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as unknown; + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + return parsed as Record; + } catch { + return undefined; + } +} + +async function readCodexUserEmail(authFile: string): Promise { + try { + const raw = JSON.parse(await fs.readFile(authFile, "utf-8")) as unknown; + const auth = CodexAuthSchema.parse(raw); + const token = auth.tokens?.id_token; + if (!token) return undefined; + + const email = readJwtPayload(token)?.email; + if (typeof email !== "string") return undefined; + + const trimmed = email.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } catch { + return undefined; + } +} + function getVar(suffix: string, env: Record): string | undefined { return env[`LANGFUSE_CODEX_${suffix}`] ?? env[`LANGFUSE_${suffix}`]; } @@ -153,6 +193,11 @@ function readEnvConfig(env: Record): Partial const getHomeDir = () => process.env.HOME ?? os.homedir(); +function getCodexAuthFile(home: string, env: Record): string { + const codexHome = env.CODEX_HOME?.trim(); + return codexHome ? path.join(codexHome, "auth.json") : path.join(home, ".codex", "auth.json"); +} + export async function getConfig(options?: { home?: string; cwd?: string; @@ -167,9 +212,14 @@ export async function getConfig(options?: { readConfigFile(path.join(cwd, ".codex", "langfuse.json")), ]); const envConfig = readEnvConfig(env); + const explicitUserId = globalConfig?.user_id ?? localConfig?.user_id ?? envConfig.user_id; + const codexUserId = explicitUserId + ? undefined + : await readCodexUserEmail(getCodexAuthFile(home, env)); return ConfigSchema.parse({ ...DEFAULTS, + ...(codexUserId ? { user_id: codexUserId } : {}), ...globalConfig, ...localConfig, ...envConfig, diff --git a/plugins/tracing/test/config.test.ts b/plugins/tracing/test/config.test.ts index 797c56b..2835256 100644 --- a/plugins/tracing/test/config.test.ts +++ b/plugins/tracing/test/config.test.ts @@ -19,6 +19,11 @@ function makeTmpHome(file?: { rel: string; contents: unknown }): string { return dir; } +function makeJwt(payload: unknown): string { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(payload)}.`; +} + afterEach(() => { while (tmpDirs.length) { fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true }); @@ -68,6 +73,74 @@ describe("getConfig", () => { expect(config.secret_key).toBe("sk-standard"); }); + it("uses the Codex auth email as the default user id when available", async () => { + const home = makeTmpHome({ + rel: ".codex/auth.json", + contents: { + tokens: { + id_token: makeJwt({ email: " user@example.com " }), + }, + }, + }); + + const config = await getConfig({ home, cwd: emptyHome(), env: {} }); + + expect(config.user_id).toBe("user@example.com"); + }); + + it("reads the Codex auth email from CODEX_HOME when set", async () => { + const codexHome = makeTmpHome({ + rel: "auth.json", + contents: { + tokens: { + id_token: makeJwt({ email: "codex-home@example.com" }), + }, + }, + }); + + const config = await getConfig({ + home: emptyHome(), + cwd: emptyHome(), + env: { CODEX_HOME: codexHome }, + }); + + expect(config.user_id).toBe("codex-home@example.com"); + }); + + it("ignores missing or malformed Codex auth email claims", async () => { + const home = makeTmpHome({ + rel: ".codex/auth.json", + contents: { + tokens: { + id_token: makeJwt({ name: "Codex User" }), + }, + }, + }); + + const config = await getConfig({ home, cwd: emptyHome(), env: {} }); + + expect(config.user_id).toBeUndefined(); + }); + + it("keeps explicit user id config ahead of the Codex auth email", async () => { + const home = makeTmpHome({ + rel: ".codex/auth.json", + contents: { + tokens: { + id_token: makeJwt({ email: "codex@example.com" }), + }, + }, + }); + + const config = await getConfig({ + home, + cwd: emptyHome(), + env: { LANGFUSE_CODEX_USER_ID: "configured-user" }, + }); + + expect(config.user_id).toBe("configured-user"); + }); + it("parses tags (JSON array or comma-separated) and metadata JSON", async () => { const jsonArray = await getConfig({ home: emptyHome(),