From 52dff60f9fe65bfb1bb25ce73235a70fac9ac015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Egidijus=20Jucevi=C4=8Dius?= Date: Mon, 19 Oct 2026 15:31:12 +0300 Subject: [PATCH 1/6] feat(functions-compiler): one `_b44_error` record for a returned 5xx `errorRecords: true` on `bundle`/`bundleApp` bakes a recorder into both generated entries. It is inert unless the request carries `x-b44-feed-context`. For a handler `Response` with status >= 500 it returns that same response at once and, on a clone inside ctx.waitUntil, logs one `{ _b44_error: { v: 1, kind: "response", status, name?, message, stack? } }` through the console patch. The message comes from an Error logged during the request whose message the JSON body repeats, else the body's first non-empty error / message / detail / details / error.message (2 KB, 50 ms; the SSO "Internal server error" placeholder skipped when details is present), else the last logged Error. The stack comes only from that Error. Env and runtime secrets of 8+ characters are scrubbed, longest first, before name (64 chars), message (500 bytes) and stack (4 KB) are cut. Any failure writes nothing; the crash path is unchanged. Off by default; the public CLI never sets it, and flag-off entries and banners keep their bytes. Co-Authored-By: Claude Opus 5.5 --- packages/functions-compiler/README.md | 38 +- packages/functions-compiler/src/bundler.ts | 18 +- packages/functions-compiler/src/contracts.ts | 5 + .../functions-compiler/src/error-records.ts | 183 +++++++ .../functions-compiler/src/worker-entry.ts | 54 ++- .../__snapshots__/worker-entry.test.ts.snap | 446 +++++++++++++++++ .../test/error-records.e2e.test.ts | 450 ++++++++++++++++++ .../test/worker-entry.test.ts | 51 ++ 8 files changed, 1228 insertions(+), 17 deletions(-) create mode 100644 packages/functions-compiler/src/error-records.ts create mode 100644 packages/functions-compiler/test/error-records.e2e.test.ts diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 0040cb87d..ccab446c2 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -71,7 +71,9 @@ byte-comparable with the engine apper still runs. `functions` is sorted whatever order the shard was built in. `telemetry` and `runtimeSecrets` are there because they change the emitted bytes and a deploy has -to pair its secrets delivery with them. `compiler` is this package's version. +to pair its secrets delivery with them. `errorRecords` changes the bytes too, but +appears only when it is on, so a flag-off banner keeps its bytes. `compiler` is +this package's version. Nothing volatile may be added: a timestamp or build id would re-mint a version for unchanged code, the app id would make the same functions compile differently @@ -89,7 +91,7 @@ code that did not change, and all of these do: - the order the caller hands functions over in, for a single shard: that path builds in caller order, while a multi-shard plan sorts by name. Both branches are as apper has them, and `shard-build.e2e.test.ts` pins the difference; -- either wrapper flag. +- any wrapper flag (`postResponseTelemetry`, `runtimeSecrets`, `errorRecords`). Two behaviours differ from apper's Python on purpose, and both are tested: @@ -159,6 +161,38 @@ ask for one. Two functions in one per-app bundle can both list a `main.ts`, so a consumer that needs to know which function a frame belongs to reads that from the log line, not the map. +### Error records for a handled 5xx + +A function that catches its own error and returns a 500 leaves Cloudflare +nothing to record. `errorRecords: true` on either request bakes a small +recorder into the generated entry (single-function and per-app; not actors). +It is off by default, the public CLI never sets it, and with it off the entry +is unchanged. + +At runtime the recorder does nothing unless the request carries +`x-b44-feed-context`. When the handler then returns a `Response` with status +≥ 500, that same response goes back at once: never rebuilt, never delayed, no +header set. On a clone, inside `ctx.waitUntil`, the recorder: + +- picks the message, in this order: an Error logged during the request whose + message the JSON body repeats; else the body (JSON of at most 2,048 bytes read + within 50 ms; the first non-empty `error`, `message`, `detail`, `details` or + `error.message`, skipping `error` when it is exactly `"Internal server error"` + and `details` is present); else the last Error logged during the request. + No message means no record; +- takes the stack only from the logged Error that supplied the message; +- replaces every env-binding and runtime-secret string of 8+ characters with + `[secret]`, longest first, before cutting anything. This includes the strings + nested in JSON-valued ones, such as the private-data-sources manifest; +- caps `name` at 64 characters, `message` at 500 UTF-8 bytes (well-formed, cut + on a code point) and `stack` at 4 KB (cut at a line end); +- logs exactly one line through the console patch, so it carries + `_b44_function`: + `{ _b44_error: { v: 1, kind: "response", status, name?, message, stack? } }`. + +Any failure writes nothing and never reaches the request. An uncaught throw is +untouched: the entry still logs it and rethrows, and Cloudflare records it. + ### Process isolation `installFetchGuard()` blocks outbound `fetch` other than the dependency diff --git a/packages/functions-compiler/src/bundler.ts b/packages/functions-compiler/src/bundler.ts index c47ac9faf..cc4280f23 100644 --- a/packages/functions-compiler/src/bundler.ts +++ b/packages/functions-compiler/src/bundler.ts @@ -125,6 +125,7 @@ export async function bundle(req: BundleRequest): Promise { req.files, req.postResponseTelemetry, req.runtimeSecrets, + req.errorRecords, ); } catch (e) { return denoCompatErrorOrThrow(e); @@ -169,6 +170,7 @@ async function assembleApp(req: BundleAppRequest): Promise { const build: AppBuild = { telemetry: req.postResponseTelemetry ?? false, runtimeSecrets: req.runtimeSecrets ?? false, + errorRecords: req.errorRecords ?? false, sourceMap: req.sourceMap ?? false, }; const entries: AppFunctionEntry[] = req.functions.map((fn, index) => ({ @@ -218,11 +220,13 @@ async function assembleApp(req: BundleAppRequest): Promise { return bundleAppPerFunction(entries, build); } -/** How a per-app build is wrapped and emitted. `telemetry` and `runtimeSecrets` - * change the generated entry; `sourceMap` only adds a map beside the module. */ +/** How a per-app build is wrapped and emitted. `telemetry`, `runtimeSecrets` + * and `errorRecords` change the generated entry; `sourceMap` only adds a map + * beside the module. */ interface AppBuild { telemetry: boolean; runtimeSecrets: boolean; + errorRecords: boolean; sourceMap: boolean; } @@ -235,7 +239,12 @@ async function compileApp( build: AppBuild, ): Promise { const outcome = await installAndCompile( - prepareApp(entries, build.telemetry, build.runtimeSecrets), + prepareApp( + entries, + build.telemetry, + build.runtimeSecrets, + build.errorRecords, + ), build.sourceMap, ); if (!outcome.ok) return { ok: false, errors: outcome.errors }; @@ -427,7 +436,7 @@ type FunctionProbe = * Compiles in the real `runtimeSecrets` mode so a mode-dependent failure (a * user file named the reserved activation filename) is attributed to its * function instead of leaking out of the final assembly as a 500. Its module - * is discarded, so it skips telemetry and the source map. */ + * is discarded, so it skips telemetry, error records and the source map. */ async function probeFunction( entry: AppFunctionEntry, runtimeSecrets = false, @@ -437,6 +446,7 @@ async function probeFunction( outcome = await compileApp([entry], { telemetry: false, runtimeSecrets, + errorRecords: false, sourceMap: false, }); } catch (e) { diff --git a/packages/functions-compiler/src/contracts.ts b/packages/functions-compiler/src/contracts.ts index d14ce2122..9dd378173 100644 --- a/packages/functions-compiler/src/contracts.ts +++ b/packages/functions-compiler/src/contracts.ts @@ -26,6 +26,10 @@ export const bundleRequestSchema = z // Return an external source map whose sources name the app's own files. // Off by default; only Base44's bundler service sets it. sourceMap: z.boolean().optional(), + // Bake the handled-5xx `_b44_error` log line into the generated Worker + // entry (src/error-records.ts). Off by default; only Base44's bundler + // service sets it. + errorRecords: z.boolean().optional(), }) .refine((body) => body.entry in body.files, { message: "`entry` must be a key in `files`", @@ -48,6 +52,7 @@ export const bundleAppRequestSchema = z postResponseTelemetry: z.boolean().optional(), runtimeSecrets: z.boolean().optional(), sourceMap: z.boolean().optional(), + errorRecords: z.boolean().optional(), }) .refine( (body) => diff --git a/packages/functions-compiler/src/error-records.ts b/packages/functions-compiler/src/error-records.ts new file mode 100644 index 000000000..b056c5164 --- /dev/null +++ b/packages/functions-compiler/src/error-records.ts @@ -0,0 +1,183 @@ +/** + * One `_b44_error` log line for a handled 5xx, for the Production Agent. + * + * A function that catches its own error and returns a 500 leaves Cloudflare + * nothing to record: no exception reaches the runtime, so the Tail Worker sees + * a status and no reason. When the bundle request carries `errorRecords: true` + * (only Base44's bundler sets it), the generated entry appends this prelude and + * routes the handler's response through `_b44RecordError`. Flag off appends + * nothing — the generated entry stays byte-identical. + * + * At runtime it does nothing unless the request carries `x-b44-feed-context`, + * which the backend sends only for production calls of apps that opted in; + * presence is enough. For a `Response` with status >= 500 it returns that same + * response at once — never rebuilt, never delayed, no header set — and inside + * `ctx.waitUntil` (a line logged there still belongs to the invocation's tail + * event) picks a message: + * + * 1. an Error logged during this request whose message the JSON body repeats; + * 2. otherwise the body: JSON of at most 2,048 bytes read within 50 ms, first + * non-empty string among `error`, `message`, `detail`, `details` and + * `error.message` — skipping `error` when it is exactly + * "Internal server error" and `details` is present (the SSO examples); + * 3. otherwise the last Error logged during this request. + * + * The stack comes only from the logged Error that supplied the message. Every + * env-binding and runtime-secret string of 8+ characters (and the strings + * nested in JSON-valued ones, such as the private-data-sources manifest) is + * replaced with `[secret]`, longest first, before anything is cut. Then it logs + * one `{ _b44_error: { v: 1, kind: "response", status, name?, message, stack? } }` + * through the console patch, so the line carries function attribution. Any + * failure writes nothing and never reaches the request. + * + * Must be self-contained JS (no imports) — inlined into the generated entry + * after CONSOLE_PATCH. The entry provides `_b44Context` and the patched console. + */ + +/** The request header that turns recording on. */ +const FEED_CONTEXT_HEADER = "x-b44-feed-context"; + +const ERROR_RECORD_LIMITS = { + bodyBytes: 2048, + bodyDeadlineMs: 50, + nameChars: 64, + messageBytes: 500, + stackBytes: 4096, + minSecretChars: 8, +} as const; + +const L = ERROR_RECORD_LIMITS; + +export const ERROR_RECORDS_PATCH = [ + `const _b44ErrorStore = (request) => request.headers.has(${JSON.stringify(FEED_CONTEXT_HEADER)}) ? { errorRecord: { logged: null } } : {};`, + // The last Error passed to any console method during the request, read once + // at log time (a later mutation of the Error must not change the record). + "const _b44NoteError = (a) => {", + " const c = _b44Context();", + " if (!c || !c.errorRecord) return;", + " for (let i = a.length - 1; i >= 0; i--) {", + " const x = a[i];", + " if (!(x instanceof Error)) continue;", + " const r = (k) => { try { const v = x[k]; return typeof v === 'string' ? v : undefined; } catch (e) { return undefined; } };", + " c.errorRecord.logged = { name: r('name'), message: r('message'), stack: r('stack') };", + " return;", + " }", + "};", + "for (const _l of ['log', 'info', 'warn', 'error', 'debug']) { const _f = console[_l]; console[_l] = (...a) => { try { _b44NoteError(a); } catch (e) {} return _f(...a); }; }", + // JSON body of the clone: undefined when absent, too large, too slow or not JSON. + "const _b44ReadJson = async (res) => {", + " if (!res.body) return undefined;", + " const reader = res.body.getReader();", + " const chunks = [];", + " let size = 0;", + " let timer;", + ` const deadline = new Promise((r) => { timer = setTimeout(() => r(null), ${L.bodyDeadlineMs}); });`, + " try {", + " for (;;) {", + " const step = await Promise.race([reader.read(), deadline]);", + " if (step === null) return undefined;", + " if (step.done) break;", + " size += step.value.byteLength;", + ` if (size > ${L.bodyBytes}) return undefined;`, + " chunks.push(step.value);", + " }", + " } finally {", + " clearTimeout(timer);", + " try { reader.cancel().catch(() => {}); } catch (e) {}", + " }", + " const buf = new Uint8Array(size);", + " let o = 0;", + " for (const ch of chunks) { buf.set(ch, o); o += ch.byteLength; }", + " try { return JSON.parse(new TextDecoder().decode(buf)); } catch (e) { return undefined; }", + "};", + "const _b44HasString = (v, s, d = 0) => {", + " if (v === s) return true;", + " if (d > 16 || !v || typeof v !== 'object') return false;", + " for (const k of Object.keys(v)) if (_b44HasString(v[k], s, d + 1)) return true;", + " return false;", + "};", + "const _b44BodyMessage = (b) => {", + " if (!b || typeof b !== 'object' || Array.isArray(b)) return undefined;", + " const s = (v) => (typeof v === 'string' && v.trim() !== '' ? v : undefined);", + " const skipError = Object.prototype.hasOwnProperty.call(b, 'details') && b.error === 'Internal server error';", + " const nested = b.error && typeof b.error === 'object' ? b.error.message : undefined;", + " return (skipError ? undefined : s(b.error)) ?? s(b.message) ?? s(b.detail) ?? s(b.details) ?? s(nested);", + "};", + // Bindings are objects (KV, service, Hyperdrive); only strings and the + // values nested in JSON-valued strings are candidates. + "const _b44SecretValues = (c) => {", + " const out = new Set();", + " let budget = 10000;", + " const add = (v, d) => {", + " if (--budget < 0) return;", + " if (typeof v === 'string') {", + ` if (v.length >= ${L.minSecretChars}) out.add(v);`, + " if (d < 2 && (v[0] === '{' || v[0] === '[')) { try { add(JSON.parse(v), d + 1); } catch (e) {} }", + " } else if (d > 0 && d < 16 && v && typeof v === 'object') {", + " for (const k of Object.keys(v)) add(v[k], d + 1);", + " }", + " };", + " for (const src of [c.secrets, c.workerEnv]) {", + " if (!src || typeof src !== 'object') continue;", + " for (const k of Object.keys(src)) { try { add(src[k], 0); } catch (e) {} }", + " }", + " return [...out].sort((x, y) => y.length - x.length);", + "};", + "const _b44Scrub = (s, secrets) => { let out = s; for (const v of secrets) if (out.includes(v)) out = out.split(v).join('[secret]'); return out; };", + "const _b44Well = (s) => (typeof s.toWellFormed === 'function' ? s.toWellFormed() : s);", + "const _b44Bytes = (s) => new TextEncoder().encode(s).length;", + "const _b44CutBytes = (s, max) => {", + " if (_b44Bytes(s) <= max) return s;", + " let n = 0;", + " let out = '';", + " for (const ch of s) {", + " const cp = ch.codePointAt(0);", + " const b = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4;", + " if (n + b > max) break;", + " n += b;", + " out += ch;", + " }", + " return out;", + "};", + "const _b44CutLines = (s, max) => {", + " if (_b44Bytes(s) <= max) return s;", + " let n = 0;", + " let out = '';", + " for (const line of s.split('\\n')) {", + " const b = _b44Bytes(line) + (out ? 1 : 0);", + " if (n + b > max) break;", + " out += (out ? '\\n' : '') + line;", + " n += b;", + " }", + " return out;", + "};", + "const _b44WriteErrorRecord = async (c, status, copy, logged) => {", + " try {", + " const body = await _b44ReadJson(copy);", + " const lm = logged && logged.message ? logged.message : undefined;", + " let message;", + " let fromLogged = false;", + " if (lm !== undefined && body !== undefined && _b44HasString(body, lm)) { message = lm; fromLogged = true; }", + " else { message = _b44BodyMessage(body); if (message === undefined && lm !== undefined) { message = lm; fromLogged = true; } }", + " if (message === undefined) return;", + " const secrets = _b44SecretValues(c);", + " const clean = (s) => _b44Well(_b44Scrub(s, secrets));", + " const rec = { v: 1, kind: 'response', status };", + ` const name = fromLogged && logged.name ? Array.from(clean(logged.name)).slice(0, ${L.nameChars}).join('') : '';`, + " if (name) rec.name = name;", + ` rec.message = _b44CutBytes(clean(message), ${L.messageBytes});`, + ` const stack = fromLogged && logged.stack ? _b44CutLines(clean(logged.stack), ${L.stackBytes}) : '';`, + " if (stack) rec.stack = stack;", + " console.error({ _b44_error: rec });", + " } catch (e) {}", + "};", + // Returns `res` itself in every path; the work rides waitUntil on a clone. + "const _b44RecordError = (res) => {", + " try {", + " const c = _b44Context();", + " if (!c || !c.errorRecord || !c.waitUntil || !(res instanceof Response) || !(res.status >= 500)) return res;", + " c.waitUntil(_b44WriteErrorRecord(c, res.status, res.clone(), c.errorRecord.logged));", + " } catch (e) {}", + " return res;", + "};", +].join("\n"); diff --git a/packages/functions-compiler/src/worker-entry.ts b/packages/functions-compiler/src/worker-entry.ts index e8cfbacb3..0e4cf7be6 100644 --- a/packages/functions-compiler/src/worker-entry.ts +++ b/packages/functions-compiler/src/worker-entry.ts @@ -7,6 +7,7 @@ import { asset } from "./assets.js"; import type { AppFunctionInput } from "./contracts.js"; +import { ERROR_RECORDS_PATCH } from "./error-records.js"; import { DenoCompatError } from "./errors.js"; import { RUNTIME_CONTEXT_SPECIFIER } from "./esbuild/runtime-context-virtual.js"; import { @@ -122,6 +123,7 @@ export async function prepareFunction( files: Record, postResponseTelemetry = false, runtimeSecrets = false, + errorRecords = false, ): Promise { assertNoReservedFilenames(files); return { @@ -136,6 +138,7 @@ export async function prepareFunction( entry, postResponseTelemetry, runtimeSecrets, + errorRecords, ), }, }; @@ -162,11 +165,14 @@ function buildBanner( entries: AppFunctionEntry[], telemetry: boolean, runtimeSecrets: boolean, + errorRecords: boolean, ): string { return `//!b44:${BANNER_FORMAT} ${JSON.stringify({ functions: entries.map(({ fn }) => fn.name).sort(), telemetry, runtimeSecrets, + // Present only when on, so a flag-off banner keeps its bytes. + ...(errorRecords ? { errorRecords } : {}), compiler: COMPILER_VERSION, })}`; } @@ -188,6 +194,7 @@ export function prepareApp( entries: AppFunctionEntry[], postResponseTelemetry = false, runtimeSecrets = false, + errorRecords = false, ): PreparedWorker { const files: Record = { ...workerRuntimeFiles(), @@ -209,11 +216,17 @@ export function prepareApp( moduleFiles, postResponseTelemetry, runtimeSecrets, + errorRecords, ); return { entry: ENTRY_FILENAME, files, - banner: buildBanner(entries, postResponseTelemetry, runtimeSecrets), + banner: buildBanner( + entries, + postResponseTelemetry, + runtimeSecrets, + errorRecords, + ), }; } @@ -241,6 +254,23 @@ function activationGuard(runtimeSecrets: boolean): (expr: string) => string { runtimeSecrets ? `withoutActivationHeaders(${expr}, _b44Activation)` : expr; } +// Error records (src/error-records.ts) see the handler's own response, before +// telemetry or invocation logs re-wrap it. Each piece is empty with the flag +// off, so those entries keep their bytes. +function handlerCall(errorRecords: boolean): string { + return errorRecords + ? "_b44RecordError(await handler(request, info))" + : "await handler(request, info)"; +} + +function errorRecordsPatch(errorRecords: boolean): string { + return errorRecords ? `${ERROR_RECORDS_PATCH}\n` : ""; +} + +function errorRecordsStore(errorRecords: boolean): string { + return errorRecords ? ", ..._b44ErrorStore(request)" : ""; +} + function activationImport(runtimeSecrets: boolean): string { return runtimeSecrets ? `\nimport { ensureActivation, takeActivationChallenge, withoutActivationHeaders } from "./${ACTIVATION_FILENAME}";` @@ -254,15 +284,17 @@ function buildEntrySource( userEntry: string, telemetry: boolean, runtimeSecrets = false, + errorRecords = false, ): string { const userImport = `./${userEntry.replace(/^\.\//, "")}`; // Handler responses get the shim's headers stripped (a forged error header // after side effects would read as a non-billable failed activation) and the // installing request's marker added. + const call = handlerCall(errorRecords); const handlerExpr = telemetry - ? "_b44AttachTelemetry(await handler(request, info))" - : runtimeSecrets - ? "await handler(request, info)" + ? `_b44AttachTelemetry(${call})` + : runtimeSecrets || errorRecords + ? call : "handler(request, info)"; const guard = activationGuard(runtimeSecrets); const returnExpr = guard(handlerExpr); @@ -272,7 +304,7 @@ import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as import { getRegisteredHandler, installStaticEgressFetch } from "./${SHIM_FILENAME}";${activationImport(runtimeSecrets)} ${consolePatch(runtimeSecrets)} -// Static egress reads workerEnv from the active request store. Install it +${errorRecordsPatch(errorRecords)}// Static egress reads workerEnv from the active request store. Install it // before telemetry so telemetry remains the outermost fetch wrapper. installStaticEgressFetch(); ${telemetry ? TELEMETRY_PATCH : ""} @@ -281,7 +313,7 @@ let _b44Init = null; export default { async fetch(request, env, ctx) { ${runtimeSecrets ? ACTIVATION_TAKE : ""} const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; - return _b44Run({ env: _b44Env, secrets: ${runtimeSecrets ? "process.env" : "env"}, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p)${telemetry ? `, ${TELEMETRY_STORE_FIELDS}` : ""} }, async () => { + return _b44Run({ env: _b44Env, secrets: ${runtimeSecrets ? "process.env" : "env"}, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p)${errorRecordsStore(errorRecords)}${telemetry ? `, ${TELEMETRY_STORE_FIELDS}` : ""} }, async () => { ${runtimeSecrets ? ACTIVATION_GATE : ""} if (!_b44Init) _b44Init = import(${JSON.stringify(userImport)}).catch(e => { _b44Init = null; throw e; }); const _b44Mod = await _b44Init; // Deno.serve capture wins (legacy contract, zero behavior change); @@ -322,14 +354,14 @@ function buildAppEntrySource( functionModules: string[], telemetry: boolean, runtimeSecrets = false, + errorRecords = false, ): string { const moduleImports = functionModules .map((file) => `import "./${file}";`) .join("\n"); + const call = handlerCall(errorRecords); const handlerExpr = `_b44AttachInvocationLogs(${ - telemetry - ? "_b44AttachTelemetry(await handler(request, info))" - : "await handler(request, info)" + telemetry ? `_b44AttachTelemetry(${call})` : call })`; const guard = activationGuard(runtimeSecrets); const returnExpr = guard(handlerExpr); @@ -341,7 +373,7 @@ ${moduleImports} ${consolePatch(runtimeSecrets)} ${INVOCATION_LOGS_PATCH} -// Static egress reads workerEnv from the active request store. Install it +${errorRecordsPatch(errorRecords)}// Static egress reads workerEnv from the active request store. Install it // before telemetry so telemetry remains the outermost fetch wrapper. installStaticEgressFetch(); ${telemetry ? TELEMETRY_PATCH : ""} @@ -350,7 +382,7 @@ export default { async fetch(request, env, ctx) { ${runtimeSecrets ? ACTIVATION_TAKE : ""} const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; const functionName = request.headers.get("Base44-Function-Name"); - return _b44Run({ env: _b44Env, fn: functionName ?? '', secrets: ${runtimeSecrets ? "process.env" : "env"}, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44CaptureStore(request)${telemetry ? `, ${TELEMETRY_STORE_FIELDS}` : ""} }, async () => { + return _b44Run({ env: _b44Env, fn: functionName ?? '', secrets: ${runtimeSecrets ? "process.env" : "env"}, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44CaptureStore(request)${errorRecordsStore(errorRecords)}${telemetry ? `, ${TELEMETRY_STORE_FIELDS}` : ""} }, async () => { ${runtimeSecrets ? ACTIVATION_GATE : ""} // Each early return below logs through the patch first: per-function log // queries on per-app scripts keep only stamped lines, so a bare return // would leave the failing invocation with no trace in its own logs. diff --git a/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap b/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap index a238a5f4b..a1b5a850f 100644 --- a/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap +++ b/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap @@ -453,3 +453,449 @@ export default { }; " `; + +exports[`error-records entries > are pinned when on > per-app 1`] = ` +"// Auto-generated by the base44 bundler. Do not edit. + +import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as _b44Run } from "base44:internal/runtime-context"; +import { installStaticEgressFetch, resolveHandler } from "./__base44_deno_shim.mjs"; +import "./__base44_fn_0.mjs"; + +const _b44Orig = console.log; +const _b44S = x => { if (x instanceof Error) return x.stack || x.message; try { return typeof x === 'string' ? x : (JSON.stringify(x) ?? String(x)); } catch (e) { return String(x); } }; +const _b44Fmt = (a) => { + if (typeof a[0] !== 'string' || !a[0].includes('%')) return a.map(_b44S).join(' '); + let i = 1; + const s = a[0].replace(/%([sdifoOc])/g, (_, t) => { + if (i >= a.length) return '%' + t; + const v = a[i++]; + if (t === 'd' || t === 'i') { try { return String(Math.trunc(+v)); } catch(_) { return _b44S(v); } } + if (t === 'f') { try { return String(+v); } catch(_) { return _b44S(v); } } + if (t === 'c') return ''; + return _b44S(v); + }); + const tail = a.slice(i).map(_b44S).join(' '); + return tail ? s + ' ' + tail : s; +}; +const _b44Ascii = (s) => s.replace(/[\\u007f-\\uffff]/g, (c) => '\\\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')); +const _b44Capture = (c, lvl, msg) => { + if (!c.invocationLogs) return; + const cost = _b44Ascii(JSON.stringify(msg)).length + 30; + if (c.invocationLogs.length >= 40 || c.invocationLogsBytes + cost > 6144) { c.invocationLogsDropped += 1; return; } + c.invocationLogsBytes += cost; + c.invocationLogs.push({ level: lvl, message: msg }); +}; +const _b44Wrap = (lvl, a) => { const _c = _b44Context() ?? {}; const _m = _b44Fmt(a); _b44Capture(_c, lvl, _m); _b44Orig({ _b44_env: _c.env ?? 'preview', ...(_c.fn ? { _b44_function: _c.fn } : {}), level: lvl, message: _m }); }; +console.log = (...a) => _b44Wrap('info', a); +console.info = (...a) => _b44Wrap('info', a); +console.warn = (...a) => _b44Wrap('warn', a); +console.error = (...a) => _b44Wrap('error', a); +console.debug = (...a) => _b44Wrap('debug', a); +const _b44ReservedSecrets = new Set(['BASE44_ACTOR_PRIVATE_KEY', 'BASE44_ACTOR_PUBLIC_KEY', 'BASE44_ACTOR_SCRIPT_ID', 'BASE44_PRIVATE_DATA_SOURCES']); +globalThis.Base44 = Object.assign(globalThis.Base44 ?? {}, { waitUntil: (p) => { const _c = _b44Context(); if (_c?.waitUntil) { _c.waitUntil(p); } else { Promise.resolve(p).catch(() => {}); } }, secrets: { get: (n) => { const _k = String(n); if (_b44ReservedSecrets.has(_k)) return undefined; const _v = _b44Context()?.secrets?.[_k]; return typeof _v === 'string' ? _v : undefined; } } }); +globalThis.EdgeRuntime = globalThis.EdgeRuntime ?? { waitUntil: (p) => globalThis.Base44.waitUntil(p) }; +const _b44CaptureStore = (request) => request.headers.get("X-B44-Capture-Logs") === '1' ? { invocationLogs: [], invocationLogsBytes: 0, invocationLogsDropped: 0 } : {}; +const _b44AttachInvocationLogs = (res) => { + const c = _b44Context(); + if (!c || !c.invocationLogs) return res; + if (!(res instanceof Response) || res.status === 101 || res.webSocket) return res; + let existingBytes = 25; + res.headers.forEach((v, k) => { existingBytes += k.length + v.length + 4; }); + let payload = _b44Ascii(JSON.stringify({ lines: c.invocationLogs, dropped: c.invocationLogsDropped })); + if (existingBytes + payload.length > 7168) payload = JSON.stringify({ lines: [], dropped: c.invocationLogs.length + c.invocationLogsDropped }); + if (existingBytes + payload.length > 7168) return res; + let wrapped; + try { wrapped = new Response(res.body, res); } catch (e) { return res; } + try { wrapped.headers.set("X-B44-Invocation-Logs", payload); } catch (e) {} + return wrapped; +}; +const _b44CrashResponse = (e) => { + const c = _b44Context(); + if (!c || !c.invocationLogs) return null; + const m = String(e instanceof Error ? e.message : e).toLowerCase(); + if (m.includes('cpu time limit') || m.includes('too many subrequests')) return null; + return _b44AttachInvocationLogs(new Response(JSON.stringify({ error: 'user-exception', detail: 'user worker threw an exception' }), { status: 500, headers: { 'Content-Type': 'application/json' } })); +}; +const _b44ErrorStore = (request) => request.headers.has("x-b44-feed-context") ? { errorRecord: { logged: null } } : {}; +const _b44NoteError = (a) => { + const c = _b44Context(); + if (!c || !c.errorRecord) return; + for (let i = a.length - 1; i >= 0; i--) { + const x = a[i]; + if (!(x instanceof Error)) continue; + const r = (k) => { try { const v = x[k]; return typeof v === 'string' ? v : undefined; } catch (e) { return undefined; } }; + c.errorRecord.logged = { name: r('name'), message: r('message'), stack: r('stack') }; + return; + } +}; +for (const _l of ['log', 'info', 'warn', 'error', 'debug']) { const _f = console[_l]; console[_l] = (...a) => { try { _b44NoteError(a); } catch (e) {} return _f(...a); }; } +const _b44ReadJson = async (res) => { + if (!res.body) return undefined; + const reader = res.body.getReader(); + const chunks = []; + let size = 0; + let timer; + const deadline = new Promise((r) => { timer = setTimeout(() => r(null), 50); }); + try { + for (;;) { + const step = await Promise.race([reader.read(), deadline]); + if (step === null) return undefined; + if (step.done) break; + size += step.value.byteLength; + if (size > 2048) return undefined; + chunks.push(step.value); + } + } finally { + clearTimeout(timer); + try { reader.cancel().catch(() => {}); } catch (e) {} + } + const buf = new Uint8Array(size); + let o = 0; + for (const ch of chunks) { buf.set(ch, o); o += ch.byteLength; } + try { return JSON.parse(new TextDecoder().decode(buf)); } catch (e) { return undefined; } +}; +const _b44HasString = (v, s, d = 0) => { + if (v === s) return true; + if (d > 16 || !v || typeof v !== 'object') return false; + for (const k of Object.keys(v)) if (_b44HasString(v[k], s, d + 1)) return true; + return false; +}; +const _b44BodyMessage = (b) => { + if (!b || typeof b !== 'object' || Array.isArray(b)) return undefined; + const s = (v) => (typeof v === 'string' && v.trim() !== '' ? v : undefined); + const skipError = Object.prototype.hasOwnProperty.call(b, 'details') && b.error === 'Internal server error'; + const nested = b.error && typeof b.error === 'object' ? b.error.message : undefined; + return (skipError ? undefined : s(b.error)) ?? s(b.message) ?? s(b.detail) ?? s(b.details) ?? s(nested); +}; +const _b44SecretValues = (c) => { + const out = new Set(); + let budget = 10000; + const add = (v, d) => { + if (--budget < 0) return; + if (typeof v === 'string') { + if (v.length >= 8) out.add(v); + if (d < 2 && (v[0] === '{' || v[0] === '[')) { try { add(JSON.parse(v), d + 1); } catch (e) {} } + } else if (d > 0 && d < 16 && v && typeof v === 'object') { + for (const k of Object.keys(v)) add(v[k], d + 1); + } + }; + for (const src of [c.secrets, c.workerEnv]) { + if (!src || typeof src !== 'object') continue; + for (const k of Object.keys(src)) { try { add(src[k], 0); } catch (e) {} } + } + return [...out].sort((x, y) => y.length - x.length); +}; +const _b44Scrub = (s, secrets) => { let out = s; for (const v of secrets) if (out.includes(v)) out = out.split(v).join('[secret]'); return out; }; +const _b44Well = (s) => (typeof s.toWellFormed === 'function' ? s.toWellFormed() : s); +const _b44Bytes = (s) => new TextEncoder().encode(s).length; +const _b44CutBytes = (s, max) => { + if (_b44Bytes(s) <= max) return s; + let n = 0; + let out = ''; + for (const ch of s) { + const cp = ch.codePointAt(0); + const b = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4; + if (n + b > max) break; + n += b; + out += ch; + } + return out; +}; +const _b44CutLines = (s, max) => { + if (_b44Bytes(s) <= max) return s; + let n = 0; + let out = ''; + for (const line of s.split('\\n')) { + const b = _b44Bytes(line) + (out ? 1 : 0); + if (n + b > max) break; + out += (out ? '\\n' : '') + line; + n += b; + } + return out; +}; +const _b44WriteErrorRecord = async (c, status, copy, logged) => { + try { + const body = await _b44ReadJson(copy); + const lm = logged && logged.message ? logged.message : undefined; + let message; + let fromLogged = false; + if (lm !== undefined && body !== undefined && _b44HasString(body, lm)) { message = lm; fromLogged = true; } + else { message = _b44BodyMessage(body); if (message === undefined && lm !== undefined) { message = lm; fromLogged = true; } } + if (message === undefined) return; + const secrets = _b44SecretValues(c); + const clean = (s) => _b44Well(_b44Scrub(s, secrets)); + const rec = { v: 1, kind: 'response', status }; + const name = fromLogged && logged.name ? Array.from(clean(logged.name)).slice(0, 64).join('') : ''; + if (name) rec.name = name; + rec.message = _b44CutBytes(clean(message), 500); + const stack = fromLogged && logged.stack ? _b44CutLines(clean(logged.stack), 4096) : ''; + if (stack) rec.stack = stack; + console.error({ _b44_error: rec }); + } catch (e) {} +}; +const _b44RecordError = (res) => { + try { + const c = _b44Context(); + if (!c || !c.errorRecord || !c.waitUntil || !(res instanceof Response) || !(res.status >= 500)) return res; + c.waitUntil(_b44WriteErrorRecord(c, res.status, res.clone(), c.errorRecord.logged)); + } catch (e) {} + return res; +}; +// Static egress reads workerEnv from the active request store. Install it +// before telemetry so telemetry remains the outermost fetch wrapper. +installStaticEgressFetch(); + + +export default { + async fetch(request, env, ctx) { + const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; + const functionName = request.headers.get("Base44-Function-Name"); + return _b44Run({ env: _b44Env, fn: functionName ?? '', secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44CaptureStore(request), ..._b44ErrorStore(request) }, async () => { + // Each early return below logs through the patch first: per-function log + // queries on per-app scripts keep only stamped lines, so a bare return + // would leave the failing invocation with no trace in its own logs. + const pendingHandler = functionName ? resolveHandler(functionName) : undefined; + if (pendingHandler === undefined) { + const message = \`No function registered for "\${functionName ?? ""}"\`; + console.error(message); + return _b44AttachInvocationLogs(new Response(message, { status: 404 })); + } + let handler; + try { + handler = await pendingHandler; + } catch (e) { + console.error(\`Function "\${functionName}" failed to initialize:\`, e); + return _b44AttachInvocationLogs(new Response( + \`Function "\${functionName}" failed to initialize: \${e instanceof Error ? e.message : String(e)}\`, + { status: 500 }, + )); + } + if (handler === null) { + const message = \`Function "\${functionName}" must export default a request handler or call Deno.serve()\`; + console.error(message); + return _b44AttachInvocationLogs(new Response(message, { status: 503 })); + } + // Real client IP is in the "cf-connecting-ip" header, not this placeholder. + const info = { + remoteAddr: { transport: "tcp", hostname: "0.0.0.0", port: 0 }, + }; + // Cloudflare reports uncaught exceptions as its own log events, outside + // the console patch and therefore without function attribution. Log the + // crash through the patch (stamped with _b44_function) before + // rethrowing; per-function log queries on per-app scripts keep only + // stamped lines (see log_query.event_matches_function). Known gap: + // exceptions thrown while a response body streams happen after this + // frame returns and cannot be stamped — those crash events are + // dropped from per-function views, and lines logged after the response + // starts streaming are past the invocation-logs header too. + try { + return _b44AttachInvocationLogs(_b44RecordError(await handler(request, info))); + } catch (e) { + console.error(e); + const crash = _b44CrashResponse(e); + if (crash) return crash; + throw e; + } + }); + }, +}; +" +`; + +exports[`error-records entries > are pinned when on > single function 1`] = ` +"// Auto-generated by the base44 bundler. Do not edit. + +import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as _b44Run } from "base44:internal/runtime-context"; +import { getRegisteredHandler, installStaticEgressFetch } from "./__base44_deno_shim.mjs"; + +const _b44Orig = console.log; +const _b44S = x => { if (x instanceof Error) return x.stack || x.message; try { return typeof x === 'string' ? x : (JSON.stringify(x) ?? String(x)); } catch (e) { return String(x); } }; +const _b44Fmt = (a) => { + if (typeof a[0] !== 'string' || !a[0].includes('%')) return a.map(_b44S).join(' '); + let i = 1; + const s = a[0].replace(/%([sdifoOc])/g, (_, t) => { + if (i >= a.length) return '%' + t; + const v = a[i++]; + if (t === 'd' || t === 'i') { try { return String(Math.trunc(+v)); } catch(_) { return _b44S(v); } } + if (t === 'f') { try { return String(+v); } catch(_) { return _b44S(v); } } + if (t === 'c') return ''; + return _b44S(v); + }); + const tail = a.slice(i).map(_b44S).join(' '); + return tail ? s + ' ' + tail : s; +}; +const _b44Ascii = (s) => s.replace(/[\\u007f-\\uffff]/g, (c) => '\\\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')); +const _b44Capture = (c, lvl, msg) => { + if (!c.invocationLogs) return; + const cost = _b44Ascii(JSON.stringify(msg)).length + 30; + if (c.invocationLogs.length >= 40 || c.invocationLogsBytes + cost > 6144) { c.invocationLogsDropped += 1; return; } + c.invocationLogsBytes += cost; + c.invocationLogs.push({ level: lvl, message: msg }); +}; +const _b44Wrap = (lvl, a) => { const _c = _b44Context() ?? {}; const _m = _b44Fmt(a); _b44Capture(_c, lvl, _m); _b44Orig({ _b44_env: _c.env ?? 'preview', ...(_c.fn ? { _b44_function: _c.fn } : {}), level: lvl, message: _m }); }; +console.log = (...a) => _b44Wrap('info', a); +console.info = (...a) => _b44Wrap('info', a); +console.warn = (...a) => _b44Wrap('warn', a); +console.error = (...a) => _b44Wrap('error', a); +console.debug = (...a) => _b44Wrap('debug', a); +const _b44ReservedSecrets = new Set(['BASE44_ACTOR_PRIVATE_KEY', 'BASE44_ACTOR_PUBLIC_KEY', 'BASE44_ACTOR_SCRIPT_ID', 'BASE44_PRIVATE_DATA_SOURCES']); +globalThis.Base44 = Object.assign(globalThis.Base44 ?? {}, { waitUntil: (p) => { const _c = _b44Context(); if (_c?.waitUntil) { _c.waitUntil(p); } else { Promise.resolve(p).catch(() => {}); } }, secrets: { get: (n) => { const _k = String(n); if (_b44ReservedSecrets.has(_k)) return undefined; const _v = _b44Context()?.secrets?.[_k]; return typeof _v === 'string' ? _v : undefined; } } }); +globalThis.EdgeRuntime = globalThis.EdgeRuntime ?? { waitUntil: (p) => globalThis.Base44.waitUntil(p) }; +const _b44ErrorStore = (request) => request.headers.has("x-b44-feed-context") ? { errorRecord: { logged: null } } : {}; +const _b44NoteError = (a) => { + const c = _b44Context(); + if (!c || !c.errorRecord) return; + for (let i = a.length - 1; i >= 0; i--) { + const x = a[i]; + if (!(x instanceof Error)) continue; + const r = (k) => { try { const v = x[k]; return typeof v === 'string' ? v : undefined; } catch (e) { return undefined; } }; + c.errorRecord.logged = { name: r('name'), message: r('message'), stack: r('stack') }; + return; + } +}; +for (const _l of ['log', 'info', 'warn', 'error', 'debug']) { const _f = console[_l]; console[_l] = (...a) => { try { _b44NoteError(a); } catch (e) {} return _f(...a); }; } +const _b44ReadJson = async (res) => { + if (!res.body) return undefined; + const reader = res.body.getReader(); + const chunks = []; + let size = 0; + let timer; + const deadline = new Promise((r) => { timer = setTimeout(() => r(null), 50); }); + try { + for (;;) { + const step = await Promise.race([reader.read(), deadline]); + if (step === null) return undefined; + if (step.done) break; + size += step.value.byteLength; + if (size > 2048) return undefined; + chunks.push(step.value); + } + } finally { + clearTimeout(timer); + try { reader.cancel().catch(() => {}); } catch (e) {} + } + const buf = new Uint8Array(size); + let o = 0; + for (const ch of chunks) { buf.set(ch, o); o += ch.byteLength; } + try { return JSON.parse(new TextDecoder().decode(buf)); } catch (e) { return undefined; } +}; +const _b44HasString = (v, s, d = 0) => { + if (v === s) return true; + if (d > 16 || !v || typeof v !== 'object') return false; + for (const k of Object.keys(v)) if (_b44HasString(v[k], s, d + 1)) return true; + return false; +}; +const _b44BodyMessage = (b) => { + if (!b || typeof b !== 'object' || Array.isArray(b)) return undefined; + const s = (v) => (typeof v === 'string' && v.trim() !== '' ? v : undefined); + const skipError = Object.prototype.hasOwnProperty.call(b, 'details') && b.error === 'Internal server error'; + const nested = b.error && typeof b.error === 'object' ? b.error.message : undefined; + return (skipError ? undefined : s(b.error)) ?? s(b.message) ?? s(b.detail) ?? s(b.details) ?? s(nested); +}; +const _b44SecretValues = (c) => { + const out = new Set(); + let budget = 10000; + const add = (v, d) => { + if (--budget < 0) return; + if (typeof v === 'string') { + if (v.length >= 8) out.add(v); + if (d < 2 && (v[0] === '{' || v[0] === '[')) { try { add(JSON.parse(v), d + 1); } catch (e) {} } + } else if (d > 0 && d < 16 && v && typeof v === 'object') { + for (const k of Object.keys(v)) add(v[k], d + 1); + } + }; + for (const src of [c.secrets, c.workerEnv]) { + if (!src || typeof src !== 'object') continue; + for (const k of Object.keys(src)) { try { add(src[k], 0); } catch (e) {} } + } + return [...out].sort((x, y) => y.length - x.length); +}; +const _b44Scrub = (s, secrets) => { let out = s; for (const v of secrets) if (out.includes(v)) out = out.split(v).join('[secret]'); return out; }; +const _b44Well = (s) => (typeof s.toWellFormed === 'function' ? s.toWellFormed() : s); +const _b44Bytes = (s) => new TextEncoder().encode(s).length; +const _b44CutBytes = (s, max) => { + if (_b44Bytes(s) <= max) return s; + let n = 0; + let out = ''; + for (const ch of s) { + const cp = ch.codePointAt(0); + const b = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4; + if (n + b > max) break; + n += b; + out += ch; + } + return out; +}; +const _b44CutLines = (s, max) => { + if (_b44Bytes(s) <= max) return s; + let n = 0; + let out = ''; + for (const line of s.split('\\n')) { + const b = _b44Bytes(line) + (out ? 1 : 0); + if (n + b > max) break; + out += (out ? '\\n' : '') + line; + n += b; + } + return out; +}; +const _b44WriteErrorRecord = async (c, status, copy, logged) => { + try { + const body = await _b44ReadJson(copy); + const lm = logged && logged.message ? logged.message : undefined; + let message; + let fromLogged = false; + if (lm !== undefined && body !== undefined && _b44HasString(body, lm)) { message = lm; fromLogged = true; } + else { message = _b44BodyMessage(body); if (message === undefined && lm !== undefined) { message = lm; fromLogged = true; } } + if (message === undefined) return; + const secrets = _b44SecretValues(c); + const clean = (s) => _b44Well(_b44Scrub(s, secrets)); + const rec = { v: 1, kind: 'response', status }; + const name = fromLogged && logged.name ? Array.from(clean(logged.name)).slice(0, 64).join('') : ''; + if (name) rec.name = name; + rec.message = _b44CutBytes(clean(message), 500); + const stack = fromLogged && logged.stack ? _b44CutLines(clean(logged.stack), 4096) : ''; + if (stack) rec.stack = stack; + console.error({ _b44_error: rec }); + } catch (e) {} +}; +const _b44RecordError = (res) => { + try { + const c = _b44Context(); + if (!c || !c.errorRecord || !c.waitUntil || !(res instanceof Response) || !(res.status >= 500)) return res; + c.waitUntil(_b44WriteErrorRecord(c, res.status, res.clone(), c.errorRecord.logged)); + } catch (e) {} + return res; +}; +// Static egress reads workerEnv from the active request store. Install it +// before telemetry so telemetry remains the outermost fetch wrapper. +installStaticEgressFetch(); + + +let _b44Init = null; +export default { + async fetch(request, env, ctx) { + const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; + return _b44Run({ env: _b44Env, secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44ErrorStore(request) }, async () => { + if (!_b44Init) _b44Init = import("./main.ts").catch(e => { _b44Init = null; throw e; }); + const _b44Mod = await _b44Init; + // Deno.serve capture wins (legacy contract, zero behavior change); + // a default-exported handler is the new-contract fallback. + const handler = getRegisteredHandler() ?? (typeof _b44Mod?.default === 'function' ? _b44Mod.default : null); + if (!handler) { + return new Response( + "The function must export default a request handler or call Deno.serve()", + { status: 503 }, + ); + } + // Deno's handler signature is (request, info). Cloudflare doesn't expose a + // connection address the same way; pass a best-effort placeholder. Real + // client IP is available via the "cf-connecting-ip" request header. + const info = { + remoteAddr: { transport: "tcp", hostname: "0.0.0.0", port: 0 }, + }; + return _b44RecordError(await handler(request, info)); + }); + }, +}; +" +`; diff --git a/packages/functions-compiler/test/error-records.e2e.test.ts b/packages/functions-compiler/test/error-records.e2e.test.ts new file mode 100644 index 000000000..297b9e3c1 --- /dev/null +++ b/packages/functions-compiler/test/error-records.e2e.test.ts @@ -0,0 +1,450 @@ +/** + * `errorRecords: true` — one `_b44_error` log line for a returned 5xx, read the + * way production reads it: through a Tail Worker attached to the function. + * Every case runs in real workerd, with and without `x-b44-feed-context`. + */ + +import { Miniflare } from "miniflare"; +import { describe, expect, it } from "vitest"; + +import { bundle, bundleApp } from "../src/bundler"; +import { WFP_COMPAT_DATE } from "./workerd"; + +const FEED = { "x-b44-feed-context": "1" }; + +interface ErrorRecord { + v: number; + kind: string; + status: number; + name?: string; + message: string; + stack?: string; +} + +interface TailEvent { + logs: { level: string; message: unknown[] }[]; + exceptions: { name: string; message: string }[]; +} + +interface Invocation { + status: number; + headers: Record; + text: string; + events: TailEvent[]; + records: ErrorRecord[]; +} + +// The tail worker forwards each invocation's trace to Node. +const TAIL_SCRIPT = `export default { + async tail(events, env) { + await env.SINK.fetch("http://sink/", { method: "POST", body: JSON.stringify(events) }); + }, +};`; + +/** Each `_b44_error` payload in the trace. The console patch logs one object + * whose `message` is the JSON of what the code passed. */ +function recordsIn(events: TailEvent[]): ErrorRecord[] { + const out: ErrorRecord[] = []; + for (const event of events) { + for (const log of event.logs) { + const line = log.message[0] as { message?: unknown }; + if (typeof line?.message !== "string") continue; + try { + const parsed = JSON.parse(line.message) as { _b44_error?: ErrorRecord }; + if (parsed?._b44_error) out.push(parsed._b44_error); + } catch {} + } + } + return out; +} + +async function invoke( + module: string, + opts: { headers?: Record; env?: Record } = {}, +): Promise { + const events: TailEvent[] = []; + const mf = new Miniflare({ + workers: [ + { + name: "fn", + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: module }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + bindings: opts.env ?? {}, + tails: ["tail"], + }, + { + name: "tail", + modules: true, + script: TAIL_SCRIPT, + compatibilityDate: WFP_COMPAT_DATE, + serviceBindings: { + SINK: async (req) => { + events.push(...((await req.json()) as TailEvent[])); + return new Response("ok"); + }, + }, + }, + ], + }); + try { + const res = await mf.dispatchFetch("http://localhost/", { headers: opts.headers }); + const text = await res.text(); + // The trace arrives once the invocation (waitUntil included) is done. + const until = Date.now() + 5000; + while (events.length === 0 && Date.now() < until) { + await new Promise((r) => setTimeout(r, 20)); + } + return { + status: res.status, + headers: Object.fromEntries(res.headers), + text, + events, + records: recordsIn(events), + }; + } finally { + await mf.dispose(); + } +} + +async function compile(source: string, errorRecords = true): Promise { + const r = await bundle({ entry: "main.ts", files: { "main.ts": source }, errorRecords }); + if (!r.ok) throw new Error(`bundle failed: ${JSON.stringify(r.errors)}`); + return r.module; +} + +async function compileApp(source: string, errorRecords = true): Promise { + const r = await bundleApp({ + functions: [{ name: "alpha", entry: "main.ts", files: { "main.ts": source } }], + errorRecords, + }); + if (!r.ok) throw new Error(`bundleApp failed: ${JSON.stringify(r.functions)}`); + return r.module; +} + +const serveJson = (body: unknown, status = 500) => + `Deno.serve(() => Response.json(${JSON.stringify(body)}, { status: ${status} }));`; + +/** Run with the feed header and without; the record must appear only with it. */ +async function recordFor(module: string, env?: Record): Promise { + const without = await invoke(module, { env }); + expect(without.records).toEqual([]); + return (await invoke(module, { headers: FEED, env })).records; +} + +describe("a 5xx JSON body", () => { + it.each([ + ["error", { error: "boom-error" }, "boom-error"], + ["message", { message: "boom-message" }, "boom-message"], + ["detail", { detail: "boom-detail" }, "boom-detail"], + ["details", { details: "boom-details" }, "boom-details"], + ["error.message", { error: { message: "boom-nested" } }, "boom-nested"], + ["the first non-empty field", { error: " ", message: "", detail: "boom-third" }, "boom-third"], + ["SSO, with details", { error: "Internal server error", details: "token exchange failed" }, "token exchange failed"], + ["SSO, without details", { error: "Internal server error" }, "Internal server error"], + ])("yields one record from %s", async (_label, body, message) => { + const records = await recordFor(await compile(serveJson(body))); + expect(records).toEqual([{ v: 1, kind: "response", status: 500, message }]); + }); + + it("skips error only when it is exactly the SSO placeholder", async () => { + const records = await recordFor( + await compile(serveJson({ error: "Internal server error!", details: "not this" })), + ); + expect(records.map((r) => r.message)).toEqual(["Internal server error!"]); + }); + + it("reports the status the handler returned", async () => { + const records = await recordFor(await compile(serveJson({ error: "unavailable" }, 503))); + expect(records).toEqual([{ v: 1, kind: "response", status: 503, message: "unavailable" }]); + }); + + it("works the same in the per-app entry", async () => { + const module = await compileApp(serveJson({ error: "app-boom" })); + const fnHeader = { "Base44-Function-Name": "alpha" }; + expect((await invoke(module, { headers: fnHeader })).records).toEqual([]); + const on = await invoke(module, { headers: { ...fnHeader, ...FEED } }); + expect(on.records).toEqual([{ v: 1, kind: "response", status: 500, message: "app-boom" }]); + // The line goes through the console patch, so it carries attribution. + const line = on.events.flatMap((e) => e.logs).find((l) => JSON.stringify(l).includes("_b44_error")); + expect((line?.message[0] as { _b44_function?: string })._b44_function).toBe("alpha"); + }); +}); + +describe("no record", () => { + it.each([ + ["a 5xx text body", 'Deno.serve(() => new Response("plain failure", { status: 500 }));'], + ["a 4xx", serveJson({ error: "bad input" }, 400)], + ["a 2xx", serveJson({ error: "not an error" }, 200)], + ["a 5xx with no message field", serveJson({ code: 42 })], + ["a non-Response return", "export default () => ({ status: 500 });"], + ])("for %s", async (_label, source) => { + const run = await invoke(await compile(source), { headers: FEED }); + expect(run.events.length).toBeGreaterThan(0); + expect(run.records).toEqual([]); + }); + + it("for a body over 2 KB, which still reaches the client whole", async () => { + const body = { error: "too big", pad: "x".repeat(3000) }; + const run = await invoke(await compile(serveJson(body)), { headers: FEED }); + expect(run.records).toEqual([]); + expect(JSON.parse(run.text)).toEqual(body); + }); + + it("for a streamed body that misses the 50 ms deadline, which still reaches the client whole", async () => { + const run = await invoke( + await compile(` + Deno.serve(() => { + const enc = new TextEncoder(); + const body = new ReadableStream({ + async start(c) { + c.enqueue(enc.encode('{"error":"')); + await new Promise((r) => setTimeout(r, 200)); + c.enqueue(enc.encode('slow"}')); + c.close(); + }, + }); + return new Response(body, { status: 500, headers: { "content-type": "application/json" } }); + }); + `), + { headers: FEED }, + ); + expect(run.records).toEqual([]); + expect(run.text).toBe('{"error":"slow"}'); + }); + + it("when anything throws, and the request is unaffected", async () => { + // A Response whose clone() throws: the recorder gives up, the response stands. + const run = await invoke( + await compile(` + class Unclonable extends Response { clone() { throw new Error("no clone"); } } + Deno.serve(() => new Unclonable(JSON.stringify({ error: "x" }), { status: 500 })); + `), + { headers: FEED }, + ); + expect(run.status).toBe(500); + expect(run.text).toBe('{"error":"x"}'); + expect(run.records).toEqual([]); + }); +}); + +describe("a logged Error", () => { + it("supplies name, message and stack when the body repeats its message", async () => { + const records = await recordFor( + await compile(` + class DbError extends Error { name = "DbError"; } + Deno.serve(() => { + try { throw new DbError("db down"); } + catch (e) { + console.error("query failed:", e); + return Response.json({ error: "Something went wrong", details: e.message }, { status: 500 }); + } + }); + `), + ); + expect(records).toHaveLength(1); + const [r] = records; + expect(r).toMatchObject({ v: 1, kind: "response", status: 500, name: "DbError", message: "db down" }); + expect(r.stack?.split("\n")[0]).toBe("DbError: db down"); + expect(r.stack?.split("\n").length).toBeGreaterThan(1); + }); + + it("is ignored when the body says something else, and gives no stack", async () => { + const records = await recordFor( + await compile(` + Deno.serve(() => { + console.error(new Error("unrelated warning")); + return Response.json({ error: "payment failed" }, { status: 500 }); + }); + `), + ); + expect(records).toEqual([{ v: 1, kind: "response", status: 500, message: "payment failed" }]); + }); + + it("is the fallback when the body has no message", async () => { + const records = await recordFor( + await compile(` + Deno.serve(() => { + console.warn(new TypeError("cannot read x")); + return new Response("oops", { status: 500 }); + }); + `), + ); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ name: "TypeError", message: "cannot read x" }); + expect(records[0].stack).toMatch(/^TypeError: cannot read x\n/); + }); + + it("is per request: an Error logged in an earlier request does not leak", async () => { + const module = await compile(` + let n = 0; + Deno.serve(() => { + if (n++ === 0) { console.error(new Error("first request")); return new Response("ok"); } + return new Response("second", { status: 500 }); + }); + `); + const events: TailEvent[] = []; + const mf = new Miniflare({ + workers: [ + { name: "fn", modules: [{ type: "ESModule", path: "_bundled.mjs", contents: module }], compatibilityDate: WFP_COMPAT_DATE, compatibilityFlags: ["nodejs_compat"], tails: ["tail"] }, + { name: "tail", modules: true, script: TAIL_SCRIPT, compatibilityDate: WFP_COMPAT_DATE, serviceBindings: { SINK: async (req) => { events.push(...((await req.json()) as TailEvent[])); return new Response("ok"); } } }, + ], + }); + try { + await (await mf.dispatchFetch("http://localhost/", { headers: FEED })).text(); + await (await mf.dispatchFetch("http://localhost/", { headers: FEED })).text(); + const until = Date.now() + 5000; + while (events.length < 2 && Date.now() < until) await new Promise((r) => setTimeout(r, 20)); + } finally { + await mf.dispose(); + } + expect(recordsIn(events)).toEqual([]); + }); +}); + +describe("bounds and scrubbing", () => { + it("cuts a non-ASCII message to 500 bytes on a code point", async () => { + const long = "Ошибка 💥 ".repeat(100); + const [r] = await recordFor(await compile(serveJson({ error: long }))); + expect(Buffer.byteLength(r.message, "utf8")).toBeLessThanOrEqual(500); + expect(Buffer.byteLength(r.message, "utf8")).toBeGreaterThan(490); + expect(long.startsWith(r.message)).toBe(true); + expect(r.message).not.toContain("�"); + }); + + it("replaces a lone surrogate", async () => { + const [r] = await recordFor( + await compile('Deno.serve(() => Response.json({ error: "bad \\ud800 half" }, { status: 500 }));'), + ); + expect(r.message).toBe("bad � half"); + }); + + it("scrubs env secrets from the message and the stack, longest first", async () => { + const env = { + API_KEY: "sk_live_0123456789abcdef", + API_KEY_PREFIX: "sk_live_0123", + SHORT: "tiny", + PDS: JSON.stringify({ db: { password: "hunter2-hunter2" } }), + }; + const records = await recordFor( + await compile(` + Deno.serve(() => { + const e = new Error("auth failed for sk_live_0123456789abcdef and sk_live_0123 (tiny) pw hunter2-hunter2"); + console.error(e); + return Response.json({ error: e.message }, { status: 500 }); + }); + `), + env, + ); + expect(records).toHaveLength(1); + const [r] = records; + expect(r.message).toBe("auth failed for [secret] and [secret] (tiny) pw [secret]"); + expect(r.stack?.split("\n")[0]).toBe(`Error: ${r.message}`); + const json = JSON.stringify(r); + for (const secret of ["sk_live_0123", "hunter2-hunter2"]) expect(json).not.toContain(secret); + }); + + it("keeps the stack under 4 KB, cut at a line end", async () => { + const [r] = await recordFor( + await compile(` + Deno.serve(() => { + const e = new Error("deep"); + e.stack = "Error: deep\\n" + Array.from({ length: 200 }, (_, i) => " at frame" + i + " (file:///main.ts:" + i + ":1)").join("\\n"); + console.error(e); + return Response.json({ error: "deep" }, { status: 500 }); + }); + `), + ); + expect(Buffer.byteLength(r.stack!, "utf8")).toBeLessThanOrEqual(4096); + expect(r.stack!.split("\n").at(-1)).toMatch(/^ {4}at frame\d+ \(file:\/\/\/main\.ts:\d+:1\)$/); + }); + + it("caps the name at 64 characters", async () => { + const [r] = await recordFor( + await compile(` + Deno.serve(() => { + const e = new Error("named"); + e.name = "N".repeat(100); + console.error(e); + return Response.json({ error: "named" }, { status: 500 }); + }); + `), + ); + expect(r.name).toBe("N".repeat(64)); + }); +}); + +describe("the response", () => { + const source = ` + Deno.serve(() => { + console.error(new Error("db down")); + return Response.json({ error: "db down" }, { status: 500, headers: { "x-app": "kept" } }); + }); + `; + + it("is the same response, with or without the record", async () => { + const off = await invoke(await compile(source, false), { headers: FEED }); + const on = await invoke(await compile(source), { headers: FEED }); + expect(on.records).toHaveLength(1); + expect(off.records).toEqual([]); + expect({ status: on.status, headers: on.headers, text: on.text }).toEqual({ + status: off.status, + headers: off.headers, + text: off.text, + }); + }); + + it("is not held back while the record is written", async () => { + // The response must reach the client while the handler's stream is still + // open: nothing waits on the recorder's read of the clone. (Miniflare's + // dispatchFetch hands over this body only once it closes, flag on or off, + // so the headers are what can be timed here.) + const module = await compile(` + Deno.serve(() => { + const enc = new TextEncoder(); + const body = new ReadableStream({ + async start(c) { + c.enqueue(enc.encode('{"error":"slow')); + await new Promise((r) => setTimeout(r, 600)); + c.enqueue(enc.encode(' tail"}')); + c.close(); + }, + }); + return new Response(body, { status: 500 }); + }); + `); + const mf = new Miniflare({ + modules: [{ type: "ESModule", path: "_bundled.mjs", contents: module }], + compatibilityDate: WFP_COMPAT_DATE, + compatibilityFlags: ["nodejs_compat"], + }); + try { + await mf.ready; + const started = Date.now(); + const res = await mf.dispatchFetch("http://localhost/", { headers: FEED }); + expect(res.status).toBe(500); + // The handler's stream stays open for 600 ms. + expect(Date.now() - started).toBeLessThan(400); + expect(await res.text()).toBe('{"error":"slow tail"}'); + } finally { + await mf.dispose(); + } + }); +}); + +describe("an uncaught throw", () => { + const source = 'Deno.serve(() => { throw new Error("kaboom"); });'; + + it.each([ + ["single function", compile, {}], + ["per-app", compileApp, { "Base44-Function-Name": "alpha" }], + ] as const)("still rethrows exactly as today (%s)", async (_label, build, headers) => { + const off = await invoke(await build(source, false), { headers: { ...headers, ...FEED } }); + const on = await invoke(await build(source, true), { headers: { ...headers, ...FEED } }); + // Cloudflare records the exception; the record is only for returned 5xx. + expect(on.events.flatMap((e) => e.exceptions).map((x) => x.message)).toEqual(["kaboom"]); + expect(off.events.flatMap((e) => e.exceptions).map((x) => x.message)).toEqual(["kaboom"]); + expect(on.records).toEqual([]); + expect({ status: on.status, text: on.text }).toEqual({ status: off.status, text: off.text }); + }); +}); diff --git a/packages/functions-compiler/test/worker-entry.test.ts b/packages/functions-compiler/test/worker-entry.test.ts index e21a5142a..ec5a00d92 100644 --- a/packages/functions-compiler/test/worker-entry.test.ts +++ b/packages/functions-compiler/test/worker-entry.test.ts @@ -19,6 +19,57 @@ describe("binding-mode entries", () => { }); }); +describe("error-records entries", () => { + const files = { "main.ts": serve("a") }; + const app = (errorRecords: boolean, telemetry = false) => + prepareApp([{ index: 0, fn: { name: "alpha", entry: "main.ts", files } }], telemetry, false, errorRecords); + + it.each([ + [false, false], + [false, true], + [true, false], + [true, true], + ])("leave the entry and banner unchanged when off (telemetry=%s, runtimeSecrets=%s)", async (telemetry, runtimeSecrets) => { + const before = await prepareFunction("main.ts", files, telemetry, runtimeSecrets); + const off = await prepareFunction("main.ts", files, telemetry, runtimeSecrets, false); + expect(off.files).toEqual(before.files); + const appBefore = prepareApp([{ index: 0, fn: { name: "alpha", entry: "main.ts", files } }], telemetry, runtimeSecrets); + const appOff = prepareApp([{ index: 0, fn: { name: "alpha", entry: "main.ts", files } }], telemetry, runtimeSecrets, false); + expect(appOff).toEqual(appBefore); + expect(off.files[off.entry]).not.toContain("_b44RecordError"); + expect(appOff.banner).not.toContain("errorRecords"); + }); + + it("are pinned when on", async () => { + const single = await prepareFunction("main.ts", files, false, false, true); + expect(single.files[single.entry]).toMatchSnapshot("single function"); + expect(app(true).files["__base44_entry.mjs"]).toMatchSnapshot("per-app"); + }); + + it("record the handler's own response, inside telemetry and invocation logs", async () => { + const single = (await prepareFunction("main.ts", files, true, false, true)).files["__base44_entry.mjs"]; + expect(single).toContain("_b44AttachTelemetry(_b44RecordError(await handler(request, info)))"); + expect(single).toContain("..._b44ErrorStore(request)"); + expect(app(true, true).files["__base44_entry.mjs"]).toContain( + "_b44AttachInvocationLogs(_b44AttachTelemetry(_b44RecordError(await handler(request, info))))", + ); + }); + + it("leave the crash path exactly as it is", () => { + // The uncaught throw is Cloudflare's to record; the record is only for a + // returned 5xx. + const on = app(true).files["__base44_entry.mjs"]; + const off = app(false).files["__base44_entry.mjs"]; + const crash = (entry: string) => entry.slice(entry.indexOf("} catch (e) {\n console.error(e);")); + expect(crash(on)).toBe(crash(off)); + expect(crash(on)).toContain("throw e;"); + }); + + it("say so in the per-app banner only when on", () => { + expect(JSON.parse(app(true).banner!.replace(/^\/\/!b44:1 /, "")).errorRecords).toBe(true); + }); +}); + describe("prepareFunction", () => { it("injects a shim and a wrapper entry that delegates to the Deno.serve handler", async () => { const result = await prepareFunction("main.ts", { "main.ts": serve("ok") }); From d4cfc862530bcb16169f7bee05cd966365c92429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Egidijus=20Jucevi=C4=8Dius?= Date: Tue, 20 Oct 2026 10:23:16 +0300 Subject: [PATCH 2/6] docs(functions-compiler): `_b44_error.stack` is minified It is the raw Error.stack read inside the Worker, and Cloudflare remaps only the exceptions it captures, never a logged string. Say so, and that a consumer remaps it with the same version's source_map. Co-Authored-By: Claude Opus 5.5 --- packages/functions-compiler/README.md | 5 ++++- packages/functions-compiler/src/error-records.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index ccab446c2..4078fb18b 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -180,7 +180,10 @@ header set. On a clone, inside `ctx.waitUntil`, the recorder: `error.message`, skipping `error` when it is exactly `"Internal server error"` and `details` is present); else the last Error logged during the request. No message means no record; -- takes the stack only from the logged Error that supplied the message; +- takes the stack only from the logged Error that supplied the message. It is + that Error's raw `stack`, read inside the Worker, so it is **minified**: + Cloudflare remaps only the exceptions it captures, never a logged string. A + consumer remaps it with the same version's `source_map` (see Source maps); - replaces every env-binding and runtime-secret string of 8+ characters with `[secret]`, longest first, before cutting anything. This includes the strings nested in JSON-valued ones, such as the private-data-sources manifest; diff --git a/packages/functions-compiler/src/error-records.ts b/packages/functions-compiler/src/error-records.ts index b056c5164..e033a94f3 100644 --- a/packages/functions-compiler/src/error-records.ts +++ b/packages/functions-compiler/src/error-records.ts @@ -22,7 +22,10 @@ * "Internal server error" and `details` is present (the SSO examples); * 3. otherwise the last Error logged during this request. * - * The stack comes only from the logged Error that supplied the message. Every + * The stack comes only from the logged Error that supplied the message, and is + * minified: Cloudflare remaps the exceptions it captures, never a stack string + * logged from inside the Worker, so a consumer remaps it with the version's + * `source_map`. Every * env-binding and runtime-secret string of 8+ characters (and the strings * nested in JSON-valued ones, such as the private-data-sources manifest) is * replaced with `[secret]`, longest first, before anything is cut. Then it logs From c9f3bf583404d4b0a82b04a5969ffcb8484b3ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Egidijus=20Jucevi=C4=8Dius?= Date: Tue, 20 Oct 2026 10:40:06 +0300 Subject: [PATCH 3/6] fix(functions-compiler): scrub whitespace-led JSON bindings; match any recent logged Error Two review findings on the error recorder: - A JSON-valued binding with leading whitespace (`\n{"db":{...}}`) was not parsed, so the secrets nested in it were logged unredacted. Detect JSON after trimStart(); the original string stays a candidate. - A single saved Error was overwritten on every log, so a later unrelated warning lost the matching Error's name and stack. Keep the last 8 Errors logged in the request, use the newest one whose message the body repeats, and the newest one only as the fallback. Co-Authored-By: Claude Opus 5.5 --- packages/functions-compiler/README.md | 13 ++-- .../functions-compiler/src/error-records.ts | 46 ++++++++------ .../__snapshots__/worker-entry.test.ts.snap | 62 +++++++++--------- .../test/error-records.e2e.test.ts | 63 +++++++++++++++++++ 4 files changed, 133 insertions(+), 51 deletions(-) diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 4078fb18b..fb4409217 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -175,10 +175,12 @@ At runtime the recorder does nothing unless the request carries header set. On a clone, inside `ctx.waitUntil`, the recorder: - picks the message, in this order: an Error logged during the request whose - message the JSON body repeats; else the body (JSON of at most 2,048 bytes read - within 50 ms; the first non-empty `error`, `message`, `detail`, `details` or - `error.message`, skipping `error` when it is exactly `"Internal server error"` - and `details` is present); else the last Error logged during the request. + message the JSON body repeats (the newest such among the last 8 Errors + logged, so a later unrelated warning doesn't hide it); else the body (JSON of + at most 2,048 bytes read within 50 ms; the first non-empty `error`, + `message`, `detail`, `details` or `error.message`, skipping `error` when it + is exactly `"Internal server error"` and `details` is present); else the last + Error logged during the request. No message means no record; - takes the stack only from the logged Error that supplied the message. It is that Error's raw `stack`, read inside the Worker, so it is **minified**: @@ -186,7 +188,8 @@ header set. On a clone, inside `ctx.waitUntil`, the recorder: consumer remaps it with the same version's `source_map` (see Source maps); - replaces every env-binding and runtime-secret string of 8+ characters with `[secret]`, longest first, before cutting anything. This includes the strings - nested in JSON-valued ones, such as the private-data-sources manifest; + nested in JSON-valued ones (leading whitespace allowed), such as the + private-data-sources manifest; - caps `name` at 64 characters, `message` at 500 UTF-8 bytes (well-formed, cut on a code point) and `stack` at 4 KB (cut at a line end); - logs exactly one line through the console patch, so it carries diff --git a/packages/functions-compiler/src/error-records.ts b/packages/functions-compiler/src/error-records.ts index e033a94f3..3e7898b17 100644 --- a/packages/functions-compiler/src/error-records.ts +++ b/packages/functions-compiler/src/error-records.ts @@ -15,12 +15,13 @@ * `ctx.waitUntil` (a line logged there still belongs to the invocation's tail * event) picks a message: * - * 1. an Error logged during this request whose message the JSON body repeats; + * 1. an Error logged during this request whose message the JSON body repeats + * (the newest such, among the last 8 logged); * 2. otherwise the body: JSON of at most 2,048 bytes read within 50 ms, first * non-empty string among `error`, `message`, `detail`, `details` and * `error.message` — skipping `error` when it is exactly * "Internal server error" and `details` is present (the SSO examples); - * 3. otherwise the last Error logged during this request. + * 3. otherwise the last Error logged during this request (with a message). * * The stack comes only from the logged Error that supplied the message, and is * minified: Cloudflare remaps the exceptions it captures, never a stack string @@ -47,23 +48,26 @@ const ERROR_RECORD_LIMITS = { messageBytes: 500, stackBytes: 4096, minSecretChars: 8, + loggedErrors: 8, } as const; const L = ERROR_RECORD_LIMITS; export const ERROR_RECORDS_PATCH = [ - `const _b44ErrorStore = (request) => request.headers.has(${JSON.stringify(FEED_CONTEXT_HEADER)}) ? { errorRecord: { logged: null } } : {};`, - // The last Error passed to any console method during the request, read once - // at log time (a later mutation of the Error must not change the record). + `const _b44ErrorStore = (request) => request.headers.has(${JSON.stringify(FEED_CONTEXT_HEADER)}) ? { errorRecord: { logged: [] } } : {};`, + // The last few Errors passed to any console method during the request, + // oldest first, each read once at log time (a later mutation of the Error + // must not change the record). More than one: a handler that logs the real + // failure and then an unrelated warning must still match the first. "const _b44NoteError = (a) => {", " const c = _b44Context();", " if (!c || !c.errorRecord) return;", - " for (let i = a.length - 1; i >= 0; i--) {", - " const x = a[i];", + " for (const x of a) {", " if (!(x instanceof Error)) continue;", " const r = (k) => { try { const v = x[k]; return typeof v === 'string' ? v : undefined; } catch (e) { return undefined; } };", - " c.errorRecord.logged = { name: r('name'), message: r('message'), stack: r('stack') };", - " return;", + " const logged = c.errorRecord.logged;", + " logged.push({ name: r('name'), message: r('message'), stack: r('stack') });", + ` if (logged.length > ${L.loggedErrors}) logged.shift();`, " }", "};", "for (const _l of ['log', 'info', 'warn', 'error', 'debug']) { const _f = console[_l]; console[_l] = (...a) => { try { _b44NoteError(a); } catch (e) {} return _f(...a); }; }", @@ -115,7 +119,10 @@ export const ERROR_RECORDS_PATCH = [ " if (--budget < 0) return;", " if (typeof v === 'string') {", ` if (v.length >= ${L.minSecretChars}) out.add(v);`, - " if (d < 2 && (v[0] === '{' || v[0] === '[')) { try { add(JSON.parse(v), d + 1); } catch (e) {} }", + // JSON.parse accepts leading whitespace, so the detection must too; the + // original string stays a candidate either way. + " const t = v.trimStart();", + " if (d < 2 && (t[0] === '{' || t[0] === '[')) { try { add(JSON.parse(v), d + 1); } catch (e) {} }", " } else if (d > 0 && d < 16 && v && typeof v === 'object') {", " for (const k of Object.keys(v)) add(v[k], d + 1);", " }", @@ -154,22 +161,25 @@ export const ERROR_RECORDS_PATCH = [ " }", " return out;", "};", + // `logged` is the request's Errors at response time, oldest first. "const _b44WriteErrorRecord = async (c, status, copy, logged) => {", " try {", " const body = await _b44ReadJson(copy);", - " const lm = logged && logged.message ? logged.message : undefined;", - " let message;", - " let fromLogged = false;", - " if (lm !== undefined && body !== undefined && _b44HasString(body, lm)) { message = lm; fromLogged = true; }", - " else { message = _b44BodyMessage(body); if (message === undefined && lm !== undefined) { message = lm; fromLogged = true; } }", + " const usable = logged.filter((e) => e.message);", + " let chosen;", + " if (body !== undefined) {", + " for (let i = usable.length - 1; i >= 0; i--) if (_b44HasString(body, usable[i].message)) { chosen = usable[i]; break; }", + " }", + " let message = chosen ? chosen.message : _b44BodyMessage(body);", + " if (message === undefined && usable.length > 0) { chosen = usable[usable.length - 1]; message = chosen.message; }", " if (message === undefined) return;", " const secrets = _b44SecretValues(c);", " const clean = (s) => _b44Well(_b44Scrub(s, secrets));", " const rec = { v: 1, kind: 'response', status };", - ` const name = fromLogged && logged.name ? Array.from(clean(logged.name)).slice(0, ${L.nameChars}).join('') : '';`, + ` const name = chosen && chosen.name ? Array.from(clean(chosen.name)).slice(0, ${L.nameChars}).join('') : '';`, " if (name) rec.name = name;", ` rec.message = _b44CutBytes(clean(message), ${L.messageBytes});`, - ` const stack = fromLogged && logged.stack ? _b44CutLines(clean(logged.stack), ${L.stackBytes}) : '';`, + ` const stack = chosen && chosen.stack ? _b44CutLines(clean(chosen.stack), ${L.stackBytes}) : '';`, " if (stack) rec.stack = stack;", " console.error({ _b44_error: rec });", " } catch (e) {}", @@ -179,7 +189,7 @@ export const ERROR_RECORDS_PATCH = [ " try {", " const c = _b44Context();", " if (!c || !c.errorRecord || !c.waitUntil || !(res instanceof Response) || !(res.status >= 500)) return res;", - " c.waitUntil(_b44WriteErrorRecord(c, res.status, res.clone(), c.errorRecord.logged));", + " c.waitUntil(_b44WriteErrorRecord(c, res.status, res.clone(), c.errorRecord.logged.slice()));", " } catch (e) {}", " return res;", "};", diff --git a/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap b/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap index a1b5a850f..f52ed773e 100644 --- a/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap +++ b/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap @@ -516,16 +516,16 @@ const _b44CrashResponse = (e) => { if (m.includes('cpu time limit') || m.includes('too many subrequests')) return null; return _b44AttachInvocationLogs(new Response(JSON.stringify({ error: 'user-exception', detail: 'user worker threw an exception' }), { status: 500, headers: { 'Content-Type': 'application/json' } })); }; -const _b44ErrorStore = (request) => request.headers.has("x-b44-feed-context") ? { errorRecord: { logged: null } } : {}; +const _b44ErrorStore = (request) => request.headers.has("x-b44-feed-context") ? { errorRecord: { logged: [] } } : {}; const _b44NoteError = (a) => { const c = _b44Context(); if (!c || !c.errorRecord) return; - for (let i = a.length - 1; i >= 0; i--) { - const x = a[i]; + for (const x of a) { if (!(x instanceof Error)) continue; const r = (k) => { try { const v = x[k]; return typeof v === 'string' ? v : undefined; } catch (e) { return undefined; } }; - c.errorRecord.logged = { name: r('name'), message: r('message'), stack: r('stack') }; - return; + const logged = c.errorRecord.logged; + logged.push({ name: r('name'), message: r('message'), stack: r('stack') }); + if (logged.length > 8) logged.shift(); } }; for (const _l of ['log', 'info', 'warn', 'error', 'debug']) { const _f = console[_l]; console[_l] = (...a) => { try { _b44NoteError(a); } catch (e) {} return _f(...a); }; } @@ -574,7 +574,8 @@ const _b44SecretValues = (c) => { if (--budget < 0) return; if (typeof v === 'string') { if (v.length >= 8) out.add(v); - if (d < 2 && (v[0] === '{' || v[0] === '[')) { try { add(JSON.parse(v), d + 1); } catch (e) {} } + const t = v.trimStart(); + if (d < 2 && (t[0] === '{' || t[0] === '[')) { try { add(JSON.parse(v), d + 1); } catch (e) {} } } else if (d > 0 && d < 16 && v && typeof v === 'object') { for (const k of Object.keys(v)) add(v[k], d + 1); } @@ -616,19 +617,21 @@ const _b44CutLines = (s, max) => { const _b44WriteErrorRecord = async (c, status, copy, logged) => { try { const body = await _b44ReadJson(copy); - const lm = logged && logged.message ? logged.message : undefined; - let message; - let fromLogged = false; - if (lm !== undefined && body !== undefined && _b44HasString(body, lm)) { message = lm; fromLogged = true; } - else { message = _b44BodyMessage(body); if (message === undefined && lm !== undefined) { message = lm; fromLogged = true; } } + const usable = logged.filter((e) => e.message); + let chosen; + if (body !== undefined) { + for (let i = usable.length - 1; i >= 0; i--) if (_b44HasString(body, usable[i].message)) { chosen = usable[i]; break; } + } + let message = chosen ? chosen.message : _b44BodyMessage(body); + if (message === undefined && usable.length > 0) { chosen = usable[usable.length - 1]; message = chosen.message; } if (message === undefined) return; const secrets = _b44SecretValues(c); const clean = (s) => _b44Well(_b44Scrub(s, secrets)); const rec = { v: 1, kind: 'response', status }; - const name = fromLogged && logged.name ? Array.from(clean(logged.name)).slice(0, 64).join('') : ''; + const name = chosen && chosen.name ? Array.from(clean(chosen.name)).slice(0, 64).join('') : ''; if (name) rec.name = name; rec.message = _b44CutBytes(clean(message), 500); - const stack = fromLogged && logged.stack ? _b44CutLines(clean(logged.stack), 4096) : ''; + const stack = chosen && chosen.stack ? _b44CutLines(clean(chosen.stack), 4096) : ''; if (stack) rec.stack = stack; console.error({ _b44_error: rec }); } catch (e) {} @@ -637,7 +640,7 @@ const _b44RecordError = (res) => { try { const c = _b44Context(); if (!c || !c.errorRecord || !c.waitUntil || !(res instanceof Response) || !(res.status >= 500)) return res; - c.waitUntil(_b44WriteErrorRecord(c, res.status, res.clone(), c.errorRecord.logged)); + c.waitUntil(_b44WriteErrorRecord(c, res.status, res.clone(), c.errorRecord.logged.slice())); } catch (e) {} return res; }; @@ -741,16 +744,16 @@ console.debug = (...a) => _b44Wrap('debug', a); const _b44ReservedSecrets = new Set(['BASE44_ACTOR_PRIVATE_KEY', 'BASE44_ACTOR_PUBLIC_KEY', 'BASE44_ACTOR_SCRIPT_ID', 'BASE44_PRIVATE_DATA_SOURCES']); globalThis.Base44 = Object.assign(globalThis.Base44 ?? {}, { waitUntil: (p) => { const _c = _b44Context(); if (_c?.waitUntil) { _c.waitUntil(p); } else { Promise.resolve(p).catch(() => {}); } }, secrets: { get: (n) => { const _k = String(n); if (_b44ReservedSecrets.has(_k)) return undefined; const _v = _b44Context()?.secrets?.[_k]; return typeof _v === 'string' ? _v : undefined; } } }); globalThis.EdgeRuntime = globalThis.EdgeRuntime ?? { waitUntil: (p) => globalThis.Base44.waitUntil(p) }; -const _b44ErrorStore = (request) => request.headers.has("x-b44-feed-context") ? { errorRecord: { logged: null } } : {}; +const _b44ErrorStore = (request) => request.headers.has("x-b44-feed-context") ? { errorRecord: { logged: [] } } : {}; const _b44NoteError = (a) => { const c = _b44Context(); if (!c || !c.errorRecord) return; - for (let i = a.length - 1; i >= 0; i--) { - const x = a[i]; + for (const x of a) { if (!(x instanceof Error)) continue; const r = (k) => { try { const v = x[k]; return typeof v === 'string' ? v : undefined; } catch (e) { return undefined; } }; - c.errorRecord.logged = { name: r('name'), message: r('message'), stack: r('stack') }; - return; + const logged = c.errorRecord.logged; + logged.push({ name: r('name'), message: r('message'), stack: r('stack') }); + if (logged.length > 8) logged.shift(); } }; for (const _l of ['log', 'info', 'warn', 'error', 'debug']) { const _f = console[_l]; console[_l] = (...a) => { try { _b44NoteError(a); } catch (e) {} return _f(...a); }; } @@ -799,7 +802,8 @@ const _b44SecretValues = (c) => { if (--budget < 0) return; if (typeof v === 'string') { if (v.length >= 8) out.add(v); - if (d < 2 && (v[0] === '{' || v[0] === '[')) { try { add(JSON.parse(v), d + 1); } catch (e) {} } + const t = v.trimStart(); + if (d < 2 && (t[0] === '{' || t[0] === '[')) { try { add(JSON.parse(v), d + 1); } catch (e) {} } } else if (d > 0 && d < 16 && v && typeof v === 'object') { for (const k of Object.keys(v)) add(v[k], d + 1); } @@ -841,19 +845,21 @@ const _b44CutLines = (s, max) => { const _b44WriteErrorRecord = async (c, status, copy, logged) => { try { const body = await _b44ReadJson(copy); - const lm = logged && logged.message ? logged.message : undefined; - let message; - let fromLogged = false; - if (lm !== undefined && body !== undefined && _b44HasString(body, lm)) { message = lm; fromLogged = true; } - else { message = _b44BodyMessage(body); if (message === undefined && lm !== undefined) { message = lm; fromLogged = true; } } + const usable = logged.filter((e) => e.message); + let chosen; + if (body !== undefined) { + for (let i = usable.length - 1; i >= 0; i--) if (_b44HasString(body, usable[i].message)) { chosen = usable[i]; break; } + } + let message = chosen ? chosen.message : _b44BodyMessage(body); + if (message === undefined && usable.length > 0) { chosen = usable[usable.length - 1]; message = chosen.message; } if (message === undefined) return; const secrets = _b44SecretValues(c); const clean = (s) => _b44Well(_b44Scrub(s, secrets)); const rec = { v: 1, kind: 'response', status }; - const name = fromLogged && logged.name ? Array.from(clean(logged.name)).slice(0, 64).join('') : ''; + const name = chosen && chosen.name ? Array.from(clean(chosen.name)).slice(0, 64).join('') : ''; if (name) rec.name = name; rec.message = _b44CutBytes(clean(message), 500); - const stack = fromLogged && logged.stack ? _b44CutLines(clean(logged.stack), 4096) : ''; + const stack = chosen && chosen.stack ? _b44CutLines(clean(chosen.stack), 4096) : ''; if (stack) rec.stack = stack; console.error({ _b44_error: rec }); } catch (e) {} @@ -862,7 +868,7 @@ const _b44RecordError = (res) => { try { const c = _b44Context(); if (!c || !c.errorRecord || !c.waitUntil || !(res instanceof Response) || !(res.status >= 500)) return res; - c.waitUntil(_b44WriteErrorRecord(c, res.status, res.clone(), c.errorRecord.logged)); + c.waitUntil(_b44WriteErrorRecord(c, res.status, res.clone(), c.errorRecord.logged.slice())); } catch (e) {} return res; }; diff --git a/packages/functions-compiler/test/error-records.e2e.test.ts b/packages/functions-compiler/test/error-records.e2e.test.ts index 297b9e3c1..1cfcfa598 100644 --- a/packages/functions-compiler/test/error-records.e2e.test.ts +++ b/packages/functions-compiler/test/error-records.e2e.test.ts @@ -249,6 +249,57 @@ describe("a logged Error", () => { expect(r.stack?.split("\n").length).toBeGreaterThan(1); }); + it("still matches when an unrelated Error is logged after it", async () => { + const records = await recordFor( + await compile(` + class DbError extends Error { name = "DbError"; } + Deno.serve(() => { + const db = new DbError("db down"); + console.error("query failed:", db); + console.warn(new Error("cleanup warning")); + return Response.json({ error: db.message }, { status: 500 }); + }); + `), + ); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ name: "DbError", message: "db down" }); + expect(records[0].stack?.split("\n")[0]).toBe("DbError: db down"); + }); + + it.each([ + [7, { name: "DbError", hasStack: true }], + [8, { name: undefined, hasStack: false }], + ])("remembers the last 8 Errors: %i unrelated ones after the match", async (unrelated, expected) => { + // With 8 later Errors the match has been dropped, so the message comes + // from the body alone, with no name or stack. + const [r] = await recordFor( + await compile(` + class DbError extends Error { name = "DbError"; } + Deno.serve(() => { + console.error(new DbError("db down")); + for (let i = 0; i < ${unrelated}; i++) console.warn(new Error("noise " + i)); + return Response.json({ error: "db down" }, { status: 500 }); + }); + `), + ); + expect(r.message).toBe("db down"); + expect(r.name).toBe(expected.name); + expect(r.stack !== undefined).toBe(expected.hasStack); + }); + + it("falls back to the newest logged Error", async () => { + const [r] = await recordFor( + await compile(` + Deno.serve(() => { + console.error(new Error("older")); + console.error(new RangeError("newest")); + return new Response("oops", { status: 500 }); + }); + `), + ); + expect(r).toMatchObject({ name: "RangeError", message: "newest" }); + }); + it("is ignored when the body says something else, and gives no stack", async () => { const records = await recordFor( await compile(` @@ -344,6 +395,18 @@ describe("bounds and scrubbing", () => { for (const secret of ["sk_live_0123", "hunter2-hunter2"]) expect(json).not.toContain(secret); }); + it.each([ + ["an object after a newline", '\n{"db":{"password":"private-password-123"}}'], + ["an array after spaces", ' [ "private-password-123" ]'], + ["an object after mixed whitespace", '\r\n\t {"nested":{"deeper":"private-password-123"}}'], + ])("scrubs a JSON binding that starts with whitespace (%s)", async (_label, binding) => { + const records = await recordFor( + await compile('Deno.serve(() => Response.json({ error: "login failed with private-password-123" }, { status: 500 }));'), + { PDS: binding }, + ); + expect(records.map((r) => r.message)).toEqual(["login failed with [secret]"]); + }); + it("keeps the stack under 4 KB, cut at a line end", async () => { const [r] = await recordFor( await compile(` From 547c523d6894f47b9f3bd6bf19d2e317335d03cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Egidijus=20Jucevi=C4=8Dius?= Date: Tue, 20 Oct 2026 10:57:37 +0300 Subject: [PATCH 4/6] refactor(functions-compiler): scrub secrets to `[redacted]` The error recorder now replaces a matched secret with `[redacted]` instead of `[secret]`. Co-Authored-By: Claude Opus 5.5 --- packages/functions-compiler/README.md | 2 +- packages/functions-compiler/src/error-records.ts | 4 ++-- .../test/__snapshots__/worker-entry.test.ts.snap | 4 ++-- packages/functions-compiler/test/error-records.e2e.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index fb4409217..0fccaddf1 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -187,7 +187,7 @@ header set. On a clone, inside `ctx.waitUntil`, the recorder: Cloudflare remaps only the exceptions it captures, never a logged string. A consumer remaps it with the same version's `source_map` (see Source maps); - replaces every env-binding and runtime-secret string of 8+ characters with - `[secret]`, longest first, before cutting anything. This includes the strings + `[redacted]`, longest first, before cutting anything. This includes the strings nested in JSON-valued ones (leading whitespace allowed), such as the private-data-sources manifest; - caps `name` at 64 characters, `message` at 500 UTF-8 bytes (well-formed, cut diff --git a/packages/functions-compiler/src/error-records.ts b/packages/functions-compiler/src/error-records.ts index 3e7898b17..513f14bdb 100644 --- a/packages/functions-compiler/src/error-records.ts +++ b/packages/functions-compiler/src/error-records.ts @@ -29,7 +29,7 @@ * `source_map`. Every * env-binding and runtime-secret string of 8+ characters (and the strings * nested in JSON-valued ones, such as the private-data-sources manifest) is - * replaced with `[secret]`, longest first, before anything is cut. Then it logs + * replaced with `[redacted]`, longest first, before anything is cut. Then it logs * one `{ _b44_error: { v: 1, kind: "response", status, name?, message, stack? } }` * through the console patch, so the line carries function attribution. Any * failure writes nothing and never reaches the request. @@ -133,7 +133,7 @@ export const ERROR_RECORDS_PATCH = [ " }", " return [...out].sort((x, y) => y.length - x.length);", "};", - "const _b44Scrub = (s, secrets) => { let out = s; for (const v of secrets) if (out.includes(v)) out = out.split(v).join('[secret]'); return out; };", + "const _b44Scrub = (s, secrets) => { let out = s; for (const v of secrets) if (out.includes(v)) out = out.split(v).join('[redacted]'); return out; };", "const _b44Well = (s) => (typeof s.toWellFormed === 'function' ? s.toWellFormed() : s);", "const _b44Bytes = (s) => new TextEncoder().encode(s).length;", "const _b44CutBytes = (s, max) => {", diff --git a/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap b/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap index f52ed773e..b541f446d 100644 --- a/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap +++ b/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap @@ -586,7 +586,7 @@ const _b44SecretValues = (c) => { } return [...out].sort((x, y) => y.length - x.length); }; -const _b44Scrub = (s, secrets) => { let out = s; for (const v of secrets) if (out.includes(v)) out = out.split(v).join('[secret]'); return out; }; +const _b44Scrub = (s, secrets) => { let out = s; for (const v of secrets) if (out.includes(v)) out = out.split(v).join('[redacted]'); return out; }; const _b44Well = (s) => (typeof s.toWellFormed === 'function' ? s.toWellFormed() : s); const _b44Bytes = (s) => new TextEncoder().encode(s).length; const _b44CutBytes = (s, max) => { @@ -814,7 +814,7 @@ const _b44SecretValues = (c) => { } return [...out].sort((x, y) => y.length - x.length); }; -const _b44Scrub = (s, secrets) => { let out = s; for (const v of secrets) if (out.includes(v)) out = out.split(v).join('[secret]'); return out; }; +const _b44Scrub = (s, secrets) => { let out = s; for (const v of secrets) if (out.includes(v)) out = out.split(v).join('[redacted]'); return out; }; const _b44Well = (s) => (typeof s.toWellFormed === 'function' ? s.toWellFormed() : s); const _b44Bytes = (s) => new TextEncoder().encode(s).length; const _b44CutBytes = (s, max) => { diff --git a/packages/functions-compiler/test/error-records.e2e.test.ts b/packages/functions-compiler/test/error-records.e2e.test.ts index 1cfcfa598..37a24a406 100644 --- a/packages/functions-compiler/test/error-records.e2e.test.ts +++ b/packages/functions-compiler/test/error-records.e2e.test.ts @@ -389,7 +389,7 @@ describe("bounds and scrubbing", () => { ); expect(records).toHaveLength(1); const [r] = records; - expect(r.message).toBe("auth failed for [secret] and [secret] (tiny) pw [secret]"); + expect(r.message).toBe("auth failed for [redacted] and [redacted] (tiny) pw [redacted]"); expect(r.stack?.split("\n")[0]).toBe(`Error: ${r.message}`); const json = JSON.stringify(r); for (const secret of ["sk_live_0123", "hunter2-hunter2"]) expect(json).not.toContain(secret); @@ -404,7 +404,7 @@ describe("bounds and scrubbing", () => { await compile('Deno.serve(() => Response.json({ error: "login failed with private-password-123" }, { status: 500 }));'), { PDS: binding }, ); - expect(records.map((r) => r.message)).toEqual(["login failed with [secret]"]); + expect(records.map((r) => r.message)).toEqual(["login failed with [redacted]"]); }); it("keeps the stack under 4 KB, cut at a line end", async () => { From de77dbf6a370d659ae65af657b863f984eb20935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Egidijus=20Jucevi=C4=8Dius?= Date: Tue, 20 Oct 2026 11:37:46 +0300 Subject: [PATCH 5/6] feat(functions-compiler): every build carries the `_b44_error` record Drop the `errorRecords` option. Both generated entries now always append the recorder, route the handler's response through `_b44RecordError` and seed `_b44ErrorStore(request)`, including the public CLI's own shard builds and bundleApp's probe compiles. An app is ready for error reporting the moment it opts in, with no rebuild. The off switch is the request: the recorder does nothing unless it carries `x-b44-feed-context`, which the backend sends only for production calls of opted-in apps while its tail switch is on. Runtime behaviour is unchanged, crash path included. The banner loses its `errorRecords` field; `compiler` says which builds carry the record. This changes every build's output once: about +3 KB raw (+1.2 KB gzipped) per module. Co-Authored-By: Claude Opus 5.5 --- packages/functions-compiler/README.md | 38 +- packages/functions-compiler/src/bundler.ts | 18 +- packages/functions-compiler/src/contracts.ts | 5 - .../functions-compiler/src/error-records.ts | 13 +- .../functions-compiler/src/worker-entry.ts | 56 +- .../__snapshots__/worker-entry.test.ts.snap | 596 ++++++++++-------- .../test/error-records.e2e.test.ts | 28 +- .../test/worker-entry.test.ts | 62 +- 8 files changed, 420 insertions(+), 396 deletions(-) diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 0fccaddf1..3611d80e6 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -65,15 +65,17 @@ The first line of a compiled shard is its own manifest: `//!b44:` is a fixed sentinel, so `head -1` on a script pulled from Cloudflare answers "what is in this?" without executing or parsing anything, and -the payload is JSON so a tool parses it in one call. Only the app path emits it; -the legacy single-function `bundle()` stays bannerless, which keeps that lane -byte-comparable with the engine apper still runs. +the payload is JSON so a tool parses it in one call. Only the app path emits it. +The legacy single-function `bundle()` stays bannerless: it compiles one function, +so "which functions are inside" already has its answer. apper's bundler service +now runs this package (it no longer keeps its own engine), and nothing compares +that lane's output byte for byte across engines. `functions` is sorted whatever order the shard was built in. `telemetry` and `runtimeSecrets` are there because they change the emitted bytes and a deploy has -to pair its secrets delivery with them. `errorRecords` changes the bytes too, but -appears only when it is on, so a flag-off banner keeps its bytes. `compiler` is -this package's version. +to pair its secrets delivery with them. `compiler` is this package's version; it +also tells a consumer which wrapper features a build carries, such as the +`_b44_error` record (see "Error records for a handled 5xx"). Nothing volatile may be added: a timestamp or build id would re-mint a version for unchanged code, the app id would make the same functions compile differently @@ -91,7 +93,9 @@ code that did not change, and all of these do: - the order the caller hands functions over in, for a single shard: that path builds in caller order, while a multi-shard plan sorts by name. Both branches are as apper has them, and `shard-build.e2e.test.ts` pins the difference; -- any wrapper flag (`postResponseTelemetry`, `runtimeSecrets`, `errorRecords`). +- either wrapper flag (`postResponseTelemetry`, `runtimeSecrets`). The + `_b44_error` recorder is not a flag: every build carries it, so it moves the + bytes only when this package's version does. Two behaviours differ from apper's Python on purpose, and both are tested: @@ -164,13 +168,19 @@ function a frame belongs to reads that from the log line, not the map. ### Error records for a handled 5xx A function that catches its own error and returns a 500 leaves Cloudflare -nothing to record. `errorRecords: true` on either request bakes a small -recorder into the generated entry (single-function and per-app; not actors). -It is off by default, the public CLI never sets it, and with it off the entry -is unchanged. - -At runtime the recorder does nothing unless the request carries -`x-b44-feed-context`. When the handler then returns a `Response` with status +nothing to record. **Every build** carries a small recorder in its generated +entry (single-function and per-app; not actors), with no build option. That +includes the public CLI's own builds, so an app is ready the moment it opts in, +with no rebuild. It adds about 3 KB to a module (about 1.2 KB gzipped). + +The off switch is the request, not the build: the recorder does nothing unless +the request carries `x-b44-feed-context`. The backend sends that header only for +production calls of apps that opted in, while its tail switch is on; turning the +switch off silences the record in every bundle, whatever built it. Without the +header the recorder costs one header check per request and one context lookup +per `console.*` call. + +When the header is present and the handler returns a `Response` with status ≥ 500, that same response goes back at once: never rebuilt, never delayed, no header set. On a clone, inside `ctx.waitUntil`, the recorder: diff --git a/packages/functions-compiler/src/bundler.ts b/packages/functions-compiler/src/bundler.ts index cc4280f23..c47ac9faf 100644 --- a/packages/functions-compiler/src/bundler.ts +++ b/packages/functions-compiler/src/bundler.ts @@ -125,7 +125,6 @@ export async function bundle(req: BundleRequest): Promise { req.files, req.postResponseTelemetry, req.runtimeSecrets, - req.errorRecords, ); } catch (e) { return denoCompatErrorOrThrow(e); @@ -170,7 +169,6 @@ async function assembleApp(req: BundleAppRequest): Promise { const build: AppBuild = { telemetry: req.postResponseTelemetry ?? false, runtimeSecrets: req.runtimeSecrets ?? false, - errorRecords: req.errorRecords ?? false, sourceMap: req.sourceMap ?? false, }; const entries: AppFunctionEntry[] = req.functions.map((fn, index) => ({ @@ -220,13 +218,11 @@ async function assembleApp(req: BundleAppRequest): Promise { return bundleAppPerFunction(entries, build); } -/** How a per-app build is wrapped and emitted. `telemetry`, `runtimeSecrets` - * and `errorRecords` change the generated entry; `sourceMap` only adds a map - * beside the module. */ +/** How a per-app build is wrapped and emitted. `telemetry` and `runtimeSecrets` + * change the generated entry; `sourceMap` only adds a map beside the module. */ interface AppBuild { telemetry: boolean; runtimeSecrets: boolean; - errorRecords: boolean; sourceMap: boolean; } @@ -239,12 +235,7 @@ async function compileApp( build: AppBuild, ): Promise { const outcome = await installAndCompile( - prepareApp( - entries, - build.telemetry, - build.runtimeSecrets, - build.errorRecords, - ), + prepareApp(entries, build.telemetry, build.runtimeSecrets), build.sourceMap, ); if (!outcome.ok) return { ok: false, errors: outcome.errors }; @@ -436,7 +427,7 @@ type FunctionProbe = * Compiles in the real `runtimeSecrets` mode so a mode-dependent failure (a * user file named the reserved activation filename) is attributed to its * function instead of leaking out of the final assembly as a 500. Its module - * is discarded, so it skips telemetry, error records and the source map. */ + * is discarded, so it skips telemetry and the source map. */ async function probeFunction( entry: AppFunctionEntry, runtimeSecrets = false, @@ -446,7 +437,6 @@ async function probeFunction( outcome = await compileApp([entry], { telemetry: false, runtimeSecrets, - errorRecords: false, sourceMap: false, }); } catch (e) { diff --git a/packages/functions-compiler/src/contracts.ts b/packages/functions-compiler/src/contracts.ts index 9dd378173..d14ce2122 100644 --- a/packages/functions-compiler/src/contracts.ts +++ b/packages/functions-compiler/src/contracts.ts @@ -26,10 +26,6 @@ export const bundleRequestSchema = z // Return an external source map whose sources name the app's own files. // Off by default; only Base44's bundler service sets it. sourceMap: z.boolean().optional(), - // Bake the handled-5xx `_b44_error` log line into the generated Worker - // entry (src/error-records.ts). Off by default; only Base44's bundler - // service sets it. - errorRecords: z.boolean().optional(), }) .refine((body) => body.entry in body.files, { message: "`entry` must be a key in `files`", @@ -52,7 +48,6 @@ export const bundleAppRequestSchema = z postResponseTelemetry: z.boolean().optional(), runtimeSecrets: z.boolean().optional(), sourceMap: z.boolean().optional(), - errorRecords: z.boolean().optional(), }) .refine( (body) => diff --git a/packages/functions-compiler/src/error-records.ts b/packages/functions-compiler/src/error-records.ts index 513f14bdb..dc60a4541 100644 --- a/packages/functions-compiler/src/error-records.ts +++ b/packages/functions-compiler/src/error-records.ts @@ -3,13 +3,14 @@ * * A function that catches its own error and returns a 500 leaves Cloudflare * nothing to record: no exception reaches the runtime, so the Tail Worker sees - * a status and no reason. When the bundle request carries `errorRecords: true` - * (only Base44's bundler sets it), the generated entry appends this prelude and - * routes the handler's response through `_b44RecordError`. Flag off appends - * nothing — the generated entry stays byte-identical. + * a status and no reason. Every generated entry (single-function and per-app; + * not actors) appends this prelude and routes the handler's response through + * `_b44RecordError`, with no build flag: an app is ready the moment it opts in, + * whatever compiled it. * - * At runtime it does nothing unless the request carries `x-b44-feed-context`, - * which the backend sends only for production calls of apps that opted in; + * The off switch is the request, not the build: it does nothing unless the + * request carries `x-b44-feed-context`, which the backend sends only for + * production calls of apps that opted in while its tail switch is on; * presence is enough. For a `Response` with status >= 500 it returns that same * response at once — never rebuilt, never delayed, no header set — and inside * `ctx.waitUntil` (a line logged there still belongs to the invocation's tail diff --git a/packages/functions-compiler/src/worker-entry.ts b/packages/functions-compiler/src/worker-entry.ts index 0e4cf7be6..86dc0fcd4 100644 --- a/packages/functions-compiler/src/worker-entry.ts +++ b/packages/functions-compiler/src/worker-entry.ts @@ -123,7 +123,6 @@ export async function prepareFunction( files: Record, postResponseTelemetry = false, runtimeSecrets = false, - errorRecords = false, ): Promise { assertNoReservedFilenames(files); return { @@ -138,7 +137,6 @@ export async function prepareFunction( entry, postResponseTelemetry, runtimeSecrets, - errorRecords, ), }, }; @@ -165,14 +163,11 @@ function buildBanner( entries: AppFunctionEntry[], telemetry: boolean, runtimeSecrets: boolean, - errorRecords: boolean, ): string { return `//!b44:${BANNER_FORMAT} ${JSON.stringify({ functions: entries.map(({ fn }) => fn.name).sort(), telemetry, runtimeSecrets, - // Present only when on, so a flag-off banner keeps its bytes. - ...(errorRecords ? { errorRecords } : {}), compiler: COMPILER_VERSION, })}`; } @@ -194,7 +189,6 @@ export function prepareApp( entries: AppFunctionEntry[], postResponseTelemetry = false, runtimeSecrets = false, - errorRecords = false, ): PreparedWorker { const files: Record = { ...workerRuntimeFiles(), @@ -216,17 +210,11 @@ export function prepareApp( moduleFiles, postResponseTelemetry, runtimeSecrets, - errorRecords, ); return { entry: ENTRY_FILENAME, files, - banner: buildBanner( - entries, - postResponseTelemetry, - runtimeSecrets, - errorRecords, - ), + banner: buildBanner(entries, postResponseTelemetry, runtimeSecrets), }; } @@ -254,22 +242,10 @@ function activationGuard(runtimeSecrets: boolean): (expr: string) => string { runtimeSecrets ? `withoutActivationHeaders(${expr}, _b44Activation)` : expr; } -// Error records (src/error-records.ts) see the handler's own response, before -// telemetry or invocation logs re-wrap it. Each piece is empty with the flag -// off, so those entries keep their bytes. -function handlerCall(errorRecords: boolean): string { - return errorRecords - ? "_b44RecordError(await handler(request, info))" - : "await handler(request, info)"; -} - -function errorRecordsPatch(errorRecords: boolean): string { - return errorRecords ? `${ERROR_RECORDS_PATCH}\n` : ""; -} - -function errorRecordsStore(errorRecords: boolean): string { - return errorRecords ? ", ..._b44ErrorStore(request)" : ""; -} +// Every entry records a returned 5xx (src/error-records.ts), inert unless the +// request carries the feed-context header. The recorder sees the handler's own +// response, before telemetry or invocation logs re-wrap it. +const HANDLER_CALL = "_b44RecordError(await handler(request, info))"; function activationImport(runtimeSecrets: boolean): string { return runtimeSecrets @@ -284,18 +260,14 @@ function buildEntrySource( userEntry: string, telemetry: boolean, runtimeSecrets = false, - errorRecords = false, ): string { const userImport = `./${userEntry.replace(/^\.\//, "")}`; // Handler responses get the shim's headers stripped (a forged error header // after side effects would read as a non-billable failed activation) and the // installing request's marker added. - const call = handlerCall(errorRecords); const handlerExpr = telemetry - ? `_b44AttachTelemetry(${call})` - : runtimeSecrets || errorRecords - ? call - : "handler(request, info)"; + ? `_b44AttachTelemetry(${HANDLER_CALL})` + : HANDLER_CALL; const guard = activationGuard(runtimeSecrets); const returnExpr = guard(handlerExpr); return `// Auto-generated by the base44 bundler. Do not edit. @@ -304,7 +276,8 @@ import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as import { getRegisteredHandler, installStaticEgressFetch } from "./${SHIM_FILENAME}";${activationImport(runtimeSecrets)} ${consolePatch(runtimeSecrets)} -${errorRecordsPatch(errorRecords)}// Static egress reads workerEnv from the active request store. Install it +${ERROR_RECORDS_PATCH} +// Static egress reads workerEnv from the active request store. Install it // before telemetry so telemetry remains the outermost fetch wrapper. installStaticEgressFetch(); ${telemetry ? TELEMETRY_PATCH : ""} @@ -313,7 +286,7 @@ let _b44Init = null; export default { async fetch(request, env, ctx) { ${runtimeSecrets ? ACTIVATION_TAKE : ""} const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; - return _b44Run({ env: _b44Env, secrets: ${runtimeSecrets ? "process.env" : "env"}, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p)${errorRecordsStore(errorRecords)}${telemetry ? `, ${TELEMETRY_STORE_FIELDS}` : ""} }, async () => { + return _b44Run({ env: _b44Env, secrets: ${runtimeSecrets ? "process.env" : "env"}, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44ErrorStore(request)${telemetry ? `, ${TELEMETRY_STORE_FIELDS}` : ""} }, async () => { ${runtimeSecrets ? ACTIVATION_GATE : ""} if (!_b44Init) _b44Init = import(${JSON.stringify(userImport)}).catch(e => { _b44Init = null; throw e; }); const _b44Mod = await _b44Init; // Deno.serve capture wins (legacy contract, zero behavior change); @@ -354,14 +327,12 @@ function buildAppEntrySource( functionModules: string[], telemetry: boolean, runtimeSecrets = false, - errorRecords = false, ): string { const moduleImports = functionModules .map((file) => `import "./${file}";`) .join("\n"); - const call = handlerCall(errorRecords); const handlerExpr = `_b44AttachInvocationLogs(${ - telemetry ? `_b44AttachTelemetry(${call})` : call + telemetry ? `_b44AttachTelemetry(${HANDLER_CALL})` : HANDLER_CALL })`; const guard = activationGuard(runtimeSecrets); const returnExpr = guard(handlerExpr); @@ -373,7 +344,8 @@ ${moduleImports} ${consolePatch(runtimeSecrets)} ${INVOCATION_LOGS_PATCH} -${errorRecordsPatch(errorRecords)}// Static egress reads workerEnv from the active request store. Install it +${ERROR_RECORDS_PATCH} +// Static egress reads workerEnv from the active request store. Install it // before telemetry so telemetry remains the outermost fetch wrapper. installStaticEgressFetch(); ${telemetry ? TELEMETRY_PATCH : ""} @@ -382,7 +354,7 @@ export default { async fetch(request, env, ctx) { ${runtimeSecrets ? ACTIVATION_TAKE : ""} const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; const functionName = request.headers.get("Base44-Function-Name"); - return _b44Run({ env: _b44Env, fn: functionName ?? '', secrets: ${runtimeSecrets ? "process.env" : "env"}, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44CaptureStore(request)${errorRecordsStore(errorRecords)}${telemetry ? `, ${TELEMETRY_STORE_FIELDS}` : ""} }, async () => { + return _b44Run({ env: _b44Env, fn: functionName ?? '', secrets: ${runtimeSecrets ? "process.env" : "env"}, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44CaptureStore(request), ..._b44ErrorStore(request)${telemetry ? `, ${TELEMETRY_STORE_FIELDS}` : ""} }, async () => { ${runtimeSecrets ? ACTIVATION_GATE : ""} // Each early return below logs through the patch first: per-function log // queries on per-app scripts keep only stamped lines, so a bare return // would leave the failing invocation with no trace in its own logs. diff --git a/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap b/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap index b541f446d..c3e15f417 100644 --- a/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap +++ b/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap @@ -62,243 +62,144 @@ const _b44CrashResponse = (e) => { if (m.includes('cpu time limit') || m.includes('too many subrequests')) return null; return _b44AttachInvocationLogs(new Response(JSON.stringify({ error: 'user-exception', detail: 'user worker threw an exception' }), { status: 500, headers: { 'Content-Type': 'application/json' } })); }; -// Static egress reads workerEnv from the active request store. Install it -// before telemetry so telemetry remains the outermost fetch wrapper. -installStaticEgressFetch(); - - -export default { - async fetch(request, env, ctx) { - const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; - const functionName = request.headers.get("Base44-Function-Name"); - return _b44Run({ env: _b44Env, fn: functionName ?? '', secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44CaptureStore(request) }, async () => { - // Each early return below logs through the patch first: per-function log - // queries on per-app scripts keep only stamped lines, so a bare return - // would leave the failing invocation with no trace in its own logs. - const pendingHandler = functionName ? resolveHandler(functionName) : undefined; - if (pendingHandler === undefined) { - const message = \`No function registered for "\${functionName ?? ""}"\`; - console.error(message); - return _b44AttachInvocationLogs(new Response(message, { status: 404 })); - } - let handler; - try { - handler = await pendingHandler; - } catch (e) { - console.error(\`Function "\${functionName}" failed to initialize:\`, e); - return _b44AttachInvocationLogs(new Response( - \`Function "\${functionName}" failed to initialize: \${e instanceof Error ? e.message : String(e)}\`, - { status: 500 }, - )); - } - if (handler === null) { - const message = \`Function "\${functionName}" must export default a request handler or call Deno.serve()\`; - console.error(message); - return _b44AttachInvocationLogs(new Response(message, { status: 503 })); - } - // Real client IP is in the "cf-connecting-ip" header, not this placeholder. - const info = { - remoteAddr: { transport: "tcp", hostname: "0.0.0.0", port: 0 }, - }; - // Cloudflare reports uncaught exceptions as its own log events, outside - // the console patch and therefore without function attribution. Log the - // crash through the patch (stamped with _b44_function) before - // rethrowing; per-function log queries on per-app scripts keep only - // stamped lines (see log_query.event_matches_function). Known gap: - // exceptions thrown while a response body streams happen after this - // frame returns and cannot be stamped — those crash events are - // dropped from per-function views, and lines logged after the response - // starts streaming are past the invocation-logs header too. - try { - return _b44AttachInvocationLogs(await handler(request, info)); - } catch (e) { - console.error(e); - const crash = _b44CrashResponse(e); - if (crash) return crash; - throw e; - } - }); - }, -}; -" -`; - -exports[`binding-mode entries > are pinned (telemetry=false) > single function 1`] = ` -"// Auto-generated by the base44 bundler. Do not edit. - -import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as _b44Run } from "base44:internal/runtime-context"; -import { getRegisteredHandler, installStaticEgressFetch } from "./__base44_deno_shim.mjs"; - -const _b44Orig = console.log; -const _b44S = x => { if (x instanceof Error) return x.stack || x.message; try { return typeof x === 'string' ? x : (JSON.stringify(x) ?? String(x)); } catch (e) { return String(x); } }; -const _b44Fmt = (a) => { - if (typeof a[0] !== 'string' || !a[0].includes('%')) return a.map(_b44S).join(' '); - let i = 1; - const s = a[0].replace(/%([sdifoOc])/g, (_, t) => { - if (i >= a.length) return '%' + t; - const v = a[i++]; - if (t === 'd' || t === 'i') { try { return String(Math.trunc(+v)); } catch(_) { return _b44S(v); } } - if (t === 'f') { try { return String(+v); } catch(_) { return _b44S(v); } } - if (t === 'c') return ''; - return _b44S(v); - }); - const tail = a.slice(i).map(_b44S).join(' '); - return tail ? s + ' ' + tail : s; -}; -const _b44Ascii = (s) => s.replace(/[\\u007f-\\uffff]/g, (c) => '\\\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')); -const _b44Capture = (c, lvl, msg) => { - if (!c.invocationLogs) return; - const cost = _b44Ascii(JSON.stringify(msg)).length + 30; - if (c.invocationLogs.length >= 40 || c.invocationLogsBytes + cost > 6144) { c.invocationLogsDropped += 1; return; } - c.invocationLogsBytes += cost; - c.invocationLogs.push({ level: lvl, message: msg }); -}; -const _b44Wrap = (lvl, a) => { const _c = _b44Context() ?? {}; const _m = _b44Fmt(a); _b44Capture(_c, lvl, _m); _b44Orig({ _b44_env: _c.env ?? 'preview', ...(_c.fn ? { _b44_function: _c.fn } : {}), level: lvl, message: _m }); }; -console.log = (...a) => _b44Wrap('info', a); -console.info = (...a) => _b44Wrap('info', a); -console.warn = (...a) => _b44Wrap('warn', a); -console.error = (...a) => _b44Wrap('error', a); -console.debug = (...a) => _b44Wrap('debug', a); -const _b44ReservedSecrets = new Set(['BASE44_ACTOR_PRIVATE_KEY', 'BASE44_ACTOR_PUBLIC_KEY', 'BASE44_ACTOR_SCRIPT_ID', 'BASE44_PRIVATE_DATA_SOURCES']); -globalThis.Base44 = Object.assign(globalThis.Base44 ?? {}, { waitUntil: (p) => { const _c = _b44Context(); if (_c?.waitUntil) { _c.waitUntil(p); } else { Promise.resolve(p).catch(() => {}); } }, secrets: { get: (n) => { const _k = String(n); if (_b44ReservedSecrets.has(_k)) return undefined; const _v = _b44Context()?.secrets?.[_k]; return typeof _v === 'string' ? _v : undefined; } } }); -globalThis.EdgeRuntime = globalThis.EdgeRuntime ?? { waitUntil: (p) => globalThis.Base44.waitUntil(p) }; -// Static egress reads workerEnv from the active request store. Install it -// before telemetry so telemetry remains the outermost fetch wrapper. -installStaticEgressFetch(); - - -let _b44Init = null; -export default { - async fetch(request, env, ctx) { - const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; - return _b44Run({ env: _b44Env, secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p) }, async () => { - if (!_b44Init) _b44Init = import("./main.ts").catch(e => { _b44Init = null; throw e; }); - const _b44Mod = await _b44Init; - // Deno.serve capture wins (legacy contract, zero behavior change); - // a default-exported handler is the new-contract fallback. - const handler = getRegisteredHandler() ?? (typeof _b44Mod?.default === 'function' ? _b44Mod.default : null); - if (!handler) { - return new Response( - "The function must export default a request handler or call Deno.serve()", - { status: 503 }, - ); - } - // Deno's handler signature is (request, info). Cloudflare doesn't expose a - // connection address the same way; pass a best-effort placeholder. Real - // client IP is available via the "cf-connecting-ip" request header. - const info = { - remoteAddr: { transport: "tcp", hostname: "0.0.0.0", port: 0 }, - }; - return handler(request, info); - }); - }, +const _b44ErrorStore = (request) => request.headers.has("x-b44-feed-context") ? { errorRecord: { logged: [] } } : {}; +const _b44NoteError = (a) => { + const c = _b44Context(); + if (!c || !c.errorRecord) return; + for (const x of a) { + if (!(x instanceof Error)) continue; + const r = (k) => { try { const v = x[k]; return typeof v === 'string' ? v : undefined; } catch (e) { return undefined; } }; + const logged = c.errorRecord.logged; + logged.push({ name: r('name'), message: r('message'), stack: r('stack') }); + if (logged.length > 8) logged.shift(); + } }; -" -`; - -exports[`binding-mode entries > are pinned (telemetry=true) > per-app 1`] = ` -"// Auto-generated by the base44 bundler. Do not edit. - -import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as _b44Run } from "base44:internal/runtime-context"; -import { installStaticEgressFetch, resolveHandler } from "./__base44_deno_shim.mjs"; -import "./__base44_fn_0.mjs"; - -const _b44Orig = console.log; -const _b44S = x => { if (x instanceof Error) return x.stack || x.message; try { return typeof x === 'string' ? x : (JSON.stringify(x) ?? String(x)); } catch (e) { return String(x); } }; -const _b44Fmt = (a) => { - if (typeof a[0] !== 'string' || !a[0].includes('%')) return a.map(_b44S).join(' '); - let i = 1; - const s = a[0].replace(/%([sdifoOc])/g, (_, t) => { - if (i >= a.length) return '%' + t; - const v = a[i++]; - if (t === 'd' || t === 'i') { try { return String(Math.trunc(+v)); } catch(_) { return _b44S(v); } } - if (t === 'f') { try { return String(+v); } catch(_) { return _b44S(v); } } - if (t === 'c') return ''; - return _b44S(v); - }); - const tail = a.slice(i).map(_b44S).join(' '); - return tail ? s + ' ' + tail : s; +for (const _l of ['log', 'info', 'warn', 'error', 'debug']) { const _f = console[_l]; console[_l] = (...a) => { try { _b44NoteError(a); } catch (e) {} return _f(...a); }; } +const _b44ReadJson = async (res) => { + if (!res.body) return undefined; + const reader = res.body.getReader(); + const chunks = []; + let size = 0; + let timer; + const deadline = new Promise((r) => { timer = setTimeout(() => r(null), 50); }); + try { + for (;;) { + const step = await Promise.race([reader.read(), deadline]); + if (step === null) return undefined; + if (step.done) break; + size += step.value.byteLength; + if (size > 2048) return undefined; + chunks.push(step.value); + } + } finally { + clearTimeout(timer); + try { reader.cancel().catch(() => {}); } catch (e) {} + } + const buf = new Uint8Array(size); + let o = 0; + for (const ch of chunks) { buf.set(ch, o); o += ch.byteLength; } + try { return JSON.parse(new TextDecoder().decode(buf)); } catch (e) { return undefined; } }; -const _b44Ascii = (s) => s.replace(/[\\u007f-\\uffff]/g, (c) => '\\\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')); -const _b44Capture = (c, lvl, msg) => { - if (!c.invocationLogs) return; - const cost = _b44Ascii(JSON.stringify(msg)).length + 30; - if (c.invocationLogs.length >= 40 || c.invocationLogsBytes + cost > 6144) { c.invocationLogsDropped += 1; return; } - c.invocationLogsBytes += cost; - c.invocationLogs.push({ level: lvl, message: msg }); +const _b44HasString = (v, s, d = 0) => { + if (v === s) return true; + if (d > 16 || !v || typeof v !== 'object') return false; + for (const k of Object.keys(v)) if (_b44HasString(v[k], s, d + 1)) return true; + return false; }; -const _b44Wrap = (lvl, a) => { const _c = _b44Context() ?? {}; const _m = _b44Fmt(a); _b44Capture(_c, lvl, _m); _b44Orig({ _b44_env: _c.env ?? 'preview', ...(_c.fn ? { _b44_function: _c.fn } : {}), level: lvl, message: _m }); }; -console.log = (...a) => _b44Wrap('info', a); -console.info = (...a) => _b44Wrap('info', a); -console.warn = (...a) => _b44Wrap('warn', a); -console.error = (...a) => _b44Wrap('error', a); -console.debug = (...a) => _b44Wrap('debug', a); -const _b44ReservedSecrets = new Set(['BASE44_ACTOR_PRIVATE_KEY', 'BASE44_ACTOR_PUBLIC_KEY', 'BASE44_ACTOR_SCRIPT_ID', 'BASE44_PRIVATE_DATA_SOURCES']); -globalThis.Base44 = Object.assign(globalThis.Base44 ?? {}, { waitUntil: (p) => { const _c = _b44Context(); if (_c?.waitUntil) { _c.waitUntil(p); } else { Promise.resolve(p).catch(() => {}); } }, secrets: { get: (n) => { const _k = String(n); if (_b44ReservedSecrets.has(_k)) return undefined; const _v = _b44Context()?.secrets?.[_k]; return typeof _v === 'string' ? _v : undefined; } } }); -globalThis.EdgeRuntime = globalThis.EdgeRuntime ?? { waitUntil: (p) => globalThis.Base44.waitUntil(p) }; -const _b44CaptureStore = (request) => request.headers.get("X-B44-Capture-Logs") === '1' ? { invocationLogs: [], invocationLogsBytes: 0, invocationLogsDropped: 0 } : {}; -const _b44AttachInvocationLogs = (res) => { - const c = _b44Context(); - if (!c || !c.invocationLogs) return res; - if (!(res instanceof Response) || res.status === 101 || res.webSocket) return res; - let existingBytes = 25; - res.headers.forEach((v, k) => { existingBytes += k.length + v.length + 4; }); - let payload = _b44Ascii(JSON.stringify({ lines: c.invocationLogs, dropped: c.invocationLogsDropped })); - if (existingBytes + payload.length > 7168) payload = JSON.stringify({ lines: [], dropped: c.invocationLogs.length + c.invocationLogsDropped }); - if (existingBytes + payload.length > 7168) return res; - let wrapped; - try { wrapped = new Response(res.body, res); } catch (e) { return res; } - try { wrapped.headers.set("X-B44-Invocation-Logs", payload); } catch (e) {} - return wrapped; +const _b44BodyMessage = (b) => { + if (!b || typeof b !== 'object' || Array.isArray(b)) return undefined; + const s = (v) => (typeof v === 'string' && v.trim() !== '' ? v : undefined); + const skipError = Object.prototype.hasOwnProperty.call(b, 'details') && b.error === 'Internal server error'; + const nested = b.error && typeof b.error === 'object' ? b.error.message : undefined; + return (skipError ? undefined : s(b.error)) ?? s(b.message) ?? s(b.detail) ?? s(b.details) ?? s(nested); }; -const _b44CrashResponse = (e) => { - const c = _b44Context(); - if (!c || !c.invocationLogs) return null; - const m = String(e instanceof Error ? e.message : e).toLowerCase(); - if (m.includes('cpu time limit') || m.includes('too many subrequests')) return null; - return _b44AttachInvocationLogs(new Response(JSON.stringify({ error: 'user-exception', detail: 'user worker threw an exception' }), { status: 500, headers: { 'Content-Type': 'application/json' } })); +const _b44SecretValues = (c) => { + const out = new Set(); + let budget = 10000; + const add = (v, d) => { + if (--budget < 0) return; + if (typeof v === 'string') { + if (v.length >= 8) out.add(v); + const t = v.trimStart(); + if (d < 2 && (t[0] === '{' || t[0] === '[')) { try { add(JSON.parse(v), d + 1); } catch (e) {} } + } else if (d > 0 && d < 16 && v && typeof v === 'object') { + for (const k of Object.keys(v)) add(v[k], d + 1); + } + }; + for (const src of [c.secrets, c.workerEnv]) { + if (!src || typeof src !== 'object') continue; + for (const k of Object.keys(src)) { try { add(src[k], 0); } catch (e) {} } + } + return [...out].sort((x, y) => y.length - x.length); }; -// Static egress reads workerEnv from the active request store. Install it -// before telemetry so telemetry remains the outermost fetch wrapper. -installStaticEgressFetch(); -const _b44Tele = (c, event, fields) => { - if (!(c.logBudget > 0)) return; - c.logBudget -= 1; - try { console.log(JSON.stringify({ b44_telemetry: 'post_response_work', event, request_id: c.reqId, ...fields })); } catch (e) {} +const _b44Scrub = (s, secrets) => { let out = s; for (const v of secrets) if (out.includes(v)) out = out.split(v).join('[redacted]'); return out; }; +const _b44Well = (s) => (typeof s.toWellFormed === 'function' ? s.toWellFormed() : s); +const _b44Bytes = (s) => new TextEncoder().encode(s).length; +const _b44CutBytes = (s, max) => { + if (_b44Bytes(s) <= max) return s; + let n = 0; + let out = ''; + for (const ch of s) { + const cp = ch.codePointAt(0); + const b = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4; + if (n + b > max) break; + n += b; + out += ch; + } + return out; }; -const _b44Target = (input) => { try { const u = typeof input === 'string' ? new URL(input) : (input instanceof URL ? input : new URL(input.url)); return u.origin; } catch (e) { return 'unknown'; } }; -const _b44OrigFetch = globalThis.fetch.bind(globalThis); -globalThis.fetch = (input, init) => { - const c = _b44Context(); - if (!c || c.respondedAt === undefined) return _b44OrigFetch(input, init); - const t = _b44Target(input); - if (c.respondedAt !== null) _b44Tele(c, 'fetch_started_post_response', { target: t, ms_after_response: Date.now() - c.respondedAt }); - const tracked = c.inflight.has(t) || c.inflight.size < 10; - if (tracked) c.inflight.set(t, (c.inflight.get(t) ?? 0) + 1); - c.inflightTotal += 1; - const done = () => { c.inflightTotal -= 1; if (tracked) { const n = c.inflight.get(t); if (n !== undefined) { if (n <= 1) c.inflight.delete(t); else c.inflight.set(t, n - 1); } } }; - const failed = () => { if (c.respondedAt === null) c.hadRejection = true; done(); }; - const settled = (res) => { if (c.respondedAt === null && res && res.ok === false) c.hadNonOk = true; done(); }; - try { const p = _b44OrigFetch(input, init); p.then(settled, failed); if (c.waitUntil) { try { c.waitUntil(p.then(() => {}, () => {})); } catch (e) {} } return p; } catch (e) { failed(); throw e; } +const _b44CutLines = (s, max) => { + if (_b44Bytes(s) <= max) return s; + let n = 0; + let out = ''; + for (const line of s.split('\\n')) { + const b = _b44Bytes(line) + (out ? 1 : 0); + if (n + b > max) break; + out += (out ? '\\n' : '') + line; + n += b; + } + return out; }; -const _b44AttachTelemetry = (res) => { - const c = _b44Context(); - if (!c || c.respondedAt === undefined) return res; - c.respondedAt = Date.now(); - if (c.inflightTotal <= 0 || res.status === 101 || res.webSocket) return res; - const pending = { request_id: c.reqId, inflight: c.inflightTotal, targets: [...c.inflight.keys()], rejected_pre_response: c.hadRejection, non_ok_pre_response: c.hadNonOk }; - _b44Tele(c, 'inflight_at_response', pending); - let wrapped; - try { wrapped = new Response(res.body, res); } catch (e) { return res; } - try { wrapped.headers.set('X-B44-Post-Response-Telemetry', JSON.stringify({ pending })); } catch (e) {} - return wrapped; +const _b44WriteErrorRecord = async (c, status, copy, logged) => { + try { + const body = await _b44ReadJson(copy); + const usable = logged.filter((e) => e.message); + let chosen; + if (body !== undefined) { + for (let i = usable.length - 1; i >= 0; i--) if (_b44HasString(body, usable[i].message)) { chosen = usable[i]; break; } + } + let message = chosen ? chosen.message : _b44BodyMessage(body); + if (message === undefined && usable.length > 0) { chosen = usable[usable.length - 1]; message = chosen.message; } + if (message === undefined) return; + const secrets = _b44SecretValues(c); + const clean = (s) => _b44Well(_b44Scrub(s, secrets)); + const rec = { v: 1, kind: 'response', status }; + const name = chosen && chosen.name ? Array.from(clean(chosen.name)).slice(0, 64).join('') : ''; + if (name) rec.name = name; + rec.message = _b44CutBytes(clean(message), 500); + const stack = chosen && chosen.stack ? _b44CutLines(clean(chosen.stack), 4096) : ''; + if (stack) rec.stack = stack; + console.error({ _b44_error: rec }); + } catch (e) {} +}; +const _b44RecordError = (res) => { + try { + const c = _b44Context(); + if (!c || !c.errorRecord || !c.waitUntil || !(res instanceof Response) || !(res.status >= 500)) return res; + c.waitUntil(_b44WriteErrorRecord(c, res.status, res.clone(), c.errorRecord.logged.slice())); + } catch (e) {} + return res; }; +// Static egress reads workerEnv from the active request store. Install it +// before telemetry so telemetry remains the outermost fetch wrapper. +installStaticEgressFetch(); + export default { async fetch(request, env, ctx) { const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; const functionName = request.headers.get("Base44-Function-Name"); - return _b44Run({ env: _b44Env, fn: functionName ?? '', secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44CaptureStore(request), reqId: crypto.randomUUID(), respondedAt: null, inflight: new Map(), inflightTotal: 0, hadRejection: false, hadNonOk: false, logBudget: 5 }, async () => { + return _b44Run({ env: _b44Env, fn: functionName ?? '', secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44CaptureStore(request), ..._b44ErrorStore(request) }, async () => { // Each early return below logs through the patch first: per-function log // queries on per-app scripts keep only stamped lines, so a bare return // would leave the failing invocation with no trace in its own logs. @@ -337,7 +238,7 @@ export default { // dropped from per-function views, and lines logged after the response // starts streaming are past the invocation-logs header too. try { - return _b44AttachInvocationLogs(_b44AttachTelemetry(await handler(request, info))); + return _b44AttachInvocationLogs(_b44RecordError(await handler(request, info))); } catch (e) { console.error(e); const crash = _b44CrashResponse(e); @@ -350,7 +251,7 @@ export default { " `; -exports[`binding-mode entries > are pinned (telemetry=true) > single function 1`] = ` +exports[`binding-mode entries > are pinned (telemetry=false) > single function 1`] = ` "// Auto-generated by the base44 bundler. Do not edit. import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as _b44Run } from "base44:internal/runtime-context"; @@ -389,47 +290,144 @@ console.debug = (...a) => _b44Wrap('debug', a); const _b44ReservedSecrets = new Set(['BASE44_ACTOR_PRIVATE_KEY', 'BASE44_ACTOR_PUBLIC_KEY', 'BASE44_ACTOR_SCRIPT_ID', 'BASE44_PRIVATE_DATA_SOURCES']); globalThis.Base44 = Object.assign(globalThis.Base44 ?? {}, { waitUntil: (p) => { const _c = _b44Context(); if (_c?.waitUntil) { _c.waitUntil(p); } else { Promise.resolve(p).catch(() => {}); } }, secrets: { get: (n) => { const _k = String(n); if (_b44ReservedSecrets.has(_k)) return undefined; const _v = _b44Context()?.secrets?.[_k]; return typeof _v === 'string' ? _v : undefined; } } }); globalThis.EdgeRuntime = globalThis.EdgeRuntime ?? { waitUntil: (p) => globalThis.Base44.waitUntil(p) }; -// Static egress reads workerEnv from the active request store. Install it -// before telemetry so telemetry remains the outermost fetch wrapper. -installStaticEgressFetch(); -const _b44Tele = (c, event, fields) => { - if (!(c.logBudget > 0)) return; - c.logBudget -= 1; - try { console.log(JSON.stringify({ b44_telemetry: 'post_response_work', event, request_id: c.reqId, ...fields })); } catch (e) {} -}; -const _b44Target = (input) => { try { const u = typeof input === 'string' ? new URL(input) : (input instanceof URL ? input : new URL(input.url)); return u.origin; } catch (e) { return 'unknown'; } }; -const _b44OrigFetch = globalThis.fetch.bind(globalThis); -globalThis.fetch = (input, init) => { +const _b44ErrorStore = (request) => request.headers.has("x-b44-feed-context") ? { errorRecord: { logged: [] } } : {}; +const _b44NoteError = (a) => { const c = _b44Context(); - if (!c || c.respondedAt === undefined) return _b44OrigFetch(input, init); - const t = _b44Target(input); - if (c.respondedAt !== null) _b44Tele(c, 'fetch_started_post_response', { target: t, ms_after_response: Date.now() - c.respondedAt }); - const tracked = c.inflight.has(t) || c.inflight.size < 10; - if (tracked) c.inflight.set(t, (c.inflight.get(t) ?? 0) + 1); - c.inflightTotal += 1; - const done = () => { c.inflightTotal -= 1; if (tracked) { const n = c.inflight.get(t); if (n !== undefined) { if (n <= 1) c.inflight.delete(t); else c.inflight.set(t, n - 1); } } }; - const failed = () => { if (c.respondedAt === null) c.hadRejection = true; done(); }; - const settled = (res) => { if (c.respondedAt === null && res && res.ok === false) c.hadNonOk = true; done(); }; - try { const p = _b44OrigFetch(input, init); p.then(settled, failed); if (c.waitUntil) { try { c.waitUntil(p.then(() => {}, () => {})); } catch (e) {} } return p; } catch (e) { failed(); throw e; } + if (!c || !c.errorRecord) return; + for (const x of a) { + if (!(x instanceof Error)) continue; + const r = (k) => { try { const v = x[k]; return typeof v === 'string' ? v : undefined; } catch (e) { return undefined; } }; + const logged = c.errorRecord.logged; + logged.push({ name: r('name'), message: r('message'), stack: r('stack') }); + if (logged.length > 8) logged.shift(); + } }; -const _b44AttachTelemetry = (res) => { - const c = _b44Context(); - if (!c || c.respondedAt === undefined) return res; - c.respondedAt = Date.now(); - if (c.inflightTotal <= 0 || res.status === 101 || res.webSocket) return res; - const pending = { request_id: c.reqId, inflight: c.inflightTotal, targets: [...c.inflight.keys()], rejected_pre_response: c.hadRejection, non_ok_pre_response: c.hadNonOk }; - _b44Tele(c, 'inflight_at_response', pending); - let wrapped; - try { wrapped = new Response(res.body, res); } catch (e) { return res; } - try { wrapped.headers.set('X-B44-Post-Response-Telemetry', JSON.stringify({ pending })); } catch (e) {} - return wrapped; +for (const _l of ['log', 'info', 'warn', 'error', 'debug']) { const _f = console[_l]; console[_l] = (...a) => { try { _b44NoteError(a); } catch (e) {} return _f(...a); }; } +const _b44ReadJson = async (res) => { + if (!res.body) return undefined; + const reader = res.body.getReader(); + const chunks = []; + let size = 0; + let timer; + const deadline = new Promise((r) => { timer = setTimeout(() => r(null), 50); }); + try { + for (;;) { + const step = await Promise.race([reader.read(), deadline]); + if (step === null) return undefined; + if (step.done) break; + size += step.value.byteLength; + if (size > 2048) return undefined; + chunks.push(step.value); + } + } finally { + clearTimeout(timer); + try { reader.cancel().catch(() => {}); } catch (e) {} + } + const buf = new Uint8Array(size); + let o = 0; + for (const ch of chunks) { buf.set(ch, o); o += ch.byteLength; } + try { return JSON.parse(new TextDecoder().decode(buf)); } catch (e) { return undefined; } +}; +const _b44HasString = (v, s, d = 0) => { + if (v === s) return true; + if (d > 16 || !v || typeof v !== 'object') return false; + for (const k of Object.keys(v)) if (_b44HasString(v[k], s, d + 1)) return true; + return false; +}; +const _b44BodyMessage = (b) => { + if (!b || typeof b !== 'object' || Array.isArray(b)) return undefined; + const s = (v) => (typeof v === 'string' && v.trim() !== '' ? v : undefined); + const skipError = Object.prototype.hasOwnProperty.call(b, 'details') && b.error === 'Internal server error'; + const nested = b.error && typeof b.error === 'object' ? b.error.message : undefined; + return (skipError ? undefined : s(b.error)) ?? s(b.message) ?? s(b.detail) ?? s(b.details) ?? s(nested); +}; +const _b44SecretValues = (c) => { + const out = new Set(); + let budget = 10000; + const add = (v, d) => { + if (--budget < 0) return; + if (typeof v === 'string') { + if (v.length >= 8) out.add(v); + const t = v.trimStart(); + if (d < 2 && (t[0] === '{' || t[0] === '[')) { try { add(JSON.parse(v), d + 1); } catch (e) {} } + } else if (d > 0 && d < 16 && v && typeof v === 'object') { + for (const k of Object.keys(v)) add(v[k], d + 1); + } + }; + for (const src of [c.secrets, c.workerEnv]) { + if (!src || typeof src !== 'object') continue; + for (const k of Object.keys(src)) { try { add(src[k], 0); } catch (e) {} } + } + return [...out].sort((x, y) => y.length - x.length); +}; +const _b44Scrub = (s, secrets) => { let out = s; for (const v of secrets) if (out.includes(v)) out = out.split(v).join('[redacted]'); return out; }; +const _b44Well = (s) => (typeof s.toWellFormed === 'function' ? s.toWellFormed() : s); +const _b44Bytes = (s) => new TextEncoder().encode(s).length; +const _b44CutBytes = (s, max) => { + if (_b44Bytes(s) <= max) return s; + let n = 0; + let out = ''; + for (const ch of s) { + const cp = ch.codePointAt(0); + const b = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4; + if (n + b > max) break; + n += b; + out += ch; + } + return out; +}; +const _b44CutLines = (s, max) => { + if (_b44Bytes(s) <= max) return s; + let n = 0; + let out = ''; + for (const line of s.split('\\n')) { + const b = _b44Bytes(line) + (out ? 1 : 0); + if (n + b > max) break; + out += (out ? '\\n' : '') + line; + n += b; + } + return out; +}; +const _b44WriteErrorRecord = async (c, status, copy, logged) => { + try { + const body = await _b44ReadJson(copy); + const usable = logged.filter((e) => e.message); + let chosen; + if (body !== undefined) { + for (let i = usable.length - 1; i >= 0; i--) if (_b44HasString(body, usable[i].message)) { chosen = usable[i]; break; } + } + let message = chosen ? chosen.message : _b44BodyMessage(body); + if (message === undefined && usable.length > 0) { chosen = usable[usable.length - 1]; message = chosen.message; } + if (message === undefined) return; + const secrets = _b44SecretValues(c); + const clean = (s) => _b44Well(_b44Scrub(s, secrets)); + const rec = { v: 1, kind: 'response', status }; + const name = chosen && chosen.name ? Array.from(clean(chosen.name)).slice(0, 64).join('') : ''; + if (name) rec.name = name; + rec.message = _b44CutBytes(clean(message), 500); + const stack = chosen && chosen.stack ? _b44CutLines(clean(chosen.stack), 4096) : ''; + if (stack) rec.stack = stack; + console.error({ _b44_error: rec }); + } catch (e) {} +}; +const _b44RecordError = (res) => { + try { + const c = _b44Context(); + if (!c || !c.errorRecord || !c.waitUntil || !(res instanceof Response) || !(res.status >= 500)) return res; + c.waitUntil(_b44WriteErrorRecord(c, res.status, res.clone(), c.errorRecord.logged.slice())); + } catch (e) {} + return res; }; +// Static egress reads workerEnv from the active request store. Install it +// before telemetry so telemetry remains the outermost fetch wrapper. +installStaticEgressFetch(); + let _b44Init = null; export default { async fetch(request, env, ctx) { const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; - return _b44Run({ env: _b44Env, secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), reqId: crypto.randomUUID(), respondedAt: null, inflight: new Map(), inflightTotal: 0, hadRejection: false, hadNonOk: false, logBudget: 5 }, async () => { + return _b44Run({ env: _b44Env, secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44ErrorStore(request) }, async () => { if (!_b44Init) _b44Init = import("./main.ts").catch(e => { _b44Init = null; throw e; }); const _b44Mod = await _b44Init; // Deno.serve capture wins (legacy contract, zero behavior change); @@ -447,14 +445,14 @@ export default { const info = { remoteAddr: { transport: "tcp", hostname: "0.0.0.0", port: 0 }, }; - return _b44AttachTelemetry(await handler(request, info)); + return _b44RecordError(await handler(request, info)); }); }, }; " `; -exports[`error-records entries > are pinned when on > per-app 1`] = ` +exports[`binding-mode entries > are pinned (telemetry=true) > per-app 1`] = ` "// Auto-generated by the base44 bundler. Do not edit. import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as _b44Run } from "base44:internal/runtime-context"; @@ -647,13 +645,44 @@ const _b44RecordError = (res) => { // Static egress reads workerEnv from the active request store. Install it // before telemetry so telemetry remains the outermost fetch wrapper. installStaticEgressFetch(); - +const _b44Tele = (c, event, fields) => { + if (!(c.logBudget > 0)) return; + c.logBudget -= 1; + try { console.log(JSON.stringify({ b44_telemetry: 'post_response_work', event, request_id: c.reqId, ...fields })); } catch (e) {} +}; +const _b44Target = (input) => { try { const u = typeof input === 'string' ? new URL(input) : (input instanceof URL ? input : new URL(input.url)); return u.origin; } catch (e) { return 'unknown'; } }; +const _b44OrigFetch = globalThis.fetch.bind(globalThis); +globalThis.fetch = (input, init) => { + const c = _b44Context(); + if (!c || c.respondedAt === undefined) return _b44OrigFetch(input, init); + const t = _b44Target(input); + if (c.respondedAt !== null) _b44Tele(c, 'fetch_started_post_response', { target: t, ms_after_response: Date.now() - c.respondedAt }); + const tracked = c.inflight.has(t) || c.inflight.size < 10; + if (tracked) c.inflight.set(t, (c.inflight.get(t) ?? 0) + 1); + c.inflightTotal += 1; + const done = () => { c.inflightTotal -= 1; if (tracked) { const n = c.inflight.get(t); if (n !== undefined) { if (n <= 1) c.inflight.delete(t); else c.inflight.set(t, n - 1); } } }; + const failed = () => { if (c.respondedAt === null) c.hadRejection = true; done(); }; + const settled = (res) => { if (c.respondedAt === null && res && res.ok === false) c.hadNonOk = true; done(); }; + try { const p = _b44OrigFetch(input, init); p.then(settled, failed); if (c.waitUntil) { try { c.waitUntil(p.then(() => {}, () => {})); } catch (e) {} } return p; } catch (e) { failed(); throw e; } +}; +const _b44AttachTelemetry = (res) => { + const c = _b44Context(); + if (!c || c.respondedAt === undefined) return res; + c.respondedAt = Date.now(); + if (c.inflightTotal <= 0 || res.status === 101 || res.webSocket) return res; + const pending = { request_id: c.reqId, inflight: c.inflightTotal, targets: [...c.inflight.keys()], rejected_pre_response: c.hadRejection, non_ok_pre_response: c.hadNonOk }; + _b44Tele(c, 'inflight_at_response', pending); + let wrapped; + try { wrapped = new Response(res.body, res); } catch (e) { return res; } + try { wrapped.headers.set('X-B44-Post-Response-Telemetry', JSON.stringify({ pending })); } catch (e) {} + return wrapped; +}; export default { async fetch(request, env, ctx) { const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; const functionName = request.headers.get("Base44-Function-Name"); - return _b44Run({ env: _b44Env, fn: functionName ?? '', secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44CaptureStore(request), ..._b44ErrorStore(request) }, async () => { + return _b44Run({ env: _b44Env, fn: functionName ?? '', secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44CaptureStore(request), ..._b44ErrorStore(request), reqId: crypto.randomUUID(), respondedAt: null, inflight: new Map(), inflightTotal: 0, hadRejection: false, hadNonOk: false, logBudget: 5 }, async () => { // Each early return below logs through the patch first: per-function log // queries on per-app scripts keep only stamped lines, so a bare return // would leave the failing invocation with no trace in its own logs. @@ -692,7 +721,7 @@ export default { // dropped from per-function views, and lines logged after the response // starts streaming are past the invocation-logs header too. try { - return _b44AttachInvocationLogs(_b44RecordError(await handler(request, info))); + return _b44AttachInvocationLogs(_b44AttachTelemetry(_b44RecordError(await handler(request, info)))); } catch (e) { console.error(e); const crash = _b44CrashResponse(e); @@ -705,7 +734,7 @@ export default { " `; -exports[`error-records entries > are pinned when on > single function 1`] = ` +exports[`binding-mode entries > are pinned (telemetry=true) > single function 1`] = ` "// Auto-generated by the base44 bundler. Do not edit. import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as _b44Run } from "base44:internal/runtime-context"; @@ -875,13 +904,44 @@ const _b44RecordError = (res) => { // Static egress reads workerEnv from the active request store. Install it // before telemetry so telemetry remains the outermost fetch wrapper. installStaticEgressFetch(); - +const _b44Tele = (c, event, fields) => { + if (!(c.logBudget > 0)) return; + c.logBudget -= 1; + try { console.log(JSON.stringify({ b44_telemetry: 'post_response_work', event, request_id: c.reqId, ...fields })); } catch (e) {} +}; +const _b44Target = (input) => { try { const u = typeof input === 'string' ? new URL(input) : (input instanceof URL ? input : new URL(input.url)); return u.origin; } catch (e) { return 'unknown'; } }; +const _b44OrigFetch = globalThis.fetch.bind(globalThis); +globalThis.fetch = (input, init) => { + const c = _b44Context(); + if (!c || c.respondedAt === undefined) return _b44OrigFetch(input, init); + const t = _b44Target(input); + if (c.respondedAt !== null) _b44Tele(c, 'fetch_started_post_response', { target: t, ms_after_response: Date.now() - c.respondedAt }); + const tracked = c.inflight.has(t) || c.inflight.size < 10; + if (tracked) c.inflight.set(t, (c.inflight.get(t) ?? 0) + 1); + c.inflightTotal += 1; + const done = () => { c.inflightTotal -= 1; if (tracked) { const n = c.inflight.get(t); if (n !== undefined) { if (n <= 1) c.inflight.delete(t); else c.inflight.set(t, n - 1); } } }; + const failed = () => { if (c.respondedAt === null) c.hadRejection = true; done(); }; + const settled = (res) => { if (c.respondedAt === null && res && res.ok === false) c.hadNonOk = true; done(); }; + try { const p = _b44OrigFetch(input, init); p.then(settled, failed); if (c.waitUntil) { try { c.waitUntil(p.then(() => {}, () => {})); } catch (e) {} } return p; } catch (e) { failed(); throw e; } +}; +const _b44AttachTelemetry = (res) => { + const c = _b44Context(); + if (!c || c.respondedAt === undefined) return res; + c.respondedAt = Date.now(); + if (c.inflightTotal <= 0 || res.status === 101 || res.webSocket) return res; + const pending = { request_id: c.reqId, inflight: c.inflightTotal, targets: [...c.inflight.keys()], rejected_pre_response: c.hadRejection, non_ok_pre_response: c.hadNonOk }; + _b44Tele(c, 'inflight_at_response', pending); + let wrapped; + try { wrapped = new Response(res.body, res); } catch (e) { return res; } + try { wrapped.headers.set('X-B44-Post-Response-Telemetry', JSON.stringify({ pending })); } catch (e) {} + return wrapped; +}; let _b44Init = null; export default { async fetch(request, env, ctx) { const _b44Env = (request.headers.get('base44-functions-version') ?? '') === 'prod' ? 'prod' : 'preview'; - return _b44Run({ env: _b44Env, secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44ErrorStore(request) }, async () => { + return _b44Run({ env: _b44Env, secrets: env, workerEnv: env, waitUntil: (p) => ctx.waitUntil(p), ..._b44ErrorStore(request), reqId: crypto.randomUUID(), respondedAt: null, inflight: new Map(), inflightTotal: 0, hadRejection: false, hadNonOk: false, logBudget: 5 }, async () => { if (!_b44Init) _b44Init = import("./main.ts").catch(e => { _b44Init = null; throw e; }); const _b44Mod = await _b44Init; // Deno.serve capture wins (legacy contract, zero behavior change); @@ -899,7 +959,7 @@ export default { const info = { remoteAddr: { transport: "tcp", hostname: "0.0.0.0", port: 0 }, }; - return _b44RecordError(await handler(request, info)); + return _b44AttachTelemetry(_b44RecordError(await handler(request, info))); }); }, }; diff --git a/packages/functions-compiler/test/error-records.e2e.test.ts b/packages/functions-compiler/test/error-records.e2e.test.ts index 37a24a406..d9c2b42f7 100644 --- a/packages/functions-compiler/test/error-records.e2e.test.ts +++ b/packages/functions-compiler/test/error-records.e2e.test.ts @@ -1,7 +1,8 @@ /** - * `errorRecords: true` — one `_b44_error` log line for a returned 5xx, read the - * way production reads it: through a Tail Worker attached to the function. - * Every case runs in real workerd, with and without `x-b44-feed-context`. + * The `_b44_error` record every build carries: one log line for a returned 5xx, + * read the way production reads it, through a Tail Worker attached to the + * function. Every case runs in real workerd, and the request header is the only + * switch, so each one is checked with and without `x-b44-feed-context`. */ import { Miniflare } from "miniflare"; @@ -107,16 +108,15 @@ async function invoke( } } -async function compile(source: string, errorRecords = true): Promise { - const r = await bundle({ entry: "main.ts", files: { "main.ts": source }, errorRecords }); +async function compile(source: string): Promise { + const r = await bundle({ entry: "main.ts", files: { "main.ts": source } }); if (!r.ok) throw new Error(`bundle failed: ${JSON.stringify(r.errors)}`); return r.module; } -async function compileApp(source: string, errorRecords = true): Promise { +async function compileApp(source: string): Promise { const r = await bundleApp({ functions: [{ name: "alpha", entry: "main.ts", files: { "main.ts": source } }], - errorRecords, }); if (!r.ok) throw new Error(`bundleApp failed: ${JSON.stringify(r.functions)}`); return r.module; @@ -446,8 +446,9 @@ describe("the response", () => { `; it("is the same response, with or without the record", async () => { - const off = await invoke(await compile(source, false), { headers: FEED }); - const on = await invoke(await compile(source), { headers: FEED }); + const module = await compile(source); + const off = await invoke(module); + const on = await invoke(module, { headers: FEED }); expect(on.records).toHaveLength(1); expect(off.records).toEqual([]); expect({ status: on.status, headers: on.headers, text: on.text }).toEqual({ @@ -460,8 +461,8 @@ describe("the response", () => { it("is not held back while the record is written", async () => { // The response must reach the client while the handler's stream is still // open: nothing waits on the recorder's read of the clone. (Miniflare's - // dispatchFetch hands over this body only once it closes, flag on or off, - // so the headers are what can be timed here.) + // dispatchFetch hands over this body only once it closes, with or without + // the recorder, so the headers are what can be timed here.) const module = await compile(` Deno.serve(() => { const enc = new TextEncoder(); @@ -502,8 +503,9 @@ describe("an uncaught throw", () => { ["single function", compile, {}], ["per-app", compileApp, { "Base44-Function-Name": "alpha" }], ] as const)("still rethrows exactly as today (%s)", async (_label, build, headers) => { - const off = await invoke(await build(source, false), { headers: { ...headers, ...FEED } }); - const on = await invoke(await build(source, true), { headers: { ...headers, ...FEED } }); + const module = await build(source); + const off = await invoke(module, { headers }); + const on = await invoke(module, { headers: { ...headers, ...FEED } }); // Cloudflare records the exception; the record is only for returned 5xx. expect(on.events.flatMap((e) => e.exceptions).map((x) => x.message)).toEqual(["kaboom"]); expect(off.events.flatMap((e) => e.exceptions).map((x) => x.message)).toEqual(["kaboom"]); diff --git a/packages/functions-compiler/test/worker-entry.test.ts b/packages/functions-compiler/test/worker-entry.test.ts index ec5a00d92..f715e90d0 100644 --- a/packages/functions-compiler/test/worker-entry.test.ts +++ b/packages/functions-compiler/test/worker-entry.test.ts @@ -9,7 +9,8 @@ const serve = (body: string) => describe("binding-mode entries", () => { // Every app without the pull flag runs these bytes, so any change to them has - // to show up in review as a snapshot diff. + // to show up in review as a snapshot diff. They include the error recorder, + // which every build carries. it.each([false, true])("are pinned (telemetry=%s)", async (telemetry) => { const files = { "main.ts": serve("a") }; const single = await prepareFunction("main.ts", files, telemetry, false); @@ -19,54 +20,47 @@ describe("binding-mode entries", () => { }); }); -describe("error-records entries", () => { +describe("the error recorder", () => { + // Part of every build, with no option: the request header is the switch. const files = { "main.ts": serve("a") }; - const app = (errorRecords: boolean, telemetry = false) => - prepareApp([{ index: 0, fn: { name: "alpha", entry: "main.ts", files } }], telemetry, false, errorRecords); + const app = (telemetry = false, runtimeSecrets = false) => + prepareApp([{ index: 0, fn: { name: "alpha", entry: "main.ts", files } }], telemetry, runtimeSecrets); it.each([ [false, false], [false, true], [true, false], [true, true], - ])("leave the entry and banner unchanged when off (telemetry=%s, runtimeSecrets=%s)", async (telemetry, runtimeSecrets) => { - const before = await prepareFunction("main.ts", files, telemetry, runtimeSecrets); - const off = await prepareFunction("main.ts", files, telemetry, runtimeSecrets, false); - expect(off.files).toEqual(before.files); - const appBefore = prepareApp([{ index: 0, fn: { name: "alpha", entry: "main.ts", files } }], telemetry, runtimeSecrets); - const appOff = prepareApp([{ index: 0, fn: { name: "alpha", entry: "main.ts", files } }], telemetry, runtimeSecrets, false); - expect(appOff).toEqual(appBefore); - expect(off.files[off.entry]).not.toContain("_b44RecordError"); - expect(appOff.banner).not.toContain("errorRecords"); + ])("is in both entries (telemetry=%s, runtimeSecrets=%s)", async (telemetry, runtimeSecrets) => { + const single = (await prepareFunction("main.ts", files, telemetry, runtimeSecrets)).files["__base44_entry.mjs"]; + for (const entry of [single, app(telemetry, runtimeSecrets).files["__base44_entry.mjs"]]) { + expect(entry).toContain("const _b44RecordError = (res) =>"); + expect(entry).toContain("..._b44ErrorStore(request)"); + expect(entry).toContain("_b44RecordError(await handler(request, info))"); + } }); - it("are pinned when on", async () => { - const single = await prepareFunction("main.ts", files, false, false, true); - expect(single.files[single.entry]).toMatchSnapshot("single function"); - expect(app(true).files["__base44_entry.mjs"]).toMatchSnapshot("per-app"); - }); - - it("record the handler's own response, inside telemetry and invocation logs", async () => { - const single = (await prepareFunction("main.ts", files, true, false, true)).files["__base44_entry.mjs"]; + it("records the handler's own response, inside telemetry and invocation logs", async () => { + const single = (await prepareFunction("main.ts", files, true)).files["__base44_entry.mjs"]; expect(single).toContain("_b44AttachTelemetry(_b44RecordError(await handler(request, info)))"); - expect(single).toContain("..._b44ErrorStore(request)"); - expect(app(true, true).files["__base44_entry.mjs"]).toContain( + expect(app(true).files["__base44_entry.mjs"]).toContain( "_b44AttachInvocationLogs(_b44AttachTelemetry(_b44RecordError(await handler(request, info))))", ); }); - it("leave the crash path exactly as it is", () => { + it("leaves the crash path exactly as it is", () => { // The uncaught throw is Cloudflare's to record; the record is only for a - // returned 5xx. - const on = app(true).files["__base44_entry.mjs"]; - const off = app(false).files["__base44_entry.mjs"]; - const crash = (entry: string) => entry.slice(entry.indexOf("} catch (e) {\n console.error(e);")); - expect(crash(on)).toBe(crash(off)); - expect(crash(on)).toContain("throw e;"); + // returned 5xx. The per-app entry still logs it and rethrows. + const entry = app().files["__base44_entry.mjs"]; + expect(entry).toContain( + " } catch (e) {\n console.error(e);\n const crash = _b44CrashResponse(e);\n if (crash) return crash;\n throw e;\n }", + ); }); - it("say so in the per-app banner only when on", () => { - expect(JSON.parse(app(true).banner!.replace(/^\/\/!b44:1 /, "")).errorRecords).toBe(true); + it("adds nothing to the banner", () => { + // The banner's `compiler` version is what tells a build that carries it. + const payload = JSON.parse(app().banner!.replace(/^\/\/!b44:1 /, "")); + expect(Object.keys(payload)).toEqual(["functions", "telemetry", "runtimeSecrets", "compiler"]); }); }); @@ -188,7 +182,7 @@ describe("prepareApp", () => { expect(entry).toContain("..._b44CaptureStore(request)"); expect(entry).toContain("_b44Capture(_c, lvl, _m)"); - expect(entry).toContain("_b44AttachInvocationLogs(await handler(request, info))"); + expect(entry).toContain("_b44AttachInvocationLogs(_b44RecordError(await handler(request, info)))"); // The 404 / init-500 / 503 early returns and the crash path answer through // the same attach: an import error is the test tool's most common failure. expect(entry.match(/_b44AttachInvocationLogs\(/g)).toHaveLength(5); @@ -202,7 +196,7 @@ describe("prepareApp", () => { ); expect(result.files["__base44_entry.mjs"]).toContain( - "_b44AttachInvocationLogs(_b44AttachTelemetry(await handler(request, info)))", + "_b44AttachInvocationLogs(_b44AttachTelemetry(_b44RecordError(await handler(request, info))))", ); }); From 420abf3993591016865663b226b396e25f559d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Egidijus=20Jucevi=C4=8Dius?= Date: Fri, 23 Oct 2026 15:28:18 +0300 Subject: [PATCH 6/6] test(functions-compiler): pin the raw Tail shape of a `_b44_error` record The record is logged through the console patch, not raw: a Tail Worker gets one entry with level "log" whose single argument is the patch's wrapper `{ _b44_env, _b44_function?, level: "error", message }`, and `message` is the JSON string of `{ "_b44_error": {...} }`. apper's error tail is the consumer. Pin that entry for both entries, so a change to the console patch fails here instead of silently in production, and state the shape in the README and the module doc. No change to what is emitted. Co-Authored-By: Claude Opus 5.5 --- packages/functions-compiler/README.md | 23 ++++++++++-- .../functions-compiler/src/error-records.ts | 18 +++++++--- .../test/error-records.e2e.test.ts | 35 +++++++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 3611d80e6..6f55cb3ec 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -202,9 +202,26 @@ header set. On a clone, inside `ctx.waitUntil`, the recorder: private-data-sources manifest; - caps `name` at 64 characters, `message` at 500 UTF-8 bytes (well-formed, cut on a code point) and `stack` at 4 KB (cut at a line end); -- logs exactly one line through the console patch, so it carries - `_b44_function`: - `{ _b44_error: { v: 1, kind: "response", status, name?, message, stack? } }`. +- logs exactly one line, with `console.error({ _b44_error: record })`. + +That call goes through the console patch, so **the object is not what a +consumer sees.** A Tail Worker gets one log entry shaped like this: + +| Field | Value | +|---|---| +| the TailItem log's `level` | `"log"`. The patch writes every level through the original `console.log` | +| the log's `message` | an array with **one** argument: the patch's wrapper object | +| the wrapper's keys | `_b44_env`, `_b44_function` (per-app bundles only), `level`, `message` | +| the wrapper's `level` | `"error"` | +| the wrapper's `message` | the **JSON string** of `{ "_b44_error": { v: 1, kind: "response", status, name?, message, stack? } }` | + +So a consumer keeps logs whose wrapper `level` is `"error"`, then reads +`JSON.parse(wrapper.message)._b44_error`. apper's error tail +(`workers/base44-log-tail/src/error-tail.ts`) is that consumer, and +`error-records.e2e.test.ts` pins this shape for both entries. The record is +logged inside `waitUntil`, after the response has gone back, so it never lands +in the `X-B44-Invocation-Logs` header. It is still an ordinary stamped line, so +views built on stamped Workers log lines show it as an error line. Any failure writes nothing and never reaches the request. An uncaught throw is untouched: the entry still logs it and rethrows, and Cloudflare records it. diff --git a/packages/functions-compiler/src/error-records.ts b/packages/functions-compiler/src/error-records.ts index dc60a4541..1985b91e7 100644 --- a/packages/functions-compiler/src/error-records.ts +++ b/packages/functions-compiler/src/error-records.ts @@ -30,10 +30,20 @@ * `source_map`. Every * env-binding and runtime-secret string of 8+ characters (and the strings * nested in JSON-valued ones, such as the private-data-sources manifest) is - * replaced with `[redacted]`, longest first, before anything is cut. Then it logs - * one `{ _b44_error: { v: 1, kind: "response", status, name?, message, stack? } }` - * through the console patch, so the line carries function attribution. Any - * failure writes nothing and never reaches the request. + * replaced with `[redacted]`, longest first, before anything is cut. Then it + * calls `console.error({ _b44_error: record })` once. Any failure writes + * nothing and never reaches the request. + * + * What a consumer sees is NOT that object: the call goes through the console + * patch. A Tail Worker gets one log entry whose `level` is "log" (the patch + * writes every level through the original console.log) and whose `message` is + * one argument, the patch's wrapper: + * { _b44_env, _b44_function?, level: "error", message: "" } + * `_b44_function` is present only in per-app bundles, and `message` is the JSON + * string of `{ "_b44_error": { v: 1, kind: "response", status, name?, message, + * stack? } }`. apper's error tail (workers/base44-log-tail/src/error-tail.ts) + * parses exactly this; test/error-records.e2e.test.ts pins it. Change the + * console patch and that test must fail. * * Must be self-contained JS (no imports) — inlined into the generated entry * after CONSOLE_PATCH. The entry provides `_b44Context` and the patched console. diff --git a/packages/functions-compiler/test/error-records.e2e.test.ts b/packages/functions-compiler/test/error-records.e2e.test.ts index d9c2b42f7..6a0e25b68 100644 --- a/packages/functions-compiler/test/error-records.e2e.test.ts +++ b/packages/functions-compiler/test/error-records.e2e.test.ts @@ -132,6 +132,41 @@ async function recordFor(module: string, env?: Record): Promise< return (await invoke(module, { headers: FEED, env })).records; } +describe("the raw Tail log entry", () => { + // The contract with apper's error tail (workers/base44-log-tail/src/ + // error-tail.ts), which parses exactly this shape in production. The record + // goes through the console patch, so it is NOT logged as a raw object: a + // change to the patch must fail here, not silently drop every record. + const source = 'Deno.serve(() => Response.json({ error: "db down" }, { status: 500 }));'; + const record = { v: 1, kind: "response", status: 500, message: "db down" }; + + it.each([ + ["single function", compile, {}, ["_b44_env", "level", "message"], undefined], + [ + "per-app", + compileApp, + { "Base44-Function-Name": "alpha" }, + ["_b44_env", "_b44_function", "level", "message"], + "alpha", + ], + ] as const)("is one 'log' entry wrapping the record as JSON (%s)", async (_label, build, headers, keys, fn) => { + const run = await invoke(await build(source), { headers: { ...headers, ...FEED } }); + const entries = run.events.flatMap((e) => e.logs).filter((l) => JSON.stringify(l).includes("_b44_error")); + expect(entries).toHaveLength(1); + const [entry] = entries; + // The patch writes every level through the original console.log. + expect(entry.level).toBe("log"); + expect(entry.message).toHaveLength(1); + const wrapper = entry.message[0] as Record; + expect(Object.keys(wrapper)).toEqual(keys); + expect(wrapper.level).toBe("error"); + expect(typeof wrapper.message).toBe("string"); + expect(JSON.parse(wrapper.message as string)._b44_error).toEqual(record); + // Present only in per-app bundles, where one script serves every function. + expect(wrapper._b44_function).toBe(fn); + }); +}); + describe("a 5xx JSON body", () => { it.each([ ["error", { error: "boom-error" }, "boom-error"],