@pingops/otel 0.2.1 → 0.2.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/dist/index.mjs CHANGED
@@ -1,16 +1,16 @@
1
1
  import { BatchSpanProcessor, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
2
2
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
3
3
  import { HTTP_RESPONSE_CONTENT_ENCODING, PINGOPS_CAPTURE_REQUEST_BODY, PINGOPS_CAPTURE_RESPONSE_BODY, bufferToBodyString, createLogger, extractSpanPayload, getHttpUrlFromAttributes, getPropagatedAttributesFromContext, isCompressedContentEncoding, isSpanEligible, shouldCaptureSpan } from "@pingops/core";
4
- import { INVALID_SPAN_CONTEXT, SpanKind, SpanStatusCode, ValueType, context, propagation, trace } from "@opentelemetry/api";
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
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
+ import { hrTime, hrTimeDuration, hrTimeToMilliseconds, isTracingSuppressed } from "@opentelemetry/core";
10
11
  import * as diagch from "diagnostics_channel";
11
12
  import { URL as URL$1 } from "url";
12
13
  import { InstrumentationBase, safeExecuteInTheMiddle } from "@opentelemetry/instrumentation";
13
- import { hrTime, hrTimeDuration, hrTimeToMilliseconds } from "@opentelemetry/core";
14
14
 
15
15
  //#region src/config-store.ts
16
16
  let globalConfig = null;
@@ -31,7 +31,7 @@ function getGlobalConfig() {
31
31
 
32
32
  //#endregion
33
33
  //#region src/span-processor.ts
34
- const logger$1 = createLogger("[PingOps Processor]");
34
+ const logger$2 = createLogger("[PingOps Processor]");
35
35
  /**
36
36
  * Creates a filtered span wrapper that applies header filtering to attributes
37
37
  *
@@ -49,7 +49,7 @@ const logger$1 = createLogger("[PingOps Processor]");
49
49
  function createFilteredSpan(span, domainAllowList, globalHeadersAllowList, globalHeadersDenyList, globalCaptureRequestBody, globalCaptureResponseBody, headerRedaction) {
50
50
  const payload = extractSpanPayload(span, domainAllowList, globalHeadersAllowList, globalHeadersDenyList, globalCaptureRequestBody, globalCaptureResponseBody, headerRedaction);
51
51
  const filteredAttributes = payload?.attributes ?? span.attributes;
52
- logger$1.debug("Payload", { payload });
52
+ logger$2.debug("Payload", { payload });
53
53
  return new Proxy(span, { get(target, prop) {
54
54
  if (prop === "attributes") return filteredAttributes;
55
55
  const value = target[prop];
@@ -102,9 +102,10 @@ var PingopsSpanProcessor = class {
102
102
  captureResponseBody: config.captureResponseBody,
103
103
  domainAllowList: config.domainAllowList,
104
104
  maxRequestBodySize: config.maxRequestBodySize,
105
- maxResponseBodySize: config.maxResponseBodySize
105
+ maxResponseBodySize: config.maxResponseBodySize,
106
+ exportTraceUrl: `${config.baseUrl}/v1/traces`
106
107
  });
107
- logger$1.info("Initialized PingopsSpanProcessor", {
108
+ logger$2.info("Initialized PingopsSpanProcessor", {
108
109
  baseUrl: config.baseUrl,
109
110
  exportMode,
110
111
  batchSize: config.batchSize,
@@ -120,7 +121,7 @@ var PingopsSpanProcessor = class {
120
121
  */
121
122
  onStart(span, parentContext) {
122
123
  const spanContext = span.spanContext();
123
- logger$1.debug("Span started", {
124
+ logger$2.debug("Span started", {
124
125
  spanName: span.name,
125
126
  spanId: spanContext.spanId,
126
127
  traceId: spanContext.traceId
@@ -128,7 +129,7 @@ var PingopsSpanProcessor = class {
128
129
  const propagatedAttributes = getPropagatedAttributesFromContext(parentContext);
129
130
  if (Object.keys(propagatedAttributes).length > 0) {
130
131
  for (const [key, value] of Object.entries(propagatedAttributes)) if (typeof value === "string" || Array.isArray(value)) span.setAttribute(key, value);
131
- logger$1.debug("Set propagated attributes on span", {
132
+ logger$2.debug("Set propagated attributes on span", {
132
133
  spanName: span.name,
133
134
  attributeKeys: Object.keys(propagatedAttributes)
134
135
  });
@@ -146,7 +147,7 @@ var PingopsSpanProcessor = class {
146
147
  */
147
148
  onEnd(span) {
148
149
  const spanContext = span.spanContext();
149
- logger$1.debug("Span ended, processing", {
150
+ logger$2.debug("Span ended, processing", {
150
151
  spanName: span.name,
151
152
  spanId: spanContext.spanId,
152
153
  traceId: spanContext.traceId,
@@ -154,7 +155,7 @@ var PingopsSpanProcessor = class {
154
155
  });
155
156
  try {
156
157
  if (!isSpanEligible(span)) {
157
- logger$1.debug("Span not eligible, skipping", {
158
+ logger$2.debug("Span not eligible, skipping", {
158
159
  spanName: span.name,
159
160
  spanId: spanContext.spanId,
160
161
  reason: "not CLIENT or missing HTTP/GenAI attributes"
@@ -163,7 +164,7 @@ var PingopsSpanProcessor = class {
163
164
  }
164
165
  const attributes = span.attributes;
165
166
  const url = getHttpUrlFromAttributes(attributes) ?? "";
166
- logger$1.debug("Extracted URL for domain filtering", {
167
+ logger$2.debug("Extracted URL for domain filtering", {
167
168
  spanName: span.name,
168
169
  url,
169
170
  hasHttpUrl: !!attributes["http.url"],
@@ -172,17 +173,17 @@ var PingopsSpanProcessor = class {
172
173
  });
173
174
  if (url) {
174
175
  if (!shouldCaptureSpan(url, this.config.domainAllowList, this.config.domainDenyList)) {
175
- logger$1.info("Span filtered out by domain rules", {
176
+ logger$2.info("Span filtered out by domain rules", {
176
177
  spanName: span.name,
177
178
  spanId: spanContext.spanId,
178
179
  url
179
180
  });
180
181
  return;
181
182
  }
182
- } else logger$1.debug("No URL found for domain filtering, proceeding", { spanName: span.name });
183
+ } else logger$2.debug("No URL found for domain filtering, proceeding", { spanName: span.name });
183
184
  const filteredSpan = createFilteredSpan(span, this.config.domainAllowList, this.config.headersAllowList, this.config.headersDenyList, this.config.captureRequestBody, this.config.captureResponseBody, this.config.headerRedaction);
184
185
  this.processor.onEnd(filteredSpan);
185
- logger$1.info("Span passed all filters and queued for export", {
186
+ logger$2.info("Span passed all filters and queued for export", {
186
187
  spanName: span.name,
187
188
  spanId: spanContext.spanId,
188
189
  traceId: spanContext.traceId,
@@ -190,7 +191,7 @@ var PingopsSpanProcessor = class {
190
191
  hasHeaderFiltering: !!(this.config.headersAllowList || this.config.headersDenyList)
191
192
  });
192
193
  } catch (error) {
193
- logger$1.error("Error processing span", {
194
+ logger$2.error("Error processing span", {
194
195
  spanName: span.name,
195
196
  spanId: spanContext.spanId,
196
197
  error: error instanceof Error ? error.message : String(error)
@@ -203,12 +204,12 @@ var PingopsSpanProcessor = class {
203
204
  * @returns Promise that resolves when all pending operations are complete
204
205
  */
205
206
  async forceFlush() {
206
- logger$1.info("Force flushing spans");
207
+ logger$2.info("Force flushing spans");
207
208
  try {
208
209
  await this.processor.forceFlush();
209
- logger$1.info("Force flush complete");
210
+ logger$2.info("Force flush complete");
210
211
  } catch (error) {
211
- logger$1.error("Error during force flush", { error: error instanceof Error ? error.message : String(error) });
212
+ logger$2.error("Error during force flush", { error: error instanceof Error ? error.message : String(error) });
212
213
  throw error;
213
214
  }
214
215
  }
@@ -218,12 +219,12 @@ var PingopsSpanProcessor = class {
218
219
  * @returns Promise that resolves when shutdown is complete
219
220
  */
220
221
  async shutdown() {
221
- logger$1.info("Shutting down processor");
222
+ logger$2.info("Shutting down processor");
222
223
  try {
223
224
  await this.processor.shutdown();
224
- logger$1.info("Processor shutdown complete");
225
+ logger$2.info("Processor shutdown complete");
225
226
  } catch (error) {
226
- logger$1.error("Error during processor shutdown", { error: error instanceof Error ? error.message : String(error) });
227
+ logger$2.error("Error during processor shutdown", { error: error instanceof Error ? error.message : String(error) });
227
228
  throw error;
228
229
  }
229
230
  }
@@ -238,7 +239,7 @@ const PINGOPS_GLOBAL_SYMBOL = Symbol.for("pingops");
238
239
  /**
239
240
  * Logger instance for tracer provider
240
241
  */
241
- const logger = createLogger("[PingOps TracerProvider]");
242
+ const logger$1 = createLogger("[PingOps TracerProvider]");
242
243
  /**
243
244
  * Creates initial global state
244
245
  */
@@ -253,21 +254,21 @@ function getGlobalState() {
253
254
  try {
254
255
  const g = globalThis;
255
256
  if (typeof g !== "object" || g === null) {
256
- logger.warn("globalThis is not available, using fallback state");
257
+ logger$1.warn("globalThis is not available, using fallback state");
257
258
  return initialState;
258
259
  }
259
260
  if (!g[PINGOPS_GLOBAL_SYMBOL]) {
260
- logger.debug("Creating new global state");
261
+ logger$1.debug("Creating new global state");
261
262
  Object.defineProperty(g, PINGOPS_GLOBAL_SYMBOL, {
262
263
  value: initialState,
263
264
  writable: false,
264
265
  configurable: false,
265
266
  enumerable: false
266
267
  });
267
- } else logger.debug("Retrieved existing global state");
268
+ } else logger$1.debug("Retrieved existing global state");
268
269
  return g[PINGOPS_GLOBAL_SYMBOL];
269
270
  } catch (err) {
270
- logger.error("Failed to access global state:", err instanceof Error ? err.message : String(err));
271
+ logger$1.error("Failed to access global state:", err instanceof Error ? err.message : String(err));
271
272
  return initialState;
272
273
  }
273
274
  }
@@ -285,11 +286,11 @@ function setPingopsTracerProvider(provider) {
285
286
  const state = getGlobalState();
286
287
  const hadProvider = state.isolatedTracerProvider !== null;
287
288
  state.isolatedTracerProvider = provider;
288
- if (provider) logger.info("Set isolated TracerProvider", {
289
+ if (provider) logger$1.info("Set isolated TracerProvider", {
289
290
  hadPrevious: hadProvider,
290
291
  providerType: provider.constructor.name
291
292
  });
292
- else logger.info("Cleared isolated TracerProvider", { hadPrevious: hadProvider });
293
+ else logger$1.info("Cleared isolated TracerProvider", { hadPrevious: hadProvider });
293
294
  }
294
295
  /**
295
296
  * Gets the TracerProvider for PingOps tracing operations.
@@ -303,31 +304,65 @@ function setPingopsTracerProvider(provider) {
303
304
  function getPingopsTracerProvider() {
304
305
  const { isolatedTracerProvider } = getGlobalState();
305
306
  if (isolatedTracerProvider) {
306
- logger.debug("Using isolated TracerProvider", { providerType: isolatedTracerProvider.constructor.name });
307
+ logger$1.debug("Using isolated TracerProvider", { providerType: isolatedTracerProvider.constructor.name });
307
308
  return isolatedTracerProvider;
308
309
  }
309
310
  const globalProvider = trace.getTracerProvider();
310
- logger.debug("Using global TracerProvider", { providerType: globalProvider.constructor.name });
311
+ logger$1.debug("Using global TracerProvider", { providerType: globalProvider.constructor.name });
311
312
  return globalProvider;
312
313
  }
313
314
  /**
314
315
  * Shuts down the TracerProvider and flushes remaining spans
315
316
  */
316
317
  async function shutdownTracerProvider() {
317
- logger.info("Shutting down TracerProvider");
318
+ logger$1.info("Shutting down TracerProvider");
318
319
  const providerWithShutdown = getPingopsTracerProvider();
319
320
  if (providerWithShutdown && typeof providerWithShutdown.shutdown === "function") {
320
- logger.debug("Calling provider.shutdown()");
321
+ logger$1.debug("Calling provider.shutdown()");
321
322
  try {
322
323
  await providerWithShutdown.shutdown();
323
- logger.info("TracerProvider shutdown complete");
324
+ logger$1.info("TracerProvider shutdown complete");
324
325
  } catch (error) {
325
- logger.error("Error during TracerProvider shutdown:", error instanceof Error ? error.message : String(error));
326
+ logger$1.error("Error during TracerProvider shutdown:", error instanceof Error ? error.message : String(error));
326
327
  throw error;
327
328
  }
328
- } else logger.warn("TracerProvider does not have shutdown method, skipping");
329
+ } else logger$1.warn("TracerProvider does not have shutdown method, skipping");
329
330
  setPingopsTracerProvider(null);
330
- logger.info("TracerProvider shutdown finished");
331
+ logger$1.info("TracerProvider shutdown finished");
332
+ }
333
+
334
+ //#endregion
335
+ //#region src/instrumentations/suppression-guard.ts
336
+ const logger = createLogger("[PingOps SuppressionGuard]");
337
+ let hasLoggedSuppressionLeakWarning = false;
338
+ function normalizeUrl(url) {
339
+ try {
340
+ return new URL(url).toString();
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+ function isExporterRequestUrl(requestUrl) {
346
+ if (!requestUrl) return false;
347
+ const exporterUrl = getGlobalConfig()?.exportTraceUrl;
348
+ 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);
353
+ }
354
+ /**
355
+ * Returns a context for outbound span creation that neutralizes leaked suppression
356
+ * for user traffic while preserving suppression for exporter requests.
357
+ */
358
+ function resolveOutboundSpanParentContext(activeContext, requestUrl) {
359
+ if (!isTracingSuppressed(activeContext)) return activeContext;
360
+ if (isExporterRequestUrl(requestUrl)) return activeContext;
361
+ if (!hasLoggedSuppressionLeakWarning) {
362
+ logger.warn("Detected suppressed context for outbound user request; running instrumentation on ROOT_CONTEXT to prevent Noop spans from suppression leakage");
363
+ hasLoggedSuppressionLeakWarning = true;
364
+ } else logger.debug("Suppressed context detected for outbound user request; using ROOT_CONTEXT");
365
+ return ROOT_CONTEXT;
331
366
  }
332
367
 
333
368
  //#endregion
@@ -497,6 +532,20 @@ var PingopsHttpInstrumentation = class extends HttpInstrumentation {
497
532
  constructor(config) {
498
533
  super(config);
499
534
  this._config = this._createConfig(config);
535
+ this._installOutgoingSuppressionGuard();
536
+ }
537
+ /**
538
+ * HttpInstrumentation's span creation is private, so we wrap the instance method
539
+ * to swap suppressed parent contexts with ROOT_CONTEXT for outgoing user requests.
540
+ */
541
+ _installOutgoingSuppressionGuard() {
542
+ const target = this;
543
+ if (typeof target._startHttpSpan !== "function") return;
544
+ const originalStartHttpSpan = target._startHttpSpan.bind(this);
545
+ target._startHttpSpan = (name, options, ctx = context.active()) => {
546
+ if (options.kind !== SpanKind.CLIENT) return originalStartHttpSpan(name, options, ctx);
547
+ return originalStartHttpSpan(name, options, resolveOutboundSpanParentContext(ctx, typeof options.attributes?.["url.full"] === "string" ? options.attributes["url.full"] : void 0));
548
+ };
500
549
  }
501
550
  _createConfig(config) {
502
551
  return {
@@ -754,16 +803,16 @@ var UndiciInstrumentation = class extends InstrumentationBase {
754
803
  if (hookAttributes) Object.entries(hookAttributes).forEach(([key, val]) => {
755
804
  attributes[key] = val;
756
805
  });
757
- const activeCtx = context.active();
758
- const currentSpan = trace.getSpan(activeCtx);
806
+ const spanParentContext = resolveOutboundSpanParentContext(context.active(), requestUrl.toString());
807
+ const currentSpan = trace.getSpan(spanParentContext);
759
808
  let span;
760
809
  if (config.requireParentforSpans && (!currentSpan || !trace.isSpanContextValid(currentSpan.spanContext()))) span = trace.wrapSpanContext(INVALID_SPAN_CONTEXT);
761
810
  else span = this.tracer.startSpan(requestMethod === "_OTHER" ? "HTTP" : requestMethod, {
762
811
  kind: SpanKind.CLIENT,
763
812
  attributes
764
- }, activeCtx);
813
+ }, spanParentContext);
765
814
  safeExecuteInTheMiddle(() => config.requestHook?.(span, request), (e) => e && this._diag.error("caught requestHook error: ", e), true);
766
- const requestContext = trace.setSpan(context.active(), span);
815
+ const requestContext = trace.setSpan(spanParentContext, span);
767
816
  const addedHeaders = {};
768
817
  propagation.inject(requestContext, addedHeaders);
769
818
  const headerEntries = Object.entries(addedHeaders);