pipework 0.283.0 → 0.285.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +6 -0
- package/dist/core/config/load.d.ts +8 -0
- package/dist/core/config/load.d.ts.map +1 -1
- package/dist/core/config/load.js +4 -0
- package/dist/core/config/load.js.map +1 -1
- package/dist/core/config/namespace.d.ts +4 -0
- package/dist/core/config/namespace.d.ts.map +1 -1
- package/dist/core/config/schema.d.ts +8 -0
- package/dist/core/config/schema.d.ts.map +1 -1
- package/dist/core/config/schema.js +5 -1
- package/dist/core/config/schema.js.map +1 -1
- package/dist/core/config/statement-observer.d.ts +33 -9
- package/dist/core/config/statement-observer.d.ts.map +1 -1
- package/dist/core/surface/types.d.ts +4 -0
- package/dist/core/surface/types.d.ts.map +1 -1
- package/dist/data/db/namespace.d.ts +1 -1
- package/dist/data/db/namespace.d.ts.map +1 -1
- package/dist/data/db/namespace.js +1 -1
- package/dist/data/db/namespace.js.map +1 -1
- package/dist/data/db/pool.js +2 -2
- package/dist/data/db/pool.js.map +1 -1
- package/dist/data/db/query-size.d.ts +18 -0
- package/dist/data/db/query-size.d.ts.map +1 -0
- package/dist/data/db/query-size.js +68 -0
- package/dist/data/db/query-size.js.map +1 -0
- package/dist/data/db/query-watch.d.ts +16 -0
- package/dist/data/db/query-watch.d.ts.map +1 -1
- package/dist/data/db/query-watch.js +57 -9
- package/dist/data/db/query-watch.js.map +1 -1
- package/dist/test/plugin.js +3 -3
- package/dist/test/plugin.js.map +1 -1
- package/package.json +6 -2
- package/src/core/config/load.ts +12 -0
- package/src/core/config/schema.ts +5 -1
- package/src/core/config/statement-observer.ts +33 -9
- package/src/core/surface/types.ts +4 -0
- package/src/data/db/namespace.ts +1 -1
- package/src/data/db/pool.ts +2 -2
- package/src/data/db/query-size.ts +63 -0
- package/src/data/db/query-watch.ts +71 -9
- package/src/test/plugin.ts +3 -3
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import postgres from 'postgres'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
2
3
|
import { getLogger } from '../../core/logging/logger.js'
|
|
3
4
|
import { currentQueryLabel } from './query-label.js'
|
|
4
5
|
import { DrizzleQueryError } from '../query/errors.js'
|
|
6
|
+
import { parameterBytes, setOperationArms } from './query-size.js'
|
|
5
7
|
import type { StatementWatch } from '../query/postgres-js/session.js'
|
|
6
8
|
import type { StatementSettledError, StatementSettledObserver } from '../../core/config/statement-observer.js'
|
|
7
9
|
|
|
8
10
|
const LABEL_SQL_CHARS = 120
|
|
11
|
+
const STATEMENT_ID_HEX_CHARS = 16
|
|
9
12
|
const SAMPLE_TIMEOUT_MS = 2_000
|
|
10
13
|
const PG_TRUNCATED_QUERY_LENGTH = 1_000
|
|
11
14
|
|
|
@@ -18,6 +21,24 @@ interface BackendSample {
|
|
|
18
21
|
|
|
19
22
|
const UNKNOWN_BACKEND: BackendSample = { pid: null, state: null, waitEventType: null, waitEvent: null }
|
|
20
23
|
|
|
24
|
+
/** `observability.largeQuery*` budgets; 0 disables a budget and nothing is measured for it. */
|
|
25
|
+
export interface LargeQueryBudgets {
|
|
26
|
+
readonly parameterBytes: number
|
|
27
|
+
readonly sqlChars: number
|
|
28
|
+
readonly arms: number
|
|
29
|
+
readonly rows: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The budgets of a resolved `observability` section. */
|
|
33
|
+
export function largeQueryBudgets(o: {
|
|
34
|
+
readonly largeQueryParamBytes: number
|
|
35
|
+
readonly largeQuerySqlChars: number
|
|
36
|
+
readonly largeQueryArms: number
|
|
37
|
+
readonly largeQueryRows: number
|
|
38
|
+
}): LargeQueryBudgets {
|
|
39
|
+
return { parameterBytes: o.largeQueryParamBytes, sqlChars: o.largeQuerySqlChars, arms: o.largeQueryArms, rows: o.largeQueryRows }
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
/** What a pool's watch needs: where it logs, how slow is slow, and how to reach the server for a wait-event sample. */
|
|
22
43
|
export interface StatementWatchOptions {
|
|
23
44
|
readonly database: string
|
|
@@ -27,6 +48,8 @@ export interface StatementWatchOptions {
|
|
|
27
48
|
readonly inFlightQueryMs: number | undefined
|
|
28
49
|
/** `observability.onStatementSettled`; undefined: nothing extra is tracked or fired. */
|
|
29
50
|
readonly onSettled?: StatementSettledObserver | undefined
|
|
51
|
+
/** Undefined: no `query:large` check. */
|
|
52
|
+
readonly budgets?: LargeQueryBudgets | undefined
|
|
30
53
|
}
|
|
31
54
|
|
|
32
55
|
export interface StatementWatchHandle {
|
|
@@ -61,8 +84,22 @@ function settledError(raw: unknown): StatementSettledError {
|
|
|
61
84
|
}
|
|
62
85
|
}
|
|
63
86
|
|
|
64
|
-
function
|
|
65
|
-
return
|
|
87
|
+
function collapse(sqlText: string): string {
|
|
88
|
+
return sqlText.replace(/\s+/g, ' ').trim()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The first 120 characters of the whitespace-collapsed statement text: the `sql` of the log lines and `statementPrefix`. */
|
|
92
|
+
function sqlPrefix(sqlText: string): string {
|
|
93
|
+
return collapse(sqlText).slice(0, LABEL_SQL_CHARS)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `v1:` + the first 16 hex characters (64 bits) of the SHA-256 of the whitespace-collapsed
|
|
98
|
+
* parameterized text. 64 bits keeps a collision among an app's few thousand statement shapes
|
|
99
|
+
* negligible (birthday bound near 4 billion shapes) while the id stays short in a log line.
|
|
100
|
+
*/
|
|
101
|
+
function statementIdOf(sqlText: string): string {
|
|
102
|
+
return `v1:${createHash('sha256').update(collapse(sqlText)).digest('hex').slice(0, STATEMENT_ID_HEX_CHARS)}`
|
|
66
103
|
}
|
|
67
104
|
|
|
68
105
|
/**
|
|
@@ -72,7 +109,9 @@ function labelFor(sqlText: string): string {
|
|
|
72
109
|
* dedicated connection, never the statement's own and never the app pool.
|
|
73
110
|
*/
|
|
74
111
|
export function createStatementWatch(options: StatementWatchOptions): StatementWatchHandle {
|
|
75
|
-
const { database, url, slowQueryMs, inFlightQueryMs, onSettled } = options
|
|
112
|
+
const { database, url, slowQueryMs, inFlightQueryMs, onSettled, budgets } = options
|
|
113
|
+
const watchesSize = budgets !== undefined && (budgets.parameterBytes > 0 || budgets.sqlChars > 0 || budgets.arms > 0 || budgets.rows > 0)
|
|
114
|
+
const needsOutcome = onSettled !== undefined || (budgets?.rows ?? 0) > 0
|
|
76
115
|
// Keyed on the parameter array the driver builds for a statement: the same array reaches the
|
|
77
116
|
// debug hook (with the connection ordinal) and sits on the pending query the session hands us.
|
|
78
117
|
const connections = onSettled ? new WeakMap<object, number>() : undefined
|
|
@@ -119,6 +158,25 @@ export function createStatementWatch(options: StatementWatchOptions): StatementW
|
|
|
119
158
|
}
|
|
120
159
|
}
|
|
121
160
|
|
|
161
|
+
/** One `query:large` per statement over any enabled budget, carrying every enabled measurement. Never throws. */
|
|
162
|
+
function warnIfLarge(sqlText: string, parameters: unknown, outcome: { result: unknown } | { error: unknown } | undefined, b: LargeQueryBudgets): void {
|
|
163
|
+
try {
|
|
164
|
+
const measured: Record<string, number> = {}
|
|
165
|
+
const over: string[] = []
|
|
166
|
+
const check = (name: string, budget: number, measure: () => number): void => {
|
|
167
|
+
if (budget <= 0) return
|
|
168
|
+
const value = measure()
|
|
169
|
+
measured[name] = value
|
|
170
|
+
if (value > budget) over.push(name)
|
|
171
|
+
}
|
|
172
|
+
check('parameterBytes', b.parameterBytes, () => parameterBytes(parameters))
|
|
173
|
+
check('sqlChars', b.sqlChars, () => sqlText.length)
|
|
174
|
+
check('arms', b.arms, () => setOperationArms(sqlText))
|
|
175
|
+
check('rows', b.rows, () => (outcome !== undefined && 'result' in outcome ? rowsOf(outcome.result) : 0))
|
|
176
|
+
if (over.length > 0) getLogger().warn({ label: currentQueryLabel() ?? null, sql: sqlPrefix(sqlText), database, over, ...measured }, 'query:large')
|
|
177
|
+
} catch { /* a measurement must never affect the statement */ }
|
|
178
|
+
}
|
|
179
|
+
|
|
122
180
|
const watch: StatementWatch = async <T>(sqlText: string, run: (seen: (pending: object) => void) => Promise<T>): Promise<T> => {
|
|
123
181
|
const startedMs = Date.now()
|
|
124
182
|
let pendingQuery: object | undefined
|
|
@@ -128,13 +186,14 @@ export function createStatementWatch(options: StatementWatchOptions): StatementW
|
|
|
128
186
|
let settled = false
|
|
129
187
|
if (inFlightQueryMs !== undefined) {
|
|
130
188
|
const log = getLogger()
|
|
131
|
-
const label =
|
|
189
|
+
const label = currentQueryLabel() ?? null
|
|
190
|
+
const sql = sqlPrefix(sqlText)
|
|
132
191
|
interval = setInterval(() => {
|
|
133
192
|
const elapsedS = Math.round((Date.now() - startedMs) / 100) / 10
|
|
134
193
|
void sampleBackend(sqlText, startedMs).then(backend => {
|
|
135
194
|
if (settled) return
|
|
136
195
|
log.warn({
|
|
137
|
-
label, elapsedS, database,
|
|
196
|
+
label, sql, elapsedS, database,
|
|
138
197
|
pid: backend.pid, state: backend.state,
|
|
139
198
|
waitEventType: backend.waitEventType, waitEvent: backend.waitEvent,
|
|
140
199
|
}, 'query:in-flight')
|
|
@@ -144,23 +203,26 @@ export function createStatementWatch(options: StatementWatchOptions): StatementW
|
|
|
144
203
|
}
|
|
145
204
|
try {
|
|
146
205
|
const result = await run(seen)
|
|
147
|
-
if (
|
|
206
|
+
if (needsOutcome) outcome = { result }
|
|
148
207
|
return result
|
|
149
208
|
} catch (err) {
|
|
150
|
-
if (
|
|
209
|
+
if (needsOutcome) outcome = { error: err }
|
|
151
210
|
throw err
|
|
152
211
|
} finally {
|
|
153
212
|
settled = true
|
|
154
213
|
if (interval !== undefined) clearInterval(interval)
|
|
155
214
|
const durationMs = Date.now() - startedMs
|
|
156
215
|
if (slowQueryMs > 0 && durationMs > slowQueryMs) {
|
|
157
|
-
getLogger().warn({ label:
|
|
216
|
+
getLogger().warn({ label: currentQueryLabel() ?? null, sql: sqlPrefix(sqlText), durationMs, database }, 'query:slow')
|
|
158
217
|
}
|
|
218
|
+
const parameters = pendingQuery === undefined ? undefined : (pendingQuery as { parameters?: unknown }).parameters
|
|
219
|
+
if (watchesSize) warnIfLarge(sqlText, parameters, outcome, budgets)
|
|
159
220
|
if (onSettled && outcome !== undefined) {
|
|
160
|
-
const parameters = pendingQuery === undefined ? undefined : (pendingQuery as { parameters?: unknown }).parameters
|
|
161
221
|
const connectionId = typeof parameters === 'object' && parameters !== null ? connections?.get(parameters) ?? null : null
|
|
162
222
|
fireSettled({
|
|
163
223
|
label: currentQueryLabel() ?? null,
|
|
224
|
+
statementId: statementIdOf(sqlText),
|
|
225
|
+
statementPrefix: sqlPrefix(sqlText),
|
|
164
226
|
durationMs,
|
|
165
227
|
rows: 'result' in outcome ? rowsOf(outcome.result) : 0,
|
|
166
228
|
connectionId,
|
package/src/test/plugin.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { runInContext } from '../request/context/store.js'
|
|
|
8
8
|
import type { Flow } from '../request/context/types.js'
|
|
9
9
|
import { randomUUID } from 'node:crypto'
|
|
10
10
|
import { createDebugFn, isObserverOnly } from '../data/db/instrumentation.js'
|
|
11
|
-
import { createStatementWatch } from '../data/db/query-watch.js'
|
|
11
|
+
import { createStatementWatch, largeQueryBudgets } from '../data/db/query-watch.js'
|
|
12
12
|
import { markObservedConnection } from '../data/query/postgres-js/driver-error-boundary.js'
|
|
13
13
|
import type { ResolvedObservability } from '../core/config/load.js'
|
|
14
14
|
import { collapseTransactionsToSavepoints } from './savepoints.js'
|
|
@@ -127,7 +127,7 @@ async function createIsolatedConnection(
|
|
|
127
127
|
observability: ResolvedObservability,
|
|
128
128
|
withSystem: boolean,
|
|
129
129
|
): Promise<PostgresJsDatabase> {
|
|
130
|
-
const watch = createStatementWatch({ database: name, url: testDb.url, slowQueryMs: observability.slowQueryMs, inFlightQueryMs: observability.inFlightQueryMs, onSettled: observability.onStatementSettled })
|
|
130
|
+
const watch = createStatementWatch({ database: name, url: testDb.url, slowQueryMs: observability.slowQueryMs, inFlightQueryMs: observability.inFlightQueryMs, onSettled: observability.onStatementSettled, budgets: largeQueryBudgets(observability) })
|
|
131
131
|
const debug = createDebugFn(observability, watch.noteConnection)
|
|
132
132
|
const client = postgres(testDb.url, { max: 1, onnotice: () => {}, ...(debug ? { debug } : {}) })
|
|
133
133
|
if (isObserverOnly(observability)) markObservedConnection(client)
|
|
@@ -178,7 +178,7 @@ export function createSetupConnection(
|
|
|
178
178
|
observability?: ResolvedObservability,
|
|
179
179
|
): { drizzle: PostgresJsDatabase; close: () => Promise<void> } {
|
|
180
180
|
const watch = observability
|
|
181
|
-
? createStatementWatch({ database: 'setup', url: testDb.url, slowQueryMs: observability.slowQueryMs, inFlightQueryMs: observability.inFlightQueryMs, onSettled: observability.onStatementSettled })
|
|
181
|
+
? createStatementWatch({ database: 'setup', url: testDb.url, slowQueryMs: observability.slowQueryMs, inFlightQueryMs: observability.inFlightQueryMs, onSettled: observability.onStatementSettled, budgets: largeQueryBudgets(observability) })
|
|
182
182
|
: undefined
|
|
183
183
|
const debug = observability ? createDebugFn(observability, watch?.noteConnection) : undefined
|
|
184
184
|
const client = postgres(testDb.url, { max: 1, onnotice: () => {}, ...(debug ? { debug } : {}) })
|