@alwaysmeticulous/backend-recorder-workerd 2.326.0 → 2.329.0
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/README.md +36 -0
- package/dist/binding-patch.js +2 -2
- package/dist/binding-patch.js.map +1 -1
- package/dist/body-capture.js +2 -2
- package/dist/body-capture.js.map +1 -1
- package/dist/capture-buffer.js +2 -2
- package/dist/capture-buffer.js.map +1 -1
- package/dist/context.js +2 -2
- package/dist/context.js.map +1 -1
- package/dist/coverage/coverage.types.d.ts +32 -0
- package/dist/coverage/replay-coverage.js +84 -0
- package/dist/coverage/replay-coverage.js.map +1 -0
- package/dist/coverage/runtime.d.ts +70 -0
- package/dist/coverage/runtime.js +138 -0
- package/dist/coverage/runtime.js.map +1 -0
- package/dist/error-capture.js +2 -2
- package/dist/error-capture.js.map +1 -1
- package/dist/fetch-patch.js +2 -2
- package/dist/fetch-patch.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/kv-capture.js +2 -2
- package/dist/kv-capture.js.map +1 -1
- package/dist/kv-patch.js +2 -2
- package/dist/kv-patch.js.map +1 -1
- package/dist/log.js +2 -2
- package/dist/log.js.map +1 -1
- package/dist/original-fetch.js +2 -2
- package/dist/original-fetch.js.map +1 -1
- package/dist/outbound-capture.js +2 -2
- package/dist/outbound-capture.js.map +1 -1
- package/dist/pages-function.js +2 -2
- package/dist/pages-function.js.map +1 -1
- package/dist/postgres/capture.js +2 -2
- package/dist/postgres/capture.js.map +1 -1
- package/dist/postgres/with-meticulous-postgres.js +2 -2
- package/dist/postgres/with-meticulous-postgres.js.map +1 -1
- package/dist/protocol.d.ts +48 -18
- package/dist/protocol.js +22 -6
- package/dist/protocol.js.map +1 -1
- package/dist/provisional-session-id.js +2 -2
- package/dist/provisional-session-id.js.map +1 -1
- package/dist/publish-session-id.js +2 -2
- package/dist/publish-session-id.js.map +1 -1
- package/dist/publish-shim-version.js +37 -0
- package/dist/publish-shim-version.js.map +1 -0
- package/dist/redact-body.js +2 -2
- package/dist/redact-body.js.map +1 -1
- package/dist/replay-fetch.js +15 -12
- package/dist/replay-fetch.js.map +1 -1
- package/dist/replay-log-tagging.js +57 -0
- package/dist/replay-log-tagging.js.map +1 -0
- package/dist/replay-sidecar-url.js +2 -2
- package/dist/replay-sidecar-url.js.map +1 -1
- package/dist/sidecar-client.js +47 -6
- package/dist/sidecar-client.js.map +1 -1
- package/dist/sidecar-transport.js +2 -2
- package/dist/sidecar-transport.js.map +1 -1
- package/dist/spans/attributes.js +2 -2
- package/dist/spans/attributes.js.map +1 -1
- package/dist/spans/readable-span.js +2 -2
- package/dist/spans/readable-span.js.map +1 -1
- package/dist/spans/serialize-span.js +2 -2
- package/dist/spans/serialize-span.js.map +1 -1
- package/dist/spans/span-builder.js +2 -2
- package/dist/spans/span-builder.js.map +1 -1
- package/dist/spans/url-redaction.js +2 -2
- package/dist/spans/url-redaction.js.map +1 -1
- package/dist/version.d.ts +14 -0
- package/dist/version.js +18 -0
- package/dist/version.js.map +1 -0
- package/dist/virtual-clock.js +2 -2
- package/dist/virtual-clock.js.map +1 -1
- package/dist/virtual-random.js +47 -20
- package/dist/virtual-random.js.map +1 -1
- package/dist/vite/index.d.ts +62 -0
- package/dist/vite/index.js +6764 -0
- package/dist/vite/index.js.map +1 -0
- package/dist/with-meticulous.js +0 -0
- package/dist/with-meticulous.js.map +1 -1
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -155,8 +155,44 @@ Replay is **hermetic**: a call the recording does not cover fails with a `[backe
|
|
|
155
155
|
await fetch(url, { headers: { "meticulous-passthrough": "true" } });
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
+
While serving a replay, the shim prefixes request-scoped `console.debug`, `console.log`,
|
|
159
|
+
`console.info`, `console.warn`, `console.error`, and `console.trace` output with the active replay
|
|
160
|
+
ID. Each request reads its own ID from `AsyncLocalStorage`, so logs stay correctly attributed when
|
|
161
|
+
one Worker isolate serves concurrent replays. Startup and other work outside a replay request stays
|
|
162
|
+
untagged.
|
|
163
|
+
|
|
158
164
|
Calls through bindings and KV namespaces are not served from the recording (see Limitations) and are never failed — they always reach the real binding.
|
|
159
165
|
|
|
166
|
+
## Code coverage
|
|
167
|
+
|
|
168
|
+
Meticulous can report which of your server lines each replayed session executed. Workerd exposes no usable V8 coverage — its inspector never dispatches `Profiler.enable`, so `Profiler.startPreciseCoverage` is unreachable, and the one coverage call it does forward reports binary counts that never reset, which cannot be split per request. So the lines are marked at build time instead.
|
|
169
|
+
|
|
170
|
+
Add the plugin to the build you **upload for testing**, not the one you deploy:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { defineConfig } from "vite";
|
|
174
|
+
import { meticulousCoverage } from "@alwaysmeticulous/backend-recorder-workerd/vite";
|
|
175
|
+
|
|
176
|
+
export default defineConfig({
|
|
177
|
+
plugins: [meticulousCoverage()],
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Then replays report coverage automatically — the wrapper posts the lines each request ran to the Meticulous replay sidecar, which attributes them to that session.
|
|
182
|
+
|
|
183
|
+
- Only **server** modules are instrumented; the client build is untouched unless you pass `includeClient: true`.
|
|
184
|
+
- `node_modules` and virtual modules are always skipped. `exclude: [/pattern/]` skips more.
|
|
185
|
+
- The instrumented bundle is safe to run with coverage off: every marker is a no-op when nothing is collecting, so the same image still works for ordinary replays and for local development.
|
|
186
|
+
- A module the plugin cannot parse is passed through unchanged. Coverage is never worth failing your build for.
|
|
187
|
+
- Some lines are deliberately not marked, because marking them would mean rewriting code rather than adding to it: statements at module top level (they run at isolate startup, outside any request), class `static` blocks, the body of a braceless single statement such as `if (x) return;`, and the condition of an `else if` (only the first `if` of a chain gets a marker, though every branch body still gets its own). Those lines therefore read as uncovered even in a run that executed them.
|
|
188
|
+
|
|
189
|
+
Reported line numbers are lines in your source, not in the bundle. The plugin runs after your build's TypeScript/JSX pass, which re-prints each module and drops comments and blank lines — so it resolves every line back through that pass's source map. Two consequences worth knowing:
|
|
190
|
+
|
|
191
|
+
- A module whose source map your build does not produce is still instrumented, but its lines are reported as the build printed them. The plugin says how many modules this affected at the end of the build.
|
|
192
|
+
- A module whose source map covers some other file is skipped entirely rather than reported against the wrong lines.
|
|
193
|
+
|
|
194
|
+
Because each function re-reads the current request's sink, a callback that outlives the request that created it is credited to the request that actually runs it, not the one that built it.
|
|
195
|
+
|
|
160
196
|
## Limitations
|
|
161
197
|
|
|
162
198
|
- Streamed responses are captured truncated: the inbound request is reported once the handler returns, which for a streamed response is before the stream ends, and outbound bodies cap at 256 KB. An app whose main endpoints are SSE will not record them faithfully.
|
package/dist/binding-patch.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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]="
|
|
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
3
|
import { SIDECAR_BINDING_ENV_KEY } from "./sidecar-transport.js";
|
|
4
4
|
import { requestCaptureContext, sidecarOriginOf } from "./context.js";
|
|
5
5
|
import { warnOnce } from "./log.js";
|
|
@@ -145,4 +145,4 @@ const toRequest = (args) => {
|
|
|
145
145
|
export { installBindingPatch };
|
|
146
146
|
|
|
147
147
|
//# sourceMappingURL=binding-patch.js.map
|
|
148
|
-
//# debugId=
|
|
148
|
+
//# debugId=30b53a90-e363-545b-ba99-464e83c2b094
|
|
@@ -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":"233eef03-f460-5f72-ac30-1337fa3d62e8"}
|
|
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"}
|
package/dist/body-capture.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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]="
|
|
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
3
|
import { redactRequestBody } from "./redact-body.js";
|
|
4
4
|
//#region src/body-capture.ts
|
|
5
5
|
/** Matches the Node backend recorder's body capture cap (met-http-instrumentation). */
|
|
@@ -76,4 +76,4 @@ const readRequestBodyWithCap = async (stream) => {
|
|
|
76
76
|
export { MAX_BODY_CAPTURE_SIZE, readBodyWithCap, readRequestBodyWithCap };
|
|
77
77
|
|
|
78
78
|
//# sourceMappingURL=body-capture.js.map
|
|
79
|
-
//# debugId=
|
|
79
|
+
//# debugId=a0efd618-4630-5143-8869-b5c81997779f
|
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"],"names":[],"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","debugId":"
|
|
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"],"names":[],"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","debugId":"a0efd618-4630-5143-8869-b5c81997779f"}
|
package/dist/capture-buffer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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]="
|
|
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
3
|
import { warnOnce } from "./log.js";
|
|
4
4
|
import { postCaptureEvents } from "./sidecar-client.js";
|
|
5
5
|
//#region src/capture-buffer.ts
|
|
@@ -111,4 +111,4 @@ const estimateChars = (event) => {
|
|
|
111
111
|
export { CaptureBuffer };
|
|
112
112
|
|
|
113
113
|
//# sourceMappingURL=capture-buffer.js.map
|
|
114
|
-
//# debugId=
|
|
114
|
+
//# debugId=736a00e9-e9cf-578b-bca0-554b57ed34ed
|
|
@@ -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"],"names":[],"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","debugId":"
|
|
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"],"names":[],"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","debugId":"736a00e9-e9cf-578b-bca0-554b57ed34ed"}
|
package/dist/context.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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]="
|
|
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]="fd65e097-c18e-51ca-8f2b-59427a88d125")}catch(e){}}();
|
|
3
3
|
import { transportOrigin } from "./sidecar-transport.js";
|
|
4
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
5
|
//#region src/context.ts
|
|
@@ -10,4 +10,4 @@ const sidecarOriginOf = (ctx) => ctx.mode === "replay" ? ctx.sidecarUrl : transp
|
|
|
10
10
|
export { requestCaptureContext, sidecarOriginOf };
|
|
11
11
|
|
|
12
12
|
//# sourceMappingURL=context.js.map
|
|
13
|
-
//# debugId=
|
|
13
|
+
//# debugId=fd65e097-c18e-51ca-8f2b-59427a88d125
|
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\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\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"],"names":[],"mappings":";;;;;
|
|
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\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"],"names":[],"mappings":";;;;;AA2EA,MAAa,wBAAwB,IAAI,mBAAmC;;AAG5E,MAAa,mBAAmB,QAC9B,IAAI,SAAS,WAAW,IAAI,aAAa,gBAAgB,IAAI,UAAU","debugId":"fd65e097-c18e-51ca-8f2b-59427a88d125"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/coverage/coverage.types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Build-time line coverage contract, shared by the Vite plugin (which produces
|
|
4
|
+
* the ids), the in-worker runtime (which reports hits against them) and the
|
|
5
|
+
* sidecar (which turns hits back into file/line ranges).
|
|
6
|
+
*/
|
|
7
|
+
/** One instrumented source file's slice of the global id space. */
|
|
8
|
+
interface CoverageMapFile {
|
|
9
|
+
/** Source path as the build saw it, project-root-relative where possible. */
|
|
10
|
+
path: string;
|
|
11
|
+
/** First global id assigned to this file. */
|
|
12
|
+
firstId: number;
|
|
13
|
+
/**
|
|
14
|
+
* Inclusive 1-based line ranges, flattened as `[s1, e1, s2, e2, ...]` — the
|
|
15
|
+
* range at pair index `i` belongs to global id `firstId + i`. Flat rather
|
|
16
|
+
* than nested because this ships inside the customer's bundle, and it matches
|
|
17
|
+
* the `[start, end]` flattening the V8 chunk format already uses.
|
|
18
|
+
*
|
|
19
|
+
* A range covers only the lines the marked statement occupies itself: nested
|
|
20
|
+
* statements and function bodies carry their own ids, so an enclosing `if`
|
|
21
|
+
* never credits its untaken branch.
|
|
22
|
+
*/
|
|
23
|
+
lineRanges: number[];
|
|
24
|
+
}
|
|
25
|
+
interface CoverageMap {
|
|
26
|
+
/** Size of the global id space, and therefore of a request's sink. */
|
|
27
|
+
totalIds: number;
|
|
28
|
+
files: CoverageMapFile[];
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { CoverageMap, CoverageMapFile };
|
|
32
|
+
//# sourceMappingURL=coverage.types.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
import { requestCaptureContext } from "../context.js";
|
|
4
|
+
import { warnOnce } from "../log.js";
|
|
5
|
+
import { getOriginalFetch } from "../original-fetch.js";
|
|
6
|
+
import { postCoverageReport } from "../sidecar-client.js";
|
|
7
|
+
import { collectHitIds, createCoverageSink, getUnreportedCoverageFiles, markCoverageFilesReported } from "./runtime.js";
|
|
8
|
+
//#region src/coverage/replay-coverage.ts
|
|
9
|
+
/**
|
|
10
|
+
* The replay-mode half of line coverage: giving a request a sink, and reporting
|
|
11
|
+
* what it marked. The marking itself is `runtime.ts`, which the instrumented
|
|
12
|
+
* bundle calls directly.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Whether the sidecar has told us it will never take coverage (404 on the route: an older
|
|
16
|
+
* sidecar, or one started without coverage enabled). Latched per isolate so a replay against
|
|
17
|
+
* such a sidecar stops allocating sinks and serialising hits after the first report.
|
|
18
|
+
*/
|
|
19
|
+
let coverageDisabled = false;
|
|
20
|
+
/**
|
|
21
|
+
* Runs the handler with a per-request coverage sink, reporting what it marked once the response
|
|
22
|
+
* exists.
|
|
23
|
+
*
|
|
24
|
+
* The sink is per-request rather than per-isolate because workerd interleaves concurrent
|
|
25
|
+
* requests, and every replayed request belongs to a different session — a shared sink would
|
|
26
|
+
* merge their coverage. Reporting goes through `waitUntil` so serialising the hits never delays
|
|
27
|
+
* the response, and every failure path leaves the response untouched: coverage is strictly
|
|
28
|
+
* best-effort.
|
|
29
|
+
*/
|
|
30
|
+
const runReplayWithCoverage = async (replayContext, invokeHandler) => {
|
|
31
|
+
if (coverageDisabled) return requestCaptureContext.run(replayContext, invokeHandler);
|
|
32
|
+
let sink;
|
|
33
|
+
try {
|
|
34
|
+
sink = createCoverageSink();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
warnOnce("coverage-sink", "Could not allocate a coverage sink.", error);
|
|
37
|
+
}
|
|
38
|
+
if (sink === void 0) return requestCaptureContext.run(replayContext, invokeHandler);
|
|
39
|
+
const contextWithCoverage = {
|
|
40
|
+
...replayContext,
|
|
41
|
+
coverage: sink
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
return await requestCaptureContext.run(contextWithCoverage, invokeHandler);
|
|
45
|
+
} finally {
|
|
46
|
+
reportCoverage(contextWithCoverage);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Sends the hits, plus any id→line map the sidecar has not acknowledged yet.
|
|
51
|
+
*
|
|
52
|
+
* The map goes out incrementally rather than once per isolate because a
|
|
53
|
+
* code-split app registers a module only when its chunk is first imported: a
|
|
54
|
+
* route's server module usually registers on the request that first calls it,
|
|
55
|
+
* after earlier requests have already reported. Without the delta its hits would
|
|
56
|
+
* arrive against ids no file describes, and be dropped by the post-process.
|
|
57
|
+
*/
|
|
58
|
+
const reportCoverage = (context) => {
|
|
59
|
+
try {
|
|
60
|
+
const sink = context.coverage;
|
|
61
|
+
if (sink === void 0) return;
|
|
62
|
+
const hitIds = collectHitIds(sink);
|
|
63
|
+
const files = getUnreportedCoverageFiles();
|
|
64
|
+
if (hitIds.length === 0 && files.length === 0) return;
|
|
65
|
+
context.waitUntil(postCoverageReport(getOriginalFetch(), context.sidecarUrl, {
|
|
66
|
+
frontendSessionId: context.frontendSessionId,
|
|
67
|
+
hitIds,
|
|
68
|
+
...files.length > 0 ? { files } : {}
|
|
69
|
+
}).then((outcome) => {
|
|
70
|
+
if (outcome === "unsupported") {
|
|
71
|
+
coverageDisabled = true;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (outcome === "accepted") markCoverageFilesReported(files);
|
|
75
|
+
}));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
warnOnce("coverage-report", "Failed to report line coverage.", error);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
//#endregion
|
|
81
|
+
export { runReplayWithCoverage };
|
|
82
|
+
|
|
83
|
+
//# sourceMappingURL=replay-coverage.js.map
|
|
84
|
+
//# debugId=7dccd8d2-bdd2-5af1-ab64-d5b05a22f58e
|
|
@@ -0,0 +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"],"names":[],"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","debugId":"7dccd8d2-bdd2-5af1-ab64-d5b05a22f58e"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { CoverageMap, CoverageMapFile } from "./coverage.types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/coverage/runtime.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Registers one module's slice of the id space, as a side effect of that module
|
|
6
|
+
* being evaluated.
|
|
7
|
+
*
|
|
8
|
+
* Per-module rather than one generated manifest because the full map is not
|
|
9
|
+
* known until every module has been transformed, and a manifest module would be
|
|
10
|
+
* loaded by the bundler as soon as the *first* instrumented module imported it.
|
|
11
|
+
* Self-registration sidesteps that ordering entirely.
|
|
12
|
+
*
|
|
13
|
+
* Deduplicated by path+id so a bundler that evaluates a module twice cannot
|
|
14
|
+
* inflate the id space.
|
|
15
|
+
*/
|
|
16
|
+
declare const registerCoverageFile: (file: CoverageMapFile) => void;
|
|
17
|
+
/** The whole registered id space. Exposed for debugging an instrumented build. */
|
|
18
|
+
declare const getCoverageMap: () => CoverageMap;
|
|
19
|
+
/**
|
|
20
|
+
* The registered files the sidecar has not acknowledged yet.
|
|
21
|
+
*
|
|
22
|
+
* Reported per request rather than once per isolate because a code-split app
|
|
23
|
+
* registers modules as their chunks are first imported — a route's server module
|
|
24
|
+
* typically registers on the request that first calls into it, long after the
|
|
25
|
+
* isolate's first report. Sending the map only once would leave every one of
|
|
26
|
+
* that module's hits unresolvable, which is coverage silently lost rather than
|
|
27
|
+
* coverage reported as missing.
|
|
28
|
+
*/
|
|
29
|
+
declare const getUnreportedCoverageFiles: () => CoverageMapFile[];
|
|
30
|
+
/**
|
|
31
|
+
* Marks files as delivered. Called only once the sidecar has accepted them, so a
|
|
32
|
+
* report lost to a timeout or a 5xx goes out again with the next one — the
|
|
33
|
+
* sidecar keeps the first map it sees for an id block, so a dropped map is
|
|
34
|
+
* permanent otherwise.
|
|
35
|
+
*/
|
|
36
|
+
declare const markCoverageFilesReported: (reported: readonly CoverageMapFile[]) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Resolves the current request's sink. Called once per function invocation by
|
|
39
|
+
* instrumented code, which then stores into the returned array directly.
|
|
40
|
+
*/
|
|
41
|
+
declare const __mcEnter: () => Uint8Array | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Marks one line hit. Used only for concise arrow bodies (`() => expr`), which
|
|
44
|
+
* have no block in which to hoist the sink lookup.
|
|
45
|
+
*/
|
|
46
|
+
declare const __mcHit: (id: number) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Allocates a request's sink, sized to the id space registered so far — which on
|
|
49
|
+
* a cold isolate is empty, since a code-split app registers nothing until its
|
|
50
|
+
* chunks are imported. An empty sink is deliberately not the same as "no
|
|
51
|
+
* instrumentation": {@link registerCoverageFile} grows it as modules arrive, so a
|
|
52
|
+
* request that starts with nothing registered still collects what it loads.
|
|
53
|
+
*
|
|
54
|
+
* Never undefined, so an uninstrumented bundle costs one zero-length allocation
|
|
55
|
+
* per request and reports nothing, rather than needing a per-isolate latch that
|
|
56
|
+
* cannot tell "no instrumentation" from "not loaded yet".
|
|
57
|
+
*/
|
|
58
|
+
declare const createCoverageSink: () => Uint8Array;
|
|
59
|
+
/**
|
|
60
|
+
* The ids marked during a request.
|
|
61
|
+
*
|
|
62
|
+
* Scanning the whole sink costs one pass over the id space per request — for a
|
|
63
|
+
* large app a few hundred thousand array reads, well under a millisecond, and
|
|
64
|
+
* paid once rather than on every line hit. The alternative (a push-list beside
|
|
65
|
+
* the bitmap) would add a branch to the hot path to save that single pass.
|
|
66
|
+
*/
|
|
67
|
+
declare const collectHitIds: (sink: Uint8Array) => number[];
|
|
68
|
+
//#endregion
|
|
69
|
+
export { __mcEnter, __mcHit, collectHitIds, createCoverageSink, getCoverageMap, getUnreportedCoverageFiles, markCoverageFilesReported, registerCoverageFile };
|
|
70
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
import { requestCaptureContext } from "../context.js";
|
|
4
|
+
//#region src/coverage/runtime.ts
|
|
5
|
+
/**
|
|
6
|
+
* The in-worker half of build-time line coverage.
|
|
7
|
+
*
|
|
8
|
+
* The Meticulous Vite plugin rewrites the app's modules so each line marks
|
|
9
|
+
* itself against a sink, and injects a manifest module that calls
|
|
10
|
+
* {@link registerCoverageMap} once per isolate. Everything else here exists to
|
|
11
|
+
* keep the hot path to a single array store.
|
|
12
|
+
*
|
|
13
|
+
* The sink lives on the per-request AsyncLocalStorage store rather than in a
|
|
14
|
+
* module-scope global, which is the whole reason this can attribute coverage at
|
|
15
|
+
* all: workerd interleaves concurrent requests in one isolate, so a shared
|
|
16
|
+
* global would mix sessions together — the same over-attribution that makes
|
|
17
|
+
* workerd's `getBestEffortCoverage` useless for this.
|
|
18
|
+
*
|
|
19
|
+
* Both entry points are no-ops when no sink is present (recording, an
|
|
20
|
+
* uninstrumented request, module-level code running at isolate init), so an
|
|
21
|
+
* instrumented bundle is safe to run with coverage off.
|
|
22
|
+
*/
|
|
23
|
+
const files = [];
|
|
24
|
+
const registeredKeys = /* @__PURE__ */ new Set();
|
|
25
|
+
const reportedKeys = /* @__PURE__ */ new Set();
|
|
26
|
+
let totalIds = 0;
|
|
27
|
+
const fileKey = (file) => `${file.path}#${file.firstId}`;
|
|
28
|
+
/**
|
|
29
|
+
* Registers one module's slice of the id space, as a side effect of that module
|
|
30
|
+
* being evaluated.
|
|
31
|
+
*
|
|
32
|
+
* Per-module rather than one generated manifest because the full map is not
|
|
33
|
+
* known until every module has been transformed, and a manifest module would be
|
|
34
|
+
* loaded by the bundler as soon as the *first* instrumented module imported it.
|
|
35
|
+
* Self-registration sidesteps that ordering entirely.
|
|
36
|
+
*
|
|
37
|
+
* Deduplicated by path+id so a bundler that evaluates a module twice cannot
|
|
38
|
+
* inflate the id space.
|
|
39
|
+
*/
|
|
40
|
+
const registerCoverageFile = (file) => {
|
|
41
|
+
const key = fileKey(file);
|
|
42
|
+
if (registeredKeys.has(key)) return;
|
|
43
|
+
registeredKeys.add(key);
|
|
44
|
+
files.push(file);
|
|
45
|
+
const idsInFile = file.lineRanges.length / 2;
|
|
46
|
+
totalIds = Math.max(totalIds, file.firstId + idsInFile);
|
|
47
|
+
growCurrentRequestSink();
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Extends the in-flight request's sink to cover the ids just registered.
|
|
51
|
+
*
|
|
52
|
+
* Registration happens as a module is evaluated, which for a code-split app is
|
|
53
|
+
* inside whichever request first imported its chunk — usually the one about to
|
|
54
|
+
* execute it. The sink was sized before that, so without this the request that
|
|
55
|
+
* brings a module in is the one request whose coverage of it is lost, and on a
|
|
56
|
+
* cold isolate that is the session's very first render.
|
|
57
|
+
*
|
|
58
|
+
* Replacing the array is safe because instrumented code re-reads the sink at
|
|
59
|
+
* every function entry: only a function already executing keeps the old one, and
|
|
60
|
+
* module evaluation itself carries no markers.
|
|
61
|
+
*/
|
|
62
|
+
const growCurrentRequestSink = () => {
|
|
63
|
+
const store = requestCaptureContext.getStore();
|
|
64
|
+
if (store === void 0) return;
|
|
65
|
+
const sink = store.coverage;
|
|
66
|
+
if (sink === void 0 || sink.length >= totalIds) return;
|
|
67
|
+
const grown = new Uint8Array(totalIds);
|
|
68
|
+
grown.set(sink);
|
|
69
|
+
store.coverage = grown;
|
|
70
|
+
};
|
|
71
|
+
/** The whole registered id space. Exposed for debugging an instrumented build. */
|
|
72
|
+
const getCoverageMap = () => ({
|
|
73
|
+
totalIds,
|
|
74
|
+
files
|
|
75
|
+
});
|
|
76
|
+
/**
|
|
77
|
+
* The registered files the sidecar has not acknowledged yet.
|
|
78
|
+
*
|
|
79
|
+
* Reported per request rather than once per isolate because a code-split app
|
|
80
|
+
* registers modules as their chunks are first imported — a route's server module
|
|
81
|
+
* typically registers on the request that first calls into it, long after the
|
|
82
|
+
* isolate's first report. Sending the map only once would leave every one of
|
|
83
|
+
* that module's hits unresolvable, which is coverage silently lost rather than
|
|
84
|
+
* coverage reported as missing.
|
|
85
|
+
*/
|
|
86
|
+
const getUnreportedCoverageFiles = () => files.filter((file) => !reportedKeys.has(fileKey(file)));
|
|
87
|
+
/**
|
|
88
|
+
* Marks files as delivered. Called only once the sidecar has accepted them, so a
|
|
89
|
+
* report lost to a timeout or a 5xx goes out again with the next one — the
|
|
90
|
+
* sidecar keeps the first map it sees for an id block, so a dropped map is
|
|
91
|
+
* permanent otherwise.
|
|
92
|
+
*/
|
|
93
|
+
const markCoverageFilesReported = (reported) => {
|
|
94
|
+
for (const file of reported) reportedKeys.add(fileKey(file));
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Resolves the current request's sink. Called once per function invocation by
|
|
98
|
+
* instrumented code, which then stores into the returned array directly.
|
|
99
|
+
*/
|
|
100
|
+
const __mcEnter = () => requestCaptureContext.getStore()?.coverage;
|
|
101
|
+
/**
|
|
102
|
+
* Marks one line hit. Used only for concise arrow bodies (`() => expr`), which
|
|
103
|
+
* have no block in which to hoist the sink lookup.
|
|
104
|
+
*/
|
|
105
|
+
const __mcHit = (id) => {
|
|
106
|
+
const sink = requestCaptureContext.getStore()?.coverage;
|
|
107
|
+
if (sink !== void 0 && id < sink.length) sink[id] = 1;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Allocates a request's sink, sized to the id space registered so far — which on
|
|
111
|
+
* a cold isolate is empty, since a code-split app registers nothing until its
|
|
112
|
+
* chunks are imported. An empty sink is deliberately not the same as "no
|
|
113
|
+
* instrumentation": {@link registerCoverageFile} grows it as modules arrive, so a
|
|
114
|
+
* request that starts with nothing registered still collects what it loads.
|
|
115
|
+
*
|
|
116
|
+
* Never undefined, so an uninstrumented bundle costs one zero-length allocation
|
|
117
|
+
* per request and reports nothing, rather than needing a per-isolate latch that
|
|
118
|
+
* cannot tell "no instrumentation" from "not loaded yet".
|
|
119
|
+
*/
|
|
120
|
+
const createCoverageSink = () => new Uint8Array(totalIds);
|
|
121
|
+
/**
|
|
122
|
+
* The ids marked during a request.
|
|
123
|
+
*
|
|
124
|
+
* Scanning the whole sink costs one pass over the id space per request — for a
|
|
125
|
+
* large app a few hundred thousand array reads, well under a millisecond, and
|
|
126
|
+
* paid once rather than on every line hit. The alternative (a push-list beside
|
|
127
|
+
* the bitmap) would add a branch to the hot path to save that single pass.
|
|
128
|
+
*/
|
|
129
|
+
const collectHitIds = (sink) => {
|
|
130
|
+
const hits = [];
|
|
131
|
+
for (let id = 0; id < sink.length; id++) if (sink[id] !== 0) hits.push(id);
|
|
132
|
+
return hits;
|
|
133
|
+
};
|
|
134
|
+
//#endregion
|
|
135
|
+
export { __mcEnter, __mcHit, collectHitIds, createCoverageSink, getCoverageMap, getUnreportedCoverageFiles, markCoverageFilesReported, registerCoverageFile };
|
|
136
|
+
|
|
137
|
+
//# sourceMappingURL=runtime.js.map
|
|
138
|
+
//# debugId=a559d75e-b978-551b-aaf6-c1a5a06e7cfd
|
|
@@ -0,0 +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"],"names":[],"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","debugId":"a559d75e-b978-551b-aaf6-c1a5a06e7cfd"}
|
package/dist/error-capture.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* consumer would have seen from a `catch` that assumed an `Error`.
|
|
29
29
|
*/
|
|
30
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]="
|
|
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
32
|
const serializeCapturedError = (error, omitProps) => {
|
|
33
33
|
if (!(error instanceof Error)) return JSON.stringify({
|
|
34
34
|
name: "Error",
|
|
@@ -62,4 +62,4 @@ const deserializeCapturedError = (json, { unparseableMessage, fallbackMessage })
|
|
|
62
62
|
export { deserializeCapturedError, serializeCapturedError };
|
|
63
63
|
|
|
64
64
|
//# sourceMappingURL=error-capture.js.map
|
|
65
|
-
//# debugId=
|
|
65
|
+
//# debugId=543df2da-8d0b-579f-8a26-5392d803794b
|