@elysia/opentelemetry 1.4.11 → 1.4.12

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,3 +1,28 @@
1
- # @elysiajs/opentelemetry
1
+ # @elysia/opentelemetry
2
2
 
3
- Please read about this plugin in [our documentation](https://elysiajs.com/plugins/opentelemetry.html).
3
+ ## Installation
4
+ ```bash
5
+ bun install @elysia/opentelemetry
6
+ ```
7
+
8
+ ## Example
9
+ ```typescript twoslash
10
+ import { Elysia } from 'elysia'
11
+ import { opentelemetry } from '@elysia/opentelemetry'
12
+
13
+ import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'
14
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
15
+
16
+ new Elysia()
17
+ .use(
18
+ opentelemetry({
19
+ spanProcessors: [
20
+ new BatchSpanProcessor(
21
+ new OTLPTraceExporter()
22
+ )
23
+ ]
24
+ })
25
+ )
26
+ ```
27
+
28
+ See [documentation](https://elysiajs.com/plugins/opentelemetry.html) for more details.
package/bunfig.toml ADDED
@@ -0,0 +1,2 @@
1
+ [install]
2
+ registry = "https://registry.npmjs.org/"
@@ -17,6 +17,34 @@ export interface ElysiaOpenTelemetryOptions extends OpenTeleMetryOptions {
17
17
  * @returns A boolean indicating whether tracing should be enabled for this request.
18
18
  */
19
19
  checkIfShouldTrace?: (req: Request) => boolean;
20
+ /**
21
+ * Redact `userinfo` and sensitive query values in `url.full` / `url.query`.
22
+ * Omitted: default redaction. `false`: record raw URLs (may leak secrets in query or credentials).
23
+ */
24
+ spanUrlRedaction?: false | {
25
+ stripCredentials?: boolean;
26
+ sensitiveQueryParams?: string[];
27
+ };
28
+ /**
29
+ * Record full request/response body content on spans.
30
+ * `true`: record both request and response bodies.
31
+ * `{ request: true }` or `{ response: true }`: record only one side.
32
+ * Default: `false` (no body content recorded).
33
+ */
34
+ recordBody?: boolean | {
35
+ request?: boolean;
36
+ response?: boolean;
37
+ };
38
+ /**
39
+ * HTTP header names (case-insensitive) to capture as span attributes.
40
+ * Use `"*"` in either list to capture all headers (useful for dev/debugging; may include sensitive values).
41
+ * Including `"cookie"` in `requestHeaders` also emits `http.request.cookie` when `context.cookie` exists.
42
+ * Default: none (no headers recorded).
43
+ */
44
+ headersToSpanAttributes?: {
45
+ request?: string[];
46
+ response?: string[];
47
+ };
20
48
  }
21
49
  export type ActiveSpanArgs<F extends (span: Span) => unknown = (span: Span) => unknown> = [name: string, fn: F] | [name: string, options: SpanOptions, fn: F] | [name: string, options: SpanOptions, context: Context, fn: F];
22
50
  export declare const shouldStartNodeSDK: (provider: TracerProvider) => boolean;
@@ -39,7 +67,7 @@ export declare const getCurrentSpan: () => Span | undefined;
39
67
  * @returns boolean - whether the attributes are set or not
40
68
  */
41
69
  export declare const setAttributes: (attributes: Attributes) => boolean;
42
- export declare const opentelemetry: ({ serviceName, instrumentations, contextManager, checkIfShouldTrace, ...options }?: ElysiaOpenTelemetryOptions) => Elysia<"", {
70
+ export declare const opentelemetry: ({ serviceName, instrumentations, contextManager, checkIfShouldTrace, spanUrlRedaction, recordBody, headersToSpanAttributes, ...options }?: ElysiaOpenTelemetryOptions) => Elysia<"", {
43
71
  decorator: {};
44
72
  store: {};
45
73
  derive: {};
package/dist/cjs/index.js CHANGED
@@ -35,6 +35,26 @@ var import_elysia = require("elysia");
35
35
  var import_api = require("@opentelemetry/api");
36
36
  var import_sdk_node = require("@opentelemetry/sdk-node");
37
37
  var headerHasToJSON = typeof new Headers().toJSON === "function";
38
+ var toHeaderNameSet = (names) => new Set((names ?? []).map((name) => name.toLowerCase()));
39
+ var SENSITIVE_QUERY_KEYS = /* @__PURE__ */ new Set([
40
+ "token",
41
+ "access_token",
42
+ "refresh_token",
43
+ "id_token",
44
+ "password",
45
+ "passwd",
46
+ "pwd",
47
+ "secret",
48
+ "client_secret",
49
+ "api_key",
50
+ "apikey",
51
+ "api-key",
52
+ "authorization",
53
+ "credential",
54
+ "credentials",
55
+ "code",
56
+ "nonce"
57
+ ]);
38
58
  var parseNumericString = (message) => {
39
59
  if (message.length < 16) {
40
60
  if (message.length === 0) return null;
@@ -95,6 +115,40 @@ var createContext = (parent) => ({
95
115
  return import_api.context.active();
96
116
  }
97
117
  });
118
+ var serializeBody = (body) => {
119
+ if (body instanceof Uint8Array) return { text: "", size: body.length };
120
+ if (body instanceof ArrayBuffer) return { text: "", size: body.byteLength };
121
+ if (body instanceof Blob) return { text: "", size: body.size };
122
+ let text;
123
+ try {
124
+ text = typeof body === "object" ? JSON.stringify(body) : String(body);
125
+ } catch {
126
+ text = "[Unserializable]";
127
+ }
128
+ return { text, size: text.length };
129
+ };
130
+ var redactQueryString = (query, keys) => {
131
+ if (query === "" || keys.size === 0) return query;
132
+ let out = "";
133
+ let partStart = 0;
134
+ let keyEnd = -1;
135
+ for (let i = 0; i <= query.length; i++) {
136
+ const ch = i === query.length ? 38 : query.charCodeAt(i);
137
+ if (ch === 61 && keyEnd === -1) {
138
+ keyEnd = i;
139
+ continue;
140
+ }
141
+ if (ch !== 38) continue;
142
+ const partEnd = i;
143
+ const rawKeyEnd = keyEnd === -1 ? partEnd : keyEnd;
144
+ const rawKey = query.slice(partStart, rawKeyEnd);
145
+ if (out) out += "&";
146
+ out += keys.has(rawKey.toLowerCase()) ? rawKey + "=[REDACTED]" : query.slice(partStart, partEnd);
147
+ partStart = i + 1;
148
+ keyEnd = -1;
149
+ }
150
+ return out;
151
+ };
98
152
  var shouldStartNodeSDK = (provider) => {
99
153
  return provider instanceof import_api.ProxyTracerProvider && provider.getDelegateTracer("check") === void 0;
100
154
  };
@@ -165,8 +219,29 @@ var opentelemetry = ({
165
219
  instrumentations,
166
220
  contextManager,
167
221
  checkIfShouldTrace,
222
+ spanUrlRedaction,
223
+ recordBody,
224
+ headersToSpanAttributes,
168
225
  ...options
169
226
  } = {}) => {
227
+ const spanRequestHeaderSet = toHeaderNameSet(
228
+ headersToSpanAttributes?.request
229
+ );
230
+ const spanResponseHeaderSet = toHeaderNameSet(
231
+ headersToSpanAttributes?.response
232
+ );
233
+ const requestHeaderWildcard = spanRequestHeaderSet.has("*");
234
+ const responseHeaderWildcard = spanResponseHeaderSet.has("*");
235
+ const recordRequestBody = recordBody === true || recordBody && recordBody.request || false;
236
+ const recordResponseBody = recordBody === true || recordBody && recordBody.response || false;
237
+ const urlRedactOpts = spanUrlRedaction === false ? null : spanUrlRedaction ?? {};
238
+ const sensitiveKeys = urlRedactOpts ? /* @__PURE__ */ new Set([
239
+ ...SENSITIVE_QUERY_KEYS,
240
+ ...(urlRedactOpts.sensitiveQueryParams ?? []).map(
241
+ (k) => k.toLowerCase()
242
+ )
243
+ ]) : void 0;
244
+ const stripCreds = urlRedactOpts?.stripCredentials !== false;
170
245
  let tracer = import_api.trace.getTracer(serviceName);
171
246
  if (shouldStartNodeSDK(import_api.trace.getTracerProvider())) {
172
247
  const sdk = new import_sdk_node.NodeSDK({
@@ -184,6 +259,39 @@ var opentelemetry = ({
184
259
  import_api.context.setGlobalContextManager(contextManager);
185
260
  } catch {
186
261
  }
262
+ const meter = import_api.metrics.getMeter(serviceName);
263
+ const httpServerDuration = meter.createHistogram(
264
+ "http.server.request.duration",
265
+ {
266
+ description: "Duration of HTTP server requests.",
267
+ unit: "s",
268
+ advice: {
269
+ explicitBucketBoundaries: [
270
+ 5e-3,
271
+ 0.01,
272
+ 0.025,
273
+ 0.05,
274
+ 0.075,
275
+ 0.1,
276
+ 0.25,
277
+ 0.5,
278
+ 0.75,
279
+ 1,
280
+ 2.5,
281
+ 5,
282
+ 7.5,
283
+ 10,
284
+ 30,
285
+ 60,
286
+ 120,
287
+ 300,
288
+ 600,
289
+ 900,
290
+ 1800
291
+ ]
292
+ }
293
+ }
294
+ );
187
295
  return new import_elysia.Elysia({
188
296
  name: "@elysia/opentelemetry"
189
297
  }).wrap((fn, request) => {
@@ -274,27 +382,13 @@ var opentelemetry = ({
274
382
  }
275
383
  onStop2(({ error }) => {
276
384
  setParent(rootSpan);
277
- if (span.ended || rootSpan.ended) return;
385
+ if (span.ended || rootSpan.ended)
386
+ return;
278
387
  if (error) {
279
- rootSpan.setStatus({
280
- code: import_api.SpanStatusCode.ERROR,
281
- message: error.message
282
- });
283
388
  span.setAttributes({
284
389
  "error.type": error.constructor?.name ?? error.name,
285
390
  "error.stack": error.stack
286
391
  });
287
- span.setStatus({
288
- code: import_api.SpanStatusCode.ERROR,
289
- message: error.message
290
- });
291
- } else {
292
- rootSpan.setStatus({
293
- code: import_api.SpanStatusCode.OK
294
- });
295
- span.setStatus({
296
- code: import_api.SpanStatusCode.OK
297
- });
298
392
  }
299
393
  if (useChildSpan) span.end();
300
394
  });
@@ -308,22 +402,59 @@ var opentelemetry = ({
308
402
  );
309
403
  };
310
404
  }
311
- const url = context.url;
405
+ const rawUrl = context.url;
406
+ const qi = context.qi;
407
+ const hasQuery = qi !== void 0 && qi !== -1;
408
+ let urlQuery = hasQuery ? rawUrl.slice(qi + 1) : void 0;
409
+ let urlFull = rawUrl;
410
+ if (urlRedactOpts) {
411
+ if (urlQuery !== void 0) {
412
+ urlQuery = redactQueryString(urlQuery, sensitiveKeys);
413
+ urlFull = `${rawUrl.slice(0, qi)}?${urlQuery}`;
414
+ }
415
+ if (stripCreds && urlFull.indexOf("@") > 0) {
416
+ try {
417
+ const u = new URL(urlFull);
418
+ if (u.username || u.password) {
419
+ u.username = "";
420
+ u.password = "";
421
+ urlFull = u.href;
422
+ }
423
+ } catch {
424
+ }
425
+ }
426
+ }
312
427
  const attributes = Object.assign(/* @__PURE__ */ Object.create(null), {
313
428
  // ? Elysia Custom attribute
314
429
  "http.request.id": id,
315
430
  "http.request.method": method,
316
431
  "url.path": path,
317
- "url.full": url
432
+ "url.full": urlFull
318
433
  });
319
- if (context.qi && context.qi !== -1)
320
- attributes["url.query"] = url.slice(
321
- // @ts-ignore private property
322
- context.qi + 1
323
- );
324
- const protocolSeparator = url.indexOf("://");
434
+ if (urlQuery !== void 0) attributes["url.query"] = urlQuery;
435
+ const protocolSeparator = urlFull.indexOf("://");
325
436
  if (protocolSeparator > 0)
326
- attributes["url.scheme"] = url.slice(0, protocolSeparator);
437
+ attributes["url.scheme"] = urlFull.slice(
438
+ 0,
439
+ protocolSeparator
440
+ );
441
+ const requestStartTime = performance.now();
442
+ let durationRecorded = false;
443
+ const recordDuration = () => {
444
+ if (durationRecorded) return;
445
+ durationRecorded = true;
446
+ const durationS = (performance.now() - requestStartTime) / 1e3;
447
+ const statusCode = attributes["http.response.status_code"];
448
+ const metricAttributes = {
449
+ "http.request.method": attributes["http.request.method"] ?? method,
450
+ "url.scheme": attributes["url.scheme"],
451
+ "http.response.status_code": statusCode,
452
+ "http.route": attributes["http.route"]
453
+ };
454
+ if (typeof statusCode === "number" && statusCode >= 500)
455
+ metricAttributes["error.type"] = String(statusCode);
456
+ httpServerDuration.record(durationS, metricAttributes);
457
+ };
327
458
  onRequest(inspect("Request"));
328
459
  onParse(inspect("Parse"));
329
460
  onTransform(inspect("Transform"));
@@ -339,23 +470,8 @@ var opentelemetry = ({
339
470
  setParent(rootSpan);
340
471
  if (span.ended || rootSpan.ended) return;
341
472
  if (error) {
342
- rootSpan.setStatus({
343
- code: import_api.SpanStatusCode.ERROR,
344
- message: error.message
345
- });
346
- span.setStatus({
347
- code: import_api.SpanStatusCode.ERROR,
348
- message: error.message
349
- });
350
473
  span.recordException(error);
351
474
  rootSpan.recordException(error);
352
- } else {
353
- rootSpan.setStatus({
354
- code: import_api.SpanStatusCode.OK
355
- });
356
- span.setStatus({
357
- code: import_api.SpanStatusCode.OK
358
- });
359
475
  }
360
476
  span.end();
361
477
  });
@@ -385,8 +501,10 @@ var opentelemetry = ({
385
501
  if (
386
502
  // @ts-ignore
387
503
  !rootSpan.ended
388
- )
504
+ ) {
505
+ recordDuration();
389
506
  rootSpan.end();
507
+ }
390
508
  });
391
509
  });
392
510
  onMapResponse(inspect("MapResponse"));
@@ -398,24 +516,18 @@ var opentelemetry = ({
398
516
  `${method} ${route || path2}`
399
517
  );
400
518
  if (context.route) attributes["http.route"] = context.route;
401
- {
402
- let contentLength = request.headers.get("content-length");
403
- if (contentLength) {
404
- const number = parseNumericString(contentLength);
405
- if (number)
406
- attributes["http.request_content_length"] = number;
407
- }
408
- }
409
- {
410
- const userAgent = request.headers.get("User-Agent");
411
- if (userAgent)
412
- attributes["user_agent.original"] = userAgent;
519
+ const contentLength = request.headers.get("content-length");
520
+ if (contentLength) {
521
+ const number = parseNumericString(contentLength);
522
+ if (number !== null)
523
+ attributes["http.request_content_length"] = number;
413
524
  }
525
+ const userAgent = request.headers.get("User-Agent");
526
+ if (userAgent) attributes["user_agent.original"] = userAgent;
414
527
  const server = context.server;
415
528
  if (server) {
416
529
  attributes["server.port"] = server.port ?? 80;
417
530
  attributes["server.address"] = server.url.hostname;
418
- attributes["server.address"] = server.url.hostname;
419
531
  }
420
532
  let headers;
421
533
  {
@@ -435,21 +547,23 @@ var opentelemetry = ({
435
547
  for (let [key, value] of _headers) {
436
548
  key = key.toLowerCase();
437
549
  if (hasHeaders) {
438
- if (key === "user-agent") continue;
550
+ if (!requestHeaderWildcard && !spanRequestHeaderSet.has(key))
551
+ continue;
439
552
  if (typeof value === "object")
440
553
  attributes[`http.request.header.${key}`] = JSON.stringify(value);
441
554
  else if (value !== void 0)
442
555
  attributes[`http.request.header.${key}`] = value;
443
556
  continue;
444
557
  }
445
- if (typeof value === "object")
446
- headers[key] = attributes[`http.request.header.${key}`] = JSON.stringify(value);
447
- else if (value !== void 0) {
448
- if (key === "user-agent") {
449
- headers[key] = value;
450
- continue;
451
- }
452
- headers[key] = attributes[`http.request.header.${key}`] = value;
558
+ if (typeof value === "object") {
559
+ const serialized = JSON.stringify(value);
560
+ headers[key] = serialized;
561
+ if (requestHeaderWildcard || spanRequestHeaderSet.has(key))
562
+ attributes[`http.request.header.${key}`] = serialized;
563
+ } else if (value !== void 0) {
564
+ headers[key] = value;
565
+ if (requestHeaderWildcard || spanRequestHeaderSet.has(key))
566
+ attributes[`http.request.header.${key}`] = value;
453
567
  }
454
568
  }
455
569
  }
@@ -465,6 +579,8 @@ var opentelemetry = ({
465
579
  } else headers2 = Object.entries(context.set.headers);
466
580
  for (let [key, value] of headers2) {
467
581
  key = key.toLowerCase();
582
+ if (!responseHeaderWildcard && !spanResponseHeaderSet.has(key))
583
+ continue;
468
584
  if (typeof value === "object")
469
585
  attributes[`http.response.header.${key}`] = JSON.stringify(value);
470
586
  else
@@ -478,7 +594,7 @@ var opentelemetry = ({
478
594
  if (ip)
479
595
  attributes["client.address"] = typeof ip === "string" ? ip : ip.address ?? ip.toString();
480
596
  }
481
- if (cookie) {
597
+ if ((requestHeaderWildcard || spanRequestHeaderSet.has("cookie")) && cookie) {
482
598
  const _cookie = {};
483
599
  for (const [key, { value }] of Object.entries(cookie))
484
600
  _cookie[key] = JSON.stringify(value);
@@ -488,38 +604,18 @@ var opentelemetry = ({
488
604
  });
489
605
  onParse(() => {
490
606
  const body = context.body;
491
- if (body !== void 0 && body !== null) {
492
- const value = typeof body === "object" ? JSON.stringify(body) : body.toString();
493
- attributes["http.request.body"] = value;
494
- if (typeof body === "object") {
495
- if (body instanceof Uint8Array)
496
- attributes["http.request.body.size"] = body.length;
497
- else if (body instanceof ArrayBuffer)
498
- attributes["http.request.body.size"] = body.byteLength;
499
- else if (body instanceof Blob)
500
- attributes["http.request.body.size"] = body.size;
501
- attributes["http.request.body.size"] = value.length;
502
- } else {
503
- attributes["http.request.body.size"] = value.length;
504
- }
505
- }
607
+ if (body === void 0 || body === null || !recordRequestBody)
608
+ return;
609
+ const { text, size } = serializeBody(body);
610
+ if (text) attributes["http.request.body"] = text;
611
+ attributes["http.request.body.size"] = size;
506
612
  });
507
613
  onMapResponse(() => {
508
614
  const body = context.body;
509
- if (body !== void 0 && body !== null) {
510
- const value = typeof body === "object" ? JSON.stringify(body) : body.toString();
511
- attributes["http.request.body"] = value;
512
- if (typeof body === "object") {
513
- if (body instanceof Uint8Array)
514
- attributes["http.request.body.size"] = body.length;
515
- else if (body instanceof ArrayBuffer)
516
- attributes["http.request.body.size"] = body.byteLength;
517
- else if (body instanceof Blob)
518
- attributes["http.request.body.size"] = body.size;
519
- attributes["http.request.body.size"] = value.length;
520
- } else {
521
- attributes["http.request.body.size"] = value.length;
522
- }
615
+ if (body !== void 0 && body !== null && recordRequestBody) {
616
+ const { text, size } = serializeBody(body);
617
+ if (text) attributes["http.request.body"] = text;
618
+ attributes["http.request.body.size"] = size;
523
619
  }
524
620
  {
525
621
  let status = context.set.status ?? 200;
@@ -528,31 +624,11 @@ var opentelemetry = ({
528
624
  attributes["http.response.status_code"] = status;
529
625
  }
530
626
  const response = context.responseValue;
531
- if (response !== void 0)
532
- switch (typeof response) {
533
- case "object":
534
- if (response instanceof Response) {
535
- } else if (response instanceof Uint8Array)
536
- attributes["http.response.body.size"] = response.length;
537
- else if (response instanceof ArrayBuffer)
538
- attributes["http.response.body.size"] = response.byteLength;
539
- else if (response instanceof Blob)
540
- attributes["http.response.body.size"] = response.size;
541
- else {
542
- const value = JSON.stringify(response);
543
- attributes["http.response.body"] = value;
544
- attributes["http.response.body.size"] = value.length;
545
- }
546
- break;
547
- default:
548
- if (response === void 0 || response === null)
549
- attributes["http.response.body.size"] = 0;
550
- else {
551
- const value = response.toString();
552
- attributes["http.response.body"] = value;
553
- attributes["http.response.body.size"] = value.length;
554
- }
555
- }
627
+ if (response !== void 0 && recordResponseBody) {
628
+ const { text, size } = serializeBody(response);
629
+ if (text) attributes["http.response.body"] = text;
630
+ attributes["http.response.body.size"] = size;
631
+ }
556
632
  if (!rootSpan.ended) {
557
633
  const statusCode = attributes["http.response.status_code"];
558
634
  if (typeof statusCode === "number" && statusCode >= 500) {
@@ -572,20 +648,10 @@ var opentelemetry = ({
572
648
  attributes["http.response.status_code"] = status;
573
649
  }
574
650
  const body = context.body;
575
- if (body !== void 0 && body !== null) {
576
- const value = typeof body === "object" ? JSON.stringify(body) : body.toString();
577
- attributes["http.request.body"] = value;
578
- if (typeof body === "object") {
579
- if (body instanceof Uint8Array)
580
- attributes["http.request.body.size"] = body.length;
581
- else if (body instanceof ArrayBuffer)
582
- attributes["http.request.body.size"] = body.byteLength;
583
- else if (body instanceof Blob)
584
- attributes["http.request.body.size"] = body.size;
585
- attributes["http.request.body.size"] = value.length;
586
- } else {
587
- attributes["http.request.body.size"] = value.length;
588
- }
651
+ if (body !== void 0 && body !== null && recordRequestBody) {
652
+ const { text, size } = serializeBody(body);
653
+ if (text) attributes["http.request.body"] = text;
654
+ attributes["http.request.body.size"] = size;
589
655
  }
590
656
  if (!rootSpan.ended) {
591
657
  const statusCode = attributes["http.response.status_code"];
@@ -601,8 +667,10 @@ var opentelemetry = ({
601
667
  if (
602
668
  // @ts-ignore
603
669
  !rootSpan.ended
604
- )
670
+ ) {
671
+ recordDuration();
605
672
  rootSpan.end();
673
+ }
606
674
  });
607
675
  });
608
676
  context.request.signal.addEventListener("abort", () => {
@@ -613,6 +681,7 @@ var opentelemetry = ({
613
681
  code: import_api.SpanStatusCode.ERROR,
614
682
  message: "Request aborted"
615
683
  });
684
+ recordDuration();
616
685
  rootSpan.end();
617
686
  });
618
687
  }
package/dist/index.d.ts CHANGED
@@ -17,6 +17,34 @@ export interface ElysiaOpenTelemetryOptions extends OpenTeleMetryOptions {
17
17
  * @returns A boolean indicating whether tracing should be enabled for this request.
18
18
  */
19
19
  checkIfShouldTrace?: (req: Request) => boolean;
20
+ /**
21
+ * Redact `userinfo` and sensitive query values in `url.full` / `url.query`.
22
+ * Omitted: default redaction. `false`: record raw URLs (may leak secrets in query or credentials).
23
+ */
24
+ spanUrlRedaction?: false | {
25
+ stripCredentials?: boolean;
26
+ sensitiveQueryParams?: string[];
27
+ };
28
+ /**
29
+ * Record full request/response body content on spans.
30
+ * `true`: record both request and response bodies.
31
+ * `{ request: true }` or `{ response: true }`: record only one side.
32
+ * Default: `false` (no body content recorded).
33
+ */
34
+ recordBody?: boolean | {
35
+ request?: boolean;
36
+ response?: boolean;
37
+ };
38
+ /**
39
+ * HTTP header names (case-insensitive) to capture as span attributes.
40
+ * Use `"*"` in either list to capture all headers (useful for dev/debugging; may include sensitive values).
41
+ * Including `"cookie"` in `requestHeaders` also emits `http.request.cookie` when `context.cookie` exists.
42
+ * Default: none (no headers recorded).
43
+ */
44
+ headersToSpanAttributes?: {
45
+ request?: string[];
46
+ response?: string[];
47
+ };
20
48
  }
21
49
  export type ActiveSpanArgs<F extends (span: Span) => unknown = (span: Span) => unknown> = [name: string, fn: F] | [name: string, options: SpanOptions, fn: F] | [name: string, options: SpanOptions, context: Context, fn: F];
22
50
  export declare const shouldStartNodeSDK: (provider: TracerProvider) => boolean;
@@ -39,7 +67,7 @@ export declare const getCurrentSpan: () => Span | undefined;
39
67
  * @returns boolean - whether the attributes are set or not
40
68
  */
41
69
  export declare const setAttributes: (attributes: Attributes) => boolean;
42
- export declare const opentelemetry: ({ serviceName, instrumentations, contextManager, checkIfShouldTrace, ...options }?: ElysiaOpenTelemetryOptions) => Elysia<"", {
70
+ export declare const opentelemetry: ({ serviceName, instrumentations, contextManager, checkIfShouldTrace, spanUrlRedaction, recordBody, headersToSpanAttributes, ...options }?: ElysiaOpenTelemetryOptions) => Elysia<"", {
43
71
  decorator: {};
44
72
  store: {};
45
73
  derive: {};
package/dist/index.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { Elysia, StatusMap } from "elysia";
3
3
  import {
4
4
  trace,
5
+ metrics,
5
6
  context as otelContext,
6
7
  propagation,
7
8
  SpanStatusCode,
@@ -10,6 +11,26 @@ import {
10
11
  } from "@opentelemetry/api";
11
12
  import { NodeSDK } from "@opentelemetry/sdk-node";
12
13
  var headerHasToJSON = typeof new Headers().toJSON === "function";
14
+ var toHeaderNameSet = (names) => new Set((names ?? []).map((name) => name.toLowerCase()));
15
+ var SENSITIVE_QUERY_KEYS = /* @__PURE__ */ new Set([
16
+ "token",
17
+ "access_token",
18
+ "refresh_token",
19
+ "id_token",
20
+ "password",
21
+ "passwd",
22
+ "pwd",
23
+ "secret",
24
+ "client_secret",
25
+ "api_key",
26
+ "apikey",
27
+ "api-key",
28
+ "authorization",
29
+ "credential",
30
+ "credentials",
31
+ "code",
32
+ "nonce"
33
+ ]);
13
34
  var parseNumericString = (message) => {
14
35
  if (message.length < 16) {
15
36
  if (message.length === 0) return null;
@@ -70,6 +91,40 @@ var createContext = (parent) => ({
70
91
  return otelContext.active();
71
92
  }
72
93
  });
94
+ var serializeBody = (body) => {
95
+ if (body instanceof Uint8Array) return { text: "", size: body.length };
96
+ if (body instanceof ArrayBuffer) return { text: "", size: body.byteLength };
97
+ if (body instanceof Blob) return { text: "", size: body.size };
98
+ let text;
99
+ try {
100
+ text = typeof body === "object" ? JSON.stringify(body) : String(body);
101
+ } catch {
102
+ text = "[Unserializable]";
103
+ }
104
+ return { text, size: text.length };
105
+ };
106
+ var redactQueryString = (query, keys) => {
107
+ if (query === "" || keys.size === 0) return query;
108
+ let out = "";
109
+ let partStart = 0;
110
+ let keyEnd = -1;
111
+ for (let i = 0; i <= query.length; i++) {
112
+ const ch = i === query.length ? 38 : query.charCodeAt(i);
113
+ if (ch === 61 && keyEnd === -1) {
114
+ keyEnd = i;
115
+ continue;
116
+ }
117
+ if (ch !== 38) continue;
118
+ const partEnd = i;
119
+ const rawKeyEnd = keyEnd === -1 ? partEnd : keyEnd;
120
+ const rawKey = query.slice(partStart, rawKeyEnd);
121
+ if (out) out += "&";
122
+ out += keys.has(rawKey.toLowerCase()) ? rawKey + "=[REDACTED]" : query.slice(partStart, partEnd);
123
+ partStart = i + 1;
124
+ keyEnd = -1;
125
+ }
126
+ return out;
127
+ };
73
128
  var shouldStartNodeSDK = (provider) => {
74
129
  return provider instanceof ProxyTracerProvider && provider.getDelegateTracer("check") === void 0;
75
130
  };
@@ -140,8 +195,29 @@ var opentelemetry = ({
140
195
  instrumentations,
141
196
  contextManager,
142
197
  checkIfShouldTrace,
198
+ spanUrlRedaction,
199
+ recordBody,
200
+ headersToSpanAttributes,
143
201
  ...options
144
202
  } = {}) => {
203
+ const spanRequestHeaderSet = toHeaderNameSet(
204
+ headersToSpanAttributes?.request
205
+ );
206
+ const spanResponseHeaderSet = toHeaderNameSet(
207
+ headersToSpanAttributes?.response
208
+ );
209
+ const requestHeaderWildcard = spanRequestHeaderSet.has("*");
210
+ const responseHeaderWildcard = spanResponseHeaderSet.has("*");
211
+ const recordRequestBody = recordBody === true || recordBody && recordBody.request || false;
212
+ const recordResponseBody = recordBody === true || recordBody && recordBody.response || false;
213
+ const urlRedactOpts = spanUrlRedaction === false ? null : spanUrlRedaction ?? {};
214
+ const sensitiveKeys = urlRedactOpts ? /* @__PURE__ */ new Set([
215
+ ...SENSITIVE_QUERY_KEYS,
216
+ ...(urlRedactOpts.sensitiveQueryParams ?? []).map(
217
+ (k) => k.toLowerCase()
218
+ )
219
+ ]) : void 0;
220
+ const stripCreds = urlRedactOpts?.stripCredentials !== false;
145
221
  let tracer = trace.getTracer(serviceName);
146
222
  if (shouldStartNodeSDK(trace.getTracerProvider())) {
147
223
  const sdk = new NodeSDK({
@@ -159,6 +235,39 @@ var opentelemetry = ({
159
235
  otelContext.setGlobalContextManager(contextManager);
160
236
  } catch {
161
237
  }
238
+ const meter = metrics.getMeter(serviceName);
239
+ const httpServerDuration = meter.createHistogram(
240
+ "http.server.request.duration",
241
+ {
242
+ description: "Duration of HTTP server requests.",
243
+ unit: "s",
244
+ advice: {
245
+ explicitBucketBoundaries: [
246
+ 5e-3,
247
+ 0.01,
248
+ 0.025,
249
+ 0.05,
250
+ 0.075,
251
+ 0.1,
252
+ 0.25,
253
+ 0.5,
254
+ 0.75,
255
+ 1,
256
+ 2.5,
257
+ 5,
258
+ 7.5,
259
+ 10,
260
+ 30,
261
+ 60,
262
+ 120,
263
+ 300,
264
+ 600,
265
+ 900,
266
+ 1800
267
+ ]
268
+ }
269
+ }
270
+ );
162
271
  return new Elysia({
163
272
  name: "@elysia/opentelemetry"
164
273
  }).wrap((fn, request) => {
@@ -249,27 +358,13 @@ var opentelemetry = ({
249
358
  }
250
359
  onStop2(({ error }) => {
251
360
  setParent(rootSpan);
252
- if (span.ended || rootSpan.ended) return;
361
+ if (span.ended || rootSpan.ended)
362
+ return;
253
363
  if (error) {
254
- rootSpan.setStatus({
255
- code: SpanStatusCode.ERROR,
256
- message: error.message
257
- });
258
364
  span.setAttributes({
259
365
  "error.type": error.constructor?.name ?? error.name,
260
366
  "error.stack": error.stack
261
367
  });
262
- span.setStatus({
263
- code: SpanStatusCode.ERROR,
264
- message: error.message
265
- });
266
- } else {
267
- rootSpan.setStatus({
268
- code: SpanStatusCode.OK
269
- });
270
- span.setStatus({
271
- code: SpanStatusCode.OK
272
- });
273
368
  }
274
369
  if (useChildSpan) span.end();
275
370
  });
@@ -283,22 +378,59 @@ var opentelemetry = ({
283
378
  );
284
379
  };
285
380
  }
286
- const url = context.url;
381
+ const rawUrl = context.url;
382
+ const qi = context.qi;
383
+ const hasQuery = qi !== void 0 && qi !== -1;
384
+ let urlQuery = hasQuery ? rawUrl.slice(qi + 1) : void 0;
385
+ let urlFull = rawUrl;
386
+ if (urlRedactOpts) {
387
+ if (urlQuery !== void 0) {
388
+ urlQuery = redactQueryString(urlQuery, sensitiveKeys);
389
+ urlFull = `${rawUrl.slice(0, qi)}?${urlQuery}`;
390
+ }
391
+ if (stripCreds && urlFull.indexOf("@") > 0) {
392
+ try {
393
+ const u = new URL(urlFull);
394
+ if (u.username || u.password) {
395
+ u.username = "";
396
+ u.password = "";
397
+ urlFull = u.href;
398
+ }
399
+ } catch {
400
+ }
401
+ }
402
+ }
287
403
  const attributes = Object.assign(/* @__PURE__ */ Object.create(null), {
288
404
  // ? Elysia Custom attribute
289
405
  "http.request.id": id,
290
406
  "http.request.method": method,
291
407
  "url.path": path,
292
- "url.full": url
408
+ "url.full": urlFull
293
409
  });
294
- if (context.qi && context.qi !== -1)
295
- attributes["url.query"] = url.slice(
296
- // @ts-ignore private property
297
- context.qi + 1
298
- );
299
- const protocolSeparator = url.indexOf("://");
410
+ if (urlQuery !== void 0) attributes["url.query"] = urlQuery;
411
+ const protocolSeparator = urlFull.indexOf("://");
300
412
  if (protocolSeparator > 0)
301
- attributes["url.scheme"] = url.slice(0, protocolSeparator);
413
+ attributes["url.scheme"] = urlFull.slice(
414
+ 0,
415
+ protocolSeparator
416
+ );
417
+ const requestStartTime = performance.now();
418
+ let durationRecorded = false;
419
+ const recordDuration = () => {
420
+ if (durationRecorded) return;
421
+ durationRecorded = true;
422
+ const durationS = (performance.now() - requestStartTime) / 1e3;
423
+ const statusCode = attributes["http.response.status_code"];
424
+ const metricAttributes = {
425
+ "http.request.method": attributes["http.request.method"] ?? method,
426
+ "url.scheme": attributes["url.scheme"],
427
+ "http.response.status_code": statusCode,
428
+ "http.route": attributes["http.route"]
429
+ };
430
+ if (typeof statusCode === "number" && statusCode >= 500)
431
+ metricAttributes["error.type"] = String(statusCode);
432
+ httpServerDuration.record(durationS, metricAttributes);
433
+ };
302
434
  onRequest(inspect("Request"));
303
435
  onParse(inspect("Parse"));
304
436
  onTransform(inspect("Transform"));
@@ -314,23 +446,8 @@ var opentelemetry = ({
314
446
  setParent(rootSpan);
315
447
  if (span.ended || rootSpan.ended) return;
316
448
  if (error) {
317
- rootSpan.setStatus({
318
- code: SpanStatusCode.ERROR,
319
- message: error.message
320
- });
321
- span.setStatus({
322
- code: SpanStatusCode.ERROR,
323
- message: error.message
324
- });
325
449
  span.recordException(error);
326
450
  rootSpan.recordException(error);
327
- } else {
328
- rootSpan.setStatus({
329
- code: SpanStatusCode.OK
330
- });
331
- span.setStatus({
332
- code: SpanStatusCode.OK
333
- });
334
451
  }
335
452
  span.end();
336
453
  });
@@ -360,8 +477,10 @@ var opentelemetry = ({
360
477
  if (
361
478
  // @ts-ignore
362
479
  !rootSpan.ended
363
- )
480
+ ) {
481
+ recordDuration();
364
482
  rootSpan.end();
483
+ }
365
484
  });
366
485
  });
367
486
  onMapResponse(inspect("MapResponse"));
@@ -373,24 +492,18 @@ var opentelemetry = ({
373
492
  `${method} ${route || path2}`
374
493
  );
375
494
  if (context.route) attributes["http.route"] = context.route;
376
- {
377
- let contentLength = request.headers.get("content-length");
378
- if (contentLength) {
379
- const number = parseNumericString(contentLength);
380
- if (number)
381
- attributes["http.request_content_length"] = number;
382
- }
383
- }
384
- {
385
- const userAgent = request.headers.get("User-Agent");
386
- if (userAgent)
387
- attributes["user_agent.original"] = userAgent;
495
+ const contentLength = request.headers.get("content-length");
496
+ if (contentLength) {
497
+ const number = parseNumericString(contentLength);
498
+ if (number !== null)
499
+ attributes["http.request_content_length"] = number;
388
500
  }
501
+ const userAgent = request.headers.get("User-Agent");
502
+ if (userAgent) attributes["user_agent.original"] = userAgent;
389
503
  const server = context.server;
390
504
  if (server) {
391
505
  attributes["server.port"] = server.port ?? 80;
392
506
  attributes["server.address"] = server.url.hostname;
393
- attributes["server.address"] = server.url.hostname;
394
507
  }
395
508
  let headers;
396
509
  {
@@ -410,21 +523,23 @@ var opentelemetry = ({
410
523
  for (let [key, value] of _headers) {
411
524
  key = key.toLowerCase();
412
525
  if (hasHeaders) {
413
- if (key === "user-agent") continue;
526
+ if (!requestHeaderWildcard && !spanRequestHeaderSet.has(key))
527
+ continue;
414
528
  if (typeof value === "object")
415
529
  attributes[`http.request.header.${key}`] = JSON.stringify(value);
416
530
  else if (value !== void 0)
417
531
  attributes[`http.request.header.${key}`] = value;
418
532
  continue;
419
533
  }
420
- if (typeof value === "object")
421
- headers[key] = attributes[`http.request.header.${key}`] = JSON.stringify(value);
422
- else if (value !== void 0) {
423
- if (key === "user-agent") {
424
- headers[key] = value;
425
- continue;
426
- }
427
- headers[key] = attributes[`http.request.header.${key}`] = value;
534
+ if (typeof value === "object") {
535
+ const serialized = JSON.stringify(value);
536
+ headers[key] = serialized;
537
+ if (requestHeaderWildcard || spanRequestHeaderSet.has(key))
538
+ attributes[`http.request.header.${key}`] = serialized;
539
+ } else if (value !== void 0) {
540
+ headers[key] = value;
541
+ if (requestHeaderWildcard || spanRequestHeaderSet.has(key))
542
+ attributes[`http.request.header.${key}`] = value;
428
543
  }
429
544
  }
430
545
  }
@@ -440,6 +555,8 @@ var opentelemetry = ({
440
555
  } else headers2 = Object.entries(context.set.headers);
441
556
  for (let [key, value] of headers2) {
442
557
  key = key.toLowerCase();
558
+ if (!responseHeaderWildcard && !spanResponseHeaderSet.has(key))
559
+ continue;
443
560
  if (typeof value === "object")
444
561
  attributes[`http.response.header.${key}`] = JSON.stringify(value);
445
562
  else
@@ -453,7 +570,7 @@ var opentelemetry = ({
453
570
  if (ip)
454
571
  attributes["client.address"] = typeof ip === "string" ? ip : ip.address ?? ip.toString();
455
572
  }
456
- if (cookie) {
573
+ if ((requestHeaderWildcard || spanRequestHeaderSet.has("cookie")) && cookie) {
457
574
  const _cookie = {};
458
575
  for (const [key, { value }] of Object.entries(cookie))
459
576
  _cookie[key] = JSON.stringify(value);
@@ -463,38 +580,18 @@ var opentelemetry = ({
463
580
  });
464
581
  onParse(() => {
465
582
  const body = context.body;
466
- if (body !== void 0 && body !== null) {
467
- const value = typeof body === "object" ? JSON.stringify(body) : body.toString();
468
- attributes["http.request.body"] = value;
469
- if (typeof body === "object") {
470
- if (body instanceof Uint8Array)
471
- attributes["http.request.body.size"] = body.length;
472
- else if (body instanceof ArrayBuffer)
473
- attributes["http.request.body.size"] = body.byteLength;
474
- else if (body instanceof Blob)
475
- attributes["http.request.body.size"] = body.size;
476
- attributes["http.request.body.size"] = value.length;
477
- } else {
478
- attributes["http.request.body.size"] = value.length;
479
- }
480
- }
583
+ if (body === void 0 || body === null || !recordRequestBody)
584
+ return;
585
+ const { text, size } = serializeBody(body);
586
+ if (text) attributes["http.request.body"] = text;
587
+ attributes["http.request.body.size"] = size;
481
588
  });
482
589
  onMapResponse(() => {
483
590
  const body = context.body;
484
- if (body !== void 0 && body !== null) {
485
- const value = typeof body === "object" ? JSON.stringify(body) : body.toString();
486
- attributes["http.request.body"] = value;
487
- if (typeof body === "object") {
488
- if (body instanceof Uint8Array)
489
- attributes["http.request.body.size"] = body.length;
490
- else if (body instanceof ArrayBuffer)
491
- attributes["http.request.body.size"] = body.byteLength;
492
- else if (body instanceof Blob)
493
- attributes["http.request.body.size"] = body.size;
494
- attributes["http.request.body.size"] = value.length;
495
- } else {
496
- attributes["http.request.body.size"] = value.length;
497
- }
591
+ if (body !== void 0 && body !== null && recordRequestBody) {
592
+ const { text, size } = serializeBody(body);
593
+ if (text) attributes["http.request.body"] = text;
594
+ attributes["http.request.body.size"] = size;
498
595
  }
499
596
  {
500
597
  let status = context.set.status ?? 200;
@@ -503,31 +600,11 @@ var opentelemetry = ({
503
600
  attributes["http.response.status_code"] = status;
504
601
  }
505
602
  const response = context.responseValue;
506
- if (response !== void 0)
507
- switch (typeof response) {
508
- case "object":
509
- if (response instanceof Response) {
510
- } else if (response instanceof Uint8Array)
511
- attributes["http.response.body.size"] = response.length;
512
- else if (response instanceof ArrayBuffer)
513
- attributes["http.response.body.size"] = response.byteLength;
514
- else if (response instanceof Blob)
515
- attributes["http.response.body.size"] = response.size;
516
- else {
517
- const value = JSON.stringify(response);
518
- attributes["http.response.body"] = value;
519
- attributes["http.response.body.size"] = value.length;
520
- }
521
- break;
522
- default:
523
- if (response === void 0 || response === null)
524
- attributes["http.response.body.size"] = 0;
525
- else {
526
- const value = response.toString();
527
- attributes["http.response.body"] = value;
528
- attributes["http.response.body.size"] = value.length;
529
- }
530
- }
603
+ if (response !== void 0 && recordResponseBody) {
604
+ const { text, size } = serializeBody(response);
605
+ if (text) attributes["http.response.body"] = text;
606
+ attributes["http.response.body.size"] = size;
607
+ }
531
608
  if (!rootSpan.ended) {
532
609
  const statusCode = attributes["http.response.status_code"];
533
610
  if (typeof statusCode === "number" && statusCode >= 500) {
@@ -547,20 +624,10 @@ var opentelemetry = ({
547
624
  attributes["http.response.status_code"] = status;
548
625
  }
549
626
  const body = context.body;
550
- if (body !== void 0 && body !== null) {
551
- const value = typeof body === "object" ? JSON.stringify(body) : body.toString();
552
- attributes["http.request.body"] = value;
553
- if (typeof body === "object") {
554
- if (body instanceof Uint8Array)
555
- attributes["http.request.body.size"] = body.length;
556
- else if (body instanceof ArrayBuffer)
557
- attributes["http.request.body.size"] = body.byteLength;
558
- else if (body instanceof Blob)
559
- attributes["http.request.body.size"] = body.size;
560
- attributes["http.request.body.size"] = value.length;
561
- } else {
562
- attributes["http.request.body.size"] = value.length;
563
- }
627
+ if (body !== void 0 && body !== null && recordRequestBody) {
628
+ const { text, size } = serializeBody(body);
629
+ if (text) attributes["http.request.body"] = text;
630
+ attributes["http.request.body.size"] = size;
564
631
  }
565
632
  if (!rootSpan.ended) {
566
633
  const statusCode = attributes["http.response.status_code"];
@@ -576,8 +643,10 @@ var opentelemetry = ({
576
643
  if (
577
644
  // @ts-ignore
578
645
  !rootSpan.ended
579
- )
646
+ ) {
647
+ recordDuration();
580
648
  rootSpan.end();
649
+ }
581
650
  });
582
651
  });
583
652
  context.request.signal.addEventListener("abort", () => {
@@ -588,6 +657,7 @@ var opentelemetry = ({
588
657
  code: SpanStatusCode.ERROR,
589
658
  message: "Request aborted"
590
659
  });
660
+ recordDuration();
591
661
  rootSpan.end();
592
662
  });
593
663
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@elysia/opentelemetry",
3
- "version": "1.4.11",
3
+ "description": "Elysia plugin to integrate OpenTelemetry",
4
+ "version": "1.4.12",
4
5
  "license": "MIT",
5
6
  "scripts": {
6
7
  "dev": "bun run --watch example/index.ts",
@@ -36,7 +37,7 @@
36
37
  "keywords": [
37
38
  "elysia",
38
39
  "opentelemetry",
39
- "tracing"
40
+ "otel"
40
41
  ],
41
42
  "dependencies": {
42
43
  "@opentelemetry/api": "^1.9.0",