Skip to content

Commit f26e656

Browse files
authored
fix(selfhost): stop the redaction scrubber nulling every AI token count (#10212)
SECRET_KEY matches /token/i, so scrubRecord -- wired as posthog-node's before_send -- rewrote PostHog's own $ai_input_tokens and $ai_output_tokens to the "[redacted]" STRING, which PostHog then coerced to null on its numerically-typed properties. The result: not one AI call in the project has ever carried a token count. posthog.ai_events.input_tokens/output_tokens/total_tokens are NULL for every model over the retention window, including claude-sonnet-5 at 2,321 calls and $498.43 of real spend. $ai_total_cost_usd came through untouched because it has no secret-shaped word in it. PostHog derives $ai_input_cost_usd/$ai_output_cost_usd from tokens, so those could not be computed either -- and the miner-side split landed in #10199 would have been scrubbed the same way. A secret-shaped key holding a NUMBER is a counter, not a credential: every secret this module exists to catch is a string, and there is no numeric form of one to leak. Skip redaction for numbers only; a string, object, array or boolean under the same key is still redacted exactly as before. Deliberately general rather than an allowlist of the two $ai_* keys -- an allowlist goes stale the moment PostHog adds $ai_cache_read_input_tokens, and it would fail the same silent way. Closes #10211
1 parent fa9789e commit f26e656

2 files changed

Lines changed: 61 additions & 1 deletion

File tree

src/selfhost/redaction-scrub.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,14 @@ export function scrubRecord(obj: unknown, depth: number): void {
232232
delete rec[key];
233233
continue;
234234
}
235-
if (shouldRedactKey(key)) {
235+
// #10211: a secret-shaped KEY holding a NUMBER is a counter, not a credential. Every secret this module
236+
// exists to catch -- a token, an API key, a password, a cookie, a DSN, a bearer header -- is a string;
237+
// there is no numeric form of one to leak. Redacting numbers anyway silently destroyed real telemetry:
238+
// SECRET_KEY matches /token/i, so PostHog's own `$ai_input_tokens`/`$ai_output_tokens` were rewritten to
239+
// the "[redacted]" STRING, which PostHog then coerced to null on its numerically-typed properties. The
240+
// result was that not one AI call in the project ever carried a token count, while cost came through
241+
// untouched (`$ai_total_cost_usd` has no secret-shaped word in it).
242+
if (shouldRedactKey(key) && typeof rec[key] !== "number") {
236243
rec[key] = REDACTED;
237244
continue;
238245
}

test/unit/selfhost-redaction-scrub.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,56 @@ describe("scrubRecord — end-to-end via the structured-identifier keys (#9142)"
8787
expect(properties.detail).toContain("private context");
8888
});
8989
});
90+
91+
describe("scrubRecord — a secret-shaped key holding a NUMBER is a counter, not a credential (#10211)", () => {
92+
it("REGRESSION: PostHog's own token-count properties survive the scrub", () => {
93+
// SECRET_KEY matches /token/i, so these were rewritten to the "[redacted]" STRING and PostHog then
94+
// coerced that to null on its numerically-typed properties -- not one AI call in the live project
95+
// ever carried a token count, while $ai_total_cost_usd came through untouched.
96+
const properties: Record<string, unknown> = {
97+
$ai_input_tokens: 1200,
98+
$ai_output_tokens: 300,
99+
$ai_total_cost_usd: 0.44,
100+
tokens_used: 1500,
101+
};
102+
scrubRecord(properties, 0);
103+
expect(properties.$ai_input_tokens).toBe(1200);
104+
expect(properties.$ai_output_tokens).toBe(300);
105+
expect(properties.$ai_total_cost_usd).toBe(0.44);
106+
expect(properties.tokens_used).toBe(1500);
107+
});
108+
109+
it("a zero count is preserved rather than read as absent", () => {
110+
const properties: Record<string, unknown> = { $ai_input_tokens: 0 };
111+
scrubRecord(properties, 0);
112+
expect(properties.$ai_input_tokens).toBe(0);
113+
});
114+
115+
it("still redacts a secret-shaped key holding a STRING, which is the only shape a real credential takes", () => {
116+
const properties: Record<string, unknown> = {
117+
api_token: ["ghp", "abcdefghijklmnopqrst123456"].join("_"),
118+
password: "hunter2",
119+
authorization: "Bearer abc.def.ghi",
120+
session_cookie: "sid=abc123",
121+
};
122+
scrubRecord(properties, 0);
123+
expect(properties.api_token).toBe(REDACTED);
124+
expect(properties.password).toBe(REDACTED);
125+
expect(properties.authorization).toBe(REDACTED);
126+
expect(properties.session_cookie).toBe(REDACTED);
127+
});
128+
129+
it("still redacts a secret-shaped key holding a non-numeric, non-string value", () => {
130+
// An object or array under a secret-shaped key is not a counter, so the number carve-out must not
131+
// widen into "anything that is not a string".
132+
const properties: Record<string, unknown> = {
133+
credentials: { token: "abc" },
134+
api_keys: ["one", "two"],
135+
secret_flag: true,
136+
};
137+
scrubRecord(properties, 0);
138+
expect(properties.credentials).toBe(REDACTED);
139+
expect(properties.api_keys).toBe(REDACTED);
140+
expect(properties.secret_flag).toBe(REDACTED);
141+
});
142+
});

0 commit comments

Comments
 (0)