Skip to content

Commit a5995aa

Browse files
committed
feat(performance): scrub DB, GenAI, GraphQL and HTTP span attributes by default
Item 3 of the Sentry data-collection review, and the shape of it is deliberately not Sentry's. Their v11 turned cookies, HTTP bodies, GenAI I/O and database query data ON by default, in the clear. This SDK's rule is that privacy-relevant data is obscured by default, so the answer here is to capture the same categories and scrub them on the way in. It matters because consuming OpenTelemetry brings in data the SDK never produced: with `@opentelemetry/instrumentation-pg` registered, `db.statement` arrives as raw SQL with literal values, and the GenAI conventions carry prompts and completions verbatim. db.statement / db.query.text literals replaced, SHAPE kept — the form that actually groups gen_ai.*, graphql.*, bodies redacted outright; no shape worth keeping, and a prompt is whatever the user typed everything else the SDK's single definition of a sensitive key, on the leaf name, so `db.user` survives and `db.password` does not SQL's doubled-quote escape is consumed as part of the string it sits in — treating it as a terminator would leave `Brien` behind in `'O''Brien'`, which is exactly the data being removed. Digits inside an identifier (`column2`) are not literals. An integrator `setSpanFilter` REPLACES the built-in rather than layering, the same XOR the network sanitizer follows: someone who has written a filter has decided what leaves their process, and silently re-scrubbing on top would make their filter unpredictable. `sanitizeSpans: false` turns it off entirely. Wiring it surfaced a real regression in my own earlier work: `applySpanFilter` rebuilt the transaction unconditionally, which was invisible while filters were opt-in and became "rebuild every transaction forever to change nothing" the moment a default filter existed. There is now a no-change fast path returning the original by reference, and a test that pins it. One test's premise genuinely changed rather than broke — "passes the transaction through untouched when no filter is set" is no longer true, because no filter no longer means no scrubbing. It was rewritten to assert what is now the case, alongside a new test for the by-reference path. NOT included, and worth stating: first-party instrumentation for pg/mysql/prisma/openai etc. That is per-library work, and with this landed a customer wiring OTel instrumentation already gets the data safely — first-party would buy zero-config, not new signal. Gates: lint 0 · typecheck 100/100 · 435 files / 6065 tests · coverage 97/97. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent ddf2185 commit a5995aa

7 files changed

Lines changed: 281 additions & 10 deletions

File tree

‎.changeset/span-sanitizer.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@bugsee/performance': minor
3+
---
4+
5+
Database, GenAI, GraphQL and HTTP span attributes are now scrubbed by default.
6+
7+
Consuming OpenTelemetry brings in whole categories of data the SDK never produced:
8+
`@opentelemetry/instrumentation-pg` puts executed SQL on `db.statement` with the literal values still
9+
in it, the GenAI conventions carry prompts and completions verbatim, and HTTP instrumentation can carry
10+
bodies. Peer SDKs default all of that on, in the clear. This one captures it — scrubbed on the way in.
11+
12+
- **Database statements keep their SHAPE and lose their data.**
13+
`SELECT * FROM users WHERE email = 'a@b.com'` becomes `SELECT * FROM users WHERE email = ?`, which is
14+
the form that is actually useful: every execution of one query looks the same, so it groups, and it
15+
carries nothing about a customer. SQL's own doubled-quote escape is consumed as part of the string it
16+
sits in, so `'O''Brien'` is one literal rather than a terminator followed by a name.
17+
- **Free-form content is redacted outright** — `gen_ai.prompt`, `gen_ai.completion`, `graphql.document`,
18+
`graphql.variables`, `http.request.body` and friends. Unlike a query there is no shape worth keeping,
19+
and a prompt is whatever the user typed.
20+
- **Everything else goes through the SDK's single definition of a sensitive key**, the same predicate
21+
that redacts headers and query params, matched on the attribute's leaf name: `db.user` is an
22+
identifier worth keeping, `db.password` is not.
23+
24+
An integrator `setSpanFilter` REPLACES the built-in rather than layering over it — the same XOR the
25+
network sanitizer follows, because someone who has written a filter has decided what leaves their
26+
process. It can also be turned off with `sanitizeSpans: false`.
27+
28+
A transaction the sanitizer did not change is returned by reference, so running on every transaction
29+
costs no allocation.

‎packages/performance/src/extension.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ export interface PerformanceExtensionOptions {
3232
maxTransactions?: number;
3333
/** Injectable transaction buffer (tests, or a launch that wires its own drains). */
3434
store?: TransactionStore;
35+
/**
36+
* Run the BUILT-IN span sanitizer — SQL literals replaced, model prompts and bodies redacted,
37+
* sensitive attribute names removed. Default true.
38+
*
39+
* An integrator `setSpanFilter` REPLACES it rather than layering, the same XOR the network sanitizer
40+
* follows: someone who has written a filter has decided what leaves their process.
41+
*/
42+
sanitizeSpans?: boolean;
3543
}
3644

3745
export interface PerformanceExtension {
@@ -66,7 +74,12 @@ export function createPerformanceExtension(
6674
// have configured one on.
6775
let filters: FilterStore | undefined;
6876
const filterTransaction = (transaction: TransactionWire): TransactionWire | null =>
69-
applySpanFilter(transaction, filters?.span ?? null, (error) => filters?.onError(error));
77+
applySpanFilter(
78+
transaction,
79+
filters?.span ?? null,
80+
(error) => filters?.onError(error),
81+
options.sanitizeSpans ?? true,
82+
);
7083
return {
7184
name: 'performance',
7285
store,

‎packages/performance/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export {
7878
type TransactionOptions,
7979
type TransactionWire,
8080
} from './span';
81+
export { redactSqlLiterals, sanitizeSpan } from './span-sanitizer';
8182
export {
8283
createTransactionStore,
8384
type TransactionStore,

‎packages/performance/src/span-filter.test.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,23 @@ const child = (over: Partial<TransactionWire['spans'][number]> = {}) => ({
2929
});
3030

3131
describe('applySpanFilter', () => {
32-
it('passes the transaction through untouched when no filter is set', () => {
32+
it('applies the BUILT-IN sanitizer when no filter is set', () => {
33+
// "No filter" does not mean "no scrubbing": consuming OpenTelemetry brings in SQL, prompts and
34+
// bodies the SDK never produced, and the default is that they are captured scrubbed.
35+
const out = applySpanFilter(txn({ spans: [child()] }), null, () => {});
36+
expect(out?.spans[0]?.attributes?.['db.statement']).toBe('SELECT * FROM users WHERE email = ?');
37+
});
38+
39+
it('returns the SAME transaction by reference when the sanitizer changed nothing', () => {
40+
// The built-in runs on every transaction the SDK produces, so it must not rebuild all of them to
41+
// change nothing.
42+
const t = txn({ spans: [child({ attributes: { 'db.system': 'postgresql' } })] });
43+
expect(applySpanFilter(t, null, () => {})).toBe(t);
44+
});
45+
46+
it('can be turned off, leaving the transaction untouched', () => {
3347
const t = txn({ spans: [child()] });
34-
expect(applySpanFilter(t, null, () => {})).toBe(t); // same reference — no needless copying
48+
expect(applySpanFilter(t, null, () => {}, false)).toBe(t);
3549
});
3650

3751
it('scrubs an attribute on a CHILD span', () => {

‎packages/performance/src/span-filter.ts‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type FilterableSpan, runFilter, type SpanFilter } from '@bugsee/core';
22
import type { SpanStatus, TransactionWire } from './span';
3+
import { sanitizeSpan } from './span-sanitizer';
34

45
// The span redaction seam (core's `filters.span`), applied to every finished transaction — the SDK's own
56
// and every externally recorded one, which is where consumed OpenTelemetry spans arrive.
@@ -30,6 +31,13 @@ const projectRoot = (transaction: TransactionWire): FilterableSpan => ({
3031
...(transaction.attributes !== undefined ? { attributes: transaction.attributes } : {}),
3132
});
3233

34+
/** Did the filter alter anything the root projection carries back onto the transaction? */
35+
const rootChanged = (transaction: TransactionWire, root: FilterableSpan): boolean =>
36+
root.description !== transaction.name ||
37+
root.operation !== transaction.operation ||
38+
root.status !== transaction.status ||
39+
root.attributes !== transaction.attributes;
40+
3341
/**
3442
* Run `filter` over a transaction's root and every child.
3543
*
@@ -45,22 +53,38 @@ export function applySpanFilter(
4553
transaction: TransactionWire,
4654
filter: SpanFilter | null,
4755
onError: (error: unknown) => void,
56+
sanitizeDefault = true,
4857
): TransactionWire | null {
49-
if (filter === null) {
58+
// An integrator filter REPLACES the built-in sanitizer rather than layering over it — the same XOR
59+
// the network sanitizer follows. Someone who has written a span filter has decided what leaves their
60+
// process, and silently re-scrubbing on top of it would make their filter's behaviour unpredictable.
61+
const effective = filter ?? (sanitizeDefault ? sanitizeSpan : null);
62+
if (effective === null) {
5063
return transaction;
5164
}
52-
const root = runFilter(filter, projectRoot(transaction), onError);
65+
const root = runFilter(effective, projectRoot(transaction), onError);
5366
if (root === null) {
5467
return null;
5568
}
5669
const spans: TransactionWire['spans'] = [];
70+
// Whether anything actually changed. Without this the built-in sanitizer would rebuild every
71+
// transaction the SDK produces, forever, to change nothing — it runs on all of them by default.
72+
let changed = root !== undefined && rootChanged(transaction, root);
5773
for (const span of transaction.spans) {
58-
const kept = runFilter(filter, span as FilterableSpan, onError);
59-
if (kept !== null) {
60-
// The cast mirrors `FilterableSpan`'s widening of `status` to `string`: core sits below this
61-
// package and cannot name the `SpanStatus` union, so the narrowing is restored here.
62-
spans.push(kept as TransactionWire['spans'][number]);
74+
const kept = runFilter(effective, span as FilterableSpan, onError);
75+
if (kept === null) {
76+
changed = true;
77+
continue;
78+
}
79+
if (kept !== span) {
80+
changed = true;
6381
}
82+
// The cast mirrors `FilterableSpan`'s widening of `status` to `string`: core sits below this
83+
// package and cannot name the `SpanStatus` union, so the narrowing is restored here.
84+
spans.push(kept as TransactionWire['spans'][number]);
85+
}
86+
if (!changed) {
87+
return transaction;
6488
}
6589
return {
6690
...transaction,
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { REDACTED } from '@bugsee/protocol';
2+
import { describe, expect, it } from 'vitest';
3+
import { redactSqlLiterals, sanitizeSpan } from './span-sanitizer';
4+
5+
const span = (attributes?: Record<string, unknown>) => ({
6+
spanId: 's',
7+
operation: 'db.query',
8+
status: 'OK',
9+
startTimestampMs: 1,
10+
...(attributes !== undefined ? { attributes } : {}),
11+
});
12+
13+
describe('redactSqlLiterals', () => {
14+
it('replaces string literals while keeping the query SHAPE', () => {
15+
// The shape is the whole point: every execution of one query looks the same, so it groups — and it
16+
// carries no customer data.
17+
expect(redactSqlLiterals("SELECT * FROM users WHERE email = 'a@b.com'")).toBe(
18+
'SELECT * FROM users WHERE email = ?',
19+
);
20+
});
21+
22+
it('replaces numeric literals', () => {
23+
expect(redactSqlLiterals('SELECT * FROM orders WHERE id = 42 AND total > 9.99')).toBe(
24+
'SELECT * FROM orders WHERE id = ? AND total > ?',
25+
);
26+
});
27+
28+
it("consumes SQL's own doubled-quote escape as part of its string", () => {
29+
// `'O''Brien'` is ONE literal. Treating the doubled quote as a terminator would leave `Brien` in the
30+
// statement — the exact customer data this removes.
31+
expect(redactSqlLiterals("SELECT * FROM users WHERE name = 'O''Brien'")).toBe(
32+
'SELECT * FROM users WHERE name = ?',
33+
);
34+
});
35+
36+
it('does not mistake digits inside an identifier for a literal', () => {
37+
expect(redactSqlLiterals('SELECT column2 FROM t1')).toBe('SELECT column2 FROM t1');
38+
});
39+
40+
it('handles a double-quoted literal', () => {
41+
expect(redactSqlLiterals('SELECT * FROM t WHERE a = "secret"')).toBe(
42+
'SELECT * FROM t WHERE a = ?',
43+
);
44+
});
45+
46+
it('leaves a statement with no literals alone', () => {
47+
expect(redactSqlLiterals('SELECT id FROM users')).toBe('SELECT id FROM users');
48+
});
49+
});
50+
51+
describe('sanitizeSpan', () => {
52+
it('redacts the literals in a db statement, under either convention name', () => {
53+
expect(
54+
sanitizeSpan(span({ 'db.statement': "SELECT * FROM u WHERE e = 'a@b.com'" })).attributes,
55+
).toEqual({ 'db.statement': 'SELECT * FROM u WHERE e = ?' });
56+
expect(
57+
sanitizeSpan(span({ 'db.query.text': "SELECT 'x'" })).attributes?.['db.query.text'],
58+
).toBe('SELECT ?');
59+
});
60+
61+
it('REDACTS model prompts and completions outright', () => {
62+
// Unlike a query there is no shape worth keeping, and a prompt is as sensitive as a password —
63+
// it is whatever the user typed.
64+
const out = sanitizeSpan(
65+
span({ 'gen_ai.prompt': 'my medical history is…', 'gen_ai.completion': 'you should…' }),
66+
).attributes;
67+
expect(out).toEqual({ 'gen_ai.prompt': REDACTED, 'gen_ai.completion': REDACTED });
68+
});
69+
70+
it('redacts GraphQL documents and variables, and HTTP bodies', () => {
71+
const out = sanitizeSpan(
72+
span({ 'graphql.variables': '{"id":1}', 'http.request.body': '{"card":"4111..."}' }),
73+
).attributes;
74+
expect(out).toEqual({ 'graphql.variables': REDACTED, 'http.request.body': REDACTED });
75+
});
76+
77+
it("redacts by the SDK's single definition of a sensitive key, on the leaf name", () => {
78+
// Derived, never restated: the same predicate that redacts headers and query params. `db.user` is
79+
// an identifier worth keeping; `db.password` is not.
80+
const out = sanitizeSpan(
81+
span({ 'db.password': 'hunter2', 'db.user': 'app', 'custom.api_token': 'sk-1' }),
82+
).attributes;
83+
expect(out).toEqual({
84+
'db.password': REDACTED,
85+
'db.user': 'app',
86+
'custom.api_token': REDACTED,
87+
});
88+
});
89+
90+
it('keeps ordinary attributes untouched and returns the SAME span when nothing changed', () => {
91+
const input = span({ 'db.system': 'postgresql', 'net.peer.port': 5432 });
92+
expect(sanitizeSpan(input)).toBe(input);
93+
});
94+
95+
it('handles a span with no attributes at all', () => {
96+
const input = span();
97+
expect(sanitizeSpan(input)).toBe(input);
98+
});
99+
100+
it('leaves a non-string statement alone rather than coercing it', () => {
101+
const input = span({ 'db.statement': 42 });
102+
expect(sanitizeSpan(input).attributes?.['db.statement']).toBe(42);
103+
});
104+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { type FilterableSpan, isSensitiveInput } from '@bugsee/core';
2+
import { isSensitiveKey, REDACTED } from '@bugsee/protocol';
3+
4+
// The BUILT-IN span sanitizer, applied to every span unless the integrator installs their own filter
5+
// (the same XOR the network sanitizer follows: a user filter REPLACES the default rather than layering).
6+
//
7+
// It exists because consuming OpenTelemetry now brings in whole categories of data the SDK never had a
8+
// hand in producing: `@opentelemetry/instrumentation-pg` puts executed SQL on `db.statement` with the
9+
// literal values still in it, the GenAI conventions carry prompts and completions verbatim, and HTTP
10+
// instrumentation can carry bodies. Peers default all of that ON in the clear. This SDK's rule is that
11+
// privacy-relevant data is obscured by default, so it is captured — and scrubbed on the way in.
12+
13+
/** Attribute keys whose VALUE is free-form user or model content, whatever it is called upstream. */
14+
const CONTENT_KEYS = new Set([
15+
'gen_ai.prompt',
16+
'gen_ai.completion',
17+
'gen_ai.request.messages',
18+
'gen_ai.response.messages',
19+
'gen_ai.content.prompt',
20+
'gen_ai.content.completion',
21+
'http.request.body',
22+
'http.response.body',
23+
'graphql.document',
24+
'graphql.variables',
25+
'messaging.message.body',
26+
]);
27+
28+
/** Keys carrying a database statement, across the OTel conventions that have named it differently. */
29+
const STATEMENT_KEYS = new Set(['db.statement', 'db.query.text']);
30+
31+
/**
32+
* Replace the LITERALS in a SQL statement with placeholders, keeping its shape.
33+
*
34+
* `SELECT * FROM users WHERE email = 'a@b.com'` becomes `SELECT * FROM users WHERE email = ?`, which is
35+
* the form that is actually useful in a report: it groups (every execution of one query looks the same)
36+
* and it carries no customer data. Quoted strings go first so a number inside a string is not treated as
37+
* a number, and doubled quotes — SQL's own escape — are consumed as part of the string they sit in.
38+
*/
39+
export function redactSqlLiterals(statement: string): string {
40+
return (
41+
statement
42+
// '...' with '' escapes, and "..." with "" escapes
43+
.replace(/'(?:[^']|'')*'/g, '?')
44+
.replace(/"(?:[^"]|"")*"/g, '?')
45+
// numeric literals, but never the digits inside an identifier like `column2`
46+
.replace(/\b\d+(?:\.\d+)?\b/g, '?')
47+
);
48+
}
49+
50+
/**
51+
* Scrub one span's attributes. Returns the SAME span when nothing needed changing, so the common case —
52+
* an SDK-produced span with no such attributes — copies nothing.
53+
*/
54+
export function sanitizeSpan(span: FilterableSpan): FilterableSpan {
55+
const attributes = span.attributes;
56+
if (attributes === undefined) {
57+
return span;
58+
}
59+
let changed = false;
60+
const out: Record<string, unknown> = {};
61+
for (const [key, value] of Object.entries(attributes)) {
62+
const scrubbed = sanitizeAttribute(key, value);
63+
if (scrubbed !== value) {
64+
changed = true;
65+
}
66+
out[key] = scrubbed;
67+
}
68+
return changed ? { ...span, attributes: out } : span;
69+
}
70+
71+
function sanitizeAttribute(key: string, value: unknown): unknown {
72+
// A statement keeps its SHAPE — that is what makes it worth reporting at all — while losing its data.
73+
if (STATEMENT_KEYS.has(key) && typeof value === 'string') {
74+
return redactSqlLiterals(value);
75+
}
76+
// Free-form content: there is no shape worth keeping, and a prompt is as sensitive as a password.
77+
if (CONTENT_KEYS.has(key)) {
78+
return REDACTED;
79+
}
80+
// …and the general rule, through the SDK's single definitions of a sensitive key and a sensitive
81+
// field name, rather than a list restated here. `db.user` stays; `db.password` does not.
82+
const leaf = key.slice(key.lastIndexOf('.') + 1);
83+
return isSensitiveKey(key) || isSensitiveKey(leaf) || isSensitiveInput({ name: leaf })
84+
? REDACTED
85+
: value;
86+
}

0 commit comments

Comments
 (0)