@debugbundle/sdk-browser 1.3.1 → 1.4.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.
Files changed (40) hide show
  1. package/README.md +27 -0
  2. package/dist/analytics-friction.d.ts +8 -0
  3. package/dist/analytics-friction.d.ts.map +1 -0
  4. package/dist/analytics-friction.js +59 -0
  5. package/dist/analytics-friction.js.map +1 -0
  6. package/dist/analytics-normalization.d.ts +17 -0
  7. package/dist/analytics-normalization.d.ts.map +1 -0
  8. package/dist/analytics-normalization.js +276 -0
  9. package/dist/analytics-normalization.js.map +1 -0
  10. package/dist/analytics.d.ts +41 -0
  11. package/dist/analytics.d.ts.map +1 -0
  12. package/dist/analytics.js +410 -0
  13. package/dist/analytics.js.map +1 -0
  14. package/dist/capture-helpers.d.ts +11 -0
  15. package/dist/capture-helpers.d.ts.map +1 -0
  16. package/dist/capture-helpers.js +156 -0
  17. package/dist/capture-helpers.js.map +1 -0
  18. package/dist/event-pipeline.d.ts +13 -0
  19. package/dist/event-pipeline.d.ts.map +1 -0
  20. package/dist/event-pipeline.js +96 -0
  21. package/dist/event-pipeline.js.map +1 -0
  22. package/dist/event-transport.d.ts +31 -0
  23. package/dist/event-transport.d.ts.map +1 -0
  24. package/dist/event-transport.js +217 -0
  25. package/dist/event-transport.js.map +1 -0
  26. package/dist/index.d.ts +10 -31
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +147 -672
  29. package/dist/index.js.map +1 -1
  30. package/dist/probes.d.ts +39 -0
  31. package/dist/probes.d.ts.map +1 -0
  32. package/dist/probes.js +190 -0
  33. package/dist/probes.js.map +1 -0
  34. package/dist/runtime.d.ts +4 -2
  35. package/dist/runtime.d.ts.map +1 -1
  36. package/dist/runtime.js +52 -0
  37. package/dist/runtime.js.map +1 -1
  38. package/dist/types.d.ts +123 -1
  39. package/dist/types.d.ts.map +1 -1
  40. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,164 +1,22 @@
1
1
  import { redact } from "@debugbundle/redaction";
2
2
  import { createEventEnvelope } from "@debugbundle/shared-types";
3
+ import { BrowserAnalyticsController } from "./analytics.js";
3
4
  import { applyBrowserBeforeSend } from "./before-send.js";
4
- import { evaluateBrowserCaptureRulesForEvent, parseRemoteCaptureRulesPayload } from "./capture-rules.js";
5
+ import { isImmediateRequestIncidentStatus, normalizeUnhandledRejectionReason, shouldCaptureBrowserNetworkRequest, shouldCaptureFailedBrowserNetworkRequest, shouldCaptureRequestStatus } from "./capture-helpers.js";
6
+ import { applyBrowserCaptureRules, buildBrowserSuppressionKey } from "./event-pipeline.js";
5
7
  import { collectDeviceInfo, installConsoleHook, installNetworkHook } from "./hooks.js";
6
8
  import { EventSuppressionTracker } from "./suppression.js";
7
- import { validateBrowserTriggerToken } from "./trigger-token.js";
8
- import { buildSelector, buildBrowserTransportRequestBody, createFetchTransport, deriveSdkConfigEndpoint, getConsoleSource, getDocumentSource, getFetchSource, getHistorySource, getLocationSource, getNavigatorSource, getWindowSource, matchesBrowserPattern, matchesStatusCodeFilter, normalizeBrowserErrorEvent, normalizeBoolean, normalizeError, normalizeLogLevel, normalizeNetworkFilter, normalizePositiveNumber, normalizeSampleRate, normalizeTracePropagationTargets, normalizeUnknownRecord, parseIngestionProbeDirectives, parseRemoteProbeConfigPayload, resolveBrowserTransport, } from "./runtime.js";
9
+ import { BrowserEventTransport } from "./event-transport.js";
10
+ import { BrowserProbeController } from "./probes.js";
11
+ import { buildSelector, createFetchTransport, getConsoleSource, getDocumentSource, getFetchSource, getHistorySource, getLocationSource, getWindowSource, createBrowserTraceId, normalizeBrowserErrorEvent, normalizeBoolean, normalizeError, normalizeLogLevel, normalizeNetworkFilter, normalizePositiveNumber, normalizeSampleRate, normalizeTracePropagationTargets, normalizeUnknownRecord, resolveBrowserTransport, } from "./runtime.js";
9
12
  import { DEFAULT_BATCH_SIZE, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_LOG_LEVEL, DEFAULT_MAX_BREADCRUMBS, DEFAULT_MAX_EVENTS_PER_SESSION, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_SAMPLE_RATE, DEFAULT_SESSION_SAMPLE_RATE, LOG_LEVEL_ORDER, SDK_NAME, SDK_SCHEMA_VERSION, SDK_VERSION } from "./types.js";
10
- const DEFAULT_REQUEST_FAILURE_PRESET = "balanced";
11
- const DEFAULT_REQUEST_CAPTURE_EVENTS = "failures_only";
12
- const DEFAULT_IMMEDIATE_CLIENT_ERROR_STATUSES = [];
13
- const MAX_REJECTION_REASON_PREVIEW_LENGTH = 500;
14
- function createInitialRemoteProbeState() {
15
- return {
16
- probesEnabled: false,
17
- remoteProbesEnabled: false,
18
- directives: [],
19
- triggerTokenKey: null,
20
- requestFailurePreset: DEFAULT_REQUEST_FAILURE_PRESET,
21
- requestCaptureEvents: DEFAULT_REQUEST_CAPTURE_EVENTS,
22
- immediateClientErrorStatuses: [...DEFAULT_IMMEDIATE_CLIENT_ERROR_STATUSES],
23
- immediateClientErrorPathRules: []
24
- };
25
- }
26
- function truncateRejectionReasonPreview(value) {
27
- return value.length > MAX_REJECTION_REASON_PREVIEW_LENGTH
28
- ? `${value.slice(0, MAX_REJECTION_REASON_PREVIEW_LENGTH)}[truncated]`
29
- : value;
30
- }
31
- function readReasonStringField(record, key) {
32
- const value = record[key];
33
- return typeof value === "string" && value.trim().length > 0
34
- ? truncateRejectionReasonPreview(value.trim())
35
- : undefined;
36
- }
37
- function normalizeUnhandledRejectionReason(reason) {
38
- if (reason instanceof Error) {
39
- return {
40
- error: reason,
41
- rejectionReason: {
42
- kind: "error",
43
- name: reason.name || "Error",
44
- message: truncateRejectionReasonPreview(reason.message || "Unknown rejection error")
45
- }
46
- };
47
- }
48
- if (typeof reason === "string") {
49
- const preview = truncateRejectionReasonPreview(reason.length > 0 ? reason : "[empty string]");
50
- return {
51
- error: new Error(reason.length > 0 ? reason : "Unhandled promise rejection"),
52
- rejectionReason: { kind: "string", preview }
53
- };
54
- }
55
- if (reason === null) {
56
- return {
57
- error: new Error("Unhandled promise rejection: null"),
58
- rejectionReason: { kind: "null", preview: "null" }
59
- };
60
- }
61
- if (reason === undefined) {
62
- return {
63
- error: new Error("Unhandled promise rejection: undefined"),
64
- rejectionReason: { kind: "undefined", preview: "undefined" }
65
- };
66
- }
67
- const record = normalizeUnknownRecord(reason);
68
- const name = readReasonStringField(record, "name");
69
- const message = readReasonStringField(record, "message");
70
- const constructorName = typeof reason === "object" && reason !== null && "constructor" in reason
71
- ? reason.constructor?.name
72
- : undefined;
73
- const preview = typeof constructorName === "string" && constructorName.length > 0 ? constructorName : "object";
74
- return {
75
- error: new Error(message ?? "Unhandled promise rejection"),
76
- rejectionReason: {
77
- kind: "object",
78
- ...(name === undefined ? {} : { name }),
79
- ...(message === undefined ? {} : { message }),
80
- preview
81
- }
82
- };
83
- }
84
- const BALANCED_IMMEDIATE_REQUEST_STATUSES = new Set([408, 423, 424, 425, 429]);
85
- const INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES = new Set([...BALANCED_IMMEDIATE_REQUEST_STATUSES, 409]);
86
- function isImmediateRequestIncidentStatus(statusCode, preset, immediateClientErrorStatuses = [], requestPath, httpMethod, immediateClientErrorPathRules = []) {
87
- if (!Number.isFinite(statusCode)) {
88
- return false;
89
- }
90
- if (statusCode >= 500) {
91
- return true;
92
- }
93
- if (immediateClientErrorStatuses.includes(statusCode)) {
94
- return true;
95
- }
96
- if (matchesImmediateClientErrorPathRule(statusCode, requestPath, httpMethod, immediateClientErrorPathRules)) {
97
- return true;
98
- }
99
- if (preset === "investigative") {
100
- return INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES.has(statusCode);
101
- }
102
- if (preset === "balanced") {
103
- return BALANCED_IMMEDIATE_REQUEST_STATUSES.has(statusCode);
104
- }
105
- return false;
106
- }
107
- function matchesImmediateClientErrorPathRule(statusCode, requestPath, httpMethod, rules) {
108
- if (statusCode < 400 || statusCode > 499 || requestPath === undefined) {
109
- return false;
110
- }
111
- const normalizedPath = normalizeRequestPath(requestPath);
112
- const normalizedMethod = typeof httpMethod === "string" ? httpMethod.toUpperCase() : null;
113
- return rules.some((rule) => {
114
- if (rule.statusCode !== statusCode) {
115
- return false;
116
- }
117
- if (rule.methods.length > 0 && (normalizedMethod === null || !rule.methods.includes(normalizedMethod))) {
118
- return false;
119
- }
120
- if (rule.pathPattern.endsWith("*")) {
121
- return normalizedPath.startsWith(rule.pathPattern.slice(0, -1));
122
- }
123
- return normalizedPath === rule.pathPattern;
124
- });
125
- }
126
- function normalizeRequestPath(value) {
127
- try {
128
- return new URL(value, getLocationSource()?.href ?? "https://debugbundle.local").pathname || "/";
129
- }
130
- catch {
131
- const queryIndex = value.indexOf("?");
132
- const fragmentIndex = value.indexOf("#");
133
- const end = queryIndex === -1 ? (fragmentIndex === -1 ? value.length : fragmentIndex) : fragmentIndex === -1 ? queryIndex : Math.min(queryIndex, fragmentIndex);
134
- const path = value.slice(0, end);
135
- return path.startsWith("/") && path.length > 0 ? path : "/";
136
- }
137
- }
138
- function shouldCaptureRequestStatus(statusCode, preset, policy, immediateClientErrorStatuses = [], requestPath, httpMethod, immediateClientErrorPathRules = []) {
139
- if (isImmediateRequestIncidentStatus(statusCode, preset, immediateClientErrorStatuses, requestPath, httpMethod, immediateClientErrorPathRules)) {
140
- return true;
141
- }
142
- if (policy === "all") {
143
- return Number.isFinite(statusCode) && statusCode >= 400;
144
- }
145
- if (policy === "failures_only") {
146
- return statusCode >= 500;
147
- }
148
- return false;
149
- }
150
13
  export class BrowserSdk {
151
14
  config = null;
152
- bufferedEvents = [];
153
15
  breadcrumbs = [];
154
16
  persistentContext = {};
155
17
  deviceInfo = null;
156
- flushPromise = null;
157
- flushTimer = null;
158
- nextRetryAt = null;
159
- _lastEventAt = null;
160
- _consecutiveFailures = 0;
161
- authRejected = false;
18
+ browserSessionId = null;
19
+ analyticsInitialization = null;
162
20
  registeredListeners = [];
163
21
  originalPushState = null;
164
22
  originalReplaceState = null;
@@ -168,28 +26,36 @@ export class BrowserSdk {
168
26
  originalConsoleWarn = null;
169
27
  sessionSampledIn = true;
170
28
  sessionEventCount = 0;
171
- probeBuffers = new Map();
172
29
  suppressionTracker = new EventSuppressionTracker();
173
- remoteProbeState = createInitialRemoteProbeState();
174
- pendingTriggerToken = null;
175
- activeTriggerDirective = null;
176
- get status() {
177
- if (this.config === null) {
178
- return "disconnected";
179
- }
180
- if (this.authRejected) {
181
- return "disconnected";
182
- }
183
- if (this._consecutiveFailures >= 3) {
184
- return "disconnected";
185
- }
186
- if (this.nextRetryAt !== null) {
187
- return "degraded";
30
+ probeController = new BrowserProbeController({
31
+ getConfig: () => this.config,
32
+ isDebugRejected: () => this.eventTransport.debugRejected,
33
+ isSessionSampledIn: () => this.sessionSampledIn,
34
+ emitProbeEvent: ({ label, data, directive }) => this.emitProbeEvent(label, data, directive),
35
+ applyRemoteAnalytics: (config) => this.analyticsController.applyRemoteSettings(config)
36
+ });
37
+ eventTransport = new BrowserEventTransport({
38
+ onDebugResponse: (payload) => this.probeController.updateFromIngestionResponse(payload),
39
+ onUnauthorized: (lane, statusCode, endpoint, body) => {
40
+ this.reportUnauthorizedTransportFailure(lane, statusCode, endpoint, body);
188
41
  }
189
- return "healthy";
42
+ });
43
+ analyticsController = new BrowserAnalyticsController({
44
+ getConfig: () => this.config,
45
+ getDeviceInfo: () => this.deviceInfo,
46
+ getCurrentRoute: () => this.getCurrentRoute(),
47
+ getSessionId: () => this.browserSessionId ?? createBrowserTraceId(),
48
+ enqueue: (event) => this.enqueueAnalyticsEvent(event)
49
+ });
50
+ analytics = this.analyticsController.api;
51
+ get remoteProbeState() {
52
+ return this.probeController.state;
53
+ }
54
+ get status() {
55
+ return this.eventTransport.status;
190
56
  }
191
57
  get lastEventAt() {
192
- return this._lastEventAt;
58
+ return this.eventTransport.lastEventAt;
193
59
  }
194
60
  init(config) {
195
61
  this.dispose();
@@ -227,19 +93,38 @@ export class BrowserSdk {
227
93
  maxProbeEntriesPerLabel: normalizePositiveNumber(config.maxProbeEntriesPerLabel, 10),
228
94
  probeFlushOnError: normalizeBoolean(config.probeFlushOnError, true),
229
95
  requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS),
96
+ requestsAnalyticsConfig: config.analytics?.enabled === true,
230
97
  captureRules: [],
231
98
  fetchImpl: getFetchSource(),
232
99
  transport: config.transport ?? createFetchTransport(),
233
100
  transportMode: resolvedTransport.mode,
234
101
  ...(config.beforeSend === undefined ? {} : { beforeSend: config.beforeSend })
235
102
  };
236
- this.authRejected = false;
103
+ this.eventTransport.configure(this.config);
237
104
  this.sessionSampledIn = this.config.sessionSampleRate >= 1 || Math.random() < this.config.sessionSampleRate;
238
105
  this.sessionEventCount = 0;
106
+ this.browserSessionId = createBrowserTraceId();
239
107
  this.deviceInfo = collectDeviceInfo();
240
- this.pendingTriggerToken = this.consumeTriggerTokenFromLocation();
241
- void this.refreshRemoteProbeConfig();
108
+ const deferAnalyticsCapture = this.config.requestsAnalyticsConfig &&
109
+ this.config.transportMode === "direct" &&
110
+ this.config.projectToken !== null &&
111
+ this.config.fetchImpl !== null;
112
+ this.analyticsController.configure(config.analytics, { deferCapture: deferAnalyticsCapture });
113
+ const remoteInitialization = this.probeController.initialize();
242
114
  this.installBrowserHooks();
115
+ if (deferAnalyticsCapture) {
116
+ const activeConfig = this.config;
117
+ this.analyticsInitialization = remoteInitialization.finally(() => {
118
+ if (this.config !== activeConfig) {
119
+ return;
120
+ }
121
+ this.analyticsController.markCaptureReady();
122
+ });
123
+ }
124
+ else {
125
+ this.analyticsController.captureSessionStart();
126
+ this.analyticsController.captureInitialPageView();
127
+ }
243
128
  }
244
129
  captureException(error, context = {}) {
245
130
  const config = this.config;
@@ -251,7 +136,9 @@ export class BrowserSdk {
251
136
  const device = this.deviceInfo;
252
137
  const browser = device?.browser ?? { name: "Unknown", version: "0" };
253
138
  const breadcrumbs = this.consumeBreadcrumbs();
254
- const probeData = config.probeFlushOnError ? this.consumeProbeData() : { version: 1, items: [] };
139
+ const probeData = config.probeFlushOnError
140
+ ? this.probeController.consumeBufferedData()
141
+ : { version: 1, items: [] };
255
142
  const domContext = typeof context.target?.outerHTML === "string" && context.target.outerHTML.length > 0
256
143
  ? {
257
144
  mode: "lightweight",
@@ -374,133 +261,26 @@ export class BrowserSdk {
374
261
  this.persistentContext[key] = redacted[key] ?? null;
375
262
  }
376
263
  probe(label, data) {
377
- const config = this.config;
378
- const normalizedLabel = label.trim();
379
- if (config === null || normalizedLabel.length === 0) {
380
- return;
381
- }
382
- try {
383
- const redacted = redact(this.normalizeProbeInput(data), {
384
- sensitiveKeys: config.redactFields
385
- }).redacted;
386
- const probeData = normalizeUnknownRecord(redacted);
387
- this.bufferProbe(normalizedLabel, probeData);
388
- const matchingDirectives = this.getMatchingRemoteProbeDirectives(normalizedLabel, Date.now());
389
- if (!this.sessionSampledIn || matchingDirectives.length === 0) {
390
- return;
391
- }
392
- for (const directive of matchingDirectives) {
393
- this.enqueueEvent(this.createSdkEventEnvelope(config, {
394
- schema_version: SDK_SCHEMA_VERSION,
395
- event_type: "probe_event",
396
- ...this.getProjectTokenFields(config),
397
- sdk_name: SDK_NAME,
398
- sdk_version: SDK_VERSION,
399
- service: {
400
- name: config.service,
401
- runtime: "browser",
402
- framework: null,
403
- environment: config.environment
404
- },
405
- occurred_at: new Date().toISOString(),
406
- correlation: this.createCorrelation(),
407
- payload: {
408
- label: normalizedLabel,
409
- data: probeData,
410
- activation_id: directive.activationId,
411
- probe_label_pattern: directive.labelPattern
412
- }
413
- }), false);
414
- }
415
- }
416
- catch {
417
- return;
418
- }
264
+ this.probeController.capture(label, data);
419
265
  }
420
266
  async flush() {
421
- const config = this.config;
422
- if (config === null) {
423
- return;
424
- }
267
+ await this.analyticsInitialization;
425
268
  this.enqueueSuppressionAggregates();
426
- if (this.bufferedEvents.length === 0) {
427
- return;
428
- }
429
- if (this.flushPromise !== null) {
430
- return this.flushPromise;
431
- }
432
- if (this.nextRetryAt !== null && Date.now() < this.nextRetryAt) {
433
- return;
434
- }
435
- if (this.authRejected) {
436
- return;
437
- }
438
- this.clearFlushTimer();
439
- const events = [...this.bufferedEvents];
440
- this.flushPromise = (async () => {
441
- try {
442
- const response = await config.transport({
443
- endpoint: config.endpoint,
444
- headers: this.getTransportHeaders(config),
445
- events,
446
- transportMode: config.transportMode,
447
- timeout_ms: config.requestTimeoutMs
448
- });
449
- if (response.status >= 200 && response.status < 300) {
450
- this.updateRemoteProbeStateFromIngestionResponse(response.body);
451
- this.nextRetryAt = null;
452
- this._lastEventAt = Date.now();
453
- this._consecutiveFailures = 0;
454
- if (this.bufferedEvents === events || this.sameLeadingEvents(events)) {
455
- this.bufferedEvents.splice(0, events.length);
456
- }
457
- return;
458
- }
459
- this._consecutiveFailures++;
460
- if (response.status === 401 || response.status === 403) {
461
- this.authRejected = true;
462
- this.nextRetryAt = null;
463
- this.reportUnauthorizedTransportFailure(response.status, config.endpoint, response.body);
464
- this.bufferedEvents = [];
465
- return;
466
- }
467
- if (response.status === 429) {
468
- this.nextRetryAt = Date.now() + (response.retry_after_ms ?? 1_000);
469
- }
470
- }
471
- catch {
472
- this._consecutiveFailures++;
473
- return;
474
- }
475
- finally {
476
- this.flushPromise = null;
477
- if (this.bufferedEvents.length > 0) {
478
- const retryDelay = this.nextRetryAt === null ? undefined : Math.max(0, this.nextRetryAt - Date.now());
479
- this.scheduleFlush(retryDelay);
480
- }
481
- }
482
- })();
483
- return this.flushPromise;
269
+ await this.eventTransport.flush();
484
270
  }
485
271
  dispose() {
486
- this.clearFlushTimer();
487
- this.flushPromise = null;
488
- this.bufferedEvents = [];
272
+ this.eventTransport.reset();
489
273
  this.breadcrumbs = [];
490
- this.probeBuffers = new Map();
491
274
  this.persistentContext = {};
492
275
  this.deviceInfo = null;
276
+ this.browserSessionId = null;
277
+ this.analyticsInitialization = null;
493
278
  this.config = null;
494
279
  this.sessionSampledIn = true;
495
280
  this.sessionEventCount = 0;
496
- this.nextRetryAt = null;
497
- this._lastEventAt = null;
498
- this._consecutiveFailures = 0;
499
- this.authRejected = false;
500
281
  this.suppressionTracker.reset();
501
- this.remoteProbeState = createInitialRemoteProbeState();
502
- this.pendingTriggerToken = null;
503
- this.activeTriggerDirective = null;
282
+ this.probeController.reset();
283
+ this.analyticsController.reset();
504
284
  while (this.registeredListeners.length > 0) {
505
285
  this.registeredListeners.pop()?.();
506
286
  }
@@ -531,7 +311,7 @@ export class BrowserSdk {
531
311
  this.originalXmlHttpRequest = null;
532
312
  }
533
313
  }
534
- reportUnauthorizedTransportFailure(statusCode, endpoint, body) {
314
+ reportUnauthorizedTransportFailure(lane, statusCode, endpoint, body) {
535
315
  const consoleSource = getConsoleSource();
536
316
  if (consoleSource === null) {
537
317
  return;
@@ -539,7 +319,8 @@ export class BrowserSdk {
539
319
  const bodyRecord = normalizeUnknownRecord(body);
540
320
  const errorCode = typeof bodyRecord["error"] === "string" && bodyRecord["error"].length > 0 ? bodyRecord["error"] : null;
541
321
  const detail = errorCode === null ? "" : ` (${errorCode})`;
542
- const message = `DebugBundle browser SDK disabled after ingestion returned ${statusCode} for ${endpoint}. ` +
322
+ const laneLabel = lane === "debug" ? "browser SDK" : "browser analytics";
323
+ const message = `DebugBundle ${laneLabel} disabled after ingestion returned ${statusCode} for ${endpoint}. ` +
543
324
  `Check the project token or relay configuration${detail}.`;
544
325
  if (typeof consoleSource.error === "function") {
545
326
  consoleSource.error(message);
@@ -550,7 +331,10 @@ export class BrowserSdk {
550
331
  installBrowserHooks() {
551
332
  const windowSource = getWindowSource();
552
333
  if (windowSource !== null) {
553
- const onPageHide = () => {
334
+ const onPageHide = (event) => {
335
+ if (normalizeUnknownRecord(event)["persisted"] !== true) {
336
+ this.analyticsController.captureSessionSummary();
337
+ }
554
338
  this.flushViaBeacon();
555
339
  };
556
340
  const onError = (event) => {
@@ -578,21 +362,32 @@ export class BrowserSdk {
578
362
  const documentSource = getDocumentSource();
579
363
  if (documentSource !== null) {
580
364
  const onClick = (event) => {
581
- if (this.config?.captureClicks !== true) {
582
- return;
583
- }
584
- const target = normalizeUnknownRecord(normalizeUnknownRecord(event)["target"]);
585
- const selector = buildSelector(target);
586
- if (selector === null) {
365
+ const captureDebugClick = this.config?.captureClicks === true;
366
+ const captureAnalyticsAction = this.analyticsController.shouldCaptureStructuralActions();
367
+ const captureAnalyticsFriction = this.analyticsController.shouldCaptureFrictionSignals();
368
+ if (!captureDebugClick && !captureAnalyticsAction && !captureAnalyticsFriction) {
587
369
  return;
588
370
  }
589
- this.addBreadcrumb({
590
- ts: new Date().toISOString(),
591
- breadcrumb_type: "click",
592
- data: {
593
- selector
371
+ const targetIdentity = normalizeUnknownRecord(event)["target"];
372
+ const target = normalizeUnknownRecord(targetIdentity);
373
+ if (captureDebugClick) {
374
+ const selector = buildSelector(target);
375
+ if (selector !== null) {
376
+ this.addBreadcrumb({
377
+ ts: new Date().toISOString(),
378
+ breadcrumb_type: "click",
379
+ data: {
380
+ selector
381
+ }
382
+ });
594
383
  }
595
- });
384
+ }
385
+ if (captureAnalyticsAction) {
386
+ this.analyticsController.captureStructuralAction(target);
387
+ }
388
+ if (captureAnalyticsFriction) {
389
+ this.analyticsController.captureFrictionClick(target, targetIdentity);
390
+ }
596
391
  };
597
392
  const onSubmit = (event) => {
598
393
  const target = normalizeUnknownRecord(normalizeUnknownRecord(event)["target"]);
@@ -644,7 +439,7 @@ export class BrowserSdk {
644
439
  this.addBreadcrumb(breadcrumb);
645
440
  }, (breadcrumb) => {
646
441
  this.captureNetworkRequestFailure(breadcrumb);
647
- }, (url, statusCode, durationMs) => this.shouldCaptureNetworkRequest(url, statusCode, durationMs), (url, durationMs) => this.shouldCaptureFailedNetworkRequest(url, durationMs), () => this.getCurrentRoute());
442
+ }, (url, statusCode, durationMs) => shouldCaptureBrowserNetworkRequest(this.config, url, statusCode, durationMs), (url, durationMs) => shouldCaptureFailedBrowserNetworkRequest(this.config, url, durationMs), () => this.getCurrentRoute());
648
443
  this.originalFetch = networkHooks.originalFetch;
649
444
  this.originalXmlHttpRequest = networkHooks.originalXmlHttpRequest;
650
445
  }
@@ -652,13 +447,13 @@ export class BrowserSdk {
652
447
  return {
653
448
  request_id: null,
654
449
  trace_id: null,
655
- session_id: null,
450
+ session_id: this.browserSessionId,
656
451
  user_id_hash: null
657
452
  };
658
453
  }
659
454
  addBreadcrumb(breadcrumb) {
660
455
  const config = this.config;
661
- if (config === null || this.authRejected || !this.shouldCaptureBreadcrumb()) {
456
+ if (config === null || this.eventTransport.debugRejected || !this.shouldCaptureBreadcrumb()) {
662
457
  return;
663
458
  }
664
459
  if (config.breadcrumbsOnErrorOnly !== true) {
@@ -671,34 +466,6 @@ export class BrowserSdk {
671
466
  this.breadcrumbs.shift();
672
467
  }
673
468
  }
674
- bufferProbe(label, data) {
675
- const config = this.config;
676
- if (config === null || this.authRejected) {
677
- return;
678
- }
679
- if (!this.probeBuffers.has(label) && this.probeBuffers.size >= config.maxProbeLabels) {
680
- return;
681
- }
682
- const buffer = this.probeBuffers.get(label) ?? [];
683
- buffer.push({
684
- label,
685
- data,
686
- timestamp: new Date().toISOString(),
687
- activation_id: null
688
- });
689
- while (buffer.length > config.maxProbeEntriesPerLabel) {
690
- buffer.shift();
691
- }
692
- this.probeBuffers.set(label, buffer);
693
- }
694
- consumeProbeData() {
695
- const items = Array.from(this.probeBuffers.values()).flatMap((buffer) => buffer);
696
- this.probeBuffers.clear();
697
- return {
698
- version: 1,
699
- items
700
- };
701
- }
702
469
  consumeBreadcrumbs() {
703
470
  const breadcrumbs = [...this.breadcrumbs];
704
471
  this.breadcrumbs = [];
@@ -724,6 +491,7 @@ export class BrowserSdk {
724
491
  route
725
492
  }
726
493
  });
494
+ this.analyticsController.captureRouteChange(route);
727
495
  }
728
496
  createBreadcrumbEvent(breadcrumb) {
729
497
  const config = this.config;
@@ -751,6 +519,33 @@ export class BrowserSdk {
751
519
  }
752
520
  });
753
521
  }
522
+ emitProbeEvent(label, data, directive) {
523
+ const config = this.config;
524
+ if (config === null) {
525
+ return;
526
+ }
527
+ this.enqueueEvent(this.createSdkEventEnvelope(config, {
528
+ schema_version: SDK_SCHEMA_VERSION,
529
+ event_type: "probe_event",
530
+ ...this.getProjectTokenFields(config),
531
+ sdk_name: SDK_NAME,
532
+ sdk_version: SDK_VERSION,
533
+ service: {
534
+ name: config.service,
535
+ runtime: "browser",
536
+ framework: null,
537
+ environment: config.environment
538
+ },
539
+ occurred_at: new Date().toISOString(),
540
+ correlation: this.createCorrelation(),
541
+ payload: {
542
+ label,
543
+ data,
544
+ activation_id: directive.activationId,
545
+ probe_label_pattern: directive.labelPattern
546
+ }
547
+ }), false);
548
+ }
754
549
  captureNetworkRequestFailure(breadcrumb) {
755
550
  const config = this.config;
756
551
  if (config === null || breadcrumb.breadcrumb_type !== "network_request") {
@@ -822,135 +617,48 @@ export class BrowserSdk {
822
617
  if (beforeSendEvent === null) {
823
618
  return;
824
619
  }
825
- const resolvedEvent = this.applyCaptureRulesToEvent(beforeSendEvent);
620
+ const captureRuleResult = applyBrowserCaptureRules({
621
+ config: this.config,
622
+ event: beforeSendEvent,
623
+ currentRoute: this.getCurrentRoute(),
624
+ now: new Date().toISOString()
625
+ });
626
+ if (captureRuleResult.breadcrumb !== null) {
627
+ this.addBreadcrumb(captureRuleResult.breadcrumb);
628
+ }
629
+ const resolvedEvent = captureRuleResult.event;
826
630
  if (resolvedEvent === null) {
827
631
  return;
828
632
  }
829
633
  if (!this.shouldCaptureBySampleRate(resolvedEvent)) {
830
634
  return;
831
635
  }
832
- const suppressionKey = this.buildSuppressionKey(resolvedEvent);
636
+ const suppressionKey = buildBrowserSuppressionKey(resolvedEvent);
833
637
  if (suppressionKey !== null && !this.suppressionTracker.shouldCapture(suppressionKey, Date.now())) {
834
- this.scheduleFlush();
638
+ this.eventTransport.scheduleDebug();
835
639
  return;
836
640
  }
837
641
  this.enqueueInternalEvent(resolvedEvent, countTowardSession, false);
838
642
  }
839
- applyCaptureRulesToEvent(event) {
840
- const config = this.config;
841
- if (config === null || config.captureRules.length === 0) {
842
- return event;
843
- }
844
- const projectId = config.captureRules[0]?.project_id;
845
- if (typeof projectId !== "string" || projectId.length === 0) {
846
- return event;
847
- }
848
- try {
849
- const captureRule = evaluateBrowserCaptureRulesForEvent(config.captureRules, projectId, event, new Date().toISOString());
850
- if (captureRule === null) {
851
- return event;
852
- }
853
- if (captureRule.outcome === "drop" || captureRule.outcome === "sampled_out") {
854
- return null;
855
- }
856
- if (event.event_type === "frontend_exception" &&
857
- (captureRule.outcome === "demote" || captureRule.sample_event_class === "context")) {
858
- this.addBreadcrumb(this.createDemotedExceptionBreadcrumb(event, captureRule));
859
- return null;
860
- }
861
- if (event.event_type === "request_event" &&
862
- (captureRule.outcome === "demote" || captureRule.sample_event_class === "context")) {
863
- return null;
864
- }
865
- }
866
- catch {
867
- return event;
868
- }
869
- return event;
870
- }
871
- createDemotedExceptionBreadcrumb(event, captureRule) {
872
- const payload = event.payload;
873
- const browserEventRecord = typeof payload["browser_event"] === "object" && payload["browser_event"] !== null
874
- ? payload["browser_event"]
875
- : null;
876
- const targetRecord = typeof browserEventRecord?.["target"] === "object" && browserEventRecord["target"] !== null
877
- ? browserEventRecord["target"]
878
- : null;
879
- const browserEventKind = browserEventRecord?.["kind"] === "window_error" || browserEventRecord?.["kind"] === "resource_error"
880
- ? browserEventRecord["kind"]
881
- : undefined;
882
- const sourceUrl = typeof targetRecord?.["source_url"] === "string"
883
- ? targetRecord["source_url"]
884
- : typeof browserEventRecord?.["file_name"] === "string"
885
- ? browserEventRecord["file_name"]
886
- : null;
887
- return {
888
- ts: event.occurred_at,
889
- breadcrumb_type: "console_log",
890
- route: event.payload.route ?? this.getCurrentRoute(),
891
- data: {
892
- level: "error",
893
- message: `${event.payload.name}: ${event.payload.message}`,
894
- source: "capture_rule_demoted_exception",
895
- capture_rule_action: captureRule.action,
896
- capture_rule_outcome: captureRule.outcome,
897
- ...(browserEventKind === undefined ? {} : { browser_event_kind: browserEventKind }),
898
- ...(sourceUrl === null ? {} : { source_url: sourceUrl })
899
- }
900
- };
643
+ enqueueAnalyticsEvent(event) {
644
+ this.eventTransport.enqueueAnalytics(event);
901
645
  }
902
646
  enqueueInternalEvent(event, countTowardSession = true, applyBeforeSend = true) {
903
647
  const config = this.config;
904
- if (config === null || this.authRejected) {
648
+ if (config === null || this.eventTransport.debugRejected) {
905
649
  return;
906
650
  }
907
- if (applyBeforeSend) {
651
+ if (applyBeforeSend && event.event_type !== "analytics_event") {
908
652
  const beforeSendEvent = applyBrowserBeforeSend(event, config.beforeSend);
909
653
  if (beforeSendEvent === null) {
910
654
  return;
911
655
  }
912
656
  event = beforeSendEvent;
913
657
  }
914
- this.bufferedEvents.push(event);
658
+ this.eventTransport.enqueueDebug(event);
915
659
  if (countTowardSession && event.event_type !== "frontend_exception") {
916
660
  this.sessionEventCount += 1;
917
661
  }
918
- if (this.bufferedEvents.length >= config.batchSize) {
919
- queueMicrotask(() => {
920
- void this.flush();
921
- });
922
- return;
923
- }
924
- this.scheduleFlush();
925
- }
926
- buildSuppressionKey(event) {
927
- if (event.event_type === "frontend_exception") {
928
- const stackFrame = event.payload.stack.split("\n")[1]?.trim() ?? null;
929
- return JSON.stringify({
930
- event_type: event.event_type,
931
- name: event.payload.name,
932
- message: event.payload.message,
933
- stack_frame: stackFrame,
934
- route: event.payload.route
935
- });
936
- }
937
- if (event.event_type === "log_event") {
938
- return JSON.stringify({
939
- event_type: event.event_type,
940
- level: event.payload.level,
941
- message: event.payload.message,
942
- attributes: event.payload.attributes
943
- });
944
- }
945
- if (event.event_type === "request_event") {
946
- return JSON.stringify({
947
- event_type: event.event_type,
948
- method: event.payload.method,
949
- path: event.payload.path,
950
- response_status: event.payload.response_status
951
- });
952
- }
953
- return null;
954
662
  }
955
663
  shouldCaptureBySampleRate(event) {
956
664
  const config = this.config;
@@ -965,78 +673,9 @@ export class BrowserSdk {
965
673
  }
966
674
  return config.sampleRate >= 1 || Math.random() <= config.sampleRate;
967
675
  }
968
- scheduleFlush(delayMs) {
969
- const config = this.config;
970
- if (config === null) {
971
- return;
972
- }
973
- if (this.flushTimer !== null) {
974
- clearTimeout(this.flushTimer);
975
- this.flushTimer = null;
976
- }
977
- this.flushTimer = setTimeout(() => {
978
- this.flushTimer = null;
979
- void this.flush();
980
- }, delayMs ?? config.flushInterval);
981
- }
982
- clearFlushTimer() {
983
- if (this.flushTimer !== null) {
984
- clearTimeout(this.flushTimer);
985
- this.flushTimer = null;
986
- }
987
- }
988
676
  flushViaBeacon() {
989
- const config = this.config;
990
- const navigatorSource = getNavigatorSource();
991
- if (config === null || this.bufferedEvents.length === 0 || navigatorSource === null) {
992
- return;
993
- }
994
- const pendingEvents = [...this.bufferedEvents];
995
- const body = buildBrowserTransportRequestBody(config.transportMode, pendingEvents);
996
- const flushViaKeepalive = () => {
997
- if (config.fetchImpl === null) {
998
- void this.flush();
999
- return;
1000
- }
1001
- void config
1002
- .fetchImpl(config.endpoint, {
1003
- method: "POST",
1004
- headers: this.getTransportHeaders(config),
1005
- body,
1006
- keepalive: true
1007
- })
1008
- .then(() => {
1009
- if (this.bufferedEvents === pendingEvents || this.sameLeadingEvents(pendingEvents)) {
1010
- this.bufferedEvents.splice(0, pendingEvents.length);
1011
- }
1012
- this.nextRetryAt = null;
1013
- this.clearFlushTimer();
1014
- })
1015
- .catch(() => {
1016
- return;
1017
- });
1018
- };
1019
- if (typeof navigatorSource.sendBeacon !== "function") {
1020
- flushViaKeepalive();
1021
- return;
1022
- }
1023
- const beaconBody = typeof Blob === "function"
1024
- ? new Blob([body], { type: "application/json" })
1025
- : body;
1026
- const accepted = navigatorSource.sendBeacon(config.endpoint, beaconBody);
1027
- if (accepted) {
1028
- this.bufferedEvents = [];
1029
- this.nextRetryAt = null;
1030
- this.clearFlushTimer();
1031
- return;
1032
- }
1033
- flushViaKeepalive();
1034
- }
1035
- sameLeadingEvents(events) {
1036
- if (this.bufferedEvents.length < events.length) {
1037
- return false;
1038
- }
1039
- return events.every((event, index) => this.bufferedEvents[index]?.event_id === event.event_id);
677
+ this.analyticsController.prepareForUnload();
678
+ this.eventTransport.flushViaBeacon();
1040
679
  }
1041
680
  shouldCaptureNonExceptionEvent() {
1042
681
  const config = this.config;
@@ -1056,17 +695,6 @@ export class BrowserSdk {
1056
695
  project_token: config.projectToken
1057
696
  };
1058
697
  }
1059
- getTransportHeaders(config) {
1060
- if (config.projectToken === null) {
1061
- return {
1062
- "content-type": "application/json"
1063
- };
1064
- }
1065
- return {
1066
- "content-type": "application/json",
1067
- authorization: `Bearer ${config.projectToken}`
1068
- };
1069
- }
1070
698
  createSdkEventEnvelope(config, input) {
1071
699
  const event = createEventEnvelope(input);
1072
700
  this.removeEmptyProjectToken(event, config);
@@ -1107,159 +735,6 @@ export class BrowserSdk {
1107
735
  }), false);
1108
736
  }
1109
737
  }
1110
- shouldCaptureNetworkRequest(url, statusCode, durationMs) {
1111
- const config = this.config;
1112
- if (config === null) {
1113
- return false;
1114
- }
1115
- const filter = config.networkFilter;
1116
- if (filter.urlPatterns.length > 0 && !filter.urlPatterns.some((pattern) => matchesBrowserPattern(url, pattern))) {
1117
- return false;
1118
- }
1119
- if (filter.urlDenyPatterns.some((pattern) => matchesBrowserPattern(url, pattern))) {
1120
- return false;
1121
- }
1122
- if (filter.minResponseTime !== null && durationMs < filter.minResponseTime) {
1123
- return false;
1124
- }
1125
- return matchesStatusCodeFilter(statusCode, filter.statusCodes);
1126
- }
1127
- shouldCaptureFailedNetworkRequest(url, durationMs) {
1128
- const config = this.config;
1129
- if (config === null) {
1130
- return false;
1131
- }
1132
- const filter = config.networkFilter;
1133
- if (filter.urlPatterns.length > 0 && !filter.urlPatterns.some((pattern) => matchesBrowserPattern(url, pattern))) {
1134
- return false;
1135
- }
1136
- if (filter.urlDenyPatterns.some((pattern) => matchesBrowserPattern(url, pattern))) {
1137
- return false;
1138
- }
1139
- if (filter.minResponseTime !== null && durationMs < filter.minResponseTime) {
1140
- return false;
1141
- }
1142
- return true;
1143
- }
1144
- pruneExpiredRemoteProbeDirectives(nowMs) {
1145
- const directives = this.remoteProbeState.directives.filter((directive) => Date.parse(directive.expiresAt) > nowMs);
1146
- if (this.activeTriggerDirective !== null && Date.parse(this.activeTriggerDirective.expiresAt) <= nowMs) {
1147
- this.activeTriggerDirective = null;
1148
- }
1149
- if (directives.length === this.remoteProbeState.directives.length) {
1150
- return;
1151
- }
1152
- this.remoteProbeState = {
1153
- ...this.remoteProbeState,
1154
- directives
1155
- };
1156
- }
1157
- async refreshRemoteProbeConfig() {
1158
- const config = this.config;
1159
- if (config === null || config.fetchImpl === null || config.transportMode !== "direct" || config.projectToken === null) {
1160
- return;
1161
- }
1162
- try {
1163
- const response = await config.fetchImpl(deriveSdkConfigEndpoint(config.endpoint), {
1164
- method: "GET",
1165
- headers: {
1166
- authorization: `Bearer ${config.projectToken}`
1167
- }
1168
- });
1169
- if (response.status === 304 || typeof response.json !== "function") {
1170
- return;
1171
- }
1172
- const payload = await response.json();
1173
- const parsed = parseRemoteProbeConfigPayload(payload, Date.now());
1174
- if (parsed !== null) {
1175
- this.remoteProbeState = parsed;
1176
- this.pruneExpiredRemoteProbeDirectives(Date.now());
1177
- await this.activatePendingTriggerTokenIfPossible();
1178
- }
1179
- config.captureRules = parseRemoteCaptureRulesPayload(payload);
1180
- }
1181
- catch {
1182
- return;
1183
- }
1184
- }
1185
- updateRemoteProbeStateFromIngestionResponse(payload) {
1186
- const directives = parseIngestionProbeDirectives(payload, Date.now());
1187
- if (directives === null) {
1188
- this.pruneExpiredRemoteProbeDirectives(Date.now());
1189
- return;
1190
- }
1191
- this.remoteProbeState = {
1192
- ...this.remoteProbeState,
1193
- directives
1194
- };
1195
- this.pruneExpiredRemoteProbeDirectives(Date.now());
1196
- }
1197
- consumeTriggerTokenFromLocation() {
1198
- const locationSource = getLocationSource();
1199
- const historySource = getHistorySource();
1200
- const search = typeof locationSource?.search === "string" ? locationSource.search : "";
1201
- if (search.length === 0) {
1202
- return null;
1203
- }
1204
- const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
1205
- const token = params.get("_debug_probe");
1206
- if (token === null || token.length === 0) {
1207
- return null;
1208
- }
1209
- params.delete("_debug_probe");
1210
- const cleanedPath = `${locationSource?.pathname ?? ""}${params.toString().length > 0 ? `?${params.toString()}` : ""}`;
1211
- historySource?.replaceState({}, "", cleanedPath);
1212
- return token;
1213
- }
1214
- async activatePendingTriggerTokenIfPossible() {
1215
- if (this.pendingTriggerToken === null) {
1216
- return;
1217
- }
1218
- const directive = await validateBrowserTriggerToken({
1219
- token: this.pendingTriggerToken,
1220
- triggerTokenKey: this.remoteProbeState.triggerTokenKey,
1221
- nowMs: Date.now()
1222
- });
1223
- this.pendingTriggerToken = null;
1224
- this.activeTriggerDirective = directive;
1225
- }
1226
- normalizeProbeInput(data) {
1227
- if (data === null || typeof data !== "object" || Array.isArray(data)) {
1228
- return { value: data };
1229
- }
1230
- return data;
1231
- }
1232
- getMatchingRemoteProbeDirectives(label, nowMs) {
1233
- const config = this.config;
1234
- if (config === null ||
1235
- this.remoteProbeState.probesEnabled !== true ||
1236
- this.remoteProbeState.remoteProbesEnabled !== true) {
1237
- return [];
1238
- }
1239
- this.pruneExpiredRemoteProbeDirectives(nowMs);
1240
- const activeDirectives = this.activeTriggerDirective === null
1241
- ? this.remoteProbeState.directives
1242
- : [...this.remoteProbeState.directives, this.activeTriggerDirective];
1243
- return activeDirectives.filter((directive) => {
1244
- if (directive.service !== "*" && directive.service !== config.service) {
1245
- return false;
1246
- }
1247
- if (directive.environment !== "*" && directive.environment !== config.environment) {
1248
- return false;
1249
- }
1250
- return this.matchesProbeLabelPattern(directive.labelPattern, label);
1251
- });
1252
- }
1253
- matchesProbeLabelPattern(pattern, label) {
1254
- if (pattern === "*") {
1255
- return true;
1256
- }
1257
- if (pattern.endsWith(".*")) {
1258
- const prefix = pattern.slice(0, -2);
1259
- return label === prefix || label.startsWith(`${prefix}.`);
1260
- }
1261
- return pattern === label;
1262
- }
1263
738
  }
1264
739
  export function createDebugBundleBrowserSdk() {
1265
740
  return new BrowserSdk();