@reliantlabs/forge-web-runtime 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/dist/benign-errors.d.ts +33 -0
- package/dist/benign-errors.js +115 -0
- package/dist/benign-errors.js.map +1 -0
- package/dist/client-context.d.ts +35 -0
- package/dist/client-context.js +204 -0
- package/dist/client-context.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/interceptors.js +10 -3
- package/dist/interceptors.js.map +1 -1
- package/dist/node.js +10 -0
- package/dist/node.js.map +1 -1
- package/dist/resource.d.ts +62 -3
- package/dist/resource.js +96 -17
- package/dist/resource.js.map +1 -1
- package/dist/service-hooks.js +8 -6
- package/dist/service-hooks.js.map +1 -1
- package/dist/session-cookie-fallback.d.ts +23 -0
- package/dist/session-cookie-fallback.js +203 -0
- package/dist/session-cookie-fallback.js.map +1 -0
- package/dist/telemetry-gate.d.ts +4 -0
- package/dist/telemetry-gate.js +25 -0
- package/dist/telemetry-gate.js.map +1 -1
- package/dist/telemetry.d.ts +29 -2
- package/dist/telemetry.js +370 -17
- package/dist/telemetry.js.map +1 -1
- package/dist/trace.d.ts +7 -0
- package/dist/trace.js +21 -2
- package/dist/trace.js.map +1 -1
- package/package.json +4 -2
package/dist/telemetry.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { type AttributionTouch } from "./attribution.js";
|
|
2
|
+
import { type BenignErrorClassifier } from "./benign-errors.js";
|
|
3
|
+
export { clientResourceAttributes, type ForgeDesktopInfo, viewportAttributes, } from "./client-context.js";
|
|
4
|
+
export { cookiesPersist, installSessionCookieFallback, RUM_SESSION_COOKIE, RUM_SESSION_STORAGE_KEY, } from "./session-cookie-fallback.js";
|
|
5
|
+
export { type BenignErrorClassifier, type BenignErrorKind, classifyBenignError, FILTERED_ERRORS_ACTION, } from "./benign-errors.js";
|
|
2
6
|
/** Where the browser ships telemetry: this origin's `/_otel`. */
|
|
3
7
|
export declare const DEFAULT_INGEST_PATH = "/_otel";
|
|
4
8
|
/**
|
|
@@ -95,6 +99,18 @@ export interface BrowserTelemetryConfig {
|
|
|
95
99
|
* site attributing acquisition to the campaign that first brought a visitor.
|
|
96
100
|
*/
|
|
97
101
|
attributionTouch?: AttributionTouch;
|
|
102
|
+
/**
|
|
103
|
+
* Decides which errors are benign browser noise: kept out of the error
|
|
104
|
+
* pipeline and counted as `forge.errors_filtered` actions instead
|
|
105
|
+
* (benign-errors.ts). Default classifyBenignError: an AbortError, and the
|
|
106
|
+
* browser's generic "Failed to fetch" TypeError; never a ConnectError. It
|
|
107
|
+
* applies to what the SDK records from unhandled rejections, uncaught
|
|
108
|
+
* errors, console.error and reportException; the app's own handlers see
|
|
109
|
+
* every event unchanged. For an unhandled rejection or uncaught error it is
|
|
110
|
+
* handed `{ name, message }` as the SDK recorded them, not the original
|
|
111
|
+
* value. Pass `() => null` to record everything.
|
|
112
|
+
*/
|
|
113
|
+
classifyBenignError?: BenignErrorClassifier;
|
|
98
114
|
}
|
|
99
115
|
/**
|
|
100
116
|
* Turn on visitor attribution for a page that started telemetry with
|
|
@@ -115,9 +131,11 @@ export declare function enableAttribution(options?: {
|
|
|
115
131
|
export declare function initBrowserTelemetry(config: BrowserTelemetryConfig): Promise<boolean>;
|
|
116
132
|
/**
|
|
117
133
|
* The options passed to HyperDX.init. Exported for tests and for apps that
|
|
118
|
-
* want to see exactly what is configured — it is pure.
|
|
134
|
+
* want to see exactly what is configured — it is pure. `clientAttributes`
|
|
135
|
+
* are the page's own client resource attributes (client-context.ts:
|
|
136
|
+
* browser.* client hints, app.desktop.*); the config's keys win over them.
|
|
119
137
|
*/
|
|
120
|
-
export declare function buildSdkOptions(config: BrowserTelemetryConfig, ingestUrl: string, pageOrigin: string): Record<string, unknown>;
|
|
138
|
+
export declare function buildSdkOptions(config: BrowserTelemetryConfig, ingestUrl: string, pageOrigin: string, clientAttributes?: Record<string, string>): Record<string, unknown>;
|
|
121
139
|
/** Elements a replay never records: their pixels stay in the browser. */
|
|
122
140
|
export declare const REPLAY_BLOCK_SELECTOR = "img, picture, video, audio, iframe, object, embed";
|
|
123
141
|
/** Where a replay sampling decision is held for the tab's session. */
|
|
@@ -137,6 +155,15 @@ interface DecisionStore {
|
|
|
137
155
|
export declare function decideReplay(config: Pick<BrowserTelemetryConfig, "replay" | "replaySampleRate">, store: DecisionStore | null, random: () => number): boolean;
|
|
138
156
|
/** `url` without its query string and fragment. */
|
|
139
157
|
export declare function stripUrlQuery(url: string): string;
|
|
158
|
+
/**
|
|
159
|
+
* The name a fetch/XHR span gets: `<METHOD> /<package>.<Service>/<Method>`,
|
|
160
|
+
* the server span's name with the method in front, when the URL is a
|
|
161
|
+
* Connect/gRPC procedure. Empty for any other URL, which keeps the SDK's
|
|
162
|
+
* `HTTP <METHOD>`: an arbitrary path can hold values (ids, tokens, emails) no
|
|
163
|
+
* pattern recognises reliably, and a span name must be neither sensitive nor
|
|
164
|
+
* unbounded. Empty too when `url` cannot be parsed. Exported for tests.
|
|
165
|
+
*/
|
|
166
|
+
export declare function spanRouteName(method: string, url: string): string;
|
|
140
167
|
/**
|
|
141
168
|
* resolveIngestUrl turns the configured endpoint into the absolute URL the SDK
|
|
142
169
|
* is given, or null when telemetry is off. A path resolves against the page
|
package/dist/telemetry.js
CHANGED
|
@@ -52,6 +52,27 @@
|
|
|
52
52
|
// SDK has no session sampler, so the decision is made here, once, and held in
|
|
53
53
|
// sessionStorage for the tab's session.
|
|
54
54
|
//
|
|
55
|
+
// ── One session per visit, even where cookies are dropped ────────────────
|
|
56
|
+
//
|
|
57
|
+
// The SDK keeps its session id only in the `__rum_sid` cookie and mints a new
|
|
58
|
+
// one whenever it cannot read that cookie back, which it checks every minute.
|
|
59
|
+
// An origin that drops cookies (an Electron renderer on a custom scheme such
|
|
60
|
+
// as `app://`) therefore got a new session every minute. Before init, the
|
|
61
|
+
// runtime probes whether cookies persist and, when they do not, keeps that one
|
|
62
|
+
// cookie in localStorage with the same 15-minute inactivity and 4-hour limits
|
|
63
|
+
// (session-cookie-fallback.ts). Origins whose cookies work are untouched.
|
|
64
|
+
//
|
|
65
|
+
// ── No session until the page has been seen ──────────────────────────────
|
|
66
|
+
//
|
|
67
|
+
// A page loaded in the background (a desktop window opened hidden, a tab
|
|
68
|
+
// restored behind another) starts no session at all: the SDK is not loaded
|
|
69
|
+
// until the document is first visible. Started hidden, it would mint a session
|
|
70
|
+
// made only of background fetch spans, with nothing on screen to record, and
|
|
71
|
+
// the recorder would hold no replay for it. Until then reportException returns
|
|
72
|
+
// false (the caller logs to the console) and track() calls are held, bounded,
|
|
73
|
+
// as they are while the SDK loads. A page that never becomes visible sends
|
|
74
|
+
// nothing.
|
|
75
|
+
//
|
|
55
76
|
// ── Network URLs carry no query string ───────────────────────────────────
|
|
56
77
|
//
|
|
57
78
|
// fetch/XHR spans record the request URL, and a query string is where tokens,
|
|
@@ -60,6 +81,25 @@
|
|
|
60
81
|
// The SDK's own `location.href` attribute (the page URL, stamped on every span)
|
|
61
82
|
// has no client-side hook; the ingest side scrubs it.
|
|
62
83
|
//
|
|
84
|
+
// ── Network spans are named by what they called ──────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// HyperDX names every fetch/XHR span `HTTP <METHOD>`, so a trace a browser
|
|
87
|
+
// starts is titled "HTTP POST" whatever it did. The same hook renames it to
|
|
88
|
+
// the procedure when it is a Connect/gRPC call (`POST /pkg.v1.Service/Method`,
|
|
89
|
+
// the server span's name with the method in front; spanRouteName). Any other
|
|
90
|
+
// URL keeps `HTTP <METHOD>`: a free-form path can carry ids and tokens, and
|
|
91
|
+
// the span name is exported unscrubbed.
|
|
92
|
+
//
|
|
93
|
+
// ── Client context ───────────────────────────────────────────────────────
|
|
94
|
+
//
|
|
95
|
+
// The page reports what only it can know about its client — client hints
|
|
96
|
+
// (browser.brands/platform/mobile), its viewport and screen (app.viewport.*,
|
|
97
|
+
// app.screen.*) and, inside a desktop app, the app's versions from the
|
|
98
|
+
// `window.forgeDesktop` preload bridge (app.desktop.*) — sanitized
|
|
99
|
+
// (client-context.ts). Browser, OS and device type are NOT page claims: the
|
|
100
|
+
// ingest side derives them from the request's User-Agent and Sec-CH-UA
|
|
101
|
+
// headers and overwrites any the page sent.
|
|
102
|
+
//
|
|
63
103
|
// ── Loading ──────────────────────────────────────────────────────────────
|
|
64
104
|
//
|
|
65
105
|
// The SDK is ~600 KB minified (rrweb and the OTel web stack are bundled in
|
|
@@ -71,9 +111,15 @@
|
|
|
71
111
|
// endpoint, and it never throws.
|
|
72
112
|
import { trace } from "@opentelemetry/api";
|
|
73
113
|
import { captureAttribution, detectAppClient, takeIdentifyProps, } from "./attribution.js";
|
|
114
|
+
import { classifyBenignError, FilteredErrorCounter, } from "./benign-errors.js";
|
|
115
|
+
import { pageClientResourceAttributes, watchViewport, } from "./client-context.js";
|
|
74
116
|
import { setExceptionReporter } from "./error-reporter.js";
|
|
75
|
-
import {
|
|
117
|
+
import { installSessionCookieFallback } from "./session-cookie-fallback.js";
|
|
118
|
+
import { registerRumSession, registerTelemetryStartup, rumSessionId, } from "./telemetry-gate.js";
|
|
76
119
|
import { isTelemetryDisabled, setTelemetrySink, } from "./track.js";
|
|
120
|
+
export { clientResourceAttributes, viewportAttributes, } from "./client-context.js";
|
|
121
|
+
export { cookiesPersist, installSessionCookieFallback, RUM_SESSION_COOKIE, RUM_SESSION_STORAGE_KEY, } from "./session-cookie-fallback.js";
|
|
122
|
+
export { classifyBenignError, FILTERED_ERRORS_ACTION, } from "./benign-errors.js";
|
|
77
123
|
/** Where the browser ships telemetry: this origin's `/_otel`. */
|
|
78
124
|
export const DEFAULT_INGEST_PATH = "/_otel";
|
|
79
125
|
/**
|
|
@@ -153,14 +199,20 @@ export function initBrowserTelemetry(config) {
|
|
|
153
199
|
}
|
|
154
200
|
async function start(config, ingest) {
|
|
155
201
|
try {
|
|
202
|
+
await firstVisible();
|
|
156
203
|
const hdx = resolveClient(await import("@hyperdx/browser"));
|
|
157
204
|
if (!hdx || isTelemetryDisabled()) {
|
|
158
205
|
setTelemetrySink(null);
|
|
159
206
|
return false;
|
|
160
207
|
}
|
|
161
208
|
const replay = decideReplay(config, sessionStorageOrNull(), Math.random);
|
|
162
|
-
|
|
163
|
-
|
|
209
|
+
// Before init: the SDK reads its session cookie as it starts.
|
|
210
|
+
keepSessionWithoutCookies();
|
|
211
|
+
hdx.init(buildSdkOptions({ ...config, replay }, ingest, window.location.origin, pageClientResourceAttributes()));
|
|
212
|
+
// After init: both hook what the SDK RECORDS, never what the app sees.
|
|
213
|
+
const benign = benignFilter(config, hdx);
|
|
214
|
+
filterSdkErrorSpans(benign);
|
|
215
|
+
tapSdkInputs(benign);
|
|
164
216
|
// Every span carries where it came from and which visitor made it; the
|
|
165
217
|
// product-event view reads these as app.client and visitor.id.
|
|
166
218
|
// With attribution deferred (attribution: false), visitor.id joins them
|
|
@@ -175,10 +227,21 @@ async function start(config, ingest) {
|
|
|
175
227
|
globals["visitor.id"] = captureVisitor(config);
|
|
176
228
|
}
|
|
177
229
|
hdx.setGlobalAttributes?.(globals);
|
|
230
|
+
// The viewport and screen ride on every span, refreshed on resize
|
|
231
|
+
// (client-context.ts): the SDK's global attributes are a span processor
|
|
232
|
+
// that stamps the current set on each span as it starts, so a span carries
|
|
233
|
+
// the size of the moment it began (proved against the real SDK in
|
|
234
|
+
// scripts/browser-telemetry-smoke.mjs). Device, OS and browser are
|
|
235
|
+
// derived at ingest.
|
|
236
|
+
const stopViewport = watchViewport((attrs) => hdx.setGlobalAttributes?.(attrs));
|
|
178
237
|
runningClient = hdx;
|
|
179
238
|
runningConfig = config;
|
|
239
|
+
// The session id rides on every Connect call as baggage (trace.ts) and on
|
|
240
|
+
// every fetch/XHR span (sessionAttribute), joining backend traces to the
|
|
241
|
+
// session that caused them.
|
|
242
|
+
registerRumSession(() => hdx.getSessionId?.());
|
|
180
243
|
setExceptionReporter((error, attributes) => {
|
|
181
|
-
if (alreadyRecorded(error)) {
|
|
244
|
+
if (alreadyRecorded(error) || benign.filtered(error, "reportException")) {
|
|
182
245
|
return;
|
|
183
246
|
}
|
|
184
247
|
hdx.recordException(error, attributes);
|
|
@@ -198,6 +261,8 @@ async function start(config, ingest) {
|
|
|
198
261
|
// Back to "no SDK": reportException returns false, so a caller falls
|
|
199
262
|
// back to console.error and the error does not vanish.
|
|
200
263
|
setExceptionReporter(null);
|
|
264
|
+
registerRumSession(null);
|
|
265
|
+
stopViewport();
|
|
201
266
|
hdx.stopSessionRecorder?.();
|
|
202
267
|
},
|
|
203
268
|
});
|
|
@@ -205,11 +270,34 @@ async function start(config, ingest) {
|
|
|
205
270
|
}
|
|
206
271
|
catch (err) {
|
|
207
272
|
// Blocked chunk, ad-blocker, SDK throw: telemetry must never break the app.
|
|
273
|
+
// A startup that failed after the hooks went in takes them out again.
|
|
274
|
+
removeBenignFilter();
|
|
208
275
|
console.debug("[telemetry] disabled:", err);
|
|
209
276
|
setTelemetrySink(null);
|
|
210
277
|
return false;
|
|
211
278
|
}
|
|
212
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* Resolves once the document is visible: at once for a visible page or one
|
|
282
|
+
* with no Page Visibility API, otherwise at its first visibilitychange to
|
|
283
|
+
* visible. See "No session until the page has been seen" in the header.
|
|
284
|
+
*/
|
|
285
|
+
function firstVisible() {
|
|
286
|
+
if (typeof document === "undefined" ||
|
|
287
|
+
document.visibilityState !== "hidden" ||
|
|
288
|
+
typeof document.addEventListener !== "function") {
|
|
289
|
+
return Promise.resolve();
|
|
290
|
+
}
|
|
291
|
+
return new Promise((resolve) => {
|
|
292
|
+
const onChange = () => {
|
|
293
|
+
if (document.visibilityState !== "hidden") {
|
|
294
|
+
document.removeEventListener("visibilitychange", onChange);
|
|
295
|
+
resolve();
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
document.addEventListener("visibilitychange", onChange);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
213
301
|
// ── One failure, one record ──────────────────────────────────────────────
|
|
214
302
|
//
|
|
215
303
|
// The SDK records an Error that reaches window "error", window
|
|
@@ -238,35 +326,238 @@ function noteSeen(value) {
|
|
|
238
326
|
seenBySdk.add(value);
|
|
239
327
|
}
|
|
240
328
|
}
|
|
241
|
-
function tapSdkInputs() {
|
|
329
|
+
function tapSdkInputs(benign) {
|
|
242
330
|
if (tapped) {
|
|
243
331
|
return;
|
|
244
332
|
}
|
|
245
333
|
tapped = true;
|
|
246
334
|
const wrapped = console.error;
|
|
247
|
-
|
|
335
|
+
const tap = function (...args) {
|
|
248
336
|
for (const arg of args) {
|
|
249
|
-
|
|
250
|
-
|
|
337
|
+
try {
|
|
338
|
+
if (arg instanceof Error) {
|
|
339
|
+
noteSeen(arg);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// A revoked Proxy: not ours to inspect; forward it untouched.
|
|
251
344
|
}
|
|
252
345
|
}
|
|
253
|
-
|
|
346
|
+
// The call always goes on to the SDK and the console, exactly as without
|
|
347
|
+
// this tap. Only the verdict on the SDK's RECORD of it is decided here,
|
|
348
|
+
// where the real arguments are: the SDK opens its console.error span
|
|
349
|
+
// synchronously inside wrapped(), and filterSdkErrorSpans reads it.
|
|
350
|
+
const previous = consoleVerdict;
|
|
351
|
+
consoleVerdict = benignKindsOf(benign, args);
|
|
352
|
+
try {
|
|
353
|
+
return wrapped.apply(this, args);
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
consoleVerdict = previous;
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
console.error = tap;
|
|
360
|
+
const onError = (e) => noteSeen(e.error);
|
|
361
|
+
const onRejection = (e) => noteSeen(e.reason);
|
|
362
|
+
window.addEventListener("error", onError, true);
|
|
363
|
+
window.addEventListener("unhandledrejection", onRejection, true);
|
|
364
|
+
benignTeardown.push(() => {
|
|
365
|
+
if (console.error === tap) {
|
|
366
|
+
console.error = wrapped;
|
|
367
|
+
}
|
|
368
|
+
window.removeEventListener?.("error", onError, true);
|
|
369
|
+
window.removeEventListener?.("unhandledrejection", onRejection, true);
|
|
370
|
+
tapped = false;
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* The kind of every exception in a console.error call when ALL of them are
|
|
375
|
+
* benign, or null to record the call: no exception, one real one, or an
|
|
376
|
+
* argument that cannot even be inspected (a throwing Symbol.toStringTag
|
|
377
|
+
* getter, a revoked Proxy).
|
|
378
|
+
*/
|
|
379
|
+
function benignKindsOf(benign, args) {
|
|
380
|
+
const kinds = [];
|
|
381
|
+
for (const arg of args) {
|
|
382
|
+
let exception;
|
|
383
|
+
try {
|
|
384
|
+
exception = isExceptionLike(arg);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
if (!exception) {
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const kind = benign.classify(arg);
|
|
393
|
+
if (kind === null) {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
kinds.push(kind);
|
|
397
|
+
}
|
|
398
|
+
return kinds.length > 0 ? kinds : null;
|
|
399
|
+
}
|
|
400
|
+
function benignFilter(config, hdx) {
|
|
401
|
+
const classify = config.classifyBenignError ?? classifyBenignError;
|
|
402
|
+
const counter = new FilteredErrorCounter((name, attributes) => {
|
|
403
|
+
if (!isTelemetryDisabled()) {
|
|
404
|
+
hdx.addAction?.(name, attributes);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
const onPageHide = () => counter.flush();
|
|
408
|
+
const onVisibility = () => {
|
|
409
|
+
if (document.visibilityState === "hidden") {
|
|
410
|
+
counter.flush();
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
if (typeof window.addEventListener === "function") {
|
|
414
|
+
window.addEventListener("pagehide", onPageHide);
|
|
415
|
+
}
|
|
416
|
+
if (typeof document !== "undefined") {
|
|
417
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
418
|
+
}
|
|
419
|
+
benignTeardown.push(() => {
|
|
420
|
+
window.removeEventListener?.("pagehide", onPageHide);
|
|
421
|
+
if (typeof document !== "undefined") {
|
|
422
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
const kindOf = (error) => {
|
|
426
|
+
try {
|
|
427
|
+
return classify(error) || null;
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// A classifier that throws keeps the error.
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
return {
|
|
435
|
+
classify: kindOf,
|
|
436
|
+
note: (kind, source) => counter.note(kind, source),
|
|
437
|
+
filtered(error, source) {
|
|
438
|
+
const kind = kindOf(error);
|
|
439
|
+
if (!kind) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
counter.note(kind, source);
|
|
443
|
+
return true;
|
|
444
|
+
},
|
|
254
445
|
};
|
|
255
|
-
|
|
256
|
-
|
|
446
|
+
}
|
|
447
|
+
/** The scope (name, version) the SDK's error capture records under. */
|
|
448
|
+
const SDK_ERRORS_SCOPE = { name: "errors", version: "1" };
|
|
449
|
+
/** The spans that scope opens, one per captured failure, and their source. */
|
|
450
|
+
const SDK_ERROR_SPANS = {
|
|
451
|
+
onerror: "error",
|
|
452
|
+
unhandledrejection: "unhandledrejection",
|
|
453
|
+
"console.error": "console.error",
|
|
454
|
+
};
|
|
455
|
+
// The console tap's verdict on the call now inside the SDK's wrapper: the
|
|
456
|
+
// benign kinds, null to record it, undefined when no tapped call is running.
|
|
457
|
+
let consoleVerdict;
|
|
458
|
+
function filterSdkErrorSpans(benign) {
|
|
459
|
+
const proxy = trace.getTracerProvider();
|
|
460
|
+
const provider = (proxy.getDelegate?.() ?? proxy);
|
|
461
|
+
const tracer = provider.getTracer?.(SDK_ERRORS_SCOPE.name, SDK_ERRORS_SCOPE.version);
|
|
462
|
+
if (!tracer || typeof tracer.startSpan !== "function") {
|
|
463
|
+
// No seam: record everything, as without the filter.
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const original = tracer.startSpan;
|
|
467
|
+
const startSpan = function (name, ...rest) {
|
|
468
|
+
const span = original.call(this, name, ...rest);
|
|
469
|
+
const source = SDK_ERROR_SPANS[name];
|
|
470
|
+
if (!source || typeof span?.end !== "function") {
|
|
471
|
+
return span;
|
|
472
|
+
}
|
|
473
|
+
const verdict = name === "console.error" ? consoleVerdict : undefined;
|
|
474
|
+
const end = span.end;
|
|
475
|
+
span.end = function (...args) {
|
|
476
|
+
const kinds = name === "console.error"
|
|
477
|
+
? (verdict ?? null)
|
|
478
|
+
: recordedKind(benign, span);
|
|
479
|
+
if (kinds) {
|
|
480
|
+
for (const kind of kinds) {
|
|
481
|
+
benign.note(kind, source);
|
|
482
|
+
}
|
|
483
|
+
// Never ended, so never exported.
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
return end.apply(this, args);
|
|
487
|
+
};
|
|
488
|
+
return span;
|
|
489
|
+
};
|
|
490
|
+
tracer.startSpan = startSpan;
|
|
491
|
+
benignTeardown.push(() => {
|
|
492
|
+
if (tracer.startSpan === startSpan) {
|
|
493
|
+
tracer.startSpan = original;
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
/** The benign kind of the one failure a window-event span recorded, or null. */
|
|
498
|
+
function recordedKind(benign, span) {
|
|
499
|
+
try {
|
|
500
|
+
const name = span.attributes?.["error.object"];
|
|
501
|
+
const message = span.attributes?.["error.message"];
|
|
502
|
+
if (typeof name !== "string" || name === "String") {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
const kind = benign.classify({
|
|
506
|
+
name,
|
|
507
|
+
message: typeof message === "string" ? message : "",
|
|
508
|
+
});
|
|
509
|
+
return kind ? [kind] : null;
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
// What undoes the benign filter's hooks (the tracer wrap, the console tap, the
|
|
516
|
+
// flush listeners), run when startup fails at any point after they went in.
|
|
517
|
+
let benignTeardown = [];
|
|
518
|
+
function removeBenignFilter() {
|
|
519
|
+
const undo = benignTeardown;
|
|
520
|
+
benignTeardown = [];
|
|
521
|
+
for (const fn of undo.reverse()) {
|
|
522
|
+
try {
|
|
523
|
+
fn();
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
// Telemetry must never break the app.
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// An exception value as console.error receives it: an Error, a DOMException
|
|
531
|
+
// (not an Error in every browser), or either from another realm (an iframe),
|
|
532
|
+
// which fails instanceof but keeps its [object Error] / [object DOMException]
|
|
533
|
+
// tag.
|
|
534
|
+
function isExceptionLike(value) {
|
|
535
|
+
if (typeof value !== "object" || value === null) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
if (value instanceof Error) {
|
|
539
|
+
return true;
|
|
540
|
+
}
|
|
541
|
+
if (typeof DOMException !== "undefined" && value instanceof DOMException) {
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
const tag = Object.prototype.toString.call(value);
|
|
545
|
+
return tag === "[object Error]" || tag === "[object DOMException]";
|
|
257
546
|
}
|
|
258
547
|
function alreadyRecorded(error) {
|
|
259
548
|
return typeof error === "object" && error !== null && seenBySdk.has(error);
|
|
260
549
|
}
|
|
261
550
|
/**
|
|
262
551
|
* The options passed to HyperDX.init. Exported for tests and for apps that
|
|
263
|
-
* want to see exactly what is configured — it is pure.
|
|
552
|
+
* want to see exactly what is configured — it is pure. `clientAttributes`
|
|
553
|
+
* are the page's own client resource attributes (client-context.ts:
|
|
554
|
+
* browser.* client hints, app.desktop.*); the config's keys win over them.
|
|
264
555
|
*/
|
|
265
|
-
export function buildSdkOptions(config, ingestUrl, pageOrigin) {
|
|
556
|
+
export function buildSdkOptions(config, ingestUrl, pageOrigin, clientAttributes = {}) {
|
|
266
557
|
// `config.replay` is the FINAL decision here. initBrowserTelemetry resolves
|
|
267
558
|
// replaySampleRate into it (decideReplay) before calling this.
|
|
268
559
|
const replay = config.replay === true;
|
|
269
|
-
const resourceAttributes = {};
|
|
560
|
+
const resourceAttributes = { ...clientAttributes };
|
|
270
561
|
if (config.serviceVersion) {
|
|
271
562
|
resourceAttributes["service.version"] = config.serviceVersion;
|
|
272
563
|
}
|
|
@@ -296,15 +587,20 @@ export function buildSdkOptions(config, ingestUrl, pageOrigin) {
|
|
|
296
587
|
// everything it would have set (propagation targets, the capture switch)
|
|
297
588
|
// must be restated here or it is lost.
|
|
298
589
|
instrumentations: {
|
|
590
|
+
// Raw DOM events belong to replay, not distributed traces. HyperDX's
|
|
591
|
+
// default traces every mousedown/up listener (including replay's own),
|
|
592
|
+
// creating empty roots. Meaningful actions can still use manual spans;
|
|
593
|
+
// fetch/xhr and document-load instrumentation keep their real parents.
|
|
594
|
+
interactions: false,
|
|
299
595
|
fetch: {
|
|
300
596
|
propagateTraceHeaderCorsUrls: targets,
|
|
301
597
|
advancedNetworkCapture: () => advancedNetworkCapture,
|
|
302
|
-
applyCustomAttributesOnSpan:
|
|
598
|
+
applyCustomAttributesOnSpan: finishNetworkSpan,
|
|
303
599
|
},
|
|
304
600
|
xhr: {
|
|
305
601
|
propagateTraceHeaderCorsUrls: targets,
|
|
306
602
|
advancedNetworkCapture: () => advancedNetworkCapture,
|
|
307
|
-
applyCustomAttributesOnSpan:
|
|
603
|
+
applyCustomAttributesOnSpan: finishNetworkSpan,
|
|
308
604
|
},
|
|
309
605
|
},
|
|
310
606
|
// Polls for window.Intercom on a timer; nothing here uses Intercom.
|
|
@@ -375,6 +671,18 @@ function clampRate(rate) {
|
|
|
375
671
|
}
|
|
376
672
|
return Math.min(1, Math.max(0, rate));
|
|
377
673
|
}
|
|
674
|
+
/** See "One session per visit" in the header. Never throws. */
|
|
675
|
+
function keepSessionWithoutCookies() {
|
|
676
|
+
try {
|
|
677
|
+
if (typeof document === "undefined") {
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
installSessionCookieFallback(document, window.localStorage ?? null);
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
// localStorage access can throw (blocked storage): keep the SDK default.
|
|
684
|
+
}
|
|
685
|
+
}
|
|
378
686
|
function sessionStorageOrNull() {
|
|
379
687
|
try {
|
|
380
688
|
return window.sessionStorage;
|
|
@@ -391,7 +699,27 @@ export function stripUrlQuery(url) {
|
|
|
391
699
|
return cut === -1 ? url : url.slice(0, cut);
|
|
392
700
|
}
|
|
393
701
|
// applyCustomAttributesOnSpan for both instrumentations: it runs before the
|
|
394
|
-
// span ends,
|
|
702
|
+
// span ends, and after HyperDX's own `HTTP <METHOD>` rename, so the values
|
|
703
|
+
// set here are the ones exported. The span carries the RUM session as
|
|
704
|
+
// `session.id` (the resource's rum.sessionId says the same for the browser;
|
|
705
|
+
// the attribute is what the backend's own spans carry too, so a trace is found
|
|
706
|
+
// by session from either side).
|
|
707
|
+
function finishNetworkSpan(span) {
|
|
708
|
+
stripNetworkQuery(span);
|
|
709
|
+
const url = span.attributes?.["url.full"] ?? span.attributes?.["http.url"];
|
|
710
|
+
const method = span.attributes?.["http.request.method"] ??
|
|
711
|
+
span.attributes?.["http.method"];
|
|
712
|
+
if (typeof url === "string" && typeof span.updateName === "function") {
|
|
713
|
+
const name = spanRouteName(typeof method === "string" ? method : "", url);
|
|
714
|
+
if (name) {
|
|
715
|
+
span.updateName(name);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const session = rumSessionId();
|
|
719
|
+
if (session) {
|
|
720
|
+
span.setAttribute("session.id", session);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
395
723
|
function stripNetworkQuery(span) {
|
|
396
724
|
for (const key of URL_ATTRIBUTES) {
|
|
397
725
|
const value = span.attributes?.[key];
|
|
@@ -403,6 +731,31 @@ function stripNetworkQuery(span) {
|
|
|
403
731
|
}
|
|
404
732
|
}
|
|
405
733
|
}
|
|
734
|
+
// A Connect/gRPC procedure: `/<package>.<Service>/<Method>`, the service and
|
|
735
|
+
// method PascalCase as protobuf declares them. The whole path is the route,
|
|
736
|
+
// fixed by the proto, so it carries no request values.
|
|
737
|
+
const RPC_PATH = /^\/[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\.[A-Z]\w*\/[A-Z]\w*$/;
|
|
738
|
+
/**
|
|
739
|
+
* The name a fetch/XHR span gets: `<METHOD> /<package>.<Service>/<Method>`,
|
|
740
|
+
* the server span's name with the method in front, when the URL is a
|
|
741
|
+
* Connect/gRPC procedure. Empty for any other URL, which keeps the SDK's
|
|
742
|
+
* `HTTP <METHOD>`: an arbitrary path can hold values (ids, tokens, emails) no
|
|
743
|
+
* pattern recognises reliably, and a span name must be neither sensitive nor
|
|
744
|
+
* unbounded. Empty too when `url` cannot be parsed. Exported for tests.
|
|
745
|
+
*/
|
|
746
|
+
export function spanRouteName(method, url) {
|
|
747
|
+
let path;
|
|
748
|
+
try {
|
|
749
|
+
path = new URL(url, "http://base.invalid").pathname;
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
return "";
|
|
753
|
+
}
|
|
754
|
+
if (!RPC_PATH.test(path)) {
|
|
755
|
+
return "";
|
|
756
|
+
}
|
|
757
|
+
return `${method.trim().toUpperCase() || "POST"} ${path}`;
|
|
758
|
+
}
|
|
406
759
|
/**
|
|
407
760
|
* resolveIngestUrl turns the configured endpoint into the absolute URL the SDK
|
|
408
761
|
* is given, or null when telemetry is off. A path resolves against the page
|