@sentienguard/apm 1.0.4 → 1.0.6

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.
@@ -1,24 +1,24 @@
1
1
  /**
2
2
  * Browser Metrics Aggregator
3
3
  *
4
- * Batches browser-specific metrics in memory and flushes them periodically.
4
+ * Batches browser metrics in memory and flushes them periodically.
5
5
  * Produces payloads compatible with the backend's ingestApmData controller.
6
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)
7
+ * Tracks:
8
+ * - Outgoing fetch/XHR as requests (keyed by method:route)
11
9
  * - Unhandled JS errors
10
+ * - Web Vitals (LCP, FID, INP, CLS)
11
+ * - Page load timing
12
12
  * - SPA route changes
13
13
  */
14
14
 
15
15
  import config from './config.js';
16
16
 
17
17
  /**
18
- * Create an aggregation key for dependency metrics
18
+ * Create an aggregation key for request metrics
19
19
  */
20
- function createDependencyKey(name, type) {
21
- return `${name}:${type}`;
20
+ function createRequestKey(method, route) {
21
+ return `${method}:${route}`;
22
22
  }
23
23
 
24
24
  export class BrowserMetricsAggregator {
@@ -28,12 +28,9 @@ export class BrowserMetricsAggregator {
28
28
  }
29
29
 
30
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 = [];
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();
37
34
 
38
35
  // Web Vitals - latest values per metric name
39
36
  this.webVitals = {};
@@ -43,32 +40,33 @@ export class BrowserMetricsAggregator {
43
40
 
44
41
  // SPA route change events
45
42
  this.routeChanges = [];
43
+
44
+ // Page load timing entries
45
+ this.pageLoads = [];
46
46
  }
47
47
 
48
48
  /**
49
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
50
  *
53
- * @param {string} name - Service/host name
54
- * @param {string} type - Dependency type (http)
51
+ * @param {string} method - HTTP method (GET, POST, PATCH, DELETE)
52
+ * @param {string} route - URL pathname (e.g., /api/todos)
55
53
  * @param {number} latency - Response time in ms
56
- * @param {boolean} isError - Whether the request failed
54
+ * @param {boolean} isError - Whether the request failed (4xx/5xx or network error)
57
55
  */
58
- recordDependency(name, type, latency, isError = false) {
59
- const key = createDependencyKey(name, type);
56
+ recordRequest(method, route, latency, isError = false) {
57
+ const key = createRequestKey(method, route);
60
58
 
61
- let metric = this.dependencies.get(key);
59
+ let metric = this.requests.get(key);
62
60
  if (!metric) {
63
- if (this.dependencies.size >= this.maxRoutes) return;
61
+ if (this.requests.size >= this.maxRoutes) return;
64
62
  metric = {
65
- name,
66
- type,
63
+ method,
64
+ route,
67
65
  count: 0,
68
66
  errorCount: 0,
69
67
  latency: { sum: 0, min: Infinity, max: 0 }
70
68
  };
71
- this.dependencies.set(key, metric);
69
+ this.requests.set(key, metric);
72
70
  }
73
71
 
74
72
  metric.count++;
@@ -113,24 +111,24 @@ export class BrowserMetricsAggregator {
113
111
  }
114
112
 
115
113
  hasData() {
116
- return this.dependencies.size > 0 ||
117
- this.pageLoads.length > 0 ||
114
+ return this.requests.size > 0 ||
118
115
  Object.keys(this.webVitals).length > 0 ||
119
116
  this.jsErrors > 0 ||
120
- this.routeChanges.length > 0;
117
+ this.routeChanges.length > 0 ||
118
+ this.pageLoads.length > 0;
121
119
  }
122
120
 
123
121
  /**
124
122
  * Flush aggregated metrics and return payload for the backend.
125
123
  * Payload format matches the backend's ingestApmData expectations:
126
- * { interval, service, environment, requests[], dependencies[], browser{} }
124
+ * { interval, service, environment, requests[], dependencies[] }
127
125
  */
128
126
  flush() {
129
- const dependencies = [];
130
- for (const metric of this.dependencies.values()) {
131
- dependencies.push({
132
- name: metric.name,
133
- type: metric.type,
127
+ const requests = [];
128
+ for (const metric of this.requests.values()) {
129
+ requests.push({
130
+ method: metric.method,
131
+ route: metric.route,
134
132
  count: metric.count,
135
133
  errorCount: metric.errorCount,
136
134
  latency: {
@@ -145,18 +143,8 @@ export class BrowserMetricsAggregator {
145
143
  interval: `${config.flushInterval}s`,
146
144
  service: config.service,
147
145
  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
- }
146
+ requests,
147
+ dependencies: []
160
148
  };
161
149
 
162
150
  this.reset();
@@ -165,11 +153,11 @@ export class BrowserMetricsAggregator {
165
153
 
166
154
  getStats() {
167
155
  return {
168
- dependencyMetrics: this.dependencies.size,
169
- pageLoads: this.pageLoads.length,
156
+ requestMetrics: this.requests.size,
170
157
  webVitals: Object.keys(this.webVitals).length,
171
158
  jsErrors: this.jsErrors,
172
- routeChanges: this.routeChanges.length
159
+ routeChanges: this.routeChanges.length,
160
+ pageLoads: this.pageLoads.length
173
161
  };
174
162
  }
175
163
  }
@@ -2,10 +2,8 @@
2
2
  * Browser Network Instrumentation
3
3
  *
4
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.
5
+ * Captured data is recorded as requests (method + pathname), matching the
6
+ * Node.js SDK's request format so the backend processes them identically.
9
7
  */
10
8
 
11
9
  import { getAggregator } from './aggregator.js';
@@ -33,17 +31,16 @@ function shouldExclude(url) {
33
31
  }
34
32
 
35
33
  /**
36
- * Extract a service name from a URL.
37
- * For same-origin requests, returns 'self'.
38
- * For cross-origin, returns the hostname.
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"
39
37
  */
40
- function getServiceName(url) {
38
+ function getPathname(url) {
41
39
  try {
42
40
  const parsed = new URL(url, window.location.origin);
43
- if (parsed.origin === window.location.origin) return 'self';
44
- return parsed.hostname;
41
+ return parsed.pathname;
45
42
  } catch {
46
- return 'unknown';
43
+ return '/unknown';
47
44
  }
48
45
  }
49
46
 
@@ -66,23 +63,23 @@ function instrumentFetch() {
66
63
 
67
64
  const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
68
65
  const startTime = performance.now();
69
- const serviceName = getServiceName(url);
66
+ const pathname = getPathname(url);
70
67
 
71
68
  return originalFetch.apply(this, arguments).then(
72
69
  (response) => {
73
70
  const latency = performance.now() - startTime;
74
71
  const isError = !response.ok;
75
72
 
76
- getAggregator().recordDependency(serviceName, 'http', latency, isError);
77
- debug(`Fetch: ${method} ${serviceName} ${response.status} ${latency.toFixed(1)}ms`);
73
+ getAggregator().recordRequest(method, pathname, latency, isError);
74
+ debug(`Fetch: ${method} ${pathname} ${response.status} ${latency.toFixed(1)}ms`);
78
75
 
79
76
  return response;
80
77
  },
81
78
  (error) => {
82
79
  const latency = performance.now() - startTime;
83
80
 
84
- getAggregator().recordDependency(serviceName, 'http', latency, true);
85
- debug(`Fetch error: ${method} ${serviceName} ${latency.toFixed(1)}ms`);
81
+ getAggregator().recordRequest(method, pathname, latency, true);
82
+ debug(`Fetch error: ${method} ${pathname} ${latency.toFixed(1)}ms`);
86
83
 
87
84
  throw error;
88
85
  }
@@ -113,14 +110,14 @@ function instrumentXHR() {
113
110
  const startTime = performance.now();
114
111
  const url = this._sgUrl;
115
112
  const method = this._sgMethod;
116
- const serviceName = getServiceName(url);
113
+ const pathname = getPathname(url);
117
114
 
118
115
  this.addEventListener('loadend', function () {
119
116
  const latency = performance.now() - startTime;
120
117
  const isError = this.status === 0 || this.status >= 400;
121
118
 
122
- getAggregator().recordDependency(serviceName, 'http', latency, isError);
123
- debug(`XHR: ${method} ${serviceName} ${this.status} ${latency.toFixed(1)}ms`);
119
+ getAggregator().recordRequest(method, pathname, latency, isError);
120
+ debug(`XHR: ${method} ${pathname} ${this.status} ${latency.toFixed(1)}ms`);
124
121
  });
125
122
 
126
123
  return originalXhrSend.apply(this, arguments);
@@ -2,12 +2,12 @@
2
2
  * Browser Transport Layer
3
3
  *
4
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).
5
+ * Uses fetch() for normal sends and navigator.sendBeacon() for page unload.
7
6
  *
8
- * Transport strategy (matches Datadog RUM pattern):
9
- * - Normal flush: fetch() with X-APM-Key header
10
- * - Unload flush: sendBeacon() with ?apmKey= query param
7
+ * Transport strategy:
8
+ * - API key is always sent as ?apmKey= query param (avoids custom header CORS preflight issues)
9
+ * - Normal flush: fetch() with keepalive
10
+ * - Unload flush: sendBeacon() (more reliable on page close)
11
11
  * - Listens on visibilitychange (NOT beforeunload/unload which are unreliable on mobile)
12
12
  */
13
13
 
@@ -21,7 +21,16 @@ let consecutiveFailures = 0;
21
21
  const MAX_CONSECUTIVE_FAILURES = 5;
22
22
 
23
23
  /**
24
- * Send payload to backend using fetch (supports custom headers)
24
+ * Build the ingest URL with API key as query parameter.
25
+ * Using query param instead of X-APM-Key header avoids CORS preflight
26
+ * issues across different server/proxy configurations.
27
+ */
28
+ function getIngestUrl() {
29
+ return `${config.endpoint}?apmKey=${encodeURIComponent(config.apiKey)}`;
30
+ }
31
+
32
+ /**
33
+ * Send payload to backend using fetch
25
34
  */
26
35
  async function sendToBackend(payload) {
27
36
  const data = JSON.stringify(payload);
@@ -31,12 +40,9 @@ async function sendToBackend(payload) {
31
40
  return;
32
41
  }
33
42
 
34
- const response = await fetch(config.endpoint, {
43
+ const response = await fetch(getIngestUrl(), {
35
44
  method: 'POST',
36
- headers: {
37
- 'Content-Type': 'application/json',
38
- 'X-APM-Key': config.apiKey
39
- },
45
+ headers: { 'Content-Type': 'application/json' },
40
46
  body: data,
41
47
  keepalive: true
42
48
  });
@@ -48,26 +54,21 @@ async function sendToBackend(payload) {
48
54
 
49
55
  /**
50
56
  * 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.
57
+ * sendBeacon is more reliable than fetch during page close/navigation.
53
58
  */
54
59
  function sendBeacon(payload) {
55
60
  const data = JSON.stringify(payload);
56
61
 
57
62
  if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
58
- const url = `${config.endpoint}?apmKey=${encodeURIComponent(config.apiKey)}`;
59
63
  const blob = new Blob([data], { type: 'application/json' });
60
- return navigator.sendBeacon(url, blob);
64
+ return navigator.sendBeacon(getIngestUrl(), blob);
61
65
  }
62
66
 
63
67
  // Fallback: fetch with keepalive (survives page unload in modern browsers)
64
68
  try {
65
- fetch(config.endpoint, {
69
+ fetch(getIngestUrl(), {
66
70
  method: 'POST',
67
- headers: {
68
- 'Content-Type': 'application/json',
69
- 'X-APM-Key': config.apiKey
70
- },
71
+ headers: { 'Content-Type': 'application/json' },
71
72
  body: data,
72
73
  keepalive: true
73
74
  });
@@ -86,7 +87,7 @@ export async function flush() {
86
87
  if (!aggregator.hasData()) return;
87
88
 
88
89
  const payload = aggregator.flush();
89
- debug(`Flushing ${payload.dependencies.length} dependency metrics`);
90
+ debug(`Flushing ${payload.requests.length} request metrics`);
90
91
 
91
92
  try {
92
93
  const startTime = performance.now();
package/src/browser.js CHANGED
@@ -130,8 +130,9 @@ function getStatus() {
130
130
  const noop = () => {};
131
131
  function normalizeRoute(route) { return route; }
132
132
  function extractRoute(req) { return req?.url || '/'; }
133
- function instrumentMongoDB() {}
134
- function instrumentOpenAI() {}
133
+
134
+ function instrumentMongoDB() { return noop(); }
135
+ function instrumentOpenAI() { return noop(); }
135
136
  function createBreaker(fn) { return fn; }
136
137
  function wrapMongoOperation(fn) { return fn; }
137
138
  function getBreakerStats() { return {}; }