@sentienguard/apm 1.0.23-debug.1 → 1.0.24

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/README.md CHANGED
@@ -1,141 +1,177 @@
1
- # @sentienguard/apm
2
-
3
- Minimal, production-safe APM SDK for Node.js applications. Zero-config setup with automatic instrumentation.
4
-
5
- ## Installation
6
-
7
- ```bash
8
- npm install @sentienguard/apm
9
- ```
10
-
11
- ## Quick Start
12
-
13
- ```js
14
- // If using dotenv, import it first
15
- import 'dotenv/config';
16
-
17
- // Then import the SDK (before other app modules for best instrumentation coverage)
18
- import '@sentienguard/apm';
19
-
20
- // Your app code
21
- import express from 'express';
22
- const app = express();
23
- // ...
24
- ```
25
-
26
- Set environment variables:
27
-
28
- ```bash
29
- SENTIENGUARD_APM_KEY=your-app-key
30
- SENTIENGUARD_SERVICE=my-api
31
- ```
32
-
33
- That's it. The SDK automatically instruments your application and sends metrics to SentienGuard.
34
-
35
- ## Configuration
36
-
37
- All configuration is via environment variables:
38
-
39
- | Variable | Required | Default | Description |
40
- |----------|----------|---------|-------------|
41
- | `SENTIENGUARD_APM_KEY` | Yes | - | Your application's APM key |
42
- | `SENTIENGUARD_SERVICE` | Yes | - | Service name (e.g., `orders-api`) |
43
- | `SENTIENGUARD_ENV` | No | `production` | Environment (`production`, `staging`, `development`) |
44
- | `SENTIENGUARD_ENDPOINT` | No | `https://sentienguard-dev.the-algo.com/api/v1` | SentienGuard backend URL |
45
- | `SENTIENGUARD_FLUSH_INTERVAL` | No | `10` | Metrics flush interval in seconds |
46
-
47
- > **Note:** If `SENTIENGUARD_APM_KEY` or `SENTIENGUARD_SERVICE` is missing, the SDK disables itself silently without affecting your application.
48
-
49
- ## What Gets Tracked
50
-
51
- - **HTTP Requests** - Incoming requests with method, route, status, and latency
52
- - **Dependencies** - Outgoing HTTP/HTTPS calls to external services
53
- - **Errors** - Uncaught exceptions and unhandled rejections
54
-
55
- ## Framework Integration
56
-
57
- ### Express
58
-
59
- For better route extraction, add the middleware:
60
-
61
- ```js
62
- import 'dotenv/config'; // if using dotenv
63
- import '@sentienguard/apm';
64
- import { expressMiddleware, expressErrorMiddleware } from '@sentienguard/apm';
65
- import express from 'express';
66
-
67
- const app = express();
68
-
69
- // Add early in middleware chain
70
- app.use(expressMiddleware());
71
-
72
- // Your routes
73
- app.get('/users/:id', (req, res) => { ... });
74
-
75
- // Add error middleware last
76
- app.use(expressErrorMiddleware());
77
- ```
78
-
79
- ### Fastify
80
-
81
- ```js
82
- import 'dotenv/config'; // if using dotenv
83
- import '@sentienguard/apm';
84
- import { fastifyPlugin, fastifyErrorHandler } from '@sentienguard/apm';
85
- import Fastify from 'fastify';
86
-
87
- const app = Fastify();
88
-
89
- // Register plugin
90
- app.register(fastifyPlugin);
91
-
92
- // Add error handler
93
- app.setErrorHandler(fastifyErrorHandler);
94
- ```
95
-
96
- ## API Reference
97
-
98
- ### Functions
99
-
100
- ```js
101
- import {
102
- shutdown, // Graceful shutdown (flushes pending metrics)
103
- getStatus, // Get SDK status and stats
104
- flush, // Force flush metrics now
105
- isEnabled // Check if SDK is enabled
106
- } from '@sentienguard/apm';
107
- ```
108
-
109
- ### Graceful Shutdown
110
-
111
- The SDK automatically handles `SIGTERM` and `SIGINT` signals. For manual shutdown:
112
-
113
- ```js
114
- import { shutdown } from '@sentienguard/apm';
115
-
116
- process.on('exit', async () => {
117
- await shutdown();
118
- });
119
- ```
120
-
121
- ### Check Status
122
-
123
- ```js
124
- import { getStatus } from '@sentienguard/apm';
125
-
126
- console.log(getStatus());
127
- // {
128
- // enabled: true,
129
- // initialized: true,
130
- // config: { service: 'my-api', environment: 'production', flushInterval: 10 },
131
- // stats: { requests: 150, dependencies: 45, errors: 2 }
132
- // }
133
- ```
134
-
135
- ## Requirements
136
-
137
- - Node.js >= 16.0.0
138
-
139
- ## License
140
-
141
- MIT
1
+ # @sentienguard/apm
2
+
3
+ Minimal, production-safe APM SDK for Node.js applications. Zero-config setup with automatic instrumentation.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @sentienguard/apm
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```js
14
+ // If using dotenv, import it first
15
+ import 'dotenv/config';
16
+
17
+ // Then import the SDK (before other app modules for best instrumentation coverage)
18
+ import '@sentienguard/apm';
19
+
20
+ // Your app code
21
+ import express from 'express';
22
+ const app = express();
23
+ // ...
24
+ ```
25
+
26
+ Set environment variables:
27
+
28
+ ```bash
29
+ SENTIENGUARD_APM_KEY=your-app-key
30
+ SENTIENGUARD_SERVICE=my-api
31
+ ```
32
+
33
+ That's it. The SDK automatically instruments your application and sends metrics to SentienGuard.
34
+
35
+ ## Configuration
36
+
37
+ All configuration is via environment variables:
38
+
39
+ | Variable | Required | Default | Description |
40
+ |----------|----------|---------|-------------|
41
+ | `SENTIENGUARD_APM_KEY` | Yes | - | Your application's APM key |
42
+ | `SENTIENGUARD_SERVICE` | Yes | - | Service name (e.g., `orders-api`) |
43
+ | `SENTIENGUARD_ENV` | No | `production` | Environment (`production`, `staging`, `development`) |
44
+ | `SENTIENGUARD_ENDPOINT` | No | `https://sentienguard-dev.the-algo.com/api/v1` | SentienGuard backend URL |
45
+ | `SENTIENGUARD_FLUSH_INTERVAL` | No | `10` | Metrics flush interval in seconds |
46
+
47
+ > **Note:** If `SENTIENGUARD_APM_KEY` or `SENTIENGUARD_SERVICE` is missing, the SDK disables itself silently without affecting your application.
48
+
49
+ ## What Gets Tracked
50
+
51
+ - **HTTP Requests** - Incoming requests with method, route, status, and latency
52
+ - **Dependencies** - Outgoing HTTP/HTTPS calls to external services
53
+ - **Errors** - Uncaught exceptions and unhandled rejections
54
+
55
+ ## Framework Integration
56
+
57
+ ### Express
58
+
59
+ For better route extraction, add the middleware:
60
+
61
+ ```js
62
+ import 'dotenv/config'; // if using dotenv
63
+ import '@sentienguard/apm';
64
+ import { expressMiddleware, expressErrorMiddleware } from '@sentienguard/apm';
65
+ import express from 'express';
66
+
67
+ const app = express();
68
+
69
+ // Add early in middleware chain
70
+ app.use(expressMiddleware());
71
+
72
+ // Your routes
73
+ app.get('/users/:id', (req, res) => { ... });
74
+
75
+ // Add error middleware last
76
+ app.use(expressErrorMiddleware());
77
+ ```
78
+
79
+ ### Fastify
80
+
81
+ ```js
82
+ import 'dotenv/config'; // if using dotenv
83
+ import '@sentienguard/apm';
84
+ import { fastifyPlugin, fastifyErrorHandler } from '@sentienguard/apm';
85
+ import Fastify from 'fastify';
86
+
87
+ const app = Fastify();
88
+
89
+ // Register plugin
90
+ app.register(fastifyPlugin);
91
+
92
+ // Add error handler
93
+ app.setErrorHandler(fastifyErrorHandler);
94
+ ```
95
+
96
+ ## API Reference
97
+
98
+ ### Functions
99
+
100
+ ```js
101
+ import {
102
+ shutdown, // Graceful shutdown (flushes pending metrics)
103
+ getStatus, // Get SDK status and stats
104
+ flush, // Force flush metrics now
105
+ isEnabled // Check if SDK is enabled
106
+ } from '@sentienguard/apm';
107
+ ```
108
+
109
+ ### Graceful Shutdown
110
+
111
+ The SDK automatically handles `SIGTERM` and `SIGINT` signals. For manual shutdown:
112
+
113
+ ```js
114
+ import { shutdown } from '@sentienguard/apm';
115
+
116
+ process.on('exit', async () => {
117
+ await shutdown();
118
+ });
119
+ ```
120
+
121
+ ### Check Status
122
+
123
+ ```js
124
+ import { getStatus } from '@sentienguard/apm';
125
+
126
+ console.log(getStatus());
127
+ // {
128
+ // enabled: true,
129
+ // initialized: true,
130
+ // config: { service: 'my-api', environment: 'production', flushInterval: 10 },
131
+ // stats: { requests: 150, dependencies: 45, errors: 2 }
132
+ // }
133
+ ```
134
+
135
+ ## Browser / Frontend Monitoring (RUM)
136
+
137
+ For React, Vue, or other browser apps, bundlers automatically use the browser build.
138
+ Call `init()` explicitly in your app entry (client-side only):
139
+
140
+ ```js
141
+ import SentienGuard from '@sentienguard/apm';
142
+
143
+ SentienGuard.init({
144
+ apiKey: 'your-apm-key',
145
+ service: 'my-frontend',
146
+ endpoint: 'https://your-backend.com/api/v1/apm/ingest',
147
+ environment: 'production'
148
+ });
149
+ ```
150
+
151
+ ### What Gets Tracked (Browser)
152
+
153
+ - **Outgoing fetch/XHR** — API calls from the browser with latency and errors
154
+ - **Core Web Vitals** — LCP, FID, INP, CLS
155
+ - **Page load timing** — TTFB, DOM ready, full load
156
+ - **JS errors** — Unhandled exceptions and promise rejections
157
+ - **SPA routes** — Client-side navigation via History API
158
+
159
+ ### Browser Configuration
160
+
161
+ | Option | Required | Default | Description |
162
+ |--------|----------|---------|-------------|
163
+ | `apiKey` | Yes | - | APM application key |
164
+ | `service` | Yes | - | Frontend service name |
165
+ | `endpoint` | No | SentienGuard dev ingest URL | Full ingest URL |
166
+ | `environment` | No | `production` | Environment name |
167
+ | `flushInterval` | No | `10` | Flush interval in seconds |
168
+ | `debug` | No | `false` | Enable SDK debug logging |
169
+
170
+ ## Requirements
171
+
172
+ - Node.js >= 16.0.0 (server SDK)
173
+ - Modern browser with PerformanceObserver support (browser SDK)
174
+
175
+ ## License
176
+
177
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentienguard/apm",
3
- "version": "1.0.23-debug.1",
3
+ "version": "1.0.24",
4
4
  "description": "SentienGuard APM SDK - Minimal, production-safe application performance monitoring",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -139,6 +139,29 @@ export class BrowserMetricsAggregator {
139
139
  });
140
140
  }
141
141
 
142
+ const webVitals = Object.keys(this.webVitals).length > 0
143
+ ? { ...this.webVitals }
144
+ : undefined;
145
+
146
+ const jsErrors = this.jsErrors > 0 ? this.jsErrors : undefined;
147
+
148
+ const routeChanges = this.routeChanges.length > 0
149
+ ? this.routeChanges.map(({ from, to, timestamp }) => ({ from, to, timestamp }))
150
+ : undefined;
151
+
152
+ const pageLoads = this.pageLoads.length > 0
153
+ ? this.pageLoads.map((timing) => ({
154
+ dns: Math.round(timing.dns || 0),
155
+ tcp: Math.round(timing.tcp || 0),
156
+ tls: Math.round(timing.tls || 0),
157
+ ttfb: Math.round(timing.ttfb || 0),
158
+ download: Math.round(timing.download || 0),
159
+ domInteractive: Math.round(timing.domInteractive || 0),
160
+ domContentLoaded: Math.round(timing.domContentLoaded || 0),
161
+ load: Math.round(timing.load || 0)
162
+ }))
163
+ : undefined;
164
+
142
165
  const payload = {
143
166
  interval: `${config.flushInterval}s`,
144
167
  service: config.service,
@@ -147,6 +170,11 @@ export class BrowserMetricsAggregator {
147
170
  dependencies: []
148
171
  };
149
172
 
173
+ if (webVitals) payload.webVitals = webVitals;
174
+ if (jsErrors) payload.jsErrors = jsErrors;
175
+ if (routeChanges) payload.routeChanges = routeChanges;
176
+ if (pageLoads) payload.pageLoads = pageLoads;
177
+
150
178
  this.reset();
151
179
  return payload;
152
180
  }
@@ -87,7 +87,16 @@ export async function flush() {
87
87
  if (!aggregator.hasData()) return;
88
88
 
89
89
  const payload = aggregator.flush();
90
- debug(`Flushing ${payload.requests.length} request metrics`);
90
+ const frontendExtras = [
91
+ payload.webVitals ? 'vitals' : null,
92
+ payload.jsErrors ? 'errors' : null,
93
+ payload.routeChanges?.length ? 'routes' : null,
94
+ payload.pageLoads?.length ? 'pageLoads' : null
95
+ ].filter(Boolean);
96
+ debug(
97
+ `Flushing ${payload.requests.length} request metrics` +
98
+ (frontendExtras.length ? ` + ${frontendExtras.join(', ')}` : '')
99
+ );
91
100
 
92
101
  try {
93
102
  const startTime = performance.now();
@@ -24,14 +24,14 @@ let observers = [];
24
24
  * Safely create a PerformanceObserver for a given entry type.
25
25
  * Returns null if the browser doesn't support the entry type.
26
26
  */
27
- function createObserver(type, callback) {
27
+ function createObserver(type, callback, observeOptions = {}) {
28
28
  try {
29
29
  // Check if the entry type is supported
30
30
  if (typeof PerformanceObserver === 'undefined') return null;
31
31
  if (!PerformanceObserver.supportedEntryTypes?.includes(type)) return null;
32
32
 
33
33
  const observer = new PerformanceObserver(callback);
34
- observer.observe({ type, buffered: true });
34
+ observer.observe({ type, buffered: true, ...observeOptions });
35
35
  observers.push(observer);
36
36
  return observer;
37
37
  } catch {
@@ -81,10 +81,10 @@ function captureFID() {
81
81
  function captureINP() {
82
82
  const interactions = [];
83
83
 
84
+ // durationThreshold defaults to 104ms in Chrome — misses fast clicks (FID can be ~13ms).
84
85
  createObserver('event', (list) => {
85
86
  for (const entry of list.getEntries()) {
86
87
  // Only count discrete interactions (click, keypress, etc.)
87
- // Ignore events with 0 duration (non-interactive)
88
88
  if (entry.duration > 0 && entry.interactionId) {
89
89
  interactions.push(entry.duration);
90
90
 
@@ -96,9 +96,10 @@ function captureINP() {
96
96
  const inpValue = sorted[p98Index] || sorted[0];
97
97
 
98
98
  getAggregator().recordWebVital('inp', Math.round(inpValue));
99
+ debug(`INP: ${inpValue.toFixed(1)}ms`);
99
100
  }
100
101
  }
101
- });
102
+ }, { durationThreshold: 0 });
102
103
  }
103
104
 
104
105
  /**
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Bun + OpenTelemetry: @opentelemetry/instrumentation-http does not hook Bun's HTTP stack,
3
+ * so NodeSDK starts but produces no spans. Emit spans via the OTel API so they still reach
4
+ * BatchSpanProcessor → SentienGuardTraceSpanExporter (same as Node).
5
+ */
6
+
7
+ import { context, trace, SpanKind, SpanStatusCode } from '@opentelemetry/api';
8
+ import { isTracingActive } from './tracing.js';
9
+
10
+ const TRACER_NAME = '@sentienguard/apm';
11
+
12
+ export function shouldEmitBunManualSpans() {
13
+ return typeof globalThis.Bun !== 'undefined' && isTracingActive();
14
+ }
15
+
16
+ export function getBunTracer() {
17
+ return trace.getTracer(TRACER_NAME);
18
+ }
19
+
20
+ export { context, trace, SpanKind, SpanStatusCode };
@@ -11,9 +11,10 @@
11
11
 
12
12
  import http from 'http';
13
13
  import https from 'https';
14
- import { context, propagation } from '@opentelemetry/api';
14
+ import { context, propagation, trace, SpanKind, SpanStatusCode } from '@opentelemetry/api';
15
15
  import { getAggregator } from './aggregator.js';
16
16
  import { debug, getConfig } from './config.js';
17
+ import { shouldEmitBunManualSpans, getBunTracer } from './bunManualSpans.js';
17
18
 
18
19
  let isInstrumented = false;
19
20
  let originalHttpRequest = null;
@@ -178,6 +179,22 @@ function shouldExcludeUrl(u) {
178
179
  return shouldExclude(u.hostname, u);
179
180
  }
180
181
 
182
+ /** Full URL string for outgoing http.request options (for trace attributes). */
183
+ function safeOutgoingUrl(options) {
184
+ try {
185
+ if (typeof options === 'string') return options;
186
+ if (!options || typeof options !== 'object') return '';
187
+ const protocol = options.protocol || 'http:';
188
+ const hostRaw = options.hostname || options.host || '';
189
+ const host = String(hostRaw).trim().split(':')[0];
190
+ const path = options.path || options.pathname || '/';
191
+ if (!host) return '';
192
+ return `${protocol}//${host}${path}`;
193
+ } catch {
194
+ return '';
195
+ }
196
+ }
197
+
181
198
  function injectTraceHeaders(init) {
182
199
  const headers = new Headers((init && init.headers) || {});
183
200
  try {
@@ -215,6 +232,45 @@ export function instrumentFetch() {
215
232
  const peerLabel = resolveOutgoingPeerLabel(hostname, u ? u.toString() : '');
216
233
  const depType = getDependencyType(hostname, u?.pathname || '/');
217
234
 
235
+ if (shouldEmitBunManualSpans()) {
236
+ const tracer = getBunTracer();
237
+ const method = (init && init.method && String(init.method).toUpperCase()) || 'GET';
238
+ const span = tracer.startSpan(`HTTP ${method}`, { kind: SpanKind.CLIENT });
239
+ span.setAttribute('http.method', method);
240
+ if (hostname) span.setAttribute('net.peer.name', hostname);
241
+ if (u?.href) span.setAttribute('http.url', u.href);
242
+ const ctx = trace.setSpan(context.active(), span);
243
+ return context.with(ctx, async () => {
244
+ try {
245
+ const res = await originalFetch.call(this, input, injectTraceHeaders(init));
246
+ const endTime = process.hrtime.bigint();
247
+ const latencyMs = Number(endTime - startTime) / 1e6;
248
+ const isError = (res?.status || 0) >= 400;
249
+ span.setAttribute('http.status_code', res?.status || 0);
250
+ span.setStatus(
251
+ isError ? { code: SpanStatusCode.ERROR } : { code: SpanStatusCode.OK }
252
+ );
253
+ getAggregator().recordDependency(peerLabel, depType, latencyMs, isError);
254
+ debug(`Service call: ${caller} -> ${peerLabel} ${latencyMs.toFixed(2)}ms (${depType}) ${res?.status}`);
255
+ return res;
256
+ } catch (err) {
257
+ const endTime = process.hrtime.bigint();
258
+ const latencyMs = Number(endTime - startTime) / 1e6;
259
+ span.recordException(err);
260
+ span.setStatus({ code: SpanStatusCode.ERROR });
261
+ getAggregator().recordDependency(peerLabel, depType, latencyMs, true);
262
+ debug(`Service call: ${caller} -> ${peerLabel} ${latencyMs.toFixed(2)}ms (${depType}) error`);
263
+ throw err;
264
+ } finally {
265
+ try {
266
+ span.end();
267
+ } catch {
268
+ /* ignore */
269
+ }
270
+ }
271
+ });
272
+ }
273
+
218
274
  try {
219
275
  const res = await originalFetch.call(this, input, injectTraceHeaders(init));
220
276
  const endTime = process.hrtime.bigint();
@@ -270,11 +326,52 @@ function wrapRequest(original, protocol) {
270
326
  const depType = getDependencyType(hostname, path);
271
327
  const caller = getConfig().service || 'unknown';
272
328
 
273
- // Call original
274
- const req = original.apply(this, arguments);
329
+ let clientSpan = null;
330
+ let clientSpanEnded = false;
331
+ const finishClientSpan = (statusCode, networkError) => {
332
+ if (!clientSpan || clientSpanEnded) return;
333
+ clientSpanEnded = true;
334
+ try {
335
+ if (statusCode > 0) {
336
+ clientSpan.setAttribute('http.status_code', statusCode);
337
+ }
338
+ clientSpan.setStatus(
339
+ networkError || statusCode >= 400
340
+ ? { code: SpanStatusCode.ERROR }
341
+ : { code: SpanStatusCode.OK }
342
+ );
343
+ clientSpan.end();
344
+ } catch {
345
+ /* ignore */
346
+ }
347
+ };
348
+
349
+ if (shouldEmitBunManualSpans()) {
350
+ try {
351
+ const tracer = getBunTracer();
352
+ const method =
353
+ typeof options === 'string'
354
+ ? 'GET'
355
+ : String(options?.method || 'GET').toUpperCase();
356
+ clientSpan = tracer.startSpan(`HTTP ${method}`, { kind: SpanKind.CLIENT });
357
+ clientSpan.setAttribute('http.method', method);
358
+ if (hostname) clientSpan.setAttribute('net.peer.name', hostname);
359
+ const urlStr = safeOutgoingUrl(options);
360
+ if (urlStr) clientSpan.setAttribute('http.url', urlStr);
361
+ } catch {
362
+ clientSpan = null;
363
+ }
364
+ }
365
+
366
+ const req = clientSpan
367
+ ? context.with(trace.setSpan(context.active(), clientSpan), () =>
368
+ original.apply(this, arguments)
369
+ )
370
+ : original.apply(this, arguments);
275
371
 
276
372
  // Track response
277
373
  req.on('response', (res) => {
374
+ finishClientSpan(res.statusCode || 0, false);
278
375
  const endTime = process.hrtime.bigint();
279
376
  const latencyMs = Number(endTime - startTime) / 1e6;
280
377
  const isError = res.statusCode >= 400;
@@ -289,6 +386,7 @@ function wrapRequest(original, protocol) {
289
386
 
290
387
  // Track errors
291
388
  req.on('error', () => {
389
+ finishClientSpan(0, true);
292
390
  const endTime = process.hrtime.bigint();
293
391
  const latencyMs = Number(endTime - startTime) / 1e6;
294
392
 
@@ -16,6 +16,14 @@ import { extractRoute } from './normalizer.js';
16
16
  import { getAggregator } from './aggregator.js';
17
17
  import { debug } from './config.js';
18
18
  import { isTracingActive } from './tracing.js';
19
+ import {
20
+ context,
21
+ trace,
22
+ SpanKind,
23
+ SpanStatusCode,
24
+ shouldEmitBunManualSpans,
25
+ getBunTracer
26
+ } from './bunManualSpans.js';
19
27
 
20
28
  let isInstrumented = false;
21
29
  let originalHttpCreateServer = null;
@@ -28,6 +36,19 @@ function wrapRequestHandler(handler) {
28
36
  return function wrappedHandler(req, res) {
29
37
  const startTime = process.hrtime.bigint();
30
38
 
39
+ let span = null;
40
+ let spanEnded = false;
41
+ if (shouldEmitBunManualSpans()) {
42
+ try {
43
+ span = getBunTracer().startSpan('HTTP', { kind: SpanKind.SERVER });
44
+ span.setAttribute('http.method', req.method || 'GET');
45
+ const rawUrl = req.url || '/';
46
+ span.setAttribute('http.target', typeof rawUrl === 'string' ? rawUrl : String(rawUrl));
47
+ } catch {
48
+ span = null;
49
+ }
50
+ }
51
+
31
52
  // Store original end method
32
53
  const originalEnd = res.end;
33
54
 
@@ -46,13 +67,52 @@ function wrapRequestHandler(handler) {
46
67
  const aggregator = getAggregator();
47
68
  aggregator.recordRequest(method, route, latencyMs, isError);
48
69
 
70
+ if (span && !spanEnded) {
71
+ spanEnded = true;
72
+ try {
73
+ span.setAttribute('http.route', route);
74
+ span.setAttribute('http.status_code', statusCode);
75
+ span.updateName(`${method} ${route}`);
76
+ span.setStatus(
77
+ statusCode >= 400
78
+ ? { code: SpanStatusCode.ERROR }
79
+ : { code: SpanStatusCode.OK }
80
+ );
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ try {
85
+ span.end();
86
+ } catch {
87
+ /* ignore */
88
+ }
89
+ }
90
+
49
91
  debug(`Request: ${method} ${route} ${statusCode} ${latencyMs.toFixed(2)}ms`);
50
92
 
51
93
  // Call original end
52
94
  return originalEnd.apply(this, args);
53
95
  };
54
96
 
55
- // Call original handler
97
+ if (span) {
98
+ try {
99
+ const ctx = trace.setSpan(context.active(), span);
100
+ return context.with(ctx, () => handler.call(this, req, res));
101
+ } catch (err) {
102
+ if (!spanEnded) {
103
+ spanEnded = true;
104
+ try {
105
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err?.message });
106
+ span.recordException(err);
107
+ span.end();
108
+ } catch {
109
+ /* ignore */
110
+ }
111
+ }
112
+ throw err;
113
+ }
114
+ }
115
+
56
116
  return handler.call(this, req, res);
57
117
  };
58
118
  }
@@ -95,6 +95,27 @@ function shouldSampleTraceId(traceId, sampleRate) {
95
95
  }
96
96
  }
97
97
 
98
+ function getParentSpanId(span) {
99
+ try {
100
+ const direct = span?.parentSpanId;
101
+ if (typeof direct === 'string' && direct) return direct;
102
+
103
+ const psc = span?.parentSpanContext;
104
+ if (psc && typeof psc === 'object') {
105
+ const id = psc.spanId;
106
+ if (typeof id === 'string' && id) return id;
107
+ }
108
+ if (typeof psc === 'function') {
109
+ const ctx = psc();
110
+ const id = ctx?.spanId;
111
+ if (typeof id === 'string' && id) return id;
112
+ }
113
+ } catch {
114
+ // ignore
115
+ }
116
+ return null;
117
+ }
118
+
98
119
  function serializeSpan(span) {
99
120
  const ctx = span?.spanContext?.();
100
121
  if (!ctx?.traceId || !ctx?.spanId) return null;
@@ -103,7 +124,7 @@ function serializeSpan(span) {
103
124
  const endNano = hrTimeToUnixNanoString(span.endTime);
104
125
  if (!startNano || !endNano) return null;
105
126
 
106
- const parentSpanId = span?.parentSpanId || span?.parentSpanContext?.spanId || null;
127
+ const parentSpanId = getParentSpanId(span);
107
128
  const status = statusForSpan(span);
108
129
 
109
130
  const durationMs =
@@ -146,13 +167,6 @@ export class SentienGuardTraceSpanExporter {
146
167
  }
147
168
  }
148
169
 
149
- // #region agent log
150
- try {
151
- const first = serialized[0] || null;
152
- console.log('[SG-APM-DBG]', JSON.stringify({sessionId:'ecc573',runId:'trace-export',hypothesisId:'F,H,I',location:'traceSpanExporter.js:export',message:'export() called',data:{receivedCount:Array.isArray(spans)?spans.length:0,sampledCount:serialized.length,sampleRate:rate,firstName:first?.name||null,firstKind:first?.kind||null,firstTraceId:first?.trace_id||null},timestamp:Date.now()}));
153
- } catch {}
154
- // #endregion
155
-
156
170
  if (serialized.length) {
157
171
  enqueueSpans(serialized);
158
172
  }
@@ -97,25 +97,14 @@ async function flushOnce(batch) {
97
97
  };
98
98
 
99
99
  try {
100
- const startedAt = Date.now();
101
- const res = await sendToBackend(payload);
100
+ await sendToBackend(payload);
102
101
  consecutiveFailures = 0;
103
102
  lastFailureAtMs = 0;
104
103
  debug(`Trace flush ok: spans=${batch.length}`);
105
- // #region agent log
106
- try {
107
- console.log('[SG-APM-DBG]', JSON.stringify({sessionId:'ecc573',runId:'trace-send',hypothesisId:'G',location:'traceTransport.js:flushOnce',message:'sendToBackend SUCCESS',data:{spans:batch.length,statusCode:res?.statusCode||null,durationMs:Date.now()-startedAt,endpoint:cfg.tracesEndpoint},timestamp:Date.now()}));
108
- } catch {}
109
- // #endregion
110
104
  } catch (err) {
111
105
  consecutiveFailures++;
112
106
  lastFailureAtMs = Date.now();
113
107
  warn(`Trace flush failed (attempt ${consecutiveFailures}): ${err.message}`);
114
- // #region agent log
115
- try {
116
- console.log('[SG-APM-DBG]', JSON.stringify({sessionId:'ecc573',runId:'trace-send',hypothesisId:'G',location:'traceTransport.js:flushOnce',message:'sendToBackend FAILED',data:{spans:batch.length,error:err?.message||String(err),consecutiveFailures,endpoint:cfg.tracesEndpoint},timestamp:Date.now()}));
117
- } catch {}
118
- // #endregion
119
108
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
120
109
  // Stop retrying aggressively; drop future spans until backend recovers.
121
110
  warn('Trace flush: max failures reached; dropping spans under backpressure');
@@ -165,12 +154,6 @@ export function enqueueSpans(serializedSpans) {
165
154
  const cfg = getConfig();
166
155
  const maxQueue = cfg.tracing?.maxQueueSize || 2048;
167
156
 
168
- // #region agent log
169
- try {
170
- console.log('[SG-APM-DBG]', JSON.stringify({sessionId:'ecc573',runId:'trace-enqueue',hypothesisId:'F,G',location:'traceTransport.js:enqueueSpans',message:'enqueueSpans called',data:{incomingCount:Array.isArray(serializedSpans)?serializedSpans.length:0,queueLenBefore:queue.length,maxQueue,sdkEnabled:isEnabled(),scheduled,consecutiveFailures},timestamp:Date.now()}));
171
- } catch {}
172
- // #endregion
173
-
174
157
  if (!Array.isArray(serializedSpans) || serializedSpans.length === 0) return;
175
158
  if (!isEnabled()) return;
176
159
 
package/src/tracing.js CHANGED
@@ -193,11 +193,6 @@ export function startTracing() {
193
193
  sdk.start();
194
194
  tracingActive = true;
195
195
  debug('OpenTelemetry tracing started (W3C Trace Context + HTTP/Express)');
196
- // #region agent log
197
- try {
198
- console.log('[SG-APM-DBG]', JSON.stringify({sessionId:'ecc573',runId:'trace-init',hypothesisId:'F,H,J',location:'tracing.js:startTracing',message:'tracing config snapshot',data:{isBun:typeof globalThis.Bun!=='undefined',tracingEnabled:cfg.tracing?.enabled,sampleRate:cfg.tracing?.sampleRate,maxQueueSize:cfg.tracing?.maxQueueSize,maxBatchSize:cfg.tracing?.maxBatchSize,endpoint:cfg.endpoint,tracesEndpoint:cfg.tracesEndpoint,service:cfg.service,environment:cfg.environment,apiKeyPresent:!!cfg.apiKey,apiKeyLen:cfg.apiKey?.length||0},timestamp:Date.now()}));
199
- } catch {}
200
- // #endregion
201
196
  return true;
202
197
  } catch (err) {
203
198
  const msg = err instanceof Error ? err.message : String(err);