@sentienguard/apm 1.0.23-debug.1 → 1.0.23
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 +177 -141
- package/package.json +1 -1
- package/src/browser/aggregator.js +28 -0
- package/src/browser/transport.js +10 -1
- package/src/bunManualSpans.js +20 -0
- package/src/dependencies.js +101 -3
- package/src/instrumentation.js +61 -1
- package/src/traceSpanExporter.js +22 -8
- package/src/traceTransport.js +1 -18
- package/src/tracing.js +0 -5
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
|
-
##
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
@@ -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
|
}
|
package/src/browser/transport.js
CHANGED
|
@@ -87,7 +87,16 @@ export async function flush() {
|
|
|
87
87
|
if (!aggregator.hasData()) return;
|
|
88
88
|
|
|
89
89
|
const payload = aggregator.flush();
|
|
90
|
-
|
|
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();
|
|
@@ -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 };
|
package/src/dependencies.js
CHANGED
|
@@ -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
|
-
|
|
274
|
-
|
|
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
|
|
package/src/instrumentation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/traceSpanExporter.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/traceTransport.js
CHANGED
|
@@ -97,25 +97,14 @@ async function flushOnce(batch) {
|
|
|
97
97
|
};
|
|
98
98
|
|
|
99
99
|
try {
|
|
100
|
-
|
|
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);
|