@monoscopetech/browser 0.8.0 → 0.9.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/dist/router.d.ts CHANGED
@@ -1,12 +1,12 @@
1
- type EmitFn = (name: string, attrs: Record<string, string | number>) => void;
1
+ type NavFn = (from: string, to: string, method: string) => void;
2
2
  export declare class SPARouter {
3
- private emit;
3
+ private onNavigation;
4
4
  private currentUrl;
5
5
  private _active;
6
6
  private origPushState;
7
7
  private origReplaceState;
8
8
  private popstateHandler;
9
- constructor(emit: EmitFn);
9
+ constructor(onNavigation: NavFn);
10
10
  start(): void;
11
11
  stop(): void;
12
12
  }
package/dist/router.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { addBreadcrumb } from "./breadcrumbs";
2
2
  export class SPARouter {
3
- constructor(emit) {
3
+ constructor(onNavigation) {
4
4
  this.currentUrl = "";
5
5
  this._active = false;
6
6
  this.origPushState = null;
7
7
  this.origReplaceState = null;
8
8
  this.popstateHandler = null;
9
- this.emit = emit;
9
+ this.onNavigation = onNavigation;
10
10
  }
11
11
  start() {
12
12
  if (typeof window === "undefined" || this._active)
@@ -23,12 +23,7 @@ export class SPARouter {
23
23
  return;
24
24
  this.currentUrl = to;
25
25
  addBreadcrumb({ type: "navigation", message: `${from} → ${to}`, data: { method } });
26
- this.emit("navigation", {
27
- "navigation.from": from,
28
- "navigation.to": to,
29
- "navigation.method": method,
30
- "page.title": document.title,
31
- });
26
+ this.onNavigation(from, to, method);
32
27
  }
33
28
  catch (e) {
34
29
  try {
package/dist/tracing.d.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  import { MonoscopeConfig, MonoscopeUser } from "./types";
2
2
  import { Span } from "@opentelemetry/api";
3
+ export type MonoscopeKind = "page_load" | "navigation" | "interaction" | "network" | "resource" | "web_vital" | "error" | "long_task" | "custom";
4
+ export declare function shortPath(url: string): string;
5
+ export declare function describeElement(el: EventTarget | Element | null | undefined): string;
6
+ /**
7
+ * RFC4122 v4 id with a fallback for non-secure contexts (HTTP / file:// /
8
+ * older Safari/Edge) where `crypto.randomUUID` is undefined.
9
+ */
10
+ export declare function newId(): string;
3
11
  export declare class OpenTelemetryManager {
4
12
  private config;
5
13
  private sessionId;
6
14
  private tabId;
15
+ private pageviewId;
7
16
  private provider;
8
17
  private processor;
9
18
  private longTaskObserver;
@@ -11,18 +20,36 @@ export declare class OpenTelemetryManager {
11
20
  private _enabled;
12
21
  private _configured;
13
22
  private _firstExportLogged;
14
- private pageSpan;
15
- private pageContext;
16
- private endPageSpanHandler;
23
+ private routeSpan;
24
+ private routeContext;
25
+ private routeIdleTimer;
26
+ private flushOnHideHandler;
27
+ private visibilityHandler;
17
28
  onExportStatus: ((ok: boolean) => void) | null;
29
+ onSpanStart: (() => void) | null;
18
30
  constructor(config: MonoscopeConfig, sessionId: string, tabId: string);
19
31
  private createProvider;
20
32
  private commonAttrs;
21
33
  private applyCommonAttrs;
22
34
  configure(): void;
23
- private startPageSpan;
24
- getPageContext(): import("@opentelemetry/api").Context | null;
25
- private withPageContext;
35
+ getPageviewId(): string;
36
+ rotatePageview(): string;
37
+ /**
38
+ * Open a short-lived route.change root span for an SPA navigation. Closes
39
+ * any previous route span, rotates pageview.id, and publishes the span as
40
+ * the active context so async work started in the same Zone (fetch/XHR)
41
+ * inherits it as parent. Auto-closes after ROUTE_IDLE_MS or on next nav.
42
+ */
43
+ startRouteChange(from: string, to: string, method: string): void;
44
+ endRouteChange(): void;
45
+ /**
46
+ * Flush pending spans before the JS context is destroyed. Critical for
47
+ * MPAs where every navigation unloads the page, and still valuable for
48
+ * SPAs at tab close. pagehide is preferred over beforeunload (fires for
49
+ * bfcache eviction and mobile backgrounding; beforeunload does not).
50
+ */
51
+ private installFlushOnHide;
52
+ private withActiveContext;
26
53
  emitSpan(name: string, attrs: Record<string, string | number | boolean>, configure?: (span: Span) => void): void;
27
54
  private observeLongTasks;
28
55
  private observeResourceTiming;
package/dist/tracing.js CHANGED
@@ -12,18 +12,71 @@ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
12
12
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
13
13
  import { context, SpanStatusCode, trace } from "@opentelemetry/api";
14
14
  const MONOSCOPE_TRACER = "monoscope";
15
+ const ROUTE_IDLE_MS = 3000;
16
+ // Display-label helpers. These derive a human-readable label ("what happened?")
17
+ // from raw span attributes so trace viewers don't have to reinvent the wheel.
18
+ export function shortPath(url) {
19
+ try {
20
+ const u = new URL(url, typeof location !== "undefined" ? location.href : "http://_");
21
+ return u.pathname + (u.search ? "?…" : "");
22
+ }
23
+ catch {
24
+ return url.slice(0, 80);
25
+ }
26
+ }
27
+ export function describeElement(el) {
28
+ const e = el;
29
+ if (!e || !e.tagName)
30
+ return "?";
31
+ const aria = e.getAttribute?.("aria-label");
32
+ if (aria)
33
+ return aria;
34
+ const text = (e.textContent || "").trim().replace(/\s+/g, " ").slice(0, 40);
35
+ if (text)
36
+ return text;
37
+ const id = e.id ? `#${e.id}` : "";
38
+ const cls = (e.getAttribute?.("class") || "").split(" ").filter(Boolean)[0];
39
+ return `${e.tagName.toLowerCase()}${id}${cls ? `.${cls}` : ""}`;
40
+ }
41
+ /**
42
+ * RFC4122 v4 id with a fallback for non-secure contexts (HTTP / file:// /
43
+ * older Safari/Edge) where `crypto.randomUUID` is undefined.
44
+ */
45
+ export function newId() {
46
+ try {
47
+ if (typeof crypto !== "undefined" && crypto.randomUUID)
48
+ return crypto.randomUUID();
49
+ }
50
+ catch { /* fall through */ }
51
+ const b = new Uint8Array(16);
52
+ try {
53
+ crypto.getRandomValues(b);
54
+ }
55
+ catch {
56
+ for (let i = 0; i < 16; i++)
57
+ b[i] = (Math.random() * 256) | 0;
58
+ }
59
+ b[6] = (b[6] & 0x0f) | 0x40;
60
+ b[8] = (b[8] & 0x3f) | 0x80;
61
+ const h = Array.from(b, x => x.toString(16).padStart(2, "0")).join("");
62
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
63
+ }
15
64
  export class OpenTelemetryManager {
16
65
  constructor(config, sessionId, tabId) {
66
+ this.pageviewId = "";
17
67
  this.processor = null;
18
68
  this.longTaskObserver = null;
19
69
  this.resourceObserver = null;
20
70
  this._enabled = true;
21
71
  this._configured = false;
22
72
  this._firstExportLogged = false;
23
- this.pageSpan = null;
24
- this.pageContext = null;
25
- this.endPageSpanHandler = null;
73
+ this.routeSpan = null;
74
+ this.routeContext = null;
75
+ this.routeIdleTimer = null;
76
+ this.flushOnHideHandler = null;
77
+ this.visibilityHandler = null;
26
78
  this.onExportStatus = null;
79
+ this.onSpanStart = null;
27
80
  this.config = config;
28
81
  this.sessionId = sessionId;
29
82
  this.tabId = tabId;
@@ -66,19 +119,32 @@ export class OpenTelemetryManager {
66
119
  });
67
120
  const processor = new BatchSpanProcessor(wrappedExporter);
68
121
  this.processor = processor;
122
+ // Count every span at start — covers auto-instrumentations, internal
123
+ // emitSpan, and manual APIs uniformly. onEnd would undercount dropped
124
+ // spans; onStart reflects telemetry volume the SDK actually observed.
125
+ const countingProcessor = {
126
+ onStart: () => { try {
127
+ self.onSpanStart?.();
128
+ }
129
+ catch { /* never throw from processor */ } },
130
+ onEnd: () => { },
131
+ shutdown: () => Promise.resolve(),
132
+ forceFlush: () => Promise.resolve(),
133
+ };
69
134
  return new WebTracerProvider({
70
135
  resource: resourceFromAttributes({
71
136
  [ATTR_SERVICE_NAME]: serviceName,
72
137
  "x-api-key": apiKey,
73
138
  ...resourceAttributes,
74
139
  }),
75
- spanProcessors: [processor],
140
+ spanProcessors: [processor, countingProcessor],
76
141
  });
77
142
  }
78
143
  commonAttrs() {
79
144
  const attrs = {
80
145
  "session.id": this.sessionId,
81
146
  "tab.id": this.tabId,
147
+ "pageview.id": this.pageviewId,
82
148
  "page.url": location.href,
83
149
  "page.title": document.title,
84
150
  };
@@ -100,13 +166,29 @@ export class OpenTelemetryManager {
100
166
  if (typeof window === "undefined" || this._configured)
101
167
  return;
102
168
  this._configured = true;
169
+ // Sticky per-tab sampling decision so MPA navigations and SPA reloads don't
170
+ // produce half-traced sessions. sessionStorage is tab-scoped.
103
171
  const rate = Math.max(0, Math.min(1, this.config.sampleRate ?? 1));
104
- if (Math.random() >= rate) {
172
+ let sampled;
173
+ try {
174
+ const cached = sessionStorage.getItem("monoscope-sampled");
175
+ if (cached === "1" || cached === "0")
176
+ sampled = cached === "1";
177
+ else {
178
+ sampled = Math.random() < rate;
179
+ sessionStorage.setItem("monoscope-sampled", sampled ? "1" : "0");
180
+ }
181
+ }
182
+ catch {
183
+ sampled = Math.random() < rate;
184
+ }
185
+ if (!sampled) {
105
186
  this._enabled = false;
106
187
  if (this.config.debug)
107
188
  console.log("MonoscopeOTel: sampled out");
108
189
  return;
109
190
  }
191
+ this.pageviewId = newId();
110
192
  this.provider.register({
111
193
  contextManager: new ZoneContextManager(),
112
194
  propagator: new W3CTraceContextPropagator(),
@@ -118,45 +200,155 @@ export class OpenTelemetryManager {
118
200
  /^https?:\/\/(?:[^\/]+\.)?monoscope\.tech\//,
119
201
  ];
120
202
  const addAttrs = (span) => this.applyCommonAttrs(span);
203
+ const stamp = (span, kind, label) => {
204
+ this.applyCommonAttrs(span);
205
+ span.setAttribute("monoscope.kind", kind);
206
+ if (label)
207
+ span.setAttribute("monoscope.display.label", label);
208
+ };
209
+ // User interactions: default to click/submit only. Tracing every keydown/
210
+ // mouseover would flood the backend; consumers can override via
211
+ // instrumentations config to opt into more.
212
+ const userInteraction = this.config.enableUserInteraction !== false
213
+ ? [new UserInteractionInstrumentation({
214
+ eventNames: ["click", "submit"],
215
+ shouldPreventSpanCreation: (eventType, element, span) => {
216
+ stamp(span, "interaction", `${eventType} · ${describeElement(element)}`);
217
+ return false;
218
+ },
219
+ })]
220
+ : [];
121
221
  registerInstrumentations({
122
222
  tracerProvider: this.provider,
123
223
  instrumentations: [
124
224
  ...(this.config.instrumentations || []),
125
225
  new DocumentLoadInstrumentation({
126
226
  ignoreNetworkEvents: !this.config.enableNetworkEvents,
127
- applyCustomAttributesOnSpan: { documentLoad: addAttrs },
227
+ applyCustomAttributesOnSpan: {
228
+ documentLoad: (span) => stamp(span, "page_load", `Page · ${shortPath(location.href)}`),
229
+ resourceFetch: (span) => stamp(span, "resource"),
230
+ },
128
231
  }),
129
232
  new XMLHttpRequestInstrumentation({
130
- propagateTraceHeaderCorsUrls: headerUrls, ignoreUrls, applyCustomAttributesOnSpan: addAttrs,
233
+ propagateTraceHeaderCorsUrls: headerUrls, ignoreUrls,
234
+ applyCustomAttributesOnSpan: (span, xhr) => {
235
+ const url = xhr.responseURL;
236
+ stamp(span, "network", url ? `XHR · ${shortPath(url)}` : undefined);
237
+ },
131
238
  }),
132
239
  new FetchInstrumentation({
133
- propagateTraceHeaderCorsUrls: headerUrls, ignoreUrls, applyCustomAttributesOnSpan: addAttrs,
240
+ propagateTraceHeaderCorsUrls: headerUrls, ignoreUrls,
241
+ applyCustomAttributesOnSpan: (span, request) => {
242
+ let url;
243
+ let method = "GET";
244
+ if (typeof request === "string")
245
+ url = request;
246
+ else if (request && typeof request.url === "string") {
247
+ url = request.url;
248
+ method = request.method || "GET";
249
+ }
250
+ else if (request && typeof request.method === "string") {
251
+ method = request.method;
252
+ }
253
+ stamp(span, "network", url ? `${method} · ${shortPath(url)}` : undefined);
254
+ },
134
255
  }),
135
- ...(this.config.enableUserInteraction ? [new UserInteractionInstrumentation()] : []),
256
+ ...userInteraction,
136
257
  ],
137
258
  });
138
- this.startPageSpan();
139
- this.observeLongTasks();
140
- this.observeResourceTiming();
259
+ if (this.config.captureLongTasks !== false)
260
+ this.observeLongTasks();
261
+ if (this.config.captureResourceTiming)
262
+ this.observeResourceTiming();
263
+ this.installFlushOnHide();
141
264
  }
142
- startPageSpan() {
143
- const tracer = trace.getTracer(MONOSCOPE_TRACER);
144
- this.pageSpan = tracer.startSpan("browser.session", { attributes: this.commonAttrs() });
145
- this.pageContext = trace.setSpan(context.active(), this.pageSpan);
146
- this.endPageSpanHandler = () => { this.pageSpan?.end(); this.pageSpan = null; };
147
- window.addEventListener("pagehide", this.endPageSpanHandler);
148
- window.addEventListener("beforeunload", this.endPageSpanHandler);
265
+ getPageviewId() { return this.pageviewId; }
266
+ rotatePageview() {
267
+ this.pageviewId = newId();
268
+ return this.pageviewId;
269
+ }
270
+ /**
271
+ * Open a short-lived route.change root span for an SPA navigation. Closes
272
+ * any previous route span, rotates pageview.id, and publishes the span as
273
+ * the active context so async work started in the same Zone (fetch/XHR)
274
+ * inherits it as parent. Auto-closes after ROUTE_IDLE_MS or on next nav.
275
+ */
276
+ startRouteChange(from, to, method) {
277
+ try {
278
+ this.endRouteChange();
279
+ this.rotatePageview();
280
+ const tracer = trace.getTracer(MONOSCOPE_TRACER);
281
+ const span = tracer.startSpan("route.change", {
282
+ attributes: {
283
+ "navigation.from": from,
284
+ "navigation.to": to,
285
+ "navigation.method": method,
286
+ "monoscope.kind": "navigation",
287
+ "monoscope.display.label": `Nav · ${shortPath(from)} → ${shortPath(to)}`,
288
+ },
289
+ });
290
+ this.applyCommonAttrs(span);
291
+ this.routeSpan = span;
292
+ this.routeContext = trace.setSpan(context.active(), span);
293
+ this.routeIdleTimer = setTimeout(() => {
294
+ try {
295
+ this.endRouteChange();
296
+ }
297
+ catch (e) {
298
+ if (this.config.debug)
299
+ console.warn("Monoscope: route idle close failed", e);
300
+ }
301
+ }, ROUTE_IDLE_MS);
302
+ }
303
+ catch (e) {
304
+ if (this.config.debug)
305
+ console.warn("Monoscope: startRouteChange failed", e);
306
+ this.routeSpan = null;
307
+ this.routeContext = null;
308
+ this.routeIdleTimer = null;
309
+ }
310
+ }
311
+ endRouteChange() {
312
+ if (this.routeIdleTimer) {
313
+ clearTimeout(this.routeIdleTimer);
314
+ this.routeIdleTimer = null;
315
+ }
316
+ this.routeSpan?.end();
317
+ this.routeSpan = null;
318
+ this.routeContext = null;
319
+ }
320
+ /**
321
+ * Flush pending spans before the JS context is destroyed. Critical for
322
+ * MPAs where every navigation unloads the page, and still valuable for
323
+ * SPAs at tab close. pagehide is preferred over beforeunload (fires for
324
+ * bfcache eviction and mobile backgrounding; beforeunload does not).
325
+ */
326
+ installFlushOnHide() {
327
+ const flush = () => {
328
+ try {
329
+ this.endRouteChange();
330
+ this.processor?.forceFlush().catch(() => { });
331
+ }
332
+ catch (e) {
333
+ if (this.config.debug)
334
+ console.warn("Monoscope: flush on hide failed", e);
335
+ }
336
+ };
337
+ this.flushOnHideHandler = flush;
338
+ this.visibilityHandler = () => { if (document.visibilityState === "hidden")
339
+ flush(); };
340
+ window.addEventListener("pagehide", this.flushOnHideHandler);
341
+ document.addEventListener("visibilitychange", this.visibilityHandler);
149
342
  }
150
- getPageContext() { return this.pageContext; }
151
- withPageContext(fn) {
152
- if (this.pageContext)
153
- return context.with(this.pageContext, fn);
343
+ withActiveContext(fn) {
344
+ if (this.routeContext)
345
+ return context.with(this.routeContext, fn);
154
346
  return fn();
155
347
  }
156
348
  emitSpan(name, attrs, configure) {
157
349
  try {
158
350
  const tracer = trace.getTracer(MONOSCOPE_TRACER);
159
- this.withPageContext(() => tracer.startActiveSpan(name, (span) => {
351
+ this.withActiveContext(() => tracer.startActiveSpan(name, (span) => {
160
352
  this.applyCommonAttrs(span);
161
353
  for (const [k, v] of Object.entries(attrs))
162
354
  span.setAttribute(k, v);
@@ -181,6 +373,8 @@ export class OpenTelemetryManager {
181
373
  const attrs = {
182
374
  "longtask.duration": entry.duration,
183
375
  "longtask.name": entry.name,
376
+ "monoscope.kind": "long_task",
377
+ "monoscope.display.label": `Long task · ${Math.round(entry.duration)}ms`,
184
378
  };
185
379
  const attr = entry.attribution;
186
380
  if (attr?.[0]?.containerSrc)
@@ -214,12 +408,15 @@ export class OpenTelemetryManager {
214
408
  continue;
215
409
  try {
216
410
  const re = entry;
411
+ const base = re.name.split("?")[0].split("/").pop() || re.name;
217
412
  this.emitSpan("resource", {
218
413
  "resource.name": re.name,
219
414
  "resource.duration": re.duration,
220
415
  "resource.type": re.initiatorType,
221
416
  "resource.transferSize": re.transferSize,
222
417
  "resource.encodedBodySize": re.encodedBodySize,
418
+ "monoscope.kind": "resource",
419
+ "monoscope.display.label": `${re.initiatorType} · ${base}`,
223
420
  });
224
421
  }
225
422
  catch (e) {
@@ -236,7 +433,7 @@ export class OpenTelemetryManager {
236
433
  }
237
434
  startSpan(name, fn) {
238
435
  const tracer = trace.getTracer(MONOSCOPE_TRACER);
239
- return this.withPageContext(() => tracer.startActiveSpan(name, (span) => {
436
+ return this.withActiveContext(() => tracer.startActiveSpan(name, (span) => {
240
437
  this.applyCommonAttrs(span);
241
438
  try {
242
439
  const result = fn(span);
@@ -266,14 +463,15 @@ export class OpenTelemetryManager {
266
463
  async shutdown() {
267
464
  this.longTaskObserver?.disconnect();
268
465
  this.resourceObserver?.disconnect();
269
- if (this.endPageSpanHandler && typeof window !== "undefined") {
270
- this.endPageSpanHandler();
271
- window.removeEventListener("pagehide", this.endPageSpanHandler);
272
- window.removeEventListener("beforeunload", this.endPageSpanHandler);
273
- this.endPageSpanHandler = null;
466
+ this.endRouteChange();
467
+ if (this.flushOnHideHandler) {
468
+ window.removeEventListener("pagehide", this.flushOnHideHandler);
469
+ this.flushOnHideHandler = null;
470
+ }
471
+ if (this.visibilityHandler) {
472
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
473
+ this.visibilityHandler = null;
274
474
  }
275
- this.pageSpan = null;
276
- this.pageContext = null;
277
475
  this._configured = false;
278
476
  await this.provider.shutdown();
279
477
  }
package/dist/types.d.ts CHANGED
@@ -16,6 +16,8 @@ export type MonoscopeConfig = {
16
16
  replaySampleRate?: number;
17
17
  enabled?: boolean;
18
18
  resourceTimingThresholdMs?: number;
19
+ captureResourceTiming?: boolean;
20
+ captureLongTasks?: boolean;
19
21
  enableUserInteraction?: boolean;
20
22
  };
21
23
  export type MonoscopeUser = {
@@ -13,12 +13,17 @@ export class WebVitalsCollector {
13
13
  const report = (m) => {
14
14
  if (!this._enabled)
15
15
  return;
16
+ // CLS is unitless; the others are milliseconds.
17
+ const unit = m.name === "CLS" ? "" : "ms";
18
+ const value = m.name === "CLS" ? m.value.toFixed(3) : Math.round(m.value);
16
19
  this.emit(`web-vital.${m.name}`, {
17
20
  "vital.name": m.name,
18
21
  "vital.value": m.value,
19
22
  "vital.rating": m.rating,
20
23
  "vital.id": m.id,
21
24
  "vital.navigationType": m.navigationType,
25
+ "monoscope.kind": "web_vital",
26
+ "monoscope.display.label": `${m.name} · ${value}${unit} (${m.rating})`,
22
27
  });
23
28
  };
24
29
  [onCLS, onINP, onLCP, onFCP, onTTFB].forEach(fn => fn(report));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monoscopetech/browser",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",