@reliantlabs/forge-web-runtime 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/interceptors.d.ts +4 -0
- package/dist/interceptors.js +6 -0
- package/dist/interceptors.js.map +1 -1
- package/dist/telemetry-gate.d.ts +12 -0
- package/dist/telemetry-gate.js +51 -0
- package/dist/telemetry-gate.js.map +1 -0
- package/dist/telemetry.d.ts +44 -2
- package/dist/telemetry.js +154 -16
- package/dist/telemetry.js.map +1 -1
- package/dist/track.d.ts +72 -0
- package/dist/track.js +243 -0
- package/dist/track.js.map +1 -0
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -13,6 +13,9 @@ import {
|
|
|
13
13
|
RouteGuard, // signed-in render gate
|
|
14
14
|
Resource, // the list tristate + cursor pagination
|
|
15
15
|
reportException, // record a caught error with the browser telemetry SDK
|
|
16
|
+
track, // record a product event (funnel step) on the current session
|
|
17
|
+
setTelemetryUser, // attach an opaque user id to every span and replay
|
|
18
|
+
disableTelemetry, // stop recording for the rest of the page load (consent)
|
|
16
19
|
formatMinorUnits, // exact money display; parseMinorUnits for input
|
|
17
20
|
} from "@reliantlabs/forge-web-runtime";
|
|
18
21
|
```
|
|
@@ -90,6 +93,49 @@ build — it is no longer "these are `.tsx` files" but "these modules carry
|
|
|
90
93
|
`"use client"` and Next has to process them rather than treat the package as
|
|
91
94
|
an opaque external."
|
|
92
95
|
|
|
96
|
+
## Browser telemetry (`/telemetry`)
|
|
97
|
+
|
|
98
|
+
`initBrowserTelemetry` starts the HyperDX browser SDK once per page load
|
|
99
|
+
(errors, console, fetch/XHR, page loads, web vitals, optional replay) and ships
|
|
100
|
+
to this origin's `/_otel`. It is a no-op without a DOM or an endpoint and never
|
|
101
|
+
throws.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { initBrowserTelemetry } from "@reliantlabs/forge-web-runtime/telemetry";
|
|
105
|
+
|
|
106
|
+
await initBrowserTelemetry({
|
|
107
|
+
serviceName: "web",
|
|
108
|
+
serviceVersion: import.meta.env.VITE_APP_VERSION, // the forge build id
|
|
109
|
+
frontend: "web", // resource attribute forge.frontend
|
|
110
|
+
endpoint: "/_otel",
|
|
111
|
+
consoleCapture: false, // log/info/warn/debug only; console.error(err) is still an error
|
|
112
|
+
replay: true,
|
|
113
|
+
replaySampleRate: 0.1, // 0..1 of SESSIONS; decided once, held in sessionStorage
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`track`, `setTelemetryUser` and `disableTelemetry` come from the barrel, so a
|
|
118
|
+
component can call them without importing the SDK. They are safe anywhere: no-ops
|
|
119
|
+
on the server, and calls made while the SDK chunk is still loading are held
|
|
120
|
+
(at most 100) and replayed in order.
|
|
121
|
+
|
|
122
|
+
- `track(name, props?)` — one `custom-action` span. At most 20 props; keys
|
|
123
|
+
`[a-z0-9_.]{1,64}`; values string, number or boolean; strings are redacted for
|
|
124
|
+
credential shapes and cut to 256 chars. Not for user content.
|
|
125
|
+
- `setTelemetryUser(id | null)` — the `userId` global attribute. Opaque id only,
|
|
126
|
+
never an email. `null` clears it.
|
|
127
|
+
- `disableTelemetry()` — stops the replay recorder and silences `track`,
|
|
128
|
+
`setTelemetryUser` and `reportException` until the next page load. It cannot
|
|
129
|
+
recall spans already batched, and the SDK's auto-instrumentation has no off
|
|
130
|
+
switch once running: an app that must send nothing without consent should not
|
|
131
|
+
call `initBrowserTelemetry` before it has it.
|
|
132
|
+
|
|
133
|
+
Privacy is forced, not configurable: when replay records, all text and inputs
|
|
134
|
+
are masked, canvas is never recorded, `img, picture, video, audio, iframe,
|
|
135
|
+
object, embed` are blocked, images are not inlined and fonts are not collected.
|
|
136
|
+
fetch/XHR spans have their `http.url` / `url.full` stripped of query string and
|
|
137
|
+
fragment; request and response bodies and headers are not captured.
|
|
138
|
+
|
|
93
139
|
## The DOM-free interceptors entry point
|
|
94
140
|
|
|
95
141
|
```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -42,6 +42,14 @@ export { Resource, type ResourceColumn, type ResourceProps, type ResourceStatus,
|
|
|
42
42
|
* through the "/telemetry" subpath, never the barrel.
|
|
43
43
|
*/
|
|
44
44
|
export { reportException, setExceptionReporter, type ExceptionAttributes, } from "./error-reporter.js";
|
|
45
|
+
/**
|
|
46
|
+
* Product events, the signed-in user and the consent kill-switch for browser
|
|
47
|
+
* telemetry. Like reportException these are safe to call anywhere — no-ops on
|
|
48
|
+
* the server and when telemetry is off — and calls made while the SDK is still
|
|
49
|
+
* loading are held and replayed. The SDK itself arrives through the
|
|
50
|
+
* "/telemetry" subpath, never the barrel.
|
|
51
|
+
*/
|
|
52
|
+
export { disableTelemetry, setTelemetryUser, track, type TrackProps, type TrackValue, } from "./track.js";
|
|
45
53
|
/**
|
|
46
54
|
* Dev-only browser log forwarding: mirrors console output and uncaught errors
|
|
47
55
|
* to the dev server, which prints them into
|
package/dist/index.js
CHANGED
|
@@ -60,6 +60,14 @@ export { Resource, } from "./resource.js";
|
|
|
60
60
|
* through the "/telemetry" subpath, never the barrel.
|
|
61
61
|
*/
|
|
62
62
|
export { reportException, setExceptionReporter, } from "./error-reporter.js";
|
|
63
|
+
/**
|
|
64
|
+
* Product events, the signed-in user and the consent kill-switch for browser
|
|
65
|
+
* telemetry. Like reportException these are safe to call anywhere — no-ops on
|
|
66
|
+
* the server and when telemetry is off — and calls made while the SDK is still
|
|
67
|
+
* loading are held and replayed. The SDK itself arrives through the
|
|
68
|
+
* "/telemetry" subpath, never the barrel.
|
|
69
|
+
*/
|
|
70
|
+
export { disableTelemetry, setTelemetryUser, track, } from "./track.js";
|
|
63
71
|
/**
|
|
64
72
|
* Dev-only browser log forwarding: mirrors console output and uncaught errors
|
|
65
73
|
* to the dev server, which prints them into
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,EAAE;AACF,yEAAyE;AACzE,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,4EAA4E;AAC5E,8EAA8E;AAC9E,gEAAgE;AAChE,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,4CAA4C;AAE5C,yEAAyE;AACzE,6EAA6E;AAC7E,4EAA4E;AAC5E,4EAA4E;AAC5E,sEAAsE;AAEtE,mCAAmC;AACnC,OAAO,EACL,eAAe,EACf,wBAAwB,EACxB,yBAAyB,EACzB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,GAEjB,MAAM,mBAAmB,CAAC;AAE3B;;;GAGG;AACH,OAAO,EACL,kBAAkB,EAClB,yBAAyB,EACzB,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB;;;;;;GAMG;AACH,OAAO,EACL,cAAc,EACd,YAAY,EACZ,iBAAiB,GAElB,MAAM,eAAe,CAAC;AAEvB;;;;;;;GAOG;AACH,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,eAAe,GAKhB,MAAM,YAAY,CAAC;AAEpB,qCAAqC;AACrC,OAAO,EACL,gBAAgB,EAChB,kBAAkB,EAClB,eAAe,EACf,cAAc,GACf,MAAM,YAAY,CAAC;AAEpB,qFAAqF;AACrF,OAAO,EACL,SAAS,EACT,eAAe,EACf,UAAU,GAIX,MAAM,cAAc,CAAC;AAEtB,gFAAgF;AAChF,OAAO,EAAE,YAAY,EAA0B,MAAM,gBAAgB,CAAC;AAEtE,+EAA+E;AAC/E,OAAO,EACL,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,qBAAqB,CAAC;AAE7B,yEAAyE;AACzE,OAAO,EACL,gBAAgB,GAMjB,MAAM,iBAAiB,CAAC;AAEzB,gEAAgE;AAChE,OAAO,EAAE,UAAU,EAAwB,MAAM,kBAAkB,CAAC;AAEpE,uFAAuF;AACvF,OAAO,EACL,QAAQ,GAIT,MAAM,eAAe,CAAC;AAEvB;;;;GAIG;AACH,OAAO,EACL,eAAe,EACf,oBAAoB,GAErB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;GAMG;AACH,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EACjB,mBAAmB,GAGpB,MAAM,aAAa,CAAC","sourcesContent":["// @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Public barrel. Import batteries from \"@reliantlabs/forge-web-runtime\".\n//\n// Everything here is MECHANISM: the transport interceptor stack, the session,\n// the error boundary, the toast queue, the <Resource> tristate ladder, W3C\n// trace propagation, client RUM. Nothing here reaches into an application —\n// app-shaped values (auth state, event subscriptions, toast markup) arrive as\n// props, so an app can restyle or replace its own files freely.\n//\n// Presentation that a project is meant to own — the component library under\n// src/components/ui, globals.css, nav, layout — deliberately stays in the\n// scaffold and is NOT part of this package.\n\n// The per-group docs below are JSDoc, not `//`, on purpose: TypeScript's\n// declaration emit preserves JSDoc and DROPS line comments, and dist/ is now\n// the whole of what this package publishes. `forge project libraries` reads\n// this inventory out of the published barrel, and a consumer's editor shows\n// it on hover — both would get symbols with no explanation otherwise.\n\n/** Transport interceptor stack. */\nexport {\n authInterceptor,\n buildRuntimeInterceptors,\n errorNormalizeInterceptor,\n headerInterceptor,\n retryInterceptor,\n traceInterceptor,\n type RuntimeInterceptorConfig,\n} from \"./interceptors.js\";\n\n/**\n * Typed client errors, and the backend-framing strippers every surface that\n * SHOWS an error goes through.\n */\nexport {\n ConnectClientError,\n FORGE_ERROR_REASON_HEADER,\n isRetryableCode,\n normalizeError,\n stripServerFraming,\n userMessage,\n} from \"./errors.js\";\n\n/**\n * Mount-prefix helpers, for the URLs Next.js's router never sees — absolute\n * URLs handed to external systems that round-trip back into the app.\n *\n * Barrel-exported rather than behind a subpath: three string functions with\n * no imports, so there is no dependency footprint for a subpath to fence off.\n */\nexport {\n createBasePath,\n joinBasePath,\n normalizeBasePath,\n type BasePathHelpers,\n} from \"./basepath.js\";\n\n/**\n * Money and basis points as integers: format minor units through Intl, and\n * parse form input back to minor units without rounding.\n *\n * The web counterpart of forge/pkg/money — an int64 count of the currency's\n * minor unit plus an ISO 4217 code, exact past 2^53. Barrel-exported for the\n * same reason as the base-path helpers: it imports nothing.\n */\nexport {\n basisPointsToInput,\n currencyMinorDigits,\n formatBasisPoints,\n formatMinorUnits,\n minorUnitsToInput,\n parseBasisPoints,\n parseMinorUnits,\n type BasisPointsOptions,\n type MoneyOptions,\n type ParseBasisPointsOptions,\n type ParseMoneyOptions,\n} from \"./money.js\";\n\n/** W3C trace-context propagation. */\nexport {\n freshTraceparent,\n injectTraceContext,\n setTraceSampled,\n TRACEPARENT_RE,\n} from \"./trace.js\";\n\n/** Session: the signed-in user, derived from the app's auth state and JWT claims. */\nexport {\n decodeJwt,\n SessionProvider,\n useSession,\n type JwtClaims,\n type Session,\n type SessionAuth,\n} from \"./session.js\";\n\n/** The app-shell provider set: session + error boundary + toast host in one. */\nexport { RuntimeShell, type RuntimeShellProps } from \"./providers.js\";\n\n/** Error boundary with a designed crash fallback, for a route or a subtree. */\nexport {\n DefaultErrorFallback,\n RuntimeErrorBoundary,\n} from \"./error-boundary.js\";\n\n/** The toast queue. The app supplies the event source and the markup. */\nexport {\n RuntimeToastHost,\n type RuntimeToast,\n type RuntimeToastHostProps,\n type RuntimeToastPayload,\n type RuntimeToastSink,\n type RuntimeToastVariant,\n} from \"./toast-host.js\";\n\n/** Route guarding: render a page only to a signed-in caller. */\nexport { RouteGuard, type RouteGuardProps } from \"./route-guard.js\";\n\n/** Generic data-table container: the tristate ladder, filter and cursor pagination. */\nexport {\n Resource,\n type ResourceColumn,\n type ResourceProps,\n type ResourceStatus,\n} from \"./resource.js\";\n\n/**\n * Record a caught error with the browser telemetry SDK, when one is running.\n * A no-op otherwise, so it is safe in any catch block. The SDK itself arrives\n * through the \"/telemetry\" subpath, never the barrel.\n */\nexport {\n reportException,\n setExceptionReporter,\n type ExceptionAttributes,\n} from \"./error-reporter.js\";\n\n/**\n * Dev-only browser log forwarding: mirrors console output and uncaught errors\n * to the dev server, which prints them into\n * `.forge/logs/<env>/frontend_<name>.log`. No-ops unless `dev: true`, and the\n * receiving endpoint exists only in a dev server — so a production bundle\n * neither posts nor has anywhere to post to.\n */\nexport {\n DEV_LOG_ENDPOINT,\n devLoggingInstalled,\n installDevLogging,\n uninstallDevLogging,\n type DevLoggingOptions,\n type DevLogPayload,\n} from \"./devlog.js\";\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8DAA8D;AAC9D,EAAE;AACF,yEAAyE;AACzE,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,4EAA4E;AAC5E,8EAA8E;AAC9E,gEAAgE;AAChE,EAAE;AACF,4EAA4E;AAC5E,0EAA0E;AAC1E,4CAA4C;AAE5C,yEAAyE;AACzE,6EAA6E;AAC7E,4EAA4E;AAC5E,4EAA4E;AAC5E,sEAAsE;AAEtE,mCAAmC;AACnC,OAAO,EACL,eAAe,EACf,wBAAwB,EACxB,yBAAyB,EACzB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,GAEjB,MAAM,mBAAmB,CAAC;AAE3B;;;GAGG;AACH,OAAO,EACL,kBAAkB,EAClB,yBAAyB,EACzB,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,WAAW,GACZ,MAAM,aAAa,CAAC;AAErB;;;;;;GAMG;AACH,OAAO,EACL,cAAc,EACd,YAAY,EACZ,iBAAiB,GAElB,MAAM,eAAe,CAAC;AAEvB;;;;;;;GAOG;AACH,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,eAAe,GAKhB,MAAM,YAAY,CAAC;AAEpB,qCAAqC;AACrC,OAAO,EACL,gBAAgB,EAChB,kBAAkB,EAClB,eAAe,EACf,cAAc,GACf,MAAM,YAAY,CAAC;AAEpB,qFAAqF;AACrF,OAAO,EACL,SAAS,EACT,eAAe,EACf,UAAU,GAIX,MAAM,cAAc,CAAC;AAEtB,gFAAgF;AAChF,OAAO,EAAE,YAAY,EAA0B,MAAM,gBAAgB,CAAC;AAEtE,+EAA+E;AAC/E,OAAO,EACL,oBAAoB,EACpB,oBAAoB,GACrB,MAAM,qBAAqB,CAAC;AAE7B,yEAAyE;AACzE,OAAO,EACL,gBAAgB,GAMjB,MAAM,iBAAiB,CAAC;AAEzB,gEAAgE;AAChE,OAAO,EAAE,UAAU,EAAwB,MAAM,kBAAkB,CAAC;AAEpE,uFAAuF;AACvF,OAAO,EACL,QAAQ,GAIT,MAAM,eAAe,CAAC;AAEvB;;;;GAIG;AACH,OAAO,EACL,eAAe,EACf,oBAAoB,GAErB,MAAM,qBAAqB,CAAC;AAE7B;;;;;;GAMG;AACH,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,GAGN,MAAM,YAAY,CAAC;AAEpB;;;;;;GAMG;AACH,OAAO,EACL,gBAAgB,EAChB,mBAAmB,EACnB,iBAAiB,EACjB,mBAAmB,GAGpB,MAAM,aAAa,CAAC","sourcesContent":["// @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Public barrel. Import batteries from \"@reliantlabs/forge-web-runtime\".\n//\n// Everything here is MECHANISM: the transport interceptor stack, the session,\n// the error boundary, the toast queue, the <Resource> tristate ladder, W3C\n// trace propagation, client RUM. Nothing here reaches into an application —\n// app-shaped values (auth state, event subscriptions, toast markup) arrive as\n// props, so an app can restyle or replace its own files freely.\n//\n// Presentation that a project is meant to own — the component library under\n// src/components/ui, globals.css, nav, layout — deliberately stays in the\n// scaffold and is NOT part of this package.\n\n// The per-group docs below are JSDoc, not `//`, on purpose: TypeScript's\n// declaration emit preserves JSDoc and DROPS line comments, and dist/ is now\n// the whole of what this package publishes. `forge project libraries` reads\n// this inventory out of the published barrel, and a consumer's editor shows\n// it on hover — both would get symbols with no explanation otherwise.\n\n/** Transport interceptor stack. */\nexport {\n authInterceptor,\n buildRuntimeInterceptors,\n errorNormalizeInterceptor,\n headerInterceptor,\n retryInterceptor,\n traceInterceptor,\n type RuntimeInterceptorConfig,\n} from \"./interceptors.js\";\n\n/**\n * Typed client errors, and the backend-framing strippers every surface that\n * SHOWS an error goes through.\n */\nexport {\n ConnectClientError,\n FORGE_ERROR_REASON_HEADER,\n isRetryableCode,\n normalizeError,\n stripServerFraming,\n userMessage,\n} from \"./errors.js\";\n\n/**\n * Mount-prefix helpers, for the URLs Next.js's router never sees — absolute\n * URLs handed to external systems that round-trip back into the app.\n *\n * Barrel-exported rather than behind a subpath: three string functions with\n * no imports, so there is no dependency footprint for a subpath to fence off.\n */\nexport {\n createBasePath,\n joinBasePath,\n normalizeBasePath,\n type BasePathHelpers,\n} from \"./basepath.js\";\n\n/**\n * Money and basis points as integers: format minor units through Intl, and\n * parse form input back to minor units without rounding.\n *\n * The web counterpart of forge/pkg/money — an int64 count of the currency's\n * minor unit plus an ISO 4217 code, exact past 2^53. Barrel-exported for the\n * same reason as the base-path helpers: it imports nothing.\n */\nexport {\n basisPointsToInput,\n currencyMinorDigits,\n formatBasisPoints,\n formatMinorUnits,\n minorUnitsToInput,\n parseBasisPoints,\n parseMinorUnits,\n type BasisPointsOptions,\n type MoneyOptions,\n type ParseBasisPointsOptions,\n type ParseMoneyOptions,\n} from \"./money.js\";\n\n/** W3C trace-context propagation. */\nexport {\n freshTraceparent,\n injectTraceContext,\n setTraceSampled,\n TRACEPARENT_RE,\n} from \"./trace.js\";\n\n/** Session: the signed-in user, derived from the app's auth state and JWT claims. */\nexport {\n decodeJwt,\n SessionProvider,\n useSession,\n type JwtClaims,\n type Session,\n type SessionAuth,\n} from \"./session.js\";\n\n/** The app-shell provider set: session + error boundary + toast host in one. */\nexport { RuntimeShell, type RuntimeShellProps } from \"./providers.js\";\n\n/** Error boundary with a designed crash fallback, for a route or a subtree. */\nexport {\n DefaultErrorFallback,\n RuntimeErrorBoundary,\n} from \"./error-boundary.js\";\n\n/** The toast queue. The app supplies the event source and the markup. */\nexport {\n RuntimeToastHost,\n type RuntimeToast,\n type RuntimeToastHostProps,\n type RuntimeToastPayload,\n type RuntimeToastSink,\n type RuntimeToastVariant,\n} from \"./toast-host.js\";\n\n/** Route guarding: render a page only to a signed-in caller. */\nexport { RouteGuard, type RouteGuardProps } from \"./route-guard.js\";\n\n/** Generic data-table container: the tristate ladder, filter and cursor pagination. */\nexport {\n Resource,\n type ResourceColumn,\n type ResourceProps,\n type ResourceStatus,\n} from \"./resource.js\";\n\n/**\n * Record a caught error with the browser telemetry SDK, when one is running.\n * A no-op otherwise, so it is safe in any catch block. The SDK itself arrives\n * through the \"/telemetry\" subpath, never the barrel.\n */\nexport {\n reportException,\n setExceptionReporter,\n type ExceptionAttributes,\n} from \"./error-reporter.js\";\n\n/**\n * Product events, the signed-in user and the consent kill-switch for browser\n * telemetry. Like reportException these are safe to call anywhere — no-ops on\n * the server and when telemetry is off — and calls made while the SDK is still\n * loading are held and replayed. The SDK itself arrives through the\n * \"/telemetry\" subpath, never the barrel.\n */\nexport {\n disableTelemetry,\n setTelemetryUser,\n track,\n type TrackProps,\n type TrackValue,\n} from \"./track.js\";\n\n/**\n * Dev-only browser log forwarding: mirrors console output and uncaught errors\n * to the dev server, which prints them into\n * `.forge/logs/<env>/frontend_<name>.log`. No-ops unless `dev: true`, and the\n * receiving endpoint exists only in a dev server — so a production bundle\n * neither posts nor has anywhere to post to.\n */\nexport {\n DEV_LOG_ENDPOINT,\n devLoggingInstalled,\n installDevLogging,\n uninstallDevLogging,\n type DevLoggingOptions,\n type DevLogPayload,\n} from \"./devlog.js\";\n"]}
|
package/dist/interceptors.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export declare function headerInterceptor(cfg: RuntimeInterceptorConfig): Interc
|
|
|
24
24
|
/**
|
|
25
25
|
* Propagates a W3C traceparent so the backend (otelconnect + otelhttp) joins
|
|
26
26
|
* the same distributed trace. Always emits a valid header — see trace.ts.
|
|
27
|
+
*
|
|
28
|
+
* While browser telemetry is still starting it waits (bounded; see
|
|
29
|
+
* telemetry-gate.ts) so the call is made through the SDK's patched fetch and
|
|
30
|
+
* its traceparent names a span the browser really exports.
|
|
27
31
|
*/
|
|
28
32
|
export declare function traceInterceptor(): Interceptor;
|
|
29
33
|
/** Converts any thrown failure into a typed ConnectClientError. */
|
package/dist/interceptors.js
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
// split fixed.
|
|
35
35
|
import { MethodOptions_IdempotencyLevel } from "@bufbuild/protobuf/wkt";
|
|
36
36
|
import { ConnectClientError, normalizeError } from "./errors.js";
|
|
37
|
+
import { telemetryReady } from "./telemetry-gate.js";
|
|
37
38
|
import { injectTraceContext } from "./trace.js";
|
|
38
39
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
39
40
|
/** Attaches the bearer token from the app's auth provider. */
|
|
@@ -59,9 +60,14 @@ export function headerInterceptor(cfg) {
|
|
|
59
60
|
/**
|
|
60
61
|
* Propagates a W3C traceparent so the backend (otelconnect + otelhttp) joins
|
|
61
62
|
* the same distributed trace. Always emits a valid header — see trace.ts.
|
|
63
|
+
*
|
|
64
|
+
* While browser telemetry is still starting it waits (bounded; see
|
|
65
|
+
* telemetry-gate.ts) so the call is made through the SDK's patched fetch and
|
|
66
|
+
* its traceparent names a span the browser really exports.
|
|
62
67
|
*/
|
|
63
68
|
export function traceInterceptor() {
|
|
64
69
|
return (next) => async (req) => {
|
|
70
|
+
await telemetryReady();
|
|
65
71
|
injectTraceContext(req.header);
|
|
66
72
|
return next(req);
|
|
67
73
|
};
|
package/dist/interceptors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"interceptors.js","sourceRoot":"","sources":["../src/interceptors.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// This module is also the package's SECOND ENTRY POINT, published as\n// \"@reliantlabs/forge-web-runtime/interceptors\". It is the DOM-free, React-free\n// half of the runtime: the transport layer and nothing else. Importing it\n// pulls in exactly ./errors and ./trace — no React, no @types/react, no\n// window/document. That matters for React Native, where Expo pins React 18\n// while the package's components are typed against React 19: a mobile app\n// importing the full barrel would typecheck the components it never renders\n// and fail. Everything re-exported at the bottom of this file is part of\n// this entry point's surface; keep it React-free (guarded by\n// interceptors-entry.test.ts).\n//\n// The Connect transport interceptor stack. This is the one place every\n// frontend→backend call flows through, composed once and handed to\n// createConnectTransport by the app's own connect module. Order (outermost\n// first):\n//\n// error-normalize → auth → brand headers → traceparent\n//\n// error-normalize is outermost so every caller sees a typed\n// ConnectClientError instead of a raw ConnectError.\n//\n// RETRY OWNERSHIP — read before adding a retry layer.\n// The single retry owner is the app's React Query client: it\n// structurally knows reads from writes, so it retries transient READ\n// failures and NEVER retries a mutation — no duplicate writes. Each React\n// Query retry re-runs the queryFn, which re-runs this whole interceptor\n// chain (fresh token + traceparent), so nothing is lost by not retrying\n// here. `retryInterceptor` below is therefore NOT in the default chain; it\n// stays exported (idempotency-gated) only for callers who use the raw\n// transport OUTSIDE React Query. Putting it back in the default chain would\n// stack two retry layers AND retry non-idempotent mutations — the bug this\n// split fixed.\nimport { MethodOptions_IdempotencyLevel } from \"@bufbuild/protobuf/wkt\";\n\nimport { ConnectClientError, normalizeError } from \"./errors.js\";\nimport { injectTraceContext } from \"./trace.js\";\n\nimport type {\n Interceptor,\n UnaryRequest,\n StreamRequest,\n} from \"@connectrpc/connect\";\n\n/**\n * RuntimeInterceptorConfig is the seam the app wires its runtime dependencies\n * through. Everything is optional: an unconfigured chain still propagates\n * traceparent and normalizes errors.\n */\nexport interface RuntimeInterceptorConfig {\n /** Returns the current bearer token, or null when unauthenticated. */\n getToken?: () => Promise<string | null> | string | null;\n /** Returns the active brand, attached as the X-Brand header. */\n getBrand?: () => string | null | undefined;\n /** Invoked with the typed error on every failed RPC (telemetry sink). */\n onError?: (err: ConnectClientError) => void;\n /** Max transparent retries for retryable unary failures. Default 2. */\n maxRetries?: number;\n /** Base backoff in ms; grows exponentially with jitter. Default 200. */\n retryBaseMs?: number;\n}\n\nconst sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));\n\n/** Attaches the bearer token from the app's auth provider. */\nexport function authInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n return (next) => async (req) => {\n const token = cfg.getToken ? await cfg.getToken() : null;\n if (token) {\n req.header.set(\"Authorization\", `Bearer ${token}`);\n }\n return next(req);\n };\n}\n\n/** Attaches brand context headers when the app supplies them. */\nexport function headerInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n return (next) => async (req) => {\n const brand = cfg.getBrand?.();\n if (brand) {\n req.header.set(\"X-Brand\", brand);\n }\n return next(req);\n };\n}\n\n/**\n * Propagates a W3C traceparent so the backend (otelconnect + otelhttp) joins\n * the same distributed trace. Always emits a valid header — see trace.ts.\n */\nexport function traceInterceptor(): Interceptor {\n return (next) => async (req) => {\n injectTraceContext(req.header);\n return next(req);\n };\n}\n\n/** Converts any thrown failure into a typed ConnectClientError. */\nexport function errorNormalizeInterceptor(\n cfg: RuntimeInterceptorConfig,\n): Interceptor {\n return (next) => async (req) => {\n try {\n return await next(req);\n } catch (err) {\n const normalized = normalizeError(err);\n cfg.onError?.(normalized);\n throw normalized;\n }\n };\n}\n\n/**\n * retryEligible reports whether a request is SAFE to retry transparently:\n * a no-side-effects / idempotent method (declared via proto\n * `idempotency_level`), or one carrying an explicit Idempotency-Key header.\n * A plain mutation (the default IDEMPOTENCY_UNKNOWN) is never retried, so a\n * transparent retry can't produce a duplicate write.\n */\nfunction retryEligible(req: UnaryRequest | StreamRequest): boolean {\n const level = req.method.idempotency;\n if (\n level === MethodOptions_IdempotencyLevel.NO_SIDE_EFFECTS ||\n level === MethodOptions_IdempotencyLevel.IDEMPOTENT\n ) {\n return true;\n }\n return req.header.has(\"Idempotency-Key\");\n}\n\n/**\n * Transparently retries retryable failures with exponential backoff +\n * jitter — for IDEMPOTENT requests ONLY (see retryEligible). NOT part of the\n * default chain: React Query owns retries (see the retry-ownership note at\n * the top of this file). Exported for callers using the raw transport\n * outside React Query. Streaming RPCs are never retried (a partially-\n * consumed stream can't be safely replayed).\n */\nexport function retryInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n const max = cfg.maxRetries ?? 2;\n const base = cfg.retryBaseMs ?? 200;\n return (next) => async (req) => {\n if (req.stream || !retryEligible(req)) {\n return next(req);\n }\n let attempt = 0;\n for (;;) {\n try {\n return await next(req);\n } catch (err) {\n const normalized =\n err instanceof ConnectClientError ? err : normalizeError(err);\n if (attempt >= max || !normalized.retryable) {\n throw normalized;\n }\n const backoff = base * 2 ** attempt + Math.random() * base;\n await sleep(backoff);\n attempt++;\n }\n }\n };\n}\n\n/**\n * buildRuntimeInterceptors composes the full stack in the correct order.\n * Pass the result to createConnectTransport's `interceptors` option.\n *\n * retryInterceptor is deliberately NOT included — React Query is the single\n * retry owner (see the retry-ownership note at the top of this file).\n */\nexport function buildRuntimeInterceptors(\n cfg: RuntimeInterceptorConfig = {},\n): Interceptor[] {\n return [\n errorNormalizeInterceptor(cfg),\n authInterceptor(cfg),\n headerInterceptor(cfg),\n traceInterceptor(),\n ];\n}\n\n// ── The rest of the transport-layer surface ──────────────────────────────\n//\n// These belong to this entry point because the interceptor stack's contract\n// is expressed in them: errorNormalizeInterceptor THROWS ConnectClientError\n// and RuntimeInterceptorConfig.onError RECEIVES one, so a consumer cannot\n// write a handler without the type; setTraceSampled is traceInterceptor's\n// only knob. A consumer of \"@reliantlabs/forge-web-runtime/interceptors\" must\n// never have to fall back to the React-typed barrel to complete the job.\n\nexport {\n ConnectClientError,\n FORGE_ERROR_REASON_HEADER,\n isRetryableCode,\n normalizeError,\n} from \"./errors.js\";\n\nexport {\n freshTraceparent,\n injectTraceContext,\n setTraceSampled,\n TRACEPARENT_RE,\n} from \"./trace.js\";\n"]}
|
|
1
|
+
{"version":3,"file":"interceptors.js","sourceRoot":"","sources":["../src/interceptors.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// This module is also the package's SECOND ENTRY POINT, published as\n// \"@reliantlabs/forge-web-runtime/interceptors\". It is the DOM-free, React-free\n// half of the runtime: the transport layer and nothing else. Importing it\n// pulls in exactly ./errors and ./trace — no React, no @types/react, no\n// window/document. That matters for React Native, where Expo pins React 18\n// while the package's components are typed against React 19: a mobile app\n// importing the full barrel would typecheck the components it never renders\n// and fail. Everything re-exported at the bottom of this file is part of\n// this entry point's surface; keep it React-free (guarded by\n// interceptors-entry.test.ts).\n//\n// The Connect transport interceptor stack. This is the one place every\n// frontend→backend call flows through, composed once and handed to\n// createConnectTransport by the app's own connect module. Order (outermost\n// first):\n//\n// error-normalize → auth → brand headers → traceparent\n//\n// error-normalize is outermost so every caller sees a typed\n// ConnectClientError instead of a raw ConnectError.\n//\n// RETRY OWNERSHIP — read before adding a retry layer.\n// The single retry owner is the app's React Query client: it\n// structurally knows reads from writes, so it retries transient READ\n// failures and NEVER retries a mutation — no duplicate writes. Each React\n// Query retry re-runs the queryFn, which re-runs this whole interceptor\n// chain (fresh token + traceparent), so nothing is lost by not retrying\n// here. `retryInterceptor` below is therefore NOT in the default chain; it\n// stays exported (idempotency-gated) only for callers who use the raw\n// transport OUTSIDE React Query. Putting it back in the default chain would\n// stack two retry layers AND retry non-idempotent mutations — the bug this\n// split fixed.\nimport { MethodOptions_IdempotencyLevel } from \"@bufbuild/protobuf/wkt\";\n\nimport { ConnectClientError, normalizeError } from \"./errors.js\";\nimport { telemetryReady } from \"./telemetry-gate.js\";\nimport { injectTraceContext } from \"./trace.js\";\n\nimport type {\n Interceptor,\n UnaryRequest,\n StreamRequest,\n} from \"@connectrpc/connect\";\n\n/**\n * RuntimeInterceptorConfig is the seam the app wires its runtime dependencies\n * through. Everything is optional: an unconfigured chain still propagates\n * traceparent and normalizes errors.\n */\nexport interface RuntimeInterceptorConfig {\n /** Returns the current bearer token, or null when unauthenticated. */\n getToken?: () => Promise<string | null> | string | null;\n /** Returns the active brand, attached as the X-Brand header. */\n getBrand?: () => string | null | undefined;\n /** Invoked with the typed error on every failed RPC (telemetry sink). */\n onError?: (err: ConnectClientError) => void;\n /** Max transparent retries for retryable unary failures. Default 2. */\n maxRetries?: number;\n /** Base backoff in ms; grows exponentially with jitter. Default 200. */\n retryBaseMs?: number;\n}\n\nconst sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));\n\n/** Attaches the bearer token from the app's auth provider. */\nexport function authInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n return (next) => async (req) => {\n const token = cfg.getToken ? await cfg.getToken() : null;\n if (token) {\n req.header.set(\"Authorization\", `Bearer ${token}`);\n }\n return next(req);\n };\n}\n\n/** Attaches brand context headers when the app supplies them. */\nexport function headerInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n return (next) => async (req) => {\n const brand = cfg.getBrand?.();\n if (brand) {\n req.header.set(\"X-Brand\", brand);\n }\n return next(req);\n };\n}\n\n/**\n * Propagates a W3C traceparent so the backend (otelconnect + otelhttp) joins\n * the same distributed trace. Always emits a valid header — see trace.ts.\n *\n * While browser telemetry is still starting it waits (bounded; see\n * telemetry-gate.ts) so the call is made through the SDK's patched fetch and\n * its traceparent names a span the browser really exports.\n */\nexport function traceInterceptor(): Interceptor {\n return (next) => async (req) => {\n await telemetryReady();\n injectTraceContext(req.header);\n return next(req);\n };\n}\n\n/** Converts any thrown failure into a typed ConnectClientError. */\nexport function errorNormalizeInterceptor(\n cfg: RuntimeInterceptorConfig,\n): Interceptor {\n return (next) => async (req) => {\n try {\n return await next(req);\n } catch (err) {\n const normalized = normalizeError(err);\n cfg.onError?.(normalized);\n throw normalized;\n }\n };\n}\n\n/**\n * retryEligible reports whether a request is SAFE to retry transparently:\n * a no-side-effects / idempotent method (declared via proto\n * `idempotency_level`), or one carrying an explicit Idempotency-Key header.\n * A plain mutation (the default IDEMPOTENCY_UNKNOWN) is never retried, so a\n * transparent retry can't produce a duplicate write.\n */\nfunction retryEligible(req: UnaryRequest | StreamRequest): boolean {\n const level = req.method.idempotency;\n if (\n level === MethodOptions_IdempotencyLevel.NO_SIDE_EFFECTS ||\n level === MethodOptions_IdempotencyLevel.IDEMPOTENT\n ) {\n return true;\n }\n return req.header.has(\"Idempotency-Key\");\n}\n\n/**\n * Transparently retries retryable failures with exponential backoff +\n * jitter — for IDEMPOTENT requests ONLY (see retryEligible). NOT part of the\n * default chain: React Query owns retries (see the retry-ownership note at\n * the top of this file). Exported for callers using the raw transport\n * outside React Query. Streaming RPCs are never retried (a partially-\n * consumed stream can't be safely replayed).\n */\nexport function retryInterceptor(cfg: RuntimeInterceptorConfig): Interceptor {\n const max = cfg.maxRetries ?? 2;\n const base = cfg.retryBaseMs ?? 200;\n return (next) => async (req) => {\n if (req.stream || !retryEligible(req)) {\n return next(req);\n }\n let attempt = 0;\n for (;;) {\n try {\n return await next(req);\n } catch (err) {\n const normalized =\n err instanceof ConnectClientError ? err : normalizeError(err);\n if (attempt >= max || !normalized.retryable) {\n throw normalized;\n }\n const backoff = base * 2 ** attempt + Math.random() * base;\n await sleep(backoff);\n attempt++;\n }\n }\n };\n}\n\n/**\n * buildRuntimeInterceptors composes the full stack in the correct order.\n * Pass the result to createConnectTransport's `interceptors` option.\n *\n * retryInterceptor is deliberately NOT included — React Query is the single\n * retry owner (see the retry-ownership note at the top of this file).\n */\nexport function buildRuntimeInterceptors(\n cfg: RuntimeInterceptorConfig = {},\n): Interceptor[] {\n return [\n errorNormalizeInterceptor(cfg),\n authInterceptor(cfg),\n headerInterceptor(cfg),\n traceInterceptor(),\n ];\n}\n\n// ── The rest of the transport-layer surface ──────────────────────────────\n//\n// These belong to this entry point because the interceptor stack's contract\n// is expressed in them: errorNormalizeInterceptor THROWS ConnectClientError\n// and RuntimeInterceptorConfig.onError RECEIVES one, so a consumer cannot\n// write a handler without the type; setTraceSampled is traceInterceptor's\n// only knob. A consumer of \"@reliantlabs/forge-web-runtime/interceptors\" must\n// never have to fall back to the React-typed barrel to complete the job.\n\nexport {\n ConnectClientError,\n FORGE_ERROR_REASON_HEADER,\n isRetryableCode,\n normalizeError,\n} from \"./errors.js\";\n\nexport {\n freshTraceparent,\n injectTraceContext,\n setTraceSampled,\n TRACEPARENT_RE,\n} from \"./trace.js\";\n"]}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The longest an RPC waits for browser telemetry to finish starting. */
|
|
2
|
+
export declare const TELEMETRY_WAIT_MS = 2000;
|
|
3
|
+
/** Called once by initBrowserTelemetry with the promise of its startup. */
|
|
4
|
+
export declare function registerTelemetryStartup(p: Promise<unknown>): void;
|
|
5
|
+
/**
|
|
6
|
+
* Resolves when telemetry has finished starting (or failed to), or after
|
|
7
|
+
* `maxMs`, whichever is first. Resolves immediately when none is starting or it
|
|
8
|
+
* has already settled.
|
|
9
|
+
*/
|
|
10
|
+
export declare function telemetryReady(maxMs?: number): Promise<void>;
|
|
11
|
+
/** Test seam. */
|
|
12
|
+
export declare function resetTelemetryGate(): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
|
|
2
|
+
//
|
|
3
|
+
// The hand-off between browser telemetry startup and the first RPCs.
|
|
4
|
+
//
|
|
5
|
+
// The HyperDX SDK is a ~600 KB lazy chunk, so it patches `fetch` some hundreds
|
|
6
|
+
// of milliseconds after the app is interactive — and a page fires its first
|
|
7
|
+
// queries on mount, inside that window. A call made before the patch carries a
|
|
8
|
+
// SYNTHESIZED traceparent (trace.ts) that names a span no browser ever
|
|
9
|
+
// exported: the API's span has a parent id that matches nothing, and "this page
|
|
10
|
+
// load, end to end" has no answer exactly for the requests the page makes
|
|
11
|
+
// first.
|
|
12
|
+
//
|
|
13
|
+
// So initBrowserTelemetry registers its startup here, and the trace interceptor
|
|
14
|
+
// waits for it — at most TELEMETRY_WAIT_MS, so a blocked chunk or an ad blocker
|
|
15
|
+
// delays a request by that much and no more, then falls back to the synthesized
|
|
16
|
+
// traceparent. With telemetry off nothing is registered and nothing waits.
|
|
17
|
+
//
|
|
18
|
+
// DOM-free and import-free on purpose: the transport entry point
|
|
19
|
+
// (@reliantlabs/forge-web-runtime/interceptors) imports this and must stay
|
|
20
|
+
// light enough for React Native.
|
|
21
|
+
/** The longest an RPC waits for browser telemetry to finish starting. */
|
|
22
|
+
export const TELEMETRY_WAIT_MS = 2000;
|
|
23
|
+
let startup = null;
|
|
24
|
+
/** Called once by initBrowserTelemetry with the promise of its startup. */
|
|
25
|
+
export function registerTelemetryStartup(p) {
|
|
26
|
+
startup = p;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolves when telemetry has finished starting (or failed to), or after
|
|
30
|
+
* `maxMs`, whichever is first. Resolves immediately when none is starting or it
|
|
31
|
+
* has already settled.
|
|
32
|
+
*/
|
|
33
|
+
export function telemetryReady(maxMs = TELEMETRY_WAIT_MS) {
|
|
34
|
+
const p = startup;
|
|
35
|
+
if (!p) {
|
|
36
|
+
return Promise.resolve();
|
|
37
|
+
}
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
const timer = setTimeout(resolve, maxMs);
|
|
40
|
+
const done = () => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
resolve();
|
|
43
|
+
};
|
|
44
|
+
p.then(done, done);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/** Test seam. */
|
|
48
|
+
export function resetTelemetryGate() {
|
|
49
|
+
startup = null;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=telemetry-gate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"telemetry-gate.js","sourceRoot":"","sources":["../src/telemetry-gate.ts"],"names":[],"mappings":"AAAA,sEAAsE;AACtE,EAAE;AACF,qEAAqE;AACrE,EAAE;AACF,+EAA+E;AAC/E,4EAA4E;AAC5E,+EAA+E;AAC/E,uEAAuE;AACvE,gFAAgF;AAChF,0EAA0E;AAC1E,SAAS;AACT,EAAE;AACF,gFAAgF;AAChF,gFAAgF;AAChF,gFAAgF;AAChF,2EAA2E;AAC3E,EAAE;AACF,iEAAiE;AACjE,2EAA2E;AAC3E,iCAAiC;AAEjC,yEAAyE;AACzE,MAAM,CAAC,MAAM,iBAAiB,GAAG,IAAI,CAAC;AAEtC,IAAI,OAAO,GAA4B,IAAI,CAAC;AAE5C,2EAA2E;AAC3E,MAAM,UAAU,wBAAwB,CAAC,CAAmB;IAC1D,OAAO,GAAG,CAAC,CAAC;AACd,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAC5B,QAAgB,iBAAiB;IAEjC,MAAM,CAAC,GAAG,OAAO,CAAC;IAClB,IAAI,CAAC,CAAC,EAAE,CAAC;QACP,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;IACD,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;QACnC,MAAM,KAAK,GAAG,UAAU,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QACzC,MAAM,IAAI,GAAG,GAAG,EAAE;YAChB,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,OAAO,EAAE,CAAC;QACZ,CAAC,CAAC;QACF,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACrB,CAAC,CAAC,CAAC;AACL,CAAC;AAED,iBAAiB;AACjB,MAAM,UAAU,kBAAkB;IAChC,OAAO,GAAG,IAAI,CAAC;AACjB,CAAC","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// The hand-off between browser telemetry startup and the first RPCs.\n//\n// The HyperDX SDK is a ~600 KB lazy chunk, so it patches `fetch` some hundreds\n// of milliseconds after the app is interactive — and a page fires its first\n// queries on mount, inside that window. A call made before the patch carries a\n// SYNTHESIZED traceparent (trace.ts) that names a span no browser ever\n// exported: the API's span has a parent id that matches nothing, and \"this page\n// load, end to end\" has no answer exactly for the requests the page makes\n// first.\n//\n// So initBrowserTelemetry registers its startup here, and the trace interceptor\n// waits for it — at most TELEMETRY_WAIT_MS, so a blocked chunk or an ad blocker\n// delays a request by that much and no more, then falls back to the synthesized\n// traceparent. With telemetry off nothing is registered and nothing waits.\n//\n// DOM-free and import-free on purpose: the transport entry point\n// (@reliantlabs/forge-web-runtime/interceptors) imports this and must stay\n// light enough for React Native.\n\n/** The longest an RPC waits for browser telemetry to finish starting. */\nexport const TELEMETRY_WAIT_MS = 2000;\n\nlet startup: Promise<unknown> | null = null;\n\n/** Called once by initBrowserTelemetry with the promise of its startup. */\nexport function registerTelemetryStartup(p: Promise<unknown>): void {\n startup = p;\n}\n\n/**\n * Resolves when telemetry has finished starting (or failed to), or after\n * `maxMs`, whichever is first. Resolves immediately when none is starting or it\n * has already settled.\n */\nexport function telemetryReady(\n maxMs: number = TELEMETRY_WAIT_MS,\n): Promise<void> {\n const p = startup;\n if (!p) {\n return Promise.resolve();\n }\n return new Promise<void>((resolve) => {\n const timer = setTimeout(resolve, maxMs);\n const done = () => {\n clearTimeout(timer);\n resolve();\n };\n p.then(done, done);\n });\n}\n\n/** Test seam. */\nexport function resetTelemetryGate(): void {\n startup = null;\n}\n"]}
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -19,6 +19,13 @@ export interface BrowserTelemetryConfig {
|
|
|
19
19
|
environment?: string;
|
|
20
20
|
/** `service.namespace`, conventionally the project name. */
|
|
21
21
|
serviceNamespace?: string;
|
|
22
|
+
/**
|
|
23
|
+
* The forge frontend's name, emitted as the `forge.frontend` resource
|
|
24
|
+
* attribute. Unlike `serviceName` (which an app may override to anything) it
|
|
25
|
+
* is the stable key that joins this page's telemetry to the frontend and its
|
|
26
|
+
* source maps. Omit for none.
|
|
27
|
+
*/
|
|
28
|
+
frontend?: string;
|
|
22
29
|
/**
|
|
23
30
|
* Ingest base: a path on this origin (`/_otel`) or, as an escape hatch, an
|
|
24
31
|
* absolute URL. Unset, empty, or `off` disables telemetry — the app runs
|
|
@@ -32,15 +39,31 @@ export interface BrowserTelemetryConfig {
|
|
|
32
39
|
* spans. This page's own origin is always included.
|
|
33
40
|
*/
|
|
34
41
|
apiOrigins?: Array<string | undefined>;
|
|
35
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Mirror console.log/info/warn/debug into telemetry spans. Default true;
|
|
44
|
+
* pass false when console output can hold user content. It does NOT stop
|
|
45
|
+
* `console.error(err)` from being recorded as an error: the SDK's error
|
|
46
|
+
* instrumentation is separate and always on.
|
|
47
|
+
*/
|
|
36
48
|
consoleCapture?: boolean;
|
|
37
49
|
/**
|
|
38
50
|
* Capture full request/response headers AND BODIES on fetch/XHR spans.
|
|
39
51
|
* Default false, and it should stay false: bodies are user data.
|
|
40
52
|
*/
|
|
41
53
|
advancedNetworkCapture?: boolean;
|
|
42
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Session replay (rrweb). Default false. When true, all text and inputs are
|
|
56
|
+
* masked, media is blocked and canvas is not recorded (see the header).
|
|
57
|
+
*/
|
|
43
58
|
replay?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The share of sessions to record when `replay` is true, 0..1. Default 1
|
|
61
|
+
* (every session). Decided once per session and held in sessionStorage. A
|
|
62
|
+
* value that is not a number records nothing. An app with its own policy
|
|
63
|
+
* (every session of a new user, a fraction of the rest) computes the rate
|
|
64
|
+
* it wants for this session and passes it here.
|
|
65
|
+
*/
|
|
66
|
+
replaySampleRate?: number;
|
|
44
67
|
/** Verbose SDK diagnostics to the console. Default false. */
|
|
45
68
|
debug?: boolean;
|
|
46
69
|
}
|
|
@@ -54,6 +77,25 @@ export declare function initBrowserTelemetry(config: BrowserTelemetryConfig): Pr
|
|
|
54
77
|
* want to see exactly what is configured — it is pure.
|
|
55
78
|
*/
|
|
56
79
|
export declare function buildSdkOptions(config: BrowserTelemetryConfig, ingestUrl: string, pageOrigin: string): Record<string, unknown>;
|
|
80
|
+
/** Elements a replay never records: their pixels stay in the browser. */
|
|
81
|
+
export declare const REPLAY_BLOCK_SELECTOR = "img, picture, video, audio, iframe, object, embed";
|
|
82
|
+
/** Where a replay sampling decision is held for the tab's session. */
|
|
83
|
+
export declare const REPLAY_SAMPLE_KEY = "forge.replay.sampled";
|
|
84
|
+
/** The slice of Storage the sampling decision needs. */
|
|
85
|
+
interface DecisionStore {
|
|
86
|
+
getItem(key: string): string | null;
|
|
87
|
+
setItem(key: string, value: string): void;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether this session records a replay: config.replay, then the sample rate,
|
|
91
|
+
* drawn once and remembered in `store` so a reload (or a route change that
|
|
92
|
+
* re-runs init) never re-rolls into a session that only half-recorded. With no
|
|
93
|
+
* usable store the draw is made each load. Pure given its inputs; exported for
|
|
94
|
+
* tests.
|
|
95
|
+
*/
|
|
96
|
+
export declare function decideReplay(config: Pick<BrowserTelemetryConfig, "replay" | "replaySampleRate">, store: DecisionStore | null, random: () => number): boolean;
|
|
97
|
+
/** `url` without its query string and fragment. */
|
|
98
|
+
export declare function stripUrlQuery(url: string): string;
|
|
57
99
|
/**
|
|
58
100
|
* resolveIngestUrl turns the configured endpoint into the absolute URL the SDK
|
|
59
101
|
* is given, or null when telemetry is off. A path resolves against the page
|
package/dist/telemetry.js
CHANGED
|
@@ -38,13 +38,27 @@
|
|
|
38
38
|
// front of such a collector must strip the header or replace it with the real
|
|
39
39
|
// key. The local ClickStack collector does not enforce, and ignores it.
|
|
40
40
|
//
|
|
41
|
-
// ── Replay: shipped, off, masked
|
|
41
|
+
// ── Replay: shipped, off, masked, media blocked ─────────────────────────
|
|
42
42
|
//
|
|
43
43
|
// The SDK bundles an rrweb recorder. It is DISABLED unless `replay: true`.
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
44
|
+
// Privacy is forced, not offered: every input and all text is masked, canvas is
|
|
45
|
+
// never recorded, and images, video, audio, iframes and embeds are BLOCKED
|
|
46
|
+
// (replaced by a same-size placeholder; their pixels never leave the browser).
|
|
47
|
+
// Images are not inlined and web fonts are not collected. There is deliberately
|
|
48
|
+
// no option to turn any of that off while replay is on; an app that needs
|
|
49
|
+
// unmasked replay passes its own SDK options.
|
|
50
|
+
//
|
|
51
|
+
// `replaySampleRate` (0..1, default 1) records only that share of SESSIONS. The
|
|
52
|
+
// SDK has no session sampler, so the decision is made here, once, and held in
|
|
53
|
+
// sessionStorage for the tab's session.
|
|
54
|
+
//
|
|
55
|
+
// ── Network URLs carry no query string ───────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// fetch/XHR spans record the request URL, and a query string is where tokens,
|
|
58
|
+
// OAuth codes and search terms live. The runtime rewrites `http.url` and
|
|
59
|
+
// `url.full` on every fetch/XHR span to the URL without query or fragment.
|
|
60
|
+
// The SDK's own `location.href` attribute (the page URL, stamped on every span)
|
|
61
|
+
// has no client-side hook; the ingest side scrubs it.
|
|
48
62
|
//
|
|
49
63
|
// ── Loading ──────────────────────────────────────────────────────────────
|
|
50
64
|
//
|
|
@@ -57,6 +71,8 @@
|
|
|
57
71
|
// endpoint, and it never throws.
|
|
58
72
|
import { trace } from "@opentelemetry/api";
|
|
59
73
|
import { setExceptionReporter } from "./error-reporter.js";
|
|
74
|
+
import { registerTelemetryStartup } from "./telemetry-gate.js";
|
|
75
|
+
import { isTelemetryDisabled, setTelemetrySink, } from "./track.js";
|
|
60
76
|
/** Where the browser ships telemetry: this origin's `/_otel`. */
|
|
61
77
|
export const DEFAULT_INGEST_PATH = "/_otel";
|
|
62
78
|
/**
|
|
@@ -77,20 +93,28 @@ export function initBrowserTelemetry(config) {
|
|
|
77
93
|
if (started) {
|
|
78
94
|
return started;
|
|
79
95
|
}
|
|
96
|
+
if (isTelemetryDisabled()) {
|
|
97
|
+
return Promise.resolve(false);
|
|
98
|
+
}
|
|
80
99
|
const ingest = resolveIngestUrl(config.endpoint, window.location.origin);
|
|
81
100
|
if (!ingest) {
|
|
101
|
+
// Nothing will ever drain track() calls held for the SDK; let them go.
|
|
102
|
+
setTelemetrySink(null);
|
|
82
103
|
return Promise.resolve(false);
|
|
83
104
|
}
|
|
84
105
|
started = start(config, ingest);
|
|
106
|
+
registerTelemetryStartup(started);
|
|
85
107
|
return started;
|
|
86
108
|
}
|
|
87
109
|
async function start(config, ingest) {
|
|
88
110
|
try {
|
|
89
111
|
const hdx = resolveClient(await import("@hyperdx/browser"));
|
|
90
|
-
if (!hdx) {
|
|
112
|
+
if (!hdx || isTelemetryDisabled()) {
|
|
113
|
+
setTelemetrySink(null);
|
|
91
114
|
return false;
|
|
92
115
|
}
|
|
93
|
-
|
|
116
|
+
const replay = decideReplay(config, sessionStorageOrNull(), Math.random);
|
|
117
|
+
hdx.init(buildSdkOptions({ ...config, replay }, ingest, window.location.origin));
|
|
94
118
|
tapSdkInputs();
|
|
95
119
|
setExceptionReporter((error, attributes) => {
|
|
96
120
|
if (alreadyRecorded(error)) {
|
|
@@ -98,11 +122,22 @@ async function start(config, ingest) {
|
|
|
98
122
|
}
|
|
99
123
|
hdx.recordException(error, attributes);
|
|
100
124
|
});
|
|
125
|
+
setTelemetrySink({
|
|
126
|
+
track: (name, attributes) => hdx.addAction?.(name, attributes),
|
|
127
|
+
setUser: (id) => hdx.setGlobalAttributes?.({ userId: id }),
|
|
128
|
+
disable: () => {
|
|
129
|
+
// Back to "no SDK": reportException returns false, so a caller falls
|
|
130
|
+
// back to console.error and the error does not vanish.
|
|
131
|
+
setExceptionReporter(null);
|
|
132
|
+
hdx.stopSessionRecorder?.();
|
|
133
|
+
},
|
|
134
|
+
});
|
|
101
135
|
return true;
|
|
102
136
|
}
|
|
103
137
|
catch (err) {
|
|
104
138
|
// Blocked chunk, ad-blocker, SDK throw: telemetry must never break the app.
|
|
105
139
|
console.debug("[telemetry] disabled:", err);
|
|
140
|
+
setTelemetrySink(null);
|
|
106
141
|
return false;
|
|
107
142
|
}
|
|
108
143
|
}
|
|
@@ -159,6 +194,8 @@ function alreadyRecorded(error) {
|
|
|
159
194
|
* want to see exactly what is configured — it is pure.
|
|
160
195
|
*/
|
|
161
196
|
export function buildSdkOptions(config, ingestUrl, pageOrigin) {
|
|
197
|
+
// `config.replay` is the FINAL decision here. initBrowserTelemetry resolves
|
|
198
|
+
// replaySampleRate into it (decideReplay) before calling this.
|
|
162
199
|
const replay = config.replay === true;
|
|
163
200
|
const resourceAttributes = {};
|
|
164
201
|
if (config.serviceVersion) {
|
|
@@ -167,35 +204,136 @@ export function buildSdkOptions(config, ingestUrl, pageOrigin) {
|
|
|
167
204
|
if (config.serviceNamespace) {
|
|
168
205
|
resourceAttributes["service.namespace"] = config.serviceNamespace;
|
|
169
206
|
}
|
|
207
|
+
if (config.frontend) {
|
|
208
|
+
resourceAttributes["forge.frontend"] = config.frontend;
|
|
209
|
+
}
|
|
170
210
|
if (config.environment) {
|
|
171
211
|
resourceAttributes["deployment.environment.name"] = config.environment;
|
|
172
212
|
resourceAttributes["deployment.environment"] = config.environment;
|
|
173
213
|
}
|
|
214
|
+
const targets = propagationTargets(pageOrigin, config.apiOrigins);
|
|
215
|
+
const advancedNetworkCapture = config.advancedNetworkCapture ?? false;
|
|
174
216
|
const options = {
|
|
175
217
|
apiKey: PLACEHOLDER_API_KEY,
|
|
176
218
|
service: config.serviceName,
|
|
177
219
|
url: ingestUrl,
|
|
178
|
-
tracePropagationTargets:
|
|
220
|
+
tracePropagationTargets: targets,
|
|
179
221
|
// The SDK would otherwise trace its own exports, and it must never
|
|
180
222
|
// observe the route it ships through.
|
|
181
223
|
ignoreUrls: [new RegExp(`^${escapeRegExp(ingestUrl)}`)],
|
|
182
224
|
consoleCapture: config.consoleCapture ?? true,
|
|
183
|
-
advancedNetworkCapture
|
|
225
|
+
advancedNetworkCapture,
|
|
226
|
+
// The SDK spreads this over ITS fetch/xhr config as whole keys, so
|
|
227
|
+
// everything it would have set (propagation targets, the capture switch)
|
|
228
|
+
// must be restated here or it is lost.
|
|
229
|
+
instrumentations: {
|
|
230
|
+
fetch: {
|
|
231
|
+
propagateTraceHeaderCorsUrls: targets,
|
|
232
|
+
advancedNetworkCapture: () => advancedNetworkCapture,
|
|
233
|
+
applyCustomAttributesOnSpan: stripNetworkQuery,
|
|
234
|
+
},
|
|
235
|
+
xhr: {
|
|
236
|
+
propagateTraceHeaderCorsUrls: targets,
|
|
237
|
+
advancedNetworkCapture: () => advancedNetworkCapture,
|
|
238
|
+
applyCustomAttributesOnSpan: stripNetworkQuery,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
184
241
|
// Polls for window.Intercom on a timer; nothing here uses Intercom.
|
|
185
242
|
disableIntercom: true,
|
|
186
243
|
disableReplay: !replay,
|
|
187
244
|
otelResourceAttributes: resourceAttributes,
|
|
188
245
|
debug: config.debug ?? false,
|
|
246
|
+
// Privacy is forced and unconditional: no path through this function
|
|
247
|
+
// yields an unmasked or media-recording replay. The SDK's own default
|
|
248
|
+
// masks inputs but NOT text, and text is where chat content and records
|
|
249
|
+
// live.
|
|
250
|
+
maskAllInputs: true,
|
|
251
|
+
maskAllText: true,
|
|
252
|
+
recordCanvas: false,
|
|
253
|
+
blockSelector: REPLAY_BLOCK_SELECTOR,
|
|
254
|
+
replay: { inlineImages: false, collectFonts: false },
|
|
189
255
|
};
|
|
190
|
-
if (replay) {
|
|
191
|
-
// The SDK's own default masks inputs but NOT text; text is where chat
|
|
192
|
-
// content and records live. Force both.
|
|
193
|
-
options.maskAllInputs = true;
|
|
194
|
-
options.maskAllText = true;
|
|
195
|
-
options.recordCanvas = false;
|
|
196
|
-
}
|
|
197
256
|
return options;
|
|
198
257
|
}
|
|
258
|
+
/** Elements a replay never records: their pixels stay in the browser. */
|
|
259
|
+
export const REPLAY_BLOCK_SELECTOR = "img, picture, video, audio, iframe, object, embed";
|
|
260
|
+
/** Where a replay sampling decision is held for the tab's session. */
|
|
261
|
+
export const REPLAY_SAMPLE_KEY = "forge.replay.sampled";
|
|
262
|
+
/**
|
|
263
|
+
* Whether this session records a replay: config.replay, then the sample rate,
|
|
264
|
+
* drawn once and remembered in `store` so a reload (or a route change that
|
|
265
|
+
* re-runs init) never re-rolls into a session that only half-recorded. With no
|
|
266
|
+
* usable store the draw is made each load. Pure given its inputs; exported for
|
|
267
|
+
* tests.
|
|
268
|
+
*/
|
|
269
|
+
export function decideReplay(config, store, random) {
|
|
270
|
+
if (config.replay !== true) {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
const rate = clampRate(config.replaySampleRate);
|
|
274
|
+
if (rate >= 1) {
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
if (rate <= 0) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
const held = store?.getItem(REPLAY_SAMPLE_KEY);
|
|
282
|
+
if (held === "1" || held === "0") {
|
|
283
|
+
return held === "1";
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
// Storage blocked: fall through to a fresh draw.
|
|
288
|
+
}
|
|
289
|
+
const sampled = random() < rate;
|
|
290
|
+
try {
|
|
291
|
+
store?.setItem(REPLAY_SAMPLE_KEY, sampled ? "1" : "0");
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// Not persisted: the next load draws again.
|
|
295
|
+
}
|
|
296
|
+
return sampled;
|
|
297
|
+
}
|
|
298
|
+
// Unset is "every session"; anything that is not a number is a bug, and a bug
|
|
299
|
+
// must not start recording people.
|
|
300
|
+
function clampRate(rate) {
|
|
301
|
+
if (rate === undefined) {
|
|
302
|
+
return 1;
|
|
303
|
+
}
|
|
304
|
+
if (typeof rate !== "number" || Number.isNaN(rate)) {
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
return Math.min(1, Math.max(0, rate));
|
|
308
|
+
}
|
|
309
|
+
function sessionStorageOrNull() {
|
|
310
|
+
try {
|
|
311
|
+
return window.sessionStorage;
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** The span attributes that hold a request URL. */
|
|
318
|
+
const URL_ATTRIBUTES = ["http.url", "url.full"];
|
|
319
|
+
/** `url` without its query string and fragment. */
|
|
320
|
+
export function stripUrlQuery(url) {
|
|
321
|
+
const cut = url.search(/[?#]/);
|
|
322
|
+
return cut === -1 ? url : url.slice(0, cut);
|
|
323
|
+
}
|
|
324
|
+
// applyCustomAttributesOnSpan for both instrumentations: it runs before the
|
|
325
|
+
// span ends, so the rewritten value is the one exported.
|
|
326
|
+
function stripNetworkQuery(span) {
|
|
327
|
+
for (const key of URL_ATTRIBUTES) {
|
|
328
|
+
const value = span.attributes?.[key];
|
|
329
|
+
if (typeof value === "string") {
|
|
330
|
+
const stripped = stripUrlQuery(value);
|
|
331
|
+
if (stripped !== value) {
|
|
332
|
+
span.setAttribute(key, stripped);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
199
337
|
/**
|
|
200
338
|
* resolveIngestUrl turns the configured endpoint into the absolute URL the SDK
|
|
201
339
|
* is given, or null when telemetry is off. A path resolves against the page
|
package/dist/telemetry.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"telemetry.js","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Browser telemetry: the HyperDX browser SDK, initialised the forge way.\n//\n// Captures errors (uncaught, unhandled rejections, boundary-caught), console\n// output, fetch/XHR spans, document load, long tasks and web vitals, and\n// ships them to ClickStack through a SAME-ORIGIN `/_otel` route.\n//\n// ── Why HyperDX REPLACES the old OTel web wiring, not joins it ───────────\n//\n// @hyperdx/browser wraps @hyperdx/otel-web, which builds its own\n// WebTracerProvider and calls provider.register() — the global tracer\n// provider, context manager and propagator. A second registration (the old\n// `./otel` subpath did exactly that) is rejected by @opentelemetry/api with a\n// \"duplicate registration\" diagnostic and silently loses: whichever ran\n// second would simply never see a span. Two tracer providers cannot coexist\n// in one page, so there is one, and it is HyperDX's. `./otel` and its eight\n// @opentelemetry/* SDK peer dependencies are gone.\n//\n// traceparent on RPCs is unaffected. traceInterceptor (trace.ts) injects the\n// active span's context into every Connect call, or synthesises a valid\n// traceparent when none is active — with no SDK at all. With the SDK running,\n// the fetch span HyperDX opens for the call carries the same trace id, and\n// `apiOrigins` below makes that hold for a cross-origin API too (which is\n// every dev loop: the API binds its own port).\n//\n// ── The browser holds no secret ──────────────────────────────────────────\n//\n// `url` is `${location.origin}/_otel`. The browser talks only to its own\n// origin; a dev-server proxy (next.config.ts rewrites / Vite server.proxy) or\n// the deployment's ingress forwards `/_otel/v1/{traces,logs}` to the OTLP/HTTP\n// port of a collector, and that hop — never the bundle — owns credentials.\n//\n// The SDK nevertheless wants an `apiKey` and sends it as an `authorization`\n// header. We pass PLACEHOLDER_API_KEY, a constant that grants nothing and is\n// public by construction. A collector that enforces ingestion auth (ClickStack\n// `collectorAuthenticationEnforced`) rejects it with 401, so the proxy in\n// front of such a collector must strip the header or replace it with the real\n// key. The local ClickStack collector does not enforce, and ignores it.\n//\n// ── Replay: shipped, off, masked ─────────────────────────────────────────\n//\n// The SDK bundles an rrweb recorder. It is DISABLED unless `replay: true`.\n// When enabled, masking is forced on — every input and all text — because a\n// replay records the DOM, which for most apps is the most sensitive data they\n// have. There is deliberately no option to turn masking off while replay is\n// on; an app that needs unmasked replay passes its own SDK options.\n//\n// ── Loading ──────────────────────────────────────────────────────────────\n//\n// The SDK is ~600 KB minified (rrweb and the OTel web stack are bundled in\n// it), so it is imported lazily: bundlers split it into its own chunk that\n// loads after the app is interactive and costs the first paint nothing.\n// initBrowserTelemetry is therefore async and idempotent — every caller gets\n// the same promise, which is what survives React StrictMode's double mount and\n// Fast Refresh. It is a no-op without a DOM (SSR, prerender) and without an\n// endpoint, and it never throws.\nimport { trace } from \"@opentelemetry/api\";\n\nimport { setExceptionReporter } from \"./error-reporter.js\";\n\n/** Where the browser ships telemetry: this origin's `/_otel`. */\nexport const DEFAULT_INGEST_PATH = \"/_otel\";\n\n/**\n * The apiKey handed to the SDK. Public by construction: it identifies\n * nothing and authorises nothing. See the header — the proxy or collector\n * owns the real credential.\n */\nexport const PLACEHOLDER_API_KEY = \"forge-browser-public\";\n\nexport interface BrowserTelemetryConfig {\n /** `service.name` on everything this page emits. */\n serviceName: string;\n /** `service.version`. Omit for none. */\n serviceVersion?: string;\n /**\n * The forge environment name. Emitted as BOTH `deployment.environment.name`\n * (current semconv) and `deployment.environment` (the legacy key HyperDX's\n * default sources still key on).\n */\n environment?: string;\n /** `service.namespace`, conventionally the project name. */\n serviceNamespace?: string;\n /**\n * Ingest base: a path on this origin (`/_otel`) or, as an escape hatch, an\n * absolute URL. Unset, empty, or `off` disables telemetry — the app runs\n * untraced rather than throwing, which is what makes it safe to call\n * unconditionally from the app shell.\n */\n endpoint?: string;\n /**\n * Origins (or URLs — only the origin is used) of the app's own backend.\n * Requests to them always get a `traceparent` from the SDK's fetch/XHR\n * spans. This page's own origin is always included.\n */\n apiOrigins?: Array<string | undefined>;\n /** Mirror console.* into telemetry. Default true. */\n consoleCapture?: boolean;\n /**\n * Capture full request/response headers AND BODIES on fetch/XHR spans.\n * Default false, and it should stay false: bodies are user data.\n */\n advancedNetworkCapture?: boolean;\n /** Session replay (rrweb). Default false. When true, all text and inputs are masked. */\n replay?: boolean;\n /** Verbose SDK diagnostics to the console. Default false. */\n debug?: boolean;\n}\n\n/** The slice of the SDK this module calls. Typed here, not imported, so the module loads without it. */\ninterface HyperDXClient {\n init(config: Record<string, unknown>): void;\n recordException(error: unknown, attributes?: Record<string, unknown>): void;\n}\n\nlet started: Promise<boolean> | null = null;\n\n/**\n * initBrowserTelemetry starts the HyperDX browser SDK once per page load and\n * resolves to whether it is running. See the module header for the contract.\n */\nexport function initBrowserTelemetry(\n config: BrowserTelemetryConfig,\n): Promise<boolean> {\n if (typeof window === \"undefined\") {\n return Promise.resolve(false);\n }\n if (started) {\n return started;\n }\n const ingest = resolveIngestUrl(config.endpoint, window.location.origin);\n if (!ingest) {\n return Promise.resolve(false);\n }\n started = start(config, ingest);\n return started;\n}\n\nasync function start(\n config: BrowserTelemetryConfig,\n ingest: string,\n): Promise<boolean> {\n try {\n const hdx = resolveClient(await import(\"@hyperdx/browser\"));\n if (!hdx) {\n return false;\n }\n hdx.init(buildSdkOptions(config, ingest, window.location.origin));\n tapSdkInputs();\n setExceptionReporter((error, attributes) => {\n if (alreadyRecorded(error)) {\n return;\n }\n hdx.recordException(error, attributes);\n });\n return true;\n } catch (err) {\n // Blocked chunk, ad-blocker, SDK throw: telemetry must never break the app.\n console.debug(\"[telemetry] disabled:\", err);\n return false;\n }\n}\n\n// ── One failure, one record ──────────────────────────────────────────────\n//\n// The SDK records an Error that reaches window \"error\", window\n// \"unhandledrejection\", or console.error. React (18 and 19) console.errors an\n// error a boundary catches BEFORE it calls componentDidCatch, and Next\n// dispatches an uncaught render error to window \"error\" — so by the time\n// reportException runs, the SDK has usually recorded that very error already.\n// Recording it again would count one failure twice, and the issue layer\n// (which groups by fingerprint) would report two.\n//\n// So the taps below remember which Error OBJECTS the SDK was shown, and the\n// installed recorder skips those. Whatever the SDK did not see — a custom\n// onCaughtError that silences React's logging, an error rethrown as a new\n// object — is still recorded. The console tap is installed after the SDK's own\n// wrapper, so it sits outermost and only observes; every call is forwarded\n// untouched.\n//\n// The cost is the React component stack: the SDK's record of a boundary-caught\n// error comes from console.error(error), which carries the error and its\n// stack but not React's componentStack, and attributes passed to a skipped\n// reportException are dropped with it.\nconst seenBySdk = new WeakSet<object>();\nlet tapped = false;\n\nfunction noteSeen(value: unknown): void {\n if (typeof value === \"object\" && value !== null) {\n seenBySdk.add(value);\n }\n}\n\nfunction tapSdkInputs(): void {\n if (tapped) {\n return;\n }\n tapped = true;\n const wrapped = console.error;\n console.error = function (this: unknown, ...args: unknown[]) {\n for (const arg of args) {\n if (arg instanceof Error) {\n noteSeen(arg);\n }\n }\n return wrapped.apply(this, args);\n };\n window.addEventListener(\"error\", (e) => noteSeen(e.error), true);\n window.addEventListener(\n \"unhandledrejection\",\n (e) => noteSeen(e.reason),\n true,\n );\n}\n\nfunction alreadyRecorded(error: unknown): boolean {\n return typeof error === \"object\" && error !== null && seenBySdk.has(error);\n}\n\n/**\n * The options passed to HyperDX.init. Exported for tests and for apps that\n * want to see exactly what is configured — it is pure.\n */\nexport function buildSdkOptions(\n config: BrowserTelemetryConfig,\n ingestUrl: string,\n pageOrigin: string,\n): Record<string, unknown> {\n const replay = config.replay === true;\n const resourceAttributes: Record<string, string> = {};\n if (config.serviceVersion) {\n resourceAttributes[\"service.version\"] = config.serviceVersion;\n }\n if (config.serviceNamespace) {\n resourceAttributes[\"service.namespace\"] = config.serviceNamespace;\n }\n if (config.environment) {\n resourceAttributes[\"deployment.environment.name\"] = config.environment;\n resourceAttributes[\"deployment.environment\"] = config.environment;\n }\n\n const options: Record<string, unknown> = {\n apiKey: PLACEHOLDER_API_KEY,\n service: config.serviceName,\n url: ingestUrl,\n tracePropagationTargets: propagationTargets(pageOrigin, config.apiOrigins),\n // The SDK would otherwise trace its own exports, and it must never\n // observe the route it ships through.\n ignoreUrls: [new RegExp(`^${escapeRegExp(ingestUrl)}`)],\n consoleCapture: config.consoleCapture ?? true,\n advancedNetworkCapture: config.advancedNetworkCapture ?? false,\n // Polls for window.Intercom on a timer; nothing here uses Intercom.\n disableIntercom: true,\n disableReplay: !replay,\n otelResourceAttributes: resourceAttributes,\n debug: config.debug ?? false,\n };\n if (replay) {\n // The SDK's own default masks inputs but NOT text; text is where chat\n // content and records live. Force both.\n options.maskAllInputs = true;\n options.maskAllText = true;\n options.recordCanvas = false;\n }\n return options;\n}\n\n/**\n * resolveIngestUrl turns the configured endpoint into the absolute URL the SDK\n * is given, or null when telemetry is off. A path resolves against the page\n * origin, so the default is same-origin by construction.\n */\nexport function resolveIngestUrl(\n endpoint: string | undefined,\n pageOrigin: string,\n): string | null {\n const value = (endpoint ?? \"\").trim();\n if (value === \"\" || value.toLowerCase() === \"off\") {\n return null;\n }\n try {\n return new URL(value, pageOrigin).toString().replace(/\\/+$/, \"\");\n } catch {\n return null;\n }\n}\n\nfunction propagationTargets(\n pageOrigin: string,\n apiOrigins: Array<string | undefined> = [],\n): RegExp[] {\n const origins = new Set<string>([pageOrigin]);\n for (const raw of apiOrigins) {\n if (!raw) {\n continue;\n }\n try {\n origins.add(new URL(raw, pageOrigin).origin);\n } catch {\n // An unparsable origin is not a target.\n }\n }\n return [...origins].map((o) => new RegExp(`^${escapeRegExp(o)}(?:[/?#]|$)`));\n}\n\nfunction escapeRegExp(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n// @hyperdx/browser is a UMD/CJS bundle whose module.exports IS the client, and\n// its methods use `this`. A bundler hands back a module NAMESPACE that exposes\n// both the client's members at the top level (as read-only getters) and the\n// client itself as `default`. Calling init on the namespace binds `this` to that\n// frozen object and the SDK throws on its first property write, which disabled\n// telemetry silently. So descend to the deepest `default` that is still a\n// client, the real instance, and never settle for the namespace in front of it.\nfunction resolveClient(mod: unknown): HyperDXClient | null {\n let cur: unknown = mod;\n for (let depth = 0; depth < 4; depth++) {\n const next = peek(cur, \"default\");\n if (!isClient(next)) {\n break;\n }\n cur = next;\n }\n return isClient(cur) ? cur : null;\n}\n\nfunction isClient(v: unknown): v is HyperDXClient {\n return (\n typeof peek(v, \"init\") === \"function\" &&\n typeof peek(v, \"recordException\") === \"function\"\n );\n}\n\n// A module namespace object may throw on an absent key (strict ESM\n// interop shims and test doubles do), and \"absent\" is an answer here.\nfunction peek(obj: unknown, key: string): unknown {\n try {\n return (obj as Record<string, unknown>)[key];\n } catch {\n return undefined;\n }\n}\n\n/** The tracer to open manual spans on. Resolves HyperDX's provider once it is running. */\nexport function getTracer(name = \"default\") {\n return trace.getTracer(name);\n}\n\nexport { context, propagation, trace } from \"@opentelemetry/api\";\n"]}
|
|
1
|
+
{"version":3,"file":"telemetry.js","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"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","sourcesContent":["// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.\n//\n// Browser telemetry: the HyperDX browser SDK, initialised the forge way.\n//\n// Captures errors (uncaught, unhandled rejections, boundary-caught), console\n// output, fetch/XHR spans, document load, long tasks and web vitals, and\n// ships them to ClickStack through a SAME-ORIGIN `/_otel` route.\n//\n// ── Why HyperDX REPLACES the old OTel web wiring, not joins it ───────────\n//\n// @hyperdx/browser wraps @hyperdx/otel-web, which builds its own\n// WebTracerProvider and calls provider.register() — the global tracer\n// provider, context manager and propagator. A second registration (the old\n// `./otel` subpath did exactly that) is rejected by @opentelemetry/api with a\n// \"duplicate registration\" diagnostic and silently loses: whichever ran\n// second would simply never see a span. Two tracer providers cannot coexist\n// in one page, so there is one, and it is HyperDX's. `./otel` and its eight\n// @opentelemetry/* SDK peer dependencies are gone.\n//\n// traceparent on RPCs is unaffected. traceInterceptor (trace.ts) injects the\n// active span's context into every Connect call, or synthesises a valid\n// traceparent when none is active — with no SDK at all. With the SDK running,\n// the fetch span HyperDX opens for the call carries the same trace id, and\n// `apiOrigins` below makes that hold for a cross-origin API too (which is\n// every dev loop: the API binds its own port).\n//\n// ── The browser holds no secret ──────────────────────────────────────────\n//\n// `url` is `${location.origin}/_otel`. The browser talks only to its own\n// origin; a dev-server proxy (next.config.ts rewrites / Vite server.proxy) or\n// the deployment's ingress forwards `/_otel/v1/{traces,logs}` to the OTLP/HTTP\n// port of a collector, and that hop — never the bundle — owns credentials.\n//\n// The SDK nevertheless wants an `apiKey` and sends it as an `authorization`\n// header. We pass PLACEHOLDER_API_KEY, a constant that grants nothing and is\n// public by construction. A collector that enforces ingestion auth (ClickStack\n// `collectorAuthenticationEnforced`) rejects it with 401, so the proxy in\n// front of such a collector must strip the header or replace it with the real\n// key. The local ClickStack collector does not enforce, and ignores it.\n//\n// ── Replay: shipped, off, masked, media blocked ─────────────────────────\n//\n// The SDK bundles an rrweb recorder. It is DISABLED unless `replay: true`.\n// Privacy is forced, not offered: every input and all text is masked, canvas is\n// never recorded, and images, video, audio, iframes and embeds are BLOCKED\n// (replaced by a same-size placeholder; their pixels never leave the browser).\n// Images are not inlined and web fonts are not collected. There is deliberately\n// no option to turn any of that off while replay is on; an app that needs\n// unmasked replay passes its own SDK options.\n//\n// `replaySampleRate` (0..1, default 1) records only that share of SESSIONS. The\n// SDK has no session sampler, so the decision is made here, once, and held in\n// sessionStorage for the tab's session.\n//\n// ── Network URLs carry no query string ───────────────────────────────────\n//\n// fetch/XHR spans record the request URL, and a query string is where tokens,\n// OAuth codes and search terms live. The runtime rewrites `http.url` and\n// `url.full` on every fetch/XHR span to the URL without query or fragment.\n// The SDK's own `location.href` attribute (the page URL, stamped on every span)\n// has no client-side hook; the ingest side scrubs it.\n//\n// ── Loading ──────────────────────────────────────────────────────────────\n//\n// The SDK is ~600 KB minified (rrweb and the OTel web stack are bundled in\n// it), so it is imported lazily: bundlers split it into its own chunk that\n// loads after the app is interactive and costs the first paint nothing.\n// initBrowserTelemetry is therefore async and idempotent — every caller gets\n// the same promise, which is what survives React StrictMode's double mount and\n// Fast Refresh. It is a no-op without a DOM (SSR, prerender) and without an\n// endpoint, and it never throws.\nimport { trace } from \"@opentelemetry/api\";\n\nimport { setExceptionReporter } from \"./error-reporter.js\";\nimport { registerTelemetryStartup } from \"./telemetry-gate.js\";\nimport {\n isTelemetryDisabled,\n setTelemetrySink,\n type TrackValue,\n} from \"./track.js\";\n\n/** Where the browser ships telemetry: this origin's `/_otel`. */\nexport const DEFAULT_INGEST_PATH = \"/_otel\";\n\n/**\n * The apiKey handed to the SDK. Public by construction: it identifies\n * nothing and authorises nothing. See the header — the proxy or collector\n * owns the real credential.\n */\nexport const PLACEHOLDER_API_KEY = \"forge-browser-public\";\n\nexport interface BrowserTelemetryConfig {\n /** `service.name` on everything this page emits. */\n serviceName: string;\n /** `service.version`. Omit for none. */\n serviceVersion?: string;\n /**\n * The forge environment name. Emitted as BOTH `deployment.environment.name`\n * (current semconv) and `deployment.environment` (the legacy key HyperDX's\n * default sources still key on).\n */\n environment?: string;\n /** `service.namespace`, conventionally the project name. */\n serviceNamespace?: string;\n /**\n * The forge frontend's name, emitted as the `forge.frontend` resource\n * attribute. Unlike `serviceName` (which an app may override to anything) it\n * is the stable key that joins this page's telemetry to the frontend and its\n * source maps. Omit for none.\n */\n frontend?: string;\n /**\n * Ingest base: a path on this origin (`/_otel`) or, as an escape hatch, an\n * absolute URL. Unset, empty, or `off` disables telemetry — the app runs\n * untraced rather than throwing, which is what makes it safe to call\n * unconditionally from the app shell.\n */\n endpoint?: string;\n /**\n * Origins (or URLs — only the origin is used) of the app's own backend.\n * Requests to them always get a `traceparent` from the SDK's fetch/XHR\n * spans. This page's own origin is always included.\n */\n apiOrigins?: Array<string | undefined>;\n /**\n * Mirror console.log/info/warn/debug into telemetry spans. Default true;\n * pass false when console output can hold user content. It does NOT stop\n * `console.error(err)` from being recorded as an error: the SDK's error\n * instrumentation is separate and always on.\n */\n consoleCapture?: boolean;\n /**\n * Capture full request/response headers AND BODIES on fetch/XHR spans.\n * Default false, and it should stay false: bodies are user data.\n */\n advancedNetworkCapture?: boolean;\n /**\n * Session replay (rrweb). Default false. When true, all text and inputs are\n * masked, media is blocked and canvas is not recorded (see the header).\n */\n replay?: boolean;\n /**\n * The share of sessions to record when `replay` is true, 0..1. Default 1\n * (every session). Decided once per session and held in sessionStorage. A\n * value that is not a number records nothing. An app with its own policy\n * (every session of a new user, a fraction of the rest) computes the rate\n * it wants for this session and passes it here.\n */\n replaySampleRate?: number;\n /** Verbose SDK diagnostics to the console. Default false. */\n debug?: boolean;\n}\n\n/** The slice of the SDK this module calls. Typed here, not imported, so the module loads without it. */\ninterface HyperDXClient {\n init(config: Record<string, unknown>): void;\n recordException(error: unknown, attributes?: Record<string, unknown>): void;\n addAction?(name: string, attributes?: Record<string, TrackValue>): void;\n setGlobalAttributes?(attributes: Record<string, string>): void;\n stopSessionRecorder?(): void;\n}\n\nlet started: Promise<boolean> | null = null;\n\n/**\n * initBrowserTelemetry starts the HyperDX browser SDK once per page load and\n * resolves to whether it is running. See the module header for the contract.\n */\nexport function initBrowserTelemetry(\n config: BrowserTelemetryConfig,\n): Promise<boolean> {\n if (typeof window === \"undefined\") {\n return Promise.resolve(false);\n }\n if (started) {\n return started;\n }\n if (isTelemetryDisabled()) {\n return Promise.resolve(false);\n }\n const ingest = resolveIngestUrl(config.endpoint, window.location.origin);\n if (!ingest) {\n // Nothing will ever drain track() calls held for the SDK; let them go.\n setTelemetrySink(null);\n return Promise.resolve(false);\n }\n started = start(config, ingest);\n registerTelemetryStartup(started);\n return started;\n}\n\nasync function start(\n config: BrowserTelemetryConfig,\n ingest: string,\n): Promise<boolean> {\n try {\n const hdx = resolveClient(await import(\"@hyperdx/browser\"));\n if (!hdx || isTelemetryDisabled()) {\n setTelemetrySink(null);\n return false;\n }\n const replay = decideReplay(config, sessionStorageOrNull(), Math.random);\n hdx.init(\n buildSdkOptions({ ...config, replay }, ingest, window.location.origin),\n );\n tapSdkInputs();\n setExceptionReporter((error, attributes) => {\n if (alreadyRecorded(error)) {\n return;\n }\n hdx.recordException(error, attributes);\n });\n setTelemetrySink({\n track: (name, attributes) => hdx.addAction?.(name, attributes),\n setUser: (id) => hdx.setGlobalAttributes?.({ userId: id }),\n disable: () => {\n // Back to \"no SDK\": reportException returns false, so a caller falls\n // back to console.error and the error does not vanish.\n setExceptionReporter(null);\n hdx.stopSessionRecorder?.();\n },\n });\n return true;\n } catch (err) {\n // Blocked chunk, ad-blocker, SDK throw: telemetry must never break the app.\n console.debug(\"[telemetry] disabled:\", err);\n setTelemetrySink(null);\n return false;\n }\n}\n\n// ── One failure, one record ──────────────────────────────────────────────\n//\n// The SDK records an Error that reaches window \"error\", window\n// \"unhandledrejection\", or console.error. React (18 and 19) console.errors an\n// error a boundary catches BEFORE it calls componentDidCatch, and Next\n// dispatches an uncaught render error to window \"error\" — so by the time\n// reportException runs, the SDK has usually recorded that very error already.\n// Recording it again would count one failure twice, and the issue layer\n// (which groups by fingerprint) would report two.\n//\n// So the taps below remember which Error OBJECTS the SDK was shown, and the\n// installed recorder skips those. Whatever the SDK did not see — a custom\n// onCaughtError that silences React's logging, an error rethrown as a new\n// object — is still recorded. The console tap is installed after the SDK's own\n// wrapper, so it sits outermost and only observes; every call is forwarded\n// untouched.\n//\n// The cost is the React component stack: the SDK's record of a boundary-caught\n// error comes from console.error(error), which carries the error and its\n// stack but not React's componentStack, and attributes passed to a skipped\n// reportException are dropped with it.\nconst seenBySdk = new WeakSet<object>();\nlet tapped = false;\n\nfunction noteSeen(value: unknown): void {\n if (typeof value === \"object\" && value !== null) {\n seenBySdk.add(value);\n }\n}\n\nfunction tapSdkInputs(): void {\n if (tapped) {\n return;\n }\n tapped = true;\n const wrapped = console.error;\n console.error = function (this: unknown, ...args: unknown[]) {\n for (const arg of args) {\n if (arg instanceof Error) {\n noteSeen(arg);\n }\n }\n return wrapped.apply(this, args);\n };\n window.addEventListener(\"error\", (e) => noteSeen(e.error), true);\n window.addEventListener(\n \"unhandledrejection\",\n (e) => noteSeen(e.reason),\n true,\n );\n}\n\nfunction alreadyRecorded(error: unknown): boolean {\n return typeof error === \"object\" && error !== null && seenBySdk.has(error);\n}\n\n/**\n * The options passed to HyperDX.init. Exported for tests and for apps that\n * want to see exactly what is configured — it is pure.\n */\nexport function buildSdkOptions(\n config: BrowserTelemetryConfig,\n ingestUrl: string,\n pageOrigin: string,\n): Record<string, unknown> {\n // `config.replay` is the FINAL decision here. initBrowserTelemetry resolves\n // replaySampleRate into it (decideReplay) before calling this.\n const replay = config.replay === true;\n const resourceAttributes: Record<string, string> = {};\n if (config.serviceVersion) {\n resourceAttributes[\"service.version\"] = config.serviceVersion;\n }\n if (config.serviceNamespace) {\n resourceAttributes[\"service.namespace\"] = config.serviceNamespace;\n }\n if (config.frontend) {\n resourceAttributes[\"forge.frontend\"] = config.frontend;\n }\n if (config.environment) {\n resourceAttributes[\"deployment.environment.name\"] = config.environment;\n resourceAttributes[\"deployment.environment\"] = config.environment;\n }\n\n const targets = propagationTargets(pageOrigin, config.apiOrigins);\n const advancedNetworkCapture = config.advancedNetworkCapture ?? false;\n const options: Record<string, unknown> = {\n apiKey: PLACEHOLDER_API_KEY,\n service: config.serviceName,\n url: ingestUrl,\n tracePropagationTargets: targets,\n // The SDK would otherwise trace its own exports, and it must never\n // observe the route it ships through.\n ignoreUrls: [new RegExp(`^${escapeRegExp(ingestUrl)}`)],\n consoleCapture: config.consoleCapture ?? true,\n advancedNetworkCapture,\n // The SDK spreads this over ITS fetch/xhr config as whole keys, so\n // everything it would have set (propagation targets, the capture switch)\n // must be restated here or it is lost.\n instrumentations: {\n fetch: {\n propagateTraceHeaderCorsUrls: targets,\n advancedNetworkCapture: () => advancedNetworkCapture,\n applyCustomAttributesOnSpan: stripNetworkQuery,\n },\n xhr: {\n propagateTraceHeaderCorsUrls: targets,\n advancedNetworkCapture: () => advancedNetworkCapture,\n applyCustomAttributesOnSpan: stripNetworkQuery,\n },\n },\n // Polls for window.Intercom on a timer; nothing here uses Intercom.\n disableIntercom: true,\n disableReplay: !replay,\n otelResourceAttributes: resourceAttributes,\n debug: config.debug ?? false,\n // Privacy is forced and unconditional: no path through this function\n // yields an unmasked or media-recording replay. The SDK's own default\n // masks inputs but NOT text, and text is where chat content and records\n // live.\n maskAllInputs: true,\n maskAllText: true,\n recordCanvas: false,\n blockSelector: REPLAY_BLOCK_SELECTOR,\n replay: { inlineImages: false, collectFonts: false },\n };\n return options;\n}\n\n/** Elements a replay never records: their pixels stay in the browser. */\nexport const REPLAY_BLOCK_SELECTOR =\n \"img, picture, video, audio, iframe, object, embed\";\n\n/** Where a replay sampling decision is held for the tab's session. */\nexport const REPLAY_SAMPLE_KEY = \"forge.replay.sampled\";\n\n/** The slice of Storage the sampling decision needs. */\ninterface DecisionStore {\n getItem(key: string): string | null;\n setItem(key: string, value: string): void;\n}\n\n/**\n * Whether this session records a replay: config.replay, then the sample rate,\n * drawn once and remembered in `store` so a reload (or a route change that\n * re-runs init) never re-rolls into a session that only half-recorded. With no\n * usable store the draw is made each load. Pure given its inputs; exported for\n * tests.\n */\nexport function decideReplay(\n config: Pick<BrowserTelemetryConfig, \"replay\" | \"replaySampleRate\">,\n store: DecisionStore | null,\n random: () => number,\n): boolean {\n if (config.replay !== true) {\n return false;\n }\n const rate = clampRate(config.replaySampleRate);\n if (rate >= 1) {\n return true;\n }\n if (rate <= 0) {\n return false;\n }\n try {\n const held = store?.getItem(REPLAY_SAMPLE_KEY);\n if (held === \"1\" || held === \"0\") {\n return held === \"1\";\n }\n } catch {\n // Storage blocked: fall through to a fresh draw.\n }\n const sampled = random() < rate;\n try {\n store?.setItem(REPLAY_SAMPLE_KEY, sampled ? \"1\" : \"0\");\n } catch {\n // Not persisted: the next load draws again.\n }\n return sampled;\n}\n\n// Unset is \"every session\"; anything that is not a number is a bug, and a bug\n// must not start recording people.\nfunction clampRate(rate: number | undefined): number {\n if (rate === undefined) {\n return 1;\n }\n if (typeof rate !== \"number\" || Number.isNaN(rate)) {\n return 0;\n }\n return Math.min(1, Math.max(0, rate));\n}\n\nfunction sessionStorageOrNull(): DecisionStore | null {\n try {\n return window.sessionStorage;\n } catch {\n return null;\n }\n}\n\n/** The span attributes that hold a request URL. */\nconst URL_ATTRIBUTES = [\"http.url\", \"url.full\"] as const;\n\n/** `url` without its query string and fragment. */\nexport function stripUrlQuery(url: string): string {\n const cut = url.search(/[?#]/);\n return cut === -1 ? url : url.slice(0, cut);\n}\n\ninterface UrlSpan {\n attributes?: Record<string, unknown>;\n setAttribute(key: string, value: string): unknown;\n}\n\n// applyCustomAttributesOnSpan for both instrumentations: it runs before the\n// span ends, so the rewritten value is the one exported.\nfunction stripNetworkQuery(span: UrlSpan): void {\n for (const key of URL_ATTRIBUTES) {\n const value = span.attributes?.[key];\n if (typeof value === \"string\") {\n const stripped = stripUrlQuery(value);\n if (stripped !== value) {\n span.setAttribute(key, stripped);\n }\n }\n }\n}\n\n/**\n * resolveIngestUrl turns the configured endpoint into the absolute URL the SDK\n * is given, or null when telemetry is off. A path resolves against the page\n * origin, so the default is same-origin by construction.\n */\nexport function resolveIngestUrl(\n endpoint: string | undefined,\n pageOrigin: string,\n): string | null {\n const value = (endpoint ?? \"\").trim();\n if (value === \"\" || value.toLowerCase() === \"off\") {\n return null;\n }\n try {\n return new URL(value, pageOrigin).toString().replace(/\\/+$/, \"\");\n } catch {\n return null;\n }\n}\n\nfunction propagationTargets(\n pageOrigin: string,\n apiOrigins: Array<string | undefined> = [],\n): RegExp[] {\n const origins = new Set<string>([pageOrigin]);\n for (const raw of apiOrigins) {\n if (!raw) {\n continue;\n }\n try {\n origins.add(new URL(raw, pageOrigin).origin);\n } catch {\n // An unparsable origin is not a target.\n }\n }\n return [...origins].map((o) => new RegExp(`^${escapeRegExp(o)}(?:[/?#]|$)`));\n}\n\nfunction escapeRegExp(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n// @hyperdx/browser is a UMD/CJS bundle whose module.exports IS the client, and\n// its methods use `this`. A bundler hands back a module NAMESPACE that exposes\n// both the client's members at the top level (as read-only getters) and the\n// client itself as `default`. Calling init on the namespace binds `this` to that\n// frozen object and the SDK throws on its first property write, which disabled\n// telemetry silently. So descend to the deepest `default` that is still a\n// client, the real instance, and never settle for the namespace in front of it.\nfunction resolveClient(mod: unknown): HyperDXClient | null {\n let cur: unknown = mod;\n for (let depth = 0; depth < 4; depth++) {\n const next = peek(cur, \"default\");\n if (!isClient(next)) {\n break;\n }\n cur = next;\n }\n return isClient(cur) ? cur : null;\n}\n\nfunction isClient(v: unknown): v is HyperDXClient {\n return (\n typeof peek(v, \"init\") === \"function\" &&\n typeof peek(v, \"recordException\") === \"function\"\n );\n}\n\n// A module namespace object may throw on an absent key (strict ESM\n// interop shims and test doubles do), and \"absent\" is an answer here.\nfunction peek(obj: unknown, key: string): unknown {\n try {\n return (obj as Record<string, unknown>)[key];\n } catch {\n return undefined;\n }\n}\n\n/** The tracer to open manual spans on. Resolves HyperDX's provider once it is running. */\nexport function getTracer(name = \"default\") {\n return trace.getTracer(name);\n}\n\nexport { context, propagation, trace } from \"@opentelemetry/api\";\n"]}
|
package/dist/track.d.ts
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.5.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": [
|