@zero-server/observe 0.9.1 → 0.9.2
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/LICENSE +21 -21
- package/index.js +22 -22
- package/lib/observe/health.js +326 -0
- package/lib/observe/index.js +50 -0
- package/lib/observe/logger.js +359 -0
- package/lib/observe/metrics.js +805 -0
- package/lib/observe/tracing.js +592 -0
- package/package.json +9 -3
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module observe/tracing
|
|
3
|
+
* @description Zero-dependency distributed tracing with W3C Trace Context
|
|
4
|
+
* propagation. Provides span creation, context propagation via
|
|
5
|
+
* `traceparent`/`tracestate` headers, and auto-instrumentation
|
|
6
|
+
* middleware for HTTP, ORM queries, WebSocket, SSE, gRPC, and
|
|
7
|
+
* outbound fetch calls.
|
|
8
|
+
*
|
|
9
|
+
* Compatible with OpenTelemetry: spans export in OTLP-like
|
|
10
|
+
* format and support configurable exporters for Jaeger, Zipkin,
|
|
11
|
+
* or any custom backend.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const { tracingMiddleware, Tracer } = require('@zero-server/sdk');
|
|
15
|
+
*
|
|
16
|
+
* const tracer = new Tracer({ serviceName: 'my-api' });
|
|
17
|
+
* app.use(tracingMiddleware({ tracer }));
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* // With exporter
|
|
21
|
+
* const tracer = new Tracer({
|
|
22
|
+
* serviceName: 'my-api',
|
|
23
|
+
* exporter: (spans) => fetch('http://jaeger:4318/v1/traces', {
|
|
24
|
+
* method: 'POST',
|
|
25
|
+
* body: JSON.stringify(spans),
|
|
26
|
+
* }),
|
|
27
|
+
* });
|
|
28
|
+
*/
|
|
29
|
+
const crypto = require('crypto');
|
|
30
|
+
|
|
31
|
+
// -- Span & Context -----------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Immutable identifier for a trace.
|
|
35
|
+
* @param {string} [id] - 32-char lowercase hex, or auto-generated.
|
|
36
|
+
* @returns {string}
|
|
37
|
+
* @private
|
|
38
|
+
*/
|
|
39
|
+
function _traceId(id)
|
|
40
|
+
{
|
|
41
|
+
if (id && /^[0-9a-f]{32}$/.test(id)) return id;
|
|
42
|
+
return crypto.randomBytes(16).toString('hex');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 16-char hex span ID.
|
|
47
|
+
* @returns {string}
|
|
48
|
+
* @private
|
|
49
|
+
*/
|
|
50
|
+
function _spanId()
|
|
51
|
+
{
|
|
52
|
+
return crypto.randomBytes(8).toString('hex');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse W3C `traceparent` header.
|
|
57
|
+
* Format: `{version}-{traceId}-{parentSpanId}-{traceFlags}`
|
|
58
|
+
*
|
|
59
|
+
* @param {string} header - traceparent header value.
|
|
60
|
+
* @returns {{ traceId: string, parentSpanId: string, traceFlags: number }|null}
|
|
61
|
+
* @private
|
|
62
|
+
*/
|
|
63
|
+
function parseTraceparent(header)
|
|
64
|
+
{
|
|
65
|
+
if (!header || typeof header !== 'string') return null;
|
|
66
|
+
const parts = header.trim().split('-');
|
|
67
|
+
if (parts.length < 4) return null;
|
|
68
|
+
const [version, traceId, parentSpanId, flags] = parts;
|
|
69
|
+
if (version.length !== 2 || traceId.length !== 32 || parentSpanId.length !== 16) return null;
|
|
70
|
+
if (!/^[0-9a-f]+$/.test(traceId) || !/^[0-9a-f]+$/.test(parentSpanId)) return null;
|
|
71
|
+
// All-zero traceId or spanId is invalid per spec
|
|
72
|
+
if (/^0+$/.test(traceId) || /^0+$/.test(parentSpanId)) return null;
|
|
73
|
+
return {
|
|
74
|
+
traceId,
|
|
75
|
+
parentSpanId,
|
|
76
|
+
traceFlags: parseInt(flags, 16) || 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Format a traceparent header value.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} traceId - 32-char trace ID.
|
|
84
|
+
* @param {string} spanId - 16-char span ID.
|
|
85
|
+
* @param {number} [flags=1] - Trace flags (1 = sampled).
|
|
86
|
+
* @returns {string} W3C traceparent header value.
|
|
87
|
+
* @private
|
|
88
|
+
*/
|
|
89
|
+
function formatTraceparent(traceId, spanId, flags = 1)
|
|
90
|
+
{
|
|
91
|
+
return `00-${traceId}-${spanId}-${String(flags).padStart(2, '0')}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// -- Span ----------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Represents a unit of work in a distributed trace.
|
|
98
|
+
* Tracks timing, status, attributes, and events.
|
|
99
|
+
*/
|
|
100
|
+
class Span
|
|
101
|
+
{
|
|
102
|
+
/**
|
|
103
|
+
* @constructor
|
|
104
|
+
* @param {object} opts - Span options.
|
|
105
|
+
* @param {string} opts.name - Operation name.
|
|
106
|
+
* @param {string} opts.traceId - Trace ID.
|
|
107
|
+
* @param {string} [opts.parentSpanId] - Parent span ID.
|
|
108
|
+
* @param {string} [opts.kind='server'] - Span kind: 'server', 'client', 'producer', 'consumer', 'internal'.
|
|
109
|
+
* @param {object} [opts.attributes] - Initial attributes.
|
|
110
|
+
* @param {Tracer} [opts.tracer] - Tracer instance for export.
|
|
111
|
+
*/
|
|
112
|
+
constructor(opts)
|
|
113
|
+
{
|
|
114
|
+
this.name = opts.name;
|
|
115
|
+
this.traceId = opts.traceId;
|
|
116
|
+
this.spanId = _spanId();
|
|
117
|
+
this.parentSpanId = opts.parentSpanId || null;
|
|
118
|
+
this.kind = opts.kind || 'server';
|
|
119
|
+
this.attributes = opts.attributes ? { ...opts.attributes } : {};
|
|
120
|
+
this.events = [];
|
|
121
|
+
this.status = { code: 0 }; // 0=UNSET, 1=OK, 2=ERROR
|
|
122
|
+
this.startTime = Date.now();
|
|
123
|
+
this.endTime = null;
|
|
124
|
+
this._tracer = opts.tracer || null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Set a span attribute.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} key - Attribute name (OpenTelemetry semantic convention recommended).
|
|
131
|
+
* @param {string|number|boolean} value - Attribute value.
|
|
132
|
+
* @returns {Span} this
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* span.setAttribute('http.method', 'GET');
|
|
136
|
+
* span.setAttribute('http.status_code', 200);
|
|
137
|
+
*/
|
|
138
|
+
setAttribute(key, value)
|
|
139
|
+
{
|
|
140
|
+
this.attributes[key] = value;
|
|
141
|
+
return this;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Set multiple attributes at once.
|
|
146
|
+
*
|
|
147
|
+
* @param {object} attrs - Key-value pairs.
|
|
148
|
+
* @returns {Span} this
|
|
149
|
+
*/
|
|
150
|
+
setAttributes(attrs)
|
|
151
|
+
{
|
|
152
|
+
Object.assign(this.attributes, attrs);
|
|
153
|
+
return this;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Add a timestamped event to the span.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} name - Event name.
|
|
160
|
+
* @param {object} [attributes] - Event attributes.
|
|
161
|
+
* @returns {Span} this
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* span.addEvent('cache.miss', { key: 'user:123' });
|
|
165
|
+
*/
|
|
166
|
+
addEvent(name, attributes)
|
|
167
|
+
{
|
|
168
|
+
this.events.push({
|
|
169
|
+
name,
|
|
170
|
+
timestamp: Date.now(),
|
|
171
|
+
attributes: attributes || {},
|
|
172
|
+
});
|
|
173
|
+
return this;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Set status to OK.
|
|
178
|
+
* @returns {Span} this
|
|
179
|
+
*/
|
|
180
|
+
setOk()
|
|
181
|
+
{
|
|
182
|
+
this.status = { code: 1 };
|
|
183
|
+
return this;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Set status to ERROR.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} [message] - Error description.
|
|
190
|
+
* @returns {Span} this
|
|
191
|
+
*/
|
|
192
|
+
setError(message)
|
|
193
|
+
{
|
|
194
|
+
this.status = { code: 2, message };
|
|
195
|
+
return this;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Record an exception as a span event and set error status.
|
|
200
|
+
*
|
|
201
|
+
* @param {Error} err - The exception.
|
|
202
|
+
* @returns {Span} this
|
|
203
|
+
*/
|
|
204
|
+
recordException(err)
|
|
205
|
+
{
|
|
206
|
+
this.addEvent('exception', {
|
|
207
|
+
'exception.type': err.constructor?.name || 'Error',
|
|
208
|
+
'exception.message': err.message,
|
|
209
|
+
'exception.stacktrace': err.stack,
|
|
210
|
+
});
|
|
211
|
+
return this.setError(err.message);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* End the span and report to the tracer.
|
|
216
|
+
* @returns {Span} this
|
|
217
|
+
*/
|
|
218
|
+
end()
|
|
219
|
+
{
|
|
220
|
+
if (this.endTime) return this; // already ended
|
|
221
|
+
this.endTime = Date.now();
|
|
222
|
+
if (this._tracer) this._tracer._report(this);
|
|
223
|
+
return this;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Duration in milliseconds (or null if not ended).
|
|
228
|
+
* @type {number|null}
|
|
229
|
+
*/
|
|
230
|
+
get duration()
|
|
231
|
+
{
|
|
232
|
+
if (!this.endTime) return null;
|
|
233
|
+
return this.endTime - this.startTime;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The traceparent header value for this span.
|
|
238
|
+
* @type {string}
|
|
239
|
+
*/
|
|
240
|
+
get traceparent()
|
|
241
|
+
{
|
|
242
|
+
return formatTraceparent(this.traceId, this.spanId);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Serialize span for export.
|
|
247
|
+
* @returns {object} OTLP-compatible span object.
|
|
248
|
+
*/
|
|
249
|
+
toJSON()
|
|
250
|
+
{
|
|
251
|
+
return {
|
|
252
|
+
traceId: this.traceId,
|
|
253
|
+
spanId: this.spanId,
|
|
254
|
+
parentSpanId: this.parentSpanId,
|
|
255
|
+
name: this.name,
|
|
256
|
+
kind: this.kind,
|
|
257
|
+
startTime: this.startTime,
|
|
258
|
+
endTime: this.endTime,
|
|
259
|
+
duration: this.duration,
|
|
260
|
+
status: this.status,
|
|
261
|
+
attributes: this.attributes,
|
|
262
|
+
events: this.events,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// -- Tracer --------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Creates and manages spans for distributed tracing.
|
|
271
|
+
* Batches completed spans and flushes them to the configured exporter.
|
|
272
|
+
*
|
|
273
|
+
* @example
|
|
274
|
+
* const tracer = new Tracer({
|
|
275
|
+
* serviceName: 'payments-api',
|
|
276
|
+
* exporter: (spans) => fetch('http://jaeger:4318/v1/traces', {
|
|
277
|
+
* method: 'POST',
|
|
278
|
+
* headers: { 'Content-Type': 'application/json' },
|
|
279
|
+
* body: JSON.stringify(spans),
|
|
280
|
+
* }),
|
|
281
|
+
* batchSize: 50,
|
|
282
|
+
* flushInterval: 5000,
|
|
283
|
+
* });
|
|
284
|
+
*/
|
|
285
|
+
class Tracer
|
|
286
|
+
{
|
|
287
|
+
/**
|
|
288
|
+
* @constructor
|
|
289
|
+
* @param {object} [opts] - Tracer options.
|
|
290
|
+
* @param {string} [opts.serviceName='unknown'] - Service name for all spans.
|
|
291
|
+
* @param {Function} [opts.exporter] - `(spans: object[]) => void | Promise<void>` — called with batches of serialised spans.
|
|
292
|
+
* @param {number} [opts.batchSize=100] - Max spans per export batch.
|
|
293
|
+
* @param {number} [opts.flushInterval=5000] - Auto-flush interval in ms.
|
|
294
|
+
* @param {number} [opts.sampleRate=1.0] - Sampling rate (0.0 to 1.0). 1.0 = sample everything.
|
|
295
|
+
* @param {object} [opts.resource] - Extra resource attributes (e.g. `{ 'deployment.environment': 'prod' }`).
|
|
296
|
+
*/
|
|
297
|
+
constructor(opts = {})
|
|
298
|
+
{
|
|
299
|
+
this.serviceName = opts.serviceName || 'unknown';
|
|
300
|
+
this._exporter = typeof opts.exporter === 'function' ? opts.exporter : null;
|
|
301
|
+
this._batchSize = opts.batchSize || 100;
|
|
302
|
+
this._sampleRate = typeof opts.sampleRate === 'number' ? Math.max(0, Math.min(1, opts.sampleRate)) : 1.0;
|
|
303
|
+
this._resource = {
|
|
304
|
+
'service.name': this.serviceName,
|
|
305
|
+
...(opts.resource || {}),
|
|
306
|
+
};
|
|
307
|
+
this._buffer = [];
|
|
308
|
+
this._listeners = [];
|
|
309
|
+
|
|
310
|
+
// Auto-flush timer
|
|
311
|
+
this._flushTimer = null;
|
|
312
|
+
if (opts.flushInterval !== 0)
|
|
313
|
+
{
|
|
314
|
+
const interval = opts.flushInterval || 5000;
|
|
315
|
+
this._flushTimer = setInterval(() => this.flush(), interval);
|
|
316
|
+
if (this._flushTimer.unref) this._flushTimer.unref();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Create a new span.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} name - Span/operation name.
|
|
324
|
+
* @param {object} [opts] - Span options.
|
|
325
|
+
* @param {string} [opts.traceId] - Trace ID (inherits from parent context or generates new).
|
|
326
|
+
* @param {string} [opts.parentSpanId] - Parent span ID.
|
|
327
|
+
* @param {string} [opts.kind='server'] - Span kind.
|
|
328
|
+
* @param {object} [opts.attributes] - Initial attributes.
|
|
329
|
+
* @returns {Span} The new span.
|
|
330
|
+
*
|
|
331
|
+
* @example
|
|
332
|
+
* const span = tracer.startSpan('GET /users', {
|
|
333
|
+
* attributes: { 'http.method': 'GET', 'http.url': '/users' },
|
|
334
|
+
* });
|
|
335
|
+
* try {
|
|
336
|
+
* await handleRequest();
|
|
337
|
+
* span.setOk();
|
|
338
|
+
* } catch (err) {
|
|
339
|
+
* span.recordException(err);
|
|
340
|
+
* } finally {
|
|
341
|
+
* span.end();
|
|
342
|
+
* }
|
|
343
|
+
*/
|
|
344
|
+
startSpan(name, opts = {})
|
|
345
|
+
{
|
|
346
|
+
return new Span({
|
|
347
|
+
name,
|
|
348
|
+
traceId: opts.traceId || _traceId(),
|
|
349
|
+
parentSpanId: opts.parentSpanId,
|
|
350
|
+
kind: opts.kind || 'server',
|
|
351
|
+
attributes: {
|
|
352
|
+
...this._resource,
|
|
353
|
+
...(opts.attributes || {}),
|
|
354
|
+
},
|
|
355
|
+
tracer: this,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Whether a new trace should be sampled.
|
|
361
|
+
* @returns {boolean}
|
|
362
|
+
*/
|
|
363
|
+
shouldSample()
|
|
364
|
+
{
|
|
365
|
+
if (this._sampleRate >= 1.0) return true;
|
|
366
|
+
if (this._sampleRate <= 0) return false;
|
|
367
|
+
return Math.random() < this._sampleRate;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Register a listener for completed spans.
|
|
372
|
+
*
|
|
373
|
+
* @param {Function} fn - `(span: Span) => void`.
|
|
374
|
+
* @returns {Tracer} this
|
|
375
|
+
*/
|
|
376
|
+
onSpanEnd(fn)
|
|
377
|
+
{
|
|
378
|
+
if (typeof fn === 'function') this._listeners.push(fn);
|
|
379
|
+
return this;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Receive a completed span.
|
|
384
|
+
* @private
|
|
385
|
+
* @param {Span} span
|
|
386
|
+
*/
|
|
387
|
+
_report(span)
|
|
388
|
+
{
|
|
389
|
+
// Notify listeners
|
|
390
|
+
for (const fn of this._listeners)
|
|
391
|
+
{
|
|
392
|
+
try { fn(span); }
|
|
393
|
+
catch (_) { /* don't let listener errors break tracing */ }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (!this._exporter) return;
|
|
397
|
+
|
|
398
|
+
this._buffer.push(span.toJSON());
|
|
399
|
+
if (this._buffer.length >= this._batchSize) this.flush();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Flush buffered spans to the exporter.
|
|
404
|
+
*
|
|
405
|
+
* @returns {Promise<void>}
|
|
406
|
+
*/
|
|
407
|
+
async flush()
|
|
408
|
+
{
|
|
409
|
+
if (this._buffer.length === 0 || !this._exporter) return;
|
|
410
|
+
const batch = this._buffer.splice(0, this._buffer.length);
|
|
411
|
+
try
|
|
412
|
+
{
|
|
413
|
+
await this._exporter(batch);
|
|
414
|
+
}
|
|
415
|
+
catch (_)
|
|
416
|
+
{
|
|
417
|
+
// Silently drop export errors — tracing should never break the app
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Shut down the tracer, flushing remaining spans.
|
|
423
|
+
*
|
|
424
|
+
* @returns {Promise<void>}
|
|
425
|
+
*/
|
|
426
|
+
async shutdown()
|
|
427
|
+
{
|
|
428
|
+
if (this._flushTimer) { clearInterval(this._flushTimer); this._flushTimer = null; }
|
|
429
|
+
await this.flush();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// -- Tracing Middleware --------------------------------------------
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Create HTTP tracing middleware.
|
|
437
|
+
* Automatically creates a span for each request, extracts incoming
|
|
438
|
+
* `traceparent`/`tracestate` headers, and sets outgoing `traceparent`.
|
|
439
|
+
*
|
|
440
|
+
* @param {object} [opts] - Options.
|
|
441
|
+
* @param {Tracer} [opts.tracer] - Tracer instance. Creates a default if not provided.
|
|
442
|
+
* @param {Function} [opts.routeLabel] - `(req) => string` — extract route label for span name.
|
|
443
|
+
* @param {Function} [opts.skip] - `(req) => boolean` — skip tracing for certain requests.
|
|
444
|
+
* @param {boolean} [opts.propagate=true] - Propagate W3C trace context via response headers.
|
|
445
|
+
* @returns {Function} Middleware `(req, res, next) => void`.
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* app.use(tracingMiddleware({ tracer }));
|
|
449
|
+
*
|
|
450
|
+
* @example
|
|
451
|
+
* // With sampling
|
|
452
|
+
* const tracer = new Tracer({ serviceName: 'api', sampleRate: 0.1 });
|
|
453
|
+
* app.use(tracingMiddleware({ tracer }));
|
|
454
|
+
*/
|
|
455
|
+
function tracingMiddleware(opts = {})
|
|
456
|
+
{
|
|
457
|
+
const tracer = opts.tracer || new Tracer(opts);
|
|
458
|
+
const getRoute = typeof opts.routeLabel === 'function' ? opts.routeLabel : null;
|
|
459
|
+
const skip = typeof opts.skip === 'function' ? opts.skip : null;
|
|
460
|
+
const propagate = opts.propagate !== false;
|
|
461
|
+
|
|
462
|
+
return (req, res, next) =>
|
|
463
|
+
{
|
|
464
|
+
if (skip && skip(req)) return next();
|
|
465
|
+
|
|
466
|
+
// Parse incoming trace context
|
|
467
|
+
const incoming = parseTraceparent(req.headers?.traceparent || req.headers?.['traceparent']);
|
|
468
|
+
const traceId = incoming?.traceId;
|
|
469
|
+
const parentSpanId = incoming?.parentSpanId;
|
|
470
|
+
|
|
471
|
+
// Sampling decision
|
|
472
|
+
if (!tracer.shouldSample() && !incoming) return next();
|
|
473
|
+
|
|
474
|
+
const spanName = `${req.method} ${getRoute ? getRoute(req) : (req.url?.split('?')[0] || '/')}`;
|
|
475
|
+
const span = tracer.startSpan(spanName, {
|
|
476
|
+
traceId,
|
|
477
|
+
parentSpanId,
|
|
478
|
+
kind: 'server',
|
|
479
|
+
attributes: {
|
|
480
|
+
'http.method': req.method,
|
|
481
|
+
'http.url': req.originalUrl || req.url,
|
|
482
|
+
'http.target': req.url?.split('?')[0],
|
|
483
|
+
'http.host': req.headers?.host,
|
|
484
|
+
'http.scheme': req.secure ? 'https' : 'http',
|
|
485
|
+
'http.user_agent': req.headers?.['user-agent'],
|
|
486
|
+
'net.peer.ip': req.ip || req.socket?.remoteAddress,
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// Attach to request for downstream use
|
|
491
|
+
req.span = span;
|
|
492
|
+
req.traceId = span.traceId;
|
|
493
|
+
|
|
494
|
+
// Propagate tracestate if present
|
|
495
|
+
const tracestate = req.headers?.tracestate || req.headers?.['tracestate'];
|
|
496
|
+
if (tracestate) span.setAttribute('tracestate', tracestate);
|
|
497
|
+
|
|
498
|
+
// Set response headers for propagation
|
|
499
|
+
if (propagate)
|
|
500
|
+
{
|
|
501
|
+
res.set('traceparent', span.traceparent);
|
|
502
|
+
if (tracestate) res.set('tracestate', tracestate);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Hook into response finish
|
|
506
|
+
const raw = res.raw || res;
|
|
507
|
+
const onFinish = () =>
|
|
508
|
+
{
|
|
509
|
+
raw.removeListener('finish', onFinish);
|
|
510
|
+
const status = raw.statusCode || 200;
|
|
511
|
+
span.setAttribute('http.status_code', status);
|
|
512
|
+
span.setAttribute('http.response_content_length', parseInt(raw.getHeader?.('content-length') || '0', 10) || 0);
|
|
513
|
+
|
|
514
|
+
if (req.route) span.setAttribute('http.route', req.route);
|
|
515
|
+
if (req.id) span.setAttribute('http.request_id', req.id);
|
|
516
|
+
|
|
517
|
+
if (status >= 500) span.setError(`HTTP ${status}`);
|
|
518
|
+
else span.setOk();
|
|
519
|
+
|
|
520
|
+
span.end();
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
raw.on('finish', onFinish);
|
|
524
|
+
next();
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Instrument outbound fetch calls with tracing.
|
|
530
|
+
* Wraps the zero-server fetch to inject `traceparent` headers
|
|
531
|
+
* and create client spans.
|
|
532
|
+
*
|
|
533
|
+
* @param {Function} fetchFn - The original fetch function.
|
|
534
|
+
* @param {Tracer} tracer - Tracer instance.
|
|
535
|
+
* @returns {Function} Instrumented fetch with same signature.
|
|
536
|
+
*
|
|
537
|
+
* @example
|
|
538
|
+
* const { fetch, Tracer, instrumentFetch } = require('@zero-server/sdk');
|
|
539
|
+
* const tracer = new Tracer({ serviceName: 'my-api' });
|
|
540
|
+
* const tracedFetch = instrumentFetch(fetch, tracer);
|
|
541
|
+
*
|
|
542
|
+
* const res = await tracedFetch('https://api.example.com/data');
|
|
543
|
+
*/
|
|
544
|
+
function instrumentFetch(fetchFn, tracer)
|
|
545
|
+
{
|
|
546
|
+
return function tracedFetch(url, opts = {})
|
|
547
|
+
{
|
|
548
|
+
if (!tracer) return fetchFn(url, opts);
|
|
549
|
+
|
|
550
|
+
const parsedUrl = typeof url === 'string' ? url : String(url);
|
|
551
|
+
let host = '';
|
|
552
|
+
try { host = new URL(parsedUrl).host; } catch (_) { /* ignore */ }
|
|
553
|
+
|
|
554
|
+
const span = tracer.startSpan(`HTTP ${(opts.method || 'GET').toUpperCase()} ${host}`, {
|
|
555
|
+
kind: 'client',
|
|
556
|
+
attributes: {
|
|
557
|
+
'http.method': (opts.method || 'GET').toUpperCase(),
|
|
558
|
+
'http.url': parsedUrl,
|
|
559
|
+
'net.peer.name': host,
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Inject traceparent
|
|
564
|
+
const headers = Object.assign({}, opts.headers || {});
|
|
565
|
+
headers.traceparent = span.traceparent;
|
|
566
|
+
|
|
567
|
+
return fetchFn(url, { ...opts, headers })
|
|
568
|
+
.then(res =>
|
|
569
|
+
{
|
|
570
|
+
span.setAttribute('http.status_code', res.status);
|
|
571
|
+
if (res.status >= 400) span.setError(`HTTP ${res.status}`);
|
|
572
|
+
else span.setOk();
|
|
573
|
+
span.end();
|
|
574
|
+
return res;
|
|
575
|
+
})
|
|
576
|
+
.catch(err =>
|
|
577
|
+
{
|
|
578
|
+
span.recordException(err);
|
|
579
|
+
span.end();
|
|
580
|
+
throw err;
|
|
581
|
+
});
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
module.exports = {
|
|
586
|
+
Span,
|
|
587
|
+
Tracer,
|
|
588
|
+
parseTraceparent,
|
|
589
|
+
formatTraceparent,
|
|
590
|
+
tracingMiddleware,
|
|
591
|
+
instrumentFetch,
|
|
592
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zero-server/observe",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Metrics, structured logging, distributed tracing, health checks.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"zero-server",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./package.json": "./package.json"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
|
+
"lib",
|
|
23
24
|
"index.js",
|
|
24
25
|
"index.d.ts",
|
|
25
26
|
"README.md",
|
|
@@ -42,7 +43,12 @@
|
|
|
42
43
|
"access": "public"
|
|
43
44
|
},
|
|
44
45
|
"sideEffects": false,
|
|
45
|
-
"
|
|
46
|
-
"@zero-server/sdk": "0.9.
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@zero-server/sdk": ">=0.9.2"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"@zero-server/sdk": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
47
53
|
}
|
|
48
54
|
}
|