@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.
@@ -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 { registerTelemetryStartup } from "./telemetry-gate.js";
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
- hdx.init(buildSdkOptions({ ...config, replay }, ingest, window.location.origin));
163
- tapSdkInputs();
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
- console.error = function (...args) {
335
+ const tap = function (...args) {
248
336
  for (const arg of args) {
249
- if (arg instanceof Error) {
250
- noteSeen(arg);
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
- return wrapped.apply(this, args);
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
- window.addEventListener("error", (e) => noteSeen(e.error), true);
256
- window.addEventListener("unhandledrejection", (e) => noteSeen(e.reason), true);
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: stripNetworkQuery,
598
+ applyCustomAttributesOnSpan: finishNetworkSpan,
303
599
  },
304
600
  xhr: {
305
601
  propagateTraceHeaderCorsUrls: targets,
306
602
  advancedNetworkCapture: () => advancedNetworkCapture,
307
- applyCustomAttributesOnSpan: stripNetworkQuery,
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, so the rewritten value is the one exported.
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