@agent-native/core 0.84.67 → 0.85.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/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +14 -0
- package/corpus/core/docs/content/locales/ar-SA/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/de-DE/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/es-ES/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/fr-FR/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/hi-IN/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/ja-JP/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/ko-KR/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/pt-BR/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/zh-CN/tracking.mdx +55 -0
- package/corpus/core/docs/content/locales/zh-TW/tracking.mdx +55 -0
- package/corpus/core/docs/content/tracking.mdx +2 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +199 -38
- package/corpus/core/src/agent/run-manager.ts +79 -0
- package/corpus/core/src/agent/run-store.ts +117 -5
- package/corpus/core/src/agent/types.ts +13 -0
- package/corpus/core/src/client/RunStuckBanner.tsx +14 -0
- package/corpus/core/src/client/agent-chat-adapter.ts +475 -0
- package/corpus/core/src/client/analytics.ts +2 -0
- package/corpus/core/src/client/error-format.ts +4 -0
- package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +1 -1
- package/corpus/core/src/client/session-replay.ts +774 -2
- package/corpus/core/src/client/sse-event-processor.ts +65 -9
- package/corpus/core/src/client/use-run-stuck-detection.ts +31 -7
- package/corpus/core/src/db/runtime-diagnostics.ts +331 -0
- package/corpus/core/src/server/agent-chat-plugin.ts +115 -2
- package/corpus/core/src/server/core-routes-plugin.ts +91 -4
- package/corpus/core/src/server/http-response-telemetry.ts +189 -0
- package/corpus/core/src/server/self-dispatch.ts +29 -0
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +53 -1
- package/corpus/templates/analytics/AGENTS.md +13 -0
- package/corpus/templates/analytics/actions/view-screen.ts +25 -0
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +20 -0
- package/corpus/templates/analytics/app/global.css +55 -0
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +100 -1
- package/corpus/templates/analytics/app/i18n-data.ts +1226 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +3 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/EmailReportDialog.tsx +182 -144
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +11 -18
- package/corpus/templates/analytics/app/pages/analyses/AnalysisDetail.tsx +11 -18
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +49 -1
- package/corpus/templates/analytics/app/pages/sessions/SessionDevToolsPanel.tsx +557 -0
- package/corpus/templates/analytics/app/pages/sessions/session-replay-devtools.ts +274 -0
- package/corpus/templates/analytics/app/pages/settings/AlertRulesSettingsCard.tsx +974 -0
- package/corpus/templates/analytics/changelog/2026-07-02-alert-rules-can-now-be-viewed-and-managed-from-settings.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-02-recent-ask-chats-show-a-loading-placeholder-while-your-chat-.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-02-session-replays-now-capture-console-logs-and-network-request.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-02-sharing-status-labels-use-neutral-icons-instead-of-colored-d.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-02-the-session-replay-viewer-has-a-dev-tools-panel-with-console.md +6 -0
- package/corpus/templates/analytics/server/db/schema.ts +3 -0
- package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +4 -0
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +142 -1
- package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +369 -8
- package/corpus/templates/analytics/server/lib/session-replay.ts +76 -2
- package/corpus/templates/analytics/server/routes/api/session-replay/agent-diagnostics.json.get.ts +101 -0
- package/corpus/templates/analytics/shared/session-replay-diagnostics.ts +78 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +24 -12
- package/corpus/templates/design/changelog/2026-07-02-the-temporary-code-tab-is-hidden-from-the-design-editor-side.md +6 -0
- package/corpus/templates/plan/actions/create-visual-recap.ts +1 -1
- package/corpus/templates/plan/actions/update-local-plan-folder.ts +3 -1
- package/corpus/templates/plan/actions/update-visual-plan.ts +1 -1
- package/corpus/templates/plan/changelog/2026-07-02-trying-to-edit-a-recap-or-plan-you-can-only-view-now-explain.md +6 -0
- package/corpus/templates/plan/server/plans.ts +26 -9
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +155 -34
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts +29 -0
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +73 -0
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +41 -0
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +100 -4
- package/dist/agent/run-store.js.map +1 -1
- package/dist/agent/types.d.ts +13 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/client/RunStuckBanner.d.ts.map +1 -1
- package/dist/client/RunStuckBanner.js +13 -0
- package/dist/client/RunStuckBanner.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +410 -0
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/analytics.d.ts +1 -1
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/error-format.d.ts.map +1 -1
- package/dist/client/error-format.js +2 -0
- package/dist/client/error-format.js.map +1 -1
- package/dist/client/extensions/ExtensionsSidebarSection.js +1 -1
- package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
- package/dist/client/session-replay.d.ts +36 -0
- package/dist/client/session-replay.d.ts.map +1 -1
- package/dist/client/session-replay.js +579 -1
- package/dist/client/session-replay.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts +20 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +42 -11
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/client/use-run-stuck-detection.d.ts +14 -4
- package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
- package/dist/client/use-run-stuck-detection.js +18 -4
- package/dist/client/use-run-stuck-detection.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/db/runtime-diagnostics.d.ts +52 -0
- package/dist/db/runtime-diagnostics.d.ts.map +1 -0
- package/dist/db/runtime-diagnostics.js +250 -0
- package/dist/db/runtime-diagnostics.js.map +1 -0
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +101 -2
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts +17 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +65 -5
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/http-response-telemetry.d.ts +4 -0
- package/dist/server/http-response-telemetry.d.ts.map +1 -0
- package/dist/server/http-response-telemetry.js +174 -0
- package/dist/server/http-response-telemetry.js.map +1 -0
- package/dist/server/self-dispatch.d.ts +17 -0
- package/dist/server/self-dispatch.d.ts.map +1 -1
- package/dist/server/self-dispatch.js +11 -0
- package/dist/server/self-dispatch.js.map +1 -1
- package/docs/content/locales/ar-SA/tracking.mdx +55 -0
- package/docs/content/locales/de-DE/tracking.mdx +55 -0
- package/docs/content/locales/es-ES/tracking.mdx +55 -0
- package/docs/content/locales/fr-FR/tracking.mdx +55 -0
- package/docs/content/locales/hi-IN/tracking.mdx +55 -0
- package/docs/content/locales/ja-JP/tracking.mdx +55 -0
- package/docs/content/locales/ko-KR/tracking.mdx +55 -0
- package/docs/content/locales/pt-BR/tracking.mdx +55 -0
- package/docs/content/locales/zh-CN/tracking.mdx +55 -0
- package/docs/content/locales/zh-TW/tracking.mdx +55 -0
- package/docs/content/tracking.mdx +2 -0
- 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:
|
|
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
|
|
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 {
|