@alwaysmeticulous/backend-recorder-workerd 2.331.3 → 2.333.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/dist/binding-patch.js +1 -4
- package/dist/binding-patch.js.map +1 -1
- package/dist/body-capture.js +1 -4
- package/dist/body-capture.js.map +1 -1
- package/dist/capture-buffer.js +1 -4
- package/dist/capture-buffer.js.map +1 -1
- package/dist/context.js +1 -4
- package/dist/context.js.map +1 -1
- package/dist/coverage/replay-coverage.js +1 -4
- package/dist/coverage/replay-coverage.js.map +1 -1
- package/dist/coverage/runtime.js +1 -4
- package/dist/coverage/runtime.js.map +1 -1
- package/dist/error-capture.js +1 -4
- package/dist/error-capture.js.map +1 -1
- package/dist/fetch-patch.js +1 -4
- package/dist/fetch-patch.js.map +1 -1
- package/dist/index.js +1 -4
- package/dist/index.js.map +1 -1
- package/dist/kv-capture.js +1 -4
- package/dist/kv-capture.js.map +1 -1
- package/dist/kv-patch.js +1 -4
- package/dist/kv-patch.js.map +1 -1
- package/dist/log.js +1 -4
- package/dist/log.js.map +1 -1
- package/dist/original-fetch.js +1 -4
- package/dist/original-fetch.js.map +1 -1
- package/dist/outbound-capture.js +1 -4
- package/dist/outbound-capture.js.map +1 -1
- package/dist/pages-function.js +1 -4
- package/dist/pages-function.js.map +1 -1
- package/dist/postgres/capture.js +1 -4
- package/dist/postgres/capture.js.map +1 -1
- package/dist/postgres/with-meticulous-postgres.js +1 -4
- package/dist/postgres/with-meticulous-postgres.js.map +1 -1
- package/dist/protocol.js +1 -4
- package/dist/protocol.js.map +1 -1
- package/dist/provisional-session-id.js +1 -4
- package/dist/provisional-session-id.js.map +1 -1
- package/dist/publish-session-id.js +1 -4
- package/dist/publish-session-id.js.map +1 -1
- package/dist/publish-shim-version.js +1 -4
- package/dist/publish-shim-version.js.map +1 -1
- package/dist/redact-body.js +1 -4
- package/dist/redact-body.js.map +1 -1
- package/dist/replay-fetch.js +1 -4
- package/dist/replay-fetch.js.map +1 -1
- package/dist/replay-log-tagging.js +1 -4
- package/dist/replay-log-tagging.js.map +1 -1
- package/dist/replay-sidecar-url.js +1 -4
- package/dist/replay-sidecar-url.js.map +1 -1
- package/dist/sidecar-client.js +1 -4
- package/dist/sidecar-client.js.map +1 -1
- package/dist/sidecar-transport.js +1 -4
- package/dist/sidecar-transport.js.map +1 -1
- package/dist/spans/attributes.js +1 -4
- package/dist/spans/attributes.js.map +1 -1
- package/dist/spans/readable-span.js +1 -4
- package/dist/spans/readable-span.js.map +1 -1
- package/dist/spans/serialize-span.js +1 -4
- package/dist/spans/serialize-span.js.map +1 -1
- package/dist/spans/span-builder.js +1 -4
- package/dist/spans/span-builder.js.map +1 -1
- package/dist/spans/url-redaction.js +1 -4
- package/dist/spans/url-redaction.js.map +1 -1
- package/dist/version.js +2 -5
- package/dist/version.js.map +1 -1
- package/dist/virtual-clock.js +1 -4
- package/dist/virtual-clock.js.map +1 -1
- package/dist/virtual-random.js +1 -4
- package/dist/virtual-random.js.map +1 -1
- package/dist/vite/index.js +15 -16
- package/dist/vite/index.js.map +1 -1
- package/dist/with-meticulous.js +0 -0
- package/dist/with-meticulous.js.map +1 -1
- package/package.json +2 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022, Meticulous Contributors
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
11
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/dist/binding-patch.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="30b53a90-e363-545b-ba99-464e83c2b094")}catch(e){}}();
|
|
3
1
|
import { SIDECAR_BINDING_ENV_KEY } from "./sidecar-transport.js";
|
|
4
2
|
import { requestCaptureContext, sidecarOriginOf } from "./context.js";
|
|
5
3
|
import { warnOnce } from "./log.js";
|
|
@@ -144,5 +142,4 @@ const toRequest = (args) => {
|
|
|
144
142
|
//#endregion
|
|
145
143
|
export { installBindingPatch };
|
|
146
144
|
|
|
147
|
-
//# sourceMappingURL=binding-patch.js.map
|
|
148
|
-
//# debugId=30b53a90-e363-545b-ba99-464e83c2b094
|
|
145
|
+
//# sourceMappingURL=binding-patch.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"binding-patch.js","sources":["../src/binding-patch.ts"],"sourcesContent":["import { requestCaptureContext, sidecarOriginOf } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { captureOutboundCall } from \"./outbound-capture\";\nimport { SIDECAR_BINDING_ENV_KEY } from \"./sidecar-transport\";\n\n/**\n * Records calls made through Cloudflare bindings (`env.MY_SERVICE.fetch(...)`).\n *\n * The `globalThis.fetch` patch cannot see these: a binding's `fetch` is a method on a host\n * object, and workerd routes it as an internal subrequest that never touches the global. So\n * the binding's *prototype* method is patched instead — the same move the Node recorder makes\n * on `Redis.prototype.sendCommand`.\n *\n * The prototype is reached from an instance found on `env`, never from a global name: in\n * workerd there is no `globalThis.Fetcher` (the runtime type is a bare TypeScript alias, not\n * one of the exposed runtime classes). Reaching it through an instance is also what makes\n * this work at all for an app that reads its bindings from `cloudflare:workers` instead of\n * the handler argument — that module hands out a *different* `env` object but the *same*\n * binding instances, so the prototype we patch is shared either way.\n *\n * Verified against real workerd in\n * `packages/backend-recorder-js/src/__tests__/workerd-binding-patch.spec.ts`, which also\n * pins the properties this relies on: `fetch` is inherited and configurable, the receiver\n * survives, one patch covers every service binding plus Durable Object stubs, and a binding\n * call is not also counted as a `globalThis.fetch` call.\n */\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst BINDING_FETCH_PATCHED = Symbol.for(\"meticulous.workerd.bindingPatched\");\n\n/**\n * Bindings never recorded, by name.\n *\n * An assets binding is shape-identical to a service binding — same prototype, same `fetch` —\n * so it cannot be told apart by duck-typing and has to be skipped by name. Recording it would\n * push every static asset through the body capture cap for no benefit: asset bytes are large\n * and often binary, and asset serving is typically the highest-volume call a worker makes.\n *\n * The sidecar binding is here as belt-and-braces only: the wrapper also passes its resolved\n * instance as a `skipInstances` entry, which holds however the customer named it.\n */\nconst DEFAULT_SKIPPED_BINDINGS = [\n \"ASSETS\",\n \"__STATIC_CONTENT\",\n SIDECAR_BINDING_ENV_KEY,\n] as const;\n\n/** Identifies a binding instance so its calls can be attributed to an `env` key. */\nconst bindingNames = new WeakMap<object, string>();\nconst skippedBindings = new WeakSet<object>();\n\ninterface FetcherLike {\n fetch: (...args: unknown[]) => Promise<Response>;\n}\n\ntype BindingFetch = (...args: unknown[]) => Promise<Response>;\n\nexport interface InstallBindingPatchOptions {\n /** Extra binding names to leave unrecorded, on top of the defaults. */\n skipBindings?: readonly string[];\n /**\n * Binding instances to leave unrecorded, whatever they are called on `env`. This is how the\n * sidecar's own service binding is excluded: recording the shim's reports would be an infinite\n * regress, and a name-based skip would miss a binding the customer named something else or\n * passed in through `options.sidecarBinding`.\n */\n skipInstances?: readonly object[];\n}\n\n/**\n * Discovers Fetcher-shaped bindings on `env` and patches their shared prototype so their\n * `fetch` calls are recorded. Safe to call on every request — discovery is cheap and the\n * patch itself is applied once per prototype per isolate.\n *\n * Never throws: a runtime that does not permit the patch simply goes unrecorded.\n */\nexport const installBindingPatch = (\n env: unknown,\n options?: InstallBindingPatchOptions,\n): void => {\n try {\n for (const { name, value, skip } of fetcherBindings(\n env,\n options?.skipBindings,\n options?.skipInstances,\n )) {\n // Patch even a skipped binding's prototype: it is shared with every other\n // Fetcher-shaped binding (and is in a Durable Object stub's prototype chain), so\n // seeding it is what gives those coverage. Skipping is enforced per instance at call\n // time, not by declining to patch.\n patchBindingPrototype(value);\n if (skip) {\n skippedBindings.add(value);\n continue;\n }\n bindingNames.set(value, name);\n }\n } catch (error) {\n warnOnce(\n \"binding-patch-install\",\n \"Failed to install the Meticulous binding patch — binding calls will not be recorded.\",\n error,\n );\n }\n};\n\ninterface DiscoveredBinding {\n name: string;\n value: object;\n skip: boolean;\n}\n\nconst fetcherBindings = (\n env: unknown,\n extraSkipped: readonly string[] | undefined,\n skipInstances: readonly object[] | undefined,\n): DiscoveredBinding[] => {\n if (env === null || typeof env !== \"object\") {\n return [];\n }\n const skipped = new Set<string>([\n ...DEFAULT_SKIPPED_BINDINGS,\n ...(extraSkipped ?? []),\n ]);\n const skippedInstances = new Set<object>(skipInstances ?? []);\n const found: DiscoveredBinding[] = [];\n for (const [name, value] of Object.entries(env)) {\n if (isFetcherLike(value)) {\n found.push({\n name,\n value,\n skip: skipped.has(name) || skippedInstances.has(value),\n });\n }\n }\n return found;\n};\n\n/**\n * A binding we can record: something with a `fetch` method. Durable Object *namespaces* are\n * excluded — they have no `fetch`; it is the stub from `namespace.get()` that does, and that\n * stub inherits the same prototype, so it is covered without being discovered here.\n */\nconst isFetcherLike = (value: unknown): value is object & FetcherLike =>\n value !== null &&\n typeof value === \"object\" &&\n typeof (value as Partial<FetcherLike>).fetch === \"function\";\n\nconst patchBindingPrototype = (binding: object): void => {\n const prototype = Object.getPrototypeOf(binding) as\n | (object & Record<symbol, unknown>)\n | null;\n if (prototype === null || prototype[BINDING_FETCH_PATCHED] === true) {\n return;\n }\n const descriptor = Object.getOwnPropertyDescriptor(prototype, \"fetch\");\n if (\n descriptor === undefined ||\n typeof descriptor.value !== \"function\" ||\n descriptor.configurable !== true\n ) {\n // Either `fetch` is an own per-instance property or the runtime has locked the\n // prototype. Recording binding calls is not possible; everything else keeps working.\n warnOnce(\n \"binding-patch-unsupported\",\n \"This runtime does not expose a patchable binding `fetch` — binding calls will not be recorded.\",\n );\n return;\n }\n\n const original = descriptor.value as BindingFetch;\n Object.defineProperty(prototype, \"fetch\", {\n value: patchedBindingFetch(original),\n writable: true,\n enumerable: false,\n configurable: true,\n });\n Object.defineProperty(prototype, BINDING_FETCH_PATCHED, {\n value: true,\n enumerable: false,\n configurable: true,\n });\n};\n\n/**\n * A `function`, not an arrow: `this` is the binding the call was made on, and it must be\n * forwarded to the original or workerd throws \"Illegal invocation\".\n */\nconst patchedBindingFetch = (original: BindingFetch): BindingFetch =>\n function (this: unknown, ...args: unknown[]): Promise<Response> {\n const passThrough = (): Promise<Response> => original.apply(this, args);\n\n const ctx = requestCaptureContext.getStore();\n // Record mode only. `installBindingPatch` is not called for a replay request, but the\n // patch is per-isolate: a record request could have installed it earlier, and a replay\n // request must not be teed into a sidecar that has no exporter behind it.\n if (!ctx || ctx.mode !== \"record\") {\n return passThrough();\n }\n const receiver =\n typeof this === \"object\" && this !== null ? this : undefined;\n if (receiver !== undefined && skippedBindings.has(receiver)) {\n return passThrough();\n }\n\n const request = toRequest(args);\n if (request === undefined) {\n return passThrough();\n }\n\n // Defence in depth: a binding pointed at the sidecar must not be recorded. Forwards\n // `request`, not `args` — normalizing may already have consumed the original body.\n if (request.url.startsWith(`${sidecarOriginOf(ctx)}/`)) {\n return original.apply(this, [request]);\n }\n\n const bindingName =\n receiver !== undefined ? bindingNames.get(receiver) : undefined;\n return captureOutboundCall(\n ctx,\n { kind: \"binding\", bindingName },\n request,\n (req) => original.apply(this, [req]),\n );\n };\n\n/**\n * Normalizes the call's arguments to a single Request, or undefined if they cannot be\n * represented (in which case the call passes through untouched and the runtime produces its\n * own error).\n *\n * A lone `Request` is passed along as-is rather than rebuilt: `new Request(existing)` drops\n * workerd's `cf` property, which the receiving worker may read.\n */\nconst toRequest = (args: unknown[]): Request | undefined => {\n try {\n const [input, init] = args;\n if (input instanceof Request && init === undefined) {\n return input;\n }\n return new Request(input as RequestInfo, init as RequestInit | undefined);\n } catch {\n return undefined;\n }\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,MAAM,wBAAwB,OAAO,IAAI,oCAAoC;;;;;;;;;;;;AAa7E,MAAM,2BAA2B;CAC/B;CACA;CACA;CACD;;AAGD,MAAM,+BAAe,IAAI,SAAyB;AAClD,MAAM,kCAAkB,IAAI,SAAiB;;;;;;;;AA2B7C,MAAa,uBACX,KACA,YACS;AACT,KAAI;AACF,OAAK,MAAM,EAAE,MAAM,OAAO,UAAU,gBAClC,KACA,SAAS,cACT,SAAS,cACV,EAAE;AAKD,yBAAsB,MAAM;AAC5B,OAAI,MAAM;AACR,oBAAgB,IAAI,MAAM;AAC1B;;AAEF,gBAAa,IAAI,OAAO,KAAK;;UAExB,OAAO;AACd,WACE,yBACA,wFACA,MACD;;;AAUL,MAAM,mBACJ,KACA,cACA,kBACwB;AACxB,KAAI,QAAQ,QAAQ,OAAO,QAAQ,SACjC,QAAO,EAAE;CAEX,MAAM,UAAU,IAAI,IAAY,CAC9B,GAAG,0BACH,GAAI,gBAAgB,EAAE,CACvB,CAAC;CACF,MAAM,mBAAmB,IAAI,IAAY,iBAAiB,EAAE,CAAC;CAC7D,MAAM,QAA6B,EAAE;AACrC,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,IAAI,CAC7C,KAAI,cAAc,MAAM,CACtB,OAAM,KAAK;EACT;EACA;EACA,MAAM,QAAQ,IAAI,KAAK,IAAI,iBAAiB,IAAI,MAAM;EACvD,CAAC;AAGN,QAAO;;;;;;;AAQT,MAAM,iBAAiB,UACrB,UAAU,QACV,OAAO,UAAU,YACjB,OAAQ,MAA+B,UAAU;AAEnD,MAAM,yBAAyB,YAA0B;CACvD,MAAM,YAAY,OAAO,eAAe,QAAQ;AAGhD,KAAI,cAAc,QAAQ,UAAU,2BAA2B,KAC7D;CAEF,MAAM,aAAa,OAAO,yBAAyB,WAAW,QAAQ;AACtE,KACE,eAAe,KAAA,KACf,OAAO,WAAW,UAAU,cAC5B,WAAW,iBAAiB,MAC5B;AAGA,WACE,6BACA,iGACD;AACD;;CAGF,MAAM,WAAW,WAAW;AAC5B,QAAO,eAAe,WAAW,SAAS;EACxC,OAAO,oBAAoB,SAAS;EACpC,UAAU;EACV,YAAY;EACZ,cAAc;EACf,CAAC;AACF,QAAO,eAAe,WAAW,uBAAuB;EACtD,OAAO;EACP,YAAY;EACZ,cAAc;EACf,CAAC;;;;;;AAOJ,MAAM,uBAAuB,aAC3B,SAAyB,GAAG,MAAoC;CAC9D,MAAM,oBAAuC,SAAS,MAAM,MAAM,KAAK;CAEvE,MAAM,MAAM,sBAAsB,UAAU;AAI5C,KAAI,CAAC,OAAO,IAAI,SAAS,SACvB,QAAO,aAAa;CAEtB,MAAM,WACJ,OAAO,SAAS,YAAY,SAAS,OAAO,OAAO,KAAA;AACrD,KAAI,aAAa,KAAA,KAAa,gBAAgB,IAAI,SAAS,CACzD,QAAO,aAAa;CAGtB,MAAM,UAAU,UAAU,KAAK;AAC/B,KAAI,YAAY,KAAA,EACd,QAAO,aAAa;AAKtB,KAAI,QAAQ,IAAI,WAAW,GAAG,gBAAgB,IAAI,CAAC,GAAG,CACpD,QAAO,SAAS,MAAM,MAAM,CAAC,QAAQ,CAAC;AAKxC,QAAO,oBACL,KACA;EAAE,MAAM;EAAW,aAHnB,aAAa,KAAA,IAAY,aAAa,IAAI,SAAS,GAAG,KAAA;EAGtB,EAChC,UACC,QAAQ,SAAS,MAAM,MAAM,CAAC,IAAI,CAAC,CACrC;;;;;;;;;;AAWL,MAAM,aAAa,SAAyC;AAC1D,KAAI;EACF,MAAM,CAAC,OAAO,QAAQ;AACtB,MAAI,iBAAiB,WAAW,SAAS,KAAA,EACvC,QAAO;AAET,SAAO,IAAI,QAAQ,OAAsB,KAAgC;SACnE;AACN","debugId":"30b53a90-e363-545b-ba99-464e83c2b094"}
|
|
1
|
+
{"version":3,"file":"binding-patch.js","names":[],"sources":["../src/binding-patch.ts"],"sourcesContent":["import { requestCaptureContext, sidecarOriginOf } from \"./context\";\nimport { warnOnce } from \"./log\";\nimport { captureOutboundCall } from \"./outbound-capture\";\nimport { SIDECAR_BINDING_ENV_KEY } from \"./sidecar-transport\";\n\n/**\n * Records calls made through Cloudflare bindings (`env.MY_SERVICE.fetch(...)`).\n *\n * The `globalThis.fetch` patch cannot see these: a binding's `fetch` is a method on a host\n * object, and workerd routes it as an internal subrequest that never touches the global. So\n * the binding's *prototype* method is patched instead — the same move the Node recorder makes\n * on `Redis.prototype.sendCommand`.\n *\n * The prototype is reached from an instance found on `env`, never from a global name: in\n * workerd there is no `globalThis.Fetcher` (the runtime type is a bare TypeScript alias, not\n * one of the exposed runtime classes). Reaching it through an instance is also what makes\n * this work at all for an app that reads its bindings from `cloudflare:workers` instead of\n * the handler argument — that module hands out a *different* `env` object but the *same*\n * binding instances, so the prototype we patch is shared either way.\n *\n * Verified against real workerd in\n * `packages/backend-recorder-js/src/__tests__/workerd-binding-patch.spec.ts`, which also\n * pins the properties this relies on: `fetch` is inherited and configurable, the receiver\n * survives, one patch covers every service binding plus Durable Object stubs, and a binding\n * call is not also counted as a `globalThis.fetch` call.\n */\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst BINDING_FETCH_PATCHED = Symbol.for(\"meticulous.workerd.bindingPatched\");\n\n/**\n * Bindings never recorded, by name.\n *\n * An assets binding is shape-identical to a service binding — same prototype, same `fetch` —\n * so it cannot be told apart by duck-typing and has to be skipped by name. Recording it would\n * push every static asset through the body capture cap for no benefit: asset bytes are large\n * and often binary, and asset serving is typically the highest-volume call a worker makes.\n *\n * The sidecar binding is here as belt-and-braces only: the wrapper also passes its resolved\n * instance as a `skipInstances` entry, which holds however the customer named it.\n */\nconst DEFAULT_SKIPPED_BINDINGS = [\n \"ASSETS\",\n \"__STATIC_CONTENT\",\n SIDECAR_BINDING_ENV_KEY,\n] as const;\n\n/** Identifies a binding instance so its calls can be attributed to an `env` key. */\nconst bindingNames = new WeakMap<object, string>();\nconst skippedBindings = new WeakSet<object>();\n\ninterface FetcherLike {\n fetch: (...args: unknown[]) => Promise<Response>;\n}\n\ntype BindingFetch = (...args: unknown[]) => Promise<Response>;\n\nexport interface InstallBindingPatchOptions {\n /** Extra binding names to leave unrecorded, on top of the defaults. */\n skipBindings?: readonly string[];\n /**\n * Binding instances to leave unrecorded, whatever they are called on `env`. This is how the\n * sidecar's own service binding is excluded: recording the shim's reports would be an infinite\n * regress, and a name-based skip would miss a binding the customer named something else or\n * passed in through `options.sidecarBinding`.\n */\n skipInstances?: readonly object[];\n}\n\n/**\n * Discovers Fetcher-shaped bindings on `env` and patches their shared prototype so their\n * `fetch` calls are recorded. Safe to call on every request — discovery is cheap and the\n * patch itself is applied once per prototype per isolate.\n *\n * Never throws: a runtime that does not permit the patch simply goes unrecorded.\n */\nexport const installBindingPatch = (\n env: unknown,\n options?: InstallBindingPatchOptions,\n): void => {\n try {\n for (const { name, value, skip } of fetcherBindings(\n env,\n options?.skipBindings,\n options?.skipInstances,\n )) {\n // Patch even a skipped binding's prototype: it is shared with every other\n // Fetcher-shaped binding (and is in a Durable Object stub's prototype chain), so\n // seeding it is what gives those coverage. Skipping is enforced per instance at call\n // time, not by declining to patch.\n patchBindingPrototype(value);\n if (skip) {\n skippedBindings.add(value);\n continue;\n }\n bindingNames.set(value, name);\n }\n } catch (error) {\n warnOnce(\n \"binding-patch-install\",\n \"Failed to install the Meticulous binding patch — binding calls will not be recorded.\",\n error,\n );\n }\n};\n\ninterface DiscoveredBinding {\n name: string;\n value: object;\n skip: boolean;\n}\n\nconst fetcherBindings = (\n env: unknown,\n extraSkipped: readonly string[] | undefined,\n skipInstances: readonly object[] | undefined,\n): DiscoveredBinding[] => {\n if (env === null || typeof env !== \"object\") {\n return [];\n }\n const skipped = new Set<string>([\n ...DEFAULT_SKIPPED_BINDINGS,\n ...(extraSkipped ?? []),\n ]);\n const skippedInstances = new Set<object>(skipInstances ?? []);\n const found: DiscoveredBinding[] = [];\n for (const [name, value] of Object.entries(env)) {\n if (isFetcherLike(value)) {\n found.push({\n name,\n value,\n skip: skipped.has(name) || skippedInstances.has(value),\n });\n }\n }\n return found;\n};\n\n/**\n * A binding we can record: something with a `fetch` method. Durable Object *namespaces* are\n * excluded — they have no `fetch`; it is the stub from `namespace.get()` that does, and that\n * stub inherits the same prototype, so it is covered without being discovered here.\n */\nconst isFetcherLike = (value: unknown): value is object & FetcherLike =>\n value !== null &&\n typeof value === \"object\" &&\n typeof (value as Partial<FetcherLike>).fetch === \"function\";\n\nconst patchBindingPrototype = (binding: object): void => {\n const prototype = Object.getPrototypeOf(binding) as\n | (object & Record<symbol, unknown>)\n | null;\n if (prototype === null || prototype[BINDING_FETCH_PATCHED] === true) {\n return;\n }\n const descriptor = Object.getOwnPropertyDescriptor(prototype, \"fetch\");\n if (\n descriptor === undefined ||\n typeof descriptor.value !== \"function\" ||\n descriptor.configurable !== true\n ) {\n // Either `fetch` is an own per-instance property or the runtime has locked the\n // prototype. Recording binding calls is not possible; everything else keeps working.\n warnOnce(\n \"binding-patch-unsupported\",\n \"This runtime does not expose a patchable binding `fetch` — binding calls will not be recorded.\",\n );\n return;\n }\n\n const original = descriptor.value as BindingFetch;\n Object.defineProperty(prototype, \"fetch\", {\n value: patchedBindingFetch(original),\n writable: true,\n enumerable: false,\n configurable: true,\n });\n Object.defineProperty(prototype, BINDING_FETCH_PATCHED, {\n value: true,\n enumerable: false,\n configurable: true,\n });\n};\n\n/**\n * A `function`, not an arrow: `this` is the binding the call was made on, and it must be\n * forwarded to the original or workerd throws \"Illegal invocation\".\n */\nconst patchedBindingFetch = (original: BindingFetch): BindingFetch =>\n function (this: unknown, ...args: unknown[]): Promise<Response> {\n const passThrough = (): Promise<Response> => original.apply(this, args);\n\n const ctx = requestCaptureContext.getStore();\n // Record mode only. `installBindingPatch` is not called for a replay request, but the\n // patch is per-isolate: a record request could have installed it earlier, and a replay\n // request must not be teed into a sidecar that has no exporter behind it.\n if (!ctx || ctx.mode !== \"record\") {\n return passThrough();\n }\n const receiver =\n typeof this === \"object\" && this !== null ? this : undefined;\n if (receiver !== undefined && skippedBindings.has(receiver)) {\n return passThrough();\n }\n\n const request = toRequest(args);\n if (request === undefined) {\n return passThrough();\n }\n\n // Defence in depth: a binding pointed at the sidecar must not be recorded. Forwards\n // `request`, not `args` — normalizing may already have consumed the original body.\n if (request.url.startsWith(`${sidecarOriginOf(ctx)}/`)) {\n return original.apply(this, [request]);\n }\n\n const bindingName =\n receiver !== undefined ? bindingNames.get(receiver) : undefined;\n return captureOutboundCall(\n ctx,\n { kind: \"binding\", bindingName },\n request,\n (req) => original.apply(this, [req]),\n );\n };\n\n/**\n * Normalizes the call's arguments to a single Request, or undefined if they cannot be\n * represented (in which case the call passes through untouched and the runtime produces its\n * own error).\n *\n * A lone `Request` is passed along as-is rather than rebuilt: `new Request(existing)` drops\n * workerd's `cf` property, which the receiving worker may read.\n */\nconst toRequest = (args: unknown[]): Request | undefined => {\n try {\n const [input, init] = args;\n if (input instanceof Request && init === undefined) {\n return input;\n }\n return new Request(input as RequestInfo, init as RequestInit | undefined);\n } catch {\n return undefined;\n }\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,MAAM,wBAAwB,OAAO,IAAI,oCAAoC;;;;;;;;;;;;AAa7E,MAAM,2BAA2B;CAC/B;CACA;CACA;CACD;;AAGD,MAAM,+BAAe,IAAI,SAAyB;AAClD,MAAM,kCAAkB,IAAI,SAAiB;;;;;;;;AA2B7C,MAAa,uBACX,KACA,YACS;AACT,KAAI;AACF,OAAK,MAAM,EAAE,MAAM,OAAO,UAAU,gBAClC,KACA,SAAS,cACT,SAAS,cACV,EAAE;AAKD,yBAAsB,MAAM;AAC5B,OAAI,MAAM;AACR,oBAAgB,IAAI,MAAM;AAC1B;;AAEF,gBAAa,IAAI,OAAO,KAAK;;UAExB,OAAO;AACd,WACE,yBACA,wFACA,MACD;;;AAUL,MAAM,mBACJ,KACA,cACA,kBACwB;AACxB,KAAI,QAAQ,QAAQ,OAAO,QAAQ,SACjC,QAAO,EAAE;CAEX,MAAM,UAAU,IAAI,IAAY,CAC9B,GAAG,0BACH,GAAI,gBAAgB,EAAE,CACvB,CAAC;CACF,MAAM,mBAAmB,IAAI,IAAY,iBAAiB,EAAE,CAAC;CAC7D,MAAM,QAA6B,EAAE;AACrC,MAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,IAAI,CAC7C,KAAI,cAAc,MAAM,CACtB,OAAM,KAAK;EACT;EACA;EACA,MAAM,QAAQ,IAAI,KAAK,IAAI,iBAAiB,IAAI,MAAM;EACvD,CAAC;AAGN,QAAO;;;;;;;AAQT,MAAM,iBAAiB,UACrB,UAAU,QACV,OAAO,UAAU,YACjB,OAAQ,MAA+B,UAAU;AAEnD,MAAM,yBAAyB,YAA0B;CACvD,MAAM,YAAY,OAAO,eAAe,QAAQ;AAGhD,KAAI,cAAc,QAAQ,UAAU,2BAA2B,KAC7D;CAEF,MAAM,aAAa,OAAO,yBAAyB,WAAW,QAAQ;AACtE,KACE,eAAe,KAAA,KACf,OAAO,WAAW,UAAU,cAC5B,WAAW,iBAAiB,MAC5B;AAGA,WACE,6BACA,iGACD;AACD;;CAGF,MAAM,WAAW,WAAW;AAC5B,QAAO,eAAe,WAAW,SAAS;EACxC,OAAO,oBAAoB,SAAS;EACpC,UAAU;EACV,YAAY;EACZ,cAAc;EACf,CAAC;AACF,QAAO,eAAe,WAAW,uBAAuB;EACtD,OAAO;EACP,YAAY;EACZ,cAAc;EACf,CAAC;;;;;;AAOJ,MAAM,uBAAuB,aAC3B,SAAyB,GAAG,MAAoC;CAC9D,MAAM,oBAAuC,SAAS,MAAM,MAAM,KAAK;CAEvE,MAAM,MAAM,sBAAsB,UAAU;AAI5C,KAAI,CAAC,OAAO,IAAI,SAAS,SACvB,QAAO,aAAa;CAEtB,MAAM,WACJ,OAAO,SAAS,YAAY,SAAS,OAAO,OAAO,KAAA;AACrD,KAAI,aAAa,KAAA,KAAa,gBAAgB,IAAI,SAAS,CACzD,QAAO,aAAa;CAGtB,MAAM,UAAU,UAAU,KAAK;AAC/B,KAAI,YAAY,KAAA,EACd,QAAO,aAAa;AAKtB,KAAI,QAAQ,IAAI,WAAW,GAAG,gBAAgB,IAAI,CAAC,GAAG,CACpD,QAAO,SAAS,MAAM,MAAM,CAAC,QAAQ,CAAC;AAKxC,QAAO,oBACL,KACA;EAAE,MAAM;EAAW,aAHnB,aAAa,KAAA,IAAY,aAAa,IAAI,SAAS,GAAG,KAAA;EAGtB,EAChC,UACC,QAAQ,SAAS,MAAM,MAAM,CAAC,IAAI,CAAC,CACrC;;;;;;;;;;AAWL,MAAM,aAAa,SAAyC;AAC1D,KAAI;EACF,MAAM,CAAC,OAAO,QAAQ;AACtB,MAAI,iBAAiB,WAAW,SAAS,KAAA,EACvC,QAAO;AAET,SAAO,IAAI,QAAQ,OAAsB,KAAgC;SACnE;AACN"}
|
package/dist/body-capture.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="a0efd618-4630-5143-8869-b5c81997779f")}catch(e){}}();
|
|
3
1
|
import { redactRequestBody } from "./redact-body.js";
|
|
4
2
|
//#region src/body-capture.ts
|
|
5
3
|
/** Matches the Node backend recorder's body capture cap (met-http-instrumentation). */
|
|
@@ -75,5 +73,4 @@ const readRequestBodyWithCap = async (stream) => {
|
|
|
75
73
|
//#endregion
|
|
76
74
|
export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap };
|
|
77
75
|
|
|
78
|
-
//# sourceMappingURL=body-capture.js.map
|
|
79
|
-
//# debugId=a0efd618-4630-5143-8869-b5c81997779f
|
|
76
|
+
//# sourceMappingURL=body-capture.js.map
|
package/dist/body-capture.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"body-capture.js","sources":["../src/body-capture.ts"],"sourcesContent":["import type { CapturedBody } from \"./protocol\";\nimport { redactRequestBody } from \"./redact-body\";\n\n/** Matches the Node backend recorder's body capture cap (met-http-instrumentation). */\nexport const MAX_BODY_CAPTURE_SIZE = 256 * 1024;\n\n/** Cap on how long we keep reading a (possibly never-ending, e.g. SSE) body clone. */\nconst BODY_READ_TIMEOUT_MS = 10_000;\n\nconst TIMED_OUT = Symbol(\"meticulous.bodyReadTimedOut\");\n\n/**\n * Reads a body stream up to {@link MAX_BODY_CAPTURE_SIZE} bytes, decoding as\n * UTF-8. Stops (and marks the capture truncated) on the size cap or the time\n * guard, so a long-lived stream can never pin the capture work. Returns\n * undefined for absent bodies.\n */\nexport const readBodyWithCap = async (\n stream: ReadableStream<Uint8Array> | null,\n): Promise<CapturedBody | undefined> => {\n if (!stream) {\n return undefined;\n }\n\n const reader = stream.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n let text = \"\";\n let bytes = 0;\n let truncated = false;\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timedOut = new Promise<typeof TIMED_OUT>((resolve) => {\n timer = setTimeout(() => resolve(TIMED_OUT), BODY_READ_TIMEOUT_MS);\n });\n\n try {\n while (true) {\n const result = await Promise.race([reader.read(), timedOut]);\n if (result === TIMED_OUT) {\n truncated = true;\n break;\n }\n const { done, value } = result;\n if (done) {\n break;\n }\n if (!value || value.byteLength === 0) {\n continue;\n }\n const remaining = MAX_BODY_CAPTURE_SIZE - bytes;\n if (value.byteLength >= remaining) {\n text += decoder.decode(value.subarray(0, remaining), { stream: true });\n bytes += remaining;\n truncated = truncated || value.byteLength > remaining;\n // Even an exact-cap chunk means we stop reading, so a longer stream\n // counts as truncated.\n const next = await Promise.race([reader.read(), timedOut]);\n if (next === TIMED_OUT || !next.done) {\n truncated = true;\n }\n break;\n }\n text += decoder.decode(value, { stream: true });\n bytes += value.byteLength;\n }\n } finally {\n clearTimeout(timer);\n reader.cancel().catch(() => {});\n }\n\n text += decoder.decode();\n return { body: text, truncated };\n};\n\n/**\n * Reads a *request* body, redacting secret-looking fields before it leaves the worker.\n *\n * Every request-body capture path goes through here, so record and replay redact\n * identically and any hash derived from the body still agrees on both sides. Response\n * bodies deliberately use {@link readBodyWithCap} directly — they are served back to the\n * app during replay and must stay byte-exact.\n */\nexport const readRequestBodyWithCap = async (\n stream: ReadableStream<Uint8Array> | null,\n): Promise<CapturedBody | undefined> => {\n const captured = await readBodyWithCap(stream);\n if (captured === undefined) {\n return undefined;\n }\n return { ...captured, body: redactRequestBody(captured.body) };\n};\n"],"
|
|
1
|
+
{"version":3,"file":"body-capture.js","names":[],"sources":["../src/body-capture.ts"],"sourcesContent":["import type { CapturedBody } from \"./protocol\";\nimport { redactRequestBody } from \"./redact-body\";\n\n/** Matches the Node backend recorder's body capture cap (met-http-instrumentation). */\nexport const MAX_BODY_CAPTURE_SIZE = 256 * 1024;\n\n/** Cap on how long we keep reading a (possibly never-ending, e.g. SSE) body clone. */\nconst BODY_READ_TIMEOUT_MS = 10_000;\n\nconst TIMED_OUT = Symbol(\"meticulous.bodyReadTimedOut\");\n\n/**\n * Reads a body stream up to {@link MAX_BODY_CAPTURE_SIZE} bytes, decoding as\n * UTF-8. Stops (and marks the capture truncated) on the size cap or the time\n * guard, so a long-lived stream can never pin the capture work. Returns\n * undefined for absent bodies.\n */\nexport const readBodyWithCap = async (\n stream: ReadableStream<Uint8Array> | null,\n): Promise<CapturedBody | undefined> => {\n if (!stream) {\n return undefined;\n }\n\n const reader = stream.getReader();\n const decoder = new TextDecoder(\"utf-8\");\n let text = \"\";\n let bytes = 0;\n let truncated = false;\n\n let timer: ReturnType<typeof setTimeout> | undefined;\n const timedOut = new Promise<typeof TIMED_OUT>((resolve) => {\n timer = setTimeout(() => resolve(TIMED_OUT), BODY_READ_TIMEOUT_MS);\n });\n\n try {\n while (true) {\n const result = await Promise.race([reader.read(), timedOut]);\n if (result === TIMED_OUT) {\n truncated = true;\n break;\n }\n const { done, value } = result;\n if (done) {\n break;\n }\n if (!value || value.byteLength === 0) {\n continue;\n }\n const remaining = MAX_BODY_CAPTURE_SIZE - bytes;\n if (value.byteLength >= remaining) {\n text += decoder.decode(value.subarray(0, remaining), { stream: true });\n bytes += remaining;\n truncated = truncated || value.byteLength > remaining;\n // Even an exact-cap chunk means we stop reading, so a longer stream\n // counts as truncated.\n const next = await Promise.race([reader.read(), timedOut]);\n if (next === TIMED_OUT || !next.done) {\n truncated = true;\n }\n break;\n }\n text += decoder.decode(value, { stream: true });\n bytes += value.byteLength;\n }\n } finally {\n clearTimeout(timer);\n reader.cancel().catch(() => {});\n }\n\n text += decoder.decode();\n return { body: text, truncated };\n};\n\n/**\n * Reads a *request* body, redacting secret-looking fields before it leaves the worker.\n *\n * Every request-body capture path goes through here, so record and replay redact\n * identically and any hash derived from the body still agrees on both sides. Response\n * bodies deliberately use {@link readBodyWithCap} directly — they are served back to the\n * app during replay and must stay byte-exact.\n */\nexport const readRequestBodyWithCap = async (\n stream: ReadableStream<Uint8Array> | null,\n): Promise<CapturedBody | undefined> => {\n const captured = await readBodyWithCap(stream);\n if (captured === undefined) {\n return undefined;\n }\n return { ...captured, body: redactRequestBody(captured.body) };\n};\n"],"mappings":";;;AAIA,MAAa,wBAAwB,MAAM;;AAG3C,MAAM,uBAAuB;AAE7B,MAAM,YAAY,OAAO,8BAA8B;;;;;;;AAQvD,MAAa,kBAAkB,OAC7B,WACsC;AACtC,KAAI,CAAC,OACH;CAGF,MAAM,SAAS,OAAO,WAAW;CACjC,MAAM,UAAU,IAAI,YAAY,QAAQ;CACxC,IAAI,OAAO;CACX,IAAI,QAAQ;CACZ,IAAI,YAAY;CAEhB,IAAI;CACJ,MAAM,WAAW,IAAI,SAA2B,YAAY;AAC1D,UAAQ,iBAAiB,QAAQ,UAAU,EAAE,qBAAqB;GAClE;AAEF,KAAI;AACF,SAAO,MAAM;GACX,MAAM,SAAS,MAAM,QAAQ,KAAK,CAAC,OAAO,MAAM,EAAE,SAAS,CAAC;AAC5D,OAAI,WAAW,WAAW;AACxB,gBAAY;AACZ;;GAEF,MAAM,EAAE,MAAM,UAAU;AACxB,OAAI,KACF;AAEF,OAAI,CAAC,SAAS,MAAM,eAAe,EACjC;GAEF,MAAM,YAAY,wBAAwB;AAC1C,OAAI,MAAM,cAAc,WAAW;AACjC,YAAQ,QAAQ,OAAO,MAAM,SAAS,GAAG,UAAU,EAAE,EAAE,QAAQ,MAAM,CAAC;AACtE,aAAS;AACT,gBAAY,aAAa,MAAM,aAAa;IAG5C,MAAM,OAAO,MAAM,QAAQ,KAAK,CAAC,OAAO,MAAM,EAAE,SAAS,CAAC;AAC1D,QAAI,SAAS,aAAa,CAAC,KAAK,KAC9B,aAAY;AAEd;;AAEF,WAAQ,QAAQ,OAAO,OAAO,EAAE,QAAQ,MAAM,CAAC;AAC/C,YAAS,MAAM;;WAET;AACR,eAAa,MAAM;AACnB,SAAO,QAAQ,CAAC,YAAY,GAAG;;AAGjC,SAAQ,QAAQ,QAAQ;AACxB,QAAO;EAAE,MAAM;EAAM;EAAW;;;;;;;;;;AAWlC,MAAa,yBAAyB,OACpC,WACsC;CACtC,MAAM,WAAW,MAAM,gBAAgB,OAAO;AAC9C,KAAI,aAAa,KAAA,EACf;AAEF,QAAO;EAAE,GAAG;EAAU,MAAM,kBAAkB,SAAS,KAAK;EAAE"}
|
package/dist/capture-buffer.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="736a00e9-e9cf-578b-bca0-554b57ed34ed")}catch(e){}}();
|
|
3
1
|
import { warnOnce } from "./log.js";
|
|
4
2
|
import { postCaptureEvents } from "./sidecar-client.js";
|
|
5
3
|
//#region src/capture-buffer.ts
|
|
@@ -110,5 +108,4 @@ const estimateChars = (event) => {
|
|
|
110
108
|
//#endregion
|
|
111
109
|
export { CaptureBuffer };
|
|
112
110
|
|
|
113
|
-
//# sourceMappingURL=capture-buffer.js.map
|
|
114
|
-
//# debugId=736a00e9-e9cf-578b-bca0-554b57ed34ed
|
|
111
|
+
//# sourceMappingURL=capture-buffer.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capture-buffer.js","sources":["../src/capture-buffer.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport type { CaptureEvent } from \"./protocol\";\nimport { postCaptureEvents } from \"./sidecar-client\";\nimport type { SidecarTransport } from \"./sidecar-transport\";\n\n/**\n * Batches one request's capture events into a single report to the sidecar.\n *\n * A local `wrangler dev` recording could afford a POST per event over loopback. A deployed\n * Worker cannot: each one is a subrequest, and — with a service-binding transport — an extra\n * hop the runtime has to keep the request context alive for. An SSR request making twenty\n * outbound calls should cost one report, not twenty.\n *\n * Batching means the report has to wait for work that finishes *after* the handler returns: a\n * response body is only captured once its clone has been drained. So producers register here\n * with {@link track} instead of each holding its own `waitUntil`, and {@link close} — the single\n * promise the wrapper hands to `waitUntil` — drains them before sending.\n */\nexport class CaptureBuffer {\n private events: CaptureEvent[] = [];\n private bufferedChars = 0;\n private readonly pending = new Set<Promise<void>>();\n /** Chained so two flushes can never interleave and reorder a session's chunks. */\n private sending: Promise<void> = Promise.resolve();\n private closed = false;\n\n constructor(\n private readonly transport: SidecarTransport,\n private readonly waitUntil: (promise: Promise<unknown>) => void,\n ) {}\n\n /**\n * Registers work that will add zero or more events. Never rejects: a producer that throws is\n * warned about once and dropped, exactly as an unbatched capture failure was.\n */\n track(\n producer: () => Promise<void>,\n warnKey: string,\n warnMessage: string,\n ): void {\n const promise = producer()\n .catch((error) => {\n warnOnce(warnKey, warnMessage, error);\n })\n .finally(() => {\n this.pending.delete(promise);\n });\n this.pending.add(promise);\n // A closed buffer has no `close()` left to await this, so it needs its own lifetime\n // extension — see the same reasoning in `add`.\n if (this.closed) {\n this.waitUntil(promise);\n }\n }\n\n /**\n * Buffers one event, sending early if the buffer has grown past {@link MAX_BUFFERED_CHARS}.\n *\n * The early send matters for exactly the requests batching is riskiest for: a long-lived SSE\n * response, or a handler making many body-carrying calls, would otherwise hold every captured\n * body in isolate memory until the request ended.\n */\n add(event: CaptureEvent): void {\n if (this.closed) {\n // Work the app itself queued in `waitUntil` can still make calls after our own `close()`\n // has run. Report those the old way rather than dropping them.\n this.waitUntil(this.send([event]));\n return;\n }\n this.events.push(event);\n this.bufferedChars += estimateChars(event);\n if (this.bufferedChars >= MAX_BUFFERED_CHARS) {\n this.waitUntil(this.flush());\n }\n }\n\n /**\n * Drains every producer and sends what they buffered, repeating while draining produces more\n * work (a body read that starts another capture). Idempotent; after it resolves the buffer is\n * closed and later events are sent individually.\n */\n async close(): Promise<void> {\n for (let round = 0; round < MAX_DRAIN_ROUNDS; round++) {\n if (this.pending.size === 0) {\n break;\n }\n await Promise.all([...this.pending]);\n }\n this.closed = true;\n await this.flush();\n }\n\n private flush(): Promise<void> {\n if (this.events.length === 0) {\n return this.sending;\n }\n const batch = this.events;\n this.events = [];\n this.bufferedChars = 0;\n this.sending = this.sending.then(() => this.send(batch));\n return this.sending;\n }\n\n /** `postCaptureEvents` never rejects — it warns once on an unreachable sidecar. */\n private send(events: CaptureEvent[]): Promise<void> {\n return postCaptureEvents(this.transport, events);\n }\n}\n\n/**\n * Buffered-payload ceiling, in JSON characters. Two 256 KB bodies per event means a handful of\n * body-carrying calls reaches this; the sidecar's own payload limit is 8 MB, so a batch built to\n * this bound always fits with room for JSON escaping.\n */\nconst MAX_BUFFERED_CHARS = 2 * 1024 * 1024;\n\n/**\n * Bounds `close()` in the pathological case where every drained producer starts another. Capture\n * is a leaf operation, so one round is the norm and two the most a body read can cause.\n */\nconst MAX_DRAIN_ROUNDS = 4;\n\n/**\n * Rough size of an event, counting only the fields that can be large. Cheaper than\n * `JSON.stringify` on the whole event, and this only needs to be right to within a factor.\n */\nconst estimateChars = (event: CaptureEvent): number => {\n let chars = 256;\n if (\"requestBody\" in event && event.requestBody !== undefined) {\n chars += event.requestBody.body.length;\n }\n if (\"responseBody\" in event && event.responseBody !== undefined) {\n chars += event.responseBody.body.length;\n }\n if (\"result\" in event && event.result !== undefined) {\n chars += event.result.body.length;\n }\n if (\"value\" in event && event.value !== undefined) {\n chars += event.value.body.length;\n }\n if (\"args\" in event && event.args !== undefined) {\n chars += event.args.body.length;\n }\n if (\"queryText\" in event) {\n chars += event.queryText.length + event.params.length;\n }\n return chars;\n};\n"],"
|
|
1
|
+
{"version":3,"file":"capture-buffer.js","names":[],"sources":["../src/capture-buffer.ts"],"sourcesContent":["import { warnOnce } from \"./log\";\nimport type { CaptureEvent } from \"./protocol\";\nimport { postCaptureEvents } from \"./sidecar-client\";\nimport type { SidecarTransport } from \"./sidecar-transport\";\n\n/**\n * Batches one request's capture events into a single report to the sidecar.\n *\n * A local `wrangler dev` recording could afford a POST per event over loopback. A deployed\n * Worker cannot: each one is a subrequest, and — with a service-binding transport — an extra\n * hop the runtime has to keep the request context alive for. An SSR request making twenty\n * outbound calls should cost one report, not twenty.\n *\n * Batching means the report has to wait for work that finishes *after* the handler returns: a\n * response body is only captured once its clone has been drained. So producers register here\n * with {@link track} instead of each holding its own `waitUntil`, and {@link close} — the single\n * promise the wrapper hands to `waitUntil` — drains them before sending.\n */\nexport class CaptureBuffer {\n private events: CaptureEvent[] = [];\n private bufferedChars = 0;\n private readonly pending = new Set<Promise<void>>();\n /** Chained so two flushes can never interleave and reorder a session's chunks. */\n private sending: Promise<void> = Promise.resolve();\n private closed = false;\n\n constructor(\n private readonly transport: SidecarTransport,\n private readonly waitUntil: (promise: Promise<unknown>) => void,\n ) {}\n\n /**\n * Registers work that will add zero or more events. Never rejects: a producer that throws is\n * warned about once and dropped, exactly as an unbatched capture failure was.\n */\n track(\n producer: () => Promise<void>,\n warnKey: string,\n warnMessage: string,\n ): void {\n const promise = producer()\n .catch((error) => {\n warnOnce(warnKey, warnMessage, error);\n })\n .finally(() => {\n this.pending.delete(promise);\n });\n this.pending.add(promise);\n // A closed buffer has no `close()` left to await this, so it needs its own lifetime\n // extension — see the same reasoning in `add`.\n if (this.closed) {\n this.waitUntil(promise);\n }\n }\n\n /**\n * Buffers one event, sending early if the buffer has grown past {@link MAX_BUFFERED_CHARS}.\n *\n * The early send matters for exactly the requests batching is riskiest for: a long-lived SSE\n * response, or a handler making many body-carrying calls, would otherwise hold every captured\n * body in isolate memory until the request ended.\n */\n add(event: CaptureEvent): void {\n if (this.closed) {\n // Work the app itself queued in `waitUntil` can still make calls after our own `close()`\n // has run. Report those the old way rather than dropping them.\n this.waitUntil(this.send([event]));\n return;\n }\n this.events.push(event);\n this.bufferedChars += estimateChars(event);\n if (this.bufferedChars >= MAX_BUFFERED_CHARS) {\n this.waitUntil(this.flush());\n }\n }\n\n /**\n * Drains every producer and sends what they buffered, repeating while draining produces more\n * work (a body read that starts another capture). Idempotent; after it resolves the buffer is\n * closed and later events are sent individually.\n */\n async close(): Promise<void> {\n for (let round = 0; round < MAX_DRAIN_ROUNDS; round++) {\n if (this.pending.size === 0) {\n break;\n }\n await Promise.all([...this.pending]);\n }\n this.closed = true;\n await this.flush();\n }\n\n private flush(): Promise<void> {\n if (this.events.length === 0) {\n return this.sending;\n }\n const batch = this.events;\n this.events = [];\n this.bufferedChars = 0;\n this.sending = this.sending.then(() => this.send(batch));\n return this.sending;\n }\n\n /** `postCaptureEvents` never rejects — it warns once on an unreachable sidecar. */\n private send(events: CaptureEvent[]): Promise<void> {\n return postCaptureEvents(this.transport, events);\n }\n}\n\n/**\n * Buffered-payload ceiling, in JSON characters. Two 256 KB bodies per event means a handful of\n * body-carrying calls reaches this; the sidecar's own payload limit is 8 MB, so a batch built to\n * this bound always fits with room for JSON escaping.\n */\nconst MAX_BUFFERED_CHARS = 2 * 1024 * 1024;\n\n/**\n * Bounds `close()` in the pathological case where every drained producer starts another. Capture\n * is a leaf operation, so one round is the norm and two the most a body read can cause.\n */\nconst MAX_DRAIN_ROUNDS = 4;\n\n/**\n * Rough size of an event, counting only the fields that can be large. Cheaper than\n * `JSON.stringify` on the whole event, and this only needs to be right to within a factor.\n */\nconst estimateChars = (event: CaptureEvent): number => {\n let chars = 256;\n if (\"requestBody\" in event && event.requestBody !== undefined) {\n chars += event.requestBody.body.length;\n }\n if (\"responseBody\" in event && event.responseBody !== undefined) {\n chars += event.responseBody.body.length;\n }\n if (\"result\" in event && event.result !== undefined) {\n chars += event.result.body.length;\n }\n if (\"value\" in event && event.value !== undefined) {\n chars += event.value.body.length;\n }\n if (\"args\" in event && event.args !== undefined) {\n chars += event.args.body.length;\n }\n if (\"queryText\" in event) {\n chars += event.queryText.length + event.params.length;\n }\n return chars;\n};\n"],"mappings":";;;;;;;;;;;;;;;;AAkBA,IAAa,gBAAb,MAA2B;CACzB,SAAiC,EAAE;CACnC,gBAAwB;CACxB,0BAA2B,IAAI,KAAoB;;CAEnD,UAAiC,QAAQ,SAAS;CAClD,SAAiB;CAEjB,YACE,WACA,WACA;AAFiB,OAAA,YAAA;AACA,OAAA,YAAA;;;;;;CAOnB,MACE,UACA,SACA,aACM;EACN,MAAM,UAAU,UAAU,CACvB,OAAO,UAAU;AAChB,YAAS,SAAS,aAAa,MAAM;IACrC,CACD,cAAc;AACb,QAAK,QAAQ,OAAO,QAAQ;IAC5B;AACJ,OAAK,QAAQ,IAAI,QAAQ;AAGzB,MAAI,KAAK,OACP,MAAK,UAAU,QAAQ;;;;;;;;;CAW3B,IAAI,OAA2B;AAC7B,MAAI,KAAK,QAAQ;AAGf,QAAK,UAAU,KAAK,KAAK,CAAC,MAAM,CAAC,CAAC;AAClC;;AAEF,OAAK,OAAO,KAAK,MAAM;AACvB,OAAK,iBAAiB,cAAc,MAAM;AAC1C,MAAI,KAAK,iBAAiB,mBACxB,MAAK,UAAU,KAAK,OAAO,CAAC;;;;;;;CAShC,MAAM,QAAuB;AAC3B,OAAK,IAAI,QAAQ,GAAG,QAAQ,kBAAkB,SAAS;AACrD,OAAI,KAAK,QAAQ,SAAS,EACxB;AAEF,SAAM,QAAQ,IAAI,CAAC,GAAG,KAAK,QAAQ,CAAC;;AAEtC,OAAK,SAAS;AACd,QAAM,KAAK,OAAO;;CAGpB,QAA+B;AAC7B,MAAI,KAAK,OAAO,WAAW,EACzB,QAAO,KAAK;EAEd,MAAM,QAAQ,KAAK;AACnB,OAAK,SAAS,EAAE;AAChB,OAAK,gBAAgB;AACrB,OAAK,UAAU,KAAK,QAAQ,WAAW,KAAK,KAAK,MAAM,CAAC;AACxD,SAAO,KAAK;;;CAId,KAAa,QAAuC;AAClD,SAAO,kBAAkB,KAAK,WAAW,OAAO;;;;;;;;AASpD,MAAM,qBAAqB,IAAI,OAAO;;;;;AAMtC,MAAM,mBAAmB;;;;;AAMzB,MAAM,iBAAiB,UAAgC;CACrD,IAAI,QAAQ;AACZ,KAAI,iBAAiB,SAAS,MAAM,gBAAgB,KAAA,EAClD,UAAS,MAAM,YAAY,KAAK;AAElC,KAAI,kBAAkB,SAAS,MAAM,iBAAiB,KAAA,EACpD,UAAS,MAAM,aAAa,KAAK;AAEnC,KAAI,YAAY,SAAS,MAAM,WAAW,KAAA,EACxC,UAAS,MAAM,OAAO,KAAK;AAE7B,KAAI,WAAW,SAAS,MAAM,UAAU,KAAA,EACtC,UAAS,MAAM,MAAM,KAAK;AAE5B,KAAI,UAAU,SAAS,MAAM,SAAS,KAAA,EACpC,UAAS,MAAM,KAAK,KAAK;AAE3B,KAAI,eAAe,MACjB,UAAS,MAAM,UAAU,SAAS,MAAM,OAAO;AAEjD,QAAO"}
|
package/dist/context.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b4629efe-f3f5-5849-8ba3-ffcc3cdc8ba4")}catch(e){}}();
|
|
3
1
|
import { transportOrigin } from "./sidecar-transport.js";
|
|
4
2
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
3
|
//#region src/context.ts
|
|
@@ -9,5 +7,4 @@ const sidecarOriginOf = (ctx) => ctx.mode === "replay" ? ctx.sidecarUrl : transp
|
|
|
9
7
|
//#endregion
|
|
10
8
|
export { requestCaptureContext, sidecarOriginOf };
|
|
11
9
|
|
|
12
|
-
//# sourceMappingURL=context.js.map
|
|
13
|
-
//# debugId=b4629efe-f3f5-5849-8ba3-ffcc3cdc8ba4
|
|
10
|
+
//# sourceMappingURL=context.js.map
|
package/dist/context.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"context.js","sources":["../src/context.ts"],"sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\nimport type { CaptureBuffer } from \"./capture-buffer\";\nimport { type SidecarTransport, transportOrigin } from \"./sidecar-transport\";\n\n/**\n * Per-inbound-request context, entered by the withMeticulous wrapper and read by the fetch\n * patch. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag in the worker's\n * wrangler configuration.\n */\ninterface BaseRequestContext {\n requestId: string;\n /** The inbound request's ctx.waitUntil, for post-response capture work. */\n waitUntil: (promise: Promise<unknown>) => void;\n /**\n * Per-request line-coverage sink, indexed by the global line id the build\n * plugin assigned. Present only when the bundle is instrumented and the\n * request is being replayed.\n *\n * Lives here rather than in a module-scope global because workerd interleaves\n * concurrent requests in one isolate — a shared sink would merge sessions'\n * coverage together.\n */\n coverage?: Uint8Array;\n}\n\n/** Recording: capture events are reported to the sidecar, which stays out of the request path. */\nexport interface RequestCaptureContext extends BaseRequestContext {\n mode: \"record\";\n frontendSessionId: string | undefined;\n /**\n * Set only when we minted {@link frontendSessionId} ourselves for a document navigation the\n * browser could not tag, rather than reading it off the request. Recorded on the inbound\n * event so ingestion can tell an id the page may never have adopted from one it sent us.\n * See provisional-session-id.ts.\n */\n sessionIdOrigin?: \"backend\";\n transport: SidecarTransport;\n /** Batches this request's events into one report. */\n buffer: CaptureBuffer;\n /**\n * Trace this request's spans belong to, and the id of its SERVER span. Minted here rather than\n * by the sidecar so span assembly needs no cross-batch memory — see `CorrelatedEvent`.\n */\n traceId: string;\n serverSpanId: string;\n}\n\n/**\n * Replaying: outbound calls are looked up against the sidecar's mock store before they leave the\n * worker.\n *\n * Both ids are required here, unlike recording. Mocks are indexed by session and their\n * consume-once state is isolated by replay, so omitting either would make deterministic\n * lookup impossible. Making both non-optional keeps that state unrepresentable.\n *\n * Replay is URL-only, deliberately: the sidecar URL arrives on a request header the replay runner\n * injects, validated down to a loopback / docker-gateway / private-network origin. There is no\n * binding transport here because a replay sidecar is a Node process holding the mock store, never\n * a Worker.\n */\nexport interface RequestReplayContext extends BaseRequestContext {\n mode: \"replay\";\n frontendSessionId: string;\n replayId: string;\n /** Normalized origin of the sidecar, e.g. \"http://127.0.0.1:9670\". No trailing slash. */\n sidecarUrl: string;\n /**\n * Instant to freeze the worker's clock at while serving this session, or undefined to leave the\n * real clock alone.\n */\n clockAnchorMs: number | undefined;\n /**\n * Elapsed virtual time (ms from replay start) of this inbound request, when\n * the replay runner stamped {@link import(\"./protocol\").VIRTUAL_TIME_HEADER}.\n * Forwarded on every outbound lookup so concurrent calls from this request\n * share one timestamp and hash across the matching recorded burst.\n */\n virtualTimeMs?: number;\n}\n\nexport type RequestContext = RequestCaptureContext | RequestReplayContext;\n\nexport const requestCaptureContext = new AsyncLocalStorage<RequestContext>();\n\n/** The origin the shim's own sidecar traffic goes to, for the self-capture guards. */\nexport const sidecarOriginOf = (ctx: RequestContext): string =>\n ctx.mode === \"replay\" ? ctx.sidecarUrl : transportOrigin(ctx.transport);\n"],"
|
|
1
|
+
{"version":3,"file":"context.js","names":[],"sources":["../src/context.ts"],"sourcesContent":["import { AsyncLocalStorage } from \"node:async_hooks\";\nimport type { CaptureBuffer } from \"./capture-buffer\";\nimport { type SidecarTransport, transportOrigin } from \"./sidecar-transport\";\n\n/**\n * Per-inbound-request context, entered by the withMeticulous wrapper and read by the fetch\n * patch. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag in the worker's\n * wrangler configuration.\n */\ninterface BaseRequestContext {\n requestId: string;\n /** The inbound request's ctx.waitUntil, for post-response capture work. */\n waitUntil: (promise: Promise<unknown>) => void;\n /**\n * Per-request line-coverage sink, indexed by the global line id the build\n * plugin assigned. Present only when the bundle is instrumented and the\n * request is being replayed.\n *\n * Lives here rather than in a module-scope global because workerd interleaves\n * concurrent requests in one isolate — a shared sink would merge sessions'\n * coverage together.\n */\n coverage?: Uint8Array;\n}\n\n/** Recording: capture events are reported to the sidecar, which stays out of the request path. */\nexport interface RequestCaptureContext extends BaseRequestContext {\n mode: \"record\";\n frontendSessionId: string | undefined;\n /**\n * Set only when we minted {@link frontendSessionId} ourselves for a document navigation the\n * browser could not tag, rather than reading it off the request. Recorded on the inbound\n * event so ingestion can tell an id the page may never have adopted from one it sent us.\n * See provisional-session-id.ts.\n */\n sessionIdOrigin?: \"backend\";\n transport: SidecarTransport;\n /** Batches this request's events into one report. */\n buffer: CaptureBuffer;\n /**\n * Trace this request's spans belong to, and the id of its SERVER span. Minted here rather than\n * by the sidecar so span assembly needs no cross-batch memory — see `CorrelatedEvent`.\n */\n traceId: string;\n serverSpanId: string;\n}\n\n/**\n * Replaying: outbound calls are looked up against the sidecar's mock store before they leave the\n * worker.\n *\n * Both ids are required here, unlike recording. Mocks are indexed by session and their\n * consume-once state is isolated by replay, so omitting either would make deterministic\n * lookup impossible. Making both non-optional keeps that state unrepresentable.\n *\n * Replay is URL-only, deliberately: the sidecar URL arrives on a request header the replay runner\n * injects, validated down to a loopback / docker-gateway / private-network origin. There is no\n * binding transport here because a replay sidecar is a Node process holding the mock store, never\n * a Worker.\n */\nexport interface RequestReplayContext extends BaseRequestContext {\n mode: \"replay\";\n frontendSessionId: string;\n replayId: string;\n /** Normalized origin of the sidecar, e.g. \"http://127.0.0.1:9670\". No trailing slash. */\n sidecarUrl: string;\n /**\n * Instant to freeze the worker's clock at while serving this session, or undefined to leave the\n * real clock alone.\n */\n clockAnchorMs: number | undefined;\n /**\n * Elapsed virtual time (ms from replay start) of this inbound request, when\n * the replay runner stamped {@link import(\"./protocol\").VIRTUAL_TIME_HEADER}.\n * Forwarded on every outbound lookup so concurrent calls from this request\n * share one timestamp and hash across the matching recorded burst.\n */\n virtualTimeMs?: number;\n}\n\nexport type RequestContext = RequestCaptureContext | RequestReplayContext;\n\nexport const requestCaptureContext = new AsyncLocalStorage<RequestContext>();\n\n/** The origin the shim's own sidecar traffic goes to, for the self-capture guards. */\nexport const sidecarOriginOf = (ctx: RequestContext): string =>\n ctx.mode === \"replay\" ? ctx.sidecarUrl : transportOrigin(ctx.transport);\n"],"mappings":";;;AAkFA,MAAa,wBAAwB,IAAI,mBAAmC;;AAG5E,MAAa,mBAAmB,QAC9B,IAAI,SAAS,WAAW,IAAI,aAAa,gBAAgB,IAAI,UAAU"}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="7dccd8d2-bdd2-5af1-ab64-d5b05a22f58e")}catch(e){}}();
|
|
3
1
|
import { requestCaptureContext } from "../context.js";
|
|
4
2
|
import { warnOnce } from "../log.js";
|
|
5
3
|
import { getOriginalFetch } from "../original-fetch.js";
|
|
@@ -80,5 +78,4 @@ const reportCoverage = (context) => {
|
|
|
80
78
|
//#endregion
|
|
81
79
|
export { runReplayWithCoverage };
|
|
82
80
|
|
|
83
|
-
//# sourceMappingURL=replay-coverage.js.map
|
|
84
|
-
//# debugId=7dccd8d2-bdd2-5af1-ab64-d5b05a22f58e
|
|
81
|
+
//# sourceMappingURL=replay-coverage.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"replay-coverage.js","sources":["../../src/coverage/replay-coverage.ts"],"sourcesContent":["import { type RequestReplayContext, requestCaptureContext } from \"../context\";\nimport { warnOnce } from \"../log\";\nimport { getOriginalFetch } from \"../original-fetch\";\nimport { postCoverageReport } from \"../sidecar-client\";\nimport {\n collectHitIds,\n createCoverageSink,\n getUnreportedCoverageFiles,\n markCoverageFilesReported,\n} from \"./runtime\";\n\n/**\n * The replay-mode half of line coverage: giving a request a sink, and reporting\n * what it marked. The marking itself is `runtime.ts`, which the instrumented\n * bundle calls directly.\n */\n\n/**\n * Whether the sidecar has told us it will never take coverage (404 on the route: an older\n * sidecar, or one started without coverage enabled). Latched per isolate so a replay against\n * such a sidecar stops allocating sinks and serialising hits after the first report.\n */\nlet coverageDisabled = false;\n\n/**\n * Runs the handler with a per-request coverage sink, reporting what it marked once the response\n * exists.\n *\n * The sink is per-request rather than per-isolate because workerd interleaves concurrent\n * requests, and every replayed request belongs to a different session — a shared sink would\n * merge their coverage. Reporting goes through `waitUntil` so serialising the hits never delays\n * the response, and every failure path leaves the response untouched: coverage is strictly\n * best-effort.\n */\nexport const runReplayWithCoverage = async (\n replayContext: RequestReplayContext,\n invokeHandler: () => Response | Promise<Response>,\n): Promise<Response> => {\n if (coverageDisabled) {\n return requestCaptureContext.run(replayContext, invokeHandler);\n }\n\n let sink: Uint8Array | undefined;\n try {\n sink = createCoverageSink();\n } catch (error) {\n warnOnce(\"coverage-sink\", \"Could not allocate a coverage sink.\", error);\n }\n if (sink === undefined) {\n // Allocation threw — run the request uncovered rather than failing it.\n return requestCaptureContext.run(replayContext, invokeHandler);\n }\n\n const contextWithCoverage: RequestReplayContext = {\n ...replayContext,\n coverage: sink,\n };\n try {\n return await requestCaptureContext.run(contextWithCoverage, invokeHandler);\n } finally {\n reportCoverage(contextWithCoverage);\n }\n};\n\n/**\n * Sends the hits, plus any id→line map the sidecar has not acknowledged yet.\n *\n * The map goes out incrementally rather than once per isolate because a\n * code-split app registers a module only when its chunk is first imported: a\n * route's server module usually registers on the request that first calls it,\n * after earlier requests have already reported. Without the delta its hits would\n * arrive against ids no file describes, and be dropped by the post-process.\n */\nconst reportCoverage = (context: RequestReplayContext): void => {\n try {\n // Read the sink off the context rather than closing over the one allocated at\n // request start: a module imported while the request ran will have grown it.\n const sink = context.coverage;\n if (sink === undefined) {\n return;\n }\n const hitIds = collectHitIds(sink);\n const files = getUnreportedCoverageFiles();\n if (hitIds.length === 0 && files.length === 0) {\n return;\n }\n context.waitUntil(\n postCoverageReport(getOriginalFetch(), context.sidecarUrl, {\n frontendSessionId: context.frontendSessionId,\n hitIds,\n ...(files.length > 0 ? { files } : {}),\n }).then((outcome) => {\n if (outcome === \"unsupported\") {\n coverageDisabled = true;\n return;\n }\n if (outcome === \"accepted\") {\n markCoverageFilesReported(files);\n }\n }),\n );\n } catch (error) {\n warnOnce(\"coverage-report\", \"Failed to report line coverage.\", error);\n }\n};\n"],"
|
|
1
|
+
{"version":3,"file":"replay-coverage.js","names":[],"sources":["../../src/coverage/replay-coverage.ts"],"sourcesContent":["import { type RequestReplayContext, requestCaptureContext } from \"../context\";\nimport { warnOnce } from \"../log\";\nimport { getOriginalFetch } from \"../original-fetch\";\nimport { postCoverageReport } from \"../sidecar-client\";\nimport {\n collectHitIds,\n createCoverageSink,\n getUnreportedCoverageFiles,\n markCoverageFilesReported,\n} from \"./runtime\";\n\n/**\n * The replay-mode half of line coverage: giving a request a sink, and reporting\n * what it marked. The marking itself is `runtime.ts`, which the instrumented\n * bundle calls directly.\n */\n\n/**\n * Whether the sidecar has told us it will never take coverage (404 on the route: an older\n * sidecar, or one started without coverage enabled). Latched per isolate so a replay against\n * such a sidecar stops allocating sinks and serialising hits after the first report.\n */\nlet coverageDisabled = false;\n\n/**\n * Runs the handler with a per-request coverage sink, reporting what it marked once the response\n * exists.\n *\n * The sink is per-request rather than per-isolate because workerd interleaves concurrent\n * requests, and every replayed request belongs to a different session — a shared sink would\n * merge their coverage. Reporting goes through `waitUntil` so serialising the hits never delays\n * the response, and every failure path leaves the response untouched: coverage is strictly\n * best-effort.\n */\nexport const runReplayWithCoverage = async (\n replayContext: RequestReplayContext,\n invokeHandler: () => Response | Promise<Response>,\n): Promise<Response> => {\n if (coverageDisabled) {\n return requestCaptureContext.run(replayContext, invokeHandler);\n }\n\n let sink: Uint8Array | undefined;\n try {\n sink = createCoverageSink();\n } catch (error) {\n warnOnce(\"coverage-sink\", \"Could not allocate a coverage sink.\", error);\n }\n if (sink === undefined) {\n // Allocation threw — run the request uncovered rather than failing it.\n return requestCaptureContext.run(replayContext, invokeHandler);\n }\n\n const contextWithCoverage: RequestReplayContext = {\n ...replayContext,\n coverage: sink,\n };\n try {\n return await requestCaptureContext.run(contextWithCoverage, invokeHandler);\n } finally {\n reportCoverage(contextWithCoverage);\n }\n};\n\n/**\n * Sends the hits, plus any id→line map the sidecar has not acknowledged yet.\n *\n * The map goes out incrementally rather than once per isolate because a\n * code-split app registers a module only when its chunk is first imported: a\n * route's server module usually registers on the request that first calls it,\n * after earlier requests have already reported. Without the delta its hits would\n * arrive against ids no file describes, and be dropped by the post-process.\n */\nconst reportCoverage = (context: RequestReplayContext): void => {\n try {\n // Read the sink off the context rather than closing over the one allocated at\n // request start: a module imported while the request ran will have grown it.\n const sink = context.coverage;\n if (sink === undefined) {\n return;\n }\n const hitIds = collectHitIds(sink);\n const files = getUnreportedCoverageFiles();\n if (hitIds.length === 0 && files.length === 0) {\n return;\n }\n context.waitUntil(\n postCoverageReport(getOriginalFetch(), context.sidecarUrl, {\n frontendSessionId: context.frontendSessionId,\n hitIds,\n ...(files.length > 0 ? { files } : {}),\n }).then((outcome) => {\n if (outcome === \"unsupported\") {\n coverageDisabled = true;\n return;\n }\n if (outcome === \"accepted\") {\n markCoverageFilesReported(files);\n }\n }),\n );\n } catch (error) {\n warnOnce(\"coverage-report\", \"Failed to report line coverage.\", error);\n }\n};\n"],"mappings":";;;;;;;;;;;;;;;;AAsBA,IAAI,mBAAmB;;;;;;;;;;;AAYvB,MAAa,wBAAwB,OACnC,eACA,kBACsB;AACtB,KAAI,iBACF,QAAO,sBAAsB,IAAI,eAAe,cAAc;CAGhE,IAAI;AACJ,KAAI;AACF,SAAO,oBAAoB;UACpB,OAAO;AACd,WAAS,iBAAiB,uCAAuC,MAAM;;AAEzE,KAAI,SAAS,KAAA,EAEX,QAAO,sBAAsB,IAAI,eAAe,cAAc;CAGhE,MAAM,sBAA4C;EAChD,GAAG;EACH,UAAU;EACX;AACD,KAAI;AACF,SAAO,MAAM,sBAAsB,IAAI,qBAAqB,cAAc;WAClE;AACR,iBAAe,oBAAoB;;;;;;;;;;;;AAavC,MAAM,kBAAkB,YAAwC;AAC9D,KAAI;EAGF,MAAM,OAAO,QAAQ;AACrB,MAAI,SAAS,KAAA,EACX;EAEF,MAAM,SAAS,cAAc,KAAK;EAClC,MAAM,QAAQ,4BAA4B;AAC1C,MAAI,OAAO,WAAW,KAAK,MAAM,WAAW,EAC1C;AAEF,UAAQ,UACN,mBAAmB,kBAAkB,EAAE,QAAQ,YAAY;GACzD,mBAAmB,QAAQ;GAC3B;GACA,GAAI,MAAM,SAAS,IAAI,EAAE,OAAO,GAAG,EAAE;GACtC,CAAC,CAAC,MAAM,YAAY;AACnB,OAAI,YAAY,eAAe;AAC7B,uBAAmB;AACnB;;AAEF,OAAI,YAAY,WACd,2BAA0B,MAAM;IAElC,CACH;UACM,OAAO;AACd,WAAS,mBAAmB,mCAAmC,MAAM"}
|
package/dist/coverage/runtime.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="a559d75e-b978-551b-aaf6-c1a5a06e7cfd")}catch(e){}}();
|
|
3
1
|
import { requestCaptureContext } from "../context.js";
|
|
4
2
|
//#region src/coverage/runtime.ts
|
|
5
3
|
/**
|
|
@@ -134,5 +132,4 @@ const collectHitIds = (sink) => {
|
|
|
134
132
|
//#endregion
|
|
135
133
|
export { __mcEnter, __mcHit, collectHitIds, createCoverageSink, getCoverageMap, getUnreportedCoverageFiles, markCoverageFilesReported, registerCoverageFile };
|
|
136
134
|
|
|
137
|
-
//# sourceMappingURL=runtime.js.map
|
|
138
|
-
//# debugId=a559d75e-b978-551b-aaf6-c1a5a06e7cfd
|
|
135
|
+
//# sourceMappingURL=runtime.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime.js","sources":["../../src/coverage/runtime.ts"],"sourcesContent":["import { requestCaptureContext } from \"../context\";\nimport type { CoverageMap, CoverageMapFile } from \"./coverage.types\";\n\n/**\n * The in-worker half of build-time line coverage.\n *\n * The Meticulous Vite plugin rewrites the app's modules so each line marks\n * itself against a sink, and injects a manifest module that calls\n * {@link registerCoverageMap} once per isolate. Everything else here exists to\n * keep the hot path to a single array store.\n *\n * The sink lives on the per-request AsyncLocalStorage store rather than in a\n * module-scope global, which is the whole reason this can attribute coverage at\n * all: workerd interleaves concurrent requests in one isolate, so a shared\n * global would mix sessions together — the same over-attribution that makes\n * workerd's `getBestEffortCoverage` useless for this.\n *\n * Both entry points are no-ops when no sink is present (recording, an\n * uninstrumented request, module-level code running at isolate init), so an\n * instrumented bundle is safe to run with coverage off.\n */\n\nconst files: CoverageMapFile[] = [];\nconst registeredKeys = new Set<string>();\nconst reportedKeys = new Set<string>();\nlet totalIds = 0;\n\nconst fileKey = (file: CoverageMapFile): string =>\n `${file.path}#${file.firstId}`;\n\n/**\n * Registers one module's slice of the id space, as a side effect of that module\n * being evaluated.\n *\n * Per-module rather than one generated manifest because the full map is not\n * known until every module has been transformed, and a manifest module would be\n * loaded by the bundler as soon as the *first* instrumented module imported it.\n * Self-registration sidesteps that ordering entirely.\n *\n * Deduplicated by path+id so a bundler that evaluates a module twice cannot\n * inflate the id space.\n */\nexport const registerCoverageFile = (file: CoverageMapFile): void => {\n const key = fileKey(file);\n if (registeredKeys.has(key)) {\n return;\n }\n registeredKeys.add(key);\n files.push(file);\n const idsInFile = file.lineRanges.length / 2;\n totalIds = Math.max(totalIds, file.firstId + idsInFile);\n growCurrentRequestSink();\n};\n\n/**\n * Extends the in-flight request's sink to cover the ids just registered.\n *\n * Registration happens as a module is evaluated, which for a code-split app is\n * inside whichever request first imported its chunk — usually the one about to\n * execute it. The sink was sized before that, so without this the request that\n * brings a module in is the one request whose coverage of it is lost, and on a\n * cold isolate that is the session's very first render.\n *\n * Replacing the array is safe because instrumented code re-reads the sink at\n * every function entry: only a function already executing keeps the old one, and\n * module evaluation itself carries no markers.\n */\nconst growCurrentRequestSink = (): void => {\n const store = requestCaptureContext.getStore();\n if (store === undefined) {\n return;\n }\n const sink = store.coverage;\n if (sink === undefined || sink.length >= totalIds) {\n return;\n }\n const grown = new Uint8Array(totalIds);\n grown.set(sink);\n store.coverage = grown;\n};\n\n/** The whole registered id space. Exposed for debugging an instrumented build. */\nexport const getCoverageMap = (): CoverageMap => ({ totalIds, files });\n\n/**\n * The registered files the sidecar has not acknowledged yet.\n *\n * Reported per request rather than once per isolate because a code-split app\n * registers modules as their chunks are first imported — a route's server module\n * typically registers on the request that first calls into it, long after the\n * isolate's first report. Sending the map only once would leave every one of\n * that module's hits unresolvable, which is coverage silently lost rather than\n * coverage reported as missing.\n */\nexport const getUnreportedCoverageFiles = (): CoverageMapFile[] =>\n files.filter((file) => !reportedKeys.has(fileKey(file)));\n\n/**\n * Marks files as delivered. Called only once the sidecar has accepted them, so a\n * report lost to a timeout or a 5xx goes out again with the next one — the\n * sidecar keeps the first map it sees for an id block, so a dropped map is\n * permanent otherwise.\n */\nexport const markCoverageFilesReported = (\n reported: readonly CoverageMapFile[],\n): void => {\n for (const file of reported) {\n reportedKeys.add(fileKey(file));\n }\n};\n\n/**\n * Resolves the current request's sink. Called once per function invocation by\n * instrumented code, which then stores into the returned array directly.\n */\nexport const __mcEnter = (): Uint8Array | undefined =>\n requestCaptureContext.getStore()?.coverage;\n\n/**\n * Marks one line hit. Used only for concise arrow bodies (`() => expr`), which\n * have no block in which to hoist the sink lookup.\n */\nexport const __mcHit = (id: number): void => {\n const sink = requestCaptureContext.getStore()?.coverage;\n if (sink !== undefined && id < sink.length) {\n sink[id] = 1;\n }\n};\n\n/**\n * Allocates a request's sink, sized to the id space registered so far — which on\n * a cold isolate is empty, since a code-split app registers nothing until its\n * chunks are imported. An empty sink is deliberately not the same as \"no\n * instrumentation\": {@link registerCoverageFile} grows it as modules arrive, so a\n * request that starts with nothing registered still collects what it loads.\n *\n * Never undefined, so an uninstrumented bundle costs one zero-length allocation\n * per request and reports nothing, rather than needing a per-isolate latch that\n * cannot tell \"no instrumentation\" from \"not loaded yet\".\n */\nexport const createCoverageSink = (): Uint8Array => new Uint8Array(totalIds);\n\n/**\n * The ids marked during a request.\n *\n * Scanning the whole sink costs one pass over the id space per request — for a\n * large app a few hundred thousand array reads, well under a millisecond, and\n * paid once rather than on every line hit. The alternative (a push-list beside\n * the bitmap) would add a branch to the hot path to save that single pass.\n */\nexport const collectHitIds = (sink: Uint8Array): number[] => {\n const hits: number[] = [];\n for (let id = 0; id < sink.length; id++) {\n if (sink[id] !== 0) {\n hits.push(id);\n }\n }\n return hits;\n};\n"],"
|
|
1
|
+
{"version":3,"file":"runtime.js","names":[],"sources":["../../src/coverage/runtime.ts"],"sourcesContent":["import { requestCaptureContext } from \"../context\";\nimport type { CoverageMap, CoverageMapFile } from \"./coverage.types\";\n\n/**\n * The in-worker half of build-time line coverage.\n *\n * The Meticulous Vite plugin rewrites the app's modules so each line marks\n * itself against a sink, and injects a manifest module that calls\n * {@link registerCoverageMap} once per isolate. Everything else here exists to\n * keep the hot path to a single array store.\n *\n * The sink lives on the per-request AsyncLocalStorage store rather than in a\n * module-scope global, which is the whole reason this can attribute coverage at\n * all: workerd interleaves concurrent requests in one isolate, so a shared\n * global would mix sessions together — the same over-attribution that makes\n * workerd's `getBestEffortCoverage` useless for this.\n *\n * Both entry points are no-ops when no sink is present (recording, an\n * uninstrumented request, module-level code running at isolate init), so an\n * instrumented bundle is safe to run with coverage off.\n */\n\nconst files: CoverageMapFile[] = [];\nconst registeredKeys = new Set<string>();\nconst reportedKeys = new Set<string>();\nlet totalIds = 0;\n\nconst fileKey = (file: CoverageMapFile): string =>\n `${file.path}#${file.firstId}`;\n\n/**\n * Registers one module's slice of the id space, as a side effect of that module\n * being evaluated.\n *\n * Per-module rather than one generated manifest because the full map is not\n * known until every module has been transformed, and a manifest module would be\n * loaded by the bundler as soon as the *first* instrumented module imported it.\n * Self-registration sidesteps that ordering entirely.\n *\n * Deduplicated by path+id so a bundler that evaluates a module twice cannot\n * inflate the id space.\n */\nexport const registerCoverageFile = (file: CoverageMapFile): void => {\n const key = fileKey(file);\n if (registeredKeys.has(key)) {\n return;\n }\n registeredKeys.add(key);\n files.push(file);\n const idsInFile = file.lineRanges.length / 2;\n totalIds = Math.max(totalIds, file.firstId + idsInFile);\n growCurrentRequestSink();\n};\n\n/**\n * Extends the in-flight request's sink to cover the ids just registered.\n *\n * Registration happens as a module is evaluated, which for a code-split app is\n * inside whichever request first imported its chunk — usually the one about to\n * execute it. The sink was sized before that, so without this the request that\n * brings a module in is the one request whose coverage of it is lost, and on a\n * cold isolate that is the session's very first render.\n *\n * Replacing the array is safe because instrumented code re-reads the sink at\n * every function entry: only a function already executing keeps the old one, and\n * module evaluation itself carries no markers.\n */\nconst growCurrentRequestSink = (): void => {\n const store = requestCaptureContext.getStore();\n if (store === undefined) {\n return;\n }\n const sink = store.coverage;\n if (sink === undefined || sink.length >= totalIds) {\n return;\n }\n const grown = new Uint8Array(totalIds);\n grown.set(sink);\n store.coverage = grown;\n};\n\n/** The whole registered id space. Exposed for debugging an instrumented build. */\nexport const getCoverageMap = (): CoverageMap => ({ totalIds, files });\n\n/**\n * The registered files the sidecar has not acknowledged yet.\n *\n * Reported per request rather than once per isolate because a code-split app\n * registers modules as their chunks are first imported — a route's server module\n * typically registers on the request that first calls into it, long after the\n * isolate's first report. Sending the map only once would leave every one of\n * that module's hits unresolvable, which is coverage silently lost rather than\n * coverage reported as missing.\n */\nexport const getUnreportedCoverageFiles = (): CoverageMapFile[] =>\n files.filter((file) => !reportedKeys.has(fileKey(file)));\n\n/**\n * Marks files as delivered. Called only once the sidecar has accepted them, so a\n * report lost to a timeout or a 5xx goes out again with the next one — the\n * sidecar keeps the first map it sees for an id block, so a dropped map is\n * permanent otherwise.\n */\nexport const markCoverageFilesReported = (\n reported: readonly CoverageMapFile[],\n): void => {\n for (const file of reported) {\n reportedKeys.add(fileKey(file));\n }\n};\n\n/**\n * Resolves the current request's sink. Called once per function invocation by\n * instrumented code, which then stores into the returned array directly.\n */\nexport const __mcEnter = (): Uint8Array | undefined =>\n requestCaptureContext.getStore()?.coverage;\n\n/**\n * Marks one line hit. Used only for concise arrow bodies (`() => expr`), which\n * have no block in which to hoist the sink lookup.\n */\nexport const __mcHit = (id: number): void => {\n const sink = requestCaptureContext.getStore()?.coverage;\n if (sink !== undefined && id < sink.length) {\n sink[id] = 1;\n }\n};\n\n/**\n * Allocates a request's sink, sized to the id space registered so far — which on\n * a cold isolate is empty, since a code-split app registers nothing until its\n * chunks are imported. An empty sink is deliberately not the same as \"no\n * instrumentation\": {@link registerCoverageFile} grows it as modules arrive, so a\n * request that starts with nothing registered still collects what it loads.\n *\n * Never undefined, so an uninstrumented bundle costs one zero-length allocation\n * per request and reports nothing, rather than needing a per-isolate latch that\n * cannot tell \"no instrumentation\" from \"not loaded yet\".\n */\nexport const createCoverageSink = (): Uint8Array => new Uint8Array(totalIds);\n\n/**\n * The ids marked during a request.\n *\n * Scanning the whole sink costs one pass over the id space per request — for a\n * large app a few hundred thousand array reads, well under a millisecond, and\n * paid once rather than on every line hit. The alternative (a push-list beside\n * the bitmap) would add a branch to the hot path to save that single pass.\n */\nexport const collectHitIds = (sink: Uint8Array): number[] => {\n const hits: number[] = [];\n for (let id = 0; id < sink.length; id++) {\n if (sink[id] !== 0) {\n hits.push(id);\n }\n }\n return hits;\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAsBA,MAAM,QAA2B,EAAE;AACnC,MAAM,iCAAiB,IAAI,KAAa;AACxC,MAAM,+BAAe,IAAI,KAAa;AACtC,IAAI,WAAW;AAEf,MAAM,WAAW,SACf,GAAG,KAAK,KAAK,GAAG,KAAK;;;;;;;;;;;;;AAcvB,MAAa,wBAAwB,SAAgC;CACnE,MAAM,MAAM,QAAQ,KAAK;AACzB,KAAI,eAAe,IAAI,IAAI,CACzB;AAEF,gBAAe,IAAI,IAAI;AACvB,OAAM,KAAK,KAAK;CAChB,MAAM,YAAY,KAAK,WAAW,SAAS;AAC3C,YAAW,KAAK,IAAI,UAAU,KAAK,UAAU,UAAU;AACvD,yBAAwB;;;;;;;;;;;;;;;AAgB1B,MAAM,+BAAqC;CACzC,MAAM,QAAQ,sBAAsB,UAAU;AAC9C,KAAI,UAAU,KAAA,EACZ;CAEF,MAAM,OAAO,MAAM;AACnB,KAAI,SAAS,KAAA,KAAa,KAAK,UAAU,SACvC;CAEF,MAAM,QAAQ,IAAI,WAAW,SAAS;AACtC,OAAM,IAAI,KAAK;AACf,OAAM,WAAW;;;AAInB,MAAa,wBAAqC;CAAE;CAAU;CAAO;;;;;;;;;;;AAYrE,MAAa,mCACX,MAAM,QAAQ,SAAS,CAAC,aAAa,IAAI,QAAQ,KAAK,CAAC,CAAC;;;;;;;AAQ1D,MAAa,6BACX,aACS;AACT,MAAK,MAAM,QAAQ,SACjB,cAAa,IAAI,QAAQ,KAAK,CAAC;;;;;;AAQnC,MAAa,kBACX,sBAAsB,UAAU,EAAE;;;;;AAMpC,MAAa,WAAW,OAAqB;CAC3C,MAAM,OAAO,sBAAsB,UAAU,EAAE;AAC/C,KAAI,SAAS,KAAA,KAAa,KAAK,KAAK,OAClC,MAAK,MAAM;;;;;;;;;;;;;AAef,MAAa,2BAAuC,IAAI,WAAW,SAAS;;;;;;;;;AAU5E,MAAa,iBAAiB,SAA+B;CAC3D,MAAM,OAAiB,EAAE;AACzB,MAAK,IAAI,KAAK,GAAG,KAAK,KAAK,QAAQ,KACjC,KAAI,KAAK,QAAQ,EACf,MAAK,KAAK,GAAG;AAGjB,QAAO"}
|
package/dist/error-capture.js
CHANGED
|
@@ -27,8 +27,6 @@
|
|
|
27
27
|
* A non-`Error` throw (a string, an object) is captured by its `String()` form, which is what a
|
|
28
28
|
* consumer would have seen from a `catch` that assumed an `Error`.
|
|
29
29
|
*/
|
|
30
|
-
|
|
31
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="543df2da-8d0b-579f-8a26-5392d803794b")}catch(e){}}();
|
|
32
30
|
const serializeCapturedError = (error, omitProps) => {
|
|
33
31
|
if (!(error instanceof Error)) return JSON.stringify({
|
|
34
32
|
name: "Error",
|
|
@@ -61,5 +59,4 @@ const deserializeCapturedError = (json, { unparseableMessage, fallbackMessage })
|
|
|
61
59
|
//#endregion
|
|
62
60
|
export { deserializeCapturedError, serializeCapturedError };
|
|
63
61
|
|
|
64
|
-
//# sourceMappingURL=error-capture.js.map
|
|
65
|
-
//# debugId=543df2da-8d0b-579f-8a26-5392d803794b
|
|
62
|
+
//# sourceMappingURL=error-capture.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error-capture.js","sources":["../src/error-capture.ts"],"sourcesContent":["/**\n * Capturing and reconstructing a thrown error, shared by every instrumentation that replays\n * failures rather than treating them as \"nothing was recorded\".\n *\n * Recording the error matters wherever the app's own behaviour depends on it: a call that\n * legitimately failed during recording (a constraint violation, a rejected payment) must replay\n * as the same failure, not as a miss. An uncaptured error replays as a hermetic \"no recorded\n * result\", which renders a different page and reads as a product change.\n *\n * What survives: `name`, `message` and every own enumerable property — which is where a JS\n * error carries the fields consumers actually branch on (`code`, `status`, `severity`). What\n * does not: the class. Reconstruction returns a plain `Error`, so `error.name ===\n * \"PostgresError\"` and `error.code` behave as recorded but `instanceof` does not match.\n *\n * It lives in this package, rather than in the Node recorder, because the workerd postgres.js\n * capture has to produce byte-identical JSON to the Node one — the same reason `kv-capture.ts`\n * is here. `packages/backend-recorder-js/src/error-capture-shared.ts` re-exports both halves.\n */\n\n/**\n * Serializes a thrown value to a JSON string.\n *\n * `omitProps` names own properties to leave out — large debug payloads, or values that are\n * redundant with attributes the span already carries. `stack` is almost always in it: it is\n * recorded-process-specific and would be misleading replayed into another process.\n *\n * A non-`Error` throw (a string, an object) is captured by its `String()` form, which is what a\n * consumer would have seen from a `catch` that assumed an `Error`.\n */\nexport const serializeCapturedError = (\n error: unknown,\n omitProps: ReadonlySet<string>,\n): string => {\n if (!(error instanceof Error)) {\n return JSON.stringify({ name: \"Error\", message: String(error) });\n }\n const fields: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(error)) {\n if (!omitProps.has(key)) {\n fields[key] = value;\n }\n }\n // `name`/`message` last so an own property of either name cannot shadow the real one.\n return JSON.stringify({\n ...fields,\n name: error.name,\n message: error.message,\n });\n};\n\n/**\n * Reconstructs a captured error. Both message fallbacks are the caller's, because they surface\n * in the app's own error handling and should name the technology that failed.\n */\nexport const deserializeCapturedError = (\n json: string,\n {\n unparseableMessage,\n fallbackMessage,\n }: { unparseableMessage: string; fallbackMessage: string },\n): Error => {\n let parsed: Record<string, unknown>;\n try {\n parsed = JSON.parse(json) as Record<string, unknown>;\n } catch {\n return new Error(unparseableMessage);\n }\n const { message, name, ...fields } = parsed;\n const error = Object.assign(\n new Error(typeof message === \"string\" ? message : fallbackMessage),\n fields,\n );\n if (typeof name === \"string\") {\n error.name = name;\n }\n return error;\n};\n"],"
|
|
1
|
+
{"version":3,"file":"error-capture.js","names":[],"sources":["../src/error-capture.ts"],"sourcesContent":["/**\n * Capturing and reconstructing a thrown error, shared by every instrumentation that replays\n * failures rather than treating them as \"nothing was recorded\".\n *\n * Recording the error matters wherever the app's own behaviour depends on it: a call that\n * legitimately failed during recording (a constraint violation, a rejected payment) must replay\n * as the same failure, not as a miss. An uncaptured error replays as a hermetic \"no recorded\n * result\", which renders a different page and reads as a product change.\n *\n * What survives: `name`, `message` and every own enumerable property — which is where a JS\n * error carries the fields consumers actually branch on (`code`, `status`, `severity`). What\n * does not: the class. Reconstruction returns a plain `Error`, so `error.name ===\n * \"PostgresError\"` and `error.code` behave as recorded but `instanceof` does not match.\n *\n * It lives in this package, rather than in the Node recorder, because the workerd postgres.js\n * capture has to produce byte-identical JSON to the Node one — the same reason `kv-capture.ts`\n * is here. `packages/backend-recorder-js/src/error-capture-shared.ts` re-exports both halves.\n */\n\n/**\n * Serializes a thrown value to a JSON string.\n *\n * `omitProps` names own properties to leave out — large debug payloads, or values that are\n * redundant with attributes the span already carries. `stack` is almost always in it: it is\n * recorded-process-specific and would be misleading replayed into another process.\n *\n * A non-`Error` throw (a string, an object) is captured by its `String()` form, which is what a\n * consumer would have seen from a `catch` that assumed an `Error`.\n */\nexport const serializeCapturedError = (\n error: unknown,\n omitProps: ReadonlySet<string>,\n): string => {\n if (!(error instanceof Error)) {\n return JSON.stringify({ name: \"Error\", message: String(error) });\n }\n const fields: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(error)) {\n if (!omitProps.has(key)) {\n fields[key] = value;\n }\n }\n // `name`/`message` last so an own property of either name cannot shadow the real one.\n return JSON.stringify({\n ...fields,\n name: error.name,\n message: error.message,\n });\n};\n\n/**\n * Reconstructs a captured error. Both message fallbacks are the caller's, because they surface\n * in the app's own error handling and should name the technology that failed.\n */\nexport const deserializeCapturedError = (\n json: string,\n {\n unparseableMessage,\n fallbackMessage,\n }: { unparseableMessage: string; fallbackMessage: string },\n): Error => {\n let parsed: Record<string, unknown>;\n try {\n parsed = JSON.parse(json) as Record<string, unknown>;\n } catch {\n return new Error(unparseableMessage);\n }\n const { message, name, ...fields } = parsed;\n const error = Object.assign(\n new Error(typeof message === \"string\" ? message : fallbackMessage),\n fields,\n );\n if (typeof name === \"string\") {\n error.name = name;\n }\n return error;\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,MAAa,0BACX,OACA,cACW;AACX,KAAI,EAAE,iBAAiB,OACrB,QAAO,KAAK,UAAU;EAAE,MAAM;EAAS,SAAS,OAAO,MAAM;EAAE,CAAC;CAElE,MAAM,SAAkC,EAAE;AAC1C,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,CAC9C,KAAI,CAAC,UAAU,IAAI,IAAI,CACrB,QAAO,OAAO;AAIlB,QAAO,KAAK,UAAU;EACpB,GAAG;EACH,MAAM,MAAM;EACZ,SAAS,MAAM;EAChB,CAAC;;;;;;AAOJ,MAAa,4BACX,MACA,EACE,oBACA,sBAEQ;CACV,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,KAAK;SACnB;AACN,SAAO,IAAI,MAAM,mBAAmB;;CAEtC,MAAM,EAAE,SAAS,MAAM,GAAG,WAAW;CACrC,MAAM,QAAQ,OAAO,OACnB,IAAI,MAAM,OAAO,YAAY,WAAW,UAAU,gBAAgB,EAClE,OACD;AACD,KAAI,OAAO,SAAS,SAClB,OAAM,OAAO;AAEf,QAAO"}
|
package/dist/fetch-patch.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="006811cf-e9b2-5a53-b257-639036ac8912")}catch(e){}}();
|
|
3
1
|
import { requestCaptureContext, sidecarOriginOf } from "./context.js";
|
|
4
2
|
import { captureOutboundCall } from "./outbound-capture.js";
|
|
5
3
|
import { getOriginalFetch, setOriginalFetch } from "./original-fetch.js";
|
|
@@ -41,5 +39,4 @@ const patchedFetch = async (input, init) => {
|
|
|
41
39
|
//#endregion
|
|
42
40
|
export { installFetchPatch };
|
|
43
41
|
|
|
44
|
-
//# sourceMappingURL=fetch-patch.js.map
|
|
45
|
-
//# debugId=006811cf-e9b2-5a53-b257-639036ac8912
|
|
42
|
+
//# sourceMappingURL=fetch-patch.js.map
|
package/dist/fetch-patch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch-patch.js","sources":["../src/fetch-patch.ts"],"sourcesContent":["import { requestCaptureContext, sidecarOriginOf } from \"./context\";\nimport { getOriginalFetch, setOriginalFetch } from \"./original-fetch\";\nimport { captureOutboundCall } from \"./outbound-capture\";\nimport { replayOutboundCall } from \"./replay-fetch\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst FETCH_PATCHED = Symbol.for(\"meticulous.workerd.fetchPatched\");\n\nexport { getOriginalFetch } from \"./original-fetch\";\nexport { headersToRecord } from \"./outbound-capture\";\n\n/**\n * Replaces globalThis.fetch with the mode-appropriate interception:\n *\n * - recording: a passive capture tee. The real fetch is called directly (the sidecar is\n * never in the request path); request and response are cloned and reported in the\n * background.\n * - replaying: decide-then-serve. The call is looked up against the sidecar's mock store\n * and a recorded response is synthesised, or the call fails — replay is hermetic unless\n * the request opts out with the `meticulous-passthrough` header.\n *\n * Outside a withMeticulous request context the patch is a pure pass-through. Idempotent.\n */\nexport const installFetchPatch = (): void => {\n const holder = globalThis as { [FETCH_PATCHED]?: boolean };\n if (holder[FETCH_PATCHED]) {\n return;\n }\n holder[FETCH_PATCHED] = true;\n setOriginalFetch(globalThis.fetch.bind(globalThis));\n globalThis.fetch = patchedFetch;\n};\n\nconst patchedFetch: FetchFn = async (input, init) => {\n const original = getOriginalFetch();\n const ctx = requestCaptureContext.getStore();\n if (!ctx) {\n return original(input, init);\n }\n\n // Normalize to a single Request. This may consume `input`'s body (when\n // `input` is a Request), so from here on the real fetch must be given\n // `request`, never the original arguments.\n let request: Request;\n try {\n request = new Request(input, init);\n } catch {\n // Unconstructable arguments — let the real fetch produce the error.\n return original(input, init);\n }\n\n // Never intercept the shim's own traffic to the sidecar. Defence in depth: every\n // shim → sidecar call already goes through the unpatched fetch, or a service binding.\n if (request.url.startsWith(`${sidecarOriginOf(ctx)}/`)) {\n return original(request);\n }\n\n return ctx.mode === \"replay\"\n ? replayOutboundCall(ctx, request, (req) => original(req))\n : captureOutboundCall(ctx, { kind: \"outbound\" }, request, (req) =>\n original(req),\n );\n};\n"],"
|
|
1
|
+
{"version":3,"file":"fetch-patch.js","names":[],"sources":["../src/fetch-patch.ts"],"sourcesContent":["import { requestCaptureContext, sidecarOriginOf } from \"./context\";\nimport { getOriginalFetch, setOriginalFetch } from \"./original-fetch\";\nimport { captureOutboundCall } from \"./outbound-capture\";\nimport { replayOutboundCall } from \"./replay-fetch\";\n\ntype FetchFn = typeof globalThis.fetch;\n\n// Symbol.for so a second copy of the shim (e.g. bundled twice) still detects the patch.\nconst FETCH_PATCHED = Symbol.for(\"meticulous.workerd.fetchPatched\");\n\nexport { getOriginalFetch } from \"./original-fetch\";\nexport { headersToRecord } from \"./outbound-capture\";\n\n/**\n * Replaces globalThis.fetch with the mode-appropriate interception:\n *\n * - recording: a passive capture tee. The real fetch is called directly (the sidecar is\n * never in the request path); request and response are cloned and reported in the\n * background.\n * - replaying: decide-then-serve. The call is looked up against the sidecar's mock store\n * and a recorded response is synthesised, or the call fails — replay is hermetic unless\n * the request opts out with the `meticulous-passthrough` header.\n *\n * Outside a withMeticulous request context the patch is a pure pass-through. Idempotent.\n */\nexport const installFetchPatch = (): void => {\n const holder = globalThis as { [FETCH_PATCHED]?: boolean };\n if (holder[FETCH_PATCHED]) {\n return;\n }\n holder[FETCH_PATCHED] = true;\n setOriginalFetch(globalThis.fetch.bind(globalThis));\n globalThis.fetch = patchedFetch;\n};\n\nconst patchedFetch: FetchFn = async (input, init) => {\n const original = getOriginalFetch();\n const ctx = requestCaptureContext.getStore();\n if (!ctx) {\n return original(input, init);\n }\n\n // Normalize to a single Request. This may consume `input`'s body (when\n // `input` is a Request), so from here on the real fetch must be given\n // `request`, never the original arguments.\n let request: Request;\n try {\n request = new Request(input, init);\n } catch {\n // Unconstructable arguments — let the real fetch produce the error.\n return original(input, init);\n }\n\n // Never intercept the shim's own traffic to the sidecar. Defence in depth: every\n // shim → sidecar call already goes through the unpatched fetch, or a service binding.\n if (request.url.startsWith(`${sidecarOriginOf(ctx)}/`)) {\n return original(request);\n }\n\n return ctx.mode === \"replay\"\n ? replayOutboundCall(ctx, request, (req) => original(req))\n : captureOutboundCall(ctx, { kind: \"outbound\" }, request, (req) =>\n original(req),\n );\n};\n"],"mappings":";;;;;AAQA,MAAM,gBAAgB,OAAO,IAAI,kCAAkC;;;;;;;;;;;;;AAiBnE,MAAa,0BAAgC;CAC3C,MAAM,SAAS;AACf,KAAI,OAAO,eACT;AAEF,QAAO,iBAAiB;AACxB,kBAAiB,WAAW,MAAM,KAAK,WAAW,CAAC;AACnD,YAAW,QAAQ;;AAGrB,MAAM,eAAwB,OAAO,OAAO,SAAS;CACnD,MAAM,WAAW,kBAAkB;CACnC,MAAM,MAAM,sBAAsB,UAAU;AAC5C,KAAI,CAAC,IACH,QAAO,SAAS,OAAO,KAAK;CAM9B,IAAI;AACJ,KAAI;AACF,YAAU,IAAI,QAAQ,OAAO,KAAK;SAC5B;AAEN,SAAO,SAAS,OAAO,KAAK;;AAK9B,KAAI,QAAQ,IAAI,WAAW,GAAG,gBAAgB,IAAI,CAAC,GAAG,CACpD,QAAO,SAAS,QAAQ;AAG1B,QAAO,IAAI,SAAS,WAChB,mBAAmB,KAAK,UAAU,QAAQ,SAAS,IAAI,CAAC,GACxD,oBAAoB,KAAK,EAAE,MAAM,YAAY,EAAE,UAAU,QACvD,SAAS,IAAI,CACd"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="d20456bc-894a-5ea9-8719-58b43192282e")}catch(e){}}();
|
|
3
1
|
import { SIDECAR_BINDING_ENV_KEY, SIDECAR_BINDING_ORIGIN, SIDECAR_URL_ENV_KEY } from "./sidecar-transport.js";
|
|
4
2
|
import { requestCaptureContext } from "./context.js";
|
|
5
3
|
import { STR_REDACTED, redactRequestBody } from "./redact-body.js";
|
|
@@ -95,5 +93,4 @@ const getMeticulousSessionId = () => requestCaptureContext.getStore()?.frontendS
|
|
|
95
93
|
//#endregion
|
|
96
94
|
export { CAPTURED_HEADERS, CLIENT_TECHNOLOGY_ATTR, CLIENT_TECHNOLOGY_POSTGRES_JS, CLIENT_TECHNOLOGY_WORKERD_BINDING, CLIENT_TECHNOLOGY_WORKERD_FETCH, CLIENT_TECHNOLOGY_WORKERD_KV, FRONTEND_SESSION_ID_ATTR, FRONTEND_SESSION_ID_HEADER, MAX_BODY_CAPTURE_SIZE, MAX_POSTGRES_JS_RESULT_SIZE, METICULOUS_PASSTHROUGH_HEADER, POSTGRES_JS_ERROR_ATTR, POSTGRES_JS_QUERY_PARAMS_ATTR, POSTGRES_JS_QUERY_TEXT_ATTR, POSTGRES_JS_RESULT_ATTR, POSTGRES_JS_RESULT_TRUNCATED_ATTR, POSTGRES_JS_ROW_MODE_ATTR, REPLAY_ID_HEADER, REPLAY_SIDECAR_URL_HEADER, SERVER_TIMING_HEADER, SERVER_TIMING_SESSION_METRIC, SESSION_ID_ORIGIN_ATTR, SESSION_ID_ORIGIN_BACKEND, SIDECAR_BINDING_ENV_KEY, SIDECAR_BINDING_ORIGIN, SIDECAR_EVENTS_PATH, SIDECAR_PROTOCOL_VERSION, SIDECAR_PROTOCOL_VERSION_HEADER, SIDECAR_REPLAY_COVERAGE_PATH, SIDECAR_REPLAY_OUTBOUND_FETCH_PATH, SIDECAR_REPLAY_SESSION_PATH, SIDECAR_URL_ENV_KEY, SPAN_KIND_CLIENT, SPAN_KIND_INTERNAL, SPAN_KIND_SERVER, SPAN_STATUS_ERROR, SPAN_STATUS_OK, SPAN_STATUS_UNSET, STR_REDACTED, SpanBuilder, VIRTUAL_TIME_HEADER, WORKERD_BINDING_NAME_ATTR, WORKERD_KV_ARGS_ATTR, WORKERD_KV_ARGS_TRUNCATED_ATTR, WORKERD_KV_KEY_ATTR, WORKERD_KV_OMITTED_ATTR, WORKERD_KV_OPERATION_ATTR, WORKERD_KV_RESULT_ATTR, WORKERD_KV_RESULT_TRUNCATED_ATTR, WORKERD_KV_VALUE_ATTR, WORKERD_KV_VALUE_TRUNCATED_ATTR, WORKERD_SHIM_VERSION, WORKERD_SHIM_VERSION_HEADER, __mcEnter, __mcHit, buildServerTimingSessionEntry, collectHitIds, createCoverageSink, deserializeCapturedError, getCoverageMap, getMeticulousSessionId, getUnreportedCoverageFiles, headersToRecord, hrTimeToMs, isProvisionalSessionIdCandidate, isSupportedPostgresJsQuery, markCoverageFilesReported, mintProvisionalSessionId, msToHrTime, parseVirtualTimeMs, postgresJsCommandOf, randomHex, readBodyWithCap, readRequestBodyWithCap, reconstructQueryText, redactRequestBody, redactUrlCredentials, registerCoverageFile, resolveRowMode, serializeCapturedError, serializeKvArgs, serializeKvCaptureFields, serializePostgresJsArgs, serializePostgresJsError, serializePostgresJsResult, serializeSpan, withMeticulous, withMeticulousPagesFunction, withMeticulousPostgres };
|
|
97
95
|
|
|
98
|
-
//# sourceMappingURL=index.js.map
|
|
99
|
-
//# debugId=d20456bc-894a-5ea9-8719-58b43192282e
|
|
96
|
+
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":["../src/index.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport {\n type MeticulousExecutionContext,\n runWithMeticulous,\n type WithMeticulousOptions,\n} from \"./with-meticulous\";\n\nexport type {\n BindingRequestEvent,\n CaptureEvent,\n CapturedBody,\n CaptureEventsPayload,\n CoverageReportRequest,\n InboundRequestEvent,\n KvOmittedReason,\n KvOperation,\n KvOperationEvent,\n OutboundFetchLookupRequest,\n OutboundFetchLookupResponse,\n OutboundRequestEvent,\n PostgresQueryEvent,\n ReplaySessionInfoResponse,\n} from \"./protocol\";\nexport {\n CAPTURED_HEADERS,\n FRONTEND_SESSION_ID_HEADER,\n METICULOUS_PASSTHROUGH_HEADER,\n parseVirtualTimeMs,\n REPLAY_ID_HEADER,\n REPLAY_SIDECAR_URL_HEADER,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_COVERAGE_PATH,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n VIRTUAL_TIME_HEADER,\n WORKERD_SHIM_VERSION_HEADER,\n} from \"./protocol\";\nexport { WORKERD_SHIM_VERSION } from \"./version\";\nexport {\n MAX_BODY_CAPTURE_SIZE,\n readBodyWithCap,\n readRequestBodyWithCap,\n} from \"./body-capture\";\nexport { headersToRecord } from \"./outbound-capture\";\nexport { redactRequestBody, STR_REDACTED } from \"./redact-body\";\nexport type { CoverageMap, CoverageMapFile } from \"./coverage/coverage.types\";\n// __mcEnter / __mcHit are the entry points the Vite plugin's injected imports\n// bind to, so they are part of the package's public surface even though no\n// human writes a call to them.\nexport {\n __mcEnter,\n __mcHit,\n collectHitIds,\n createCoverageSink,\n getCoverageMap,\n getUnreportedCoverageFiles,\n markCoverageFilesReported,\n registerCoverageFile,\n} from \"./coverage/runtime\";\nexport type { MeticulousExecutionContext, WithMeticulousOptions };\nexport {\n type MeticulousPagesFunction,\n type MeticulousPagesFunctionContext,\n withMeticulousPagesFunction,\n} from \"./pages-function\";\nexport {\n SIDECAR_BINDING_ENV_KEY,\n SIDECAR_BINDING_ORIGIN,\n SIDECAR_URL_ENV_KEY,\n type SidecarFetcher,\n type SidecarTransport,\n} from \"./sidecar-transport\";\n/**\n * Exported for the Node recorder's `withMeticulousCloudflareEnv`, which records the same KV\n * operations from a process whose bindings come from wrangler's `getPlatformProxy`. Both surfaces\n * must produce identical fields, so the contract has exactly one implementation.\n */\nexport {\n type KvCaptureFields,\n type KvCaptureOutcome,\n serializeKvArgs,\n serializeKvCaptureFields,\n} from \"./kv-capture\";\n/**\n * Exported for the same reason: the Node recorder re-exports these so a postgres.js query, a\n * captured error and a built span are byte-identical whichever surface produced them. A\n * Worker-hosted sidecar needs them too, and cannot load the Node bundle.\n */\nexport {\n deserializeCapturedError,\n serializeCapturedError,\n} from \"./error-capture\";\nexport {\n isSupportedPostgresJsQuery,\n MAX_POSTGRES_JS_RESULT_SIZE,\n POSTGRES_JS_ERROR_ATTR,\n POSTGRES_JS_QUERY_PARAMS_ATTR,\n POSTGRES_JS_QUERY_TEXT_ATTR,\n POSTGRES_JS_RESULT_ATTR,\n POSTGRES_JS_RESULT_TRUNCATED_ATTR,\n POSTGRES_JS_ROW_MODE_ATTR,\n postgresJsCommandOf,\n type PostgresJsQueryLike,\n type PostgresJsRowMode,\n reconstructQueryText,\n resolveRowMode,\n serializePostgresJsArgs,\n serializePostgresJsError,\n serializePostgresJsResult,\n} from \"./postgres/capture\";\nexport { withMeticulousPostgres } from \"./postgres/with-meticulous-postgres\";\nexport * from \"./spans/attributes\";\nexport {\n hrTimeToMs,\n msToHrTime,\n type HrTime,\n type ReadableSpan,\n type SerializedSpanBase,\n SPAN_KIND_CLIENT,\n SPAN_KIND_INTERNAL,\n SPAN_KIND_SERVER,\n SPAN_STATUS_ERROR,\n SPAN_STATUS_OK,\n SPAN_STATUS_UNSET,\n type SpanStatus,\n} from \"./spans/readable-span\";\nexport { serializeSpan } from \"./spans/serialize-span\";\nexport { randomHex, SpanBuilder } from \"./spans/span-builder\";\nexport { redactUrlCredentials } from \"./spans/url-redaction\";\n/**\n * Exported for the Node recorder, whose own `provisional-session-id.ts` adapts these to\n * `http.IncomingMessage` / `http.ServerResponse`. The mint format, the rule for which requests\n * get an id and the `Server-Timing` metric are all wire contracts with the page, so — as with\n * the KV capture fields above — they have exactly one implementation.\n */\nexport {\n type HeaderReader,\n SERVER_TIMING_HEADER,\n SERVER_TIMING_SESSION_METRIC,\n buildServerTimingSessionEntry,\n isProvisionalSessionIdCandidate,\n mintProvisionalSessionId,\n} from \"./provisional-session-id\";\n\n/** Structural subset of an ES-module Worker handler (`export default {...}`). */\nexport interface MeticulousWorkerHandler<Env = never> {\n fetch(\n request: Request,\n env: Env,\n ctx: MeticulousExecutionContext,\n ): Response | Promise<Response>;\n}\n\n/**\n * Wraps an ES-module Worker handler so Meticulous can record, or replay, the app's HTTP\n * behaviour:\n *\n * export default withMeticulous({\n * async fetch(request, env, ctx) { ... },\n * });\n *\n * For a Cloudflare Pages project, whose worker exports `onRequest` instead, use\n * {@link withMeticulousPagesFunction} — same behaviour, different handler shape.\n *\n * **Recording** activates on either a `METICULOUS_SIDECAR` service binding (how a **deployed**\n * Worker records: the binding points at a Meticulous recorder sidecar Worker in the same account)\n * or a `METICULOUS_SIDECAR_URL` var (how `wrangler dev` records against a local sidecar process),\n * or the matching `options`. Inbound requests, outgoing `fetch` calls, calls through\n * `fetch`-shaped bindings, KV namespace operations and — with `withMeticulousPostgres` — postgres.js\n * queries are reported to the sidecar, batched into one report per request, without affecting the\n * app. A binding beats a var if both are configured: only the binding can have been added to\n * *this* deployment's wrangler configuration deliberately, whereas a stale `.dev.vars` travels\n * with an image.\n *\n * **Replay** activates on the `x-meticulous-backend-replay-sidecar-url` header, injected by the\n * Meticulous replay runner on requests to the app under test. Outgoing `fetch` calls are then\n * served from the recording instead of reaching the real service — and a call the recording does\n * not cover fails, rather than quietly becoming live traffic; put a `meticulous-passthrough: true`\n * header on a request that must stay real. The clock is frozen at the recorded session's end so\n * recorded credentials are still valid, and `Math.random` / `crypto.randomUUID` /\n * `crypto.getRandomValues` are seeded so ids the app mints are the same in every replay of that\n * session. Workerd cannot read container environment variables, which is why per-replay config\n * arrives as a request header; the shim validates it and only honours a loopback /\n * docker-gateway / private-network `http:` origin.\n *\n * Replay takes precedence over recording when both are configured. A stale sidecar var baked into\n * an image is far more likely than a spurious replay header (nothing but the replay runner emits\n * one), and letting the env win would silently record a replay instead of mocking it.\n *\n * With neither configured the wrapper is a complete pass-through, so it is safe to keep in\n * deployed code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag. A capture\n * failure or unreachable sidecar never affects the app.\n */\nexport const withMeticulous = <H extends MeticulousWorkerHandler<never>>(\n handler: H,\n options?: WithMeticulousOptions,\n): H => {\n const wrappedFetch = (\n request: Request,\n env: unknown,\n ctx: MeticulousExecutionContext,\n ): Promise<Response> =>\n runWithMeticulous(\n {\n request,\n env,\n ctx,\n invokeHandler: () => handler.fetch(request, env as never, ctx),\n },\n options,\n );\n\n // The wrapper only replaces `fetch` (same call signature), but TypeScript cannot verify a\n // spread against the bare generic H.\n return { ...handler, fetch: wrappedFetch } as H;\n};\n\n/**\n * The Meticulous session id of the request currently being served, or `undefined` if this\n * call cannot be tied to one — including whenever the wrapper is a pass-through.\n *\n * Exposed so a server-rendering worker can put the id into the HTML it is about to return,\n * typically as `data-session-id` on the recorder's own `<script>` tag. That is the exact,\n * cache-proof alternative to the `Server-Timing` header the shim publishes automatically;\n * doing both is fine, since the recorder prefers the rendered value. It is also how the id\n * reaches another process — a document navigation has no inbound header for the app to\n * forward, so a worker that fans out to its own services has to pass this on as\n * `x-meticulous-session-id` itself.\n *\n * During a replay this returns the replayed session's id, which is the same value the render\n * being replayed was given when it was recorded (the page adopted the minted id, so that is\n * what the recording is stored under, and it is what the replay runner injects back). An app\n * that renders the id therefore renders the same bytes both times, rather than the id turning\n * every server-rendered page into a diff.\n */\nexport const getMeticulousSessionId = (): string | undefined =>\n requestCaptureContext.getStore()?.frontendSessionId;\n"],"
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { requestCaptureContext } from \"./context\";\nimport {\n type MeticulousExecutionContext,\n runWithMeticulous,\n type WithMeticulousOptions,\n} from \"./with-meticulous\";\n\nexport type {\n BindingRequestEvent,\n CaptureEvent,\n CapturedBody,\n CaptureEventsPayload,\n CoverageReportRequest,\n InboundRequestEvent,\n KvOmittedReason,\n KvOperation,\n KvOperationEvent,\n OutboundFetchLookupRequest,\n OutboundFetchLookupResponse,\n OutboundRequestEvent,\n PostgresQueryEvent,\n ReplaySessionInfoResponse,\n} from \"./protocol\";\nexport {\n CAPTURED_HEADERS,\n FRONTEND_SESSION_ID_HEADER,\n METICULOUS_PASSTHROUGH_HEADER,\n parseVirtualTimeMs,\n REPLAY_ID_HEADER,\n REPLAY_SIDECAR_URL_HEADER,\n SIDECAR_EVENTS_PATH,\n SIDECAR_PROTOCOL_VERSION,\n SIDECAR_PROTOCOL_VERSION_HEADER,\n SIDECAR_REPLAY_COVERAGE_PATH,\n SIDECAR_REPLAY_OUTBOUND_FETCH_PATH,\n SIDECAR_REPLAY_SESSION_PATH,\n VIRTUAL_TIME_HEADER,\n WORKERD_SHIM_VERSION_HEADER,\n} from \"./protocol\";\nexport { WORKERD_SHIM_VERSION } from \"./version\";\nexport {\n MAX_BODY_CAPTURE_SIZE,\n readBodyWithCap,\n readRequestBodyWithCap,\n} from \"./body-capture\";\nexport { headersToRecord } from \"./outbound-capture\";\nexport { redactRequestBody, STR_REDACTED } from \"./redact-body\";\nexport type { CoverageMap, CoverageMapFile } from \"./coverage/coverage.types\";\n// __mcEnter / __mcHit are the entry points the Vite plugin's injected imports\n// bind to, so they are part of the package's public surface even though no\n// human writes a call to them.\nexport {\n __mcEnter,\n __mcHit,\n collectHitIds,\n createCoverageSink,\n getCoverageMap,\n getUnreportedCoverageFiles,\n markCoverageFilesReported,\n registerCoverageFile,\n} from \"./coverage/runtime\";\nexport type { MeticulousExecutionContext, WithMeticulousOptions };\nexport {\n type MeticulousPagesFunction,\n type MeticulousPagesFunctionContext,\n withMeticulousPagesFunction,\n} from \"./pages-function\";\nexport {\n SIDECAR_BINDING_ENV_KEY,\n SIDECAR_BINDING_ORIGIN,\n SIDECAR_URL_ENV_KEY,\n type SidecarFetcher,\n type SidecarTransport,\n} from \"./sidecar-transport\";\n/**\n * Exported for the Node recorder's `withMeticulousCloudflareEnv`, which records the same KV\n * operations from a process whose bindings come from wrangler's `getPlatformProxy`. Both surfaces\n * must produce identical fields, so the contract has exactly one implementation.\n */\nexport {\n type KvCaptureFields,\n type KvCaptureOutcome,\n serializeKvArgs,\n serializeKvCaptureFields,\n} from \"./kv-capture\";\n/**\n * Exported for the same reason: the Node recorder re-exports these so a postgres.js query, a\n * captured error and a built span are byte-identical whichever surface produced them. A\n * Worker-hosted sidecar needs them too, and cannot load the Node bundle.\n */\nexport {\n deserializeCapturedError,\n serializeCapturedError,\n} from \"./error-capture\";\nexport {\n isSupportedPostgresJsQuery,\n MAX_POSTGRES_JS_RESULT_SIZE,\n POSTGRES_JS_ERROR_ATTR,\n POSTGRES_JS_QUERY_PARAMS_ATTR,\n POSTGRES_JS_QUERY_TEXT_ATTR,\n POSTGRES_JS_RESULT_ATTR,\n POSTGRES_JS_RESULT_TRUNCATED_ATTR,\n POSTGRES_JS_ROW_MODE_ATTR,\n postgresJsCommandOf,\n type PostgresJsQueryLike,\n type PostgresJsRowMode,\n reconstructQueryText,\n resolveRowMode,\n serializePostgresJsArgs,\n serializePostgresJsError,\n serializePostgresJsResult,\n} from \"./postgres/capture\";\nexport { withMeticulousPostgres } from \"./postgres/with-meticulous-postgres\";\nexport * from \"./spans/attributes\";\nexport {\n hrTimeToMs,\n msToHrTime,\n type HrTime,\n type ReadableSpan,\n type SerializedSpanBase,\n SPAN_KIND_CLIENT,\n SPAN_KIND_INTERNAL,\n SPAN_KIND_SERVER,\n SPAN_STATUS_ERROR,\n SPAN_STATUS_OK,\n SPAN_STATUS_UNSET,\n type SpanStatus,\n} from \"./spans/readable-span\";\nexport { serializeSpan } from \"./spans/serialize-span\";\nexport { randomHex, SpanBuilder } from \"./spans/span-builder\";\nexport { redactUrlCredentials } from \"./spans/url-redaction\";\n/**\n * Exported for the Node recorder, whose own `provisional-session-id.ts` adapts these to\n * `http.IncomingMessage` / `http.ServerResponse`. The mint format, the rule for which requests\n * get an id and the `Server-Timing` metric are all wire contracts with the page, so — as with\n * the KV capture fields above — they have exactly one implementation.\n */\nexport {\n type HeaderReader,\n SERVER_TIMING_HEADER,\n SERVER_TIMING_SESSION_METRIC,\n buildServerTimingSessionEntry,\n isProvisionalSessionIdCandidate,\n mintProvisionalSessionId,\n} from \"./provisional-session-id\";\n\n/** Structural subset of an ES-module Worker handler (`export default {...}`). */\nexport interface MeticulousWorkerHandler<Env = never> {\n fetch(\n request: Request,\n env: Env,\n ctx: MeticulousExecutionContext,\n ): Response | Promise<Response>;\n}\n\n/**\n * Wraps an ES-module Worker handler so Meticulous can record, or replay, the app's HTTP\n * behaviour:\n *\n * export default withMeticulous({\n * async fetch(request, env, ctx) { ... },\n * });\n *\n * For a Cloudflare Pages project, whose worker exports `onRequest` instead, use\n * {@link withMeticulousPagesFunction} — same behaviour, different handler shape.\n *\n * **Recording** activates on either a `METICULOUS_SIDECAR` service binding (how a **deployed**\n * Worker records: the binding points at a Meticulous recorder sidecar Worker in the same account)\n * or a `METICULOUS_SIDECAR_URL` var (how `wrangler dev` records against a local sidecar process),\n * or the matching `options`. Inbound requests, outgoing `fetch` calls, calls through\n * `fetch`-shaped bindings, KV namespace operations and — with `withMeticulousPostgres` — postgres.js\n * queries are reported to the sidecar, batched into one report per request, without affecting the\n * app. A binding beats a var if both are configured: only the binding can have been added to\n * *this* deployment's wrangler configuration deliberately, whereas a stale `.dev.vars` travels\n * with an image.\n *\n * **Replay** activates on the `x-meticulous-backend-replay-sidecar-url` header, injected by the\n * Meticulous replay runner on requests to the app under test. Outgoing `fetch` calls are then\n * served from the recording instead of reaching the real service — and a call the recording does\n * not cover fails, rather than quietly becoming live traffic; put a `meticulous-passthrough: true`\n * header on a request that must stay real. The clock is frozen at the recorded session's end so\n * recorded credentials are still valid, and `Math.random` / `crypto.randomUUID` /\n * `crypto.getRandomValues` are seeded so ids the app mints are the same in every replay of that\n * session. Workerd cannot read container environment variables, which is why per-replay config\n * arrives as a request header; the shim validates it and only honours a loopback /\n * docker-gateway / private-network `http:` origin.\n *\n * Replay takes precedence over recording when both are configured. A stale sidecar var baked into\n * an image is far more likely than a spurious replay header (nothing but the replay runner emits\n * one), and letting the env win would silently record a replay instead of mocking it.\n *\n * With neither configured the wrapper is a complete pass-through, so it is safe to keep in\n * deployed code. Requires the `nodejs_als` (or `nodejs_compat`) compatibility flag. A capture\n * failure or unreachable sidecar never affects the app.\n */\nexport const withMeticulous = <H extends MeticulousWorkerHandler<never>>(\n handler: H,\n options?: WithMeticulousOptions,\n): H => {\n const wrappedFetch = (\n request: Request,\n env: unknown,\n ctx: MeticulousExecutionContext,\n ): Promise<Response> =>\n runWithMeticulous(\n {\n request,\n env,\n ctx,\n invokeHandler: () => handler.fetch(request, env as never, ctx),\n },\n options,\n );\n\n // The wrapper only replaces `fetch` (same call signature), but TypeScript cannot verify a\n // spread against the bare generic H.\n return { ...handler, fetch: wrappedFetch } as H;\n};\n\n/**\n * The Meticulous session id of the request currently being served, or `undefined` if this\n * call cannot be tied to one — including whenever the wrapper is a pass-through.\n *\n * Exposed so a server-rendering worker can put the id into the HTML it is about to return,\n * typically as `data-session-id` on the recorder's own `<script>` tag. That is the exact,\n * cache-proof alternative to the `Server-Timing` header the shim publishes automatically;\n * doing both is fine, since the recorder prefers the rendered value. It is also how the id\n * reaches another process — a document navigation has no inbound header for the app to\n * forward, so a worker that fans out to its own services has to pass this on as\n * `x-meticulous-session-id` itself.\n *\n * During a replay this returns the replayed session's id, which is the same value the render\n * being replayed was given when it was recorded (the page adopted the minted id, so that is\n * what the recording is stored under, and it is what the replay runner injects back). An app\n * that renders the id therefore renders the same bytes both times, rather than the id turning\n * every server-rendered page into a diff.\n */\nexport const getMeticulousSessionId = (): string | undefined =>\n requestCaptureContext.getStore()?.frontendSessionId;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmMA,MAAa,kBACX,SACA,YACM;CACN,MAAM,gBACJ,SACA,KACA,QAEA,kBACE;EACE;EACA;EACA;EACA,qBAAqB,QAAQ,MAAM,SAAS,KAAc,IAAI;EAC/D,EACD,QACD;AAIH,QAAO;EAAE,GAAG;EAAS,OAAO;EAAc;;;;;;;;;;;;;;;;;;;;AAqB5C,MAAa,+BACX,sBAAsB,UAAU,EAAE"}
|
package/dist/kv-capture.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="9217cc0a-7c17-5ee8-9599-7faf9bd59bf8")}catch(e){}}();
|
|
3
1
|
import { redactRequestBody } from "./redact-body.js";
|
|
4
2
|
import { MAX_BODY_CAPTURE_SIZE } from "./body-capture.js";
|
|
5
3
|
//#region src/kv-capture.ts
|
|
@@ -88,5 +86,4 @@ const capture = (json) => json.length > 262144 ? {
|
|
|
88
86
|
//#endregion
|
|
89
87
|
export { serializeKvArgs, serializeKvCaptureFields };
|
|
90
88
|
|
|
91
|
-
//# sourceMappingURL=kv-capture.js.map
|
|
92
|
-
//# debugId=9217cc0a-7c17-5ee8-9599-7faf9bd59bf8
|
|
89
|
+
//# sourceMappingURL=kv-capture.js.map
|
package/dist/kv-capture.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"kv-capture.js","sources":["../src/kv-capture.ts"],"sourcesContent":["import { MAX_BODY_CAPTURE_SIZE } from \"./body-capture\";\nimport type { CapturedBody, KvOmittedReason, KvOperation } from \"./protocol\";\nimport { redactRequestBody } from \"./redact-body\";\n\n/**\n * How a KV operation's key, arguments and value are turned into persistable fields.\n *\n * This lives apart from `kv-patch.ts` because it is not workerd-specific: the same operations\n * are recorded from a **Node** process whose bindings come from wrangler's `getPlatformProxy`\n * (`withMeticulousCloudflareEnv` in `@alwaysmeticulous/backend-recorder-js`), and the two\n * surfaces have to produce byte-identical fields or a session recorded through one cannot be\n * replayed through the other. So the contract is defined once, here, and both call it.\n */\n\ninterface SerializedKvValue {\n captured?: CapturedBody;\n omitted?: KvOmittedReason;\n}\n\n/** The outcome of a KV operation: what it returned, or how it failed. */\nexport type KvCaptureOutcome = { result: unknown } | { error: string };\n\n/** The persistable fields of one KV operation, as `KvOperationEvent` carries them. */\nexport interface KvCaptureFields {\n /** The key operated on, when it is a single string. Absent for `list` and a bulk `get`. */\n key?: string;\n /** JSON of the call's arguments, with a `put`'s value slot nulled out. */\n args?: CapturedBody;\n /** JSON of the value a `put` wrote, redacted. */\n value?: CapturedBody;\n /** JSON of what the operation returned. Absent for `put`/`delete` and for an error. */\n result?: CapturedBody;\n /** Why a value is missing. Refers to the written value for `put`, the read value otherwise. */\n omitted?: KvOmittedReason;\n}\n\n/**\n * Serializes one KV operation's fields. Never throws: a value that cannot be serialized is\n * simply absent, so the operation is still recorded and only its payload is missing.\n */\nexport const serializeKvCaptureFields = (\n operation: KvOperation,\n args: unknown[],\n outcome: KvCaptureOutcome,\n): KvCaptureFields => {\n const [key] = args;\n const written =\n operation === \"put\" ? serializeWrittenValue(args[1]) : undefined;\n const read =\n \"result\" in outcome ? serializeKvValue(outcome.result) : undefined;\n const omitted = written?.omitted ?? read?.omitted;\n const serializedArgs = serializeKvArgs(operation, args);\n\n return {\n ...(typeof key === \"string\" ? { key } : {}),\n ...(serializedArgs !== undefined ? { args: serializedArgs } : {}),\n ...(written?.captured !== undefined ? { value: written.captured } : {}),\n ...(read?.captured !== undefined ? { result: read.captured } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n };\n};\n\n/**\n * JSON of a value the runtime returned, with anything unpersistable replaced by `null` and the\n * reason reported alongside. A `Map` (what a bulk `get` returns) becomes a plain object, which\n * `JSON.stringify` would otherwise flatten to `{}`.\n *\n * Read values are stored verbatim, deliberately: replay serves them straight back to the app,\n * so redacting one would corrupt what the app receives. Only the write path\n * ({@link serializeWrittenValue}) is redacted — nothing ever replays a `put`.\n */\nconst serializeKvValue = (value: unknown): SerializedKvValue => {\n let omitted: KvOmittedReason | undefined;\n try {\n const json = JSON.stringify(value, (_key: string, nested: unknown) => {\n const reason = omittedReason(nested);\n if (reason !== undefined) {\n omitted ??= reason;\n return null;\n }\n return nested instanceof Map\n ? Object.fromEntries(nested as Map<string, unknown>)\n : nested;\n });\n return {\n // `undefined` (what `put`/`delete` resolve to) has no JSON form — nothing to capture.\n ...(json !== undefined ? { captured: capture(json) } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n };\n } catch {\n // Circular, or a getter that throws. The operation itself is still recorded.\n return {};\n }\n};\n\n/**\n * JSON of the value a `put` wrote, redacted like a request body. KV takes a string, an\n * ArrayBuffer(View) or a stream; only the string form can be captured, and it is redacted as\n * text before being JSON-encoded so that secret-looking fields *inside* the app's payload are\n * caught (redacting the JSON-encoded form would only ever see one opaque string).\n */\nconst serializeWrittenValue = (value: unknown): SerializedKvValue => {\n if (typeof value === \"string\") {\n try {\n return { captured: capture(JSON.stringify(redactRequestBody(value))) };\n } catch {\n return {};\n }\n }\n return serializeKvValue(value);\n};\n\n/**\n * JSON of the call's arguments. A `put` value is replaced by `null` rather than dropped, so the\n * remaining arguments keep their positions — and so the value is only ever persisted via the\n * redacted {@link serializeWrittenValue} path.\n *\n * Exported because it is a KV operation's whole identity: a replay derives its lookup key from the\n * live arguments and compares it against this, so the two must be produced by one function.\n */\nexport const serializeKvArgs = (\n operation: KvOperation,\n args: unknown[],\n): CapturedBody | undefined => {\n const withoutValue =\n operation === \"put\"\n ? args.map((arg, index) => (index === 1 ? null : arg))\n : args;\n return serializeKvValue(withoutValue).captured;\n};\n\nconst omittedReason = (value: unknown): KvOmittedReason | undefined => {\n // A stream is never read, only recognised: reading it would consume the app's bytes.\n if (\n typeof ReadableStream !== \"undefined\" &&\n value instanceof ReadableStream\n ) {\n return \"stream\";\n }\n if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {\n return \"binary\";\n }\n return undefined;\n};\n\nconst capture = (json: string): CapturedBody =>\n json.length > MAX_BODY_CAPTURE_SIZE\n ? { body: json.slice(0, MAX_BODY_CAPTURE_SIZE), truncated: true }\n : { body: json, truncated: false };\n"],"
|
|
1
|
+
{"version":3,"file":"kv-capture.js","names":[],"sources":["../src/kv-capture.ts"],"sourcesContent":["import { MAX_BODY_CAPTURE_SIZE } from \"./body-capture\";\nimport type { CapturedBody, KvOmittedReason, KvOperation } from \"./protocol\";\nimport { redactRequestBody } from \"./redact-body\";\n\n/**\n * How a KV operation's key, arguments and value are turned into persistable fields.\n *\n * This lives apart from `kv-patch.ts` because it is not workerd-specific: the same operations\n * are recorded from a **Node** process whose bindings come from wrangler's `getPlatformProxy`\n * (`withMeticulousCloudflareEnv` in `@alwaysmeticulous/backend-recorder-js`), and the two\n * surfaces have to produce byte-identical fields or a session recorded through one cannot be\n * replayed through the other. So the contract is defined once, here, and both call it.\n */\n\ninterface SerializedKvValue {\n captured?: CapturedBody;\n omitted?: KvOmittedReason;\n}\n\n/** The outcome of a KV operation: what it returned, or how it failed. */\nexport type KvCaptureOutcome = { result: unknown } | { error: string };\n\n/** The persistable fields of one KV operation, as `KvOperationEvent` carries them. */\nexport interface KvCaptureFields {\n /** The key operated on, when it is a single string. Absent for `list` and a bulk `get`. */\n key?: string;\n /** JSON of the call's arguments, with a `put`'s value slot nulled out. */\n args?: CapturedBody;\n /** JSON of the value a `put` wrote, redacted. */\n value?: CapturedBody;\n /** JSON of what the operation returned. Absent for `put`/`delete` and for an error. */\n result?: CapturedBody;\n /** Why a value is missing. Refers to the written value for `put`, the read value otherwise. */\n omitted?: KvOmittedReason;\n}\n\n/**\n * Serializes one KV operation's fields. Never throws: a value that cannot be serialized is\n * simply absent, so the operation is still recorded and only its payload is missing.\n */\nexport const serializeKvCaptureFields = (\n operation: KvOperation,\n args: unknown[],\n outcome: KvCaptureOutcome,\n): KvCaptureFields => {\n const [key] = args;\n const written =\n operation === \"put\" ? serializeWrittenValue(args[1]) : undefined;\n const read =\n \"result\" in outcome ? serializeKvValue(outcome.result) : undefined;\n const omitted = written?.omitted ?? read?.omitted;\n const serializedArgs = serializeKvArgs(operation, args);\n\n return {\n ...(typeof key === \"string\" ? { key } : {}),\n ...(serializedArgs !== undefined ? { args: serializedArgs } : {}),\n ...(written?.captured !== undefined ? { value: written.captured } : {}),\n ...(read?.captured !== undefined ? { result: read.captured } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n };\n};\n\n/**\n * JSON of a value the runtime returned, with anything unpersistable replaced by `null` and the\n * reason reported alongside. A `Map` (what a bulk `get` returns) becomes a plain object, which\n * `JSON.stringify` would otherwise flatten to `{}`.\n *\n * Read values are stored verbatim, deliberately: replay serves them straight back to the app,\n * so redacting one would corrupt what the app receives. Only the write path\n * ({@link serializeWrittenValue}) is redacted — nothing ever replays a `put`.\n */\nconst serializeKvValue = (value: unknown): SerializedKvValue => {\n let omitted: KvOmittedReason | undefined;\n try {\n const json = JSON.stringify(value, (_key: string, nested: unknown) => {\n const reason = omittedReason(nested);\n if (reason !== undefined) {\n omitted ??= reason;\n return null;\n }\n return nested instanceof Map\n ? Object.fromEntries(nested as Map<string, unknown>)\n : nested;\n });\n return {\n // `undefined` (what `put`/`delete` resolve to) has no JSON form — nothing to capture.\n ...(json !== undefined ? { captured: capture(json) } : {}),\n ...(omitted !== undefined ? { omitted } : {}),\n };\n } catch {\n // Circular, or a getter that throws. The operation itself is still recorded.\n return {};\n }\n};\n\n/**\n * JSON of the value a `put` wrote, redacted like a request body. KV takes a string, an\n * ArrayBuffer(View) or a stream; only the string form can be captured, and it is redacted as\n * text before being JSON-encoded so that secret-looking fields *inside* the app's payload are\n * caught (redacting the JSON-encoded form would only ever see one opaque string).\n */\nconst serializeWrittenValue = (value: unknown): SerializedKvValue => {\n if (typeof value === \"string\") {\n try {\n return { captured: capture(JSON.stringify(redactRequestBody(value))) };\n } catch {\n return {};\n }\n }\n return serializeKvValue(value);\n};\n\n/**\n * JSON of the call's arguments. A `put` value is replaced by `null` rather than dropped, so the\n * remaining arguments keep their positions — and so the value is only ever persisted via the\n * redacted {@link serializeWrittenValue} path.\n *\n * Exported because it is a KV operation's whole identity: a replay derives its lookup key from the\n * live arguments and compares it against this, so the two must be produced by one function.\n */\nexport const serializeKvArgs = (\n operation: KvOperation,\n args: unknown[],\n): CapturedBody | undefined => {\n const withoutValue =\n operation === \"put\"\n ? args.map((arg, index) => (index === 1 ? null : arg))\n : args;\n return serializeKvValue(withoutValue).captured;\n};\n\nconst omittedReason = (value: unknown): KvOmittedReason | undefined => {\n // A stream is never read, only recognised: reading it would consume the app's bytes.\n if (\n typeof ReadableStream !== \"undefined\" &&\n value instanceof ReadableStream\n ) {\n return \"stream\";\n }\n if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {\n return \"binary\";\n }\n return undefined;\n};\n\nconst capture = (json: string): CapturedBody =>\n json.length > MAX_BODY_CAPTURE_SIZE\n ? { body: json.slice(0, MAX_BODY_CAPTURE_SIZE), truncated: true }\n : { body: json, truncated: false };\n"],"mappings":";;;;;;;AAwCA,MAAa,4BACX,WACA,MACA,YACoB;CACpB,MAAM,CAAC,OAAO;CACd,MAAM,UACJ,cAAc,QAAQ,sBAAsB,KAAK,GAAG,GAAG,KAAA;CACzD,MAAM,OACJ,YAAY,UAAU,iBAAiB,QAAQ,OAAO,GAAG,KAAA;CAC3D,MAAM,UAAU,SAAS,WAAW,MAAM;CAC1C,MAAM,iBAAiB,gBAAgB,WAAW,KAAK;AAEvD,QAAO;EACL,GAAI,OAAO,QAAQ,WAAW,EAAE,KAAK,GAAG,EAAE;EAC1C,GAAI,mBAAmB,KAAA,IAAY,EAAE,MAAM,gBAAgB,GAAG,EAAE;EAChE,GAAI,SAAS,aAAa,KAAA,IAAY,EAAE,OAAO,QAAQ,UAAU,GAAG,EAAE;EACtE,GAAI,MAAM,aAAa,KAAA,IAAY,EAAE,QAAQ,KAAK,UAAU,GAAG,EAAE;EACjE,GAAI,YAAY,KAAA,IAAY,EAAE,SAAS,GAAG,EAAE;EAC7C;;;;;;;;;;;AAYH,MAAM,oBAAoB,UAAsC;CAC9D,IAAI;AACJ,KAAI;EACF,MAAM,OAAO,KAAK,UAAU,QAAQ,MAAc,WAAoB;GACpE,MAAM,SAAS,cAAc,OAAO;AACpC,OAAI,WAAW,KAAA,GAAW;AACxB,gBAAY;AACZ,WAAO;;AAET,UAAO,kBAAkB,MACrB,OAAO,YAAY,OAA+B,GAClD;IACJ;AACF,SAAO;GAEL,GAAI,SAAS,KAAA,IAAY,EAAE,UAAU,QAAQ,KAAK,EAAE,GAAG,EAAE;GACzD,GAAI,YAAY,KAAA,IAAY,EAAE,SAAS,GAAG,EAAE;GAC7C;SACK;AAEN,SAAO,EAAE;;;;;;;;;AAUb,MAAM,yBAAyB,UAAsC;AACnE,KAAI,OAAO,UAAU,SACnB,KAAI;AACF,SAAO,EAAE,UAAU,QAAQ,KAAK,UAAU,kBAAkB,MAAM,CAAC,CAAC,EAAE;SAChE;AACN,SAAO,EAAE;;AAGb,QAAO,iBAAiB,MAAM;;;;;;;;;;AAWhC,MAAa,mBACX,WACA,SAC6B;AAK7B,QAAO,iBAHL,cAAc,QACV,KAAK,KAAK,KAAK,UAAW,UAAU,IAAI,OAAO,IAAK,GACpD,KAC+B,CAAC;;AAGxC,MAAM,iBAAiB,UAAgD;AAErE,KACE,OAAO,mBAAmB,eAC1B,iBAAiB,eAEjB,QAAO;AAET,KAAI,iBAAiB,eAAe,YAAY,OAAO,MAAM,CAC3D,QAAO;;AAKX,MAAM,WAAW,SACf,KAAK,SAAA,SACD;CAAE,MAAM,KAAK,MAAM,GAAG,sBAAsB;CAAE,WAAW;CAAM,GAC/D;CAAE,MAAM;CAAM,WAAW;CAAO"}
|
package/dist/kv-patch.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="88d9b432-3aec-5b5d-abea-8e34937d6d14")}catch(e){}}();
|
|
3
1
|
import { requestCaptureContext } from "./context.js";
|
|
4
2
|
import { warnOnce } from "./log.js";
|
|
5
3
|
import { serializeKvCaptureFields } from "./kv-capture.js";
|
|
@@ -184,5 +182,4 @@ const buildKvEvent = (ctx, operation, bindingName, args, startTimeMs, outcome) =
|
|
|
184
182
|
//#endregion
|
|
185
183
|
export { installKvPatch };
|
|
186
184
|
|
|
187
|
-
//# sourceMappingURL=kv-patch.js.map
|
|
188
|
-
//# debugId=88d9b432-3aec-5b5d-abea-8e34937d6d14
|
|
185
|
+
//# sourceMappingURL=kv-patch.js.map
|