@watchforge/browser 0.1.3 → 0.1.5
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/CONFIGURATION_GUIDE.md +271 -10
- package/README.md +157 -1
- package/bin/watchforge.js +284 -0
- package/package.json +38 -5
- package/src/client.js +30 -0
- package/src/express.d.ts +12 -0
- package/src/index.d.ts +41 -0
- package/src/index.js +1 -2
- package/src/next-server.d.ts +26 -0
- package/src/next-server.js +58 -0
- package/src/next.d.ts +11 -0
- package/src/next.js +12 -0
- package/src/react.d.ts +8 -0
- package/src/replay.js +128 -0
- package/src/stacktrace.js +185 -7
- package/src/tracing.d.ts +39 -0
- package/src/transport.js +45 -3
package/src/replay.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { sendReplay } from "./transport.js";
|
|
2
|
+
|
|
3
|
+
const isBrowser = typeof window !== "undefined";
|
|
4
|
+
const MAX_BUFFER_MS = 60 * 1000;
|
|
5
|
+
const MAX_EVENTS = 500;
|
|
6
|
+
|
|
7
|
+
let options = {
|
|
8
|
+
replaysSessionSampleRate: 0,
|
|
9
|
+
replaysOnErrorSampleRate: 0,
|
|
10
|
+
maskAllInputs: true,
|
|
11
|
+
blockClass: "rr-block",
|
|
12
|
+
ignoreClass: "rr-ignore",
|
|
13
|
+
maskTextClass: "rr-mask",
|
|
14
|
+
};
|
|
15
|
+
let stopRecording = null;
|
|
16
|
+
let events = [];
|
|
17
|
+
let replayId = null;
|
|
18
|
+
let sessionId = null;
|
|
19
|
+
let startedAt = null;
|
|
20
|
+
let sessionSampled = false;
|
|
21
|
+
|
|
22
|
+
function uuid() {
|
|
23
|
+
if (isBrowser && crypto?.randomUUID) return crypto.randomUUID();
|
|
24
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
25
|
+
const r = (Math.random() * 16) | 0;
|
|
26
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
27
|
+
return v.toString(16);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shouldSample(rate) {
|
|
32
|
+
const n = Number(rate || 0);
|
|
33
|
+
if (n <= 0) return false;
|
|
34
|
+
if (n >= 1) return true;
|
|
35
|
+
return Math.random() < n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function trimBuffer(now = Date.now()) {
|
|
39
|
+
events = events.filter((event) => now - event.timestamp <= MAX_BUFFER_MS);
|
|
40
|
+
if (events.length > MAX_EVENTS) {
|
|
41
|
+
events = events.slice(-MAX_EVENTS);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function initReplay(config = {}) {
|
|
46
|
+
if (!isBrowser) return;
|
|
47
|
+
|
|
48
|
+
options = {
|
|
49
|
+
...options,
|
|
50
|
+
...config,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
sessionSampled = shouldSample(options.replaysSessionSampleRate);
|
|
54
|
+
const shouldRecord = sessionSampled || Number(options.replaysOnErrorSampleRate || 0) > 0;
|
|
55
|
+
|
|
56
|
+
if (!shouldRecord || stopRecording) return;
|
|
57
|
+
|
|
58
|
+
replayId = replayId || uuid();
|
|
59
|
+
sessionId = sessionId || uuid();
|
|
60
|
+
startedAt = new Date().toISOString();
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const { record } = await import("rrweb");
|
|
64
|
+
stopRecording = record({
|
|
65
|
+
emit(event) {
|
|
66
|
+
events.push(event);
|
|
67
|
+
trimBuffer(event.timestamp || Date.now());
|
|
68
|
+
},
|
|
69
|
+
maskAllInputs: options.maskAllInputs,
|
|
70
|
+
blockClass: options.blockClass,
|
|
71
|
+
ignoreClass: options.ignoreClass,
|
|
72
|
+
maskTextClass: options.maskTextClass,
|
|
73
|
+
maskInputOptions: {
|
|
74
|
+
password: true,
|
|
75
|
+
email: true,
|
|
76
|
+
tel: true,
|
|
77
|
+
text: Boolean(options.maskAllInputs),
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (options.debug) {
|
|
82
|
+
console.warn("WatchForge SDK: failed to start replay recording", error);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getReplayContext() {
|
|
88
|
+
if (!isBrowser || !replayId || !sessionId) return null;
|
|
89
|
+
return {
|
|
90
|
+
replay_id: replayId,
|
|
91
|
+
session_id: sessionId,
|
|
92
|
+
sampled: Boolean(stopRecording),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function flushReplayForEvent(dsn, eventId) {
|
|
97
|
+
if (!isBrowser || !dsn || !replayId || !sessionId || events.length === 0) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!sessionSampled && !shouldSample(options.replaysOnErrorSampleRate)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
trimBuffer();
|
|
106
|
+
|
|
107
|
+
const payload = {
|
|
108
|
+
replay_id: replayId,
|
|
109
|
+
session_id: sessionId,
|
|
110
|
+
event_id: eventId,
|
|
111
|
+
started_at: startedAt,
|
|
112
|
+
finished_at: new Date().toISOString(),
|
|
113
|
+
url: window.location.href,
|
|
114
|
+
user_agent: navigator.userAgent,
|
|
115
|
+
events,
|
|
116
|
+
sdk: {
|
|
117
|
+
name: "@watchforge/browser",
|
|
118
|
+
version: "0.1.3",
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
sendReplay(dsn, payload);
|
|
123
|
+
return {
|
|
124
|
+
replay_id: replayId,
|
|
125
|
+
session_id: sessionId,
|
|
126
|
+
event_count: events.length,
|
|
127
|
+
};
|
|
128
|
+
}
|
package/src/stacktrace.js
CHANGED
|
@@ -12,8 +12,7 @@ const isNode =
|
|
|
12
12
|
const LIBRARY_PATH_PATTERNS = [
|
|
13
13
|
"/node_modules/",
|
|
14
14
|
"node_modules\\",
|
|
15
|
-
"/webpack/",
|
|
16
|
-
"webpack-internal://",
|
|
15
|
+
"/webpack/runtime/",
|
|
17
16
|
];
|
|
18
17
|
|
|
19
18
|
function isLibraryPath(filePath) {
|
|
@@ -25,7 +24,7 @@ function parseStackLine(line) {
|
|
|
25
24
|
const trimmed = line.trim();
|
|
26
25
|
if (!trimmed.startsWith("at ")) return null;
|
|
27
26
|
|
|
28
|
-
const withFn = trimmed.match(/^at\s+(.*?)\s+\((.*?):(\d+):(\d+)\)$/);
|
|
27
|
+
const withFn = trimmed.match(/^at\s+(?:async\s+)?(.*?)\s+\((.*?):(\d+):(\d+)\)$/);
|
|
29
28
|
if (withFn) {
|
|
30
29
|
return {
|
|
31
30
|
function: withFn[1] || "<anonymous>",
|
|
@@ -35,7 +34,7 @@ function parseStackLine(line) {
|
|
|
35
34
|
};
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
const withoutFn = trimmed.match(/^at\s+(.*?):(\d+):(\d+)$/);
|
|
37
|
+
const withoutFn = trimmed.match(/^at\s+(?:async\s+)?(.*?):(\d+):(\d+)$/);
|
|
39
38
|
if (withoutFn) {
|
|
40
39
|
return {
|
|
41
40
|
function: "<anonymous>",
|
|
@@ -110,15 +109,45 @@ function applySourceContext(frame, sourceLines, lineno) {
|
|
|
110
109
|
.map((l) => l.replace(/\r$/, ""));
|
|
111
110
|
}
|
|
112
111
|
|
|
112
|
+
function normalizeSourcePath(source) {
|
|
113
|
+
if (!source) return "";
|
|
114
|
+
|
|
115
|
+
return String(source)
|
|
116
|
+
.replace(/^webpack-internal:\/\/\/?/, "")
|
|
117
|
+
.replace(/^webpack:\/\/(?:\([^)]*\)\/)?/, "")
|
|
118
|
+
.replace(/^webpack:\/\/[^/]+\//, "")
|
|
119
|
+
.replace(/^\([^)]*\)\//, "")
|
|
120
|
+
.replace(/^file:\/\//, "")
|
|
121
|
+
.split("?")[0]
|
|
122
|
+
.split("#")[0]
|
|
123
|
+
.replace(/\\/g, "/")
|
|
124
|
+
.replace(/^\/+/, "")
|
|
125
|
+
.replace(/^\.\//, "");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sourceMatchesFrame(source, framePath) {
|
|
129
|
+
const normalizedSource = normalizeSourcePath(source);
|
|
130
|
+
const normalizedFrame = normalizeSourcePath(framePath);
|
|
131
|
+
if (!normalizedSource || !normalizedFrame) return false;
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
normalizedSource === normalizedFrame ||
|
|
135
|
+
normalizedSource.endsWith(`/${normalizedFrame}`) ||
|
|
136
|
+
normalizedFrame.endsWith(`/${normalizedSource}`)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
113
140
|
let nodeModulesPromise = null;
|
|
141
|
+
const importNodeBuiltin = (specifier) =>
|
|
142
|
+
new Function("specifier", "return import(specifier)")(specifier);
|
|
114
143
|
|
|
115
144
|
function getNodeModules() {
|
|
116
145
|
if (!isNode) return Promise.resolve(null);
|
|
117
146
|
if (!nodeModulesPromise) {
|
|
118
147
|
nodeModulesPromise = (async () => {
|
|
119
148
|
try {
|
|
120
|
-
const { createRequire } = await
|
|
121
|
-
const { fileURLToPath } = await
|
|
149
|
+
const { createRequire } = await importNodeBuiltin("module");
|
|
150
|
+
const { fileURLToPath } = await importNodeBuiltin("url");
|
|
122
151
|
const req = createRequire(fileURLToPath(import.meta.url));
|
|
123
152
|
return {
|
|
124
153
|
fs: req("fs"),
|
|
@@ -209,10 +238,159 @@ async function fetchBrowserSourceLines(absPath) {
|
|
|
209
238
|
}
|
|
210
239
|
}
|
|
211
240
|
|
|
241
|
+
async function getSourceMapConsumer() {
|
|
242
|
+
try {
|
|
243
|
+
const mod = await import("source-map-js");
|
|
244
|
+
return mod.SourceMapConsumer || mod.default?.SourceMapConsumer || null;
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getSourceMappingUrl(sourceText) {
|
|
251
|
+
const match = sourceText.match(/\/\/# sourceMappingURL=([^\s]+)\s*$/m);
|
|
252
|
+
return match?.[1] || null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function fetchText(url) {
|
|
256
|
+
const response = await fetch(url, { credentials: "same-origin" });
|
|
257
|
+
if (!response.ok) return null;
|
|
258
|
+
const text = await response.text();
|
|
259
|
+
return text.length <= MAX_SOURCE_FILE_BYTES * 10 ? text : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function fetchBrowserSourceMap(absPath) {
|
|
263
|
+
if (!isBrowser || !absPath) return null;
|
|
264
|
+
if (!absPath.startsWith("http://") && !absPath.startsWith("https://") && !absPath.startsWith("/")) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const scriptUrl = new URL(absPath, window.location.href);
|
|
270
|
+
if (scriptUrl.origin !== window.location.origin) return null;
|
|
271
|
+
|
|
272
|
+
const scriptText = await fetchText(scriptUrl.href);
|
|
273
|
+
if (!scriptText) return null;
|
|
274
|
+
|
|
275
|
+
const sourceMappingUrl = getSourceMappingUrl(scriptText);
|
|
276
|
+
if (!sourceMappingUrl) return null;
|
|
277
|
+
|
|
278
|
+
const mapUrl = new URL(sourceMappingUrl, scriptUrl.href);
|
|
279
|
+
if (mapUrl.origin !== window.location.origin && !sourceMappingUrl.startsWith("data:")) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (sourceMappingUrl.startsWith("data:")) {
|
|
284
|
+
const encoded = sourceMappingUrl.split(",", 2)[1];
|
|
285
|
+
if (!encoded) return null;
|
|
286
|
+
const decoded = decodeURIComponent(escape(atob(encoded)));
|
|
287
|
+
return JSON.parse(decoded);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const mapText = await fetchText(mapUrl.href);
|
|
291
|
+
return mapText ? JSON.parse(mapText) : null;
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function enrichFrameWithSourceMap(frame) {
|
|
298
|
+
const sourceMap = await fetchBrowserSourceMap(frame.abs_path);
|
|
299
|
+
if (!sourceMap) return false;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const SourceMapConsumer = await getSourceMapConsumer();
|
|
303
|
+
if (!SourceMapConsumer) return false;
|
|
304
|
+
|
|
305
|
+
const consumer = await new SourceMapConsumer(sourceMap);
|
|
306
|
+
let source = null;
|
|
307
|
+
let lineno = frame.lineno;
|
|
308
|
+
let colno = frame.colno;
|
|
309
|
+
|
|
310
|
+
if (frame.lineno && frame.colno != null) {
|
|
311
|
+
const original = consumer.originalPositionFor({
|
|
312
|
+
line: frame.lineno,
|
|
313
|
+
column: frame.colno,
|
|
314
|
+
});
|
|
315
|
+
if (original?.source && original?.line) {
|
|
316
|
+
source = original.source;
|
|
317
|
+
lineno = original.line;
|
|
318
|
+
colno = original.column ?? frame.colno;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!source) {
|
|
323
|
+
source = sourceMap.sources?.find((candidate) =>
|
|
324
|
+
sourceMatchesFrame(candidate, frame.abs_path)
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (!source || !lineno) {
|
|
329
|
+
consumer.destroy?.();
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const content =
|
|
334
|
+
consumer.sourceContentFor?.(source, true) ||
|
|
335
|
+
sourceMap.sourcesContent?.[sourceMap.sources.indexOf(source)];
|
|
336
|
+
|
|
337
|
+
consumer.destroy?.();
|
|
338
|
+
|
|
339
|
+
if (!content) return false;
|
|
340
|
+
|
|
341
|
+
frame.abs_path = source;
|
|
342
|
+
frame.filename = normalizeSourcePath(source).split("/").pop() || frame.filename;
|
|
343
|
+
frame.lineno = lineno;
|
|
344
|
+
frame.colno = colno;
|
|
345
|
+
applySourceContext(frame, content.split("\n"), lineno);
|
|
346
|
+
return Boolean(frame.context_line);
|
|
347
|
+
} catch {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function getSourceContentFromWebpackFrame(frame) {
|
|
353
|
+
const globalObject = typeof globalThis !== "undefined" ? globalThis : window;
|
|
354
|
+
const webpackChunks =
|
|
355
|
+
Object.values(globalObject).find(
|
|
356
|
+
(value) =>
|
|
357
|
+
Array.isArray(value) &&
|
|
358
|
+
value.some((item) => Array.isArray(item) && item.length >= 2)
|
|
359
|
+
) || [];
|
|
360
|
+
|
|
361
|
+
const framePath = normalizeSourcePath(frame.abs_path);
|
|
362
|
+
if (!framePath || !Array.isArray(webpackChunks)) return null;
|
|
363
|
+
|
|
364
|
+
for (const chunk of webpackChunks) {
|
|
365
|
+
const modules = Array.isArray(chunk) ? chunk[1] : null;
|
|
366
|
+
if (!modules || typeof modules !== "object") continue;
|
|
367
|
+
|
|
368
|
+
for (const [moduleId, factory] of Object.entries(modules)) {
|
|
369
|
+
if (!sourceMatchesFrame(moduleId, framePath)) continue;
|
|
370
|
+
const source = String(factory);
|
|
371
|
+
return source.includes("\n") ? source.split("\n") : null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
212
378
|
async function enrichFrameWithBrowserSource(frame) {
|
|
213
379
|
if (!frame.in_app || !frame.lineno) return;
|
|
380
|
+
|
|
381
|
+
const webpackLines = getSourceContentFromWebpackFrame(frame);
|
|
382
|
+
if (webpackLines) {
|
|
383
|
+
applySourceContext(frame, webpackLines, frame.lineno);
|
|
384
|
+
if (frame.context_line) return;
|
|
385
|
+
}
|
|
386
|
+
|
|
214
387
|
const lines = await fetchBrowserSourceLines(frame.abs_path);
|
|
215
|
-
if (lines)
|
|
388
|
+
if (lines) {
|
|
389
|
+
applySourceContext(frame, lines, frame.lineno);
|
|
390
|
+
if (frame.context_line) return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
await enrichFrameWithSourceMap(frame);
|
|
216
394
|
}
|
|
217
395
|
|
|
218
396
|
export async function enrichStacktraceAsync(stacktrace) {
|
package/src/tracing.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface Span {
|
|
2
|
+
span_id?: string;
|
|
3
|
+
op?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
start_timestamp?: string;
|
|
6
|
+
finish_timestamp?: string | null;
|
|
7
|
+
duration_ms?: number;
|
|
8
|
+
status?: string;
|
|
9
|
+
data?: Record<string, unknown>;
|
|
10
|
+
tags?: Record<string, unknown>;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Transaction {
|
|
15
|
+
trace_id?: string;
|
|
16
|
+
transaction?: string;
|
|
17
|
+
transaction_name?: string;
|
|
18
|
+
op?: string;
|
|
19
|
+
status?: string;
|
|
20
|
+
spans?: Span[];
|
|
21
|
+
data?: Record<string, unknown>;
|
|
22
|
+
tags?: Record<string, unknown>;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function initTracing(
|
|
27
|
+
dsn: string,
|
|
28
|
+
environment?: string,
|
|
29
|
+
debug?: boolean
|
|
30
|
+
): void;
|
|
31
|
+
|
|
32
|
+
export function startTransaction(
|
|
33
|
+
transaction: string,
|
|
34
|
+
transactionName?: string,
|
|
35
|
+
op?: string
|
|
36
|
+
): Transaction;
|
|
37
|
+
|
|
38
|
+
export function getCurrentTransaction(): Transaction | null;
|
|
39
|
+
export function finishTransaction(status?: string): void;
|
package/src/transport.js
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
const isBrowser = typeof window !== 'undefined';
|
|
3
3
|
const isNode = typeof process !== 'undefined' && process.versions && process.versions.node;
|
|
4
4
|
|
|
5
|
-
// Lazy-load Node.js require function for ES modules
|
|
5
|
+
// Lazy-load Node.js require function for ES modules. Use Function-based import
|
|
6
|
+
// so browser bundlers do not try to resolve Node built-ins like "module".
|
|
6
7
|
let nodeRequire = null;
|
|
7
8
|
let nodeRequirePromise = null;
|
|
9
|
+
const importNodeBuiltin = (specifier) =>
|
|
10
|
+
new Function("specifier", "return import(specifier)")(specifier);
|
|
8
11
|
|
|
9
12
|
function getNodeRequire() {
|
|
10
13
|
if (!isNode) return Promise.resolve(null);
|
|
@@ -14,8 +17,8 @@ function getNodeRequire() {
|
|
|
14
17
|
if (!nodeRequirePromise) {
|
|
15
18
|
nodeRequirePromise = (async () => {
|
|
16
19
|
try {
|
|
17
|
-
const { createRequire } = await
|
|
18
|
-
const { fileURLToPath } = await
|
|
20
|
+
const { createRequire } = await importNodeBuiltin('module');
|
|
21
|
+
const { fileURLToPath } = await importNodeBuiltin('url');
|
|
19
22
|
nodeRequire = createRequire(fileURLToPath(import.meta.url));
|
|
20
23
|
return nodeRequire;
|
|
21
24
|
} catch (e) {
|
|
@@ -47,6 +50,7 @@ function parseDsn(dsn) {
|
|
|
47
50
|
host,
|
|
48
51
|
projectId,
|
|
49
52
|
apiUrl: `${finalScheme}://${host}/api/ingestion/events/`,
|
|
53
|
+
replayUrl: `${finalScheme}://${host}/api/ingestion/replays/`,
|
|
50
54
|
};
|
|
51
55
|
}
|
|
52
56
|
} catch (e) {
|
|
@@ -79,6 +83,20 @@ function getApiUrl(dsn) {
|
|
|
79
83
|
return isBrowser ? "/api/ingestion/events/" : "http://127.0.0.1:8001/api/ingestion/events/";
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
function getReplayUrl(dsn) {
|
|
87
|
+
const apiUrl = getApiUrl(dsn);
|
|
88
|
+
if (apiUrl.endsWith("/events/")) {
|
|
89
|
+
return apiUrl.replace(/\/events\/$/, "/replays/");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const parsed = parseDsn(dsn);
|
|
93
|
+
if (parsed) {
|
|
94
|
+
return parsed.replayUrl;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return isBrowser ? "/api/ingestion/replays/" : "http://127.0.0.1:8001/api/ingestion/replays/";
|
|
98
|
+
}
|
|
99
|
+
|
|
82
100
|
export function sendEvent(dsn, payload) {
|
|
83
101
|
const apiUrl = getApiUrl(dsn);
|
|
84
102
|
|
|
@@ -189,3 +207,27 @@ export function sendEvent(dsn, payload) {
|
|
|
189
207
|
});
|
|
190
208
|
}
|
|
191
209
|
}
|
|
210
|
+
|
|
211
|
+
export function sendReplay(dsn, payload) {
|
|
212
|
+
const apiUrl = getReplayUrl(dsn);
|
|
213
|
+
|
|
214
|
+
if (isBrowser) {
|
|
215
|
+
fetch(apiUrl, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: {
|
|
218
|
+
Authorization: `DSN ${dsn}`,
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
},
|
|
221
|
+
body: JSON.stringify(payload),
|
|
222
|
+
keepalive: false,
|
|
223
|
+
}).catch((error) => {
|
|
224
|
+
console.error("WatchForge SDK - Failed to send replay:", error);
|
|
225
|
+
});
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (isNode) {
|
|
230
|
+
// Replays are browser-only, but keep this no-op explicit for framework code paths.
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|