Skip to content

Commit 6882ab7

Browse files
Merge pull request #8 from langfuse/codex/add-codex-email-user-id
[codex] Add Codex auth email as Langfuse user id
2 parents f7b6f9f + 6f05cc2 commit 6882ab7

4 files changed

Lines changed: 156 additions & 2 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Run a Codex turn, then open your Langfuse project to see the trace.
9090
| `LANGFUSE_SECRET_KEY` / `LANGFUSE_CODEX_SECRET_KEY` | Yes || Langfuse secret key (`sk-lf-...`) |
9191
| `LANGFUSE_BASE_URL` / `LANGFUSE_CODEX_BASE_URL` | No | `https://cloud.langfuse.com` | Langfuse host / data region |
9292
| `LANGFUSE_TRACING_ENVIRONMENT` / `LANGFUSE_CODEX_ENVIRONMENT` | No || Environment label for the traces (e.g. `production`) |
93-
| `LANGFUSE_CODEX_USER_ID` | No | | Attach a user id to all traces |
93+
| `LANGFUSE_CODEX_USER_ID` | No | Codex auth email, if found | Attach a user id to all traces |
9494
| `LANGFUSE_CODEX_TAGS` | No || Tags for all traces (JSON array or comma-separated) |
9595
| `LANGFUSE_CODEX_METADATA` | No || JSON object of metadata to attach to all traces |
9696
| `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.
115115
| `secret_key` | `LANGFUSE_SECRET_KEY` / `LANGFUSE_CODEX_SECRET_KEY` || Langfuse secret key |
116116
| `base_url` | `LANGFUSE_BASE_URL` / `LANGFUSE_CODEX_BASE_URL` | `https://cloud.langfuse.com` | Langfuse host |
117117
| `environment` | `LANGFUSE_TRACING_ENVIRONMENT` / `LANGFUSE_CODEX_ENVIRONMENT` || Environment label |
118-
| `user_id` | `LANGFUSE_CODEX_USER_ID` | | User id for all traces |
118+
| `user_id` | `LANGFUSE_CODEX_USER_ID` | Codex auth email, if found | User id for all traces |
119119
| `tags` | `LANGFUSE_CODEX_TAGS` || Tags for all traces |
120120
| `metadata` | `LANGFUSE_CODEX_METADATA` || Metadata object for all traces |
121121
| `max_chars` | `LANGFUSE_CODEX_MAX_CHARS` | `20000` | Input/output truncation threshold |

plugins/tracing/dist/index.mjs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4286,6 +4286,7 @@ const DEFAULTS = {
42864286
debug: false,
42874287
fail_on_error: false
42884288
};
4289+
const CodexAuthSchema = object({ tokens: object({ id_token: string().optional() }).optional() }).passthrough();
42894290
function parseBoolean(value) {
42904291
if (typeof value === "boolean") return value;
42914292
if (typeof value !== "string") return void 0;
@@ -4353,6 +4354,30 @@ async function readConfigFile(file) {
43534354
return;
43544355
}
43554356
}
4357+
function readJwtPayload(token) {
4358+
const payload = token.split(".")[1];
4359+
if (!payload) return void 0;
4360+
try {
4361+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
4362+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
4363+
return parsed;
4364+
} catch {
4365+
return;
4366+
}
4367+
}
4368+
async function readCodexUserEmail(authFile) {
4369+
try {
4370+
const raw = JSON.parse(await fs.readFile(authFile, "utf-8"));
4371+
const token = CodexAuthSchema.parse(raw).tokens?.id_token;
4372+
if (!token) return void 0;
4373+
const email$1 = readJwtPayload(token)?.email;
4374+
if (typeof email$1 !== "string") return void 0;
4375+
const trimmed = email$1.trim();
4376+
return trimmed.length > 0 ? trimmed : void 0;
4377+
} catch {
4378+
return;
4379+
}
4380+
}
43564381
function getVar(suffix, env) {
43574382
return env[`LANGFUSE_CODEX_${suffix}`] ?? env[`LANGFUSE_${suffix}`];
43584383
}
@@ -4372,14 +4397,20 @@ function readEnvConfig(env) {
43724397
}));
43734398
}
43744399
const getHomeDir = () => process.env.HOME ?? os$2.homedir();
4400+
function getCodexAuthFile(home, env) {
4401+
const codexHome = env.CODEX_HOME?.trim();
4402+
return codexHome ? path.join(codexHome, "auth.json") : path.join(home, ".codex", "auth.json");
4403+
}
43754404
async function getConfig(options) {
43764405
const home = options?.home ?? getHomeDir();
43774406
const cwd = options?.cwd ?? process.cwd();
43784407
const env = options?.env ?? process.env;
43794408
const [globalConfig$1, localConfig] = await Promise.all([readConfigFile(path.join(home, ".codex", "langfuse.json")), readConfigFile(path.join(cwd, ".codex", "langfuse.json"))]);
43804409
const envConfig = readEnvConfig(env);
4410+
const codexUserId = globalConfig$1?.user_id ?? localConfig?.user_id ?? envConfig.user_id ? void 0 : await readCodexUserEmail(getCodexAuthFile(home, env));
43814411
return ConfigSchema.parse({
43824412
...DEFAULTS,
4413+
...codexUserId ? { user_id: codexUserId } : {},
43834414
...globalConfig$1,
43844415
...localConfig,
43854416
...envConfig

plugins/tracing/src/config.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ const DEFAULTS: Pick<Config, "enabled" | "base_url" | "max_chars" | "debug" | "f
5151
fail_on_error: false,
5252
};
5353

54+
const CodexAuthSchema = z
55+
.object({
56+
tokens: z
57+
.object({
58+
id_token: z.string().optional(),
59+
})
60+
.optional(),
61+
})
62+
.passthrough();
63+
5464
function parseBoolean(value: unknown): boolean | undefined {
5565
if (typeof value === "boolean") return value;
5666
if (typeof value !== "string") return undefined;
@@ -129,6 +139,36 @@ async function readConfigFile(file: string): Promise<Partial<Config> | undefined
129139
}
130140
}
131141

142+
function readJwtPayload(token: string): Record<string, unknown> | undefined {
143+
const payload = token.split(".")[1];
144+
if (!payload) return undefined;
145+
146+
try {
147+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as unknown;
148+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
149+
return parsed as Record<string, unknown>;
150+
} catch {
151+
return undefined;
152+
}
153+
}
154+
155+
async function readCodexUserEmail(authFile: string): Promise<string | undefined> {
156+
try {
157+
const raw = JSON.parse(await fs.readFile(authFile, "utf-8")) as unknown;
158+
const auth = CodexAuthSchema.parse(raw);
159+
const token = auth.tokens?.id_token;
160+
if (!token) return undefined;
161+
162+
const email = readJwtPayload(token)?.email;
163+
if (typeof email !== "string") return undefined;
164+
165+
const trimmed = email.trim();
166+
return trimmed.length > 0 ? trimmed : undefined;
167+
} catch {
168+
return undefined;
169+
}
170+
}
171+
132172
function getVar(suffix: string, env: Record<string, string | undefined>): string | undefined {
133173
return env[`LANGFUSE_CODEX_${suffix}`] ?? env[`LANGFUSE_${suffix}`];
134174
}
@@ -153,6 +193,11 @@ function readEnvConfig(env: Record<string, string | undefined>): Partial<Config>
153193

154194
const getHomeDir = () => process.env.HOME ?? os.homedir();
155195

196+
function getCodexAuthFile(home: string, env: Record<string, string | undefined>): string {
197+
const codexHome = env.CODEX_HOME?.trim();
198+
return codexHome ? path.join(codexHome, "auth.json") : path.join(home, ".codex", "auth.json");
199+
}
200+
156201
export async function getConfig(options?: {
157202
home?: string;
158203
cwd?: string;
@@ -167,9 +212,14 @@ export async function getConfig(options?: {
167212
readConfigFile(path.join(cwd, ".codex", "langfuse.json")),
168213
]);
169214
const envConfig = readEnvConfig(env);
215+
const explicitUserId = globalConfig?.user_id ?? localConfig?.user_id ?? envConfig.user_id;
216+
const codexUserId = explicitUserId
217+
? undefined
218+
: await readCodexUserEmail(getCodexAuthFile(home, env));
170219

171220
return ConfigSchema.parse({
172221
...DEFAULTS,
222+
...(codexUserId ? { user_id: codexUserId } : {}),
173223
...globalConfig,
174224
...localConfig,
175225
...envConfig,

plugins/tracing/test/config.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ function makeTmpHome(file?: { rel: string; contents: unknown }): string {
1919
return dir;
2020
}
2121

22+
function makeJwt(payload: unknown): string {
23+
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
24+
return `${encode({ alg: "none" })}.${encode(payload)}.`;
25+
}
26+
2227
afterEach(() => {
2328
while (tmpDirs.length) {
2429
fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true });
@@ -68,6 +73,74 @@ describe("getConfig", () => {
6873
expect(config.secret_key).toBe("sk-standard");
6974
});
7075

76+
it("uses the Codex auth email as the default user id when available", async () => {
77+
const home = makeTmpHome({
78+
rel: ".codex/auth.json",
79+
contents: {
80+
tokens: {
81+
id_token: makeJwt({ email: " user@example.com " }),
82+
},
83+
},
84+
});
85+
86+
const config = await getConfig({ home, cwd: emptyHome(), env: {} });
87+
88+
expect(config.user_id).toBe("user@example.com");
89+
});
90+
91+
it("reads the Codex auth email from CODEX_HOME when set", async () => {
92+
const codexHome = makeTmpHome({
93+
rel: "auth.json",
94+
contents: {
95+
tokens: {
96+
id_token: makeJwt({ email: "codex-home@example.com" }),
97+
},
98+
},
99+
});
100+
101+
const config = await getConfig({
102+
home: emptyHome(),
103+
cwd: emptyHome(),
104+
env: { CODEX_HOME: codexHome },
105+
});
106+
107+
expect(config.user_id).toBe("codex-home@example.com");
108+
});
109+
110+
it("ignores missing or malformed Codex auth email claims", async () => {
111+
const home = makeTmpHome({
112+
rel: ".codex/auth.json",
113+
contents: {
114+
tokens: {
115+
id_token: makeJwt({ name: "Codex User" }),
116+
},
117+
},
118+
});
119+
120+
const config = await getConfig({ home, cwd: emptyHome(), env: {} });
121+
122+
expect(config.user_id).toBeUndefined();
123+
});
124+
125+
it("keeps explicit user id config ahead of the Codex auth email", async () => {
126+
const home = makeTmpHome({
127+
rel: ".codex/auth.json",
128+
contents: {
129+
tokens: {
130+
id_token: makeJwt({ email: "codex@example.com" }),
131+
},
132+
},
133+
});
134+
135+
const config = await getConfig({
136+
home,
137+
cwd: emptyHome(),
138+
env: { LANGFUSE_CODEX_USER_ID: "configured-user" },
139+
});
140+
141+
expect(config.user_id).toBe("configured-user");
142+
});
143+
71144
it("parses tags (JSON array or comma-separated) and metadata JSON", async () => {
72145
const jsonArray = await getConfig({
73146
home: emptyHome(),

0 commit comments

Comments
 (0)