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.
Files changed (42) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +6 -0
  3. package/dist/core/config/load.d.ts +8 -0
  4. package/dist/core/config/load.d.ts.map +1 -1
  5. package/dist/core/config/load.js +4 -0
  6. package/dist/core/config/load.js.map +1 -1
  7. package/dist/core/config/namespace.d.ts +4 -0
  8. package/dist/core/config/namespace.d.ts.map +1 -1
  9. package/dist/core/config/schema.d.ts +8 -0
  10. package/dist/core/config/schema.d.ts.map +1 -1
  11. package/dist/core/config/schema.js +5 -1
  12. package/dist/core/config/schema.js.map +1 -1
  13. package/dist/core/config/statement-observer.d.ts +33 -9
  14. package/dist/core/config/statement-observer.d.ts.map +1 -1
  15. package/dist/core/surface/types.d.ts +4 -0
  16. package/dist/core/surface/types.d.ts.map +1 -1
  17. package/dist/data/db/namespace.d.ts +1 -1
  18. package/dist/data/db/namespace.d.ts.map +1 -1
  19. package/dist/data/db/namespace.js +1 -1
  20. package/dist/data/db/namespace.js.map +1 -1
  21. package/dist/data/db/pool.js +2 -2
  22. package/dist/data/db/pool.js.map +1 -1
  23. package/dist/data/db/query-size.d.ts +18 -0
  24. package/dist/data/db/query-size.d.ts.map +1 -0
  25. package/dist/data/db/query-size.js +68 -0
  26. package/dist/data/db/query-size.js.map +1 -0
  27. package/dist/data/db/query-watch.d.ts +16 -0
  28. package/dist/data/db/query-watch.d.ts.map +1 -1
  29. package/dist/data/db/query-watch.js +57 -9
  30. package/dist/data/db/query-watch.js.map +1 -1
  31. package/dist/test/plugin.js +3 -3
  32. package/dist/test/plugin.js.map +1 -1
  33. package/package.json +6 -2
  34. package/src/core/config/load.ts +12 -0
  35. package/src/core/config/schema.ts +5 -1
  36. package/src/core/config/statement-observer.ts +33 -9
  37. package/src/core/surface/types.ts +4 -0
  38. package/src/data/db/namespace.ts +1 -1
  39. package/src/data/db/pool.ts +2 -2
  40. package/src/data/db/query-size.ts +63 -0
  41. package/src/data/db/query-watch.ts +71 -9
  42. 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 labelFor(sqlText: string): string {
65
- return currentQueryLabel() ?? sqlText.replace(/\s+/g, ' ').trim().slice(0, LABEL_SQL_CHARS)
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 = labelFor(sqlText)
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 (onSettled) outcome = { result }
206
+ if (needsOutcome) outcome = { result }
148
207
  return result
149
208
  } catch (err) {
150
- if (onSettled) outcome = { error: err }
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: labelFor(sqlText), durationMs, database }, 'query:slow')
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,
@@ -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 } : {}) })