@pingops/otel 0.2.3 → 0.2.5

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/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ import { HTTP_RESPONSE_CONTENT_ENCODING, PINGOPS_CAPTURE_REQUEST_BODY, PINGOPS_C
4
4
  import { INVALID_SPAN_CONTEXT, ROOT_CONTEXT, SpanKind, SpanStatusCode, ValueType, context, propagation, trace } from "@opentelemetry/api";
5
5
  import "@opentelemetry/sdk-trace-node";
6
6
  import "@opentelemetry/resources";
7
- import { ATTR_ERROR_TYPE, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_REQUEST_METHOD_ORIGINAL, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_HTTP_URL, ATTR_NETWORK_PEER_ADDRESS, ATTR_NETWORK_PEER_PORT, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_FULL, ATTR_URL_PATH, ATTR_URL_QUERY, ATTR_URL_SCHEME, ATTR_USER_AGENT_ORIGINAL, METRIC_HTTP_CLIENT_REQUEST_DURATION } from "@opentelemetry/semantic-conventions";
7
+ import { ATTR_ERROR_TYPE, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_REQUEST_METHOD_ORIGINAL, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_NETWORK_PEER_ADDRESS, ATTR_NETWORK_PEER_PORT, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_FULL, ATTR_URL_PATH, ATTR_URL_QUERY, ATTR_URL_SCHEME, ATTR_USER_AGENT_ORIGINAL, METRIC_HTTP_CLIENT_REQUEST_DURATION } from "@opentelemetry/semantic-conventions";
8
8
  import { ClientRequest, IncomingMessage } from "http";
9
9
  import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
10
10
  import { hrTime, hrTimeDuration, hrTimeToMilliseconds, isTracingSuppressed } from "@opentelemetry/core";
@@ -32,6 +32,21 @@ function getGlobalConfig() {
32
32
  //#endregion
33
33
  //#region src/span-processor.ts
34
34
  const logger$2 = createLogger("[PingOps Processor]");
35
+ function normalizePath$1(pathname) {
36
+ return pathname.replace(/\/+/g, "/").replace(/\/$/, "");
37
+ }
38
+ function isExporterRequestUrl$1(url, exporterUrl) {
39
+ try {
40
+ const request = new URL(url);
41
+ const exporter = new URL(exporterUrl);
42
+ if (request.origin !== exporter.origin) return false;
43
+ const requestPath = normalizePath$1(request.pathname);
44
+ const exporterPath = normalizePath$1(exporter.pathname);
45
+ return requestPath === exporterPath || requestPath.startsWith(`${exporterPath}/`);
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
35
50
  /**
36
51
  * Creates a filtered span wrapper that applies header filtering to attributes
37
52
  *
@@ -65,6 +80,7 @@ function createFilteredSpan(span, domainAllowList, globalHeadersAllowList, globa
65
80
  */
66
81
  var PingopsSpanProcessor = class {
67
82
  processor;
83
+ exporterTraceUrl;
68
84
  config;
69
85
  /**
70
86
  * Creates a new PingopsSpanProcessor instance.
@@ -74,8 +90,9 @@ var PingopsSpanProcessor = class {
74
90
  constructor(config) {
75
91
  const exportMode = config.exportMode ?? "batched";
76
92
  const apiKey = config.apiKey || process.env.PINGOPS_API_KEY || "";
93
+ this.exporterTraceUrl = `${config.baseUrl}/v1/traces`;
77
94
  const exporter = new OTLPTraceExporter({
78
- url: `${config.baseUrl}/v1/traces`,
95
+ url: this.exporterTraceUrl,
79
96
  headers: {
80
97
  Authorization: apiKey ? `Bearer ${apiKey}` : "",
81
98
  "Content-Type": "application/json"
@@ -103,7 +120,7 @@ var PingopsSpanProcessor = class {
103
120
  domainAllowList: config.domainAllowList,
104
121
  maxRequestBodySize: config.maxRequestBodySize,
105
122
  maxResponseBodySize: config.maxResponseBodySize,
106
- exportTraceUrl: `${config.baseUrl}/v1/traces`
123
+ exportTraceUrl: this.exporterTraceUrl
107
124
  });
108
125
  logger$2.info("Initialized PingopsSpanProcessor", {
109
126
  baseUrl: config.baseUrl,
@@ -164,6 +181,14 @@ var PingopsSpanProcessor = class {
164
181
  }
165
182
  const attributes = span.attributes;
166
183
  const url = getHttpUrlFromAttributes(attributes) ?? "";
184
+ if (url && isExporterRequestUrl$1(url, this.exporterTraceUrl)) {
185
+ logger$2.debug("Skipping exporter span to prevent self-instrumentation", {
186
+ spanName: span.name,
187
+ spanId: spanContext.spanId,
188
+ url
189
+ });
190
+ return;
191
+ }
167
192
  logger$2.debug("Extracted URL for domain filtering", {
168
193
  spanName: span.name,
169
194
  url,
@@ -335,9 +360,12 @@ async function shutdownTracerProvider() {
335
360
  //#region src/instrumentations/suppression-guard.ts
336
361
  const logger = createLogger("[PingOps SuppressionGuard]");
337
362
  let hasLoggedSuppressionLeakWarning = false;
338
- function normalizeUrl(url) {
363
+ function normalizePath(pathname) {
364
+ return pathname.replace(/\/+/g, "/").replace(/\/$/, "");
365
+ }
366
+ function parseUrl(url) {
339
367
  try {
340
- return new URL(url).toString();
368
+ return new URL(url);
341
369
  } catch {
342
370
  return null;
343
371
  }
@@ -346,10 +374,20 @@ function isExporterRequestUrl(requestUrl) {
346
374
  if (!requestUrl) return false;
347
375
  const exporterUrl = getGlobalConfig()?.exportTraceUrl;
348
376
  if (!exporterUrl) return false;
349
- const normalizedRequestUrl = normalizeUrl(requestUrl);
350
- const normalizedExporterUrl = normalizeUrl(exporterUrl);
351
- if (!normalizedRequestUrl || !normalizedExporterUrl) return false;
352
- return normalizedRequestUrl.startsWith(normalizedExporterUrl);
377
+ const parsedRequestUrl = parseUrl(requestUrl);
378
+ const parsedExporterUrl = parseUrl(exporterUrl);
379
+ if (!parsedRequestUrl || !parsedExporterUrl) return false;
380
+ if (parsedRequestUrl.origin !== parsedExporterUrl.origin) return false;
381
+ const requestPath = normalizePath(parsedRequestUrl.pathname);
382
+ const exporterPath = normalizePath(parsedExporterUrl.pathname);
383
+ return requestPath === exporterPath || requestPath.startsWith(`${exporterPath}/`);
384
+ }
385
+ /**
386
+ * Determines whether an outbound request should be skipped from instrumentation
387
+ * to prevent exporter self-instrumentation loops.
388
+ */
389
+ function shouldIgnoreOutboundInstrumentation(requestUrl) {
390
+ return isExporterRequestUrl(requestUrl);
353
391
  }
354
392
  /**
355
393
  * Returns a context for outbound span creation that neutralizes leaked suppression
@@ -373,6 +411,7 @@ function resolveOutboundSpanParentContext(activeContext, requestUrl) {
373
411
  */
374
412
  const DEFAULT_MAX_REQUEST_BODY_SIZE$1 = 4 * 1024;
375
413
  const DEFAULT_MAX_RESPONSE_BODY_SIZE$1 = 4 * 1024;
414
+ const LEGACY_ATTR_HTTP_URL = "http.url";
376
415
  const PingopsSemanticAttributes = {
377
416
  HTTP_REQUEST_BODY: "http.request.body",
378
417
  HTTP_RESPONSE_BODY: "http.response.body"
@@ -380,12 +419,18 @@ const PingopsSemanticAttributes = {
380
419
  /**
381
420
  * Manually flattens a nested object into dot-notation keys
382
421
  */
422
+ function isPlainObject(value) {
423
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Buffer);
424
+ }
425
+ function isPrimitiveArray(value) {
426
+ return value.every((item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean");
427
+ }
383
428
  function flatten(obj, prefix = "") {
384
429
  const result = {};
385
430
  for (const key in obj) if (Object.prototype.hasOwnProperty.call(obj, key)) {
386
431
  const newKey = prefix ? `${prefix}.${key}` : key;
387
432
  const value = obj[key];
388
- if (value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Buffer)) Object.assign(result, flatten(value, newKey));
433
+ if (isPlainObject(value)) Object.assign(result, flatten(value, newKey));
389
434
  else result[newKey] = value;
390
435
  }
391
436
  return result;
@@ -396,11 +441,9 @@ function flatten(obj, prefix = "") {
396
441
  function setAttributeValue(span, attrName, attrValue) {
397
442
  if (typeof attrValue === "string" || typeof attrValue === "number" || typeof attrValue === "boolean") span.setAttribute(attrName, attrValue);
398
443
  else if (attrValue instanceof Buffer) span.setAttribute(attrName, attrValue.toString("utf8"));
399
- else if (typeof attrValue == "object") span.setAttributes(flatten({ [attrName]: attrValue }));
400
- else if (Array.isArray(attrValue)) if (attrValue.length) {
401
- const firstElement = attrValue[0];
402
- if (typeof firstElement === "string" || typeof firstElement === "number" || typeof firstElement === "boolean") span.setAttribute(attrName, attrValue);
403
- } else span.setAttribute(attrName, attrValue);
444
+ else if (Array.isArray(attrValue)) {
445
+ if (attrValue.length === 0 || isPrimitiveArray(attrValue)) span.setAttribute(attrName, attrValue);
446
+ } else if (isPlainObject(attrValue)) span.setAttributes(flatten({ [attrName]: attrValue }));
404
447
  }
405
448
  /**
406
449
  * Extracts domain from URL
@@ -466,9 +509,16 @@ function captureRequestBody(span, data, maxSize, semanticAttr, url) {
466
509
  /**
467
510
  * Captures response body from chunks
468
511
  */
469
- function captureResponseBody(span, chunks, semanticAttr, responseHeaders, url) {
512
+ function captureResponseBody(span, chunks, semanticAttr, responseHeaders, url, maxSize) {
470
513
  if (!shouldCaptureResponseBody$1(url)) return;
471
- if (chunks && chunks.length) try {
514
+ if (chunks === null) {
515
+ const contentEncoding = responseHeaders?.["content-encoding"];
516
+ const contentType = responseHeaders?.["content-type"];
517
+ const toHeaderString = (value) => typeof value === "string" ? value : Array.isArray(value) ? value.join(", ") : "unknown";
518
+ setAttributeValue(span, semanticAttr, `[truncated response body; exceeded maxResponseBodySize=${maxSize ?? DEFAULT_MAX_RESPONSE_BODY_SIZE$1}; content-type=${toHeaderString(contentType)}; content-encoding=${toHeaderString(contentEncoding)}]`);
519
+ return;
520
+ }
521
+ if (chunks.length) try {
472
522
  const concatedChunks = Buffer.concat(chunks);
473
523
  const contentEncoding = responseHeaders?.["content-encoding"];
474
524
  if (isCompressedContentEncoding(contentEncoding)) {
@@ -531,7 +581,7 @@ function extractRequestUrlFromSpanOptions(options) {
531
581
  const attrs = options.attributes;
532
582
  if (!attrs) return;
533
583
  if (typeof attrs[ATTR_URL_FULL] === "string") return attrs[ATTR_URL_FULL];
534
- if (typeof attrs[ATTR_HTTP_URL] === "string") return attrs[ATTR_HTTP_URL];
584
+ if (typeof attrs[LEGACY_ATTR_HTTP_URL] === "string") return attrs[LEGACY_ATTR_HTTP_URL];
535
585
  const scheme = typeof attrs[ATTR_URL_SCHEME] === "string" ? attrs[ATTR_URL_SCHEME] : "http";
536
586
  const host = typeof attrs[ATTR_SERVER_ADDRESS] === "string" ? attrs[ATTR_SERVER_ADDRESS] : void 0;
537
587
  if (!host) return;
@@ -570,17 +620,19 @@ var PingopsHttpInstrumentation = class extends HttpInstrumentation {
570
620
  if (headers) captureRequestHeaders(span, headers);
571
621
  if (request instanceof ClientRequest) {
572
622
  const maxRequestBodySize = config?.maxRequestBodySize || DEFAULT_MAX_REQUEST_BODY_SIZE$1;
573
- const url = request.path && request.getHeader("host") ? `${request.protocol || "http:"}//${request.getHeader("host")}${request.path}` : void 0;
623
+ const hostHeader = request.getHeader("host");
624
+ const host = typeof hostHeader === "string" ? hostHeader : Array.isArray(hostHeader) ? hostHeader.join(",") : typeof hostHeader === "number" ? String(hostHeader) : void 0;
625
+ const url = request.path && host ? `${request.protocol || "http:"}//${host}${request.path}` : void 0;
574
626
  const originalWrite = request.write.bind(request);
575
627
  const originalEnd = request.end.bind(request);
576
- request.write = (data) => {
628
+ request.write = ((data, ...rest) => {
577
629
  if (typeof data === "string" || data instanceof Buffer) captureRequestBody(span, data, maxRequestBodySize, PingopsSemanticAttributes.HTTP_REQUEST_BODY, url);
578
- return originalWrite(data);
579
- };
580
- request.end = (data) => {
630
+ return originalWrite(data, ...rest);
631
+ });
632
+ request.end = ((data, ...rest) => {
581
633
  if (typeof data === "string" || data instanceof Buffer) captureRequestBody(span, data, maxRequestBodySize, PingopsSemanticAttributes.HTTP_REQUEST_BODY, url);
582
- return originalEnd(data);
583
- };
634
+ return originalEnd(data, ...rest);
635
+ });
584
636
  }
585
637
  if (originalRequestHook) originalRequestHook(span, request);
586
638
  };
@@ -597,15 +649,24 @@ var PingopsHttpInstrumentation = class extends HttpInstrumentation {
597
649
  const shouldCapture = shouldCaptureResponseBody$1(url);
598
650
  response.prependListener("data", (chunk) => {
599
651
  if (!chunk || !shouldCapture) return;
600
- if (typeof chunk === "string" || chunk instanceof Buffer) {
601
- totalSize += chunk.length;
602
- if (chunks && totalSize <= maxResponseBodySize) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
603
- else chunks = null;
604
- }
605
- });
606
- response.prependOnceListener("end", () => {
607
- captureResponseBody(span, chunks, PingopsSemanticAttributes.HTTP_RESPONSE_BODY, headers, url);
652
+ let chunkBuffer = null;
653
+ if (typeof chunk === "string") chunkBuffer = Buffer.from(chunk);
654
+ else if (Buffer.isBuffer(chunk)) chunkBuffer = chunk;
655
+ else if (chunk instanceof Uint8Array) chunkBuffer = Buffer.from(chunk);
656
+ if (!chunkBuffer) return;
657
+ totalSize += chunkBuffer.length;
658
+ if (chunks && totalSize <= maxResponseBodySize) chunks.push(chunkBuffer);
659
+ else chunks = null;
608
660
  });
661
+ let finalized = false;
662
+ const finalizeCapture = () => {
663
+ if (finalized) return;
664
+ finalized = true;
665
+ captureResponseBody(span, chunks, PingopsSemanticAttributes.HTTP_RESPONSE_BODY, headers, url, maxResponseBodySize);
666
+ };
667
+ response.prependOnceListener("end", finalizeCapture);
668
+ response.prependOnceListener("close", finalizeCapture);
669
+ response.prependOnceListener("aborted", finalizeCapture);
609
670
  }
610
671
  if (originalResponseHook) originalResponseHook(span, response);
611
672
  };
@@ -617,6 +678,14 @@ var PingopsHttpInstrumentation = class extends HttpInstrumentation {
617
678
  /**
618
679
  * HTTP instrumentation for OpenTelemetry
619
680
  */
681
+ function toRequestUrl$1(request) {
682
+ if (typeof request.href === "string" && request.href.length > 0) return request.href;
683
+ const protocol = typeof request.protocol === "string" && request.protocol.length > 0 ? request.protocol : "http:";
684
+ const hostnameOrHost = typeof request.hostname === "string" && request.hostname.length > 0 ? request.hostname : request.host;
685
+ if (!hostnameOrHost) return;
686
+ const hasPortInHost = hostnameOrHost.includes(":");
687
+ return `${protocol}//${hostnameOrHost}${request.port != null && !hasPortInHost ? `:${request.port}` : ""}${typeof request.path === "string" ? request.path : typeof request.pathname === "string" ? request.pathname : "/"}`;
688
+ }
620
689
  /**
621
690
  * Creates an HTTP instrumentation instance.
622
691
  * All outgoing HTTP requests are instrumented when the SDK is initialized.
@@ -626,12 +695,16 @@ var PingopsHttpInstrumentation = class extends HttpInstrumentation {
626
695
  */
627
696
  function createHttpInstrumentation(config) {
628
697
  const globalConfig$1 = getGlobalConfig();
698
+ const userIgnoreOutgoingRequestHook = config?.ignoreOutgoingRequestHook;
629
699
  return new PingopsHttpInstrumentation({
700
+ ...config,
630
701
  ignoreIncomingRequestHook: () => true,
631
- ignoreOutgoingRequestHook: () => false,
702
+ ignoreOutgoingRequestHook: (request) => {
703
+ if (shouldIgnoreOutboundInstrumentation(toRequestUrl$1(request))) return true;
704
+ return userIgnoreOutgoingRequestHook?.(request) ?? false;
705
+ },
632
706
  maxRequestBodySize: globalConfig$1?.maxRequestBodySize,
633
- maxResponseBodySize: globalConfig$1?.maxResponseBodySize,
634
- ...config
707
+ maxResponseBodySize: globalConfig$1?.maxResponseBodySize
635
708
  });
636
709
  }
637
710
 
@@ -847,11 +920,11 @@ var UndiciInstrumentation = class extends InstrumentationBase {
847
920
  const record = this._recordFromReq.get(request);
848
921
  if (!record) return;
849
922
  const { span } = record;
850
- const { remoteAddress, remotePort } = socket;
851
- const spanAttributes = {
852
- [ATTR_NETWORK_PEER_ADDRESS]: remoteAddress,
853
- [ATTR_NETWORK_PEER_PORT]: remotePort
854
- };
923
+ const spanAttributes = {};
924
+ const remoteAddress = typeof socket.remoteAddress === "string" ? socket.remoteAddress : void 0;
925
+ const remotePort = typeof socket.remotePort === "number" ? socket.remotePort : void 0;
926
+ if (remoteAddress) spanAttributes[ATTR_NETWORK_PEER_ADDRESS] = remoteAddress;
927
+ if (remotePort !== void 0) spanAttributes[ATTR_NETWORK_PEER_PORT] = remotePort;
855
928
  const headersMap = this.parseRequestHeaders(request);
856
929
  for (const [name, value] of headersMap.entries()) {
857
930
  const attrValue = Array.isArray(value) ? value.join(", ") : value;
@@ -920,14 +993,15 @@ var UndiciInstrumentation = class extends InstrumentationBase {
920
993
  this._diag.error("Error occurred while capturing request body:", e);
921
994
  }
922
995
  }
996
+ const errorMessage = error.message;
923
997
  span.recordException(error);
924
998
  span.setStatus({
925
999
  code: SpanStatusCode.ERROR,
926
- message: error.message
1000
+ message: errorMessage
927
1001
  });
928
1002
  span.end();
929
1003
  this._recordFromReq.delete(request);
930
- attributes[ATTR_ERROR_TYPE] = error.message;
1004
+ attributes[ATTR_ERROR_TYPE] = errorMessage;
931
1005
  this.recordRequestDuration(attributes, startTime);
932
1006
  }
933
1007
  onBodyChunkSent({ request, chunk }) {
@@ -1010,6 +1084,13 @@ var UndiciInstrumentation = class extends InstrumentationBase {
1010
1084
  /**
1011
1085
  * Undici instrumentation for OpenTelemetry
1012
1086
  */
1087
+ function toRequestUrl(request) {
1088
+ try {
1089
+ return new URL(request.path, request.origin).toString();
1090
+ } catch {
1091
+ return;
1092
+ }
1093
+ }
1013
1094
  /**
1014
1095
  * Creates an Undici instrumentation instance.
1015
1096
  * All requests are instrumented when the SDK is initialized.
@@ -1020,7 +1101,9 @@ function createUndiciInstrumentation() {
1020
1101
  const globalConfig$1 = getGlobalConfig();
1021
1102
  return new UndiciInstrumentation({
1022
1103
  enabled: true,
1023
- ignoreRequestHook: () => false,
1104
+ ignoreRequestHook: (request) => {
1105
+ return shouldIgnoreOutboundInstrumentation(toRequestUrl(request));
1106
+ },
1024
1107
  maxRequestBodySize: globalConfig$1?.maxRequestBodySize,
1025
1108
  maxResponseBodySize: globalConfig$1?.maxResponseBodySize
1026
1109
  });