@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.cjs CHANGED
@@ -60,6 +60,21 @@ function getGlobalConfig() {
60
60
  //#endregion
61
61
  //#region src/span-processor.ts
62
62
  const logger$2 = (0, _pingops_core.createLogger)("[PingOps Processor]");
63
+ function normalizePath$1(pathname) {
64
+ return pathname.replace(/\/+/g, "/").replace(/\/$/, "");
65
+ }
66
+ function isExporterRequestUrl$1(url$1, exporterUrl) {
67
+ try {
68
+ const request = new URL(url$1);
69
+ const exporter = new URL(exporterUrl);
70
+ if (request.origin !== exporter.origin) return false;
71
+ const requestPath = normalizePath$1(request.pathname);
72
+ const exporterPath = normalizePath$1(exporter.pathname);
73
+ return requestPath === exporterPath || requestPath.startsWith(`${exporterPath}/`);
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
63
78
  /**
64
79
  * Creates a filtered span wrapper that applies header filtering to attributes
65
80
  *
@@ -93,6 +108,7 @@ function createFilteredSpan(span, domainAllowList, globalHeadersAllowList, globa
93
108
  */
94
109
  var PingopsSpanProcessor = class {
95
110
  processor;
111
+ exporterTraceUrl;
96
112
  config;
97
113
  /**
98
114
  * Creates a new PingopsSpanProcessor instance.
@@ -102,8 +118,9 @@ var PingopsSpanProcessor = class {
102
118
  constructor(config) {
103
119
  const exportMode = config.exportMode ?? "batched";
104
120
  const apiKey = config.apiKey || process.env.PINGOPS_API_KEY || "";
121
+ this.exporterTraceUrl = `${config.baseUrl}/v1/traces`;
105
122
  const exporter = new _opentelemetry_exporter_trace_otlp_http.OTLPTraceExporter({
106
- url: `${config.baseUrl}/v1/traces`,
123
+ url: this.exporterTraceUrl,
107
124
  headers: {
108
125
  Authorization: apiKey ? `Bearer ${apiKey}` : "",
109
126
  "Content-Type": "application/json"
@@ -131,7 +148,7 @@ var PingopsSpanProcessor = class {
131
148
  domainAllowList: config.domainAllowList,
132
149
  maxRequestBodySize: config.maxRequestBodySize,
133
150
  maxResponseBodySize: config.maxResponseBodySize,
134
- exportTraceUrl: `${config.baseUrl}/v1/traces`
151
+ exportTraceUrl: this.exporterTraceUrl
135
152
  });
136
153
  logger$2.info("Initialized PingopsSpanProcessor", {
137
154
  baseUrl: config.baseUrl,
@@ -192,6 +209,14 @@ var PingopsSpanProcessor = class {
192
209
  }
193
210
  const attributes = span.attributes;
194
211
  const url$1 = (0, _pingops_core.getHttpUrlFromAttributes)(attributes) ?? "";
212
+ if (url$1 && isExporterRequestUrl$1(url$1, this.exporterTraceUrl)) {
213
+ logger$2.debug("Skipping exporter span to prevent self-instrumentation", {
214
+ spanName: span.name,
215
+ spanId: spanContext.spanId,
216
+ url: url$1
217
+ });
218
+ return;
219
+ }
195
220
  logger$2.debug("Extracted URL for domain filtering", {
196
221
  spanName: span.name,
197
222
  url: url$1,
@@ -363,9 +388,12 @@ async function shutdownTracerProvider() {
363
388
  //#region src/instrumentations/suppression-guard.ts
364
389
  const logger = (0, _pingops_core.createLogger)("[PingOps SuppressionGuard]");
365
390
  let hasLoggedSuppressionLeakWarning = false;
366
- function normalizeUrl(url$1) {
391
+ function normalizePath(pathname) {
392
+ return pathname.replace(/\/+/g, "/").replace(/\/$/, "");
393
+ }
394
+ function parseUrl(url$1) {
367
395
  try {
368
- return new URL(url$1).toString();
396
+ return new URL(url$1);
369
397
  } catch {
370
398
  return null;
371
399
  }
@@ -374,10 +402,20 @@ function isExporterRequestUrl(requestUrl) {
374
402
  if (!requestUrl) return false;
375
403
  const exporterUrl = getGlobalConfig()?.exportTraceUrl;
376
404
  if (!exporterUrl) return false;
377
- const normalizedRequestUrl = normalizeUrl(requestUrl);
378
- const normalizedExporterUrl = normalizeUrl(exporterUrl);
379
- if (!normalizedRequestUrl || !normalizedExporterUrl) return false;
380
- return normalizedRequestUrl.startsWith(normalizedExporterUrl);
405
+ const parsedRequestUrl = parseUrl(requestUrl);
406
+ const parsedExporterUrl = parseUrl(exporterUrl);
407
+ if (!parsedRequestUrl || !parsedExporterUrl) return false;
408
+ if (parsedRequestUrl.origin !== parsedExporterUrl.origin) return false;
409
+ const requestPath = normalizePath(parsedRequestUrl.pathname);
410
+ const exporterPath = normalizePath(parsedExporterUrl.pathname);
411
+ return requestPath === exporterPath || requestPath.startsWith(`${exporterPath}/`);
412
+ }
413
+ /**
414
+ * Determines whether an outbound request should be skipped from instrumentation
415
+ * to prevent exporter self-instrumentation loops.
416
+ */
417
+ function shouldIgnoreOutboundInstrumentation(requestUrl) {
418
+ return isExporterRequestUrl(requestUrl);
381
419
  }
382
420
  /**
383
421
  * Returns a context for outbound span creation that neutralizes leaked suppression
@@ -401,6 +439,7 @@ function resolveOutboundSpanParentContext(activeContext, requestUrl) {
401
439
  */
402
440
  const DEFAULT_MAX_REQUEST_BODY_SIZE$1 = 4 * 1024;
403
441
  const DEFAULT_MAX_RESPONSE_BODY_SIZE$1 = 4 * 1024;
442
+ const LEGACY_ATTR_HTTP_URL = "http.url";
404
443
  const PingopsSemanticAttributes = {
405
444
  HTTP_REQUEST_BODY: "http.request.body",
406
445
  HTTP_RESPONSE_BODY: "http.response.body"
@@ -408,12 +447,18 @@ const PingopsSemanticAttributes = {
408
447
  /**
409
448
  * Manually flattens a nested object into dot-notation keys
410
449
  */
450
+ function isPlainObject(value) {
451
+ return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Buffer);
452
+ }
453
+ function isPrimitiveArray(value) {
454
+ return value.every((item) => typeof item === "string" || typeof item === "number" || typeof item === "boolean");
455
+ }
411
456
  function flatten(obj, prefix = "") {
412
457
  const result = {};
413
458
  for (const key in obj) if (Object.prototype.hasOwnProperty.call(obj, key)) {
414
459
  const newKey = prefix ? `${prefix}.${key}` : key;
415
460
  const value = obj[key];
416
- if (value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Buffer)) Object.assign(result, flatten(value, newKey));
461
+ if (isPlainObject(value)) Object.assign(result, flatten(value, newKey));
417
462
  else result[newKey] = value;
418
463
  }
419
464
  return result;
@@ -424,11 +469,9 @@ function flatten(obj, prefix = "") {
424
469
  function setAttributeValue(span, attrName, attrValue) {
425
470
  if (typeof attrValue === "string" || typeof attrValue === "number" || typeof attrValue === "boolean") span.setAttribute(attrName, attrValue);
426
471
  else if (attrValue instanceof Buffer) span.setAttribute(attrName, attrValue.toString("utf8"));
427
- else if (typeof attrValue == "object") span.setAttributes(flatten({ [attrName]: attrValue }));
428
- else if (Array.isArray(attrValue)) if (attrValue.length) {
429
- const firstElement = attrValue[0];
430
- if (typeof firstElement === "string" || typeof firstElement === "number" || typeof firstElement === "boolean") span.setAttribute(attrName, attrValue);
431
- } else span.setAttribute(attrName, attrValue);
472
+ else if (Array.isArray(attrValue)) {
473
+ if (attrValue.length === 0 || isPrimitiveArray(attrValue)) span.setAttribute(attrName, attrValue);
474
+ } else if (isPlainObject(attrValue)) span.setAttributes(flatten({ [attrName]: attrValue }));
432
475
  }
433
476
  /**
434
477
  * Extracts domain from URL
@@ -494,9 +537,16 @@ function captureRequestBody(span, data, maxSize, semanticAttr, url$1) {
494
537
  /**
495
538
  * Captures response body from chunks
496
539
  */
497
- function captureResponseBody(span, chunks, semanticAttr, responseHeaders, url$1) {
540
+ function captureResponseBody(span, chunks, semanticAttr, responseHeaders, url$1, maxSize) {
498
541
  if (!shouldCaptureResponseBody$1(url$1)) return;
499
- if (chunks && chunks.length) try {
542
+ if (chunks === null) {
543
+ const contentEncoding = responseHeaders?.["content-encoding"];
544
+ const contentType = responseHeaders?.["content-type"];
545
+ const toHeaderString = (value) => typeof value === "string" ? value : Array.isArray(value) ? value.join(", ") : "unknown";
546
+ setAttributeValue(span, semanticAttr, `[truncated response body; exceeded maxResponseBodySize=${maxSize ?? DEFAULT_MAX_RESPONSE_BODY_SIZE$1}; content-type=${toHeaderString(contentType)}; content-encoding=${toHeaderString(contentEncoding)}]`);
547
+ return;
548
+ }
549
+ if (chunks.length) try {
500
550
  const concatedChunks = Buffer.concat(chunks);
501
551
  const contentEncoding = responseHeaders?.["content-encoding"];
502
552
  if ((0, _pingops_core.isCompressedContentEncoding)(contentEncoding)) {
@@ -559,7 +609,7 @@ function extractRequestUrlFromSpanOptions(options) {
559
609
  const attrs = options.attributes;
560
610
  if (!attrs) return;
561
611
  if (typeof attrs[_opentelemetry_semantic_conventions.ATTR_URL_FULL] === "string") return attrs[_opentelemetry_semantic_conventions.ATTR_URL_FULL];
562
- if (typeof attrs[_opentelemetry_semantic_conventions.ATTR_HTTP_URL] === "string") return attrs[_opentelemetry_semantic_conventions.ATTR_HTTP_URL];
612
+ if (typeof attrs[LEGACY_ATTR_HTTP_URL] === "string") return attrs[LEGACY_ATTR_HTTP_URL];
563
613
  const scheme = typeof attrs[_opentelemetry_semantic_conventions.ATTR_URL_SCHEME] === "string" ? attrs[_opentelemetry_semantic_conventions.ATTR_URL_SCHEME] : "http";
564
614
  const host = typeof attrs[_opentelemetry_semantic_conventions.ATTR_SERVER_ADDRESS] === "string" ? attrs[_opentelemetry_semantic_conventions.ATTR_SERVER_ADDRESS] : void 0;
565
615
  if (!host) return;
@@ -598,17 +648,19 @@ var PingopsHttpInstrumentation = class extends _opentelemetry_instrumentation_ht
598
648
  if (headers) captureRequestHeaders(span, headers);
599
649
  if (request instanceof http.ClientRequest) {
600
650
  const maxRequestBodySize = config?.maxRequestBodySize || DEFAULT_MAX_REQUEST_BODY_SIZE$1;
601
- const url$1 = request.path && request.getHeader("host") ? `${request.protocol || "http:"}//${request.getHeader("host")}${request.path}` : void 0;
651
+ const hostHeader = request.getHeader("host");
652
+ const host = typeof hostHeader === "string" ? hostHeader : Array.isArray(hostHeader) ? hostHeader.join(",") : typeof hostHeader === "number" ? String(hostHeader) : void 0;
653
+ const url$1 = request.path && host ? `${request.protocol || "http:"}//${host}${request.path}` : void 0;
602
654
  const originalWrite = request.write.bind(request);
603
655
  const originalEnd = request.end.bind(request);
604
- request.write = (data) => {
656
+ request.write = ((data, ...rest) => {
605
657
  if (typeof data === "string" || data instanceof Buffer) captureRequestBody(span, data, maxRequestBodySize, PingopsSemanticAttributes.HTTP_REQUEST_BODY, url$1);
606
- return originalWrite(data);
607
- };
608
- request.end = (data) => {
658
+ return originalWrite(data, ...rest);
659
+ });
660
+ request.end = ((data, ...rest) => {
609
661
  if (typeof data === "string" || data instanceof Buffer) captureRequestBody(span, data, maxRequestBodySize, PingopsSemanticAttributes.HTTP_REQUEST_BODY, url$1);
610
- return originalEnd(data);
611
- };
662
+ return originalEnd(data, ...rest);
663
+ });
612
664
  }
613
665
  if (originalRequestHook) originalRequestHook(span, request);
614
666
  };
@@ -625,15 +677,24 @@ var PingopsHttpInstrumentation = class extends _opentelemetry_instrumentation_ht
625
677
  const shouldCapture = shouldCaptureResponseBody$1(url$1);
626
678
  response.prependListener("data", (chunk) => {
627
679
  if (!chunk || !shouldCapture) return;
628
- if (typeof chunk === "string" || chunk instanceof Buffer) {
629
- totalSize += chunk.length;
630
- if (chunks && totalSize <= maxResponseBodySize) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
631
- else chunks = null;
632
- }
633
- });
634
- response.prependOnceListener("end", () => {
635
- captureResponseBody(span, chunks, PingopsSemanticAttributes.HTTP_RESPONSE_BODY, headers, url$1);
680
+ let chunkBuffer = null;
681
+ if (typeof chunk === "string") chunkBuffer = Buffer.from(chunk);
682
+ else if (Buffer.isBuffer(chunk)) chunkBuffer = chunk;
683
+ else if (chunk instanceof Uint8Array) chunkBuffer = Buffer.from(chunk);
684
+ if (!chunkBuffer) return;
685
+ totalSize += chunkBuffer.length;
686
+ if (chunks && totalSize <= maxResponseBodySize) chunks.push(chunkBuffer);
687
+ else chunks = null;
636
688
  });
689
+ let finalized = false;
690
+ const finalizeCapture = () => {
691
+ if (finalized) return;
692
+ finalized = true;
693
+ captureResponseBody(span, chunks, PingopsSemanticAttributes.HTTP_RESPONSE_BODY, headers, url$1, maxResponseBodySize);
694
+ };
695
+ response.prependOnceListener("end", finalizeCapture);
696
+ response.prependOnceListener("close", finalizeCapture);
697
+ response.prependOnceListener("aborted", finalizeCapture);
637
698
  }
638
699
  if (originalResponseHook) originalResponseHook(span, response);
639
700
  };
@@ -645,6 +706,14 @@ var PingopsHttpInstrumentation = class extends _opentelemetry_instrumentation_ht
645
706
  /**
646
707
  * HTTP instrumentation for OpenTelemetry
647
708
  */
709
+ function toRequestUrl$1(request) {
710
+ if (typeof request.href === "string" && request.href.length > 0) return request.href;
711
+ const protocol = typeof request.protocol === "string" && request.protocol.length > 0 ? request.protocol : "http:";
712
+ const hostnameOrHost = typeof request.hostname === "string" && request.hostname.length > 0 ? request.hostname : request.host;
713
+ if (!hostnameOrHost) return;
714
+ const hasPortInHost = hostnameOrHost.includes(":");
715
+ return `${protocol}//${hostnameOrHost}${request.port != null && !hasPortInHost ? `:${request.port}` : ""}${typeof request.path === "string" ? request.path : typeof request.pathname === "string" ? request.pathname : "/"}`;
716
+ }
648
717
  /**
649
718
  * Creates an HTTP instrumentation instance.
650
719
  * All outgoing HTTP requests are instrumented when the SDK is initialized.
@@ -654,12 +723,16 @@ var PingopsHttpInstrumentation = class extends _opentelemetry_instrumentation_ht
654
723
  */
655
724
  function createHttpInstrumentation(config) {
656
725
  const globalConfig$1 = getGlobalConfig();
726
+ const userIgnoreOutgoingRequestHook = config?.ignoreOutgoingRequestHook;
657
727
  return new PingopsHttpInstrumentation({
728
+ ...config,
658
729
  ignoreIncomingRequestHook: () => true,
659
- ignoreOutgoingRequestHook: () => false,
730
+ ignoreOutgoingRequestHook: (request) => {
731
+ if (shouldIgnoreOutboundInstrumentation(toRequestUrl$1(request))) return true;
732
+ return userIgnoreOutgoingRequestHook?.(request) ?? false;
733
+ },
660
734
  maxRequestBodySize: globalConfig$1?.maxRequestBodySize,
661
- maxResponseBodySize: globalConfig$1?.maxResponseBodySize,
662
- ...config
735
+ maxResponseBodySize: globalConfig$1?.maxResponseBodySize
663
736
  });
664
737
  }
665
738
 
@@ -875,11 +948,11 @@ var UndiciInstrumentation = class extends _opentelemetry_instrumentation.Instrum
875
948
  const record = this._recordFromReq.get(request);
876
949
  if (!record) return;
877
950
  const { span } = record;
878
- const { remoteAddress, remotePort } = socket;
879
- const spanAttributes = {
880
- [_opentelemetry_semantic_conventions.ATTR_NETWORK_PEER_ADDRESS]: remoteAddress,
881
- [_opentelemetry_semantic_conventions.ATTR_NETWORK_PEER_PORT]: remotePort
882
- };
951
+ const spanAttributes = {};
952
+ const remoteAddress = typeof socket.remoteAddress === "string" ? socket.remoteAddress : void 0;
953
+ const remotePort = typeof socket.remotePort === "number" ? socket.remotePort : void 0;
954
+ if (remoteAddress) spanAttributes[_opentelemetry_semantic_conventions.ATTR_NETWORK_PEER_ADDRESS] = remoteAddress;
955
+ if (remotePort !== void 0) spanAttributes[_opentelemetry_semantic_conventions.ATTR_NETWORK_PEER_PORT] = remotePort;
883
956
  const headersMap = this.parseRequestHeaders(request);
884
957
  for (const [name, value] of headersMap.entries()) {
885
958
  const attrValue = Array.isArray(value) ? value.join(", ") : value;
@@ -948,14 +1021,15 @@ var UndiciInstrumentation = class extends _opentelemetry_instrumentation.Instrum
948
1021
  this._diag.error("Error occurred while capturing request body:", e);
949
1022
  }
950
1023
  }
1024
+ const errorMessage = error.message;
951
1025
  span.recordException(error);
952
1026
  span.setStatus({
953
1027
  code: _opentelemetry_api.SpanStatusCode.ERROR,
954
- message: error.message
1028
+ message: errorMessage
955
1029
  });
956
1030
  span.end();
957
1031
  this._recordFromReq.delete(request);
958
- attributes[_opentelemetry_semantic_conventions.ATTR_ERROR_TYPE] = error.message;
1032
+ attributes[_opentelemetry_semantic_conventions.ATTR_ERROR_TYPE] = errorMessage;
959
1033
  this.recordRequestDuration(attributes, startTime);
960
1034
  }
961
1035
  onBodyChunkSent({ request, chunk }) {
@@ -1038,6 +1112,13 @@ var UndiciInstrumentation = class extends _opentelemetry_instrumentation.Instrum
1038
1112
  /**
1039
1113
  * Undici instrumentation for OpenTelemetry
1040
1114
  */
1115
+ function toRequestUrl(request) {
1116
+ try {
1117
+ return new URL(request.path, request.origin).toString();
1118
+ } catch {
1119
+ return;
1120
+ }
1121
+ }
1041
1122
  /**
1042
1123
  * Creates an Undici instrumentation instance.
1043
1124
  * All requests are instrumented when the SDK is initialized.
@@ -1048,7 +1129,9 @@ function createUndiciInstrumentation() {
1048
1129
  const globalConfig$1 = getGlobalConfig();
1049
1130
  return new UndiciInstrumentation({
1050
1131
  enabled: true,
1051
- ignoreRequestHook: () => false,
1132
+ ignoreRequestHook: (request) => {
1133
+ return shouldIgnoreOutboundInstrumentation(toRequestUrl(request));
1134
+ },
1052
1135
  maxRequestBodySize: globalConfig$1?.maxRequestBodySize,
1053
1136
  maxResponseBodySize: globalConfig$1?.maxResponseBodySize
1054
1137
  });