@sentienguard/apm 1.0.3 → 1.0.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/package.json +54 -54
- package/src/browser/aggregator.js +187 -0
- package/src/browser/config.js +67 -0
- package/src/browser/errors.js +56 -0
- package/src/browser/instrumentation.js +164 -0
- package/src/browser/router.js +86 -0
- package/src/browser/transport.js +152 -0
- package/src/browser/vitals.js +202 -0
- package/src/browser.js +191 -107
package/package.json
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@sentienguard/apm",
|
|
3
|
-
"version": "1.0.
|
|
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.4",
|
|
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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Metrics Aggregator
|
|
3
|
+
*
|
|
4
|
+
* Batches browser-specific metrics in memory and flushes them periodically.
|
|
5
|
+
* Produces payloads compatible with the backend's ingestApmData controller.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the Node.js aggregator pattern but tracks browser-relevant metrics:
|
|
8
|
+
* - Outgoing fetch/XHR requests (as dependencies)
|
|
9
|
+
* - Page load timing (Navigation Timing API)
|
|
10
|
+
* - Web Vitals (LCP, FID, INP, CLS)
|
|
11
|
+
* - Unhandled JS errors
|
|
12
|
+
* - SPA route changes
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import config from './config.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create an aggregation key for dependency metrics
|
|
19
|
+
*/
|
|
20
|
+
function createDependencyKey(name, type) {
|
|
21
|
+
return `${name}:${type}`;
|
|
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 host
|
|
32
|
+
// Same shape as Node SDK dependencies: { name, type, count, errorCount, latency }
|
|
33
|
+
this.dependencies = new Map();
|
|
34
|
+
|
|
35
|
+
// Page load timing entries from Navigation Timing API
|
|
36
|
+
this.pageLoads = [];
|
|
37
|
+
|
|
38
|
+
// Web Vitals - latest values per metric name
|
|
39
|
+
this.webVitals = {};
|
|
40
|
+
|
|
41
|
+
// Unhandled JS error count
|
|
42
|
+
this.jsErrors = 0;
|
|
43
|
+
|
|
44
|
+
// SPA route change events
|
|
45
|
+
this.routeChanges = [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Record an outgoing HTTP request (fetch/XHR)
|
|
50
|
+
* Uses the same dependency shape as the Node.js SDK so the backend
|
|
51
|
+
* can process it identically.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} name - Service/host name
|
|
54
|
+
* @param {string} type - Dependency type (http)
|
|
55
|
+
* @param {number} latency - Response time in ms
|
|
56
|
+
* @param {boolean} isError - Whether the request failed
|
|
57
|
+
*/
|
|
58
|
+
recordDependency(name, type, latency, isError = false) {
|
|
59
|
+
const key = createDependencyKey(name, type);
|
|
60
|
+
|
|
61
|
+
let metric = this.dependencies.get(key);
|
|
62
|
+
if (!metric) {
|
|
63
|
+
if (this.dependencies.size >= this.maxRoutes) return;
|
|
64
|
+
metric = {
|
|
65
|
+
name,
|
|
66
|
+
type,
|
|
67
|
+
count: 0,
|
|
68
|
+
errorCount: 0,
|
|
69
|
+
latency: { sum: 0, min: Infinity, max: 0 }
|
|
70
|
+
};
|
|
71
|
+
this.dependencies.set(key, metric);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
metric.count++;
|
|
75
|
+
if (isError) metric.errorCount++;
|
|
76
|
+
metric.latency.sum += latency;
|
|
77
|
+
metric.latency.min = Math.min(metric.latency.min, latency);
|
|
78
|
+
metric.latency.max = Math.max(metric.latency.max, latency);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Record an unhandled JS error
|
|
83
|
+
*/
|
|
84
|
+
recordError() {
|
|
85
|
+
this.jsErrors++;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Record page load timing from Navigation Timing API
|
|
90
|
+
* @param {Object} timing
|
|
91
|
+
*/
|
|
92
|
+
recordPageLoad(timing) {
|
|
93
|
+
this.pageLoads.push(timing);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Record a Web Vital metric value
|
|
98
|
+
* @param {string} name - Metric name (lcp, fid, inp, cls)
|
|
99
|
+
* @param {number} value - Metric value
|
|
100
|
+
*/
|
|
101
|
+
recordWebVital(name, value) {
|
|
102
|
+
this.webVitals[name] = value;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Record a SPA route change
|
|
107
|
+
* @param {string} from - Previous route path
|
|
108
|
+
* @param {string} to - New route path
|
|
109
|
+
*/
|
|
110
|
+
recordRouteChange(from, to) {
|
|
111
|
+
if (this.routeChanges.length >= 100) return;
|
|
112
|
+
this.routeChanges.push({ from, to, timestamp: Date.now() });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
hasData() {
|
|
116
|
+
return this.dependencies.size > 0 ||
|
|
117
|
+
this.pageLoads.length > 0 ||
|
|
118
|
+
Object.keys(this.webVitals).length > 0 ||
|
|
119
|
+
this.jsErrors > 0 ||
|
|
120
|
+
this.routeChanges.length > 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Flush aggregated metrics and return payload for the backend.
|
|
125
|
+
* Payload format matches the backend's ingestApmData expectations:
|
|
126
|
+
* { interval, service, environment, requests[], dependencies[], browser{} }
|
|
127
|
+
*/
|
|
128
|
+
flush() {
|
|
129
|
+
const dependencies = [];
|
|
130
|
+
for (const metric of this.dependencies.values()) {
|
|
131
|
+
dependencies.push({
|
|
132
|
+
name: metric.name,
|
|
133
|
+
type: metric.type,
|
|
134
|
+
count: metric.count,
|
|
135
|
+
errorCount: metric.errorCount,
|
|
136
|
+
latency: {
|
|
137
|
+
sum: Math.round(metric.latency.sum),
|
|
138
|
+
min: metric.latency.min === Infinity ? 0 : Math.round(metric.latency.min),
|
|
139
|
+
max: Math.round(metric.latency.max)
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const payload = {
|
|
145
|
+
interval: `${config.flushInterval}s`,
|
|
146
|
+
service: config.service,
|
|
147
|
+
environment: config.environment,
|
|
148
|
+
// requests[] left empty - browser doesn't serve HTTP requests
|
|
149
|
+
requests: [],
|
|
150
|
+
dependencies,
|
|
151
|
+
// Browser-specific metrics in a dedicated field.
|
|
152
|
+
// The backend safely ignores unknown fields for now;
|
|
153
|
+
// a future backend update can process these.
|
|
154
|
+
browser: {
|
|
155
|
+
pageLoads: [...this.pageLoads],
|
|
156
|
+
webVitals: { ...this.webVitals },
|
|
157
|
+
jsErrors: this.jsErrors,
|
|
158
|
+
routeChanges: [...this.routeChanges]
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
this.reset();
|
|
163
|
+
return payload;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
getStats() {
|
|
167
|
+
return {
|
|
168
|
+
dependencyMetrics: this.dependencies.size,
|
|
169
|
+
pageLoads: this.pageLoads.length,
|
|
170
|
+
webVitals: Object.keys(this.webVitals).length,
|
|
171
|
+
jsErrors: this.jsErrors,
|
|
172
|
+
routeChanges: this.routeChanges.length
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Singleton
|
|
178
|
+
let instance = null;
|
|
179
|
+
|
|
180
|
+
export function getAggregator() {
|
|
181
|
+
if (!instance) {
|
|
182
|
+
instance = new BrowserMetricsAggregator();
|
|
183
|
+
}
|
|
184
|
+
return instance;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Network Instrumentation
|
|
3
|
+
*
|
|
4
|
+
* Monkey-patches window.fetch and XMLHttpRequest to track outgoing HTTP requests.
|
|
5
|
+
* This is the industry-standard approach used by Datadog, Sentry, New Relic, and Elastic APM.
|
|
6
|
+
*
|
|
7
|
+
* Captured data is recorded as dependencies (same shape as Node.js SDK)
|
|
8
|
+
* so the backend processes them identically.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getAggregator } from './aggregator.js';
|
|
12
|
+
import config, { debug } from './config.js';
|
|
13
|
+
|
|
14
|
+
let originalFetch = null;
|
|
15
|
+
let originalXhrOpen = null;
|
|
16
|
+
let originalXhrSend = null;
|
|
17
|
+
let isInstrumented = false;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if a URL should be excluded from tracking.
|
|
21
|
+
* Excludes the APM endpoint itself to prevent infinite metric loops.
|
|
22
|
+
*/
|
|
23
|
+
function shouldExclude(url) {
|
|
24
|
+
if (!url || !config.endpoint) return false;
|
|
25
|
+
try {
|
|
26
|
+
const targetUrl = new URL(url, window.location.origin);
|
|
27
|
+
const endpointUrl = new URL(config.endpoint);
|
|
28
|
+
return targetUrl.origin === endpointUrl.origin &&
|
|
29
|
+
targetUrl.pathname === endpointUrl.pathname;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract a service name from a URL.
|
|
37
|
+
* For same-origin requests, returns 'self'.
|
|
38
|
+
* For cross-origin, returns the hostname.
|
|
39
|
+
*/
|
|
40
|
+
function getServiceName(url) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = new URL(url, window.location.origin);
|
|
43
|
+
if (parsed.origin === window.location.origin) return 'self';
|
|
44
|
+
return parsed.hostname;
|
|
45
|
+
} catch {
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Instrument window.fetch
|
|
52
|
+
*/
|
|
53
|
+
function instrumentFetch() {
|
|
54
|
+
if (typeof window === 'undefined' || typeof window.fetch !== 'function') return;
|
|
55
|
+
|
|
56
|
+
originalFetch = window.fetch;
|
|
57
|
+
|
|
58
|
+
window.fetch = function (input, init) {
|
|
59
|
+
const url = typeof input === 'string'
|
|
60
|
+
? input
|
|
61
|
+
: (input instanceof Request ? input.url : String(input));
|
|
62
|
+
|
|
63
|
+
if (shouldExclude(url)) {
|
|
64
|
+
return originalFetch.apply(this, arguments);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
68
|
+
const startTime = performance.now();
|
|
69
|
+
const serviceName = getServiceName(url);
|
|
70
|
+
|
|
71
|
+
return originalFetch.apply(this, arguments).then(
|
|
72
|
+
(response) => {
|
|
73
|
+
const latency = performance.now() - startTime;
|
|
74
|
+
const isError = !response.ok;
|
|
75
|
+
|
|
76
|
+
getAggregator().recordDependency(serviceName, 'http', latency, isError);
|
|
77
|
+
debug(`Fetch: ${method} ${serviceName} ${response.status} ${latency.toFixed(1)}ms`);
|
|
78
|
+
|
|
79
|
+
return response;
|
|
80
|
+
},
|
|
81
|
+
(error) => {
|
|
82
|
+
const latency = performance.now() - startTime;
|
|
83
|
+
|
|
84
|
+
getAggregator().recordDependency(serviceName, 'http', latency, true);
|
|
85
|
+
debug(`Fetch error: ${method} ${serviceName} ${latency.toFixed(1)}ms`);
|
|
86
|
+
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Instrument XMLHttpRequest
|
|
95
|
+
*/
|
|
96
|
+
function instrumentXHR() {
|
|
97
|
+
if (typeof XMLHttpRequest === 'undefined') return;
|
|
98
|
+
|
|
99
|
+
originalXhrOpen = XMLHttpRequest.prototype.open;
|
|
100
|
+
originalXhrSend = XMLHttpRequest.prototype.send;
|
|
101
|
+
|
|
102
|
+
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
|
103
|
+
this._sgMethod = (method || 'GET').toUpperCase();
|
|
104
|
+
this._sgUrl = String(url);
|
|
105
|
+
return originalXhrOpen.apply(this, [method, url, ...rest]);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
XMLHttpRequest.prototype.send = function (body) {
|
|
109
|
+
if (!this._sgUrl || shouldExclude(this._sgUrl)) {
|
|
110
|
+
return originalXhrSend.apply(this, arguments);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const startTime = performance.now();
|
|
114
|
+
const url = this._sgUrl;
|
|
115
|
+
const method = this._sgMethod;
|
|
116
|
+
const serviceName = getServiceName(url);
|
|
117
|
+
|
|
118
|
+
this.addEventListener('loadend', function () {
|
|
119
|
+
const latency = performance.now() - startTime;
|
|
120
|
+
const isError = this.status === 0 || this.status >= 400;
|
|
121
|
+
|
|
122
|
+
getAggregator().recordDependency(serviceName, 'http', latency, isError);
|
|
123
|
+
debug(`XHR: ${method} ${serviceName} ${this.status} ${latency.toFixed(1)}ms`);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return originalXhrSend.apply(this, arguments);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Start network instrumentation (fetch + XHR)
|
|
132
|
+
*/
|
|
133
|
+
export function instrumentNetwork() {
|
|
134
|
+
if (isInstrumented) return;
|
|
135
|
+
instrumentFetch();
|
|
136
|
+
instrumentXHR();
|
|
137
|
+
isInstrumented = true;
|
|
138
|
+
debug('Network instrumentation enabled');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Remove network instrumentation (for cleanup/testing)
|
|
143
|
+
*/
|
|
144
|
+
export function uninstrumentNetwork() {
|
|
145
|
+
if (!isInstrumented) return;
|
|
146
|
+
|
|
147
|
+
if (originalFetch) {
|
|
148
|
+
window.fetch = originalFetch;
|
|
149
|
+
originalFetch = null;
|
|
150
|
+
}
|
|
151
|
+
if (originalXhrOpen) {
|
|
152
|
+
XMLHttpRequest.prototype.open = originalXhrOpen;
|
|
153
|
+
originalXhrOpen = null;
|
|
154
|
+
}
|
|
155
|
+
if (originalXhrSend) {
|
|
156
|
+
XMLHttpRequest.prototype.send = originalXhrSend;
|
|
157
|
+
originalXhrSend = null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
isInstrumented = false;
|
|
161
|
+
debug('Network instrumentation disabled');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
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
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
+
};
|