@watchforge/browser 0.1.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/src/client.js ADDED
@@ -0,0 +1,628 @@
1
+ import { sendEvent, parseDsn } from "./transport.js";
2
+
3
+ let DSN = null;
4
+ let APP_ENV = "production";
5
+ let RELEASE = null;
6
+ let DEBUG = false;
7
+
8
+ // Detect environment
9
+ const isBrowser = typeof window !== "undefined";
10
+ const isNode =
11
+ typeof process !== "undefined" && process.versions && process.versions.node;
12
+
13
+ // In-memory breadcrumb buffer (shared for all events in this process / page)
14
+ const MAX_BREADCRUMBS = 100;
15
+ let breadcrumbs = [];
16
+
17
+ function addBreadcrumb(breadcrumb) {
18
+ const entry = {
19
+ level: "info",
20
+ category: "log",
21
+ data: {},
22
+ ...breadcrumb,
23
+ timestamp: new Date().toISOString(),
24
+ };
25
+
26
+ breadcrumbs.push(entry);
27
+ if (breadcrumbs.length > MAX_BREADCRUMBS) {
28
+ breadcrumbs = breadcrumbs.slice(-MAX_BREADCRUMBS);
29
+ }
30
+ }
31
+
32
+ function getBreadcrumbsSnapshot() {
33
+ return breadcrumbs.slice();
34
+ }
35
+
36
+ // Simple browser environment detectors (best-effort, not 100% accurate)
37
+ function getBrowserContext() {
38
+ if (!isBrowser) return null;
39
+
40
+ const ua = navigator.userAgent || "";
41
+ let name = "Unknown";
42
+ if (/chrome|crios|crmo/i.test(ua) && !/edge|edg\//i.test(ua)) {
43
+ name = "Chrome";
44
+ } else if (/safari/i.test(ua) && !/chrome|crios|crmo/i.test(ua)) {
45
+ name = "Safari";
46
+ } else if (/firefox|fxios/i.test(ua)) {
47
+ name = "Firefox";
48
+ } else if (/edg\//i.test(ua)) {
49
+ name = "Edge";
50
+ }
51
+
52
+ const versionMatch = ua.match(/(chrome|crios|firefox|fxios|safari|edg)\/([\d.]+)/i);
53
+ const version = versionMatch ? versionMatch[2] : null;
54
+
55
+ const language =
56
+ (navigator.languages && navigator.languages[0]) ||
57
+ navigator.language ||
58
+ null;
59
+
60
+ let timezone = null;
61
+ try {
62
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || null;
63
+ } catch {
64
+ timezone = null;
65
+ }
66
+
67
+ return {
68
+ name,
69
+ version,
70
+ user_agent: ua,
71
+ language,
72
+ timezone,
73
+ };
74
+ }
75
+
76
+ function getOsContext() {
77
+ if (!isBrowser) return null;
78
+ const ua = navigator.userAgent || "";
79
+ let name = "Unknown";
80
+ if (/windows/i.test(ua)) name = "Windows";
81
+ else if (/mac os x/i.test(ua)) name = "macOS";
82
+ else if (/android/i.test(ua)) name = "Android";
83
+ else if (/iphone|ipad|ipod/i.test(ua)) name = "iOS";
84
+ else if (/linux/i.test(ua)) name = "Linux";
85
+
86
+ return {
87
+ name,
88
+ version: null,
89
+ };
90
+ }
91
+
92
+ function getDeviceContext() {
93
+ if (!isBrowser) return null;
94
+ const ua = navigator.userAgent || "";
95
+ const isMobile = /iphone|ipad|ipod|android|mobile/i.test(ua);
96
+ const family = isMobile ? "mobile" : "desktop";
97
+
98
+ const viewport =
99
+ typeof window !== "undefined"
100
+ ? { width: window.innerWidth, height: window.innerHeight }
101
+ : null;
102
+ const screenInfo =
103
+ typeof window !== "undefined" && window.screen
104
+ ? { width: window.screen.width, height: window.screen.height }
105
+ : null;
106
+
107
+ const ctx = { family };
108
+ if (viewport) {
109
+ ctx.viewport_width = viewport.width;
110
+ ctx.viewport_height = viewport.height;
111
+ }
112
+ if (screenInfo) {
113
+ ctx.screen_width = screenInfo.width;
114
+ ctx.screen_height = screenInfo.height;
115
+ }
116
+
117
+ return ctx;
118
+ }
119
+
120
+ function getRuntimeContext() {
121
+ if (isNode) {
122
+ return {
123
+ name: "node",
124
+ version: process.version,
125
+ };
126
+ }
127
+ if (isBrowser) {
128
+ return {
129
+ name: "javascript",
130
+ version: null,
131
+ };
132
+ }
133
+ return null;
134
+ }
135
+
136
+ function setupBrowserInstrumentation() {
137
+ if (!isBrowser) return;
138
+
139
+ // Global error handlers (already existed but keep here with breadcrumbs)
140
+ window.onerror = function (msg, url, line, col, error) {
141
+ addBreadcrumb({
142
+ type: "error",
143
+ level: "error",
144
+ category: "window.onerror",
145
+ message: String(msg),
146
+ data: { url, line, col },
147
+ });
148
+ captureException(error || msg);
149
+ };
150
+
151
+ window.onunhandledrejection = function (event) {
152
+ addBreadcrumb({
153
+ type: "error",
154
+ level: "error",
155
+ category: "unhandledrejection",
156
+ message: String(event.reason),
157
+ data: {},
158
+ });
159
+ captureException(event.reason);
160
+ };
161
+
162
+ // Console breadcrumbs
163
+ ["log", "info", "warn", "error", "debug"].forEach((level) => {
164
+ if (!console[level]) return;
165
+ const original = console[level].bind(console);
166
+ console[level] = (...args) => {
167
+ try {
168
+ addBreadcrumb({
169
+ type: "log",
170
+ level,
171
+ category: "console",
172
+ message: args.map(String).join(" "),
173
+ data: {},
174
+ });
175
+ } catch (_) {
176
+ // best-effort, never break console
177
+ }
178
+ original(...args);
179
+ };
180
+ });
181
+
182
+ // Click breadcrumbs
183
+ window.addEventListener(
184
+ "click",
185
+ (event) => {
186
+ try {
187
+ const target = event.target;
188
+ if (!target) return;
189
+ const element = target.closest
190
+ ? target.closest("*")
191
+ : target;
192
+ const tag = element && element.tagName;
193
+ const text =
194
+ element && element.innerText
195
+ ? element.innerText.trim().slice(0, 100)
196
+ : null;
197
+ const id = element && element.id;
198
+ const className = element && element.className;
199
+
200
+ addBreadcrumb({
201
+ type: "user",
202
+ level: "info",
203
+ category: "ui.click",
204
+ message: tag ? `Click on <${tag.toLowerCase()}>` : "Click",
205
+ data: { id, className, text },
206
+ });
207
+ } catch (_) {
208
+ // ignore
209
+ }
210
+ },
211
+ true
212
+ );
213
+
214
+ // Navigation breadcrumbs (history API + hash changes)
215
+ const oldHref = window.location.href;
216
+ addBreadcrumb({
217
+ type: "navigation",
218
+ level: "info",
219
+ category: "navigation",
220
+ message: "Page loaded",
221
+ data: {
222
+ from: null,
223
+ to: oldHref,
224
+ },
225
+ });
226
+
227
+ function recordNavigation(from, to) {
228
+ if (from === to) return;
229
+ addBreadcrumb({
230
+ type: "navigation",
231
+ level: "info",
232
+ category: "navigation",
233
+ message: `${from} -> ${to}`,
234
+ data: { from, to },
235
+ });
236
+ }
237
+
238
+ const originalPushState = history.pushState;
239
+ history.pushState = function (...args) {
240
+ const from = window.location.href;
241
+ originalPushState.apply(history, args);
242
+ const to = window.location.href;
243
+ recordNavigation(from, to);
244
+ };
245
+
246
+ const originalReplaceState = history.replaceState;
247
+ history.replaceState = function (...args) {
248
+ const from = window.location.href;
249
+ originalReplaceState.apply(history, args);
250
+ const to = window.location.href;
251
+ recordNavigation(from, to);
252
+ };
253
+
254
+ window.addEventListener("hashchange", (event) => {
255
+ recordNavigation(event.oldURL, event.newURL);
256
+ });
257
+
258
+ // HTTP breadcrumbs: fetch
259
+ if (typeof window.fetch === "function") {
260
+ const originalFetch = window.fetch.bind(window);
261
+ window.fetch = async (input, init = {}) => {
262
+ const method = (init.method || "GET").toUpperCase();
263
+ const url = typeof input === "string" ? input : input.url;
264
+ const start = Date.now();
265
+ try {
266
+ const response = await originalFetch(input, init);
267
+ const duration = Date.now() - start;
268
+ addBreadcrumb({
269
+ type: "http",
270
+ level: "info",
271
+ category: "http",
272
+ message: `${method} ${url}`,
273
+ data: {
274
+ url,
275
+ method,
276
+ status_code: response.status,
277
+ duration_ms: duration,
278
+ },
279
+ });
280
+ return response;
281
+ } catch (error) {
282
+ const duration = Date.now() - start;
283
+ addBreadcrumb({
284
+ type: "http",
285
+ level: "error",
286
+ category: "http",
287
+ message: `${method} ${url} - failed`,
288
+ data: {
289
+ url,
290
+ method,
291
+ error: String(error),
292
+ duration_ms: duration,
293
+ },
294
+ });
295
+ throw error;
296
+ }
297
+ };
298
+ }
299
+
300
+ // HTTP breadcrumbs: XHR
301
+ if (typeof XMLHttpRequest !== "undefined") {
302
+ const OriginalXHR = XMLHttpRequest;
303
+ function WrappedXHR() {
304
+ const xhr = new OriginalXHR();
305
+ let url = null;
306
+ let method = null;
307
+
308
+ const origOpen = xhr.open;
309
+ xhr.open = function (m, u, ...rest) {
310
+ method = m;
311
+ url = u;
312
+ origOpen.call(xhr, m, u, ...rest);
313
+ };
314
+
315
+ const origSend = xhr.send;
316
+ xhr.send = function (...sendArgs) {
317
+ const start = Date.now();
318
+ xhr.addEventListener("loadend", () => {
319
+ const duration = Date.now() - start;
320
+ addBreadcrumb({
321
+ type: "http",
322
+ level: "info",
323
+ category: "http",
324
+ message: `${method} ${url}`,
325
+ data: {
326
+ url,
327
+ method,
328
+ status_code: xhr.status,
329
+ duration_ms: duration,
330
+ },
331
+ });
332
+ });
333
+ origSend.apply(xhr, sendArgs);
334
+ };
335
+
336
+ return xhr;
337
+ }
338
+ // eslint-disable-next-line no-global-assign
339
+ XMLHttpRequest = WrappedXHR;
340
+ }
341
+ }
342
+
343
+ // Parse a JavaScript Error.stack string into structured frames compatible with backend
344
+ function buildStacktraceFromError(error) {
345
+ if (!error || !error.stack || typeof error.stack !== "string") {
346
+ return null;
347
+ }
348
+
349
+ const stackLines = error.stack.split("\n");
350
+ const frames = [];
351
+
352
+ for (let i = 1; i < stackLines.length; i++) {
353
+ const line = stackLines[i].trim();
354
+ // Examples:
355
+ // at funcName (http://localhost:5173/src/App.tsx:10:15)
356
+ // at http://localhost:5173/assets/index-abc123.js:1:1234
357
+ const match =
358
+ line.match(/^at\s+(.*?)\s+\((.*?):(\d+):(\d+)\)$/) ||
359
+ line.match(/^at\s+(.*?):(\d+):(\d+)$/);
360
+
361
+ if (!match) continue;
362
+
363
+ let functionName;
364
+ let file;
365
+ let lineNo;
366
+ let colNo;
367
+
368
+ if (match.length === 5) {
369
+ functionName = match[1] || "<anonymous>";
370
+ file = match[2];
371
+ lineNo = parseInt(match[3], 10);
372
+ colNo = parseInt(match[4], 10);
373
+ } else {
374
+ functionName = "<anonymous>";
375
+ file = match[1];
376
+ lineNo = parseInt(match[2], 10);
377
+ colNo = parseInt(match[3], 10);
378
+ }
379
+
380
+ const filename = file.split("/").pop() || file;
381
+
382
+ frames.push({
383
+ filename,
384
+ abs_path: file,
385
+ lineno: lineNo,
386
+ colno: colNo,
387
+ function: functionName,
388
+ module: null,
389
+ context_line: null,
390
+ pre_context: [],
391
+ post_context: [],
392
+ vars: {},
393
+ in_app: true,
394
+ });
395
+ }
396
+
397
+ if (!frames.length) {
398
+ return null;
399
+ }
400
+
401
+ // Backend expects oldest frame first, newest last; JS stacks are newest first,
402
+ // so we reverse to be consistent with Python-style traces.
403
+ frames.reverse();
404
+
405
+ return { frames };
406
+ }
407
+
408
+ import { initTracing } from "./tracing.js";
409
+
410
+ export function register({
411
+ dsn,
412
+ app_env = "production",
413
+ release = null,
414
+ debug = false,
415
+ }) {
416
+ DSN = dsn;
417
+ APP_ENV = app_env;
418
+ RELEASE = release;
419
+ DEBUG = debug;
420
+
421
+ // Initialize tracing
422
+ initTracing(dsn, app_env, debug);
423
+
424
+ if (DEBUG) {
425
+ // Parse DSN to show API URL in debug
426
+ const parsed = parseDsn(dsn);
427
+ const apiUrl = parsed ? parsed.apiUrl : "default";
428
+ const envApiUrl =
429
+ (isNode && process.env.WATCHFORGE_API_URL) ||
430
+ (isBrowser &&
431
+ typeof window !== "undefined" &&
432
+ window.WATCHFORGE_API_URL) ||
433
+ null;
434
+ console.log("WatchForge SDK initialized", {
435
+ dsn: dsn.substring(0, 50) + "...",
436
+ app_env,
437
+ release: release || "not set",
438
+ apiUrl: envApiUrl || apiUrl,
439
+ source: envApiUrl ? "environment variable" : "DSN-derived",
440
+ });
441
+ }
442
+
443
+ // Browser: Set up full instrumentation (errors, breadcrumbs, HTTP, navigation)
444
+ if (isBrowser) {
445
+ setupBrowserInstrumentation();
446
+ }
447
+
448
+ // Node.js: Set up process error handlers
449
+ if (isNode) {
450
+ process.on("uncaughtException", (error) => {
451
+ captureException(error);
452
+ });
453
+
454
+ process.on("unhandledRejection", (reason) => {
455
+ captureException(reason);
456
+ });
457
+ }
458
+ }
459
+
460
+ // Deprecated: Use register() instead
461
+ export function init(options) {
462
+ console.warn("WatchForge SDK: init() is deprecated. Use register() instead.");
463
+ return register(options);
464
+ }
465
+
466
+ export function captureException(error, context = {}) {
467
+ if (!DSN) return;
468
+
469
+ const stacktrace = buildStacktraceFromError(error);
470
+
471
+ const event = {
472
+ event_id: generateEventId(),
473
+ level: "error",
474
+ environment: APP_ENV,
475
+ message: error?.message || String(error),
476
+ stacktrace: stacktrace || error?.stack,
477
+ timestamp: new Date().toISOString(),
478
+ platform: isBrowser ? "javascript" : "node",
479
+ };
480
+
481
+ // Add release if set
482
+ if (RELEASE) {
483
+ event.release = RELEASE;
484
+ }
485
+
486
+ // Add context
487
+ if (context.user) {
488
+ event.user = context.user;
489
+ }
490
+ if (context.request) {
491
+ event.request = context.request;
492
+ }
493
+ if (context.tags) {
494
+ event.tags = context.tags;
495
+ }
496
+ if (context.extra) {
497
+ event.extra = context.extra;
498
+ }
499
+ // In browser, auto-populate basic request + URL tags if not provided
500
+ if (isBrowser) {
501
+ const url = typeof window !== "undefined" ? window.location.href : undefined;
502
+ const referrer =
503
+ typeof document !== "undefined" ? document.referrer || undefined : undefined;
504
+ if (!event.request && url) {
505
+ event.request = {
506
+ url,
507
+ method: "GET",
508
+ headers: {},
509
+ query: {},
510
+ body: undefined,
511
+ };
512
+ }
513
+ event.tags = {
514
+ ...(event.tags || {}),
515
+ url: url || (event.tags && event.tags.url),
516
+ referrer: referrer || (event.tags && event.tags.referrer),
517
+ };
518
+ }
519
+ // Attach runtime + environment contexts (Sentry-like)
520
+ const runtime = getRuntimeContext();
521
+ const browser = getBrowserContext();
522
+ const os = getOsContext();
523
+ const device = getDeviceContext();
524
+
525
+ event.contexts = {
526
+ ...(context.contexts || {}),
527
+ ...(runtime ? { runtime } : {}),
528
+ ...(browser ? { browser } : {}),
529
+ ...(os ? { os } : {}),
530
+ ...(device ? { device } : {}),
531
+ };
532
+
533
+ // Attach breadcrumbs (copied snapshot so later mutations don't affect this event)
534
+ const bcs = getBreadcrumbsSnapshot();
535
+ if (bcs.length > 0) {
536
+ event.breadcrumbs = bcs;
537
+ }
538
+
539
+ if (DEBUG) {
540
+ console.log("WatchForge SDK - Capturing exception:", event);
541
+ }
542
+
543
+ sendEvent(DSN, event);
544
+ }
545
+
546
+ export function captureMessage(message, level = "info", context = {}) {
547
+ if (!DSN) return;
548
+
549
+ const event = {
550
+ event_id: generateEventId(),
551
+ level,
552
+ environment: APP_ENV,
553
+ message,
554
+ timestamp: new Date().toISOString(),
555
+ platform: isBrowser ? "javascript" : "node",
556
+ };
557
+
558
+ // Add release if set
559
+ if (RELEASE) {
560
+ event.release = RELEASE;
561
+ }
562
+
563
+ // Add context
564
+ if (context.user) {
565
+ event.user = context.user;
566
+ }
567
+ if (context.request) {
568
+ event.request = context.request;
569
+ }
570
+ if (context.tags) {
571
+ event.tags = context.tags;
572
+ }
573
+ if (context.extra) {
574
+ event.extra = context.extra;
575
+ }
576
+ if (isBrowser) {
577
+ const url = typeof window !== "undefined" ? window.location.href : undefined;
578
+ const referrer =
579
+ typeof document !== "undefined" ? document.referrer || undefined : undefined;
580
+ if (!event.request && url) {
581
+ event.request = {
582
+ url,
583
+ method: "GET",
584
+ headers: {},
585
+ query: {},
586
+ body: undefined,
587
+ };
588
+ }
589
+ event.tags = {
590
+ ...(event.tags || {}),
591
+ url: url || (event.tags && event.tags.url),
592
+ referrer: referrer || (event.tags && event.tags.referrer),
593
+ };
594
+ }
595
+ // Attach runtime + environment contexts
596
+ const runtime = getRuntimeContext();
597
+ const browser = getBrowserContext();
598
+ const os = getOsContext();
599
+ const device = getDeviceContext();
600
+
601
+ event.contexts = {
602
+ ...(context.contexts || {}),
603
+ ...(runtime ? { runtime } : {}),
604
+ ...(browser ? { browser } : {}),
605
+ ...(os ? { os } : {}),
606
+ ...(device ? { device } : {}),
607
+ };
608
+
609
+ const bcs = getBreadcrumbsSnapshot();
610
+ if (bcs.length > 0) {
611
+ event.breadcrumbs = bcs;
612
+ }
613
+
614
+ if (DEBUG) {
615
+ console.log("WatchForge SDK - Capturing message:", event);
616
+ }
617
+
618
+ sendEvent(DSN, event);
619
+ }
620
+
621
+ // Generate unique event ID
622
+ function generateEventId() {
623
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
624
+ const r = (Math.random() * 16) | 0;
625
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
626
+ return v.toString(16);
627
+ });
628
+ }
package/src/express.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Express.js integration for WatchForge SDK
3
+ *
4
+ * Usage:
5
+ * import express from "express";
6
+ * import { register } from "@watchforge/browser";
7
+ * import { expressMiddleware } from "@watchforge/browser/express";
8
+ *
9
+ * register({ dsn: "...", app_env: "production" });
10
+ * app.use(express.json());
11
+ * app.use(expressMiddleware());
12
+ */
13
+
14
+ import { captureException } from "./client.js";
15
+
16
+ export function expressMiddleware() {
17
+ return function (err, req, res, next) {
18
+ // Extract IP address
19
+ const ip =
20
+ req.ip ||
21
+ req.connection?.remoteAddress ||
22
+ req.socket?.remoteAddress ||
23
+ (req.connection?.socket ? req.connection.socket.remoteAddress : null) ||
24
+ req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
25
+ req.headers["x-real-ip"] ||
26
+ null;
27
+
28
+ // Build user context
29
+ const user = req.user
30
+ ? {
31
+ id: String(req.user.id),
32
+ email: req.user.email || null,
33
+ username: req.user.username || null,
34
+ ip_address: ip,
35
+ }
36
+ : null;
37
+
38
+ // Build request context (matches backend's request data schema)
39
+ const request = {
40
+ url: `${req.protocol}://${req.get("host")}${req.originalUrl || req.url}`,
41
+ method: req.method,
42
+ headers: sanitizeHeaders(req.headers),
43
+ query: req.query || {},
44
+ body: req.body || {},
45
+ ip: ip,
46
+ };
47
+
48
+ // Capture exception with rich API-level context
49
+ captureException(err, {
50
+ user,
51
+ request,
52
+ tags: {
53
+ framework: "express",
54
+ route: req.route?.path || req.path,
55
+ status_code: res && res.statusCode,
56
+ },
57
+ extra: {
58
+ params: req.params || {},
59
+ },
60
+ });
61
+
62
+ // Call next to continue error handling
63
+ next(err);
64
+ };
65
+ }
66
+
67
+ function sanitizeHeaders(headers) {
68
+ const sanitized = {};
69
+ for (const [key, value] of Object.entries(headers)) {
70
+ const lowerKey = key.toLowerCase();
71
+ // Exclude sensitive headers
72
+ if (!["authorization", "cookie", "x-csrftoken", "x-api-key"].includes(lowerKey)) {
73
+ sanitized[key] = value;
74
+ }
75
+ }
76
+ return sanitized;
77
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // Export main functions
2
+ export { register, captureException, captureMessage } from "./client.js";
3
+
4
+ // Export deprecated init for backward compatibility
5
+ export { init } from "./client.js";
6
+
7
+ // Export tracing functions
8
+ export { startTransaction, getCurrentTransaction, finishTransaction, Transaction, Span } from "./tracing.js";
9
+
10
+ // Export framework integrations
11
+ export { expressMiddleware } from "./express.js";
12
+ export { ErrorBoundary } from "./react.js";