@watchforge/browser 0.1.3 → 0.1.4
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 +134 -1
- package/README.md +19 -0
- package/bin/watchforge.js +258 -0
- package/package.json +10 -2
- package/src/client.js +30 -0
- package/src/replay.js +128 -0
- package/src/stacktrace.js +6 -4
- package/src/transport.js +45 -3
package/CONFIGURATION_GUIDE.md
CHANGED
|
@@ -68,6 +68,36 @@ After installation, use it the same way:
|
|
|
68
68
|
import { register, ErrorBoundary } from '@watchforge/browser';
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
### Next.js Wizard Setup
|
|
72
|
+
|
|
73
|
+
From your Next.js project root:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx @watchforge/browser -i nextjs --dsn "https://PUBLIC_KEY@dev.watchforges.com/PROJECT_ID" --app-env production
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
With error-triggered Session Replay:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx @watchforge/browser -i nextjs \
|
|
83
|
+
--dsn "https://PUBLIC_KEY@dev.watchforges.com/PROJECT_ID" \
|
|
84
|
+
--app-env production \
|
|
85
|
+
--replays-on-error 1
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The wizard:
|
|
89
|
+
|
|
90
|
+
1. Installs `@watchforge/browser`
|
|
91
|
+
2. Creates `watchforge.config.ts`
|
|
92
|
+
3. Creates `app/watchforge-init.tsx` (or `src/app/watchforge-init.tsx`)
|
|
93
|
+
4. Patches `app/layout.tsx` to render `<WatchForgeInit />`
|
|
94
|
+
|
|
95
|
+
If you only want file generation and no package install:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npx @watchforge/browser -i nextjs --dsn "..." --skip-install
|
|
99
|
+
```
|
|
100
|
+
|
|
71
101
|
## Quick Testing
|
|
72
102
|
|
|
73
103
|
### Prerequisites
|
|
@@ -710,7 +740,11 @@ try {
|
|
|
710
740
|
```javascript
|
|
711
741
|
// server.js
|
|
712
742
|
const express = require('express');
|
|
713
|
-
const {
|
|
743
|
+
const {
|
|
744
|
+
register,
|
|
745
|
+
expressRequestMiddleware,
|
|
746
|
+
expressMiddleware,
|
|
747
|
+
} = require('@watchforge/browser');
|
|
714
748
|
|
|
715
749
|
const app = express();
|
|
716
750
|
|
|
@@ -719,6 +753,11 @@ register({
|
|
|
719
753
|
app_env: process.env.NODE_ENV,
|
|
720
754
|
});
|
|
721
755
|
|
|
756
|
+
app.use(express.json());
|
|
757
|
+
app.use(expressRequestMiddleware());
|
|
758
|
+
|
|
759
|
+
// routes here
|
|
760
|
+
|
|
722
761
|
// Add error handler (must be last middleware)
|
|
723
762
|
app.use(expressMiddleware());
|
|
724
763
|
|
|
@@ -735,6 +774,11 @@ import App from './App';
|
|
|
735
774
|
register({
|
|
736
775
|
dsn: process.env.REACT_APP_WATCHFORGE_DSN,
|
|
737
776
|
app_env: process.env.NODE_ENV,
|
|
777
|
+
// Optional: upload the last 60s of masked browser replay events when an error occurs.
|
|
778
|
+
replaysOnErrorSampleRate: 1.0,
|
|
779
|
+
// Optional: continuously sample full sessions. Keep 0 in production unless you need it.
|
|
780
|
+
replaysSessionSampleRate: 0,
|
|
781
|
+
maskAllInputs: true,
|
|
738
782
|
});
|
|
739
783
|
|
|
740
784
|
ReactDOM.render(
|
|
@@ -745,6 +789,95 @@ ReactDOM.render(
|
|
|
745
789
|
);
|
|
746
790
|
```
|
|
747
791
|
|
|
792
|
+
## Stack Trace Source Context
|
|
793
|
+
|
|
794
|
+
The SDK enriches stack frames with Python-style source context when possible:
|
|
795
|
+
|
|
796
|
+
| Field | Description |
|
|
797
|
+
|-------|-------------|
|
|
798
|
+
| `context_line` | The exact line where the error occurred |
|
|
799
|
+
| `pre_context` | Up to 5 lines before the error |
|
|
800
|
+
| `post_context` | Up to 5 lines after the error |
|
|
801
|
+
|
|
802
|
+
### Node.js / Express
|
|
803
|
+
|
|
804
|
+
For local project files, the SDK reads source from disk and attaches context lines automatically. No extra configuration is required.
|
|
805
|
+
|
|
806
|
+
### Browser (development)
|
|
807
|
+
|
|
808
|
+
In dev builds (Next.js, Vite, etc.), the SDK attempts to fetch same-origin script URLs from the stack trace and slice source lines around the reported line number.
|
|
809
|
+
|
|
810
|
+
Works when stack frames point to readable URLs such as:
|
|
811
|
+
|
|
812
|
+
```txt
|
|
813
|
+
http://localhost:3000/_next/static/chunks/...
|
|
814
|
+
http://localhost:3000/src/App.tsx
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
### Browser (production / minified bundles)
|
|
818
|
+
|
|
819
|
+
Production bundles often report locations like:
|
|
820
|
+
|
|
821
|
+
```txt
|
|
822
|
+
https://your-app.com/_next/static/chunks/app-abc123.js:1:98432
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
The SDK still sends filename, line, and column, but **cannot** show original TypeScript/JSX source without **source maps**.
|
|
826
|
+
|
|
827
|
+
**Current status:** source map upload and symbolication are not yet supported (planned for a future release).
|
|
828
|
+
|
|
829
|
+
**Workarounds today:**
|
|
830
|
+
|
|
831
|
+
1. Test with `npm run dev` / unminified builds to see source lines in WatchForge.
|
|
832
|
+
2. Use `release` in `register()` so issues are grouped by deploy version.
|
|
833
|
+
3. Rely on breadcrumbs and the Browser Event Summary in the dashboard to understand user actions before the error.
|
|
834
|
+
|
|
835
|
+
When source map support ships, you will upload maps during deploy and WatchForge will resolve minified frames back to original files.
|
|
836
|
+
|
|
837
|
+
## Session Replay
|
|
838
|
+
|
|
839
|
+
The browser SDK can record a Sentry-style DOM replay using `rrweb`. Replays are **not videos**; they are masked DOM snapshots and incremental browser events that WatchForge can play back in the Issue Detail page.
|
|
840
|
+
|
|
841
|
+
Enable replay capture when registering the SDK:
|
|
842
|
+
|
|
843
|
+
```javascript
|
|
844
|
+
register({
|
|
845
|
+
dsn: "https://PUBLIC_KEY@dev.watchforges.com/PROJECT_ID",
|
|
846
|
+
app_env: "production",
|
|
847
|
+
replaysOnErrorSampleRate: 1.0,
|
|
848
|
+
replaysSessionSampleRate: 0,
|
|
849
|
+
maskAllInputs: true,
|
|
850
|
+
});
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
Replay behavior:
|
|
854
|
+
|
|
855
|
+
- `replaysOnErrorSampleRate`: records in buffer mode and uploads the last 60 seconds when an error is captured.
|
|
856
|
+
- `replaysSessionSampleRate`: continuously samples full browser sessions.
|
|
857
|
+
- Text/input privacy is handled in the browser before upload.
|
|
858
|
+
- Password, email, tel and text inputs are masked when `maskAllInputs` is true.
|
|
859
|
+
- Elements with `.rr-block` are blocked.
|
|
860
|
+
- Elements with `.rr-ignore` ignore input events.
|
|
861
|
+
- Elements with `.rr-mask` mask text.
|
|
862
|
+
|
|
863
|
+
When an error is captured, the SDK attaches:
|
|
864
|
+
|
|
865
|
+
```json
|
|
866
|
+
{
|
|
867
|
+
"replay_id": "...",
|
|
868
|
+
"session_id": "...",
|
|
869
|
+
"contexts": {
|
|
870
|
+
"replay": {
|
|
871
|
+
"replay_id": "...",
|
|
872
|
+
"session_id": "...",
|
|
873
|
+
"sampled": true
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
The dashboard shows the linked replay in the Issue Detail **Session Replay** tab.
|
|
880
|
+
|
|
748
881
|
## Test Scripts
|
|
749
882
|
|
|
750
883
|
Test scripts are included in the SDK directory:
|
package/README.md
CHANGED
|
@@ -18,6 +18,25 @@ Browser and Node SDK for WatchForge. **One call to `register()`** turns on autom
|
|
|
18
18
|
npm install @watchforge/browser
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
+
## Next.js one-line setup
|
|
22
|
+
|
|
23
|
+
For Next.js apps, run the setup wizard from your app root:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx @watchforge/browser -i nextjs --dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" --app-env production
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
With Session Replay on errors:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx @watchforge/browser -i nextjs \
|
|
33
|
+
--dsn "https://PUBLIC_KEY@your-host/PROJECT_ID" \
|
|
34
|
+
--app-env production \
|
|
35
|
+
--replays-on-error 1
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The wizard installs `@watchforge/browser`, writes `watchforge.config.ts`, creates a client init component, and patches `app/layout.tsx` or `pages/_app.tsx`.
|
|
39
|
+
|
|
21
40
|
## Quick start (all most apps need)
|
|
22
41
|
|
|
23
42
|
Call **`register()` once** as early as possible (e.g. app entry / main bundle), with your **DSN** from the WatchForge project settings.
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
|
|
8
|
+
const HELP = `
|
|
9
|
+
WatchForge setup wizard
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
npx @watchforge/browser -i nextjs --dsn <dsn> [options]
|
|
13
|
+
npx @watchforge/browser init nextjs --dsn <dsn> [options]
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
-i, --integration <name> Framework integration. Currently: nextjs
|
|
17
|
+
--dsn <dsn> WatchForge DSN
|
|
18
|
+
--app-env <env> App environment (default: production)
|
|
19
|
+
--debug Enable SDK debug logging
|
|
20
|
+
--replays-on-error <rate> Upload replay when errors happen (default: 0)
|
|
21
|
+
--replays-session <rate> Continuously sample sessions (default: 0)
|
|
22
|
+
--skip-install Do not install @watchforge/browser
|
|
23
|
+
-h, --help Show help
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
npx @watchforge/browser -i nextjs --dsn "https://PUBLIC_KEY@dev.watchforges.com/PROJECT_ID" --app-env development --replays-on-error 1
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const args = {
|
|
31
|
+
integration: null,
|
|
32
|
+
dsn: null,
|
|
33
|
+
appEnv: "production",
|
|
34
|
+
debug: false,
|
|
35
|
+
replaysOnError: "0",
|
|
36
|
+
replaysSession: "0",
|
|
37
|
+
skipInstall: false,
|
|
38
|
+
help: false,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const arg = argv[i];
|
|
43
|
+
const next = argv[i + 1];
|
|
44
|
+
|
|
45
|
+
if (arg === "-h" || arg === "--help") args.help = true;
|
|
46
|
+
else if (arg === "-i" || arg === "--integration") {
|
|
47
|
+
args.integration = next;
|
|
48
|
+
i++;
|
|
49
|
+
} else if (arg === "init" || arg === "setup") {
|
|
50
|
+
args.integration = argv[i + 1] || args.integration;
|
|
51
|
+
i++;
|
|
52
|
+
} else if (arg === "--dsn") {
|
|
53
|
+
args.dsn = next;
|
|
54
|
+
i++;
|
|
55
|
+
} else if (arg === "--app-env" || arg === "--environment") {
|
|
56
|
+
args.appEnv = next || args.appEnv;
|
|
57
|
+
i++;
|
|
58
|
+
} else if (arg === "--debug") {
|
|
59
|
+
args.debug = true;
|
|
60
|
+
} else if (arg === "--replays-on-error") {
|
|
61
|
+
args.replaysOnError = next || "0";
|
|
62
|
+
i++;
|
|
63
|
+
} else if (arg === "--replays-session") {
|
|
64
|
+
args.replaysSession = next || "0";
|
|
65
|
+
i++;
|
|
66
|
+
} else if (arg === "--skip-install") {
|
|
67
|
+
args.skipInstall = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return args;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function log(message) {
|
|
75
|
+
console.log(`WatchForge: ${message}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function fail(message) {
|
|
79
|
+
console.error(`WatchForge setup failed: ${message}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function fileExists(filePath) {
|
|
84
|
+
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findNextLayout(cwd) {
|
|
88
|
+
const candidates = [
|
|
89
|
+
path.join(cwd, "src", "app", "layout.tsx"),
|
|
90
|
+
path.join(cwd, "src", "app", "layout.jsx"),
|
|
91
|
+
path.join(cwd, "app", "layout.tsx"),
|
|
92
|
+
path.join(cwd, "app", "layout.jsx"),
|
|
93
|
+
];
|
|
94
|
+
return candidates.find(fileExists) || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function findPagesApp(cwd) {
|
|
98
|
+
const candidates = [
|
|
99
|
+
path.join(cwd, "src", "pages", "_app.tsx"),
|
|
100
|
+
path.join(cwd, "src", "pages", "_app.jsx"),
|
|
101
|
+
path.join(cwd, "pages", "_app.tsx"),
|
|
102
|
+
path.join(cwd, "pages", "_app.jsx"),
|
|
103
|
+
];
|
|
104
|
+
return candidates.find(fileExists) || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function writeIfChanged(filePath, content) {
|
|
108
|
+
if (fileExists(filePath) && fs.readFileSync(filePath, "utf8") === content) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
112
|
+
fs.writeFileSync(filePath, content);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createConfig(cwd, args) {
|
|
117
|
+
const configPath = path.join(cwd, "watchforge.config.ts");
|
|
118
|
+
const content = `export const watchforgeConfig = {
|
|
119
|
+
dsn: ${JSON.stringify(args.dsn)},
|
|
120
|
+
app_env: ${JSON.stringify(args.appEnv)},
|
|
121
|
+
debug: ${args.debug ? "true" : "false"},
|
|
122
|
+
replaysOnErrorSampleRate: ${Number(args.replaysOnError)},
|
|
123
|
+
replaysSessionSampleRate: ${Number(args.replaysSession)},
|
|
124
|
+
maskAllInputs: true,
|
|
125
|
+
};
|
|
126
|
+
`;
|
|
127
|
+
writeIfChanged(configPath, content);
|
|
128
|
+
log(`wrote ${path.relative(cwd, configPath)}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function installPackage(cwd, skipInstall) {
|
|
132
|
+
if (skipInstall) {
|
|
133
|
+
log("skipped npm install");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const hasPnpm = fileExists(path.join(cwd, "pnpm-lock.yaml"));
|
|
138
|
+
const hasYarn = fileExists(path.join(cwd, "yarn.lock"));
|
|
139
|
+
const hasBun = fileExists(path.join(cwd, "bun.lockb"));
|
|
140
|
+
|
|
141
|
+
const command = hasPnpm
|
|
142
|
+
? "pnpm add @watchforge/browser"
|
|
143
|
+
: hasYarn
|
|
144
|
+
? "yarn add @watchforge/browser"
|
|
145
|
+
: hasBun
|
|
146
|
+
? "bun add @watchforge/browser"
|
|
147
|
+
: "npm install @watchforge/browser";
|
|
148
|
+
|
|
149
|
+
log(`installing package: ${command}`);
|
|
150
|
+
execSync(command, { cwd, stdio: "inherit" });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function patchAppRouter(cwd, layoutPath) {
|
|
154
|
+
const appDir = path.dirname(layoutPath);
|
|
155
|
+
const isSrcApp = appDir.endsWith(path.join("src", "app"));
|
|
156
|
+
const configImport = isSrcApp ? "../../watchforge.config" : "../watchforge.config";
|
|
157
|
+
const initPath = path.join(appDir, "watchforge-init.tsx");
|
|
158
|
+
|
|
159
|
+
const initContent = `"use client";
|
|
160
|
+
|
|
161
|
+
import { useEffect } from "react";
|
|
162
|
+
import { register } from "@watchforge/browser";
|
|
163
|
+
import { watchforgeConfig } from "${configImport}";
|
|
164
|
+
|
|
165
|
+
export default function WatchForgeInit() {
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
register(watchforgeConfig);
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
`;
|
|
173
|
+
|
|
174
|
+
writeIfChanged(initPath, initContent);
|
|
175
|
+
log(`wrote ${path.relative(cwd, initPath)}`);
|
|
176
|
+
|
|
177
|
+
let layout = fs.readFileSync(layoutPath, "utf8");
|
|
178
|
+
if (!layout.includes("WatchForgeInit")) {
|
|
179
|
+
layout = `import WatchForgeInit from "./watchforge-init";\n${layout}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!layout.includes("<WatchForgeInit />")) {
|
|
183
|
+
layout = layout.replace(/<body([^>]*)>/, "<body$1>\n <WatchForgeInit />\n ");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fs.writeFileSync(layoutPath, layout);
|
|
187
|
+
log(`patched ${path.relative(cwd, layoutPath)}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function patchPagesRouter(cwd, appPath) {
|
|
191
|
+
const pagesDir = path.dirname(appPath);
|
|
192
|
+
const isSrcPages = pagesDir.endsWith(path.join("src", "pages"));
|
|
193
|
+
const configImport = isSrcPages ? "../../watchforge.config" : "../watchforge.config";
|
|
194
|
+
const content = fs.readFileSync(appPath, "utf8");
|
|
195
|
+
|
|
196
|
+
if (content.includes("register(watchforgeConfig)")) {
|
|
197
|
+
log(`${path.relative(cwd, appPath)} already contains WatchForge setup`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const patched = `import { useEffect } from "react";
|
|
202
|
+
import { register } from "@watchforge/browser";
|
|
203
|
+
import { watchforgeConfig } from "${configImport}";
|
|
204
|
+
|
|
205
|
+
${content.replace(
|
|
206
|
+
/function\s+App\s*\(([^)]*)\)\s*{/,
|
|
207
|
+
"function App($1) {\n useEffect(() => {\n register(watchforgeConfig);\n }, []);"
|
|
208
|
+
)}`;
|
|
209
|
+
|
|
210
|
+
fs.writeFileSync(appPath, patched);
|
|
211
|
+
log(`patched ${path.relative(cwd, appPath)}`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function initNextjs(args) {
|
|
215
|
+
const cwd = process.cwd();
|
|
216
|
+
if (!fileExists(path.join(cwd, "package.json"))) {
|
|
217
|
+
fail("run this command from the root of your Next.js project");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
createConfig(cwd, args);
|
|
221
|
+
installPackage(cwd, args.skipInstall);
|
|
222
|
+
|
|
223
|
+
const layoutPath = findNextLayout(cwd);
|
|
224
|
+
if (layoutPath) {
|
|
225
|
+
patchAppRouter(cwd, layoutPath);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const appPath = findPagesApp(cwd);
|
|
230
|
+
if (appPath) {
|
|
231
|
+
patchPagesRouter(cwd, appPath);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
fail("could not find app/layout.tsx or pages/_app.tsx");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const args = parseArgs(process.argv.slice(2));
|
|
239
|
+
|
|
240
|
+
if (args.help) {
|
|
241
|
+
console.log(HELP);
|
|
242
|
+
process.exit(0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!args.integration) {
|
|
246
|
+
fail("missing integration. Use: -i nextjs");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (args.integration !== "nextjs") {
|
|
250
|
+
fail(`unsupported integration "${args.integration}". Currently supported: nextjs`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!args.dsn) {
|
|
254
|
+
fail("missing --dsn");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
initNextjs(args);
|
|
258
|
+
log("setup complete");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@watchforge/browser",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"main": "./src/index.js",
|
|
5
5
|
"description": "WatchForge JavaScript SDK for Node.js, Express.js, and React",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,12 +22,19 @@
|
|
|
22
22
|
"./express": "./src/express.js",
|
|
23
23
|
"./react": "./src/react.js"
|
|
24
24
|
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"watchforge": "bin/watchforge.js"
|
|
27
|
+
},
|
|
25
28
|
"files": [
|
|
29
|
+
"bin/**/*.js",
|
|
26
30
|
"src/**/*.js",
|
|
27
31
|
"README.md",
|
|
28
32
|
"CONFIGURATION_GUIDE.md",
|
|
29
33
|
"LICENSE"
|
|
30
34
|
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
31
38
|
"type": "module",
|
|
32
39
|
"peerDependencies": {
|
|
33
40
|
"react": ">=16.8.0"
|
|
@@ -43,7 +50,8 @@
|
|
|
43
50
|
"url": false
|
|
44
51
|
},
|
|
45
52
|
"dependencies": {
|
|
46
|
-
"express": "^5.2.1"
|
|
53
|
+
"express": "^5.2.1",
|
|
54
|
+
"rrweb": "^2.0.1"
|
|
47
55
|
},
|
|
48
56
|
"devDependencies": {
|
|
49
57
|
"rollup": "^4.60.0"
|
package/src/client.js
CHANGED
|
@@ -9,6 +9,11 @@ import {
|
|
|
9
9
|
getPerformanceContext,
|
|
10
10
|
getNodeServerContext,
|
|
11
11
|
} from "./contexts.js";
|
|
12
|
+
import {
|
|
13
|
+
flushReplayForEvent,
|
|
14
|
+
getReplayContext,
|
|
15
|
+
initReplay,
|
|
16
|
+
} from "./replay.js";
|
|
12
17
|
|
|
13
18
|
let DSN = null;
|
|
14
19
|
let APP_ENV = "production";
|
|
@@ -378,6 +383,12 @@ export function register({
|
|
|
378
383
|
app_env = "production",
|
|
379
384
|
release = null,
|
|
380
385
|
debug = false,
|
|
386
|
+
replaysSessionSampleRate = 0,
|
|
387
|
+
replaysOnErrorSampleRate = 0,
|
|
388
|
+
maskAllInputs = true,
|
|
389
|
+
blockClass = "rr-block",
|
|
390
|
+
ignoreClass = "rr-ignore",
|
|
391
|
+
maskTextClass = "rr-mask",
|
|
381
392
|
}) {
|
|
382
393
|
DSN = dsn;
|
|
383
394
|
APP_ENV = app_env;
|
|
@@ -409,6 +420,15 @@ export function register({
|
|
|
409
420
|
// Browser: Set up full instrumentation (errors, breadcrumbs, HTTP, navigation)
|
|
410
421
|
if (isBrowser) {
|
|
411
422
|
setupBrowserInstrumentation();
|
|
423
|
+
initReplay({
|
|
424
|
+
replaysSessionSampleRate,
|
|
425
|
+
replaysOnErrorSampleRate,
|
|
426
|
+
maskAllInputs,
|
|
427
|
+
blockClass,
|
|
428
|
+
ignoreClass,
|
|
429
|
+
maskTextClass,
|
|
430
|
+
debug,
|
|
431
|
+
});
|
|
412
432
|
}
|
|
413
433
|
|
|
414
434
|
// Node.js: Set up process error handlers
|
|
@@ -448,6 +468,12 @@ export async function captureException(error, context = {}) {
|
|
|
448
468
|
sdk: getSdkMetadata(),
|
|
449
469
|
};
|
|
450
470
|
|
|
471
|
+
const replay = flushReplayForEvent(DSN, event.event_id);
|
|
472
|
+
if (replay) {
|
|
473
|
+
event.replay_id = replay.replay_id;
|
|
474
|
+
event.session_id = replay.session_id;
|
|
475
|
+
}
|
|
476
|
+
|
|
451
477
|
if (RELEASE) {
|
|
452
478
|
event.release = RELEASE;
|
|
453
479
|
}
|
|
@@ -487,6 +513,10 @@ export async function captureException(error, context = {}) {
|
|
|
487
513
|
}
|
|
488
514
|
|
|
489
515
|
event.contexts = await buildEventContexts(context);
|
|
516
|
+
const replayContext = getReplayContext();
|
|
517
|
+
if (replayContext) {
|
|
518
|
+
event.contexts.replay = replayContext;
|
|
519
|
+
}
|
|
490
520
|
|
|
491
521
|
const bcs = getBreadcrumbsSnapshot();
|
|
492
522
|
if (bcs.length > 0) {
|
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
|
@@ -25,7 +25,7 @@ function parseStackLine(line) {
|
|
|
25
25
|
const trimmed = line.trim();
|
|
26
26
|
if (!trimmed.startsWith("at ")) return null;
|
|
27
27
|
|
|
28
|
-
const withFn = trimmed.match(/^at\s+(.*?)\s+\((.*?):(\d+):(\d+)\)$/);
|
|
28
|
+
const withFn = trimmed.match(/^at\s+(?:async\s+)?(.*?)\s+\((.*?):(\d+):(\d+)\)$/);
|
|
29
29
|
if (withFn) {
|
|
30
30
|
return {
|
|
31
31
|
function: withFn[1] || "<anonymous>",
|
|
@@ -35,7 +35,7 @@ function parseStackLine(line) {
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const withoutFn = trimmed.match(/^at\s+(.*?):(\d+):(\d+)$/);
|
|
38
|
+
const withoutFn = trimmed.match(/^at\s+(?:async\s+)?(.*?):(\d+):(\d+)$/);
|
|
39
39
|
if (withoutFn) {
|
|
40
40
|
return {
|
|
41
41
|
function: "<anonymous>",
|
|
@@ -111,14 +111,16 @@ function applySourceContext(frame, sourceLines, lineno) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
let nodeModulesPromise = null;
|
|
114
|
+
const importNodeBuiltin = (specifier) =>
|
|
115
|
+
new Function("specifier", "return import(specifier)")(specifier);
|
|
114
116
|
|
|
115
117
|
function getNodeModules() {
|
|
116
118
|
if (!isNode) return Promise.resolve(null);
|
|
117
119
|
if (!nodeModulesPromise) {
|
|
118
120
|
nodeModulesPromise = (async () => {
|
|
119
121
|
try {
|
|
120
|
-
const { createRequire } = await
|
|
121
|
-
const { fileURLToPath } = await
|
|
122
|
+
const { createRequire } = await importNodeBuiltin("module");
|
|
123
|
+
const { fileURLToPath } = await importNodeBuiltin("url");
|
|
122
124
|
const req = createRequire(fileURLToPath(import.meta.url));
|
|
123
125
|
return {
|
|
124
126
|
fs: req("fs"),
|
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
|
+
}
|