@alwaysmeticulous/backend-recorder-workerd 2.325.0 → 2.326.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.
Files changed (62) hide show
  1. package/README.md +94 -4
  2. package/dist/binding-patch.js +17 -8
  3. package/dist/binding-patch.js.map +1 -1
  4. package/dist/capture-buffer.js +114 -0
  5. package/dist/capture-buffer.js.map +1 -0
  6. package/dist/context.js +6 -3
  7. package/dist/context.js.map +1 -1
  8. package/dist/error-capture.d.ts +44 -0
  9. package/dist/error-capture.js +65 -0
  10. package/dist/error-capture.js.map +1 -0
  11. package/dist/fetch-patch.js +5 -5
  12. package/dist/fetch-patch.js.map +1 -1
  13. package/dist/index.d.ts +62 -39
  14. package/dist/index.js +65 -179
  15. package/dist/index.js.map +1 -1
  16. package/dist/kv-patch.js +9 -9
  17. package/dist/kv-patch.js.map +1 -1
  18. package/dist/outbound-capture.js +20 -28
  19. package/dist/outbound-capture.js.map +1 -1
  20. package/dist/pages-function.d.ts +33 -0
  21. package/dist/pages-function.js +28 -0
  22. package/dist/pages-function.js.map +1 -0
  23. package/dist/postgres/capture.d.ts +108 -0
  24. package/dist/postgres/capture.js +136 -0
  25. package/dist/postgres/capture.js.map +1 -0
  26. package/dist/postgres/with-meticulous-postgres.d.ts +32 -0
  27. package/dist/postgres/with-meticulous-postgres.js +152 -0
  28. package/dist/postgres/with-meticulous-postgres.js.map +1 -0
  29. package/dist/protocol.d.ts +76 -11
  30. package/dist/protocol.js +12 -7
  31. package/dist/protocol.js.map +1 -1
  32. package/dist/provisional-session-id.d.ts +58 -0
  33. package/dist/provisional-session-id.js +75 -0
  34. package/dist/provisional-session-id.js.map +1 -0
  35. package/dist/publish-session-id.js +41 -0
  36. package/dist/publish-session-id.js.map +1 -0
  37. package/dist/replay-fetch.js +4 -3
  38. package/dist/replay-fetch.js.map +1 -1
  39. package/dist/sidecar-client.js +29 -18
  40. package/dist/sidecar-client.js.map +1 -1
  41. package/dist/sidecar-transport.d.ts +38 -0
  42. package/dist/sidecar-transport.js +52 -0
  43. package/dist/sidecar-transport.js.map +1 -0
  44. package/dist/spans/attributes.d.ts +84 -0
  45. package/dist/spans/attributes.js +88 -0
  46. package/dist/spans/attributes.js.map +1 -0
  47. package/dist/spans/readable-span.d.ts +78 -0
  48. package/dist/spans/readable-span.js +35 -0
  49. package/dist/spans/readable-span.js.map +1 -0
  50. package/dist/spans/serialize-span.d.ts +14 -0
  51. package/dist/spans/serialize-span.js +38 -0
  52. package/dist/spans/serialize-span.js.map +1 -0
  53. package/dist/spans/span-builder.d.ts +61 -0
  54. package/dist/spans/span-builder.js +250 -0
  55. package/dist/spans/span-builder.js.map +1 -0
  56. package/dist/spans/url-redaction.d.ts +14 -0
  57. package/dist/spans/url-redaction.js +40 -0
  58. package/dist/spans/url-redaction.js.map +1 -0
  59. package/dist/with-meticulous.d.ts +32 -0
  60. package/dist/with-meticulous.js +0 -0
  61. package/dist/with-meticulous.js.map +1 -0
  62. package/package.json +2 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"readable-span.js","sources":["../../src/spans/readable-span.ts"],"sourcesContent":["/**\n * The span shape both recording surfaces produce, and the serialization that turns it into the\n * JSON a recorded session is stored as.\n *\n * Lives here rather than in the Node recorder because a Worker-hosted sidecar has to write the\n * same wire format from inside workerd, where neither `@opentelemetry/api` nor the Node\n * recorder's bundle can be loaded. `packages/backend-recorder-js/src/exporters/span-serializer.ts`\n * re-exports all of this and layers its richer, per-instrumentation attribute types on top, so\n * the *logic* has exactly one implementation while the documentation types stay where they are\n * used.\n */\n\n/**\n * OpenTelemetry's `SpanKind`, inlined. The shim has no runtime dependencies — pulling in\n * `@opentelemetry/api` for two numeric enums would put it in every customer's worker bundle.\n */\nexport const SPAN_KIND_INTERNAL = 0;\nexport const SPAN_KIND_SERVER = 1;\nexport const SPAN_KIND_CLIENT = 2;\n\n/** OpenTelemetry's `SpanStatusCode`, inlined for the same reason. */\nexport const SPAN_STATUS_UNSET = 0;\nexport const SPAN_STATUS_OK = 1;\nexport const SPAN_STATUS_ERROR = 2;\n\nexport interface SpanStatus {\n code: number;\n message?: string;\n}\n\n/** `[seconds, nanoseconds]` — OpenTelemetry's `HrTime`. */\nexport type HrTime = [number, number];\n\nexport interface ReadableSpan {\n name: string;\n /**\n * Set by the hand-built spans in `span-builder.ts`. OTel-SDK spans carry the parent in\n * {@link ReadableSpan.parentSpanContext} instead.\n */\n parentSpanId?: string;\n /**\n * How an OTel-SDK span reports its parent since `@opentelemetry/sdk-trace-base` v2, which\n * dropped the flat `parentSpanId` field.\n */\n parentSpanContext?: { spanId: string };\n kind: number;\n status: unknown;\n startTime: HrTime;\n endTime: HrTime;\n duration: HrTime;\n attributes?: Record<string, unknown>;\n spanContext(): { traceId: string; spanId: string };\n}\n\n/**\n * A serialized span, with the attribute bag left open. The Node recorder narrows `attributes`\n * to its union of per-instrumentation contracts; nothing about the wire format depends on that\n * narrowing.\n */\nexport interface SerializedSpanBase {\n name: string;\n traceId: string;\n spanId: string;\n parentSpanId: string | undefined;\n kind: number;\n status: unknown;\n startTimeMs: number;\n endTimeMs: number;\n durationMs: number;\n clientTechnology?: string;\n frontendSessionId?: string;\n attributes: Record<string, unknown>;\n}\n\nexport const hrTimeToMs = (hrTime: HrTime): number =>\n hrTime[0] * 1000 + hrTime[1] / 1e6;\n\nexport const msToHrTime = (ms: number): HrTime => {\n const seconds = Math.floor(ms / 1000);\n return [seconds, Math.round((ms - seconds * 1000) * 1e6)];\n};\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;AAgBA,MAAa,qBAAqB;AAClC,MAAa,mBAAmB;AAChC,MAAa,mBAAmB;;AAGhC,MAAa,oBAAoB;AACjC,MAAa,iBAAiB;AAC9B,MAAa,oBAAoB;AAmDjC,MAAa,cAAc,WACzB,OAAO,KAAK,MAAO,OAAO,KAAK;AAEjC,MAAa,cAAc,OAAuB;CAChD,MAAM,UAAU,KAAK,MAAM,KAAK,IAAK;AACrC,QAAO,CAAC,SAAS,KAAK,OAAO,KAAK,UAAU,OAAQ,IAAI,CAAC","debugId":"ac19293d-f05f-503b-bbe1-10eeca91129c"}
@@ -0,0 +1,14 @@
1
+ import { ReadableSpan, SerializedSpanBase } from "./readable-span.js";
2
+
3
+ //#region src/spans/serialize-span.d.ts
4
+ /**
5
+ * Turns a span into the JSON shape a recorded backend session stores.
6
+ *
7
+ * Shared by the Node exporter and the Worker-hosted sidecar so the two cannot drift: ingestion
8
+ * and every replay mock store read these field names, and a sidecar writing a near-miss of
9
+ * this shape would produce recordings that load but never match.
10
+ */
11
+ declare const serializeSpan: (span: ReadableSpan) => SerializedSpanBase;
12
+ //#endregion
13
+ export { serializeSpan };
14
+ //# sourceMappingURL=serialize-span.d.ts.map
@@ -0,0 +1,38 @@
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]="cfddc971-c481-52dc-a19b-556947e13f49")}catch(e){}}();
3
+ import { CLIENT_TECHNOLOGY_ATTR, FRONTEND_SESSION_ID_ATTR } from "./attributes.js";
4
+ import { hrTimeToMs } from "./readable-span.js";
5
+ //#region src/spans/serialize-span.ts
6
+ /**
7
+ * Turns a span into the JSON shape a recorded backend session stores.
8
+ *
9
+ * Shared by the Node exporter and the Worker-hosted sidecar so the two cannot drift: ingestion
10
+ * and every replay mock store read these field names, and a sidecar writing a near-miss of
11
+ * this shape would produce recordings that load but never match.
12
+ */
13
+ const serializeSpan = (span) => {
14
+ const ctx = span.spanContext();
15
+ const attributes = { ...span.attributes ?? {} };
16
+ const clientTechnology = attributes[CLIENT_TECHNOLOGY_ATTR];
17
+ const frontendSessionId = attributes[FRONTEND_SESSION_ID_ATTR];
18
+ delete attributes[CLIENT_TECHNOLOGY_ATTR];
19
+ return {
20
+ name: span.name,
21
+ traceId: ctx.traceId,
22
+ spanId: ctx.spanId,
23
+ parentSpanId: span.parentSpanId ?? span.parentSpanContext?.spanId,
24
+ kind: span.kind,
25
+ status: span.status,
26
+ startTimeMs: hrTimeToMs(span.startTime),
27
+ endTimeMs: hrTimeToMs(span.endTime),
28
+ durationMs: hrTimeToMs(span.duration),
29
+ ...clientTechnology ? { clientTechnology } : {},
30
+ ...frontendSessionId ? { frontendSessionId } : {},
31
+ attributes
32
+ };
33
+ };
34
+ //#endregion
35
+ export { serializeSpan };
36
+
37
+ //# sourceMappingURL=serialize-span.js.map
38
+ //# debugId=cfddc971-c481-52dc-a19b-556947e13f49
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serialize-span.js","sources":["../../src/spans/serialize-span.ts"],"sourcesContent":["import { CLIENT_TECHNOLOGY_ATTR, FRONTEND_SESSION_ID_ATTR } from \"./attributes\";\nimport {\n hrTimeToMs,\n type ReadableSpan,\n type SerializedSpanBase,\n} from \"./readable-span\";\n\n/**\n * Turns a span into the JSON shape a recorded backend session stores.\n *\n * Shared by the Node exporter and the Worker-hosted sidecar so the two cannot drift: ingestion\n * and every replay mock store read these field names, and a sidecar writing a near-miss of\n * this shape would produce recordings that load but never match.\n */\nexport const serializeSpan = (span: ReadableSpan): SerializedSpanBase => {\n const ctx = span.spanContext();\n // Copy so we never mutate the live OTel span's attributes.\n const attributes = { ...(span.attributes ?? {}) };\n\n const clientTechnology = attributes[CLIENT_TECHNOLOGY_ATTR] as\n | string\n | undefined;\n const frontendSessionId = attributes[FRONTEND_SESSION_ID_ATTR] as\n | string\n | undefined;\n\n // client_technology moves to the top level; frontend_session_id is copied (left in place for\n // backward compatibility — see download-sessions.ts in the webapp repo).\n delete attributes[CLIENT_TECHNOLOGY_ATTR];\n\n return {\n name: span.name,\n traceId: ctx.traceId,\n spanId: ctx.spanId,\n parentSpanId: span.parentSpanId ?? span.parentSpanContext?.spanId,\n kind: span.kind,\n status: span.status,\n startTimeMs: hrTimeToMs(span.startTime),\n endTimeMs: hrTimeToMs(span.endTime),\n durationMs: hrTimeToMs(span.duration),\n // Spread conditionally so we never emit `key: undefined` (exactOptionalPropertyTypes).\n ...(clientTechnology ? { clientTechnology } : {}),\n ...(frontendSessionId ? { frontendSessionId } : {}),\n attributes,\n };\n};\n"],"names":[],"mappings":";;;;;;;;;;;;AAcA,MAAa,iBAAiB,SAA2C;CACvE,MAAM,MAAM,KAAK,aAAa;CAE9B,MAAM,aAAa,EAAE,GAAI,KAAK,cAAc,EAAE,EAAG;CAEjD,MAAM,mBAAmB,WAAW;CAGpC,MAAM,oBAAoB,WAAW;AAMrC,QAAO,WAAW;AAElB,QAAO;EACL,MAAM,KAAK;EACX,SAAS,IAAI;EACb,QAAQ,IAAI;EACZ,cAAc,KAAK,gBAAgB,KAAK,mBAAmB;EAC3D,MAAM,KAAK;EACX,QAAQ,KAAK;EACb,aAAa,WAAW,KAAK,UAAU;EACvC,WAAW,WAAW,KAAK,QAAQ;EACnC,YAAY,WAAW,KAAK,SAAS;EAErC,GAAI,mBAAmB,EAAE,kBAAkB,GAAG,EAAE;EAChD,GAAI,oBAAoB,EAAE,mBAAmB,GAAG,EAAE;EAClD;EACD","debugId":"cfddc971-c481-52dc-a19b-556947e13f49"}
@@ -0,0 +1,61 @@
1
+ import { CaptureEvent } from "../protocol.js";
2
+ import { ReadableSpan } from "./readable-span.js";
3
+
4
+ //#region src/spans/span-builder.d.ts
5
+ /**
6
+ * Builds spans from shim capture events.
7
+ *
8
+ * Each inbound request becomes a SERVER span and the root of its own trace; every other event of
9
+ * that request becomes a CLIENT span parented under it. The shim supplies the trace and
10
+ * server-span ids (see `CorrelatedEvent`), so building a span needs no memory of earlier events
11
+ * — which is what lets a Worker-hosted sidecar be evicted between two batches of one request
12
+ * without splitting its trace.
13
+ *
14
+ * The `requestId → ids` fallback below exists only for a shim that predates those fields. It is
15
+ * the reason this is a class rather than a function.
16
+ *
17
+ * Shared with the Node recorder's sidecar (`packages/backend-recorder-js/src/sidecar/`), which
18
+ * re-exports it: both sidecars must produce identical spans or a recording made through one
19
+ * cannot be replayed by the machinery built for the other.
20
+ */
21
+ declare class SpanBuilder {
22
+ /**
23
+ * Legacy fallback ids, keyed by `requestId`. Outbound events usually arrive BEFORE their
24
+ * inbound event (the inbound is only reported once the handler completes), so an entry is
25
+ * created lazily on first sight from either event kind.
26
+ */
27
+ private readonly traceContexts;
28
+ build(event: CaptureEvent): ReadableSpan | null;
29
+ private buildInboundSpan;
30
+ private buildOutboundSpan;
31
+ /**
32
+ * A binding call. Named after the binding rather than the bare method, since the `env` key is
33
+ * what identifies the call — the URL is whatever the app invented.
34
+ */
35
+ private buildBindingSpan;
36
+ /**
37
+ * A KV operation. Named `kv.<binding>.<operation>` after the Prisma convention
38
+ * (`prisma.<model>.<operation>`) rather than the `METHOD TARGET` shape the HTTP-like spans use
39
+ * — nothing about a KV call is an HTTP method, and the dotted name keeps a `get` on a
40
+ * namespace distinguishable from a `GET` through a service binding at a glance.
41
+ */
42
+ private buildKvSpan;
43
+ /**
44
+ * A postgres.js query. Name and attributes match what the Node instrumentation emits
45
+ * (`postgresjs.<command>`), because the same store loads both.
46
+ */
47
+ private buildPostgresSpan;
48
+ private buildHttpClientSpan;
49
+ private buildClientSpan;
50
+ /**
51
+ * The trace and server-span ids for an event: the shim's, when it sent them, and otherwise a
52
+ * pair invented per `requestId` and remembered — the pre-`traceId` behaviour.
53
+ */
54
+ private resolveIds;
55
+ private pruneTraceContexts;
56
+ }
57
+ /** WebCrypto rather than `node:crypto`, so this runs in a Worker as well as in Node. */
58
+ declare const randomHex: (bytes: number) => string;
59
+ //#endregion
60
+ export { SpanBuilder, randomHex };
61
+ //# sourceMappingURL=span-builder.d.ts.map
@@ -0,0 +1,250 @@
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]="2d36ad42-81ac-5fed-988b-7d53c46a5f5d")}catch(e){}}();
3
+ import { CAPTURED_HEADERS } from "../protocol.js";
4
+ import { MAX_POSTGRES_JS_RESULT_SIZE, POSTGRES_JS_ERROR_ATTR, POSTGRES_JS_QUERY_PARAMS_ATTR, POSTGRES_JS_QUERY_TEXT_ATTR, POSTGRES_JS_RESULT_ATTR, POSTGRES_JS_RESULT_TRUNCATED_ATTR, POSTGRES_JS_ROW_MODE_ATTR, postgresJsCommandOf } from "../postgres/capture.js";
5
+ import { CLIENT_TECHNOLOGY_ATTR, CLIENT_TECHNOLOGY_POSTGRES_JS, CLIENT_TECHNOLOGY_WORKERD_BINDING, CLIENT_TECHNOLOGY_WORKERD_FETCH, CLIENT_TECHNOLOGY_WORKERD_KV, FRONTEND_SESSION_ID_ATTR, SESSION_ID_ORIGIN_ATTR, WORKERD_BINDING_NAME_ATTR, WORKERD_KV_ARGS_ATTR, WORKERD_KV_ARGS_TRUNCATED_ATTR, WORKERD_KV_KEY_ATTR, WORKERD_KV_OMITTED_ATTR, WORKERD_KV_OPERATION_ATTR, WORKERD_KV_RESULT_ATTR, WORKERD_KV_RESULT_TRUNCATED_ATTR, WORKERD_KV_VALUE_ATTR, WORKERD_KV_VALUE_TRUNCATED_ATTR } from "./attributes.js";
6
+ import { msToHrTime } from "./readable-span.js";
7
+ import { redactUrlCredentials } from "./url-redaction.js";
8
+ //#region src/spans/span-builder.ts
9
+ /**
10
+ * Builds spans from shim capture events.
11
+ *
12
+ * Each inbound request becomes a SERVER span and the root of its own trace; every other event of
13
+ * that request becomes a CLIENT span parented under it. The shim supplies the trace and
14
+ * server-span ids (see `CorrelatedEvent`), so building a span needs no memory of earlier events
15
+ * — which is what lets a Worker-hosted sidecar be evicted between two batches of one request
16
+ * without splitting its trace.
17
+ *
18
+ * The `requestId → ids` fallback below exists only for a shim that predates those fields. It is
19
+ * the reason this is a class rather than a function.
20
+ *
21
+ * Shared with the Node recorder's sidecar (`packages/backend-recorder-js/src/sidecar/`), which
22
+ * re-exports it: both sidecars must produce identical spans or a recording made through one
23
+ * cannot be replayed by the machinery built for the other.
24
+ */
25
+ var SpanBuilder = class {
26
+ /**
27
+ * Legacy fallback ids, keyed by `requestId`. Outbound events usually arrive BEFORE their
28
+ * inbound event (the inbound is only reported once the handler completes), so an entry is
29
+ * created lazily on first sight from either event kind.
30
+ */
31
+ traceContexts = /* @__PURE__ */ new Map();
32
+ build(event) {
33
+ switch (event.kind) {
34
+ case "inbound": return this.buildInboundSpan(event);
35
+ case "outbound": return this.buildOutboundSpan(event);
36
+ case "binding": return this.buildBindingSpan(event);
37
+ case "kv": return this.buildKvSpan(event);
38
+ case "postgres": return this.buildPostgresSpan(event);
39
+ default: return null;
40
+ }
41
+ }
42
+ buildInboundSpan(event) {
43
+ const ids = this.resolveIds(event);
44
+ const url = parseUrl(event.url);
45
+ const attributes = buildHttpAttributes(event, url);
46
+ if (event.sessionIdOrigin !== void 0) attributes[SESSION_ID_ORIGIN_ATTR] = event.sessionIdOrigin;
47
+ return {
48
+ name: `${event.method} ${url.path}`,
49
+ kind: 1,
50
+ status: buildStatus(event, 1),
51
+ startTime: msToHrTime(event.startTimeMs),
52
+ endTime: msToHrTime(event.endTimeMs),
53
+ duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),
54
+ attributes,
55
+ spanContext: () => ({
56
+ traceId: ids.traceId,
57
+ spanId: ids.serverSpanId
58
+ })
59
+ };
60
+ }
61
+ buildOutboundSpan(event) {
62
+ return this.buildHttpClientSpan(event, CLIENT_TECHNOLOGY_WORKERD_FETCH, event.method);
63
+ }
64
+ /**
65
+ * A binding call. Named after the binding rather than the bare method, since the `env` key is
66
+ * what identifies the call — the URL is whatever the app invented.
67
+ */
68
+ buildBindingSpan(event) {
69
+ return this.buildHttpClientSpan(event, CLIENT_TECHNOLOGY_WORKERD_BINDING, `${event.method} ${event.bindingName ?? "$unknown"}`, event.bindingName !== void 0 ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName } : void 0);
70
+ }
71
+ /**
72
+ * A KV operation. Named `kv.<binding>.<operation>` after the Prisma convention
73
+ * (`prisma.<model>.<operation>`) rather than the `METHOD TARGET` shape the HTTP-like spans use
74
+ * — nothing about a KV call is an HTTP method, and the dotted name keeps a `get` on a
75
+ * namespace distinguishable from a `GET` through a service binding at a glance.
76
+ */
77
+ buildKvSpan(event) {
78
+ const attributes = {
79
+ [CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_WORKERD_KV,
80
+ [WORKERD_KV_OPERATION_ATTR]: event.operation,
81
+ ...event.bindingName !== void 0 ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName } : {},
82
+ ...event.key !== void 0 ? { [WORKERD_KV_KEY_ATTR]: event.key } : {},
83
+ ...event.omitted !== void 0 ? { [WORKERD_KV_OMITTED_ATTR]: event.omitted } : {},
84
+ ...event.error !== void 0 ? { "error.type": event.error } : {},
85
+ ...frontendSessionAttribute(event)
86
+ };
87
+ if (event.args !== void 0) {
88
+ attributes[WORKERD_KV_ARGS_ATTR] = event.args.body;
89
+ attributes[WORKERD_KV_ARGS_TRUNCATED_ATTR] = event.args.truncated;
90
+ }
91
+ if (event.value !== void 0) {
92
+ attributes[WORKERD_KV_VALUE_ATTR] = event.value.body;
93
+ attributes[WORKERD_KV_VALUE_TRUNCATED_ATTR] = event.value.truncated;
94
+ }
95
+ if (event.result !== void 0) {
96
+ attributes[WORKERD_KV_RESULT_ATTR] = event.result.body;
97
+ attributes[WORKERD_KV_RESULT_TRUNCATED_ATTR] = event.result.truncated;
98
+ }
99
+ return this.buildClientSpan(event, `kv.${event.bindingName ?? "$unknown"}.${event.operation}`, attributes, event.error !== void 0 ? {
100
+ code: 2,
101
+ message: event.error
102
+ } : { code: 0 });
103
+ }
104
+ /**
105
+ * A postgres.js query. Name and attributes match what the Node instrumentation emits
106
+ * (`postgresjs.<command>`), because the same store loads both.
107
+ */
108
+ buildPostgresSpan(event) {
109
+ const attributes = {
110
+ [CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_POSTGRES_JS,
111
+ [POSTGRES_JS_QUERY_TEXT_ATTR]: event.queryText,
112
+ [POSTGRES_JS_QUERY_PARAMS_ATTR]: event.params,
113
+ [POSTGRES_JS_ROW_MODE_ATTR]: event.rowMode,
114
+ ...frontendSessionAttribute(event)
115
+ };
116
+ if (event.result !== void 0) {
117
+ const truncated = event.result.truncated || event.result.body.length > 262144;
118
+ attributes[POSTGRES_JS_RESULT_ATTR] = truncated ? event.result.body.slice(0, MAX_POSTGRES_JS_RESULT_SIZE) : event.result.body;
119
+ attributes[POSTGRES_JS_RESULT_TRUNCATED_ATTR] = truncated;
120
+ }
121
+ if (event.errorJson !== void 0) attributes[POSTGRES_JS_ERROR_ATTR] = event.errorJson;
122
+ return this.buildClientSpan(event, `postgresjs.${postgresJsCommandOf(event.queryText)}`, attributes, event.errorJson !== void 0 ? { code: 2 } : { code: 0 });
123
+ }
124
+ buildHttpClientSpan(event, technology, name, extraAttributes) {
125
+ const attributes = buildHttpAttributes(event, parseUrl(event.url));
126
+ attributes[CLIENT_TECHNOLOGY_ATTR] = technology;
127
+ Object.assign(attributes, extraAttributes);
128
+ if (event.requestBody !== void 0) {
129
+ attributes["http.request.body"] = event.requestBody.body;
130
+ attributes["http.request.body.truncated"] = event.requestBody.truncated;
131
+ }
132
+ if (event.responseBody !== void 0) {
133
+ attributes["http.response.body"] = event.responseBody.body;
134
+ attributes["http.response.body.truncated"] = event.responseBody.truncated;
135
+ }
136
+ return this.buildClientSpan(event, name, attributes, buildStatus(event, 2));
137
+ }
138
+ buildClientSpan(event, name, attributes, status) {
139
+ const ids = this.resolveIds(event);
140
+ const spanId = randomHex(8);
141
+ return {
142
+ name,
143
+ parentSpanId: ids.serverSpanId,
144
+ kind: 2,
145
+ status,
146
+ startTime: msToHrTime(event.startTimeMs),
147
+ endTime: msToHrTime(event.endTimeMs),
148
+ duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),
149
+ attributes,
150
+ spanContext: () => ({
151
+ traceId: ids.traceId,
152
+ spanId
153
+ })
154
+ };
155
+ }
156
+ /**
157
+ * The trace and server-span ids for an event: the shim's, when it sent them, and otherwise a
158
+ * pair invented per `requestId` and remembered — the pre-`traceId` behaviour.
159
+ */
160
+ resolveIds(event) {
161
+ if (event.traceId !== void 0 && event.serverSpanId !== void 0) return {
162
+ traceId: event.traceId,
163
+ serverSpanId: event.serverSpanId,
164
+ lastSeenMs: event.endTimeMs
165
+ };
166
+ const existing = this.traceContexts.get(event.requestId);
167
+ if (existing) {
168
+ existing.lastSeenMs = event.endTimeMs;
169
+ return existing;
170
+ }
171
+ this.pruneTraceContexts(event.endTimeMs);
172
+ const entry = {
173
+ traceId: randomHex(16),
174
+ serverSpanId: randomHex(8),
175
+ lastSeenMs: event.endTimeMs
176
+ };
177
+ this.traceContexts.set(event.requestId, entry);
178
+ return entry;
179
+ }
180
+ pruneTraceContexts(nowMs) {
181
+ if (this.traceContexts.size < TRACE_CONTEXT_PRUNE_THRESHOLD) return;
182
+ for (const [requestId, entry] of this.traceContexts) if (nowMs - entry.lastSeenMs > TRACE_CONTEXT_TTL_MS) this.traceContexts.delete(requestId);
183
+ }
184
+ };
185
+ const TRACE_CONTEXT_TTL_MS = 600 * 1e3;
186
+ const TRACE_CONTEXT_PRUNE_THRESHOLD = 1e3;
187
+ const HEX = "0123456789abcdef";
188
+ /** WebCrypto rather than `node:crypto`, so this runs in a Worker as well as in Node. */
189
+ const randomHex = (bytes) => {
190
+ const buffer = crypto.getRandomValues(new Uint8Array(bytes));
191
+ let hex = "";
192
+ for (const byte of buffer) hex += HEX[byte >> 4] + HEX[byte & 15];
193
+ return hex;
194
+ };
195
+ const capturedHeaders = new Set(CAPTURED_HEADERS);
196
+ const parseUrl = (raw) => {
197
+ try {
198
+ const url = redactUrlCredentials(new URL(raw));
199
+ const defaultPort = url.protocol === "https:" ? 443 : 80;
200
+ return {
201
+ full: url.toString(),
202
+ path: url.pathname,
203
+ query: url.search ? url.search.slice(1) : void 0,
204
+ scheme: url.protocol.replace(/:$/, ""),
205
+ host: url.hostname,
206
+ port: url.port ? parseInt(url.port, 10) : defaultPort
207
+ };
208
+ } catch {
209
+ return {
210
+ full: raw,
211
+ path: raw.split("?")[0] || "/",
212
+ query: void 0,
213
+ scheme: void 0,
214
+ host: void 0,
215
+ port: void 0
216
+ };
217
+ }
218
+ };
219
+ const frontendSessionAttribute = (event) => event.frontendSessionId !== void 0 ? { [FRONTEND_SESSION_ID_ATTR]: event.frontendSessionId } : {};
220
+ const buildHttpAttributes = (event, url) => {
221
+ const attributes = {
222
+ "http.request.method": event.method,
223
+ "url.full": url.full,
224
+ "url.path": url.path,
225
+ ...url.query !== void 0 ? { "url.query": url.query } : {},
226
+ ...url.scheme !== void 0 ? { "url.scheme": url.scheme } : {},
227
+ ...url.host !== void 0 ? { "server.address": url.host } : {},
228
+ ...url.port !== void 0 ? { "server.port": url.port } : {},
229
+ ...event.statusCode !== void 0 ? { "http.response.status_code": event.statusCode } : {},
230
+ ...event.error !== void 0 ? { "error.type": event.error } : {},
231
+ ...frontendSessionAttribute(event)
232
+ };
233
+ for (const [name, values] of Object.entries(event.requestHeaders)) if (capturedHeaders.has(name.toLowerCase())) attributes[`http.request.header.${name}`] = values;
234
+ for (const [name, values] of Object.entries(event.responseHeaders ?? {})) if (capturedHeaders.has(name.toLowerCase())) attributes[`http.response.header.${name}`] = values;
235
+ return attributes;
236
+ };
237
+ const buildStatus = (event, kind) => {
238
+ if (event.error !== void 0) return {
239
+ code: 2,
240
+ message: event.error
241
+ };
242
+ const errorThreshold = kind === 2 ? 400 : 500;
243
+ if (event.statusCode !== void 0 && event.statusCode >= errorThreshold) return { code: 2 };
244
+ return { code: 0 };
245
+ };
246
+ //#endregion
247
+ export { SpanBuilder, randomHex };
248
+
249
+ //# sourceMappingURL=span-builder.js.map
250
+ //# debugId=2d36ad42-81ac-5fed-988b-7d53c46a5f5d
@@ -0,0 +1 @@
1
+ {"version":3,"file":"span-builder.js","sources":["../../src/spans/span-builder.ts"],"sourcesContent":["import {\n type BindingRequestEvent,\n CAPTURED_HEADERS,\n type CaptureEvent,\n type InboundRequestEvent,\n type KvOperationEvent,\n type OutboundRequestEvent,\n type PostgresQueryEvent,\n} from \"../protocol\";\nimport {\n MAX_POSTGRES_JS_RESULT_SIZE,\n POSTGRES_JS_ERROR_ATTR,\n POSTGRES_JS_QUERY_PARAMS_ATTR,\n POSTGRES_JS_QUERY_TEXT_ATTR,\n POSTGRES_JS_RESULT_ATTR,\n POSTGRES_JS_RESULT_TRUNCATED_ATTR,\n POSTGRES_JS_ROW_MODE_ATTR,\n postgresJsCommandOf,\n} from \"../postgres/capture\";\nimport {\n CLIENT_TECHNOLOGY_ATTR,\n CLIENT_TECHNOLOGY_POSTGRES_JS,\n CLIENT_TECHNOLOGY_WORKERD_BINDING,\n CLIENT_TECHNOLOGY_WORKERD_FETCH,\n CLIENT_TECHNOLOGY_WORKERD_KV,\n FRONTEND_SESSION_ID_ATTR,\n SESSION_ID_ORIGIN_ATTR,\n WORKERD_BINDING_NAME_ATTR,\n WORKERD_KV_ARGS_ATTR,\n WORKERD_KV_ARGS_TRUNCATED_ATTR,\n WORKERD_KV_KEY_ATTR,\n WORKERD_KV_OMITTED_ATTR,\n WORKERD_KV_OPERATION_ATTR,\n WORKERD_KV_RESULT_ATTR,\n WORKERD_KV_RESULT_TRUNCATED_ATTR,\n WORKERD_KV_VALUE_ATTR,\n WORKERD_KV_VALUE_TRUNCATED_ATTR,\n} from \"./attributes\";\nimport {\n msToHrTime,\n type ReadableSpan,\n SPAN_KIND_CLIENT,\n SPAN_KIND_SERVER,\n SPAN_STATUS_ERROR,\n SPAN_STATUS_UNSET,\n type SpanStatus,\n} from \"./readable-span\";\nimport { redactUrlCredentials } from \"./url-redaction\";\n\n/**\n * Builds spans from shim capture events.\n *\n * Each inbound request becomes a SERVER span and the root of its own trace; every other event of\n * that request becomes a CLIENT span parented under it. The shim supplies the trace and\n * server-span ids (see `CorrelatedEvent`), so building a span needs no memory of earlier events\n * — which is what lets a Worker-hosted sidecar be evicted between two batches of one request\n * without splitting its trace.\n *\n * The `requestId → ids` fallback below exists only for a shim that predates those fields. It is\n * the reason this is a class rather than a function.\n *\n * Shared with the Node recorder's sidecar (`packages/backend-recorder-js/src/sidecar/`), which\n * re-exports it: both sidecars must produce identical spans or a recording made through one\n * cannot be replayed by the machinery built for the other.\n */\nexport class SpanBuilder {\n /**\n * Legacy fallback ids, keyed by `requestId`. Outbound events usually arrive BEFORE their\n * inbound event (the inbound is only reported once the handler completes), so an entry is\n * created lazily on first sight from either event kind.\n */\n private readonly traceContexts = new Map<string, TraceContextEntry>();\n\n build(event: CaptureEvent): ReadableSpan | null {\n switch (event.kind) {\n case \"inbound\":\n return this.buildInboundSpan(event);\n case \"outbound\":\n return this.buildOutboundSpan(event);\n case \"binding\":\n return this.buildBindingSpan(event);\n case \"kv\":\n return this.buildKvSpan(event);\n case \"postgres\":\n return this.buildPostgresSpan(event);\n default:\n // Wire input, possibly from a newer shim — skip rather than crash.\n return null;\n }\n }\n\n private buildInboundSpan(event: InboundRequestEvent): ReadableSpan {\n const ids = this.resolveIds(event);\n const url = parseUrl(event.url);\n const attributes = buildHttpAttributes(event, url);\n\n // Only the SERVER span carries it, matching MetHttpInstrumentation on the Node path: the\n // marker describes where this request's session id came from, and the CLIENT spans under\n // it share the same trace.\n if (event.sessionIdOrigin !== undefined) {\n attributes[SESSION_ID_ORIGIN_ATTR] = event.sessionIdOrigin;\n }\n\n return {\n name: `${event.method} ${url.path}`,\n kind: SPAN_KIND_SERVER,\n status: buildStatus(event, SPAN_KIND_SERVER),\n startTime: msToHrTime(event.startTimeMs),\n endTime: msToHrTime(event.endTimeMs),\n duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),\n attributes,\n spanContext: () => ({\n traceId: ids.traceId,\n spanId: ids.serverSpanId,\n }),\n };\n }\n\n private buildOutboundSpan(event: OutboundRequestEvent): ReadableSpan {\n return this.buildHttpClientSpan(\n event,\n CLIENT_TECHNOLOGY_WORKERD_FETCH,\n event.method,\n );\n }\n\n /**\n * A binding call. Named after the binding rather than the bare method, since the `env` key is\n * what identifies the call — the URL is whatever the app invented.\n */\n private buildBindingSpan(event: BindingRequestEvent): ReadableSpan {\n return this.buildHttpClientSpan(\n event,\n CLIENT_TECHNOLOGY_WORKERD_BINDING,\n `${event.method} ${event.bindingName ?? \"$unknown\"}`,\n event.bindingName !== undefined\n ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName }\n : undefined,\n );\n }\n\n /**\n * A KV operation. Named `kv.<binding>.<operation>` after the Prisma convention\n * (`prisma.<model>.<operation>`) rather than the `METHOD TARGET` shape the HTTP-like spans use\n * — nothing about a KV call is an HTTP method, and the dotted name keeps a `get` on a\n * namespace distinguishable from a `GET` through a service binding at a glance.\n */\n private buildKvSpan(event: KvOperationEvent): ReadableSpan {\n const attributes: Record<string, unknown> = {\n [CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_WORKERD_KV,\n [WORKERD_KV_OPERATION_ATTR]: event.operation,\n ...(event.bindingName !== undefined\n ? { [WORKERD_BINDING_NAME_ATTR]: event.bindingName }\n : {}),\n ...(event.key !== undefined ? { [WORKERD_KV_KEY_ATTR]: event.key } : {}),\n ...(event.omitted !== undefined\n ? { [WORKERD_KV_OMITTED_ATTR]: event.omitted }\n : {}),\n ...(event.error !== undefined ? { \"error.type\": event.error } : {}),\n ...frontendSessionAttribute(event),\n };\n if (event.args !== undefined) {\n attributes[WORKERD_KV_ARGS_ATTR] = event.args.body;\n attributes[WORKERD_KV_ARGS_TRUNCATED_ATTR] = event.args.truncated;\n }\n if (event.value !== undefined) {\n attributes[WORKERD_KV_VALUE_ATTR] = event.value.body;\n attributes[WORKERD_KV_VALUE_TRUNCATED_ATTR] = event.value.truncated;\n }\n if (event.result !== undefined) {\n attributes[WORKERD_KV_RESULT_ATTR] = event.result.body;\n attributes[WORKERD_KV_RESULT_TRUNCATED_ATTR] = event.result.truncated;\n }\n\n return this.buildClientSpan(\n event,\n `kv.${event.bindingName ?? \"$unknown\"}.${event.operation}`,\n attributes,\n event.error !== undefined\n ? { code: SPAN_STATUS_ERROR, message: event.error }\n : { code: SPAN_STATUS_UNSET },\n );\n }\n\n /**\n * A postgres.js query. Name and attributes match what the Node instrumentation emits\n * (`postgresjs.<command>`), because the same store loads both.\n */\n private buildPostgresSpan(event: PostgresQueryEvent): ReadableSpan {\n const attributes: Record<string, unknown> = {\n [CLIENT_TECHNOLOGY_ATTR]: CLIENT_TECHNOLOGY_POSTGRES_JS,\n [POSTGRES_JS_QUERY_TEXT_ATTR]: event.queryText,\n [POSTGRES_JS_QUERY_PARAMS_ATTR]: event.params,\n [POSTGRES_JS_ROW_MODE_ATTR]: event.rowMode,\n ...frontendSessionAttribute(event),\n };\n if (event.result !== undefined) {\n // Re-cap here as well as at capture time: the attribute contract is what the store\n // reads, and a shim that capped differently must not widen it.\n const truncated =\n event.result.truncated ||\n event.result.body.length > MAX_POSTGRES_JS_RESULT_SIZE;\n attributes[POSTGRES_JS_RESULT_ATTR] = truncated\n ? event.result.body.slice(0, MAX_POSTGRES_JS_RESULT_SIZE)\n : event.result.body;\n attributes[POSTGRES_JS_RESULT_TRUNCATED_ATTR] = truncated;\n }\n if (event.errorJson !== undefined) {\n attributes[POSTGRES_JS_ERROR_ATTR] = event.errorJson;\n }\n\n return this.buildClientSpan(\n event,\n `postgresjs.${postgresJsCommandOf(event.queryText)}`,\n attributes,\n event.errorJson !== undefined\n ? { code: SPAN_STATUS_ERROR }\n : { code: SPAN_STATUS_UNSET },\n );\n }\n\n private buildHttpClientSpan(\n event: OutboundRequestEvent | BindingRequestEvent,\n technology: string,\n name: string,\n extraAttributes?: Record<string, unknown>,\n ): ReadableSpan {\n const url = parseUrl(event.url);\n const attributes = buildHttpAttributes(event, url);\n attributes[CLIENT_TECHNOLOGY_ATTR] = technology;\n Object.assign(attributes, extraAttributes);\n if (event.requestBody !== undefined) {\n attributes[\"http.request.body\"] = event.requestBody.body;\n attributes[\"http.request.body.truncated\"] = event.requestBody.truncated;\n }\n if (event.responseBody !== undefined) {\n attributes[\"http.response.body\"] = event.responseBody.body;\n attributes[\"http.response.body.truncated\"] = event.responseBody.truncated;\n }\n\n return this.buildClientSpan(\n event,\n name,\n attributes,\n buildStatus(event, SPAN_KIND_CLIENT),\n );\n }\n\n private buildClientSpan(\n event: CaptureEvent,\n name: string,\n attributes: Record<string, unknown>,\n status: SpanStatus,\n ): ReadableSpan {\n const ids = this.resolveIds(event);\n const spanId = randomHex(8);\n return {\n name,\n parentSpanId: ids.serverSpanId,\n kind: SPAN_KIND_CLIENT,\n status,\n startTime: msToHrTime(event.startTimeMs),\n endTime: msToHrTime(event.endTimeMs),\n duration: msToHrTime(Math.max(0, event.endTimeMs - event.startTimeMs)),\n attributes,\n spanContext: () => ({ traceId: ids.traceId, spanId }),\n };\n }\n\n /**\n * The trace and server-span ids for an event: the shim's, when it sent them, and otherwise a\n * pair invented per `requestId` and remembered — the pre-`traceId` behaviour.\n */\n private resolveIds(event: CaptureEvent): TraceContextEntry {\n if (event.traceId !== undefined && event.serverSpanId !== undefined) {\n return {\n traceId: event.traceId,\n serverSpanId: event.serverSpanId,\n lastSeenMs: event.endTimeMs,\n };\n }\n const existing = this.traceContexts.get(event.requestId);\n if (existing) {\n existing.lastSeenMs = event.endTimeMs;\n return existing;\n }\n this.pruneTraceContexts(event.endTimeMs);\n const entry: TraceContextEntry = {\n traceId: randomHex(16),\n serverSpanId: randomHex(8),\n lastSeenMs: event.endTimeMs,\n };\n this.traceContexts.set(event.requestId, entry);\n return entry;\n }\n\n private pruneTraceContexts(nowMs: number): void {\n if (this.traceContexts.size < TRACE_CONTEXT_PRUNE_THRESHOLD) {\n return;\n }\n for (const [requestId, entry] of this.traceContexts) {\n if (nowMs - entry.lastSeenMs > TRACE_CONTEXT_TTL_MS) {\n this.traceContexts.delete(requestId);\n }\n }\n }\n}\n\ninterface TraceContextEntry {\n traceId: string;\n serverSpanId: string;\n lastSeenMs: number;\n}\n\nconst TRACE_CONTEXT_TTL_MS = 10 * 60 * 1000;\nconst TRACE_CONTEXT_PRUNE_THRESHOLD = 1_000;\n\nconst HEX = \"0123456789abcdef\";\n\n/** WebCrypto rather than `node:crypto`, so this runs in a Worker as well as in Node. */\nexport const randomHex = (bytes: number): string => {\n const buffer = crypto.getRandomValues(new Uint8Array(bytes));\n let hex = \"\";\n for (const byte of buffer) {\n hex += HEX[byte >> 4] + HEX[byte & 0x0f];\n }\n return hex;\n};\n\nconst capturedHeaders = new Set<string>(CAPTURED_HEADERS);\n\ninterface ParsedUrl {\n full: string;\n path: string;\n query: string | undefined;\n scheme: string | undefined;\n host: string | undefined;\n port: number | undefined;\n}\n\nconst parseUrl = (raw: string): ParsedUrl => {\n try {\n const url = redactUrlCredentials(new URL(raw));\n const defaultPort = url.protocol === \"https:\" ? 443 : 80;\n return {\n full: url.toString(),\n path: url.pathname,\n query: url.search ? url.search.slice(1) : undefined,\n scheme: url.protocol.replace(/:$/, \"\"),\n host: url.hostname,\n port: url.port ? parseInt(url.port, 10) : defaultPort,\n };\n } catch {\n return {\n full: raw,\n path: raw.split(\"?\")[0] || \"/\",\n query: undefined,\n scheme: undefined,\n host: undefined,\n port: undefined,\n };\n }\n};\n\nconst frontendSessionAttribute = (\n event: CaptureEvent,\n): Record<string, string> =>\n event.frontendSessionId !== undefined\n ? { [FRONTEND_SESSION_ID_ATTR]: event.frontendSessionId }\n : {};\n\nconst buildHttpAttributes = (\n event: InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent,\n url: ParsedUrl,\n): Record<string, unknown> => {\n const attributes: Record<string, unknown> = {\n \"http.request.method\": event.method,\n \"url.full\": url.full,\n \"url.path\": url.path,\n ...(url.query !== undefined ? { \"url.query\": url.query } : {}),\n ...(url.scheme !== undefined ? { \"url.scheme\": url.scheme } : {}),\n ...(url.host !== undefined ? { \"server.address\": url.host } : {}),\n ...(url.port !== undefined ? { \"server.port\": url.port } : {}),\n ...(event.statusCode !== undefined\n ? { \"http.response.status_code\": event.statusCode }\n : {}),\n ...(event.error !== undefined ? { \"error.type\": event.error } : {}),\n ...frontendSessionAttribute(event),\n };\n // The shim already filters to CAPTURED_HEADERS; re-filter here so a stale or third-party\n // shim can't persist sensitive headers via the sidecar.\n for (const [name, values] of Object.entries(event.requestHeaders)) {\n if (capturedHeaders.has(name.toLowerCase())) {\n attributes[`http.request.header.${name}`] = values;\n }\n }\n for (const [name, values] of Object.entries(event.responseHeaders ?? {})) {\n if (capturedHeaders.has(name.toLowerCase())) {\n attributes[`http.response.header.${name}`] = values;\n }\n }\n return attributes;\n};\n\nconst buildStatus = (\n event: InboundRequestEvent | OutboundRequestEvent | BindingRequestEvent,\n kind: number,\n): SpanStatus => {\n if (event.error !== undefined) {\n return { code: SPAN_STATUS_ERROR, message: event.error };\n }\n // Standard OTel HTTP semantics: client spans error on 4xx/5xx, server spans only on 5xx.\n const errorThreshold = kind === SPAN_KIND_CLIENT ? 400 : 500;\n if (event.statusCode !== undefined && event.statusCode >= errorThreshold) {\n return { code: SPAN_STATUS_ERROR };\n }\n return { code: SPAN_STATUS_UNSET };\n};\n"],"names":[],"mappings":"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","debugId":"2d36ad42-81ac-5fed-988b-7d53c46a5f5d"}
@@ -0,0 +1,14 @@
1
+ //#region src/spans/url-redaction.d.ts
2
+ /**
3
+ * Returns a copy of the URL with credential-bearing parts replaced: the signature-style query
4
+ * parameters above, and any `user:password@` userinfo.
5
+ *
6
+ * Record side only. A replay lookup passes the live URL through unredacted, exactly as the
7
+ * http, undici and sidecar lookup paths do, so a redacted parameter misses the exact tier and
8
+ * is served by the drop-query tier instead — the right outcome, since a signature could not
9
+ * have matched exactly across a record/replay boundary anyway.
10
+ */
11
+ declare const redactUrlCredentials: (url: URL) => URL;
12
+ //#endregion
13
+ export { redactUrlCredentials };
14
+ //# sourceMappingURL=url-redaction.d.ts.map
@@ -0,0 +1,40 @@
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]="a4ffc294-14bc-5f17-bbca-b0279e4181be")}catch(e){}}();
3
+ import { STR_REDACTED } from "../redact-body.js";
4
+ //#region src/spans/url-redaction.ts
5
+ /**
6
+ * URL query keys that carry a credential rather than identify a resource.
7
+ *
8
+ * Mirrors `DEFAULT_QUERY_STRINGS_TO_REDACT` in the Node recorder's forked http
9
+ * instrumentation. Both surfaces write URLs onto the same spans, so the two lists have to stay
10
+ * identical or the same request records differently depending on where it was captured.
11
+ */
12
+ const QUERY_STRINGS_TO_REDACT = [
13
+ "sig",
14
+ "Signature",
15
+ "AWSAccessKeyId",
16
+ "X-Goog-Signature"
17
+ ];
18
+ /**
19
+ * Returns a copy of the URL with credential-bearing parts replaced: the signature-style query
20
+ * parameters above, and any `user:password@` userinfo.
21
+ *
22
+ * Record side only. A replay lookup passes the live URL through unredacted, exactly as the
23
+ * http, undici and sidecar lookup paths do, so a redacted parameter misses the exact tier and
24
+ * is served by the drop-query tier instead — the right outcome, since a signature could not
25
+ * have matched exactly across a record/replay boundary anyway.
26
+ */
27
+ const redactUrlCredentials = (url) => {
28
+ const redacted = new URL(url.href);
29
+ for (const sensitiveParam of QUERY_STRINGS_TO_REDACT) if (redacted.searchParams.get(sensitiveParam)) redacted.searchParams.set(sensitiveParam, STR_REDACTED);
30
+ if (redacted.username || redacted.password) {
31
+ redacted.username = STR_REDACTED;
32
+ redacted.password = STR_REDACTED;
33
+ }
34
+ return redacted;
35
+ };
36
+ //#endregion
37
+ export { redactUrlCredentials };
38
+
39
+ //# sourceMappingURL=url-redaction.js.map
40
+ //# debugId=a4ffc294-14bc-5f17-bbca-b0279e4181be
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url-redaction.js","sources":["../../src/spans/url-redaction.ts"],"sourcesContent":["import { STR_REDACTED } from \"../redact-body\";\n\n/**\n * URL query keys that carry a credential rather than identify a resource.\n *\n * Mirrors `DEFAULT_QUERY_STRINGS_TO_REDACT` in the Node recorder's forked http\n * instrumentation. Both surfaces write URLs onto the same spans, so the two lists have to stay\n * identical or the same request records differently depending on where it was captured.\n */\nconst QUERY_STRINGS_TO_REDACT = [\n \"sig\",\n \"Signature\",\n \"AWSAccessKeyId\",\n \"X-Goog-Signature\",\n] as const;\n\n/**\n * Returns a copy of the URL with credential-bearing parts replaced: the signature-style query\n * parameters above, and any `user:password@` userinfo.\n *\n * Record side only. A replay lookup passes the live URL through unredacted, exactly as the\n * http, undici and sidecar lookup paths do, so a redacted parameter misses the exact tier and\n * is served by the drop-query tier instead — the right outcome, since a signature could not\n * have matched exactly across a record/replay boundary anyway.\n */\nexport const redactUrlCredentials = (url: URL): URL => {\n const redacted = new URL(url.href);\n for (const sensitiveParam of QUERY_STRINGS_TO_REDACT) {\n if (redacted.searchParams.get(sensitiveParam)) {\n redacted.searchParams.set(sensitiveParam, STR_REDACTED);\n }\n }\n if (redacted.username || redacted.password) {\n redacted.username = STR_REDACTED;\n redacted.password = STR_REDACTED;\n }\n return redacted;\n};\n"],"names":[],"mappings":";;;;;;;;;;;AASA,MAAM,0BAA0B;CAC9B;CACA;CACA;CACA;CACD;;;;;;;;;;AAWD,MAAa,wBAAwB,QAAkB;CACrD,MAAM,WAAW,IAAI,IAAI,IAAI,KAAK;AAClC,MAAK,MAAM,kBAAkB,wBAC3B,KAAI,SAAS,aAAa,IAAI,eAAe,CAC3C,UAAS,aAAa,IAAI,gBAAgB,aAAa;AAG3D,KAAI,SAAS,YAAY,SAAS,UAAU;AAC1C,WAAS,WAAW;AACpB,WAAS,WAAW;;AAEtB,QAAO","debugId":"a4ffc294-14bc-5f17-bbca-b0279e4181be"}
@@ -0,0 +1,32 @@
1
+ import { SidecarFetcher } from "./sidecar-transport.js";
2
+
3
+ //#region src/with-meticulous.d.ts
4
+ /** Structural subset of workerd's ExecutionContext — avoids a @cloudflare/workers-types dependency. */
5
+ interface MeticulousExecutionContext {
6
+ waitUntil(promise: Promise<unknown>): void;
7
+ }
8
+ interface WithMeticulousOptions {
9
+ /**
10
+ * Overrides the sidecar service binding; defaults to the `METICULOUS_SIDECAR` binding on
11
+ * `env`. This is how a **deployed** Worker records — see {@link SidecarTransport}.
12
+ */
13
+ sidecarBinding?: SidecarFetcher;
14
+ /** Overrides the sidecar origin; defaults to the METICULOUS_SIDECAR_URL var/binding. */
15
+ sidecarUrl?: string;
16
+ /**
17
+ * Binding names to leave unrecorded, on top of the defaults (`ASSETS`, `__STATIC_CONTENT`,
18
+ * `METICULOUS_SIDECAR`). Use this for a binding whose traffic is high-volume or binary. Applies
19
+ * to KV namespaces as well as `fetch`-shaped bindings.
20
+ */
21
+ skipBindings?: readonly string[];
22
+ /**
23
+ * Whether to mint a session id for a document navigation that carries none, so the
24
+ * server-side render is recorded under the same session as the page it produces. On by
25
+ * default; set `false` (or the `METICULOUS_BACKEND_PROVISIONAL_SESSION_IDS` var to
26
+ * `"false"`) to opt out. See provisional-session-id.ts.
27
+ */
28
+ mintProvisionalSessionIds?: boolean;
29
+ }
30
+ //#endregion
31
+ export { MeticulousExecutionContext, WithMeticulousOptions };
32
+ //# sourceMappingURL=with-meticulous.d.ts.map
Binary file
@@ -0,0 +1 @@
1
+ {"version":3,"file":"with-meticulous.js","sources":["../src/with-meticulous.ts"],"sourcesContent":["import { installBindingPatch } from \"./binding-patch\";\nimport { CaptureBuffer } from \"./capture-buffer\";\nimport {\n type RequestCaptureContext,\n type RequestReplayContext,\n requestCaptureContext,\n} from \"./context\";\nimport { installFetchPatch } from \"./fetch-patch\";\nimport { installKvPatch } from \"./kv-patch\";\nimport { warnOnce } from \"./log\";\nimport { getOriginalFetch } from \"./original-fetch\";\nimport { headersToRecord } from \"./outbound-capture\";\nimport {\n FRONTEND_SESSION_ID_HEADER,\n type InboundRequestEvent,\n REPLAY_ID_HEADER,\n REPLAY_SIDECAR_URL_HEADER,\n} from \"./protocol\";\nimport {\n isProvisionalSessionIdCandidate,\n mintProvisionalSessionId,\n} from \"./provisional-session-id\";\nimport { publishSessionIdOnResponse } from \"./publish-session-id\";\nimport { parseReplaySidecarUrl } from \"./replay-sidecar-url\";\nimport { getReplaySessionInfo } from \"./sidecar-client\";\nimport {\n resolveSidecarTransport,\n type SidecarFetcher,\n type SidecarTransport,\n} from \"./sidecar-transport\";\nimport { randomHex } from \"./spans/span-builder\";\nimport { installVirtualClock } from \"./virtual-clock\";\nimport { installVirtualRandom } from \"./virtual-random\";\n\n/** Structural subset of workerd's ExecutionContext — avoids a @cloudflare/workers-types dependency. */\nexport interface MeticulousExecutionContext {\n waitUntil(promise: Promise<unknown>): void;\n}\n\nexport interface WithMeticulousOptions {\n /**\n * Overrides the sidecar service binding; defaults to the `METICULOUS_SIDECAR` binding on\n * `env`. This is how a **deployed** Worker records — see {@link SidecarTransport}.\n */\n sidecarBinding?: SidecarFetcher;\n /** Overrides the sidecar origin; defaults to the METICULOUS_SIDECAR_URL var/binding. */\n sidecarUrl?: string;\n /**\n * Binding names to leave unrecorded, on top of the defaults (`ASSETS`, `__STATIC_CONTENT`,\n * `METICULOUS_SIDECAR`). Use this for a binding whose traffic is high-volume or binary. Applies\n * to KV namespaces as well as `fetch`-shaped bindings.\n */\n skipBindings?: readonly string[];\n /**\n * Whether to mint a session id for a document navigation that carries none, so the\n * server-side render is recorded under the same session as the page it produces. On by\n * default; set `false` (or the `METICULOUS_BACKEND_PROVISIONAL_SESSION_IDS` var to\n * `\"false\"`) to opt out. See provisional-session-id.ts.\n */\n mintProvisionalSessionIds?: boolean;\n}\n\n/** What the mode-agnostic core needs from whichever handler shape wraps it. */\nexport interface MeticulousInvocation {\n request: Request;\n env: unknown;\n ctx: MeticulousExecutionContext;\n invokeHandler: () => Response | Promise<Response>;\n}\n\n/**\n * Records, or replays, one inbound request. Shared by `withMeticulous` (an ES-module Worker's\n * `fetch`) and `withMeticulousPagesFunction` (a Pages Functions `onRequest`), which differ only\n * in how they get at the request, the env and `waitUntil`.\n */\nexport const runWithMeticulous = async (\n { request, env, ctx, invokeHandler }: MeticulousInvocation,\n options: WithMeticulousOptions | undefined,\n): Promise<Response> => {\n let transport: SidecarTransport | undefined;\n let replayContext: RequestReplayContext | undefined;\n try {\n replayContext = await resolveReplayContext(request, ctx);\n transport =\n replayContext === undefined\n ? resolveSidecarTransport(options, env)\n : undefined;\n if (replayContext !== undefined || transport !== undefined) {\n installFetchPatch();\n }\n if (transport !== undefined) {\n // Lazily, inside the request path, and never at module scope: the package declares no\n // `sideEffects` and is built with `platform: \"neutral\"`, so a module-scope side effect can\n // legitimately be tree-shaken by a customer's bundler. Re-run per request because `env` is\n // the only handle on the binding instances.\n //\n // Record mode only. Binding and KV calls are recorded as their own technologies\n // (`workerd-binding`, `workerd-kv`) and the replay sidecar's store holds only\n // `workerd-fetch` spans, so there is nothing to serve them from — and patching would only\n // route them into the capture tee, which POSTs to an events route a replay sidecar 404s.\n //\n // `skipInstances` keeps the sidecar's own binding out of the recording by identity rather\n // than by name, so it holds however the customer named it or passed it in.\n const patchOptions = {\n ...(options?.skipBindings !== undefined\n ? { skipBindings: options.skipBindings }\n : {}),\n ...(transport.kind === \"binding\"\n ? { skipInstances: [transport.instance] }\n : {}),\n };\n installBindingPatch(env, patchOptions);\n installKvPatch(env, patchOptions);\n }\n } catch (error) {\n warnOnce(\n \"shim-init\",\n \"Failed to initialize the Meticulous backend recorder shim — recording disabled.\",\n error,\n );\n replayContext = undefined;\n transport = undefined;\n }\n\n if (replayContext !== undefined) {\n // Install the clock before any app code runs, so a module that reads Date.now() at call time\n // (the firebase auth libraries do) sees the session's frozen instant.\n installVirtualClock();\n // Likewise the random generators: an id the app mints during SSR (a guest id, a request id)\n // otherwise differs between a base and a head replay, and every rendered page carrying it\n // diffs forever.\n installVirtualRandom();\n // No inbound reporting in replay mode: there is no exporter behind the replay sidecar, and\n // the events route 404s there.\n return requestCaptureContext.run(replayContext, invokeHandler);\n }\n\n if (transport === undefined) {\n return invokeHandler();\n }\n\n const captureCtx = buildCaptureContext(\n request,\n transport,\n ctx,\n resolveMintProvisionalSessionIds(options, env),\n );\n const mintedSessionId =\n captureCtx.sessionIdOrigin === \"backend\"\n ? captureCtx.frontendSessionId\n : undefined;\n const startTimeMs = Date.now();\n let requestHeaders: Record<string, string[]> = {};\n let method = \"GET\";\n let url = \"\";\n try {\n requestHeaders = headersToRecord(request.headers);\n method = request.method;\n url = request.url;\n } catch (error) {\n warnOnce(\n \"inbound-capture\",\n \"Failed to capture the inbound request.\",\n error,\n );\n }\n\n const reportInbound = (\n outcome:\n | { statusCode: number; responseHeaders: Record<string, string[]> }\n | { error: string },\n ): void => {\n try {\n const event: InboundRequestEvent = {\n kind: \"inbound\",\n requestId: captureCtx.requestId,\n ...(captureCtx.frontendSessionId !== undefined\n ? { frontendSessionId: captureCtx.frontendSessionId }\n : {}),\n ...(captureCtx.sessionIdOrigin !== undefined\n ? { sessionIdOrigin: captureCtx.sessionIdOrigin }\n : {}),\n traceId: captureCtx.traceId,\n serverSpanId: captureCtx.serverSpanId,\n method,\n url,\n requestHeaders,\n startTimeMs,\n endTimeMs: Date.now(),\n ...outcome,\n };\n captureCtx.buffer.add(event);\n } catch (error) {\n warnOnce(\n \"inbound-report\",\n \"Failed to report the inbound request.\",\n error,\n );\n }\n };\n\n let response: Response;\n try {\n response = await requestCaptureContext.run(captureCtx, invokeHandler);\n } catch (error) {\n reportInbound({ error: String(error) });\n closeBuffer(captureCtx);\n throw error;\n }\n\n // Ahead of reporting, so what we record as the response headers is what the browser is\n // actually served.\n if (mintedSessionId !== undefined) {\n response = publishSessionIdOnResponse(response, mintedSessionId);\n }\n\n try {\n reportInbound({\n statusCode: response.status,\n responseHeaders: headersToRecord(response.headers),\n });\n } catch (error) {\n warnOnce(\"inbound-report\", \"Failed to report the inbound request.\", error);\n }\n closeBuffer(captureCtx);\n return response;\n};\n\n/**\n * Hands the request's single report to `waitUntil`. `close()` drains the outstanding body reads\n * first, which is why there is one `waitUntil` per request rather than one per captured call.\n */\nconst closeBuffer = (ctx: RequestCaptureContext): void => {\n try {\n ctx.waitUntil(ctx.buffer.close());\n } catch (error) {\n warnOnce(\n \"buffer-close\",\n \"ctx.waitUntil failed — capture events may be lost.\",\n error,\n );\n }\n};\n\n/**\n * Whether this request should be replayed, and the context to serve it under.\n *\n * Both replay identity ids and a usable sidecar URL must be present: mocks are indexed per session\n * and consumed per replay. The sidecar is consulted once per (sidecar, session) per isolate to\n * confirm it holds mocks and to learn the clock anchor — a sidecar that does not recognise the\n * session, or the route, means replay is unavailable and the request runs normally.\n */\nconst resolveReplayContext = async (\n request: Request,\n ctx: MeticulousExecutionContext,\n): Promise<RequestReplayContext | undefined> => {\n let sidecarUrl: string | undefined;\n let frontendSessionId: string | undefined;\n let replayId: string | undefined;\n try {\n sidecarUrl = parseReplaySidecarUrl(\n request.headers.get(REPLAY_SIDECAR_URL_HEADER),\n );\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n replayId = request.headers.get(REPLAY_ID_HEADER) ?? undefined;\n } catch {\n return undefined;\n }\n if (\n sidecarUrl === undefined ||\n frontendSessionId === undefined ||\n replayId === undefined\n ) {\n return undefined;\n }\n\n const info = await getCachedReplaySessionInfo(sidecarUrl, frontendSessionId);\n if (info === null) {\n return undefined;\n }\n\n return {\n mode: \"replay\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n replayId,\n sidecarUrl,\n clockAnchorMs: info.clockAnchorMs,\n waitUntil: buildWaitUntil(ctx),\n };\n};\n\n// Caches the promise, not the result, so concurrent first requests share one round trip.\n// Bounded so a stream of unrecognised session ids cannot grow it without limit.\nconst MAX_CACHED_SESSIONS = 50;\nconst replaySessionInfoCache = new Map<\n string,\n Promise<{ clockAnchorMs: number | undefined } | null>\n>();\n\nconst getCachedReplaySessionInfo = (\n sidecarUrl: string,\n frontendSessionId: string,\n): Promise<{ clockAnchorMs: number | undefined } | null> => {\n const key = `${sidecarUrl}\u0000${frontendSessionId}`;\n const cached = replaySessionInfoCache.get(key);\n if (cached !== undefined) {\n return cached;\n }\n const pending = getReplaySessionInfo(\n getOriginalFetch(),\n sidecarUrl,\n frontendSessionId,\n ).then((result) => {\n if (result.outcome === \"unreachable\") {\n // Only settled answers are worth keeping. Caching a timeout or transport blip would\n // disable mocking for this session for the life of the isolate, quietly sending every\n // later outbound call to the real service — the failure this path exists to prevent.\n // Dropping the entry costs one extra handshake and lets the next request recover.\n replaySessionInfoCache.delete(key);\n return null;\n }\n return result.outcome === \"found\"\n ? { clockAnchorMs: result.clockAnchorMs }\n : null;\n });\n if (replaySessionInfoCache.size >= MAX_CACHED_SESSIONS) {\n const oldest = replaySessionInfoCache.keys().next();\n if (!oldest.done) {\n replaySessionInfoCache.delete(oldest.value);\n }\n }\n replaySessionInfoCache.set(key, pending);\n return pending;\n};\n\nconst buildCaptureContext = (\n request: Request,\n transport: SidecarTransport,\n ctx: MeticulousExecutionContext,\n mintProvisionalSessionIds: boolean,\n): RequestCaptureContext => {\n let frontendSessionId: string | undefined;\n let sessionIdOrigin: \"backend\" | undefined;\n try {\n frontendSessionId =\n request.headers.get(FRONTEND_SESSION_ID_HEADER) ?? undefined;\n // A top-level document navigation cannot carry the header, so for one we mint the id\n // ourselves and hand it to the page on the response — otherwise the server-side render this\n // request is about is recorded against no session at all. Every outbound, binding, KV and\n // postgres event reads the id back off this context, so putting it here attributes the whole\n // request. See provisional-session-id.ts.\n if (\n mintProvisionalSessionIds &&\n frontendSessionId === undefined &&\n isProvisionalSessionIdCandidate(request.method, (name) =>\n request.headers.get(name),\n )\n ) {\n frontendSessionId = mintProvisionalSessionId();\n sessionIdOrigin = \"backend\";\n }\n } catch {\n frontendSessionId = undefined;\n sessionIdOrigin = undefined;\n }\n const waitUntil = buildWaitUntil(ctx);\n return {\n mode: \"record\",\n requestId: crypto.randomUUID(),\n frontendSessionId,\n ...(sessionIdOrigin !== undefined ? { sessionIdOrigin } : {}),\n transport,\n buffer: new CaptureBuffer(transport, waitUntil),\n traceId: randomHex(16),\n serverSpanId: randomHex(8),\n waitUntil,\n };\n};\n\n/**\n * On by default: the render it attributes — the page's very first — is otherwise recorded\n * against no session at all, and the only thing that reaches a replay for those spans is\n * ingestion's time-window guess.\n *\n * Opting out is a worker var rather than a host environment variable for the same reason the\n * sidecar URL is: workerd cannot see the host's environment.\n */\nconst resolveMintProvisionalSessionIds = (\n options: WithMeticulousOptions | undefined,\n env: unknown,\n): boolean => {\n if (options?.mintProvisionalSessionIds !== undefined) {\n return options.mintProvisionalSessionIds;\n }\n const fromEnv =\n env !== null && typeof env === \"object\"\n ? (env as Record<string, unknown>)[\n \"METICULOUS_BACKEND_PROVISIONAL_SESSION_IDS\"\n ]\n : undefined;\n return fromEnv !== \"false\";\n};\n\nconst buildWaitUntil =\n (ctx: MeticulousExecutionContext) =>\n (promise: Promise<unknown>): void => {\n try {\n ctx.waitUntil(promise);\n } catch (error) {\n warnOnce(\n \"wait-until\",\n \"ctx.waitUntil failed — capture events may be lost.\",\n error,\n );\n }\n };\n"],"names":[],"mappings":"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","debugId":"ba3c57b0-dd6d-539f-8d4f-5cfae6c9afe1"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alwaysmeticulous/backend-recorder-workerd",
3
- "version": "2.325.0",
3
+ "version": "2.326.0",
4
4
  "type": "module",
5
5
  "description": "Meticulous backend recorder shim for Cloudflare Workers (workerd). Reports inbound and outbound HTTP capture events to a local Meticulous recorder sidecar during development recording.",
6
6
  "license": "ISC",
@@ -22,6 +22,7 @@
22
22
  ],
23
23
  "devDependencies": {
24
24
  "@types/node": "^20.0.0",
25
+ "postgres": "^3.4.7",
25
26
  "tsdown": "^0.21.7",
26
27
  "vitest": "^4.1.7"
27
28
  },