@fabrika/app 0.4.5 → 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 +8 -8
- package/skill/SKILL.md +1 -1
- package/skill/references/app-setup.md +8 -8
- package/skill/references/errors.md +17 -2
- package/src/bun.ts +71 -2
- package/src/cloudflare.ts +51 -12
- package/src/implement/boot.ts +3 -1
- package/src/implement/crashes.ts +84 -0
- package/src/implement/failures.ts +32 -18
- package/src/implement/index.ts +2 -1
- package/src/jobs/exception.ts +139 -0
- package/src/jobs/report.ts +85 -35
- package/src/jobs/runner.ts +13 -3
- package/src/jobs/scrub.ts +98 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/app",
|
|
3
|
-
"version": "0.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.
|
|
97
|
-
"@fabrika/auth-core": "^0.4.
|
|
98
|
-
"@fabrika/db": "^0.4.
|
|
99
|
-
"@fabrika/jwks": "^0.4.
|
|
100
|
-
"@fabrika/platform": "^0.4.
|
|
101
|
-
"@fabrika/platform-node": "^0.4.
|
|
102
|
-
"@fabrika/proxy-core": "^0.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.
|
|
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.
|
|
23
|
-
"@fabrika/db": "^0.4.
|
|
24
|
-
"@fabrika/provider-zerops": "^0.4.
|
|
25
|
-
"@fabrika/proxy-core": "^0.4.
|
|
26
|
-
"@fabrika/ui": "^0.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.
|
|
34
|
-
"@fabrika/cli": "^0.4.
|
|
35
|
-
"@fabrika/platform": "^0.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,
|
|
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.
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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))
|
package/src/implement/boot.ts
CHANGED
|
@@ -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.
|
|
2
|
-
// and bounded identifiers only, never an error's name or message: application code, drivers and even the
|
|
3
|
-
// own refusals can put request data there.
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
#
|
|
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.#
|
|
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
|
}
|
package/src/implement/index.ts
CHANGED
|
@@ -12,8 +12,9 @@ export {
|
|
|
12
12
|
type ScanObject,
|
|
13
13
|
type ScanVerdict,
|
|
14
14
|
} from '../files/scan'
|
|
15
|
-
export type
|
|
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
|
+
}
|
package/src/jobs/report.ts
CHANGED
|
@@ -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,
|
|
3
|
-
// fingerprint and raises its new-issue and regression alerts, so each kind of
|
|
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
|
|
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
|
-
|
|
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: [...
|
|
93
|
-
tags: { ...
|
|
94
|
-
exception: { values: [
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
package/src/jobs/runner.ts
CHANGED
|
@@ -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
|
|
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({
|
|
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'
|
|
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
|
+
}
|