@reliantlabs/forge-web-runtime 0.4.1 → 0.6.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.
@@ -0,0 +1,8 @@
1
+ /** Attribute key reportException stamps the server's trace id under. */
2
+ export declare const SERVER_TRACE_ID_ATTRIBUTE = "server.trace_id";
3
+ /**
4
+ * serverTraceIdOf returns the trace id a failed request was recorded in — the
5
+ * `traceId` a ConnectClientError carries (errors.ts) — for `value` or anything
6
+ * on its `cause` chain, or null.
7
+ */
8
+ export declare function serverTraceIdOf(value: unknown): string | null;
@@ -0,0 +1,30 @@
1
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
2
+ //
3
+ // Reading the server trace id off a thrown value, with NO dependencies.
4
+ //
5
+ // devlog.ts and error-reporter.ts are imported by every frontend, including the
6
+ // ones that never install OpenTelemetry, so they cannot import trace.ts (which
7
+ // imports @opentelemetry/api) or errors.ts (which imports @connectrpc/connect).
8
+ // They need one fact from a ConnectClientError — its traceId — and read it
9
+ // structurally here: the same shape-over-identity rule errors.ts applies to
10
+ // `instanceof`.
11
+ /** Attribute key reportException stamps the server's trace id under. */
12
+ export const SERVER_TRACE_ID_ATTRIBUTE = "server.trace_id";
13
+ const TRACE_ID_RE = /^[0-9a-f]{32}$/;
14
+ /**
15
+ * serverTraceIdOf returns the trace id a failed request was recorded in — the
16
+ * `traceId` a ConnectClientError carries (errors.ts) — for `value` or anything
17
+ * on its `cause` chain, or null.
18
+ */
19
+ export function serverTraceIdOf(value) {
20
+ let v = value;
21
+ for (let depth = 0; depth < 5 && v !== null && typeof v === "object"; depth++) {
22
+ const id = v.traceId;
23
+ if (typeof id === "string" && TRACE_ID_RE.test(id)) {
24
+ return id;
25
+ }
26
+ v = v.cause;
27
+ }
28
+ return null;
29
+ }
30
+ //# sourceMappingURL=trace-id.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trace-id.js","sourceRoot":"","sources":["../src/trace-id.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,wEAAwE;AACxE,EAAE;AACF,gFAAgF;AAChF,+EAA+E;AAC/E,gFAAgF;AAChF,2EAA2E;AAC3E,4EAA4E;AAC5E,gBAAgB;AAEhB,wEAAwE;AACxE,MAAM,CAAC,MAAM,yBAAyB,GAAG,iBAAiB,CAAC;AAE3D,MAAM,WAAW,GAAG,gBAAgB,CAAC;AAErC;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,IAAI,CAAC,GAAY,KAAK,CAAC;IACvB,KACE,IAAI,KAAK,GAAG,CAAC,EACb,KAAK,GAAG,CAAC,IAAI,CAAC,KAAK,IAAI,IAAI,OAAO,CAAC,KAAK,QAAQ,EAChD,KAAK,EAAE,EACP,CAAC;QACD,MAAM,EAAE,GAAI,CAA2B,CAAC,OAAO,CAAC;QAChD,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC;YACnD,OAAO,EAAE,CAAC;QACZ,CAAC;QACD,CAAC,GAAI,CAAyB,CAAC,KAAK,CAAC;IACvC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Reading the server trace id off a thrown value, with NO dependencies.\n//\n// devlog.ts and error-reporter.ts are imported by every frontend, including the\n// ones that never install OpenTelemetry, so they cannot import trace.ts (which\n// imports @opentelemetry/api) or errors.ts (which imports @connectrpc/connect).\n// They need one fact from a ConnectClientError — its traceId — and read it\n// structurally here: the same shape-over-identity rule errors.ts applies to\n// `instanceof`.\n\n/** Attribute key reportException stamps the server's trace id under. */\nexport const SERVER_TRACE_ID_ATTRIBUTE = \"server.trace_id\";\n\nconst TRACE_ID_RE = /^[0-9a-f]{32}$/;\n\n/**\n * serverTraceIdOf returns the trace id a failed request was recorded in — the\n * `traceId` a ConnectClientError carries (errors.ts) — for `value` or anything\n * on its `cause` chain, or null.\n */\nexport function serverTraceIdOf(value: unknown): string | null {\n let v: unknown = value;\n for (\n let depth = 0;\n depth < 5 && v !== null && typeof v === \"object\";\n depth++\n ) {\n const id = (v as { traceId?: unknown }).traceId;\n if (typeof id === \"string\" && TRACE_ID_RE.test(id)) {\n return id;\n }\n v = (v as { cause?: unknown }).cause;\n }\n return null;\n}\n"]}
package/dist/trace.d.ts CHANGED
@@ -9,6 +9,21 @@ export declare function setTraceSampled(sampled: boolean): void;
9
9
  * all-zero draw (the spec forbids all-zero ids).
10
10
  */
11
11
  export declare function freshTraceparent(): string;
12
+ /**
13
+ * TRACERESPONSE_HEADER is the W3C Trace Context Level 2 response header a
14
+ * forge server sets on every Connect response, failed ones included: the
15
+ * context of ITS server span (forge/pkg/observe TraceResponseInterceptor).
16
+ * It is the authoritative answer to "which trace did this request land in" —
17
+ * the traceparent the browser sent is only a request, which a server that does
18
+ * not trust remote parents, or a proxy that restarts the trace, overrides.
19
+ */
20
+ export declare const TRACERESPONSE_HEADER = "traceresponse";
21
+ /**
22
+ * traceIdFrom extracts the trace id from a traceparent or traceresponse value
23
+ * (`00-<trace-id>-<span-id>-<flags>`), or returns null for anything malformed
24
+ * or an all-zero id.
25
+ */
26
+ export declare function traceIdFrom(value: string | null | undefined): string | null;
12
27
  /**
13
28
  * injectTraceContext writes a `traceparent` (and any accompanying W3C
14
29
  * context headers, e.g. `tracestate`) onto the outgoing request headers.
package/dist/trace.js CHANGED
@@ -79,6 +79,27 @@ export function freshTraceparent() {
79
79
  const flags = traceSampled ? "01" : "00";
80
80
  return `00-${traceId}-${spanId}-${flags}`;
81
81
  }
82
+ /**
83
+ * TRACERESPONSE_HEADER is the W3C Trace Context Level 2 response header a
84
+ * forge server sets on every Connect response, failed ones included: the
85
+ * context of ITS server span (forge/pkg/observe TraceResponseInterceptor).
86
+ * It is the authoritative answer to "which trace did this request land in" —
87
+ * the traceparent the browser sent is only a request, which a server that does
88
+ * not trust remote parents, or a proxy that restarts the trace, overrides.
89
+ */
90
+ export const TRACERESPONSE_HEADER = "traceresponse";
91
+ /**
92
+ * traceIdFrom extracts the trace id from a traceparent or traceresponse value
93
+ * (`00-<trace-id>-<span-id>-<flags>`), or returns null for anything malformed
94
+ * or an all-zero id.
95
+ */
96
+ export function traceIdFrom(value) {
97
+ if (!value || !TRACEPARENT_RE.test(value.trim())) {
98
+ return null;
99
+ }
100
+ const traceId = value.trim().split("-")[1] ?? null;
101
+ return traceId === ZERO_TRACE_ID ? null : traceId;
102
+ }
82
103
  /**
83
104
  * injectTraceContext writes a `traceparent` (and any accompanying W3C
84
105
  * context headers, e.g. `tracestate`) onto the outgoing request headers.
package/dist/trace.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"trace.js","sourceRoot":"","sources":["../src/trace.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,iDAAiD;AACjD,EAAE;AACF,4EAA4E;AAC5E,mEAAmE;AACnE,wEAAwE;AACxE,oEAAoE;AACpE,8BAA8B;AAC9B,EAAE;AACF,wEAAwE;AACxE,4EAA4E;AAC5E,sEAAsE;AACtE,2DAA2D;AAC3D,4EAA4E;AAC5E,4EAA4E;AAC5E,6CAA6C;AAC7C,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAE1D,2EAA2E;AAC3E,MAAM,CAAC,MAAM,cAAc,GAAG,0CAA0C,CAAC;AAEzE,MAAM,aAAa,GAAG,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AACrC,MAAM,YAAY,GAAG,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AAEpC;;;;;;;;;;;;GAYG;AACH,IAAI,YAAY,GAAG,IAAI,CAAC;AAExB,2EAA2E;AAC3E,MAAM,UAAU,eAAe,CAAC,OAAgB;IAC9C,YAAY,GAAG,OAAO,CAAC;AACzB,CAAC;AAED;;;;;GAKG;AACH,SAAS,SAAS,CAAC,KAAa;IAC9B,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC;IAClC,MAAM,CAAC,GAAG,UAAU,CAAC,MAAM,CAAC;IAC5B,IAAI,CAAC,IAAI,OAAO,CAAC,CAAC,eAAe,KAAK,UAAU,EAAE,CAAC;QACjD,CAAC,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;SAAM,CAAC;QACN,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC;YAC/B,GAAG,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,GAAG,CAAC,CAAC;QAC3C,CAAC;IACH,CAAC;IACD,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,MAAM,CAAC,IAAI,GAAG,EAAE,CAAC;QACpB,GAAG,IAAI,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACzC,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB;IAC9B,IAAI,OAAO,GAAG,SAAS,CAAC,EAAE,CAAC,CAAC;IAC5B,OAAO,OAAO,KAAK,aAAa,EAAE,CAAC;QACjC,OAAO,GAAG,SAAS,CAAC,EAAE,CAAC,CAAC;IAC1B,CAAC;IACD,IAAI,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IAC1B,OAAO,MAAM,KAAK,YAAY,EAAE,CAAC;QAC/B,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IACxB,CAAC;IACD,MAAM,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;IACzC,OAAO,MAAM,OAAO,IAAI,MAAM,IAAI,KAAK,EAAE,CAAC;AAC5C,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAe;IAChD,MAAM,OAAO,GAA2B,EAAE,CAAC;IAC3C,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE;QAC5C,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;YACT,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QACnB,CAAC;KACF,CAAC,CAAC;IAEH,IAAI,OAAO,CAAC,WAAW,IAAI,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,EAAE,CAAC;QACpE,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;YAC7C,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACnB,CAAC;QACD,OAAO;IACT,CAAC;IAED,MAAM,CAAC,GAAG,CAAC,aAAa,EAAE,gBAAgB,EAAE,CAAC,CAAC;AAChD,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// W3C trace-context propagation for the browser.\n//\n// The headline capability: EVERY Connect call carries a valid `traceparent`\n// header, so the backend (which runs otelconnect + otelhttp with a\n// TraceContext propagator) joins the SAME distributed trace. This works\n// WITHOUT any OTLP collector configured — traceparent generation is\n// decoupled from span export.\n//\n// - If the app opted into full OpenTelemetry tracing (src/lib/otel.ts\n// ran initTelemetry with an endpoint, registering a WebTracerProvider),\n// there is an active recording span; we inject ITS context so the\n// browser span and the server span share one trace id.\n// - Otherwise we synthesize a fresh, valid W3C traceparent from a CSPRNG.\n// The backend still roots a trace we can correlate this request to, and\n// no browser-side collector is required.\nimport { context, propagation } from \"@opentelemetry/api\";\n\n/** Matches a well-formed W3C traceparent: version-traceid-spanid-flags. */\nexport const TRACEPARENT_RE = /^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/;\n\nconst ZERO_TRACE_ID = \"0\".repeat(32);\nconst ZERO_SPAN_ID = \"0\".repeat(16);\n\n/**\n * Whether a SYNTHESIZED traceparent (the no-active-span fallback below)\n * marks the trace as sampled/recorded.\n *\n * Default true — \"correlation-safe\": a browser-initiated request is always\n * recorded, so it can be joined to its server span even when the backend\n * uses head-based sampling (this module's whole promise is \"every call is\n * correlatable\"). Flip it to false via setTraceSampled() to instead DEFER to\n * the server's sampler — cheaper at high volume, but a synthesized trace the\n * server samples out leaves that browser request uncorrelated. Only affects\n * the synthesized path; when a real OpenTelemetry span is active, ITS\n * sampling flag is propagated verbatim (see injectTraceContext).\n */\nlet traceSampled = true;\n\n/** Override the sampled flag on synthesized traceparents. Default true. */\nexport function setTraceSampled(sampled: boolean): void {\n traceSampled = sampled;\n}\n\n/**\n * randomHex returns `bytes` random bytes as a lowercase hex string using a\n * CSPRNG. Falls back to Math.random only if Web Crypto is unavailable (old\n * embedded webviews); the fallback is fine for a correlation id that carries\n * no security weight.\n */\nfunction randomHex(bytes: number): string {\n const buf = new Uint8Array(bytes);\n const c = globalThis.crypto;\n if (c && typeof c.getRandomValues === \"function\") {\n c.getRandomValues(buf);\n } else {\n for (let i = 0; i < bytes; i++) {\n buf[i] = Math.floor(Math.random() * 256);\n }\n }\n let out = \"\";\n for (const b of buf) {\n out += b.toString(16).padStart(2, \"0\");\n }\n return out;\n}\n\n/**\n * freshTraceparent synthesizes a valid W3C traceparent. The trace-flags byte\n * reflects the configurable sampled flag (setTraceSampled; default 01 =\n * sampled). Trace/span ids are re-rolled on the astronomically-unlikely\n * all-zero draw (the spec forbids all-zero ids).\n */\nexport function freshTraceparent(): string {\n let traceId = randomHex(16);\n while (traceId === ZERO_TRACE_ID) {\n traceId = randomHex(16);\n }\n let spanId = randomHex(8);\n while (spanId === ZERO_SPAN_ID) {\n spanId = randomHex(8);\n }\n const flags = traceSampled ? \"01\" : \"00\";\n return `00-${traceId}-${spanId}-${flags}`;\n}\n\n/**\n * injectTraceContext writes a `traceparent` (and any accompanying W3C\n * context headers, e.g. `tracestate`) onto the outgoing request headers.\n *\n * Prefers an active OpenTelemetry span so a fully-instrumented app stitches\n * the browser and backend spans into one trace; otherwise falls back to a\n * synthesized traceparent so propagation ALWAYS happens. Idempotent per\n * request — safe to call once per attempt.\n */\nexport function injectTraceContext(header: Headers): void {\n const carrier: Record<string, string> = {};\n propagation.inject(context.active(), carrier, {\n set(c, k, v) {\n c[k] = String(v);\n },\n });\n\n if (carrier.traceparent && TRACEPARENT_RE.test(carrier.traceparent)) {\n for (const [k, v] of Object.entries(carrier)) {\n header.set(k, v);\n }\n return;\n }\n\n header.set(\"traceparent\", freshTraceparent());\n}\n"]}
1
+ {"version":3,"file":"trace.js","sourceRoot":"","sources":["../src/trace.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,iDAAiD;AACjD,EAAE;AACF,4EAA4E;AAC5E,mEAAmE;AACnE,wEAAwE;AACxE,oEAAoE;AACpE,8BAA8B;AAC9B,EAAE;AACF,wEAAwE;AACxE,4EAA4E;AAC5E,sEAAsE;AACtE,2DAA2D;AAC3D,4EAA4E;AAC5E,4EAA4E;AAC5E,6CAA6C;AAC7C,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAE1D,2EAA2E;AAC3E,MAAM,CAAC,MAAM,cAAc,GAAG,0CAA0C,CAAC;AAEzE,MAAM,aAAa,GAAG,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AACrC,MAAM,YAAY,GAAG,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AAEpC;;;;;;;;;;;;GAYG;AACH,IAAI,YAAY,GAAG,IAAI,CAAC;AAExB,2EAA2E;AAC3E,MAAM,UAAU,eAAe,CAAC,OAAgB;IAC9C,YAAY,GAAG,OAAO,CAAC;AACzB,CAAC;AAED;;;;;GAKG;AACH,SAAS,SAAS,CAAC,KAAa;IAC9B,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,KAAK,CAAC,CAAC;IAClC,MAAM,CAAC,GAAG,UAAU,CAAC,MAAM,CAAC;IAC5B,IAAI,CAAC,IAAI,OAAO,CAAC,CAAC,eAAe,KAAK,UAAU,EAAE,CAAC;QACjD,CAAC,CAAC,eAAe,CAAC,GAAG,CAAC,CAAC;IACzB,CAAC;SAAM,CAAC;QACN,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC;YAC/B,GAAG,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,EAAE,GAAG,GAAG,CAAC,CAAC;QAC3C,CAAC;IACH,CAAC;IACD,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,MAAM,CAAC,IAAI,GAAG,EAAE,CAAC;QACpB,GAAG,IAAI,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;IACzC,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB;IAC9B,IAAI,OAAO,GAAG,SAAS,CAAC,EAAE,CAAC,CAAC;IAC5B,OAAO,OAAO,KAAK,aAAa,EAAE,CAAC;QACjC,OAAO,GAAG,SAAS,CAAC,EAAE,CAAC,CAAC;IAC1B,CAAC;IACD,IAAI,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IAC1B,OAAO,MAAM,KAAK,YAAY,EAAE,CAAC;QAC/B,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IACxB,CAAC;IACD,MAAM,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;IACzC,OAAO,MAAM,OAAO,IAAI,MAAM,IAAI,KAAK,EAAE,CAAC;AAC5C,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,eAAe,CAAC;AAEpD;;;;GAIG;AACH,MAAM,UAAU,WAAW,CAAC,KAAgC;IAC1D,IAAI,CAAC,KAAK,IAAI,CAAC,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;QACjD,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;IACnD,OAAO,OAAO,KAAK,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC;AACpD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAAe;IAChD,MAAM,OAAO,GAA2B,EAAE,CAAC;IAC3C,WAAW,CAAC,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE;QAC5C,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC;YACT,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;QACnB,CAAC;KACF,CAAC,CAAC;IAEH,IAAI,OAAO,CAAC,WAAW,IAAI,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,EAAE,CAAC;QACpE,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;YAC7C,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;QACnB,CAAC;QACD,OAAO;IACT,CAAC;IAED,MAAM,CAAC,GAAG,CAAC,aAAa,EAAE,gBAAgB,EAAE,CAAC,CAAC;AAChD,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// W3C trace-context propagation for the browser.\n//\n// The headline capability: EVERY Connect call carries a valid `traceparent`\n// header, so the backend (which runs otelconnect + otelhttp with a\n// TraceContext propagator) joins the SAME distributed trace. This works\n// WITHOUT any OTLP collector configured — traceparent generation is\n// decoupled from span export.\n//\n// - If the app opted into full OpenTelemetry tracing (src/lib/otel.ts\n// ran initTelemetry with an endpoint, registering a WebTracerProvider),\n// there is an active recording span; we inject ITS context so the\n// browser span and the server span share one trace id.\n// - Otherwise we synthesize a fresh, valid W3C traceparent from a CSPRNG.\n// The backend still roots a trace we can correlate this request to, and\n// no browser-side collector is required.\nimport { context, propagation } from \"@opentelemetry/api\";\n\n/** Matches a well-formed W3C traceparent: version-traceid-spanid-flags. */\nexport const TRACEPARENT_RE = /^00-[0-9a-f]{32}-[0-9a-f]{16}-0[0-9a-f]$/;\n\nconst ZERO_TRACE_ID = \"0\".repeat(32);\nconst ZERO_SPAN_ID = \"0\".repeat(16);\n\n/**\n * Whether a SYNTHESIZED traceparent (the no-active-span fallback below)\n * marks the trace as sampled/recorded.\n *\n * Default true — \"correlation-safe\": a browser-initiated request is always\n * recorded, so it can be joined to its server span even when the backend\n * uses head-based sampling (this module's whole promise is \"every call is\n * correlatable\"). Flip it to false via setTraceSampled() to instead DEFER to\n * the server's sampler — cheaper at high volume, but a synthesized trace the\n * server samples out leaves that browser request uncorrelated. Only affects\n * the synthesized path; when a real OpenTelemetry span is active, ITS\n * sampling flag is propagated verbatim (see injectTraceContext).\n */\nlet traceSampled = true;\n\n/** Override the sampled flag on synthesized traceparents. Default true. */\nexport function setTraceSampled(sampled: boolean): void {\n traceSampled = sampled;\n}\n\n/**\n * randomHex returns `bytes` random bytes as a lowercase hex string using a\n * CSPRNG. Falls back to Math.random only if Web Crypto is unavailable (old\n * embedded webviews); the fallback is fine for a correlation id that carries\n * no security weight.\n */\nfunction randomHex(bytes: number): string {\n const buf = new Uint8Array(bytes);\n const c = globalThis.crypto;\n if (c && typeof c.getRandomValues === \"function\") {\n c.getRandomValues(buf);\n } else {\n for (let i = 0; i < bytes; i++) {\n buf[i] = Math.floor(Math.random() * 256);\n }\n }\n let out = \"\";\n for (const b of buf) {\n out += b.toString(16).padStart(2, \"0\");\n }\n return out;\n}\n\n/**\n * freshTraceparent synthesizes a valid W3C traceparent. The trace-flags byte\n * reflects the configurable sampled flag (setTraceSampled; default 01 =\n * sampled). Trace/span ids are re-rolled on the astronomically-unlikely\n * all-zero draw (the spec forbids all-zero ids).\n */\nexport function freshTraceparent(): string {\n let traceId = randomHex(16);\n while (traceId === ZERO_TRACE_ID) {\n traceId = randomHex(16);\n }\n let spanId = randomHex(8);\n while (spanId === ZERO_SPAN_ID) {\n spanId = randomHex(8);\n }\n const flags = traceSampled ? \"01\" : \"00\";\n return `00-${traceId}-${spanId}-${flags}`;\n}\n\n/**\n * TRACERESPONSE_HEADER is the W3C Trace Context Level 2 response header a\n * forge server sets on every Connect response, failed ones included: the\n * context of ITS server span (forge/pkg/observe TraceResponseInterceptor).\n * It is the authoritative answer to \"which trace did this request land in\" —\n * the traceparent the browser sent is only a request, which a server that does\n * not trust remote parents, or a proxy that restarts the trace, overrides.\n */\nexport const TRACERESPONSE_HEADER = \"traceresponse\";\n\n/**\n * traceIdFrom extracts the trace id from a traceparent or traceresponse value\n * (`00-<trace-id>-<span-id>-<flags>`), or returns null for anything malformed\n * or an all-zero id.\n */\nexport function traceIdFrom(value: string | null | undefined): string | null {\n if (!value || !TRACEPARENT_RE.test(value.trim())) {\n return null;\n }\n const traceId = value.trim().split(\"-\")[1] ?? null;\n return traceId === ZERO_TRACE_ID ? null : traceId;\n}\n\n/**\n * injectTraceContext writes a `traceparent` (and any accompanying W3C\n * context headers, e.g. `tracestate`) onto the outgoing request headers.\n *\n * Prefers an active OpenTelemetry span so a fully-instrumented app stitches\n * the browser and backend spans into one trace; otherwise falls back to a\n * synthesized traceparent so propagation ALWAYS happens. Idempotent per\n * request — safe to call once per attempt.\n */\nexport function injectTraceContext(header: Headers): void {\n const carrier: Record<string, string> = {};\n propagation.inject(context.active(), carrier, {\n set(c, k, v) {\n c[k] = String(v);\n },\n });\n\n if (carrier.traceparent && TRACEPARENT_RE.test(carrier.traceparent)) {\n for (const [k, v] of Object.entries(carrier)) {\n header.set(k, v);\n }\n return;\n }\n\n header.set(\"traceparent\", freshTraceparent());\n}\n"]}
@@ -0,0 +1,72 @@
1
+ /** Most events held while the SDK is still loading. Later calls are dropped. */
2
+ export declare const MAX_QUEUED_EVENTS = 100;
3
+ /** Most props kept on one event; the rest are dropped in insertion order. */
4
+ export declare const MAX_PROP_KEYS = 20;
5
+ /** Longest string prop value, after redaction. */
6
+ export declare const MAX_PROP_VALUE_CHARS = 256;
7
+ /** Longest event name. */
8
+ export declare const MAX_EVENT_NAME_CHARS = 128;
9
+ /** Longest user id. */
10
+ export declare const MAX_USER_ID_CHARS = 128;
11
+ /** A prop key is a dotted lowercase identifier. */
12
+ export declare const PROP_KEY_RE: RegExp;
13
+ /** What a redacted credential becomes. */
14
+ export declare const REDACTED = "[redacted]";
15
+ /** A prop value an event may carry. Anything else is dropped. */
16
+ export type TrackValue = string | number | boolean;
17
+ /** Props accepted by track(); non-conforming entries are dropped, not thrown on. */
18
+ export type TrackProps = Record<string, unknown>;
19
+ /** What the `/telemetry` subpath installs once the SDK is running. */
20
+ export interface TelemetrySink {
21
+ track(name: string, attributes: Record<string, TrackValue>): void;
22
+ setUser(id: string): void;
23
+ disable(): void;
24
+ }
25
+ /** Redact credential shapes from a string. Exported for tests. */
26
+ export declare function redactSecrets(value: string): string;
27
+ /**
28
+ * Reduce caller props to what an event may carry: bounded, typed, redacted.
29
+ * Pure; exported for tests.
30
+ */
31
+ export declare function sanitizeProps(props: TrackProps | undefined): Record<string, TrackValue>;
32
+ /**
33
+ * Record a product event — a funnel step, a feature used — as a span on the
34
+ * current session. Never throws, and is a no-op on the server, after
35
+ * disableTelemetry(), and (once the SDK has started) whenever telemetry is
36
+ * off. Calls made while the SDK is still loading are held, up to
37
+ * MAX_QUEUED_EVENTS, and replayed in order.
38
+ *
39
+ * `name` is a stable, low-cardinality event name ("onboarding_completed"),
40
+ * not a sentence. `props` is bounded and redacted as described in the module
41
+ * header; do not put user content in it.
42
+ */
43
+ export declare function track(name: string, props?: TrackProps): void;
44
+ /**
45
+ * Attach the signed-in user's id to every span and replay from here on, so an
46
+ * error, a funnel step and a replay can be joined to a user. Pass an opaque
47
+ * id — never an email, a name or anything else a person would recognise. Pass
48
+ * null (sign-out) to clear it.
49
+ *
50
+ * The id is carried as the `userId` global attribute. The SDK has no way to
51
+ * delete an attribute, so clearing writes an empty `userId`. Held until the
52
+ * SDK has started; the last call wins.
53
+ */
54
+ export declare function setTelemetryUser(id: string | null | undefined): void;
55
+ /**
56
+ * Turn browser telemetry off for the rest of this page load: stop the replay
57
+ * recorder, drop anything held, and make track(), setTelemetryUser() and
58
+ * reportException() no-ops. For when the user withdraws consent mid-session.
59
+ * See the module header for what it cannot undo.
60
+ */
61
+ export declare function disableTelemetry(): void;
62
+ /** Whether disableTelemetry() has been called. Read by the installed recorder. */
63
+ export declare function isTelemetryDisabled(): boolean;
64
+ /**
65
+ * Install (or clear, with null) the sink track() forwards to, replaying
66
+ * whatever was held. Called by initBrowserTelemetry; an app has no reason to
67
+ * call it directly. Clearing also drops what was held, so a start that failed
68
+ * does not keep a queue nobody will drain.
69
+ */
70
+ export declare function setTelemetrySink(next: TelemetrySink | null): void;
71
+ /** Test seam. */
72
+ export declare function resetTrackState(): void;
package/dist/track.js ADDED
@@ -0,0 +1,243 @@
1
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
2
+ //
3
+ // Product events, the signed-in user and the consent kill-switch: the part of
4
+ // browser telemetry an app calls from ordinary components.
5
+ //
6
+ // Dependency-free on purpose, for the same reason as error-reporter.ts. This
7
+ // module is exported from the barrel, which every frontend imports —
8
+ // including ones that never install the HyperDX SDK — so it cannot import the
9
+ // SDK. The `/telemetry` subpath, which does, installs the real sink here once
10
+ // the SDK has initialised. Until then (the SDK chunk is still loading) calls
11
+ // are held, bounded, and replayed in order; with telemetry off they are
12
+ // dropped. That is what makes `track()` safe to call unconditionally from a
13
+ // click handler or an effect.
14
+ //
15
+ // ── What `track` sends ───────────────────────────────────────────────────
16
+ //
17
+ // `track(name, props)` becomes one span from the SDK's `custom-action` tracer
18
+ // (HyperDX.addAction), so a product event inherits the session id, page,
19
+ // trace context and release the SDK stamps on every span — which is what lets
20
+ // a funnel step link to the replay and the errors of the same session.
21
+ //
22
+ // Props are user data in flight, so they are bounded and scrubbed HERE, before
23
+ // the SDK sees them, and not left to a server-side filter:
24
+ // - at most MAX_PROP_KEYS keys; a key must match PROP_KEY_RE or it is dropped
25
+ // - values are string | number | boolean; anything else is dropped
26
+ // - string values have credential shapes redacted, then are cut to
27
+ // MAX_PROP_VALUE_CHARS
28
+ //
29
+ // ── Consent ──────────────────────────────────────────────────────────────
30
+ //
31
+ // `disableTelemetry()` is the runtime half of "the user turned it off": it
32
+ // stops the replay recorder and makes track, setTelemetryUser and
33
+ // reportException no-ops for the rest of the page load. It cannot recall what
34
+ // the SDK already batched, and the SDK's own auto-instrumentation (page
35
+ // loads, fetch/XHR, uncaught errors) has no off switch once running — an app
36
+ // that must send nothing without consent must not call initBrowserTelemetry
37
+ // until it has it. There is no re-enable; init again on the next page load.
38
+ /** Most events held while the SDK is still loading. Later calls are dropped. */
39
+ export const MAX_QUEUED_EVENTS = 100;
40
+ /** Most props kept on one event; the rest are dropped in insertion order. */
41
+ export const MAX_PROP_KEYS = 20;
42
+ /** Longest string prop value, after redaction. */
43
+ export const MAX_PROP_VALUE_CHARS = 256;
44
+ /** Longest event name. */
45
+ export const MAX_EVENT_NAME_CHARS = 128;
46
+ /** Longest user id. */
47
+ export const MAX_USER_ID_CHARS = 128;
48
+ /** A prop key is a dotted lowercase identifier. */
49
+ export const PROP_KEY_RE = /^[a-z0-9_.]{1,64}$/;
50
+ /** What a redacted credential becomes. */
51
+ export const REDACTED = "[redacted]";
52
+ let sink = null;
53
+ let disabled = false;
54
+ let pending = [];
55
+ // Credential shapes, the same set the reliant web app scrubs from error text
56
+ // (and the backend scrubs server-side). Kept here so a product event cannot
57
+ // carry what an error message may not.
58
+ const SECRET_PATTERNS = [
59
+ // URL userinfo: postgres://user:password@host
60
+ [/([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/g, `$1${REDACTED}@`],
61
+ // key=value / "key": "value" for credential-named keys.
62
+ [
63
+ /\b(authorization|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|secret|password|passwd|token)(["']?\s*[:=]\s*["']?)(?:(?:bearer|basic|token)\s+)?[^\s"'&,;]+/gi,
64
+ `$1$2${REDACTED}`,
65
+ ],
66
+ // A bare authorization scheme outside a key=value pair.
67
+ [/\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+/gi, `$1 ${REDACTED}`],
68
+ // JWTs.
69
+ [/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]*/g, REDACTED],
70
+ // Provider and platform key prefixes.
71
+ [
72
+ /\b(?:sk-ant-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{16,}|sk_(?:live|test)_[A-Za-z0-9]{8,}|(?:rlat|dpat|gho|ghp|ghu|ghs|ghr|github_pat|glpat|xox[abprs])[_-][A-Za-z0-9_-]{10,}|AIza[0-9A-Za-z_-]{20,}|ya29\.[0-9A-Za-z_-]+|AKIA[0-9A-Z]{16})/g,
73
+ REDACTED,
74
+ ],
75
+ // Long opaque runs (digests, base64 blobs). '-', '/' and '.' are excluded so
76
+ // UUIDs, paths and dotted names survive.
77
+ [/[A-Za-z0-9+=_]{40,}/g, REDACTED],
78
+ ];
79
+ /** Redact credential shapes from a string. Exported for tests. */
80
+ export function redactSecrets(value) {
81
+ let out = value;
82
+ for (const [pattern, replacement] of SECRET_PATTERNS) {
83
+ out = out.replace(pattern, replacement);
84
+ }
85
+ return out;
86
+ }
87
+ /**
88
+ * Reduce caller props to what an event may carry: bounded, typed, redacted.
89
+ * Pure; exported for tests.
90
+ */
91
+ export function sanitizeProps(props) {
92
+ const out = {};
93
+ if (typeof props !== "object" || props === null) {
94
+ return out;
95
+ }
96
+ let kept = 0;
97
+ for (const key of Object.keys(props)) {
98
+ if (kept >= MAX_PROP_KEYS) {
99
+ break;
100
+ }
101
+ if (!PROP_KEY_RE.test(key)) {
102
+ continue;
103
+ }
104
+ const value = props[key];
105
+ if (typeof value === "string") {
106
+ // Cut first so redaction never scans an unbounded string, with headroom
107
+ // so a secret straddling the final cut is still seen whole.
108
+ out[key] = redactSecrets(value.slice(0, MAX_PROP_VALUE_CHARS * 4)).slice(0, MAX_PROP_VALUE_CHARS);
109
+ }
110
+ else if (typeof value === "number" && Number.isFinite(value)) {
111
+ out[key] = value;
112
+ }
113
+ else if (typeof value === "boolean") {
114
+ out[key] = value;
115
+ }
116
+ else {
117
+ continue;
118
+ }
119
+ kept++;
120
+ }
121
+ return out;
122
+ }
123
+ function enqueue(op) {
124
+ if (pending.length < MAX_QUEUED_EVENTS) {
125
+ pending.push(op);
126
+ }
127
+ }
128
+ /**
129
+ * Record a product event — a funnel step, a feature used — as a span on the
130
+ * current session. Never throws, and is a no-op on the server, after
131
+ * disableTelemetry(), and (once the SDK has started) whenever telemetry is
132
+ * off. Calls made while the SDK is still loading are held, up to
133
+ * MAX_QUEUED_EVENTS, and replayed in order.
134
+ *
135
+ * `name` is a stable, low-cardinality event name ("onboarding_completed"),
136
+ * not a sentence. `props` is bounded and redacted as described in the module
137
+ * header; do not put user content in it.
138
+ */
139
+ export function track(name, props) {
140
+ if (typeof window === "undefined" || disabled) {
141
+ return;
142
+ }
143
+ if (typeof name !== "string") {
144
+ return;
145
+ }
146
+ const trimmed = name.trim().slice(0, MAX_EVENT_NAME_CHARS);
147
+ if (trimmed === "") {
148
+ return;
149
+ }
150
+ const attributes = sanitizeProps(props);
151
+ try {
152
+ if (sink) {
153
+ sink.track(trimmed, attributes);
154
+ }
155
+ else {
156
+ enqueue({ kind: "track", name: trimmed, attributes });
157
+ }
158
+ }
159
+ catch {
160
+ // Telemetry must never break the app.
161
+ }
162
+ }
163
+ /**
164
+ * Attach the signed-in user's id to every span and replay from here on, so an
165
+ * error, a funnel step and a replay can be joined to a user. Pass an opaque
166
+ * id — never an email, a name or anything else a person would recognise. Pass
167
+ * null (sign-out) to clear it.
168
+ *
169
+ * The id is carried as the `userId` global attribute. The SDK has no way to
170
+ * delete an attribute, so clearing writes an empty `userId`. Held until the
171
+ * SDK has started; the last call wins.
172
+ */
173
+ export function setTelemetryUser(id) {
174
+ if (typeof window === "undefined" || disabled) {
175
+ return;
176
+ }
177
+ const value = typeof id === "string" ? id.trim().slice(0, MAX_USER_ID_CHARS) : "";
178
+ try {
179
+ if (sink) {
180
+ sink.setUser(value);
181
+ return;
182
+ }
183
+ pending = pending.filter((op) => op.kind !== "user");
184
+ enqueue({ kind: "user", id: value });
185
+ }
186
+ catch {
187
+ // Telemetry must never break the app.
188
+ }
189
+ }
190
+ /**
191
+ * Turn browser telemetry off for the rest of this page load: stop the replay
192
+ * recorder, drop anything held, and make track(), setTelemetryUser() and
193
+ * reportException() no-ops. For when the user withdraws consent mid-session.
194
+ * See the module header for what it cannot undo.
195
+ */
196
+ export function disableTelemetry() {
197
+ disabled = true;
198
+ pending = [];
199
+ try {
200
+ sink?.disable();
201
+ }
202
+ catch {
203
+ // Telemetry must never break the app.
204
+ }
205
+ }
206
+ /** Whether disableTelemetry() has been called. Read by the installed recorder. */
207
+ export function isTelemetryDisabled() {
208
+ return disabled;
209
+ }
210
+ /**
211
+ * Install (or clear, with null) the sink track() forwards to, replaying
212
+ * whatever was held. Called by initBrowserTelemetry; an app has no reason to
213
+ * call it directly. Clearing also drops what was held, so a start that failed
214
+ * does not keep a queue nobody will drain.
215
+ */
216
+ export function setTelemetrySink(next) {
217
+ sink = next;
218
+ const held = pending;
219
+ pending = [];
220
+ if (!next || disabled) {
221
+ return;
222
+ }
223
+ for (const op of held) {
224
+ try {
225
+ if (op.kind === "track") {
226
+ next.track(op.name, op.attributes);
227
+ }
228
+ else {
229
+ next.setUser(op.id);
230
+ }
231
+ }
232
+ catch {
233
+ // Telemetry must never break the app.
234
+ }
235
+ }
236
+ }
237
+ /** Test seam. */
238
+ export function resetTrackState() {
239
+ sink = null;
240
+ disabled = false;
241
+ pending = [];
242
+ }
243
+ //# sourceMappingURL=track.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"track.js","sourceRoot":"","sources":["../src/track.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Product events, the signed-in user and the consent kill-switch: the part of\n// browser telemetry an app calls from ordinary components.\n//\n// Dependency-free on purpose, for the same reason as error-reporter.ts. This\n// module is exported from the barrel, which every frontend imports —\n// including ones that never install the HyperDX SDK — so it cannot import the\n// SDK. The `/telemetry` subpath, which does, installs the real sink here once\n// the SDK has initialised. Until then (the SDK chunk is still loading) calls\n// are held, bounded, and replayed in order; with telemetry off they are\n// dropped. That is what makes `track()` safe to call unconditionally from a\n// click handler or an effect.\n//\n// ── What `track` sends ───────────────────────────────────────────────────\n//\n// `track(name, props)` becomes one span from the SDK's `custom-action` tracer\n// (HyperDX.addAction), so a product event inherits the session id, page,\n// trace context and release the SDK stamps on every span — which is what lets\n// a funnel step link to the replay and the errors of the same session.\n//\n// Props are user data in flight, so they are bounded and scrubbed HERE, before\n// the SDK sees them, and not left to a server-side filter:\n// - at most MAX_PROP_KEYS keys; a key must match PROP_KEY_RE or it is dropped\n// - values are string | number | boolean; anything else is dropped\n// - string values have credential shapes redacted, then are cut to\n// MAX_PROP_VALUE_CHARS\n//\n// ── Consent ──────────────────────────────────────────────────────────────\n//\n// `disableTelemetry()` is the runtime half of \"the user turned it off\": it\n// stops the replay recorder and makes track, setTelemetryUser and\n// reportException no-ops for the rest of the page load. It cannot recall what\n// the SDK already batched, and the SDK's own auto-instrumentation (page\n// loads, fetch/XHR, uncaught errors) has no off switch once running — an app\n// that must send nothing without consent must not call initBrowserTelemetry\n// until it has it. There is no re-enable; init again on the next page load.\n\n/** Most events held while the SDK is still loading. Later calls are dropped. */\nexport const MAX_QUEUED_EVENTS = 100;\n/** Most props kept on one event; the rest are dropped in insertion order. */\nexport const MAX_PROP_KEYS = 20;\n/** Longest string prop value, after redaction. */\nexport const MAX_PROP_VALUE_CHARS = 256;\n/** Longest event name. */\nexport const MAX_EVENT_NAME_CHARS = 128;\n/** Longest user id. */\nexport const MAX_USER_ID_CHARS = 128;\n/** A prop key is a dotted lowercase identifier. */\nexport const PROP_KEY_RE = /^[a-z0-9_.]{1,64}$/;\n/** What a redacted credential becomes. */\nexport const REDACTED = \"[redacted]\";\n\n/** A prop value an event may carry. Anything else is dropped. */\nexport type TrackValue = string | number | boolean;\n/** Props accepted by track(); non-conforming entries are dropped, not thrown on. */\nexport type TrackProps = Record<string, unknown>;\n\n/** What the `/telemetry` subpath installs once the SDK is running. */\nexport interface TelemetrySink {\n track(name: string, attributes: Record<string, TrackValue>): void;\n setUser(id: string): void;\n disable(): void;\n}\n\ntype Pending =\n | { kind: \"track\"; name: string; attributes: Record<string, TrackValue> }\n | { kind: \"user\"; id: string };\n\nlet sink: TelemetrySink | null = null;\nlet disabled = false;\nlet pending: Pending[] = [];\n\n// Credential shapes, the same set the reliant web app scrubs from error text\n// (and the backend scrubs server-side). Kept here so a product event cannot\n// carry what an error message may not.\nconst SECRET_PATTERNS: ReadonlyArray<readonly [RegExp, string]> = [\n // URL userinfo: postgres://user:password@host\n [/([A-Za-z][A-Za-z0-9+.-]*:\\/\\/)[^\\s/@:]+:[^\\s/@]+@/g, `$1${REDACTED}@`],\n // key=value / \"key\": \"value\" for credential-named keys.\n [\n /\\b(authorization|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|secret|password|passwd|token)([\"']?\\s*[:=]\\s*[\"']?)(?:(?:bearer|basic|token)\\s+)?[^\\s\"'&,;]+/gi,\n `$1$2${REDACTED}`,\n ],\n // A bare authorization scheme outside a key=value pair.\n [/\\b(bearer|basic)\\s+[A-Za-z0-9._~+/=-]+/gi, `$1 ${REDACTED}`],\n // JWTs.\n [/\\beyJ[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]{5,}\\.[A-Za-z0-9_-]*/g, REDACTED],\n // Provider and platform key prefixes.\n [\n /\\b(?:sk-ant-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{16,}|sk_(?:live|test)_[A-Za-z0-9]{8,}|(?:rlat|dpat|gho|ghp|ghu|ghs|ghr|github_pat|glpat|xox[abprs])[_-][A-Za-z0-9_-]{10,}|AIza[0-9A-Za-z_-]{20,}|ya29\\.[0-9A-Za-z_-]+|AKIA[0-9A-Z]{16})/g,\n REDACTED,\n ],\n // Long opaque runs (digests, base64 blobs). '-', '/' and '.' are excluded so\n // UUIDs, paths and dotted names survive.\n [/[A-Za-z0-9+=_]{40,}/g, REDACTED],\n];\n\n/** Redact credential shapes from a string. Exported for tests. */\nexport function redactSecrets(value: string): string {\n let out = value;\n for (const [pattern, replacement] of SECRET_PATTERNS) {\n out = out.replace(pattern, replacement);\n }\n return out;\n}\n\n/**\n * Reduce caller props to what an event may carry: bounded, typed, redacted.\n * Pure; exported for tests.\n */\nexport function sanitizeProps(\n props: TrackProps | undefined,\n): Record<string, TrackValue> {\n const out: Record<string, TrackValue> = {};\n if (typeof props !== \"object\" || props === null) {\n return out;\n }\n let kept = 0;\n for (const key of Object.keys(props)) {\n if (kept >= MAX_PROP_KEYS) {\n break;\n }\n if (!PROP_KEY_RE.test(key)) {\n continue;\n }\n const value = props[key];\n if (typeof value === \"string\") {\n // Cut first so redaction never scans an unbounded string, with headroom\n // so a secret straddling the final cut is still seen whole.\n out[key] = redactSecrets(value.slice(0, MAX_PROP_VALUE_CHARS * 4)).slice(\n 0,\n MAX_PROP_VALUE_CHARS,\n );\n } else if (typeof value === \"number\" && Number.isFinite(value)) {\n out[key] = value;\n } else if (typeof value === \"boolean\") {\n out[key] = value;\n } else {\n continue;\n }\n kept++;\n }\n return out;\n}\n\nfunction enqueue(op: Pending): void {\n if (pending.length < MAX_QUEUED_EVENTS) {\n pending.push(op);\n }\n}\n\n/**\n * Record a product event — a funnel step, a feature used — as a span on the\n * current session. Never throws, and is a no-op on the server, after\n * disableTelemetry(), and (once the SDK has started) whenever telemetry is\n * off. Calls made while the SDK is still loading are held, up to\n * MAX_QUEUED_EVENTS, and replayed in order.\n *\n * `name` is a stable, low-cardinality event name (\"onboarding_completed\"),\n * not a sentence. `props` is bounded and redacted as described in the module\n * header; do not put user content in it.\n */\nexport function track(name: string, props?: TrackProps): void {\n if (typeof window === \"undefined\" || disabled) {\n return;\n }\n if (typeof name !== \"string\") {\n return;\n }\n const trimmed = name.trim().slice(0, MAX_EVENT_NAME_CHARS);\n if (trimmed === \"\") {\n return;\n }\n const attributes = sanitizeProps(props);\n try {\n if (sink) {\n sink.track(trimmed, attributes);\n } else {\n enqueue({ kind: \"track\", name: trimmed, attributes });\n }\n } catch {\n // Telemetry must never break the app.\n }\n}\n\n/**\n * Attach the signed-in user's id to every span and replay from here on, so an\n * error, a funnel step and a replay can be joined to a user. Pass an opaque\n * id — never an email, a name or anything else a person would recognise. Pass\n * null (sign-out) to clear it.\n *\n * The id is carried as the `userId` global attribute. The SDK has no way to\n * delete an attribute, so clearing writes an empty `userId`. Held until the\n * SDK has started; the last call wins.\n */\nexport function setTelemetryUser(id: string | null | undefined): void {\n if (typeof window === \"undefined\" || disabled) {\n return;\n }\n const value =\n typeof id === \"string\" ? id.trim().slice(0, MAX_USER_ID_CHARS) : \"\";\n try {\n if (sink) {\n sink.setUser(value);\n return;\n }\n pending = pending.filter((op) => op.kind !== \"user\");\n enqueue({ kind: \"user\", id: value });\n } catch {\n // Telemetry must never break the app.\n }\n}\n\n/**\n * Turn browser telemetry off for the rest of this page load: stop the replay\n * recorder, drop anything held, and make track(), setTelemetryUser() and\n * reportException() no-ops. For when the user withdraws consent mid-session.\n * See the module header for what it cannot undo.\n */\nexport function disableTelemetry(): void {\n disabled = true;\n pending = [];\n try {\n sink?.disable();\n } catch {\n // Telemetry must never break the app.\n }\n}\n\n/** Whether disableTelemetry() has been called. Read by the installed recorder. */\nexport function isTelemetryDisabled(): boolean {\n return disabled;\n}\n\n/**\n * Install (or clear, with null) the sink track() forwards to, replaying\n * whatever was held. Called by initBrowserTelemetry; an app has no reason to\n * call it directly. Clearing also drops what was held, so a start that failed\n * does not keep a queue nobody will drain.\n */\nexport function setTelemetrySink(next: TelemetrySink | null): void {\n sink = next;\n const held = pending;\n pending = [];\n if (!next || disabled) {\n return;\n }\n for (const op of held) {\n try {\n if (op.kind === \"track\") {\n next.track(op.name, op.attributes);\n } else {\n next.setUser(op.id);\n }\n } catch {\n // Telemetry must never break the app.\n }\n }\n}\n\n/** Test seam. */\nexport function resetTrackState(): void {\n sink = null;\n disabled = false;\n pending = [];\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reliantlabs/forge-web-runtime",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "The forge frontend runtime: Connect interceptor stack, session, error boundary, toast queue, <Resource> container, W3C trace propagation and the HyperDX browser SDK wiring (errors, console, network, traces; replay off by default). The web twin of forge/pkg.",
5
5
  "license": "Apache-2.0",
6
6
  "//repository note": "REQUIRED for `npm publish --provenance`, not decoration. npm cross-checks this against the repository in the signed provenance statement and rejects the publish with a 422 if they disagree — an absent field reads as \"\" and fails that comparison. `directory` names the subdirectory this package lives in, so the provenance points at the real source. Omitting this is what made the first 0.3.1 publish fail AFTER the tarball had been signed and written to the sigstore transparency log.",
@@ -17,6 +17,12 @@
17
17
  "publishConfig": {
18
18
  "access": "public"
19
19
  },
20
+ "// overrides note": "The recorder still pins vulnerable fflate 0.7.4 (GHSA-px8p-9vwx-vf98); use the patched 0.7 line only in that subtree. npm applies overrides only at the install root, not from published dependencies, so the Next.js and Vite scaffold manifests carry the same override. Remove when HyperDX updates the recorder's pin.",
21
+ "overrides": {
22
+ "@hyperdx/otel-web-session-recorder": {
23
+ "fflate": "^0.7.5"
24
+ }
25
+ },
20
26
  "type": "module",
21
27
  "sideEffects": false,
22
28
  "files": [