@fabrika/app 0.4.4 → 0.4.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -93,13 +93,13 @@
93
93
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
94
94
  },
95
95
  "dependencies": {
96
- "@fabrika/auth": "^0.4.4",
97
- "@fabrika/auth-core": "^0.4.4",
98
- "@fabrika/db": "^0.4.4",
99
- "@fabrika/jwks": "^0.4.4",
100
- "@fabrika/platform": "^0.4.4",
101
- "@fabrika/platform-node": "^0.4.4",
102
- "@fabrika/proxy-core": "^0.4.4",
96
+ "@fabrika/auth": "^0.4.6",
97
+ "@fabrika/auth-core": "^0.4.6",
98
+ "@fabrika/db": "^0.4.6",
99
+ "@fabrika/jwks": "^0.4.6",
100
+ "@fabrika/platform": "^0.4.6",
101
+ "@fabrika/platform-node": "^0.4.6",
102
+ "@fabrika/proxy-core": "^0.4.6",
103
103
  "jose": "^5.9.0"
104
104
  },
105
105
  "peerDependencies": {
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fabrika-app
3
- version: 0.4.4
3
+ version: 0.4.6
4
4
  description: >
5
5
  How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
6
6
  slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
@@ -19,20 +19,20 @@ the SPA is added with it.
19
19
  "test": "bun test --isolate"
20
20
  },
21
21
  "dependencies": {
22
- "@fabrika/app": "^0.4.4",
23
- "@fabrika/db": "^0.4.4",
24
- "@fabrika/provider-zerops": "^0.4.4",
25
- "@fabrika/proxy-core": "^0.4.4",
26
- "@fabrika/ui": "^0.4.4",
22
+ "@fabrika/app": "^0.4.6",
23
+ "@fabrika/db": "^0.4.6",
24
+ "@fabrika/provider-zerops": "^0.4.6",
25
+ "@fabrika/proxy-core": "^0.4.6",
26
+ "@fabrika/ui": "^0.4.6",
27
27
  "kysely": "0.29.6",
28
28
  "react": "^19.0.0",
29
29
  "react-dom": "^19.0.0",
30
30
  "zod": "^3.25.76"
31
31
  },
32
32
  "devDependencies": {
33
- "@fabrika/check": "^0.4.4",
34
- "@fabrika/cli": "^0.4.4",
35
- "@fabrika/platform": "^0.4.4",
33
+ "@fabrika/check": "^0.4.6",
34
+ "@fabrika/cli": "^0.4.6",
35
+ "@fabrika/platform": "^0.4.6",
36
36
  "@happy-dom/global-registrator": "^20.11.2",
37
37
  "@types/bun": "latest",
38
38
  "@types/react": "^19.0.0",
@@ -100,9 +100,24 @@ message. The codes you meet most:
100
100
  | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
101
101
  | `query_refused` | the data layer refused a statement or an operation: `undeclared_reach`, `sql_read_only`, `unknown_column`, `read_only`, `sql_outside_transaction` …; the reason's text names the rule |
102
102
  | `capability_refused` | a capability of another kind, dimension or action than the call takes (`wrong_capability`, `wrong_action`, `out_of_reach`), or one kept past its request (`ended`, `foreign_work`) |
103
- | `handler_failed` | your handler threw something else; the log has no more, so test the handler |
103
+ | `handler_failed` | your handler threw something else; the log has no more: the Operations issue has the stack, or test the handler |
104
104
 
105
- With an Operations DSN the same 500 is a `RequestFailed` issue in Operations. A job attempt names its failure with the
105
+ With an Operations DSN the same 500 is a `RequestFailed` issue in Operations. Its event also carries what your handler
106
+ threw: the error's type, its message, its stack and its `cause` chain. Read the stack there, not in the log. A crash
107
+ outside a request (an uncaught exception, an unhandled rejection, a failed start) is an issue too, titled by the error.
108
+
109
+ The framework removes the values of your secrets and anything shaped like a credential (a token, a key, a password in a
110
+ URL) from the message before it leaves. It cannot recognize personal data or a secret it was not told about:
111
+
112
+ - never put an e-mail address, a name, a record's text or any other personal data into an error message; name the
113
+ record by its id;
114
+ - never put a secret, a token or a request header into an error message;
115
+ - name an application variable that holds a secret so its name says so (`STRIPE_SECRET_KEY`, `MAIL_API_TOKEN`): only
116
+ such a name tells the framework to remove its value;
117
+ - keep the original error as `cause` when you rethrow (`throw new Error('could not load the invoice', { cause })`), so
118
+ the chain shows both.
119
+
120
+ A job attempt names its failure with the
106
121
  same codes and reasons: `fabrika job run` prints `job <name> failed after 1 attempt: query_refused: undeclared_reach`,
107
122
  its log line adds the text, and `kit.runJob` answers `{ status: 'failed', code, reason }`. A `DatabaseError` carries
108
123
  only the SQLSTATE: `23505` is a unique violation, `23503` a foreign key, `23502` a null in a `NOT NULL` column.
package/src/bun.ts CHANGED
@@ -15,7 +15,8 @@ import { diskAssets } from './disk-assets.js'
15
15
  import type { FetchService, RequestExecutionContext } from './fetch.js'
16
16
  import { clamscanScanner } from './files/clamscan.js'
17
17
  import { boot, BootError, type IamKeySource } from './implement/boot.js'
18
- import { captureEnvironment } from './implement/environment.js'
18
+ import { type CrashEvents, environmentCrashReporter, type ProcessCrashKind, reportFatalCrashes, reportWithin } from './implement/crashes.js'
19
+ import { type CapturedEnvironment, captureEnvironment } from './implement/environment.js'
19
20
  import { type ContractImplementation, isContractImplementation } from './implement/implement.js'
20
21
  import { issuerWorkloadTokens, type WorkloadTokenSource } from './implement/workload.js'
21
22
  import { JOB_CHECK_INTERVAL_MS, type JobRunOptions, type JobRunResult } from './jobs/runner.js'
@@ -253,17 +254,84 @@ function workloadTokensOf(framework: Readonly<Record<string, string>>): Workload
253
254
  /** The application module, imported only after the environment was captured: the generated entrypoint's `import()`. */
254
255
  export type ImplementationLoader = () => Promise<{ readonly default: ContractImplementation }>
255
256
 
257
+ export interface ContractServerOptions {
258
+ /** Where the process hears of an uncaught exception or an unhandled rejection; `process` by default. */
259
+ readonly crashes?: CrashEvents
260
+ /** What posts a report to Operations' ingest; `fetch` by default. */
261
+ readonly operationsFetch?: (request: Request) => Promise<Response>
262
+ }
263
+
264
+ /** The process's own crash events: listening suppresses Bun's default, so `failAsBefore` restores it. */
265
+ function processCrashEvents(): CrashEvents {
266
+ return {
267
+ listen(listener) {
268
+ const exception = (error: unknown) => listener('uncaught_exception', error)
269
+ const rejection = (reason: unknown) => listener('unhandled_rejection', reason)
270
+ process.on('uncaughtException', exception)
271
+ process.on('unhandledRejection', rejection)
272
+ return () => {
273
+ process.off('uncaughtException', exception)
274
+ process.off('unhandledRejection', rejection)
275
+ }
276
+ },
277
+ }
278
+ }
279
+
280
+ /** Raised again with no listener left: Bun prints it and exits with 1, exactly as without a report. */
281
+ function failAsBefore(kind: ProcessCrashKind, error: unknown): void {
282
+ if (kind === 'unhandled_rejection') {
283
+ void Promise.reject(error)
284
+ return
285
+ }
286
+ setTimeout(() => {
287
+ throw error
288
+ })
289
+ }
290
+
256
291
  /**
257
292
  * Captures the framework's variables and scrubs them from the process, then imports the application, boots it and
258
293
  * serves it on `PORT` (default 3000), on every interface: the project's private network is how the proxy reaches
259
294
  * it. Called only by the generated entrypoint, with `process.env`. SIGTERM drains. Given the provisioning credential,
260
295
  * it is the launcher instead: it never loads the application and re-runs the entrypoint as the runtime role.
296
+ * With an Operations DSN, a startup that throws is reported before it is rethrown, and so is a crash once it serves.
261
297
  */
262
- export async function serveContract(load: ImplementationLoader, env: Readonly<Record<string, string | undefined>>): Promise<ContractServer> {
298
+ export async function serveContract(
299
+ load: ImplementationLoader,
300
+ env: Readonly<Record<string, string | undefined>>,
301
+ options: ContractServerOptions = {},
302
+ ): Promise<ContractServer> {
263
303
  if (holdsProvisioningCredential(env)) {
264
304
  return relaunchAsRuntime(env)
265
305
  }
266
306
  const environment = captureEnvironment(env)
307
+ const crashes = environmentCrashReporter(environment, options.operationsFetch)
308
+ let server: ContractServer
309
+ try {
310
+ server = await startContractServer(load, environment, options.operationsFetch)
311
+ } catch (error) {
312
+ if (crashes !== null) {
313
+ await reportWithin(crashes, 'boot_failed', error)
314
+ }
315
+ throw error
316
+ }
317
+ if (crashes === null) {
318
+ return server
319
+ }
320
+ const stopListening = reportFatalCrashes({ events: options.crashes ?? processCrashEvents(), reporter: crashes, fail: failAsBefore })
321
+ return {
322
+ port: server.port,
323
+ stop: () => {
324
+ stopListening()
325
+ return server.stop()
326
+ },
327
+ }
328
+ }
329
+
330
+ async function startContractServer(
331
+ load: ImplementationLoader,
332
+ environment: CapturedEnvironment,
333
+ operationsFetch: ContractServerOptions['operationsFetch'],
334
+ ): Promise<ContractServer> {
267
335
  const port = Number.parseInt(environment.application['PORT'] === undefined ? '3000' : String(environment.application['PORT']), 10)
268
336
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
269
337
  throw new Error('PORT must be an integer between 0 and 65535')
@@ -285,6 +353,7 @@ export async function serveContract(load: ImplementationLoader, env: Readonly<Re
285
353
  membership,
286
354
  workloadTokens,
287
355
  requireProxySecret: true,
356
+ ...(operationsFetch === undefined ? {} : { operationsFetch }),
288
357
  })
289
358
  // The work a request leaves running (a failure's report, a job a form kicked) is drained before the database closes.
290
359
  const handler = createBunHandler({ fetch: (request, _env: undefined, exec) => served.fetch(request, exec) }, undefined, {
package/src/cloudflare.ts CHANGED
@@ -6,6 +6,7 @@ import { parseJwks } from '@fabrika/jwks'
6
6
  import type { FetchService, RequestExecutionContext } from './fetch.js'
7
7
  import { boot, type ContractApp, type IamKeySource } from './implement/boot.js'
8
8
  import { CONNECT_BINDING_PREFIX, type ConnectFetch } from './implement/connect.js'
9
+ import { type CrashEvents, environmentCrashReporter, reportSurvivedCrashes } from './implement/crashes.js'
9
10
  import type { SpaAssets } from './implement/dispatch.js'
10
11
  import { captureEnvironment } from './implement/environment.js'
11
12
  import type { ContractImplementation } from './implement/implement.js'
@@ -146,32 +147,70 @@ function connectBindings(env: Readonly<Record<string, unknown>>): Record<string,
146
147
  /** The application module, imported only after the environment was captured: the generated entrypoint's `import()`. */
147
148
  export type ImplementationLoader = () => Promise<{ readonly default: ContractImplementation }>
148
149
 
150
+ export interface ContractWorkerOptions {
151
+ /** Where the isolate hears of an unhandled rejection; the global `unhandledrejection` event by default. */
152
+ readonly rejections?: CrashEvents
153
+ /** What posts a report to Operations' ingest; `fetch` by default. */
154
+ readonly operationsFetch?: (request: Request) => Promise<Response>
155
+ }
156
+
157
+ /** The isolate's `unhandledrejection` event. Listening leaves the runtime's own handling, its log line, in place. */
158
+ function globalRejectionEvents(): CrashEvents {
159
+ return {
160
+ listen(listener) {
161
+ const rejection = (event: Event) => listener('unhandled_rejection', Reflect.get(event, 'reason'))
162
+ globalThis.addEventListener('unhandledrejection', rejection)
163
+ return () => globalThis.removeEventListener('unhandledrejection', rejection)
164
+ },
165
+ }
166
+ }
167
+
149
168
  /**
150
169
  * A Worker module for a contract application, called only by the generated entrypoint. A Worker receives its
151
170
  * environment with the first request, so the framework captures it, scrubs the process environment and only then
152
- * imports the application and boots it, once per environment object.
171
+ * imports the application and boots it, once per environment object. With an Operations DSN, the first failed boot
172
+ * of an isolate is reported, and once one booted, every unhandled rejection of the isolate is, within a budget.
153
173
  */
154
- export function createContractWorker(load: ImplementationLoader): {
174
+ export function createContractWorker(load: ImplementationLoader, options: ContractWorkerOptions = {}): {
155
175
  fetch(request: Request, env: Readonly<Record<string, unknown>>, exec: CloudflareExecutionContext): Promise<Response>
156
176
  } {
157
177
  const booted = new WeakMap<object, Promise<ContractApp>>()
158
- const bootFor = async (env: Readonly<Record<string, unknown>>): Promise<ContractApp> => {
178
+ let bootFailureReported = false
179
+ let watchingRejections = false
180
+ const bootFor = async (env: Readonly<Record<string, unknown>>, exec: CloudflareExecutionContext): Promise<ContractApp> => {
159
181
  const variables = Object.fromEntries(Object.entries(env).filter(([name]) => !FRAMEWORK_BINDINGS.has(name)))
160
182
  const environment = captureEnvironment(variables)
161
- const { default: app } = await load()
162
- return boot(app, {
163
- environment,
164
- iam: bindingKeySource(env['IAM']),
165
- assets: bindingAssets(env['ASSETS']),
166
- membership: bindingMembership(env['IAM']),
167
- connectBindings: connectBindings(env),
168
- })
183
+ const crashes = environmentCrashReporter(environment, options.operationsFetch)
184
+ let contractApp: ContractApp
185
+ try {
186
+ const { default: app } = await load()
187
+ contractApp = boot(app, {
188
+ environment,
189
+ iam: bindingKeySource(env['IAM']),
190
+ assets: bindingAssets(env['ASSETS']),
191
+ membership: bindingMembership(env['IAM']),
192
+ connectBindings: connectBindings(env),
193
+ ...(options.operationsFetch === undefined ? {} : { operationsFetch: options.operationsFetch }),
194
+ })
195
+ } catch (error) {
196
+ // Every request of a failed isolate boots again; one report says it.
197
+ if (crashes !== null && !bootFailureReported) {
198
+ bootFailureReported = true
199
+ exec.waitUntil(crashes.report('boot_failed', error))
200
+ }
201
+ throw error
202
+ }
203
+ if (crashes !== null && !watchingRejections) {
204
+ watchingRejections = true
205
+ reportSurvivedCrashes(options.rejections ?? globalRejectionEvents(), crashes)
206
+ }
207
+ return contractApp
169
208
  }
170
209
  return {
171
210
  async fetch(request, env, exec) {
172
211
  let contractApp = booted.get(env)
173
212
  if (contractApp === undefined) {
174
- contractApp = bootFor(env)
213
+ contractApp = bootFor(env, exec)
175
214
  booted.set(env, contractApp)
176
215
  // A failed boot is not cached: the next request boots again and fails with the same problems.
177
216
  contractApp.catch(() => booted.delete(env))
@@ -46,6 +46,7 @@ import { FILE_SWEEP_JOB, FILE_SWEEP_SCHEDULE, FILE_SWEEP_TIMEOUT_MS, fileCollect
46
46
  import { FILE_SCAN_JOB, FILE_SCAN_SCHEDULE, type FileScanner, SCAN_LIMITS } from '../files/scan'
47
47
  import { logReporter, type OperationsReporter, operationsReporter, operationsSettings, type ReportSource } from '../jobs/report'
48
48
  import { bindJobs, type ContractJobs, type HandlerErrorSink } from '../jobs/runner'
49
+ import { environmentScrubber } from '../jobs/scrub'
49
50
  import { browserConfigs } from './browser-config'
50
51
  import { createCapabilityIssuer, requestIsReadOnly } from './capability'
51
52
  import { type ChallengeFetch, type ChallengeVerifier, turnstileVerifier } from './challenge'
@@ -589,10 +590,11 @@ export function boot(app: ContractImplementation, options: BootOptions): Contrac
589
590
  checkContractDigest(options.environment.framework, artifact, problems)
590
591
  const proxySecret = readProxySecret(options.environment.framework, options.requireProxySecret === true, problems)
591
592
  const operations = operationsSettings(options.environment.framework, problems)
593
+ const scrubber = environmentScrubber(options.environment)
592
594
  const reporterFor = (source: ReportSource): OperationsReporter | null =>
593
595
  operations === null
594
596
  ? null
595
- : operationsReporter({ ...operations, source, ...(options.operationsFetch === undefined ? {} : { fetch: options.operationsFetch }) })
597
+ : operationsReporter({ ...operations, source, scrubber, ...(options.operationsFetch === undefined ? {} : { fetch: options.operationsFetch }) })
596
598
  const owned = new Set(Object.values(artifact.tables).flatMap((table) => table.scope.mode === 'dimension' ? [table.scope.dimension] : []))
597
599
  // A file collection's dimension is located through its own table and its owner's.
598
600
  for (const name of Object.keys(artifact.files)) {
@@ -0,0 +1,84 @@
1
+ // A failure outside any request or job run (ADR-0059): a boot that throws, an uncaught exception, an unhandled
2
+ // rejection. Each is one Operations event of what was thrown, scrubbed like every other report. A request's and a job
3
+ // run's failures are caught and reported where they happen, so none of them reaches these listeners.
4
+
5
+ import { type CrashKind, type CrashReporter, crashReporter, operationsSettings } from '../jobs/report'
6
+ import { environmentScrubber } from '../jobs/scrub'
7
+ import type { CapturedEnvironment } from './environment'
8
+ import { MAX_FAILURE_REPORTS_PER_MINUTE, MinuteBudget } from './failures'
9
+
10
+ /** How long a crash or a failed boot waits for its report before the process fails as it would have without one. */
11
+ export const CRASH_FLUSH_MS = 2_000
12
+
13
+ export type ProcessCrashKind = Exclude<CrashKind, 'boot_failed'>
14
+
15
+ export interface CrashEvents {
16
+ /** Starts listening; the returned function stops, which gives the runtime its own handling back. */
17
+ listen(listener: (kind: ProcessCrashKind, error: unknown) => void): () => void
18
+ }
19
+
20
+ /** The reporter of `environment`'s DSN, or `null` without a usable one: `boot` reports a DSN that is not one. */
21
+ export function environmentCrashReporter(
22
+ environment: CapturedEnvironment,
23
+ fetch: ((request: Request) => Promise<Response>) | undefined,
24
+ ): CrashReporter | null {
25
+ const settings = operationsSettings(environment.framework, [])
26
+ if (settings === null) {
27
+ return null
28
+ }
29
+ return crashReporter({ ...settings, scrubber: environmentScrubber(environment), ...(fetch === undefined ? {} : { fetch }) })
30
+ }
31
+
32
+ /** Reports and waits for the delivery, at most `flushMs`. Never rejects. */
33
+ export async function reportWithin(reporter: CrashReporter, kind: CrashKind, error: unknown, flushMs = CRASH_FLUSH_MS): Promise<void> {
34
+ let timer: ReturnType<typeof setTimeout> | undefined
35
+ const elapsed = new Promise<void>((resolve) => {
36
+ timer = setTimeout(resolve, flushMs)
37
+ })
38
+ try {
39
+ await Promise.race([reporter.report(kind, error), elapsed])
40
+ } finally {
41
+ clearTimeout(timer)
42
+ }
43
+ }
44
+
45
+ export interface FatalCrashOptions {
46
+ readonly events: CrashEvents
47
+ readonly reporter: CrashReporter
48
+ /** Fails the process as the runtime would have without a listener: the listeners are stopped by then. */
49
+ readonly fail: (kind: ProcessCrashKind, error: unknown) => void
50
+ readonly flushMs?: number
51
+ }
52
+
53
+ /**
54
+ * A process that dies of a crash (Bun): the first crash is reported, then the listeners stop and `fail` hands it back
55
+ * to the runtime, which prints it and exits as before. A second crash during that wait is not reported: the process is
56
+ * already failing.
57
+ */
58
+ export function reportFatalCrashes(options: FatalCrashOptions): () => void {
59
+ let crashing = false
60
+ const stop = options.events.listen((kind, error) => {
61
+ if (crashing) {
62
+ return
63
+ }
64
+ crashing = true
65
+ void reportWithin(options.reporter, kind, error, options.flushMs).then(() => {
66
+ stop()
67
+ options.fail(kind, error)
68
+ })
69
+ })
70
+ return stop
71
+ }
72
+
73
+ /**
74
+ * An isolate that survives an unhandled rejection (a Worker): each one is reported, at most
75
+ * `MAX_FAILURE_REPORTS_PER_MINUTE` per minute, and the runtime keeps its own handling.
76
+ */
77
+ export function reportSurvivedCrashes(events: CrashEvents, reporter: CrashReporter, now: () => number = Date.now): () => void {
78
+ const budget = new MinuteBudget(MAX_FAILURE_REPORTS_PER_MINUTE, now)
79
+ return events.listen((kind, error) => {
80
+ if (budget.admit()) {
81
+ void reporter.report(kind, error)
82
+ }
83
+ })
84
+ }
@@ -1,6 +1,8 @@
1
- // A request that answered 5xx: one server log line and, with an Operations DSN, one error event. Both carry fixed codes
2
- // and bounded identifiers only, never an error's name or message: application code, drivers and even the framework's
3
- // own refusals can put request data there. A framework failure adds its reason and the reason's catalog text.
1
+ // A request that answered 5xx: one server log line and, with an Operations DSN, one error event. The log line carries
2
+ // fixed codes and bounded identifiers only, never an error's name or message: application code, drivers and even the
3
+ // framework's own refusals can put request data there. The event adds what was thrown as its cause chain, scrubbed of
4
+ // the environment's secrets and of credential patterns, a framework failure's message replaced by its catalog text
5
+ // (ADR-0059).
4
6
 
5
7
  import { CAPABILITY_ERRORS, CapabilityError, QUERY_REFUSALS, QueryRefusedError } from '@fabrika/db/framework'
6
8
  import { isStructuralError } from '../errors'
@@ -69,16 +71,37 @@ export interface RequestFailure {
69
71
  readonly error: unknown
70
72
  }
71
73
 
72
- export class FailureReports {
73
- readonly #reporter: OperationsReporter | null
74
+ /** At most `limit` admissions per minute of `now`; the next minute starts again. */
75
+ export class MinuteBudget {
76
+ readonly #limit: number
74
77
  readonly #now: () => number
75
78
  #window = -1
76
- #reported = 0
79
+ #admitted = 0
80
+
81
+ constructor(limit: number, now: () => number = Date.now) {
82
+ this.#limit = limit
83
+ this.#now = now
84
+ }
85
+
86
+ admit(): boolean {
87
+ const window = Math.floor(this.#now() / MINUTE_MS)
88
+ if (window !== this.#window) {
89
+ this.#window = window
90
+ this.#admitted = 0
91
+ }
92
+ this.#admitted++
93
+ return this.#admitted <= this.#limit
94
+ }
95
+ }
96
+
97
+ export class FailureReports {
98
+ readonly #reporter: OperationsReporter | null
99
+ readonly #budget: MinuteBudget
77
100
 
78
101
  /** `reporter` is `null` without an Operations DSN: the log line is then the only record. */
79
102
  constructor(reporter: OperationsReporter | null, now: () => number = Date.now) {
80
103
  this.#reporter = reporter
81
- this.#now = now
104
+ this.#budget = new MinuteBudget(MAX_FAILURE_REPORTS_PER_MINUTE, now)
82
105
  }
83
106
 
84
107
  /** Never rejects. The returned delivery may outlive the response; a runtime with `waitUntil` keeps it alive. */
@@ -86,7 +109,7 @@ export class FailureReports {
86
109
  const code = failureCode(failure.error)
87
110
  const why = failureReason(failure.error)
88
111
  console.error(`fabrika: ${failure.endpoint}: ${code}${why === null ? '' : `: ${why.reason}: ${why.text}`}`)
89
- if (this.#reporter === null || !this.#admit()) {
112
+ if (this.#reporter === null || !this.#budget.admit()) {
90
113
  return Promise.resolve()
91
114
  }
92
115
  const tags: Record<string, string> = { endpoint: failure.endpoint, code, status: String(failure.status) }
@@ -101,16 +124,7 @@ export class FailureReports {
101
124
  type: 'RequestFailed',
102
125
  message: `endpoint '${failure.endpoint}' answered ${failure.status}: ${code}`,
103
126
  tags,
127
+ error: failure.error,
104
128
  })
105
129
  }
106
-
107
- #admit(): boolean {
108
- const window = Math.floor(this.#now() / MINUTE_MS)
109
- if (window !== this.#window) {
110
- this.#window = window
111
- this.#reported = 0
112
- }
113
- this.#reported++
114
- return this.#reported <= MAX_FAILURE_REPORTS_PER_MINUTE
115
- }
116
130
  }
@@ -12,8 +12,9 @@ export {
12
12
  type ScanObject,
13
13
  type ScanVerdict,
14
14
  } from '../files/scan'
15
- export type { OperationsReport, OperationsReporter } from '../jobs/report'
15
+ export { crashEnvelope, type OperationsReport, type OperationsReporter, reportEnvelope } from '../jobs/report'
16
16
  export { type ContractJobs, type JobClock, JobError, type JobFailureCode, type JobRunOptions, type JobRunResult } from '../jobs/runner'
17
+ export { secretScrubber } from '../jobs/scrub'
17
18
  export type { JobContext, JobHandler, JobHandlersOf, JobPrincipal } from '../jobs/types'
18
19
  export { LINK_BOOTSTRAP_SCRIPT } from './anonymous'
19
20
  export { boot, BootError, type BootOptions, type ContractApp, type IamKeySource } from './boot'
@@ -0,0 +1,139 @@
1
+ // A thrown value as the Sentry exception values Operations ingests (ADR-0059): its type, its scrubbed message, its
2
+ // stack frames and its cause chain, each bounded so one event stays far below the ingest's size limit.
3
+
4
+ import { isStructuralError } from '../errors'
5
+ import { failureReason } from '../implement/failures'
6
+ import type { Scrubber } from './scrub'
7
+
8
+ /** A message is cut to this many characters. */
9
+ export const MAX_MESSAGE_LENGTH = 2_048
10
+ /** A file or function name is cut to this many characters. */
11
+ export const MAX_FRAME_FIELD_LENGTH = 512
12
+ /** The frames nearest the throw are kept. */
13
+ export const MAX_FRAMES = 50
14
+ /** The thrown value and at most this many minus one causes. */
15
+ export const MAX_CAUSE_DEPTH = 5
16
+ /** The encoded exception values stay within this; Operations refuses an ingest message above 120 KiB. */
17
+ export const MAX_EXCEPTION_BYTES = 64 * 1024
18
+
19
+ /** One stack frame in Sentry's shape. */
20
+ export interface ReportedFrame {
21
+ readonly filename: string
22
+ readonly function?: string
23
+ readonly lineno?: number
24
+ readonly colno?: number
25
+ /** `false` for a dependency's or the runtime's own frame. */
26
+ readonly in_app: boolean
27
+ }
28
+
29
+ /** One exception value in Sentry's shape. */
30
+ export interface ReportedException {
31
+ readonly type: string
32
+ readonly value: string
33
+ readonly stacktrace?: { readonly frames: readonly ReportedFrame[] }
34
+ }
35
+
36
+ const FRAME_LINE = /^\s*at\s+(.*)$/
37
+ const LOCATION = /^(.*?)(?::(\d+))?(?::(\d+))?$/
38
+ const CALL = /^(.*?) \((.*)\)$/
39
+ /** An error's name is reported only when it is an identifier; anything else might be data. */
40
+ const TYPE_NAME = /^[A-Za-z_$][\w$.]{0,127}$/
41
+
42
+ function cut(text: string, length: number): string {
43
+ return text.length <= length ? text : `${text.slice(0, length)}…`
44
+ }
45
+
46
+ /** A frame of the runtime, of a dependency, or without a position is no frame of the application. */
47
+ function inApp(filename: string, line: number | undefined): boolean {
48
+ return line !== undefined
49
+ && !filename.includes('/node_modules/')
50
+ && !/^(?:node|bun|internal|native|wasm):/.test(filename)
51
+ && !/^(?:native|<anonymous>|\d*)$/.test(filename)
52
+ }
53
+
54
+ function parseFrame(text: string, scrubber: Scrubber): ReportedFrame | null {
55
+ const body = FRAME_LINE.exec(text)?.[1]?.replace(/^async\s+/, '').trim()
56
+ if (body === undefined || body === '') {
57
+ return null
58
+ }
59
+ const call = CALL.exec(body)
60
+ const name = call?.[1]?.trim()
61
+ const where = LOCATION.exec(call?.[2] ?? body)
62
+ const filename = cut(scrubber.scrub(where?.[1] ?? body), MAX_FRAME_FIELD_LENGTH)
63
+ const line = where?.[2] === undefined ? undefined : Number(where[2])
64
+ const column = where?.[3] === undefined ? undefined : Number(where[3])
65
+ return {
66
+ filename,
67
+ ...(name === undefined || name === '' ? {} : { function: cut(scrubber.scrub(name), MAX_FRAME_FIELD_LENGTH) }),
68
+ ...(line === undefined ? {} : { lineno: line }),
69
+ ...(column === undefined ? {} : { colno: column }),
70
+ in_app: inApp(filename, line),
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The frames of a V8 (Cloudflare Workers) or JavaScriptCore (Bun) `stack`, oldest call first as Sentry orders them,
76
+ * the `MAX_FRAMES` nearest the throw kept. The message lines before the first frame are not read.
77
+ */
78
+ export function parseStack(stack: string, scrubber: Scrubber): ReportedFrame[] {
79
+ const frames = stack.split('\n').flatMap((line) => {
80
+ const frame = parseFrame(line, scrubber)
81
+ return frame === null ? [] : [frame]
82
+ })
83
+ return frames.slice(0, MAX_FRAMES).reverse()
84
+ }
85
+
86
+ function describe(error: unknown, scrubber: Scrubber): ReportedException {
87
+ if (error instanceof Error) {
88
+ const type = TYPE_NAME.test(error.name) ? scrubber.scrub(error.name) : 'Error'
89
+ // A framework failure's message may quote request data or a row; its catalog text says the same without them.
90
+ const why = failureReason(error)
91
+ const value = why === null ? cut(scrubber.scrub(error.message), MAX_MESSAGE_LENGTH) : `${why.reason}: ${why.text}`
92
+ const frames = typeof error.stack === 'string' ? parseStack(error.stack, scrubber) : []
93
+ return frames.length === 0 ? { type, value } : { type, value, stacktrace: { frames } }
94
+ }
95
+ if (isStructuralError(error)) {
96
+ return { type: TYPE_NAME.test(error.type) ? error.type : 'HttpError', value: cut(scrubber.scrub(error.message), MAX_MESSAGE_LENGTH) }
97
+ }
98
+ if (typeof error === 'string') {
99
+ return { type: 'Error', value: cut(scrubber.scrub(error), MAX_MESSAGE_LENGTH) }
100
+ }
101
+ return { type: 'Error', value: `a thrown ${error === null ? 'null' : typeof error} that is not an Error` }
102
+ }
103
+
104
+ /**
105
+ * `error` and its causes, at most `MAX_CAUSE_DEPTH`, the deepest cause first and `error` last, as Sentry orders a
106
+ * chain. Every text is scrubbed and cut; the chain is then fitted within `MAX_EXCEPTION_BYTES`.
107
+ */
108
+ export function exceptionChain(error: unknown, scrubber: Scrubber): ReportedException[] {
109
+ const chain: ReportedException[] = []
110
+ const seen = new Set<unknown>()
111
+ let next: unknown = error
112
+ while (chain.length < MAX_CAUSE_DEPTH && !seen.has(next)) {
113
+ seen.add(next)
114
+ chain.push(describe(next, scrubber))
115
+ if (!(next instanceof Error) || next.cause === undefined) {
116
+ break
117
+ }
118
+ next = next.cause
119
+ }
120
+ return fitWithin(chain.reverse(), MAX_EXCEPTION_BYTES)
121
+ }
122
+
123
+ const encodedSize = (values: readonly ReportedException[]): number => new TextEncoder().encode(JSON.stringify(values)).length
124
+
125
+ /** Drops the deepest causes, then halves the frames of what is left, until `values` encodes within `maxBytes`. */
126
+ export function fitWithin(values: readonly ReportedException[], maxBytes: number): ReportedException[] {
127
+ let fitted = [...values]
128
+ while (fitted.length > 1 && encodedSize(fitted) > maxBytes) {
129
+ fitted = fitted.slice(1)
130
+ }
131
+ while (encodedSize(fitted) > maxBytes && fitted.some((value) => (value.stacktrace?.frames.length ?? 0) > 0)) {
132
+ fitted = fitted.map((value) => {
133
+ const frames = value.stacktrace?.frames ?? []
134
+ const kept = frames.slice(Math.ceil(frames.length / 2))
135
+ return kept.length === 0 ? { type: value.type, value: value.value } : { ...value, stacktrace: { frames: kept } }
136
+ })
137
+ }
138
+ return fitted
139
+ }
@@ -1,15 +1,21 @@
1
1
  // What the framework tells Operations, through the ingest every application already has (`FABRIKA_OPERATIONS_DSN`, the
2
- // Sentry envelope): a failed or missed job run, and a request that answered 5xx. Operations groups by the
3
- // fingerprint and raises its new-issue and regression alerts, so each kind of failure becomes one issue.
2
+ // Sentry envelope): a failed or missed job run, a request that answered 5xx, and a process that crashed. Operations
3
+ // groups a framework report by its fingerprint and raises its new-issue and regression alerts, so each kind of
4
+ // failure becomes one issue. What was thrown travels scrubbed as the report's cause chain (ADR-0059).
5
+
6
+ import { exceptionChain, type ReportedException } from './exception'
7
+ import type { Scrubber } from './scrub'
4
8
 
5
9
  export interface OperationsReport {
6
10
  /** Grouping in Operations: one issue per kind of problem and job or endpoint. */
7
11
  readonly fingerprint: readonly string[]
8
12
  /** The exception type Operations shows, e.g. `JobFailed`. */
9
13
  readonly type: string
10
- /** Framework-authored text only: fixed codes and declared names, never an application error's name or message. */
14
+ /** Framework-authored text only: fixed codes and declared names. What was thrown travels as `error`. */
11
15
  readonly message: string
12
16
  readonly tags: Readonly<Record<string, string>>
17
+ /** What was thrown, when something was: sent scrubbed as the cause chain of the framework's exception. */
18
+ readonly error?: unknown
13
19
  }
14
20
 
15
21
  export interface OperationsReporter {
@@ -43,18 +49,20 @@ export function operationsTarget(dsn: string): OperationsTarget | null {
43
49
  }
44
50
 
45
51
  /** The envelope's `logger`: which part of the framework reported. */
46
- export type ReportSource = 'fabrika.jobs' | 'fabrika.requests'
52
+ export type ReportSource = 'fabrika.jobs' | 'fabrika.requests' | 'fabrika.process'
47
53
 
48
54
  export interface OperationsReporterOptions {
49
55
  readonly target: OperationsTarget
50
56
  readonly release: string | null
51
57
  readonly environment: string | null
52
58
  readonly source: ReportSource
59
+ /** What every text of a thrown value passes before it leaves the process. */
60
+ readonly scrubber: Scrubber
53
61
  readonly fetch?: (request: Request) => Promise<Response>
54
62
  }
55
63
 
56
64
  /** Where the reports of one application environment go: the ingest of its DSN, its release and its environment. */
57
- export type OperationsSettings = Omit<OperationsReporterOptions, 'source' | 'fetch'>
65
+ export type OperationsSettings = Omit<OperationsReporterOptions, 'source' | 'scrubber' | 'fetch'>
58
66
 
59
67
  /**
60
68
  * The settings of `FABRIKA_OPERATIONS_DSN`, `FABRIKA_RELEASE` and `FABRIKA_ENVIRONMENT`; `null` without a DSN. A DSN
@@ -75,12 +83,18 @@ export function operationsSettings(variables: Readonly<Record<string, string>>,
75
83
 
76
84
  const hexId = (): string => crypto.randomUUID().replaceAll('-', '')
77
85
 
86
+ type EnvelopeOptions = Pick<OperationsReporterOptions, 'release' | 'environment' | 'source' | 'scrubber'>
87
+
88
+ interface EventFields {
89
+ /** Absent: Operations groups by the last value's type and in-app frames. */
90
+ readonly fingerprint?: readonly string[]
91
+ readonly tags: Readonly<Record<string, string>>
92
+ /** Oldest cause first; the last value is the one Operations titles the issue by. */
93
+ readonly values: readonly ReportedException[]
94
+ }
95
+
78
96
  /** The envelope of one error event: a header line, an item header line and the event. */
79
- export function reportEnvelope(
80
- report: OperationsReport,
81
- options: Pick<OperationsReporterOptions, 'release' | 'environment' | 'source'>,
82
- now: Date,
83
- ): string {
97
+ function eventEnvelope(fields: EventFields, options: EnvelopeOptions, now: Date): string {
84
98
  const { release, environment } = options
85
99
  const eventId = hexId()
86
100
  const event = {
@@ -89,39 +103,75 @@ export function reportEnvelope(
89
103
  level: 'error',
90
104
  platform: 'javascript',
91
105
  logger: options.source,
92
- fingerprint: [...report.fingerprint],
93
- tags: { ...report.tags },
94
- exception: { values: [{ type: report.type, value: report.message }] },
106
+ ...(fields.fingerprint === undefined ? {} : { fingerprint: [...fields.fingerprint] }),
107
+ tags: { ...fields.tags },
108
+ exception: { values: [...fields.values] },
95
109
  ...(release === null ? {} : { release }),
96
110
  ...(environment === null ? {} : { environment }),
97
111
  }
98
112
  return [JSON.stringify({ event_id: eventId, sent_at: now.toISOString() }), JSON.stringify({ type: 'event' }), JSON.stringify(event), ''].join('\n')
99
113
  }
100
114
 
101
- export function operationsReporter(options: OperationsReporterOptions): OperationsReporter {
115
+ /** A framework report: its own exception last, so the issue keeps the framework's title, after what was thrown. */
116
+ export function reportEnvelope(report: OperationsReport, options: EnvelopeOptions, now: Date): string {
117
+ const cause = report.error === undefined ? [] : exceptionChain(report.error, options.scrubber)
118
+ return eventEnvelope(
119
+ {
120
+ fingerprint: report.fingerprint,
121
+ tags: report.tags,
122
+ values: [...cause, { type: report.type, value: report.message }],
123
+ },
124
+ options,
125
+ now,
126
+ )
127
+ }
128
+
129
+ /** How a process failed outside any request or job run. */
130
+ export type CrashKind = 'uncaught_exception' | 'unhandled_rejection' | 'boot_failed'
131
+
132
+ /** A crash: what was thrown is the issue itself, grouped by Operations from its type and in-app frames. */
133
+ export function crashEnvelope(kind: CrashKind, error: unknown, options: EnvelopeOptions, now: Date): string {
134
+ return eventEnvelope({ tags: { crash: kind }, values: exceptionChain(error, options.scrubber) }, options, now)
135
+ }
136
+
137
+ async function deliver(options: OperationsReporterOptions, body: string): Promise<void> {
102
138
  const send = options.fetch ?? ((request: Request) => fetch(request))
103
- return {
104
- async report(report) {
105
- const request = new Request(options.target.envelopeUrl, {
106
- method: 'POST',
107
- headers: {
108
- 'content-type': 'application/x-sentry-envelope',
109
- 'x-sentry-auth': `Sentry sentry_version=7, sentry_key=${options.target.publicKey}`,
110
- },
111
- body: reportEnvelope(report, options, new Date()),
112
- signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
113
- })
114
- try {
115
- const response = await send(request)
116
- await response.body?.cancel()
117
- if (!response.ok) {
118
- console.error(`fabrika: a report to Operations answered ${response.status}`)
119
- }
120
- } catch {
121
- // The error may quote the ingest URL with its key.
122
- console.error('fabrika: a report to Operations could not be delivered')
123
- }
139
+ const request = new Request(options.target.envelopeUrl, {
140
+ method: 'POST',
141
+ headers: {
142
+ 'content-type': 'application/x-sentry-envelope',
143
+ 'x-sentry-auth': `Sentry sentry_version=7, sentry_key=${options.target.publicKey}`,
124
144
  },
145
+ body,
146
+ signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
147
+ })
148
+ try {
149
+ const response = await send(request)
150
+ await response.body?.cancel()
151
+ if (!response.ok) {
152
+ console.error(`fabrika: a report to Operations answered ${response.status}`)
153
+ }
154
+ } catch {
155
+ // The error may quote the ingest URL with its key.
156
+ console.error('fabrika: a report to Operations could not be delivered')
157
+ }
158
+ }
159
+
160
+ export function operationsReporter(options: OperationsReporterOptions): OperationsReporter {
161
+ return {
162
+ report: (report) => deliver(options, reportEnvelope(report, options, new Date())),
163
+ }
164
+ }
165
+
166
+ export interface CrashReporter {
167
+ /** Never throws: a report that cannot be delivered is one log line. */
168
+ report(kind: CrashKind, error: unknown): Promise<void>
169
+ }
170
+
171
+ export function crashReporter(options: Omit<OperationsReporterOptions, 'source'>): CrashReporter {
172
+ const reporting: OperationsReporterOptions = { ...options, source: 'fabrika.process' }
173
+ return {
174
+ report: (kind, error) => deliver(reporting, crashEnvelope(kind, error, reporting, new Date())),
125
175
  }
126
176
  }
127
177
 
@@ -50,7 +50,7 @@ const UNIT_MS: Readonly<Record<string, number>> = { s: 1_000, m: 60_000, h: 3_60
50
50
 
51
51
  /**
52
52
  * Why a run failed. Fixed codes only: an application error's name and message may carry a secret, so neither is
53
- * ever logged, returned or reported.
53
+ * ever logged or returned. Only the report to Operations carries them, scrubbed (ADR-0059).
54
54
  */
55
55
  export type JobFailureCode =
56
56
  | 'timeout'
@@ -516,6 +516,8 @@ async function runLeased(
516
516
  interface JobFailure {
517
517
  readonly code: JobFailureCode
518
518
  readonly why: FailureReason | null
519
+ /** What the attempt threw, which only the report carries, scrubbed (ADR-0059); never a log line or the result. */
520
+ readonly thrown?: unknown
519
521
  }
520
522
 
521
523
  const failure = (code: JobFailureCode): JobFailure => ({ code, why: null })
@@ -531,7 +533,13 @@ async function failed(job: { readonly name: string }, attempts: number, outcome:
531
533
  if (why !== null) {
532
534
  tags['reason'] = why.reason
533
535
  }
534
- await reporter.report({ fingerprint: ['fabrika-job-failed', job.name], type: 'JobFailed', message, tags })
536
+ await reporter.report({
537
+ fingerprint: ['fabrika-job-failed', job.name],
538
+ type: 'JobFailed',
539
+ message,
540
+ tags,
541
+ ...('thrown' in outcome ? { error: outcome.thrown } : {}),
542
+ })
535
543
  return why === null ? { status: 'failed', attempts, code } : { status: 'failed', attempts, code, reason: why.reason }
536
544
  }
537
545
 
@@ -630,7 +638,9 @@ function failureOf(error: unknown): JobFailure {
630
638
  return failure('lease_lost')
631
639
  }
632
640
  const code = failureCode(error)
633
- return code === 'query_refused' || code === 'capability_refused' ? { code, why: failureReason(error) } : failure('handler_failed')
641
+ return code === 'query_refused' || code === 'capability_refused'
642
+ ? { code, why: failureReason(error), thrown: error }
643
+ : { code: 'handler_failed', why: null, thrown: error }
634
644
  }
635
645
 
636
646
  function jobContext(
@@ -0,0 +1,98 @@
1
+ // What an error report must not carry (ADR-0059): the values of the application's secrets, and anything shaped like a
2
+ // credential — a JWT, an `Authorization` scheme or header, a URL's userinfo, a sensitive query parameter.
3
+
4
+ import { FABRIKA_CONTRACT_DIGEST, FABRIKA_IAM_APP_ID, FABRIKA_IAM_ISSUER } from '@fabrika/auth-core'
5
+ import type { CapturedEnvironment } from '../implement/environment'
6
+
7
+ export const REDACTED = '[redacted]'
8
+
9
+ /** A shorter value is no credential the platform generates, and scrubbing it would mangle ordinary text. */
10
+ export const MIN_SECRET_LENGTH = 8
11
+
12
+ /** The framework variables that are public by design; every other `FABRIKA_*` value is treated as a secret. */
13
+ const PUBLIC_FRAMEWORK_VARIABLES: ReadonlySet<string> = new Set([
14
+ 'FABRIKA_RELEASE',
15
+ 'FABRIKA_APP_ID',
16
+ 'FABRIKA_ENVIRONMENT',
17
+ 'FABRIKA_IAM_RPC_URL',
18
+ FABRIKA_CONTRACT_DIGEST,
19
+ FABRIKA_IAM_APP_ID,
20
+ FABRIKA_IAM_ISSUER,
21
+ ])
22
+
23
+ /**
24
+ * An application variable whose name says it holds a secret. The runtime does not know which names the application
25
+ * declared as secrets, so the name decides (backlog 176).
26
+ */
27
+ const SECRET_NAME = /secret|token|passw|private|credential|key|auth|dsn|salt|connection|cookie|session|signature|cert/i
28
+
29
+ const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g
30
+ const AUTHORIZATION_SCHEME = /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi
31
+ const CREDENTIAL_HEADER = /\b(authorization|proxy-authorization|cookie|set-cookie|x-fabrika-token|x-api-key)(["']?\s*[:=]\s*["']?)[^\s"',;]+/gi
32
+ const URL_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/?#@]*@/gi
33
+ const SENSITIVE_QUERY_PARAMETER = /([?&][^=&#\s]*(?:token|key|secret|passw|signature|sig|auth|code|session|credential)[^=&#\s]*=)[^&#\s"'<>]*/gi
34
+
35
+ export interface Scrubber {
36
+ scrub(text: string): string
37
+ }
38
+
39
+ function urlCredentials(value: string): string[] {
40
+ let url: URL
41
+ try {
42
+ url = new URL(value)
43
+ } catch {
44
+ return []
45
+ }
46
+ return [url.username, url.password].flatMap((part) => {
47
+ const decoded = safeDecode(part)
48
+ return decoded === part ? [part] : [part, decoded]
49
+ })
50
+ }
51
+
52
+ function safeDecode(part: string): string {
53
+ try {
54
+ return decodeURIComponent(part)
55
+ } catch {
56
+ return part
57
+ }
58
+ }
59
+
60
+ /** Every value of `environment` that is a secret, with the user and password of a URL-shaped one. */
61
+ export function secretValues(environment: CapturedEnvironment): string[] {
62
+ const values: string[] = []
63
+ for (const [name, value] of Object.entries(environment.framework)) {
64
+ if (!PUBLIC_FRAMEWORK_VARIABLES.has(name)) {
65
+ values.push(value, ...urlCredentials(value))
66
+ }
67
+ }
68
+ for (const [name, value] of Object.entries(environment.application)) {
69
+ if (typeof value === 'string' && SECRET_NAME.test(name)) {
70
+ values.push(value, ...urlCredentials(value))
71
+ }
72
+ }
73
+ const kept = values.map((value) => value.trim()).filter((value) => value.length >= MIN_SECRET_LENGTH)
74
+ // The longest first, so a value that contains another is replaced whole.
75
+ return [...new Set(kept)].sort((a, b) => b.length - a.length)
76
+ }
77
+
78
+ export function secretScrubber(secrets: readonly string[]): Scrubber {
79
+ return {
80
+ scrub(text) {
81
+ let scrubbed = text
82
+ for (const secret of secrets) {
83
+ scrubbed = scrubbed.replaceAll(secret, REDACTED)
84
+ }
85
+ return scrubbed
86
+ .replace(JWT, REDACTED)
87
+ .replace(AUTHORIZATION_SCHEME, `$1 ${REDACTED}`)
88
+ .replace(CREDENTIAL_HEADER, `$1$2${REDACTED}`)
89
+ .replace(URL_USERINFO, `$1${REDACTED}@`)
90
+ .replace(SENSITIVE_QUERY_PARAMETER, `$1${REDACTED}`)
91
+ },
92
+ }
93
+ }
94
+
95
+ /** The scrubber of one application environment: its secrets' values, then the credential patterns. */
96
+ export function environmentScrubber(environment: CapturedEnvironment): Scrubber {
97
+ return secretScrubber(secretValues(environment))
98
+ }