@sentienguard/apm 1.0.3 → 1.0.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/package.json CHANGED
@@ -1,54 +1,54 @@
1
- {
2
- "name": "@sentienguard/apm",
3
- "version": "1.0.3",
4
- "description": "SentienGuard APM SDK - Minimal, production-safe application performance monitoring",
5
- "main": "src/index.js",
6
- "type": "module",
7
- "browser": "./src/browser.js",
8
- "exports": {
9
- ".": {
10
- "browser": "./src/browser.js",
11
- "default": "./src/index.js"
12
- }
13
- },
14
- "scripts": {
15
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
16
- "test:load": "node tests/load.test.js"
17
- },
18
- "keywords": [
19
- "apm",
20
- "monitoring",
21
- "performance",
22
- "metrics",
23
- "sentienguard",
24
- "mongodb",
25
- "circuit-breaker",
26
- "observability"
27
- ],
28
- "author": "SentienGuard",
29
- "license": "MIT",
30
- "publishConfig": {
31
- "access": "public"
32
- },
33
- "engines": {
34
- "node": ">=16.0.0"
35
- },
36
- "peerDependencies": {
37
- "mongoose": ">=6.0.0",
38
- "opossum": ">=8.0.0"
39
- },
40
- "peerDependenciesMeta": {
41
- "mongoose": {
42
- "optional": true
43
- },
44
- "opossum": {
45
- "optional": true
46
- }
47
- },
48
- "devDependencies": {
49
- "jest": "^29.7.0"
50
- },
51
- "files": [
52
- "src/**/*"
53
- ]
54
- }
1
+ {
2
+ "name": "@sentienguard/apm",
3
+ "version": "1.0.5",
4
+ "description": "SentienGuard APM SDK - Minimal, production-safe application performance monitoring",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "browser": "./src/browser.js",
8
+ "exports": {
9
+ ".": {
10
+ "browser": "./src/browser.js",
11
+ "default": "./src/index.js"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
16
+ "test:load": "node tests/load.test.js"
17
+ },
18
+ "keywords": [
19
+ "apm",
20
+ "monitoring",
21
+ "performance",
22
+ "metrics",
23
+ "sentienguard",
24
+ "mongodb",
25
+ "circuit-breaker",
26
+ "observability"
27
+ ],
28
+ "author": "SentienGuard",
29
+ "license": "MIT",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "engines": {
34
+ "node": ">=16.0.0"
35
+ },
36
+ "peerDependencies": {
37
+ "mongoose": ">=6.0.0",
38
+ "opossum": ">=8.0.0"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "mongoose": {
42
+ "optional": true
43
+ },
44
+ "opossum": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "devDependencies": {
49
+ "jest": "^29.7.0"
50
+ },
51
+ "files": [
52
+ "src/**/*"
53
+ ]
54
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Browser Metrics Aggregator
3
+ *
4
+ * Batches browser metrics in memory and flushes them periodically.
5
+ * Produces payloads compatible with the backend's ingestApmData controller.
6
+ *
7
+ * Tracks:
8
+ * - Outgoing fetch/XHR as requests (keyed by method:route)
9
+ * - Unhandled JS errors
10
+ * - Web Vitals (LCP, FID, INP, CLS)
11
+ * - Page load timing
12
+ * - SPA route changes
13
+ */
14
+
15
+ import config from './config.js';
16
+
17
+ /**
18
+ * Create an aggregation key for request metrics
19
+ */
20
+ function createRequestKey(method, route) {
21
+ return `${method}:${route}`;
22
+ }
23
+
24
+ export class BrowserMetricsAggregator {
25
+ constructor() {
26
+ this.maxRoutes = config.maxRoutes || 100;
27
+ this.reset();
28
+ }
29
+
30
+ reset() {
31
+ // Outgoing HTTP requests (fetch/XHR) aggregated by method:route
32
+ // Same shape as Node.js SDK requests: { method, route, count, errorCount, latency }
33
+ this.requests = new Map();
34
+
35
+ // Web Vitals - latest values per metric name
36
+ this.webVitals = {};
37
+
38
+ // Unhandled JS error count
39
+ this.jsErrors = 0;
40
+
41
+ // SPA route change events
42
+ this.routeChanges = [];
43
+
44
+ // Page load timing entries
45
+ this.pageLoads = [];
46
+ }
47
+
48
+ /**
49
+ * Record an outgoing HTTP request (fetch/XHR)
50
+ *
51
+ * @param {string} method - HTTP method (GET, POST, PATCH, DELETE)
52
+ * @param {string} route - URL pathname (e.g., /api/todos)
53
+ * @param {number} latency - Response time in ms
54
+ * @param {boolean} isError - Whether the request failed (4xx/5xx or network error)
55
+ */
56
+ recordRequest(method, route, latency, isError = false) {
57
+ const key = createRequestKey(method, route);
58
+
59
+ let metric = this.requests.get(key);
60
+ if (!metric) {
61
+ if (this.requests.size >= this.maxRoutes) return;
62
+ metric = {
63
+ method,
64
+ route,
65
+ count: 0,
66
+ errorCount: 0,
67
+ latency: { sum: 0, min: Infinity, max: 0 }
68
+ };
69
+ this.requests.set(key, metric);
70
+ }
71
+
72
+ metric.count++;
73
+ if (isError) metric.errorCount++;
74
+ metric.latency.sum += latency;
75
+ metric.latency.min = Math.min(metric.latency.min, latency);
76
+ metric.latency.max = Math.max(metric.latency.max, latency);
77
+ }
78
+
79
+ /**
80
+ * Record an unhandled JS error
81
+ */
82
+ recordError() {
83
+ this.jsErrors++;
84
+ }
85
+
86
+ /**
87
+ * Record page load timing from Navigation Timing API
88
+ * @param {Object} timing
89
+ */
90
+ recordPageLoad(timing) {
91
+ this.pageLoads.push(timing);
92
+ }
93
+
94
+ /**
95
+ * Record a Web Vital metric value
96
+ * @param {string} name - Metric name (lcp, fid, inp, cls)
97
+ * @param {number} value - Metric value
98
+ */
99
+ recordWebVital(name, value) {
100
+ this.webVitals[name] = value;
101
+ }
102
+
103
+ /**
104
+ * Record a SPA route change
105
+ * @param {string} from - Previous route path
106
+ * @param {string} to - New route path
107
+ */
108
+ recordRouteChange(from, to) {
109
+ if (this.routeChanges.length >= 100) return;
110
+ this.routeChanges.push({ from, to, timestamp: Date.now() });
111
+ }
112
+
113
+ hasData() {
114
+ return this.requests.size > 0 ||
115
+ Object.keys(this.webVitals).length > 0 ||
116
+ this.jsErrors > 0 ||
117
+ this.routeChanges.length > 0 ||
118
+ this.pageLoads.length > 0;
119
+ }
120
+
121
+ /**
122
+ * Flush aggregated metrics and return payload for the backend.
123
+ * Payload format matches the backend's ingestApmData expectations:
124
+ * { interval, service, environment, requests[], dependencies[] }
125
+ */
126
+ flush() {
127
+ const requests = [];
128
+ for (const metric of this.requests.values()) {
129
+ requests.push({
130
+ method: metric.method,
131
+ route: metric.route,
132
+ count: metric.count,
133
+ errorCount: metric.errorCount,
134
+ latency: {
135
+ sum: Math.round(metric.latency.sum),
136
+ min: metric.latency.min === Infinity ? 0 : Math.round(metric.latency.min),
137
+ max: Math.round(metric.latency.max)
138
+ }
139
+ });
140
+ }
141
+
142
+ const payload = {
143
+ interval: `${config.flushInterval}s`,
144
+ service: config.service,
145
+ environment: config.environment,
146
+ requests,
147
+ dependencies: []
148
+ };
149
+
150
+ this.reset();
151
+ return payload;
152
+ }
153
+
154
+ getStats() {
155
+ return {
156
+ requestMetrics: this.requests.size,
157
+ webVitals: Object.keys(this.webVitals).length,
158
+ jsErrors: this.jsErrors,
159
+ routeChanges: this.routeChanges.length,
160
+ pageLoads: this.pageLoads.length
161
+ };
162
+ }
163
+ }
164
+
165
+ // Singleton
166
+ let instance = null;
167
+
168
+ export function getAggregator() {
169
+ if (!instance) {
170
+ instance = new BrowserMetricsAggregator();
171
+ }
172
+ return instance;
173
+ }
174
+
175
+ export default { BrowserMetricsAggregator, getAggregator };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * SentienGuard APM SDK - Browser Configuration
3
+ *
4
+ * Unlike the Node.js SDK which reads process.env, the browser SDK
5
+ * requires explicit configuration via the init() call.
6
+ */
7
+
8
+ const config = {
9
+ apiKey: '',
10
+ service: '',
11
+ environment: 'production',
12
+ endpoint: 'https://sentienguard-dev.the-algo.com/api/v1/apm/ingest',
13
+ flushInterval: 10,
14
+ maxRoutes: 100,
15
+ maxPayloadSize: 512 * 1024, // 512KB (smaller than Node SDK; sendBeacon has ~64KB limit)
16
+ enabled: true,
17
+ debug: false
18
+ };
19
+
20
+ /**
21
+ * Apply user-provided options to config
22
+ * @param {Object} options
23
+ * @param {string} options.apiKey - APM application key (required)
24
+ * @param {string} options.service - Service/app name (required)
25
+ * @param {string} [options.endpoint] - Backend ingest URL
26
+ * @param {string} [options.environment] - Environment name
27
+ * @param {number} [options.flushInterval] - Flush interval in seconds
28
+ * @param {boolean} [options.debug] - Enable debug logging
29
+ */
30
+ export function configure(options) {
31
+ if (!options || typeof options !== 'object') {
32
+ warn('init() requires a config object');
33
+ return;
34
+ }
35
+
36
+ if (options.apiKey) config.apiKey = String(options.apiKey);
37
+ if (options.service) config.service = String(options.service);
38
+ if (options.endpoint) config.endpoint = String(options.endpoint);
39
+ if (options.environment) config.environment = String(options.environment);
40
+ if (typeof options.flushInterval === 'number' && options.flushInterval > 0) {
41
+ config.flushInterval = options.flushInterval;
42
+ }
43
+ if (typeof options.maxRoutes === 'number') config.maxRoutes = options.maxRoutes;
44
+ if (typeof options.maxPayloadSize === 'number') config.maxPayloadSize = options.maxPayloadSize;
45
+ if (options.enabled === false) config.enabled = false;
46
+ if (typeof options.debug === 'boolean') config.debug = options.debug;
47
+ }
48
+
49
+ export function isEnabled() {
50
+ return config.enabled && !!config.apiKey && !!config.service;
51
+ }
52
+
53
+ export function getConfig() {
54
+ return { ...config };
55
+ }
56
+
57
+ export function debug(...args) {
58
+ if (config.debug) {
59
+ console.log('[SentienGuard APM]', ...args);
60
+ }
61
+ }
62
+
63
+ export function warn(...args) {
64
+ console.warn('[SentienGuard APM]', ...args);
65
+ }
66
+
67
+ export default config;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Browser Error Capture
3
+ *
4
+ * Captures unhandled JavaScript errors and promise rejections.
5
+ * Records error counts in the aggregator (same pattern as Node.js SDK).
6
+ *
7
+ * Uses window.addEventListener('error') and window.addEventListener('unhandledrejection')
8
+ * which are the standard browser equivalents of Node.js process.on('uncaughtException')
9
+ * and process.on('unhandledRejection').
10
+ */
11
+
12
+ import { getAggregator } from './aggregator.js';
13
+ import { debug } from './config.js';
14
+
15
+ let isCapturing = false;
16
+
17
+ function handleError(event) {
18
+ debug('Captured error:', event.message || event.error?.message || 'unknown');
19
+ getAggregator().recordError();
20
+ }
21
+
22
+ function handleRejection(event) {
23
+ const reason = event.reason;
24
+ const message = reason instanceof Error ? reason.message : String(reason);
25
+ debug('Captured unhandled rejection:', message);
26
+ getAggregator().recordError();
27
+ }
28
+
29
+ /**
30
+ * Start capturing unhandled errors and promise rejections
31
+ */
32
+ export function startErrorCapture() {
33
+ if (isCapturing) return;
34
+ if (typeof window === 'undefined') return;
35
+
36
+ window.addEventListener('error', handleError);
37
+ window.addEventListener('unhandledrejection', handleRejection);
38
+
39
+ isCapturing = true;
40
+ debug('Error capture enabled');
41
+ }
42
+
43
+ /**
44
+ * Stop capturing errors
45
+ */
46
+ export function stopErrorCapture() {
47
+ if (!isCapturing) return;
48
+
49
+ window.removeEventListener('error', handleError);
50
+ window.removeEventListener('unhandledrejection', handleRejection);
51
+
52
+ isCapturing = false;
53
+ debug('Error capture disabled');
54
+ }
55
+
56
+ export default { startErrorCapture, stopErrorCapture };
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Browser Network Instrumentation
3
+ *
4
+ * Monkey-patches window.fetch and XMLHttpRequest to track outgoing HTTP requests.
5
+ * Captured data is recorded as requests (method + pathname), matching the
6
+ * Node.js SDK's request format so the backend processes them identically.
7
+ */
8
+
9
+ import { getAggregator } from './aggregator.js';
10
+ import config, { debug } from './config.js';
11
+
12
+ let originalFetch = null;
13
+ let originalXhrOpen = null;
14
+ let originalXhrSend = null;
15
+ let isInstrumented = false;
16
+
17
+ /**
18
+ * Check if a URL should be excluded from tracking.
19
+ * Excludes the APM endpoint itself to prevent infinite metric loops.
20
+ */
21
+ function shouldExclude(url) {
22
+ if (!url || !config.endpoint) return false;
23
+ try {
24
+ const targetUrl = new URL(url, window.location.origin);
25
+ const endpointUrl = new URL(config.endpoint);
26
+ return targetUrl.origin === endpointUrl.origin &&
27
+ targetUrl.pathname === endpointUrl.pathname;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Extract the pathname from a URL.
35
+ * e.g., "http://localhost:4005/api/todos/1" → "/api/todos/1"
36
+ * e.g., "/api/todos" → "/api/todos"
37
+ */
38
+ function getPathname(url) {
39
+ try {
40
+ const parsed = new URL(url, window.location.origin);
41
+ return parsed.pathname;
42
+ } catch {
43
+ return '/unknown';
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Instrument window.fetch
49
+ */
50
+ function instrumentFetch() {
51
+ if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
52
+
53
+ originalFetch = window.fetch;
54
+
55
+ window.fetch = function (input, init) {
56
+ const url = typeof input === 'string'
57
+ ? input
58
+ : (input instanceof Request ? input.url : String(input));
59
+
60
+ if (shouldExclude(url)) {
61
+ return originalFetch.apply(this, arguments);
62
+ }
63
+
64
+ const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
65
+ const startTime = performance.now();
66
+ const pathname = getPathname(url);
67
+
68
+ return originalFetch.apply(this, arguments).then(
69
+ (response) => {
70
+ const latency = performance.now() - startTime;
71
+ const isError = !response.ok;
72
+
73
+ getAggregator().recordRequest(method, pathname, latency, isError);
74
+ debug(`Fetch: ${method} ${pathname} ${response.status} ${latency.toFixed(1)}ms`);
75
+
76
+ return response;
77
+ },
78
+ (error) => {
79
+ const latency = performance.now() - startTime;
80
+
81
+ getAggregator().recordRequest(method, pathname, latency, true);
82
+ debug(`Fetch error: ${method} ${pathname} ${latency.toFixed(1)}ms`);
83
+
84
+ throw error;
85
+ }
86
+ );
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Instrument XMLHttpRequest
92
+ */
93
+ function instrumentXHR() {
94
+ if (typeof XMLHttpRequest === 'undefined') return;
95
+
96
+ originalXhrOpen = XMLHttpRequest.prototype.open;
97
+ originalXhrSend = XMLHttpRequest.prototype.send;
98
+
99
+ XMLHttpRequest.prototype.open = function (method, url, ...rest) {
100
+ this._sgMethod = (method || 'GET').toUpperCase();
101
+ this._sgUrl = String(url);
102
+ return originalXhrOpen.apply(this, [method, url, ...rest]);
103
+ };
104
+
105
+ XMLHttpRequest.prototype.send = function (body) {
106
+ if (!this._sgUrl || shouldExclude(this._sgUrl)) {
107
+ return originalXhrSend.apply(this, arguments);
108
+ }
109
+
110
+ const startTime = performance.now();
111
+ const url = this._sgUrl;
112
+ const method = this._sgMethod;
113
+ const pathname = getPathname(url);
114
+
115
+ this.addEventListener('loadend', function () {
116
+ const latency = performance.now() - startTime;
117
+ const isError = this.status === 0 || this.status >= 400;
118
+
119
+ getAggregator().recordRequest(method, pathname, latency, isError);
120
+ debug(`XHR: ${method} ${pathname} ${this.status} ${latency.toFixed(1)}ms`);
121
+ });
122
+
123
+ return originalXhrSend.apply(this, arguments);
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Start network instrumentation (fetch + XHR)
129
+ */
130
+ export function instrumentNetwork() {
131
+ if (isInstrumented) return;
132
+ instrumentFetch();
133
+ instrumentXHR();
134
+ isInstrumented = true;
135
+ debug('Network instrumentation enabled');
136
+ }
137
+
138
+ /**
139
+ * Remove network instrumentation (for cleanup/testing)
140
+ */
141
+ export function uninstrumentNetwork() {
142
+ if (!isInstrumented) return;
143
+
144
+ if (originalFetch) {
145
+ window.fetch = originalFetch;
146
+ originalFetch = null;
147
+ }
148
+ if (originalXhrOpen) {
149
+ XMLHttpRequest.prototype.open = originalXhrOpen;
150
+ originalXhrOpen = null;
151
+ }
152
+ if (originalXhrSend) {
153
+ XMLHttpRequest.prototype.send = originalXhrSend;
154
+ originalXhrSend = null;
155
+ }
156
+
157
+ isInstrumented = false;
158
+ debug('Network instrumentation disabled');
159
+ }
160
+
161
+ export default { instrumentNetwork, uninstrumentNetwork };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * SPA Route Change Detection
3
+ *
4
+ * Tracks client-side navigation in Single Page Applications by
5
+ * intercepting History API methods and listening for popstate events.
6
+ *
7
+ * This is the standard approach used by all major APM SDKs:
8
+ * - Monkey-patches history.pushState and history.replaceState
9
+ * - Listens for popstate (browser back/forward navigation)
10
+ *
11
+ * Recorded route changes are flushed with the aggregated payload.
12
+ */
13
+
14
+ import { getAggregator } from './aggregator.js';
15
+ import { debug } from './config.js';
16
+
17
+ let originalPushState = null;
18
+ let originalReplaceState = null;
19
+ let currentRoute = null;
20
+ let isTracking = false;
21
+
22
+ function handleRouteChange() {
23
+ const newRoute = window.location.pathname;
24
+ if (newRoute === currentRoute) return;
25
+
26
+ const previousRoute = currentRoute;
27
+ currentRoute = newRoute;
28
+
29
+ getAggregator().recordRouteChange(previousRoute, newRoute);
30
+ debug(`Route change: ${previousRoute} -> ${newRoute}`);
31
+ }
32
+
33
+ /**
34
+ * Start tracking SPA route changes
35
+ */
36
+ export function startRouteTracking() {
37
+ if (isTracking) return;
38
+ if (typeof window === 'undefined' || typeof history === 'undefined') return;
39
+
40
+ currentRoute = window.location.pathname;
41
+
42
+ // Monkey-patch history.pushState
43
+ originalPushState = history.pushState;
44
+ history.pushState = function (...args) {
45
+ const result = originalPushState.apply(this, args);
46
+ handleRouteChange();
47
+ return result;
48
+ };
49
+
50
+ // Monkey-patch history.replaceState
51
+ originalReplaceState = history.replaceState;
52
+ history.replaceState = function (...args) {
53
+ const result = originalReplaceState.apply(this, args);
54
+ handleRouteChange();
55
+ return result;
56
+ };
57
+
58
+ // Listen for browser back/forward navigation
59
+ window.addEventListener('popstate', handleRouteChange);
60
+
61
+ isTracking = true;
62
+ debug('Route tracking enabled');
63
+ }
64
+
65
+ /**
66
+ * Stop tracking route changes and restore original History methods
67
+ */
68
+ export function stopRouteTracking() {
69
+ if (!isTracking) return;
70
+
71
+ if (originalPushState) {
72
+ history.pushState = originalPushState;
73
+ originalPushState = null;
74
+ }
75
+ if (originalReplaceState) {
76
+ history.replaceState = originalReplaceState;
77
+ originalReplaceState = null;
78
+ }
79
+
80
+ window.removeEventListener('popstate', handleRouteChange);
81
+
82
+ isTracking = false;
83
+ debug('Route tracking disabled');
84
+ }
85
+
86
+ export default { startRouteTracking, stopRouteTracking };
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Browser Transport Layer
3
+ *
4
+ * Handles periodic flush of aggregated metrics to the backend.
5
+ * Uses fetch() for normal sends (supports custom headers) and
6
+ * navigator.sendBeacon() for page unload (more reliable but no custom headers).
7
+ *
8
+ * Transport strategy (matches Datadog RUM pattern):
9
+ * - Normal flush: fetch() with X-APM-Key header
10
+ * - Unload flush: sendBeacon() with ?apmKey= query param
11
+ * - Listens on visibilitychange (NOT beforeunload/unload which are unreliable on mobile)
12
+ */
13
+
14
+ import { getAggregator } from './aggregator.js';
15
+ import config, { debug, warn, isEnabled } from './config.js';
16
+
17
+ let flushTimer = null;
18
+ let isRunning = false;
19
+ let consecutiveFailures = 0;
20
+
21
+ const MAX_CONSECUTIVE_FAILURES = 5;
22
+
23
+ /**
24
+ * Send payload to backend using fetch (supports custom headers)
25
+ */
26
+ async function sendToBackend(payload) {
27
+ const data = JSON.stringify(payload);
28
+
29
+ if (data.length > config.maxPayloadSize) {
30
+ warn(`Payload too large (${data.length} bytes), dropping`);
31
+ return;
32
+ }
33
+
34
+ const response = await fetch(config.endpoint, {
35
+ method: 'POST',
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ 'X-APM-Key': config.apiKey
39
+ },
40
+ body: data,
41
+ keepalive: true
42
+ });
43
+
44
+ if (!response.ok) {
45
+ throw new Error(`HTTP ${response.status}`);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Send payload using navigator.sendBeacon (for page unload).
51
+ * sendBeacon cannot set custom headers, so the API key is passed
52
+ * as a query parameter — same pattern as Datadog's clientToken.
53
+ */
54
+ function sendBeacon(payload) {
55
+ const data = JSON.stringify(payload);
56
+
57
+ if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
58
+ const url = `${config.endpoint}?apmKey=${encodeURIComponent(config.apiKey)}`;
59
+ const blob = new Blob([data], { type: 'application/json' });
60
+ return navigator.sendBeacon(url, blob);
61
+ }
62
+
63
+ // Fallback: fetch with keepalive (survives page unload in modern browsers)
64
+ try {
65
+ fetch(config.endpoint, {
66
+ method: 'POST',
67
+ headers: {
68
+ 'Content-Type': 'application/json',
69
+ 'X-APM-Key': config.apiKey
70
+ },
71
+ body: data,
72
+ keepalive: true
73
+ });
74
+ } catch {
75
+ // Best effort - data loss is acceptable on unload
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Perform a flush of aggregated metrics
81
+ */
82
+ export async function flush() {
83
+ if (!isEnabled()) return;
84
+
85
+ const aggregator = getAggregator();
86
+ if (!aggregator.hasData()) return;
87
+
88
+ const payload = aggregator.flush();
89
+ debug(`Flushing ${payload.dependencies.length} dependency metrics`);
90
+
91
+ try {
92
+ const startTime = performance.now();
93
+ await sendToBackend(payload);
94
+ const duration = Math.round(performance.now() - startTime);
95
+
96
+ debug(`Flush successful in ${duration}ms`);
97
+ consecutiveFailures = 0;
98
+ } catch (error) {
99
+ consecutiveFailures++;
100
+ warn(`Flush failed (attempt ${consecutiveFailures}): ${error.message}`);
101
+
102
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
103
+ warn('Max consecutive failures reached, data will be dropped until backend recovers');
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Start the periodic flush timer
110
+ */
111
+ export function startFlushing() {
112
+ if (isRunning || !isEnabled()) return;
113
+
114
+ const intervalMs = config.flushInterval * 1000;
115
+ flushTimer = setInterval(flush, intervalMs);
116
+ isRunning = true;
117
+ debug(`Flush timer started (every ${config.flushInterval}s)`);
118
+ }
119
+
120
+ /**
121
+ * Stop the periodic flush timer
122
+ */
123
+ export function stopFlushing() {
124
+ if (!isRunning) return;
125
+ if (flushTimer) {
126
+ clearInterval(flushTimer);
127
+ flushTimer = null;
128
+ }
129
+ isRunning = false;
130
+ debug('Flush timer stopped');
131
+ }
132
+
133
+ /**
134
+ * Setup page unload flush using visibilitychange event.
135
+ * visibilitychange is more reliable than beforeunload/unload on mobile.
136
+ */
137
+ export function setupUnloadFlush() {
138
+ if (typeof document === 'undefined') return;
139
+
140
+ document.addEventListener('visibilitychange', () => {
141
+ if (document.visibilityState === 'hidden') {
142
+ const aggregator = getAggregator();
143
+ if (aggregator.hasData()) {
144
+ const payload = aggregator.flush();
145
+ sendBeacon(payload);
146
+ debug('Unload flush sent via sendBeacon');
147
+ }
148
+ }
149
+ });
150
+ }
151
+
152
+ export default { startFlushing, stopFlushing, flush, setupUnloadFlush };
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Web Vitals Collection
3
+ *
4
+ * Captures Core Web Vitals and page load timing using browser-native APIs.
5
+ * Vendors the approach from Google's web-vitals library:
6
+ * - Uses PerformanceObserver with { buffered: true } to capture entries from before SDK init
7
+ * - Finalizes metrics on visibilitychange (metric values may update until page is hidden)
8
+ * - Handles INP calculation across multiple interactions
9
+ *
10
+ * Captured metrics:
11
+ * - LCP (Largest Contentful Paint) - loading performance
12
+ * - FID (First Input Delay) - interactivity (legacy, being replaced by INP)
13
+ * - INP (Interaction to Next Paint) - responsiveness
14
+ * - CLS (Cumulative Layout Shift) - visual stability
15
+ * - Navigation Timing - DNS, TCP, TTFB, DOM ready, page load
16
+ */
17
+
18
+ import { getAggregator } from './aggregator.js';
19
+ import { debug } from './config.js';
20
+
21
+ let observers = [];
22
+
23
+ /**
24
+ * Safely create a PerformanceObserver for a given entry type.
25
+ * Returns null if the browser doesn't support the entry type.
26
+ */
27
+ function createObserver(type, callback) {
28
+ try {
29
+ // Check if the entry type is supported
30
+ if (typeof PerformanceObserver === 'undefined') return null;
31
+ if (!PerformanceObserver.supportedEntryTypes?.includes(type)) return null;
32
+
33
+ const observer = new PerformanceObserver(callback);
34
+ observer.observe({ type, buffered: true });
35
+ observers.push(observer);
36
+ return observer;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Capture Largest Contentful Paint (LCP)
44
+ * LCP values may update as new larger elements paint; the last entry is the final LCP.
45
+ * The metric is finalized when the page becomes hidden.
46
+ */
47
+ function captureLCP() {
48
+ let lastValue = 0;
49
+
50
+ createObserver('largest-contentful-paint', (list) => {
51
+ const entries = list.getEntries();
52
+ const lastEntry = entries[entries.length - 1];
53
+ if (lastEntry) {
54
+ lastValue = lastEntry.startTime;
55
+ getAggregator().recordWebVital('lcp', Math.round(lastValue));
56
+ debug(`LCP: ${lastValue.toFixed(1)}ms`);
57
+ }
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Capture First Input Delay (FID)
63
+ * Only the first input event counts. One-shot metric.
64
+ */
65
+ function captureFID() {
66
+ createObserver('first-input', (list) => {
67
+ const entry = list.getEntries()[0];
68
+ if (entry) {
69
+ const value = entry.processingStart - entry.startTime;
70
+ getAggregator().recordWebVital('fid', Math.round(value));
71
+ debug(`FID: ${value.toFixed(1)}ms`);
72
+ }
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Capture Interaction to Next Paint (INP)
78
+ * INP is the worst interaction latency (at the 98th percentile if >=50 interactions).
79
+ * We track all interactions and report the highest as a conservative estimate.
80
+ */
81
+ function captureINP() {
82
+ const interactions = [];
83
+
84
+ createObserver('event', (list) => {
85
+ for (const entry of list.getEntries()) {
86
+ // Only count discrete interactions (click, keypress, etc.)
87
+ // Ignore events with 0 duration (non-interactive)
88
+ if (entry.duration > 0 && entry.interactionId) {
89
+ interactions.push(entry.duration);
90
+
91
+ // Report the current worst interaction as INP approximation
92
+ // True INP uses p98, but for simplicity we track the max
93
+ const sorted = [...interactions].sort((a, b) => b - a);
94
+ // p98: skip top 2% of interactions
95
+ const p98Index = Math.max(0, Math.floor(sorted.length * 0.02));
96
+ const inpValue = sorted[p98Index] || sorted[0];
97
+
98
+ getAggregator().recordWebVital('inp', Math.round(inpValue));
99
+ }
100
+ }
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Capture Cumulative Layout Shift (CLS)
106
+ * CLS accumulates layout shift values that occur without recent user input.
107
+ * Uses the "session window" approach: shifts within 1s of each other
108
+ * and max 5s total form a session. CLS is the max session value.
109
+ */
110
+ function captureCLS() {
111
+ let sessionValue = 0;
112
+ let sessionEntries = [];
113
+ let maxSessionValue = 0;
114
+
115
+ createObserver('layout-shift', (list) => {
116
+ for (const entry of list.getEntries()) {
117
+ // Only count shifts without recent input
118
+ if (entry.hadRecentInput) continue;
119
+
120
+ // Check if this shift belongs to the current session window
121
+ const lastEntry = sessionEntries[sessionEntries.length - 1];
122
+ if (lastEntry &&
123
+ entry.startTime - lastEntry.startTime < 1000 &&
124
+ entry.startTime - sessionEntries[0].startTime < 5000) {
125
+ // Same session
126
+ sessionValue += entry.value;
127
+ sessionEntries.push(entry);
128
+ } else {
129
+ // New session
130
+ sessionValue = entry.value;
131
+ sessionEntries = [entry];
132
+ }
133
+
134
+ maxSessionValue = Math.max(maxSessionValue, sessionValue);
135
+ // Round to 4 decimal places (CLS is typically 0.xxxx)
136
+ getAggregator().recordWebVital('cls', Math.round(maxSessionValue * 10000) / 10000);
137
+ }
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Capture page load timing from Navigation Timing API
143
+ */
144
+ function capturePageLoad() {
145
+ function recordNavTiming() {
146
+ try {
147
+ const entries = performance.getEntriesByType('navigation');
148
+ const nav = entries[0];
149
+ if (!nav) return;
150
+
151
+ getAggregator().recordPageLoad({
152
+ dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
153
+ tcp: Math.round(nav.connectEnd - nav.connectStart),
154
+ tls: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0),
155
+ ttfb: Math.round(nav.responseStart - nav.requestStart),
156
+ download: Math.round(nav.responseEnd - nav.responseStart),
157
+ domInteractive: Math.round(nav.domInteractive - nav.startTime),
158
+ domContentLoaded: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
159
+ load: Math.round(nav.loadEventEnd - nav.startTime)
160
+ });
161
+
162
+ debug('Page load timing captured');
163
+ } catch {
164
+ // Navigation Timing not available
165
+ }
166
+ }
167
+
168
+ if (document.readyState === 'complete') {
169
+ // Page already loaded - small delay to ensure loadEventEnd is populated
170
+ setTimeout(recordNavTiming, 0);
171
+ } else {
172
+ window.addEventListener('load', () => setTimeout(recordNavTiming, 0));
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Start capturing all Web Vitals and page load timing
178
+ */
179
+ export function startVitalsCapture() {
180
+ if (typeof window === 'undefined') return;
181
+
182
+ captureLCP();
183
+ captureFID();
184
+ captureINP();
185
+ captureCLS();
186
+ capturePageLoad();
187
+
188
+ debug('Web Vitals capture started');
189
+ }
190
+
191
+ /**
192
+ * Stop capturing Web Vitals (disconnect all observers)
193
+ */
194
+ export function stopVitalsCapture() {
195
+ for (const observer of observers) {
196
+ try { observer.disconnect(); } catch { /* ignore */ }
197
+ }
198
+ observers = [];
199
+ debug('Web Vitals capture stopped');
200
+ }
201
+
202
+ export default { startVitalsCapture, stopVitalsCapture };
package/src/browser.js CHANGED
@@ -1,107 +1,191 @@
1
- /**
2
- * SentienGuard APM SDK - Browser No-Op
3
- *
4
- * This module is automatically used when the SDK is imported in a browser
5
- * environment (via bundlers like Webpack, Vite, Rsbuild, etc.).
6
- *
7
- * The APM SDK only works in Node.js (it patches http/https modules),
8
- * so in the browser we export no-op stubs to avoid build errors.
9
- */
10
-
11
- const noop = () => {};
12
- const asyncNoop = async () => {};
13
- const noopMiddleware = (_req, _res, next) => next?.();
14
-
15
- function initialize() {}
16
- async function shutdown() {}
17
- function getStatus() {
18
- return {
19
- enabled: false,
20
- initialized: false,
21
- config: { service: '', environment: '', flushInterval: 0 },
22
- stats: {}
23
- };
24
- }
25
- function flush() {}
26
- function getConfig() {
27
- return {};
28
- }
29
- function isEnabled() {
30
- return false;
31
- }
32
- function normalizeRoute(route) {
33
- return route;
34
- }
35
- function extractRoute(req) {
36
- return req?.url || '/';
37
- }
38
- function getAggregator() {
39
- return {
40
- getStats: () => ({}),
41
- recordRequest: noop,
42
- recordDependency: noop,
43
- recordError: noop
44
- };
45
- }
46
- function instrumentMongoDB() {}
47
- function instrumentOpenAI() {}
48
- function createBreaker(fn) {
49
- return fn;
50
- }
51
- function wrapMongoOperation(fn) {
52
- return fn;
53
- }
54
- function getBreakerStats() {
55
- return {};
56
- }
57
-
58
- const RouteRegistry = {
59
- register: noop,
60
- match: () => null
61
- };
62
-
63
- const expressMiddleware = noopMiddleware;
64
- const expressErrorMiddleware = (_err, _req, _res, next) => next?.();
65
- const fastifyPlugin = noop;
66
- const fastifyErrorHandler = noop;
67
-
68
- export {
69
- initialize,
70
- shutdown,
71
- getStatus,
72
- flush,
73
- getConfig,
74
- isEnabled,
75
- expressMiddleware,
76
- expressErrorMiddleware,
77
- fastifyPlugin,
78
- fastifyErrorHandler,
79
- normalizeRoute,
80
- extractRoute,
81
- RouteRegistry,
82
- getAggregator,
83
- instrumentMongoDB,
84
- instrumentOpenAI,
85
- createBreaker,
86
- wrapMongoOperation,
87
- getBreakerStats
88
- };
89
-
90
- export default {
91
- initialize,
92
- shutdown,
93
- getStatus,
94
- flush,
95
- expressMiddleware,
96
- expressErrorMiddleware,
97
- fastifyPlugin,
98
- fastifyErrorHandler,
99
- normalizeRoute,
100
- extractRoute,
101
- getAggregator,
102
- instrumentMongoDB,
103
- instrumentOpenAI,
104
- createBreaker,
105
- wrapMongoOperation,
106
- getBreakerStats
107
- };
1
+ /**
2
+ * SentienGuard APM SDK - Browser (Real User Monitoring)
3
+ *
4
+ * This module is automatically used when the SDK is imported in a browser
5
+ * environment (via bundlers like Webpack, Vite, Rsbuild, etc.).
6
+ *
7
+ * Unlike the Node.js SDK which auto-initializes on import, the browser SDK
8
+ * requires explicit initialization:
9
+ *
10
+ * import SentienGuard from '@sentienguard/apm';
11
+ *
12
+ * SentienGuard.init({
13
+ * apiKey: 'your-apm-key',
14
+ * service: 'my-frontend',
15
+ * endpoint: 'https://your-backend.com/api/v1/apm/ingest', // optional
16
+ * environment: 'production', // optional
17
+ * debug: false // optional
18
+ * });
19
+ *
20
+ * What gets captured automatically after init():
21
+ * - Outgoing fetch/XHR requests (timing, status, errors)
22
+ * - Web Vitals (LCP, FID, INP, CLS)
23
+ * - Page load timing (Navigation Timing API)
24
+ * - Unhandled JS errors and promise rejections
25
+ * - SPA route changes (history.pushState/popstate)
26
+ */
27
+
28
+ import config, { configure, isEnabled, getConfig, debug, warn } from './browser/config.js';
29
+ import { getAggregator } from './browser/aggregator.js';
30
+ import { startFlushing, stopFlushing, flush, setupUnloadFlush } from './browser/transport.js';
31
+ import { instrumentNetwork, uninstrumentNetwork } from './browser/instrumentation.js';
32
+ import { startErrorCapture, stopErrorCapture } from './browser/errors.js';
33
+ import { startVitalsCapture, stopVitalsCapture } from './browser/vitals.js';
34
+ import { startRouteTracking, stopRouteTracking } from './browser/router.js';
35
+
36
+ let isInitialized = false;
37
+
38
+ /**
39
+ * Initialize the browser SDK.
40
+ * Must be called explicitly with configuration options.
41
+ *
42
+ * @param {Object} options
43
+ * @param {string} options.apiKey - APM application key (required)
44
+ * @param {string} options.service - Service/app name (required)
45
+ * @param {string} [options.endpoint] - Backend ingest URL
46
+ * @param {string} [options.environment] - Environment name (default: 'production')
47
+ * @param {number} [options.flushInterval] - Flush interval in seconds (default: 10)
48
+ * @param {boolean} [options.debug] - Enable debug logging (default: false)
49
+ */
50
+ function initialize(options = {}) {
51
+ if (isInitialized) {
52
+ debug('SDK already initialized');
53
+ return;
54
+ }
55
+
56
+ configure(options);
57
+
58
+ if (!isEnabled()) {
59
+ warn('SDK disabled (missing apiKey or service). Call init({ apiKey, service })');
60
+ return;
61
+ }
62
+
63
+ debug(`Initializing browser SDK for service: ${config.service}`);
64
+ debug(`Environment: ${config.environment}`);
65
+ debug(`Endpoint: ${config.endpoint}`);
66
+ debug(`Flush interval: ${config.flushInterval}s`);
67
+
68
+ // Instrument outgoing network requests (fetch + XHR)
69
+ instrumentNetwork();
70
+
71
+ // Capture unhandled JS errors
72
+ startErrorCapture();
73
+
74
+ // Capture Web Vitals (LCP, FID, INP, CLS) and page load timing
75
+ startVitalsCapture();
76
+
77
+ // Track SPA route changes
78
+ startRouteTracking();
79
+
80
+ // Start periodic metric flush
81
+ startFlushing();
82
+
83
+ // Setup final flush on page unload (visibilitychange)
84
+ setupUnloadFlush();
85
+
86
+ isInitialized = true;
87
+ debug('Browser SDK initialized');
88
+ }
89
+
90
+ /**
91
+ * Shutdown the SDK and flush remaining data
92
+ */
93
+ async function shutdown() {
94
+ if (!isInitialized) return;
95
+
96
+ debug('Shutting down browser SDK');
97
+
98
+ stopFlushing();
99
+ stopVitalsCapture();
100
+ stopRouteTracking();
101
+ stopErrorCapture();
102
+ uninstrumentNetwork();
103
+
104
+ await flush();
105
+
106
+ isInitialized = false;
107
+ debug('Browser SDK shutdown complete');
108
+ }
109
+
110
+ /**
111
+ * Get current SDK status
112
+ */
113
+ function getStatus() {
114
+ return {
115
+ enabled: isEnabled(),
116
+ initialized: isInitialized,
117
+ config: {
118
+ service: config.service,
119
+ environment: config.environment,
120
+ flushInterval: config.flushInterval
121
+ },
122
+ stats: isInitialized ? getAggregator().getStats() : {}
123
+ };
124
+ }
125
+
126
+ // ============================================
127
+ // No-op stubs for Node.js-only features.
128
+ // These maintain API compatibility so isomorphic code doesn't break.
129
+ // ============================================
130
+ const noop = () => {};
131
+ function normalizeRoute(route) { return route; }
132
+ function extractRoute(req) { return req?.url || '/'; }
133
+ function instrumentMongoDB() {}
134
+ function instrumentOpenAI() {}
135
+ function createBreaker(fn) { return fn; }
136
+ function wrapMongoOperation(fn) { return fn; }
137
+ function getBreakerStats() { return {}; }
138
+
139
+ const RouteRegistry = { register: noop, match: () => null };
140
+ const expressMiddleware = (_req, _res, next) => next?.();
141
+ const expressErrorMiddleware = (_err, _req, _res, next) => next?.();
142
+ const fastifyPlugin = noop;
143
+ const fastifyErrorHandler = noop;
144
+
145
+ // Named exports (matching Node.js SDK's export surface exactly)
146
+ export {
147
+ initialize,
148
+ initialize as init,
149
+ shutdown,
150
+ getStatus,
151
+ flush,
152
+ getConfig,
153
+ isEnabled,
154
+ expressMiddleware,
155
+ expressErrorMiddleware,
156
+ fastifyPlugin,
157
+ fastifyErrorHandler,
158
+ normalizeRoute,
159
+ extractRoute,
160
+ RouteRegistry,
161
+ getAggregator,
162
+ instrumentMongoDB,
163
+ instrumentOpenAI,
164
+ createBreaker,
165
+ wrapMongoOperation,
166
+ getBreakerStats
167
+ };
168
+
169
+ // Default export
170
+ export default {
171
+ init: initialize,
172
+ initialize,
173
+ shutdown,
174
+ getStatus,
175
+ flush,
176
+ getConfig,
177
+ isEnabled,
178
+ getAggregator,
179
+ // Node.js-only stubs for isomorphic compatibility
180
+ expressMiddleware,
181
+ expressErrorMiddleware,
182
+ fastifyPlugin,
183
+ fastifyErrorHandler,
184
+ normalizeRoute,
185
+ extractRoute,
186
+ instrumentMongoDB,
187
+ instrumentOpenAI,
188
+ createBreaker,
189
+ wrapMongoOperation,
190
+ getBreakerStats
191
+ };