diff --git a/packages/functions-compiler/README.md b/packages/functions-compiler/README.md index 0040cb87..6f55cb3e 100644 --- a/packages/functions-compiler/README.md +++ b/packages/functions-compiler/README.md @@ -65,13 +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. `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 @@ -89,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; -- either wrapper flag. +- 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: @@ -159,6 +165,67 @@ 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. **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: + +- picks the message, in this order: an Error logged during the request whose + 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**: + 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 + `[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 + on a code point) and `stack` at 4 KB (cut at a line end); +- 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. + ### Process isolation `installFetchGuard()` blocks outbound `fetch` other than the dependency diff --git a/packages/functions-compiler/src/error-records.ts b/packages/functions-compiler/src/error-records.ts new file mode 100644 index 00000000..1985b91e --- /dev/null +++ b/packages/functions-compiler/src/error-records.ts @@ -0,0 +1,207 @@ +/** + * 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. 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. + * + * 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 + * event) picks a message: + * + * 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 (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 + * 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 `[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. + */ + +/** 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, + 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: [] } } : {};`, + // 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 (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 > ${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); }; }", + // 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);`, + // 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);", + " }", + " };", + " 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;", + "};", + // `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 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, ${L.nameChars}).join('') : '';`, + " if (name) rec.name = name;", + ` rec.message = _b44CutBytes(clean(message), ${L.messageBytes});`, + ` const stack = chosen && chosen.stack ? _b44CutLines(clean(chosen.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.slice()));", + " } 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 e8cfbacb..86dc0fcd 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 { @@ -241,6 +242,11 @@ function activationGuard(runtimeSecrets: boolean): (expr: string) => string { runtimeSecrets ? `withoutActivationHeaders(${expr}, _b44Activation)` : expr; } +// 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 ? `\nimport { ensureActivation, takeActivationChallenge, withoutActivationHeaders } from "./${ACTIVATION_FILENAME}";` @@ -260,10 +266,8 @@ function buildEntrySource( // after side effects would read as a non-billable failed activation) and the // installing request's marker added. const handlerExpr = telemetry - ? "_b44AttachTelemetry(await handler(request, info))" - : runtimeSecrets - ? "await handler(request, info)" - : "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. @@ -272,6 +276,7 @@ import { currentWorkerRuntimeContext as _b44Context, runWithWorkerEnvironment as import { getRegisteredHandler, installStaticEgressFetch } from "./${SHIM_FILENAME}";${activationImport(runtimeSecrets)} ${consolePatch(runtimeSecrets)} +${ERROR_RECORDS_PATCH} // Static egress reads workerEnv from the active request store. Install it // before telemetry so telemetry remains the outermost fetch wrapper. installStaticEgressFetch(); @@ -281,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)${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); @@ -327,9 +332,7 @@ function buildAppEntrySource( .map((file) => `import "./${file}";`) .join("\n"); const handlerExpr = `_b44AttachInvocationLogs(${ - telemetry - ? "_b44AttachTelemetry(await handler(request, info))" - : "await handler(request, info)" + telemetry ? `_b44AttachTelemetry(${HANDLER_CALL})` : HANDLER_CALL })`; const guard = activationGuard(runtimeSecrets); const returnExpr = guard(handlerExpr); @@ -341,6 +344,7 @@ ${moduleImports} ${consolePatch(runtimeSecrets)} ${INVOCATION_LOGS_PATCH} +${ERROR_RECORDS_PATCH} // Static egress reads workerEnv from the active request store. Install it // before telemetry so telemetry remains the outermost fetch wrapper. installStaticEgressFetch(); @@ -350,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)${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 a238a5f4..c3e15f41 100644 --- a/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap +++ b/packages/functions-compiler/test/__snapshots__/worker-entry.test.ts.snap @@ -62,6 +62,134 @@ 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: [] } } : {}; +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(); + } +}; +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(); @@ -71,7 +199,7 @@ 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 () => { + 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. @@ -110,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(await handler(request, info)); + return _b44AttachInvocationLogs(_b44RecordError(await handler(request, info))); } catch (e) { console.error(e); const crash = _b44CrashResponse(e); @@ -162,6 +290,134 @@ 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: [] } } : {}; +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(); + } +}; +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(); @@ -171,7 +427,7 @@ 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 () => { + 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); @@ -189,7 +445,7 @@ export default { const info = { remoteAddr: { transport: "tcp", hostname: "0.0.0.0", port: 0 }, }; - return handler(request, info); + return _b44RecordError(await handler(request, info)); }); }, }; @@ -258,6 +514,134 @@ 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: [] } } : {}; +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(); + } +}; +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(); @@ -298,7 +682,7 @@ 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), 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. @@ -337,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(_b44AttachTelemetry(await handler(request, info))); + return _b44AttachInvocationLogs(_b44AttachTelemetry(_b44RecordError(await handler(request, info)))); } catch (e) { console.error(e); const crash = _b44CrashResponse(e); @@ -389,6 +773,134 @@ 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: [] } } : {}; +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(); + } +}; +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(); @@ -429,7 +941,7 @@ 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), 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); @@ -447,7 +959,7 @@ export default { const info = { remoteAddr: { transport: "tcp", hostname: "0.0.0.0", port: 0 }, }; - return _b44AttachTelemetry(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 new file mode 100644 index 00000000..6a0e25b6 --- /dev/null +++ b/packages/functions-compiler/test/error-records.e2e.test.ts @@ -0,0 +1,550 @@ +/** + * 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"; +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): 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): Promise { + const r = await bundleApp({ + functions: [{ name: "alpha", entry: "main.ts", files: { "main.ts": source } }], + }); + 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("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"], + ["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("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(` + 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 [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); + }); + + 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 [redacted]"]); + }); + + 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 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({ + 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, with or without + // the recorder, 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 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"]); + 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 e21a5142..f715e90d 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,6 +20,50 @@ describe("binding-mode 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 = (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], + ])("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("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(app(true).files["__base44_entry.mjs"]).toContain( + "_b44AttachInvocationLogs(_b44AttachTelemetry(_b44RecordError(await handler(request, info))))", + ); + }); + + 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. 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("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"]); + }); +}); + 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") }); @@ -137,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); @@ -151,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))))", ); });