@agent-native/core 0.84.67 → 0.85.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 (149) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +20 -0
  3. package/corpus/core/docs/content/locales/ar-SA/tracking.mdx +55 -0
  4. package/corpus/core/docs/content/locales/de-DE/tracking.mdx +55 -0
  5. package/corpus/core/docs/content/locales/es-ES/tracking.mdx +55 -0
  6. package/corpus/core/docs/content/locales/fr-FR/tracking.mdx +55 -0
  7. package/corpus/core/docs/content/locales/hi-IN/tracking.mdx +55 -0
  8. package/corpus/core/docs/content/locales/ja-JP/tracking.mdx +55 -0
  9. package/corpus/core/docs/content/locales/ko-KR/tracking.mdx +55 -0
  10. package/corpus/core/docs/content/locales/pt-BR/tracking.mdx +55 -0
  11. package/corpus/core/docs/content/locales/zh-CN/tracking.mdx +55 -0
  12. package/corpus/core/docs/content/locales/zh-TW/tracking.mdx +55 -0
  13. package/corpus/core/docs/content/tracking.mdx +2 -0
  14. package/corpus/core/package.json +1 -1
  15. package/corpus/core/src/agent/action-continuation-guidance.ts +1 -1
  16. package/corpus/core/src/agent/production-agent.ts +222 -38
  17. package/corpus/core/src/agent/run-manager.ts +79 -0
  18. package/corpus/core/src/agent/run-store.ts +122 -8
  19. package/corpus/core/src/agent/types.ts +13 -0
  20. package/corpus/core/src/client/RunStuckBanner.tsx +14 -0
  21. package/corpus/core/src/client/agent-chat-adapter.ts +475 -0
  22. package/corpus/core/src/client/analytics.ts +2 -0
  23. package/corpus/core/src/client/error-format.ts +4 -0
  24. package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +1 -1
  25. package/corpus/core/src/client/session-replay.ts +774 -2
  26. package/corpus/core/src/client/sse-event-processor.ts +65 -9
  27. package/corpus/core/src/client/use-run-stuck-detection.ts +31 -7
  28. package/corpus/core/src/db/runtime-diagnostics.ts +331 -0
  29. package/corpus/core/src/server/agent-chat-plugin.ts +115 -2
  30. package/corpus/core/src/server/core-routes-plugin.ts +91 -4
  31. package/corpus/core/src/server/http-response-telemetry.ts +189 -0
  32. package/corpus/core/src/server/self-dispatch.ts +29 -0
  33. package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +53 -1
  34. package/corpus/templates/analytics/AGENTS.md +13 -0
  35. package/corpus/templates/analytics/actions/view-screen.ts +25 -0
  36. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +20 -0
  37. package/corpus/templates/analytics/app/global.css +55 -0
  38. package/corpus/templates/analytics/app/i18n/zh-TW.ts +100 -1
  39. package/corpus/templates/analytics/app/i18n-data.ts +1226 -0
  40. package/corpus/templates/analytics/app/pages/Settings.tsx +3 -0
  41. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/EmailReportDialog.tsx +182 -144
  42. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +11 -18
  43. package/corpus/templates/analytics/app/pages/analyses/AnalysisDetail.tsx +11 -18
  44. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +49 -1
  45. package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +557 -0
  46. package/corpus/templates/analytics/app/pages/sessions/session-replay-devtools.ts +274 -0
  47. package/corpus/templates/analytics/app/pages/settings/AlertRulesSettingsCard.tsx +974 -0
  48. package/corpus/templates/analytics/changelog/2026-07-02-alert-rules-can-now-be-viewed-and-managed-from-settings.md +6 -0
  49. package/corpus/templates/analytics/changelog/2026-07-02-recent-ask-chats-show-a-loading-placeholder-while-your-chat-.md +6 -0
  50. package/corpus/templates/analytics/changelog/2026-07-02-session-replays-now-capture-console-logs-and-network-request.md +6 -0
  51. package/corpus/templates/analytics/changelog/2026-07-02-sharing-status-labels-use-neutral-icons-instead-of-colored-d.md +6 -0
  52. package/corpus/templates/analytics/changelog/2026-07-02-the-session-replay-viewer-has-a-dev-tools-panel-with-console.md +6 -0
  53. package/corpus/templates/analytics/server/db/schema.ts +3 -0
  54. package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +4 -0
  55. package/corpus/templates/analytics/server/lib/analytics-alerts.ts +142 -1
  56. package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +369 -8
  57. package/corpus/templates/analytics/server/lib/session-replay.ts +76 -2
  58. package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +101 -0
  59. package/corpus/templates/analytics/shared/session-replay-diagnostics.ts +78 -0
  60. package/corpus/templates/design/app/pages/DesignEditor.tsx +24 -12
  61. package/corpus/templates/design/changelog/2026-07-02-the-temporary-code-tab-is-hidden-from-the-design-editor-side.md +6 -0
  62. package/corpus/templates/plan/actions/create-visual-recap.ts +1 -1
  63. package/corpus/templates/plan/actions/update-local-plan-folder.ts +3 -1
  64. package/corpus/templates/plan/actions/update-visual-plan.ts +1 -1
  65. package/corpus/templates/plan/changelog/2026-07-02-trying-to-edit-a-recap-or-plan-you-can-only-view-now-explain.md +6 -0
  66. package/corpus/templates/plan/server/plans.ts +26 -9
  67. package/dist/agent/action-continuation-guidance.js +1 -1
  68. package/dist/agent/action-continuation-guidance.js.map +1 -1
  69. package/dist/agent/production-agent.d.ts +9 -1
  70. package/dist/agent/production-agent.d.ts.map +1 -1
  71. package/dist/agent/production-agent.js +168 -34
  72. package/dist/agent/production-agent.js.map +1 -1
  73. package/dist/agent/run-manager.d.ts +29 -0
  74. package/dist/agent/run-manager.d.ts.map +1 -1
  75. package/dist/agent/run-manager.js +73 -0
  76. package/dist/agent/run-manager.js.map +1 -1
  77. package/dist/agent/run-store.d.ts +41 -0
  78. package/dist/agent/run-store.d.ts.map +1 -1
  79. package/dist/agent/run-store.js +105 -7
  80. package/dist/agent/run-store.js.map +1 -1
  81. package/dist/agent/types.d.ts +13 -0
  82. package/dist/agent/types.d.ts.map +1 -1
  83. package/dist/agent/types.js.map +1 -1
  84. package/dist/client/RunStuckBanner.d.ts.map +1 -1
  85. package/dist/client/RunStuckBanner.js +13 -0
  86. package/dist/client/RunStuckBanner.js.map +1 -1
  87. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  88. package/dist/client/agent-chat-adapter.js +410 -0
  89. package/dist/client/agent-chat-adapter.js.map +1 -1
  90. package/dist/client/analytics.d.ts +1 -1
  91. package/dist/client/analytics.d.ts.map +1 -1
  92. package/dist/client/analytics.js.map +1 -1
  93. package/dist/client/error-format.d.ts.map +1 -1
  94. package/dist/client/error-format.js +2 -0
  95. package/dist/client/error-format.js.map +1 -1
  96. package/dist/client/extensions/ExtensionsSidebarSection.js +1 -1
  97. package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
  98. package/dist/client/session-replay.d.ts +36 -0
  99. package/dist/client/session-replay.d.ts.map +1 -1
  100. package/dist/client/session-replay.js +579 -1
  101. package/dist/client/session-replay.js.map +1 -1
  102. package/dist/client/sse-event-processor.d.ts +20 -1
  103. package/dist/client/sse-event-processor.d.ts.map +1 -1
  104. package/dist/client/sse-event-processor.js +42 -11
  105. package/dist/client/sse-event-processor.js.map +1 -1
  106. package/dist/client/use-run-stuck-detection.d.ts +14 -4
  107. package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
  108. package/dist/client/use-run-stuck-detection.js +18 -4
  109. package/dist/client/use-run-stuck-detection.js.map +1 -1
  110. package/dist/collab/awareness.d.ts +2 -2
  111. package/dist/collab/awareness.d.ts.map +1 -1
  112. package/dist/collab/routes.d.ts +2 -2
  113. package/dist/db/runtime-diagnostics.d.ts +52 -0
  114. package/dist/db/runtime-diagnostics.d.ts.map +1 -0
  115. package/dist/db/runtime-diagnostics.js +250 -0
  116. package/dist/db/runtime-diagnostics.js.map +1 -0
  117. package/dist/notifications/routes.d.ts +1 -1
  118. package/dist/observability/routes.d.ts +3 -3
  119. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  120. package/dist/resources/handlers.d.ts +3 -3
  121. package/dist/secrets/routes.d.ts +3 -3
  122. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  123. package/dist/server/agent-chat-plugin.js +101 -2
  124. package/dist/server/agent-chat-plugin.js.map +1 -1
  125. package/dist/server/core-routes-plugin.d.ts +17 -0
  126. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  127. package/dist/server/core-routes-plugin.js +65 -5
  128. package/dist/server/core-routes-plugin.js.map +1 -1
  129. package/dist/server/http-response-telemetry.d.ts +4 -0
  130. package/dist/server/http-response-telemetry.d.ts.map +1 -0
  131. package/dist/server/http-response-telemetry.js +174 -0
  132. package/dist/server/http-response-telemetry.js.map +1 -0
  133. package/dist/server/self-dispatch.d.ts +17 -0
  134. package/dist/server/self-dispatch.d.ts.map +1 -1
  135. package/dist/server/self-dispatch.js +11 -0
  136. package/dist/server/self-dispatch.js.map +1 -1
  137. package/dist/server/transcribe-voice.d.ts +1 -1
  138. package/docs/content/locales/ar-SA/tracking.mdx +55 -0
  139. package/docs/content/locales/de-DE/tracking.mdx +55 -0
  140. package/docs/content/locales/es-ES/tracking.mdx +55 -0
  141. package/docs/content/locales/fr-FR/tracking.mdx +55 -0
  142. package/docs/content/locales/hi-IN/tracking.mdx +55 -0
  143. package/docs/content/locales/ja-JP/tracking.mdx +55 -0
  144. package/docs/content/locales/ko-KR/tracking.mdx +55 -0
  145. package/docs/content/locales/pt-BR/tracking.mdx +55 -0
  146. package/docs/content/locales/zh-CN/tracking.mdx +55 -0
  147. package/docs/content/locales/zh-TW/tracking.mdx +55 -0
  148. package/docs/content/tracking.mdx +2 -0
  149. package/package.json +1 -1
@@ -36,8 +36,14 @@ interface RrwebRecordOptions {
36
36
  sampling?: Record<string, unknown>;
37
37
  }
38
38
 
39
+ type RrwebRecordFn = ((
40
+ options: RrwebRecordOptions,
41
+ ) => ReplayStopFn | undefined) & {
42
+ addCustomEvent?: (tag: string, payload: unknown) => void;
43
+ };
44
+
39
45
  interface RrwebRecordModule {
40
- record: (options: RrwebRecordOptions) => ReplayStopFn | undefined;
46
+ record: RrwebRecordFn;
41
47
  }
42
48
 
43
49
  interface SessionReplayState {
@@ -56,6 +62,10 @@ interface SessionReplayState {
56
62
  stopRecorder: ReplayStopFn | null;
57
63
  restoreUrlMonitor: (() => void) | null;
58
64
  removeLifecycleListeners: (() => void) | null;
65
+ /** rrweb's `record.addCustomEvent`, captured at start (null when absent). */
66
+ addCustomEvent: ((tag: string, payload: unknown) => void) | null;
67
+ /** Uninstalls console/network interceptors and flushes pending duplicates. */
68
+ restoreCaptures: (() => void) | null;
59
69
  options: NormalizedSessionReplayOptions | null;
60
70
  lastAuthenticatedProperties: Record<string, unknown> | null;
61
71
  }
@@ -70,6 +80,26 @@ interface StoredReplaySession {
70
80
  /** rrweb `sampling` shape (mousemove/scroll/media throttles, input strategy). */
71
81
  export type ReplayEventSampling = Record<string, unknown>;
72
82
 
83
+ /**
84
+ * Console capture cap overrides. `maxEvents` bounds the number of
85
+ * `agent-native.console` custom events emitted per recording session
86
+ * (default 1000); once hit, capture stops for the rest of the session and one
87
+ * final truncation-notice event is emitted.
88
+ */
89
+ export interface SessionReplayConsoleOptions {
90
+ maxEvents?: number;
91
+ }
92
+
93
+ /**
94
+ * Network capture cap overrides. `maxEvents` bounds the number of
95
+ * `agent-native.network` custom events emitted per recording session
96
+ * (default 2000); once hit, capture stops for the rest of the session and one
97
+ * final truncation-notice event is emitted.
98
+ */
99
+ export interface SessionReplayNetworkOptions {
100
+ maxEvents?: number;
101
+ }
102
+
73
103
  export interface SessionReplayOptions {
74
104
  enabled?: boolean;
75
105
  publicKey?: string;
@@ -102,6 +132,20 @@ export interface SessionReplayOptions {
102
132
  * payloads small. Passed straight through to `rrweb.record({ sampling })`.
103
133
  */
104
134
  eventSampling?: ReplayEventSampling;
135
+ /**
136
+ * Capture console.log/info/warn/error/debug plus window `error` and
137
+ * `unhandledrejection` events as `agent-native.console` custom rrweb
138
+ * events. Defaults to on whenever session replay is enabled. Pass `false`
139
+ * to disable, or an options object to override caps.
140
+ */
141
+ console?: boolean | SessionReplayConsoleOptions;
142
+ /**
143
+ * Capture fetch/XHR requests as `agent-native.network` custom rrweb
144
+ * events (method, URL, status, timing -- never bodies or headers).
145
+ * Defaults to on whenever session replay is enabled. Pass `false` to
146
+ * disable, or an options object to override caps.
147
+ */
148
+ network?: boolean | SessionReplayNetworkOptions;
105
149
  extraProperties?:
106
150
  | Record<string, unknown>
107
151
  | (() => Record<string, unknown> | undefined);
@@ -150,9 +194,17 @@ interface NormalizedSessionReplayOptions {
150
194
  collectFonts: boolean;
151
195
  inlineImages: boolean;
152
196
  eventSampling: ReplayEventSampling;
197
+ /** Null disables console capture entirely. */
198
+ console: NormalizedCaptureOptions | null;
199
+ /** Null disables network capture entirely. */
200
+ network: NormalizedCaptureOptions | null;
153
201
  extraProperties?: SessionReplayOptions["extraProperties"];
154
202
  }
155
203
 
204
+ interface NormalizedCaptureOptions {
205
+ maxEvents: number;
206
+ }
207
+
156
208
  const DEFAULT_REPLAY_PATH = "/api/analytics/replay";
157
209
  const DEFAULT_SAMPLING_SALT = "agent-native-session-replay";
158
210
 
@@ -198,6 +250,55 @@ const DEFAULT_MAX_EVENTS_PER_BATCH = 50;
198
250
  const DEFAULT_MAX_BATCH_BYTES = 256 * 1024;
199
251
  const MAX_KEEPALIVE_REPLAY_UPLOAD_BYTES = 60 * 1024;
200
252
  const RRWEB_FULL_SNAPSHOT_EVENT_TYPE = 2;
253
+
254
+ /** rrweb custom-event tag for captured console/window-error entries. */
255
+ export const SESSION_REPLAY_CONSOLE_EVENT_TAG = "agent-native.console";
256
+ /** rrweb custom-event tag for captured fetch/XHR request summaries. */
257
+ export const SESSION_REPLAY_NETWORK_EVENT_TAG = "agent-native.network";
258
+
259
+ const DEFAULT_MAX_CONSOLE_EVENTS = 1000;
260
+ const DEFAULT_MAX_NETWORK_EVENTS = 2000;
261
+ const MAX_CONSOLE_MESSAGE_LENGTH = 500;
262
+ const MAX_CONSOLE_ARGS = 10;
263
+ const MAX_CONSOLE_STACK_LENGTH = 2000;
264
+ const MAX_CONSOLE_SERIALIZE_DEPTH = 4;
265
+ const MAX_CONSOLE_SERIALIZE_ENTRIES = 20;
266
+
267
+ /**
268
+ * Re-entrancy guard: true while the recorder itself is emitting custom events
269
+ * or flushing (synchronously) so the console/fetch/XHR wrappers never capture
270
+ * the recorder's own work and feed it back into the replay stream.
271
+ */
272
+ let replayCaptureInternal = false;
273
+
274
+ // Credential-looking token redaction for captured console/network text,
275
+ // adapted from the clips template's browser-diagnostics redaction helper.
276
+ const CAPTURE_SECRET_KEY_FRAGMENT =
277
+ "(?:authorization|cookie|set[-_]?cookie|token|secret|password|passwd|pwd|api[-_]?key|apikey|session|credential)";
278
+ const CAPTURE_AUTHORIZATION_SCHEME_RE =
279
+ /\b(authorization)\b(\s*[:=]\s*)(?:bearer|basic)\s+[a-z0-9._~+/-]+=*/gi;
280
+ const CAPTURE_BEARER_RE = /\b(bearer|basic)\s+[a-z0-9._~+/-]+=*/gi;
281
+ const CAPTURE_DOUBLE_QUOTED_SECRET_RE = new RegExp(
282
+ `(["']?)([A-Za-z0-9_$.-]*${CAPTURE_SECRET_KEY_FRAGMENT}[A-Za-z0-9_$.-]*)\\1(\\s*[:=]\\s*)"(?:[^"\\\\]|\\\\.)*"`,
283
+ "gi",
284
+ );
285
+ const CAPTURE_SINGLE_QUOTED_SECRET_RE = new RegExp(
286
+ `(["']?)([A-Za-z0-9_$.-]*${CAPTURE_SECRET_KEY_FRAGMENT}[A-Za-z0-9_$.-]*)\\1(\\s*[:=]\\s*)'(?:[^'\\\\]|\\\\.)*'`,
287
+ "gi",
288
+ );
289
+ const CAPTURE_UNQUOTED_SECRET_RE = new RegExp(
290
+ `(["']?)([A-Za-z0-9_$.-]*${CAPTURE_SECRET_KEY_FRAGMENT}[A-Za-z0-9_$.-]*)\\1(\\s*[:=]\\s*)([^"',\\s;}\\]]+)`,
291
+ "gi",
292
+ );
293
+
294
+ function redactCaptureText(value: string): string {
295
+ return value
296
+ .replace(CAPTURE_AUTHORIZATION_SCHEME_RE, "$1$2<redacted>")
297
+ .replace(CAPTURE_BEARER_RE, "$1 <redacted>")
298
+ .replace(CAPTURE_DOUBLE_QUOTED_SECRET_RE, '$1$2$1$3"<redacted>"')
299
+ .replace(CAPTURE_SINGLE_QUOTED_SECRET_RE, "$1$2$1$3'<redacted>'")
300
+ .replace(CAPTURE_UNQUOTED_SECRET_RE, "$1$2$1$3<redacted>");
301
+ }
201
302
  const URL_LIKE_KEYS = new Set([
202
303
  "url",
203
304
  "uri",
@@ -229,6 +330,8 @@ function getState(): SessionReplayState {
229
330
  stopRecorder: null,
230
331
  restoreUrlMonitor: null,
231
332
  removeLifecycleListeners: null,
333
+ addCustomEvent: null,
334
+ restoreCaptures: null,
232
335
  options: null,
233
336
  lastAuthenticatedProperties: null,
234
337
  };
@@ -527,10 +630,36 @@ function normalizeOptions(
527
630
  collectFonts: options.collectFonts ?? false,
528
631
  inlineImages: options.inlineImages ?? false,
529
632
  eventSampling: options.eventSampling ?? DEFAULT_EVENT_SAMPLING,
633
+ console: normalizeCaptureToggle(
634
+ options.console,
635
+ DEFAULT_MAX_CONSOLE_EVENTS,
636
+ ),
637
+ network: normalizeCaptureToggle(
638
+ options.network,
639
+ DEFAULT_MAX_NETWORK_EVENTS,
640
+ ),
530
641
  extraProperties: options.extraProperties,
531
642
  };
532
643
  }
533
644
 
645
+ /**
646
+ * Console/network capture default to ON whenever session replay records;
647
+ * `false` disables a category, an object form overrides its caps.
648
+ */
649
+ function normalizeCaptureToggle(
650
+ value: boolean | { maxEvents?: number } | undefined,
651
+ defaultMaxEvents: number,
652
+ ): NormalizedCaptureOptions | null {
653
+ if (value === false) return null;
654
+ const overrides = typeof value === "object" && value !== null ? value : {};
655
+ const maxEvents =
656
+ typeof overrides.maxEvents === "number" &&
657
+ Number.isFinite(overrides.maxEvents)
658
+ ? Math.max(1, Math.floor(overrides.maxEvents))
659
+ : defaultMaxEvents;
660
+ return { maxEvents };
661
+ }
662
+
534
663
  function scrubStringValue(key: string, value: string): string {
535
664
  const lowerKey = key.toLowerCase();
536
665
  const isUrlKey = URL_LIKE_KEYS.has(key) || URL_LIKE_KEYS.has(lowerKey);
@@ -973,7 +1102,15 @@ export async function flushSessionReplay(reason = "manual"): Promise<void> {
973
1102
  } catch (error) {
974
1103
  if (reservedSequence) rollbackReplaySequenceReservation(state, payload);
975
1104
  restoreReplayEvents(state, events);
976
- console.warn("[session-replay] upload failed", error);
1105
+ // Guard the recorder's own warning so console capture never records it
1106
+ // (a captured warning would enqueue an event and retrigger a flush).
1107
+ const previousInternal = replayCaptureInternal;
1108
+ replayCaptureInternal = true;
1109
+ try {
1110
+ console.warn("[session-replay] upload failed", error);
1111
+ } finally {
1112
+ replayCaptureInternal = previousInternal;
1113
+ }
977
1114
  } finally {
978
1115
  state.flushing = false;
979
1116
  }
@@ -1030,6 +1167,619 @@ function installLifecycleListeners(state: SessionReplayState): void {
1030
1167
  };
1031
1168
  }
1032
1169
 
1170
+ function truncateCaptureText(value: string, maxLength: number): string {
1171
+ return value.length > maxLength ? value.slice(0, maxLength) : value;
1172
+ }
1173
+
1174
+ /** Circular-safe, depth/length-limited plain value for JSON.stringify. */
1175
+ function toCaptureSerializable(
1176
+ value: unknown,
1177
+ depth: number,
1178
+ seen: WeakSet<object>,
1179
+ ): unknown {
1180
+ if (value === null || value === undefined) return value;
1181
+ if (typeof value === "string") return value;
1182
+ if (typeof value === "number" || typeof value === "boolean") return value;
1183
+ if (typeof value === "bigint") return `${value}n`;
1184
+ if (typeof value === "function") return "[function]";
1185
+ if (typeof value === "symbol") return String(value);
1186
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
1187
+ if (typeof value !== "object") return String(value);
1188
+ if (seen.has(value)) return "[circular]";
1189
+ if (depth >= MAX_CONSOLE_SERIALIZE_DEPTH) {
1190
+ return Array.isArray(value) ? "[array]" : "[object]";
1191
+ }
1192
+ seen.add(value);
1193
+ if (Array.isArray(value)) {
1194
+ const items = value
1195
+ .slice(0, MAX_CONSOLE_SERIALIZE_ENTRIES)
1196
+ .map((item) => toCaptureSerializable(item, depth + 1, seen));
1197
+ if (value.length > MAX_CONSOLE_SERIALIZE_ENTRIES) items.push("[truncated]");
1198
+ return items;
1199
+ }
1200
+ const out: Record<string, unknown> = {};
1201
+ let count = 0;
1202
+ for (const [key, child] of Object.entries(value)) {
1203
+ if (count >= MAX_CONSOLE_SERIALIZE_ENTRIES) {
1204
+ out["[truncated]"] = true;
1205
+ break;
1206
+ }
1207
+ out[key] = toCaptureSerializable(child, depth + 1, seen);
1208
+ count += 1;
1209
+ }
1210
+ return out;
1211
+ }
1212
+
1213
+ function serializeConsoleArg(value: unknown): string {
1214
+ try {
1215
+ if (typeof value === "string") return value;
1216
+ if (value instanceof Error) return `${value.name}: ${value.message}`;
1217
+ if (
1218
+ value === null ||
1219
+ value === undefined ||
1220
+ typeof value === "number" ||
1221
+ typeof value === "boolean" ||
1222
+ typeof value === "bigint"
1223
+ ) {
1224
+ return String(value);
1225
+ }
1226
+ return (
1227
+ JSON.stringify(toCaptureSerializable(value, 0, new WeakSet())) ??
1228
+ String(value)
1229
+ );
1230
+ } catch {
1231
+ try {
1232
+ return Object.prototype.toString.call(value);
1233
+ } catch {
1234
+ return "[unserializable]";
1235
+ }
1236
+ }
1237
+ }
1238
+
1239
+ /**
1240
+ * Emit an rrweb custom event while recording. Sets the re-entrancy guard so
1241
+ * synchronous work triggered by the emit (enqueue -> flush -> fetch of the
1242
+ * ingest endpoint) is never re-captured by the interceptors below.
1243
+ */
1244
+ function emitReplayCustomEvent(
1245
+ state: SessionReplayState,
1246
+ tag: string,
1247
+ payload: Record<string, unknown>,
1248
+ ): void {
1249
+ const addCustomEvent = state.addCustomEvent;
1250
+ if (!state.active || !addCustomEvent) return;
1251
+ const previous = replayCaptureInternal;
1252
+ replayCaptureInternal = true;
1253
+ try {
1254
+ addCustomEvent(tag, payload);
1255
+ } catch {
1256
+ // recorder already stopped -- drop the event
1257
+ } finally {
1258
+ replayCaptureInternal = previous;
1259
+ }
1260
+ }
1261
+
1262
+ function captureCurrentUrl(): string | undefined {
1263
+ try {
1264
+ return scrubUrl(window.location.href);
1265
+ } catch {
1266
+ return undefined;
1267
+ }
1268
+ }
1269
+
1270
+ type CaptureConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
1271
+ type CaptureConsoleSource = "console" | "window-error" | "unhandledrejection";
1272
+
1273
+ const CAPTURE_CONSOLE_LEVELS: CaptureConsoleLevel[] = [
1274
+ "log",
1275
+ "info",
1276
+ "warn",
1277
+ "error",
1278
+ "debug",
1279
+ ];
1280
+
1281
+ function installConsoleCapture(
1282
+ state: SessionReplayState,
1283
+ captureOptions: NormalizedCaptureOptions,
1284
+ ): () => void {
1285
+ let emitted = 0;
1286
+ let stopped = false;
1287
+ let pending: {
1288
+ key: string;
1289
+ payload: Record<string, unknown>;
1290
+ repeat: number;
1291
+ } | null = null;
1292
+
1293
+ const emitPayload = (payload: Record<string, unknown>) => {
1294
+ if (stopped) return;
1295
+ if (emitted >= captureOptions.maxEvents) {
1296
+ stopped = true;
1297
+ emitReplayCustomEvent(state, SESSION_REPLAY_CONSOLE_EVENT_TAG, {
1298
+ level: "warn",
1299
+ source: "console",
1300
+ message: "session replay console capture truncated",
1301
+ truncated: true,
1302
+ });
1303
+ return;
1304
+ }
1305
+ emitted += 1;
1306
+ emitReplayCustomEvent(state, SESSION_REPLAY_CONSOLE_EVENT_TAG, payload);
1307
+ };
1308
+
1309
+ /**
1310
+ * The first occurrence of a message is emitted immediately; consecutive
1311
+ * identical messages accumulate here and are flushed as one event whose
1312
+ * `repeat` is the number of collapsed duplicates.
1313
+ */
1314
+ const flushPending = () => {
1315
+ const entry = pending;
1316
+ pending = null;
1317
+ if (!entry || entry.repeat <= 0) return;
1318
+ emitPayload({ ...entry.payload, repeat: entry.repeat });
1319
+ };
1320
+
1321
+ const capture = (
1322
+ level: CaptureConsoleLevel,
1323
+ source: CaptureConsoleSource,
1324
+ args: unknown[],
1325
+ stackOverride?: string,
1326
+ ) => {
1327
+ if (stopped || replayCaptureInternal) return;
1328
+ try {
1329
+ const message = truncateCaptureText(
1330
+ redactCaptureText(args.length ? serializeConsoleArg(args[0]) : ""),
1331
+ MAX_CONSOLE_MESSAGE_LENGTH,
1332
+ );
1333
+ const extraArgs = args
1334
+ .slice(1, 1 + MAX_CONSOLE_ARGS)
1335
+ .map((arg) =>
1336
+ truncateCaptureText(
1337
+ redactCaptureText(serializeConsoleArg(arg)),
1338
+ MAX_CONSOLE_MESSAGE_LENGTH,
1339
+ ),
1340
+ );
1341
+ const errorArg = args.find((arg): arg is Error => arg instanceof Error);
1342
+ const rawStack = stackOverride ?? errorArg?.stack;
1343
+ const stack =
1344
+ typeof rawStack === "string" && rawStack
1345
+ ? truncateCaptureText(
1346
+ redactCaptureText(rawStack),
1347
+ MAX_CONSOLE_STACK_LENGTH,
1348
+ )
1349
+ : undefined;
1350
+ const url = captureCurrentUrl();
1351
+ const payload: Record<string, unknown> = {
1352
+ level,
1353
+ source,
1354
+ message,
1355
+ ...(extraArgs.length ? { args: extraArgs } : {}),
1356
+ ...(stack ? { stack } : {}),
1357
+ ...(url ? { url } : {}),
1358
+ };
1359
+ const key = `${level}${source}${message}`;
1360
+ if (pending && pending.key === key) {
1361
+ pending.repeat += 1;
1362
+ return;
1363
+ }
1364
+ flushPending();
1365
+ emitPayload(payload);
1366
+ pending = { key, payload, repeat: 0 };
1367
+ } catch {
1368
+ // capture must never break the host page
1369
+ }
1370
+ };
1371
+
1372
+ const originals: Partial<
1373
+ Record<CaptureConsoleLevel, (...args: unknown[]) => void>
1374
+ > = {};
1375
+ const wrappers: Partial<
1376
+ Record<CaptureConsoleLevel, (...args: unknown[]) => void>
1377
+ > = {};
1378
+ for (const level of CAPTURE_CONSOLE_LEVELS) {
1379
+ const original = console[level] as (...args: unknown[]) => void;
1380
+ if (typeof original !== "function") continue;
1381
+ originals[level] = original;
1382
+ const wrapper = (...args: unknown[]) => {
1383
+ original.apply(console, args);
1384
+ try {
1385
+ capture(level, "console", args);
1386
+ } catch {
1387
+ // never throw from the wrapper
1388
+ }
1389
+ };
1390
+ wrappers[level] = wrapper;
1391
+ console[level] = wrapper;
1392
+ }
1393
+
1394
+ const onWindowError = (event: ErrorEvent) => {
1395
+ try {
1396
+ const error = event?.error;
1397
+ capture(
1398
+ "error",
1399
+ "window-error",
1400
+ [error instanceof Error ? error : (event?.message ?? "Error")],
1401
+ error instanceof Error ? error.stack : undefined,
1402
+ );
1403
+ } catch {
1404
+ // never throw from the listener
1405
+ }
1406
+ };
1407
+ const onUnhandledRejection = (event: PromiseRejectionEvent) => {
1408
+ try {
1409
+ const reason = event?.reason;
1410
+ capture(
1411
+ "error",
1412
+ "unhandledrejection",
1413
+ [reason instanceof Error ? reason : reason],
1414
+ reason instanceof Error ? reason.stack : undefined,
1415
+ );
1416
+ } catch {
1417
+ // never throw from the listener
1418
+ }
1419
+ };
1420
+ window.addEventListener("error", onWindowError as EventListener);
1421
+ window.addEventListener(
1422
+ "unhandledrejection",
1423
+ onUnhandledRejection as EventListener,
1424
+ );
1425
+
1426
+ return () => {
1427
+ try {
1428
+ flushPending();
1429
+ } catch {
1430
+ // best-effort duplicate flush
1431
+ }
1432
+ stopped = true;
1433
+ for (const level of CAPTURE_CONSOLE_LEVELS) {
1434
+ const original = originals[level];
1435
+ // Restore only what we installed: if another library patched on top of
1436
+ // our wrapper, leave the current function in place.
1437
+ if (original && console[level] === wrappers[level]) {
1438
+ console[level] = original;
1439
+ }
1440
+ }
1441
+ window.removeEventListener("error", onWindowError as EventListener);
1442
+ window.removeEventListener(
1443
+ "unhandledrejection",
1444
+ onUnhandledRejection as EventListener,
1445
+ );
1446
+ };
1447
+ }
1448
+
1449
+ function captureRequestUrl(input: unknown): string {
1450
+ if (typeof input === "string") return input;
1451
+ if (typeof URL !== "undefined" && input instanceof URL) {
1452
+ return input.toString();
1453
+ }
1454
+ if (input && typeof input === "object" && "url" in input) {
1455
+ const url = (input as { url?: unknown }).url;
1456
+ if (typeof url === "string") return url;
1457
+ }
1458
+ return "";
1459
+ }
1460
+
1461
+ function captureRequestMethod(
1462
+ input: unknown,
1463
+ init?: { method?: unknown },
1464
+ ): string {
1465
+ const initMethod = init?.method;
1466
+ if (typeof initMethod === "string" && initMethod) {
1467
+ return initMethod.toUpperCase();
1468
+ }
1469
+ if (input && typeof input === "object" && "method" in input) {
1470
+ const method = (input as { method?: unknown }).method;
1471
+ if (typeof method === "string" && method) return method.toUpperCase();
1472
+ }
1473
+ return "GET";
1474
+ }
1475
+
1476
+ /**
1477
+ * Requests the recorder must never record: its own ingest endpoint and the
1478
+ * core analytics tracking endpoint (either would create a flush -> event ->
1479
+ * flush feedback loop), plus non-network schemes.
1480
+ */
1481
+ function isCaptureExcludedUrl(rawUrl: string, ingestEndpoint: string): boolean {
1482
+ const trimmed = rawUrl.trim();
1483
+ if (!trimmed) return true;
1484
+ const lower = trimmed.toLowerCase();
1485
+ if (
1486
+ lower.startsWith("data:") ||
1487
+ lower.startsWith("blob:") ||
1488
+ lower.startsWith("about:")
1489
+ ) {
1490
+ return true;
1491
+ }
1492
+ try {
1493
+ const resolved = new URL(trimmed, window.location.href);
1494
+ const ingest = new URL(ingestEndpoint, window.location.href);
1495
+ if (
1496
+ resolved.origin === ingest.origin &&
1497
+ resolved.pathname === ingest.pathname
1498
+ ) {
1499
+ return true;
1500
+ }
1501
+ if (resolved.pathname.endsWith("/api/analytics/replay")) return true;
1502
+ if (resolved.pathname.endsWith("/api/analytics/track")) return true;
1503
+ } catch {
1504
+ // Unresolvable URL -- skip capture rather than risk recording junk.
1505
+ return true;
1506
+ }
1507
+ return false;
1508
+ }
1509
+
1510
+ function installNetworkCapture(
1511
+ state: SessionReplayState,
1512
+ captureOptions: NormalizedCaptureOptions,
1513
+ ): () => void {
1514
+ const options = state.options;
1515
+ if (!options) return () => {};
1516
+ const ingestEndpoint = options.endpoint;
1517
+ let emitted = 0;
1518
+ let stopped = false;
1519
+
1520
+ const emitPayload = (payload: Record<string, unknown>) => {
1521
+ if (stopped) return;
1522
+ if (emitted >= captureOptions.maxEvents) {
1523
+ stopped = true;
1524
+ emitReplayCustomEvent(state, SESSION_REPLAY_NETWORK_EVENT_TAG, {
1525
+ message: "session replay network capture truncated",
1526
+ truncated: true,
1527
+ });
1528
+ return;
1529
+ }
1530
+ emitted += 1;
1531
+ emitReplayCustomEvent(state, SESSION_REPLAY_NETWORK_EVENT_TAG, payload);
1532
+ };
1533
+
1534
+ const recordRequest = (
1535
+ api: "fetch" | "xhr",
1536
+ method: string,
1537
+ rawUrl: string,
1538
+ status: number,
1539
+ ok: boolean,
1540
+ durationMs: number,
1541
+ error?: string,
1542
+ ) => {
1543
+ if (stopped) return;
1544
+ try {
1545
+ if (isCaptureExcludedUrl(rawUrl, ingestEndpoint)) return;
1546
+ const absolute = new URL(rawUrl, window.location.href).toString();
1547
+ const url = scrubUrl(absolute) ?? absolute;
1548
+ emitPayload({
1549
+ api,
1550
+ method: method.toUpperCase(),
1551
+ url,
1552
+ status,
1553
+ ok,
1554
+ durationMs: Math.max(0, Math.round(durationMs)),
1555
+ ...(error
1556
+ ? {
1557
+ error: truncateCaptureText(
1558
+ redactCaptureText(error),
1559
+ MAX_CONSOLE_MESSAGE_LENGTH,
1560
+ ),
1561
+ }
1562
+ : {}),
1563
+ });
1564
+ } catch {
1565
+ // capture must never break the host page
1566
+ }
1567
+ };
1568
+
1569
+ const restores: Array<() => void> = [];
1570
+
1571
+ if (typeof window.fetch === "function") {
1572
+ const originalFetch = window.fetch;
1573
+ const wrappedFetch = function (
1574
+ this: unknown,
1575
+ input?: RequestInfo | URL,
1576
+ init?: RequestInit,
1577
+ ): Promise<Response> {
1578
+ const self = this ?? window;
1579
+ if (stopped || replayCaptureInternal) {
1580
+ return originalFetch.call(self, input as RequestInfo | URL, init);
1581
+ }
1582
+ let method = "GET";
1583
+ let url = "";
1584
+ let skip = true;
1585
+ try {
1586
+ method = captureRequestMethod(input, init);
1587
+ url = captureRequestUrl(input);
1588
+ skip = isCaptureExcludedUrl(url, ingestEndpoint);
1589
+ } catch {
1590
+ skip = true;
1591
+ }
1592
+ if (skip) {
1593
+ return originalFetch.call(self, input as RequestInfo | URL, init);
1594
+ }
1595
+ const startedAt = performance.now();
1596
+ // Call the original exactly as the page did; never read bodies, never
1597
+ // replace the response, propagate rejections untouched.
1598
+ const result = originalFetch.call(self, input as RequestInfo | URL, init);
1599
+ if (!result || typeof (result as Promise<Response>).then !== "function") {
1600
+ return result;
1601
+ }
1602
+ return result.then(
1603
+ (response) => {
1604
+ try {
1605
+ recordRequest(
1606
+ "fetch",
1607
+ method,
1608
+ url,
1609
+ response.status,
1610
+ response.ok,
1611
+ performance.now() - startedAt,
1612
+ );
1613
+ } catch {
1614
+ // never affect the caller
1615
+ }
1616
+ return response;
1617
+ },
1618
+ (error) => {
1619
+ try {
1620
+ recordRequest(
1621
+ "fetch",
1622
+ method,
1623
+ url,
1624
+ 0,
1625
+ false,
1626
+ performance.now() - startedAt,
1627
+ error instanceof Error ? error.message : String(error),
1628
+ );
1629
+ } catch {
1630
+ // never affect the caller
1631
+ }
1632
+ throw error;
1633
+ },
1634
+ );
1635
+ };
1636
+ window.fetch = wrappedFetch as typeof window.fetch;
1637
+ restores.push(() => {
1638
+ // Restore only what we installed (another lib may have patched on top).
1639
+ if (window.fetch === (wrappedFetch as typeof window.fetch)) {
1640
+ window.fetch = originalFetch;
1641
+ }
1642
+ });
1643
+ }
1644
+
1645
+ if (typeof XMLHttpRequest !== "undefined" && XMLHttpRequest.prototype) {
1646
+ const originalOpen = XMLHttpRequest.prototype.open;
1647
+ const originalSend = XMLHttpRequest.prototype.send;
1648
+ const xhrInfo = new WeakMap<
1649
+ XMLHttpRequest,
1650
+ { method: string; url: string }
1651
+ >();
1652
+
1653
+ const wrappedOpen = function (
1654
+ this: XMLHttpRequest,
1655
+ method: string,
1656
+ url: string | URL,
1657
+ async?: boolean,
1658
+ username?: string | null,
1659
+ password?: string | null,
1660
+ ) {
1661
+ try {
1662
+ xhrInfo.set(this, { method: String(method), url: String(url) });
1663
+ } catch {
1664
+ // never throw from the wrapper
1665
+ }
1666
+ return originalOpen.call(
1667
+ this,
1668
+ method,
1669
+ url as string,
1670
+ async ?? true,
1671
+ username,
1672
+ password,
1673
+ );
1674
+ };
1675
+
1676
+ const wrappedSend = function (
1677
+ this: XMLHttpRequest,
1678
+ body?: Document | XMLHttpRequestBodyInit | null,
1679
+ ) {
1680
+ try {
1681
+ const info = xhrInfo.get(this);
1682
+ if (info && !stopped && !replayCaptureInternal) {
1683
+ const startedAt = performance.now();
1684
+ let errorMessage: string | undefined;
1685
+ const markError = (message: string) => () => {
1686
+ errorMessage = message;
1687
+ };
1688
+ this.addEventListener("error", markError("XMLHttpRequest failed"), {
1689
+ once: true,
1690
+ });
1691
+ this.addEventListener("abort", markError("XMLHttpRequest aborted"), {
1692
+ once: true,
1693
+ });
1694
+ this.addEventListener(
1695
+ "timeout",
1696
+ markError("XMLHttpRequest timed out"),
1697
+ { once: true },
1698
+ );
1699
+ this.addEventListener(
1700
+ "loadend",
1701
+ () => {
1702
+ const status = typeof this.status === "number" ? this.status : 0;
1703
+ recordRequest(
1704
+ "xhr",
1705
+ info.method,
1706
+ info.url,
1707
+ errorMessage ? 0 : status,
1708
+ !errorMessage && status >= 200 && status < 300,
1709
+ performance.now() - startedAt,
1710
+ errorMessage,
1711
+ );
1712
+ xhrInfo.delete(this);
1713
+ },
1714
+ { once: true },
1715
+ );
1716
+ }
1717
+ } catch {
1718
+ // never throw from the wrapper
1719
+ }
1720
+ return originalSend.call(this, body ?? null);
1721
+ };
1722
+
1723
+ XMLHttpRequest.prototype.open =
1724
+ wrappedOpen as typeof XMLHttpRequest.prototype.open;
1725
+ XMLHttpRequest.prototype.send =
1726
+ wrappedSend as typeof XMLHttpRequest.prototype.send;
1727
+ restores.push(() => {
1728
+ if (
1729
+ XMLHttpRequest.prototype.open ===
1730
+ (wrappedOpen as typeof XMLHttpRequest.prototype.open)
1731
+ ) {
1732
+ XMLHttpRequest.prototype.open = originalOpen;
1733
+ }
1734
+ if (
1735
+ XMLHttpRequest.prototype.send ===
1736
+ (wrappedSend as typeof XMLHttpRequest.prototype.send)
1737
+ ) {
1738
+ XMLHttpRequest.prototype.send = originalSend;
1739
+ }
1740
+ });
1741
+ }
1742
+
1743
+ return () => {
1744
+ stopped = true;
1745
+ for (const restore of restores) {
1746
+ try {
1747
+ restore();
1748
+ } catch {
1749
+ // best-effort restore
1750
+ }
1751
+ }
1752
+ };
1753
+ }
1754
+
1755
+ function installCaptureInterceptors(state: SessionReplayState): void {
1756
+ const options = state.options;
1757
+ if (!options || state.restoreCaptures || !state.addCustomEvent) return;
1758
+ if (!options.console && !options.network) return;
1759
+ const restores: Array<() => void> = [];
1760
+ try {
1761
+ if (options.console) {
1762
+ restores.push(installConsoleCapture(state, options.console));
1763
+ }
1764
+ if (options.network) {
1765
+ restores.push(installNetworkCapture(state, options.network));
1766
+ }
1767
+ } catch {
1768
+ // keep whatever installed cleanly; restores below still uninstall it
1769
+ }
1770
+ if (restores.length === 0) return;
1771
+ state.restoreCaptures = () => {
1772
+ state.restoreCaptures = null;
1773
+ for (const restore of restores) {
1774
+ try {
1775
+ restore();
1776
+ } catch {
1777
+ // best-effort restore
1778
+ }
1779
+ }
1780
+ };
1781
+ }
1782
+
1033
1783
  export async function startSessionReplay(
1034
1784
  options: SessionReplayOptions = {},
1035
1785
  ): Promise<SessionReplayStartResult> {
@@ -1183,6 +1933,11 @@ async function startSessionReplayRecorder(
1183
1933
  );
1184
1934
  installUrlMonitor(state);
1185
1935
  installLifecycleListeners(state);
1936
+ state.addCustomEvent =
1937
+ typeof rrweb.record.addCustomEvent === "function"
1938
+ ? rrweb.record.addCustomEvent
1939
+ : null;
1940
+ installCaptureInterceptors(state);
1186
1941
  return {
1187
1942
  started: true,
1188
1943
  replayId: state.replayId,
@@ -1190,6 +1945,13 @@ async function startSessionReplayRecorder(
1190
1945
  sampled,
1191
1946
  };
1192
1947
  } catch {
1948
+ try {
1949
+ state.restoreCaptures?.();
1950
+ } catch {
1951
+ // best-effort interceptor teardown
1952
+ }
1953
+ state.restoreCaptures = null;
1954
+ state.addCustomEvent = null;
1193
1955
  state.active = false;
1194
1956
  state.options = null;
1195
1957
  state.replayId = null;
@@ -1202,7 +1964,17 @@ async function startSessionReplayRecorder(
1202
1964
  export async function stopSessionReplay(reason = "manual"): Promise<void> {
1203
1965
  const state = getState();
1204
1966
  if (!state.active) return;
1967
+ // Restore console/fetch/XHR before tearing down the recorder: the restore
1968
+ // flushes any pending collapsed console duplicate, which must still be able
1969
+ // to emit through rrweb while it is recording.
1970
+ try {
1971
+ state.restoreCaptures?.();
1972
+ } catch {
1973
+ // best-effort interceptor teardown
1974
+ }
1975
+ state.restoreCaptures = null;
1205
1976
  state.active = false;
1977
+ state.addCustomEvent = null;
1206
1978
  try {
1207
1979
  state.stopRecorder?.();
1208
1980
  } catch {