Skip to content

Commit be2e53d

Browse files
committed
perf(database): reuse identical sanitized query traces
1 parent a4ac270 commit be2e53d

3 files changed

Lines changed: 112 additions & 5 deletions

File tree

storage/framework/core/database/src/query-logger.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -271,13 +271,24 @@ function sanitizeStackTrace(stack: string): string {
271271
return out
272272
}
273273

274+
interface QueryTraceInfo {
275+
trace: string
276+
caller: Pick<QueryLogRecord, 'model' | 'method' | 'file' | 'line'>
277+
}
278+
279+
// Keep only one bounded, already-sanitized trace. Repeated queries often
280+
// originate from the same call site, but every call still captures its stack.
281+
let lastTraceInfo: QueryTraceInfo | undefined
282+
274283
/**
275284
* Extract stack trace and caller information
276285
*/
277-
function extractTraceInfo() {
286+
function extractTraceInfo(): QueryTraceInfo {
278287
try {
279288
// Get the current stack trace
280289
const stack = new Error('Stack trace capture').stack || ''
290+
if (lastTraceInfo?.trace === stack)
291+
return lastTraceInfo
281292

282293
// Get the caller information (skipping this file's functions)
283294
const stackLines = stack.split('\n').slice(1)
@@ -307,10 +318,12 @@ function extractTraceInfo() {
307318
}
308319
}
309320

310-
return {
311-
trace: sanitizeStackTrace(stack),
312-
caller,
313-
}
321+
const result = { trace: sanitizeStackTrace(stack), caller }
322+
// Do not retain secret-bearing originals as cache keys or keep an
323+
// unbounded custom Error.prepareStackTrace result alive.
324+
if (result.trace === stack && stack.length <= 8192)
325+
lastTraceInfo = result
326+
return result
314327
}
315328
catch {
316329
return { trace: '', caller: {} }
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
const { config, overridesReady } = await import('@stacksjs/config')
2+
const { db, ensureDatabaseConfigLoaded, initializeDbConfig, resetDatabaseConnection } = await import('../../src/utils')
3+
const { logQuery } = await import('../../src/query-logger')
4+
5+
await overridesReady
6+
await ensureDatabaseConfigLoaded()
7+
initializeDbConfig({
8+
app: { env: 'test' },
9+
database: { default: 'sqlite', connections: { sqlite: { database: ':memory:' } } },
10+
})
11+
config.database.queryLogging = {
12+
...config.database.queryLogging,
13+
enabled: true,
14+
excludedQueries: [],
15+
analysis: { ...config.database.queryLogging?.analysis, enabled: false },
16+
}
17+
18+
async function readSite(value: number): Promise<void> {
19+
await logQuery({ query: { sql: `SELECT ${value}`, parameters: [value] }, queryDurationMillis: value })
20+
}
21+
22+
async function otherSite(value: number): Promise<void> {
23+
await logQuery({ query: { sql: `SELECT ${value}`, parameters: [value] }, queryDurationMillis: value })
24+
}
25+
26+
try {
27+
await db.unsafe(`CREATE TABLE query_logs (
28+
id INTEGER PRIMARY KEY, query TEXT, normalized_query TEXT, duration REAL,
29+
connection TEXT, status TEXT, error TEXT, executed_at TEXT, bindings TEXT,
30+
trace TEXT, model TEXT, method TEXT, file TEXT, line INTEGER, memory_usage REAL
31+
)`).execute()
32+
33+
const callers = [readSite, readSite, otherSite, readSite]
34+
for (const [index, caller] of callers.entries()) {
35+
await caller(index + 1)
36+
await Bun.sleep(2)
37+
}
38+
39+
const records = await db.unsafe('SELECT * FROM query_logs ORDER BY id').execute()
40+
if (records.length !== 4)
41+
throw new Error(`Expected four persisted traces, received ${records.length}`)
42+
for (const [index, record] of records.entries()) {
43+
if (record.query !== `SELECT ${index + 1}` || record.bindings !== `[${index + 1}]` || record.duration !== index + 1)
44+
throw new Error('A repeated trace reused query-specific values')
45+
if (record.method !== (index === 2 ? 'otherSite' : 'readSite'))
46+
throw new Error(`Wrong caller for query ${index + 1}: ${record.method}`)
47+
if (!String(record.file).endsWith('/fixtures/query-trace.ts') || !(Number(record.line) > 0))
48+
throw new Error('Missing caller location')
49+
if (!(Number(record.memory_usage) > 0) || !record.executed_at || record.status !== 'completed')
50+
throw new Error('Missing per-query diagnostics')
51+
}
52+
if (records[0]?.trace !== records[1]?.trace || records[0]?.trace !== records[3]?.trace || records[0]?.trace === records[2]?.trace)
53+
throw new Error('Captured traces did not follow the current caller')
54+
if (records[0]?.executed_at === records[1]?.executed_at)
55+
throw new Error('A repeated trace reused its execution timestamp')
56+
57+
// Computed function names really appear in Bun stack traces. Each new
58+
// secret-shaped name must be sanitized, including a repeated invocation.
59+
for (const name of [`token_${'test'.repeat(6)}`, 'TEST_ONLY_OPAQUE_VALUE_'.repeat(3)]) {
60+
const secretSite = {
61+
[name]: () => {
62+
const pending = logQuery({ query: { sql: 'SELECT 10' }, queryDurationMillis: 10 })
63+
// Keep the named frame: Bun eliminates a direct tail call here.
64+
return pending.finally(() => {})
65+
},
66+
}
67+
for (let repetition = 0; repetition < 2; repetition++)
68+
await secretSite[name]!()
69+
const traces = await db.unsafe('SELECT trace FROM query_logs WHERE query = ?', ['SELECT 10']).execute()
70+
if (traces.length !== 2 || traces.some(row => String(row.trace).includes(name) || !String(row.trace).includes('<redacted>')))
71+
throw new Error('Secret-shaped caller name was persisted in a stack trace')
72+
await db.unsafe('DELETE FROM query_logs WHERE query = ?', ['SELECT 10']).execute()
73+
}
74+
console.log('query-trace-ok')
75+
}
76+
finally {
77+
resetDatabaseConnection()
78+
}

storage/framework/core/database/tests/query-logging.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ import { isExcludedQuery, logQuery, setQueryTracker } from '../src/query-logger'
44
import { createDatabaseQueryHooks } from '../src/utils'
55

66
describe('database query logging', () => {
7+
it('keeps persisted traces accurate and redacted across repeated and changing callers', async () => {
8+
const child = Bun.spawn([process.execPath, join(import.meta.dir, 'fixtures/query-trace.ts')], {
9+
cwd: join(import.meta.dir, '..'),
10+
env: { ...process.env, APP_ENV: 'test', DB_CONNECTION: 'sqlite', DB_DATABASE_PATH: ':memory:', DB_QUERY_LOGGING_ENABLED: 'false' },
11+
stdout: 'pipe',
12+
stderr: 'pipe',
13+
})
14+
const [exitCode, stdout, stderr] = await Promise.all([
15+
child.exited,
16+
new Response(child.stdout).text(),
17+
new Response(child.stderr).text(),
18+
])
19+
expect(exitCode, stderr).toBe(0)
20+
expect(stdout).toContain('query-trace-ok')
21+
})
22+
723
it.each([false, true])('delivers real query diagnostics across reconnects and errors (persistence: %s)', async (persistence) => {
824
const child = Bun.spawn([process.execPath, join(import.meta.dir, 'fixtures/query-logger-dispatch.ts'), String(persistence)], {
925
cwd: join(import.meta.dir, '..'),

0 commit comments

Comments
 (0)