@pingops/core 0.1.1 → 0.1.3

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.
Files changed (49) hide show
  1. package/dist/index.cjs +844 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.d.cts +305 -0
  4. package/dist/index.d.cts.map +1 -0
  5. package/dist/index.d.mts +305 -0
  6. package/dist/index.d.mts.map +1 -0
  7. package/dist/index.mjs +824 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +17 -5
  10. package/dist/context-keys.d.ts +0 -42
  11. package/dist/context-keys.d.ts.map +0 -1
  12. package/dist/context-keys.js +0 -43
  13. package/dist/context-keys.js.map +0 -1
  14. package/dist/filtering/domain-filter.d.ts +0 -9
  15. package/dist/filtering/domain-filter.d.ts.map +0 -1
  16. package/dist/filtering/domain-filter.js +0 -136
  17. package/dist/filtering/domain-filter.js.map +0 -1
  18. package/dist/filtering/header-filter.d.ts +0 -31
  19. package/dist/filtering/header-filter.d.ts.map +0 -1
  20. package/dist/filtering/header-filter.js +0 -187
  21. package/dist/filtering/header-filter.js.map +0 -1
  22. package/dist/filtering/span-filter.d.ts +0 -13
  23. package/dist/filtering/span-filter.d.ts.map +0 -1
  24. package/dist/filtering/span-filter.js +0 -46
  25. package/dist/filtering/span-filter.js.map +0 -1
  26. package/dist/index.d.ts +0 -13
  27. package/dist/index.d.ts.map +0 -1
  28. package/dist/index.js +0 -13
  29. package/dist/index.js.map +0 -1
  30. package/dist/logger.d.ts +0 -21
  31. package/dist/logger.d.ts.map +0 -1
  32. package/dist/logger.js +0 -36
  33. package/dist/logger.js.map +0 -1
  34. package/dist/types.d.ts +0 -46
  35. package/dist/types.d.ts.map +0 -1
  36. package/dist/types.js +0 -5
  37. package/dist/types.js.map +0 -1
  38. package/dist/utils/context-extractor.d.ts +0 -13
  39. package/dist/utils/context-extractor.d.ts.map +0 -1
  40. package/dist/utils/context-extractor.js +0 -44
  41. package/dist/utils/context-extractor.js.map +0 -1
  42. package/dist/utils/span-extractor.d.ts +0 -10
  43. package/dist/utils/span-extractor.d.ts.map +0 -1
  44. package/dist/utils/span-extractor.js +0 -156
  45. package/dist/utils/span-extractor.js.map +0 -1
  46. package/dist/wrap-http.d.ts +0 -55
  47. package/dist/wrap-http.d.ts.map +0 -1
  48. package/dist/wrap-http.js +0 -135
  49. package/dist/wrap-http.js.map +0 -1
package/dist/index.cjs ADDED
@@ -0,0 +1,844 @@
1
+ let _opentelemetry_api = require("@opentelemetry/api");
2
+
3
+ //#region src/logger.ts
4
+ /**
5
+ * Creates a logger instance with a specific prefix
6
+ *
7
+ * @param prefix - Prefix to add to all log messages (e.g., '[PingOps Filter]')
8
+ * @returns Logger instance
9
+ */
10
+ function createLogger(prefix) {
11
+ const isDebugEnabled = process.env.PINGOPS_DEBUG === "true";
12
+ const formatMessage = (level, message) => {
13
+ return `[${(/* @__PURE__ */ new Date()).toISOString()}] ${prefix} [${level.toUpperCase()}] ${message}`;
14
+ };
15
+ return {
16
+ debug(message, ...args) {
17
+ if (isDebugEnabled) console.debug(formatMessage("debug", message), ...args);
18
+ },
19
+ info(message, ...args) {
20
+ console.log(formatMessage("info", message), ...args);
21
+ },
22
+ warn(message, ...args) {
23
+ console.warn(formatMessage("warn", message), ...args);
24
+ },
25
+ error(message, ...args) {
26
+ console.error(formatMessage("error", message), ...args);
27
+ }
28
+ };
29
+ }
30
+
31
+ //#endregion
32
+ //#region src/filtering/span-filter.ts
33
+ /**
34
+ * Span filtering logic - determines if a span is eligible for capture
35
+ */
36
+ const log$2 = createLogger("[PingOps SpanFilter]");
37
+ /**
38
+ * Checks if a span is eligible for capture based on span kind and attributes.
39
+ * A span is eligible if:
40
+ * 1. span.kind === SpanKind.CLIENT
41
+ * 2. AND has HTTP attributes (http.method, http.url, or server.address)
42
+ * OR has GenAI attributes (gen_ai.system, gen_ai.operation.name)
43
+ */
44
+ function isSpanEligible(span) {
45
+ log$2.debug("Checking span eligibility", {
46
+ spanName: span.name,
47
+ spanKind: span.kind,
48
+ spanId: span.spanContext().spanId,
49
+ traceId: span.spanContext().traceId
50
+ });
51
+ if (span.kind !== _opentelemetry_api.SpanKind.CLIENT) {
52
+ log$2.debug("Span not eligible: not CLIENT kind", {
53
+ spanName: span.name,
54
+ spanKind: span.kind
55
+ });
56
+ return false;
57
+ }
58
+ const attributes = span.attributes;
59
+ const hasHttpMethod = attributes["http.method"] !== void 0;
60
+ const hasHttpUrl = attributes["http.url"] !== void 0;
61
+ const hasServerAddress = attributes["server.address"] !== void 0;
62
+ const isEligible = hasHttpMethod || hasHttpUrl || hasServerAddress;
63
+ log$2.debug("Span eligibility check result", {
64
+ spanName: span.name,
65
+ isEligible,
66
+ httpAttributes: {
67
+ hasMethod: hasHttpMethod,
68
+ hasUrl: hasHttpUrl,
69
+ hasServerAddress
70
+ }
71
+ });
72
+ return isEligible;
73
+ }
74
+
75
+ //#endregion
76
+ //#region src/filtering/domain-filter.ts
77
+ const log$1 = createLogger("[PingOps DomainFilter]");
78
+ /**
79
+ * Extracts domain from a URL
80
+ */
81
+ function extractDomain(url) {
82
+ try {
83
+ const domain = new URL(url).hostname;
84
+ log$1.debug("Extracted domain from URL", {
85
+ url,
86
+ domain
87
+ });
88
+ return domain;
89
+ } catch {
90
+ const match = url.match(/^(?:https?:\/\/)?([^/]+)/);
91
+ const domain = match ? match[1] : "";
92
+ log$1.debug("Extracted domain from URL (fallback)", {
93
+ url,
94
+ domain
95
+ });
96
+ return domain;
97
+ }
98
+ }
99
+ /**
100
+ * Checks if a domain matches a rule (exact or suffix match)
101
+ */
102
+ function domainMatches(domain, ruleDomain) {
103
+ if (domain === ruleDomain) {
104
+ log$1.debug("Domain exact match", {
105
+ domain,
106
+ ruleDomain
107
+ });
108
+ return true;
109
+ }
110
+ if (ruleDomain.startsWith(".")) {
111
+ const matches = domain.endsWith(ruleDomain) || domain === ruleDomain.slice(1);
112
+ log$1.debug("Domain suffix match check", {
113
+ domain,
114
+ ruleDomain,
115
+ matches
116
+ });
117
+ return matches;
118
+ }
119
+ log$1.debug("Domain does not match", {
120
+ domain,
121
+ ruleDomain
122
+ });
123
+ return false;
124
+ }
125
+ /**
126
+ * Checks if a path matches any of the allowed paths (prefix match)
127
+ */
128
+ function pathMatches(path, allowedPaths) {
129
+ if (!allowedPaths || allowedPaths.length === 0) {
130
+ log$1.debug("No path restrictions, all paths match", { path });
131
+ return true;
132
+ }
133
+ const matches = allowedPaths.some((allowedPath) => path.startsWith(allowedPath));
134
+ log$1.debug("Path match check", {
135
+ path,
136
+ allowedPaths,
137
+ matches
138
+ });
139
+ return matches;
140
+ }
141
+ /**
142
+ * Determines if a span should be captured based on domain rules
143
+ */
144
+ function shouldCaptureSpan(url, domainAllowList, domainDenyList) {
145
+ log$1.debug("Checking domain filter rules", {
146
+ url,
147
+ hasAllowList: !!domainAllowList && domainAllowList.length > 0,
148
+ hasDenyList: !!domainDenyList && domainDenyList.length > 0,
149
+ allowListCount: domainAllowList?.length || 0,
150
+ denyListCount: domainDenyList?.length || 0
151
+ });
152
+ const domain = extractDomain(url);
153
+ let path = "/";
154
+ try {
155
+ path = new URL(url).pathname;
156
+ } catch {
157
+ const pathMatch = url.match(/^(?:https?:\/\/)?[^/]+(\/.*)?$/);
158
+ path = pathMatch && pathMatch[1] ? pathMatch[1] : "/";
159
+ }
160
+ log$1.debug("Extracted domain and path", {
161
+ url,
162
+ domain,
163
+ path
164
+ });
165
+ if (domainDenyList) {
166
+ for (const rule of domainDenyList) if (domainMatches(domain, rule.domain)) {
167
+ log$1.info("Domain denied by deny list", {
168
+ domain,
169
+ ruleDomain: rule.domain,
170
+ url
171
+ });
172
+ return false;
173
+ }
174
+ log$1.debug("Domain passed deny list check", { domain });
175
+ }
176
+ if (!domainAllowList || domainAllowList.length === 0) {
177
+ log$1.debug("No allow list configured, capturing span", {
178
+ domain,
179
+ url
180
+ });
181
+ return true;
182
+ }
183
+ for (const rule of domainAllowList) if (domainMatches(domain, rule.domain)) if (rule.paths && rule.paths.length > 0) if (pathMatches(path, rule.paths)) {
184
+ log$1.info("Domain and path allowed by allow list", {
185
+ domain,
186
+ ruleDomain: rule.domain,
187
+ path,
188
+ allowedPaths: rule.paths,
189
+ url
190
+ });
191
+ return true;
192
+ } else log$1.debug("Domain allowed but path not matched", {
193
+ domain,
194
+ ruleDomain: rule.domain,
195
+ path,
196
+ allowedPaths: rule.paths
197
+ });
198
+ else {
199
+ log$1.info("Domain allowed by allow list", {
200
+ domain,
201
+ ruleDomain: rule.domain,
202
+ url
203
+ });
204
+ return true;
205
+ }
206
+ log$1.info("Domain not in allow list, filtering out", {
207
+ domain,
208
+ url
209
+ });
210
+ return false;
211
+ }
212
+
213
+ //#endregion
214
+ //#region src/filtering/sensitive-headers.ts
215
+ /**
216
+ * Sensitive header patterns and redaction configuration
217
+ */
218
+ /**
219
+ * Default patterns for sensitive headers that should be redacted
220
+ * These are matched case-insensitively
221
+ */
222
+ const DEFAULT_SENSITIVE_HEADER_PATTERNS = [
223
+ "authorization",
224
+ "www-authenticate",
225
+ "proxy-authenticate",
226
+ "proxy-authorization",
227
+ "x-auth-token",
228
+ "x-api-key",
229
+ "x-api-token",
230
+ "x-access-token",
231
+ "x-auth-user",
232
+ "x-auth-password",
233
+ "x-csrf-token",
234
+ "x-xsrf-token",
235
+ "api-key",
236
+ "apikey",
237
+ "api_key",
238
+ "access-key",
239
+ "accesskey",
240
+ "access_key",
241
+ "secret-key",
242
+ "secretkey",
243
+ "secret_key",
244
+ "private-key",
245
+ "privatekey",
246
+ "private_key",
247
+ "cookie",
248
+ "set-cookie",
249
+ "session-id",
250
+ "sessionid",
251
+ "session_id",
252
+ "session-token",
253
+ "sessiontoken",
254
+ "session_token",
255
+ "oauth-token",
256
+ "oauth_token",
257
+ "oauth2-token",
258
+ "oauth2_token",
259
+ "bearer",
260
+ "x-amz-security-token",
261
+ "x-amz-signature",
262
+ "x-aws-access-key",
263
+ "x-aws-secret-key",
264
+ "x-aws-session-token",
265
+ "x-password",
266
+ "x-secret",
267
+ "x-token",
268
+ "x-jwt",
269
+ "x-jwt-token",
270
+ "x-refresh-token",
271
+ "x-client-secret",
272
+ "x-client-id",
273
+ "x-user-token",
274
+ "x-service-key"
275
+ ];
276
+ /**
277
+ * Redaction strategies for sensitive header values
278
+ */
279
+ let HeaderRedactionStrategy = /* @__PURE__ */ function(HeaderRedactionStrategy$1) {
280
+ /**
281
+ * Replace the entire value with a fixed redaction string
282
+ */
283
+ HeaderRedactionStrategy$1["REPLACE"] = "replace";
284
+ /**
285
+ * Show only the first N characters, redact the rest
286
+ */
287
+ HeaderRedactionStrategy$1["PARTIAL"] = "partial";
288
+ /**
289
+ * Show only the last N characters, redact the rest
290
+ */
291
+ HeaderRedactionStrategy$1["PARTIAL_END"] = "partial_end";
292
+ /**
293
+ * Remove the header entirely (same as deny list)
294
+ */
295
+ HeaderRedactionStrategy$1["REMOVE"] = "remove";
296
+ return HeaderRedactionStrategy$1;
297
+ }({});
298
+ /**
299
+ * Default redaction configuration
300
+ */
301
+ const DEFAULT_REDACTION_CONFIG = {
302
+ sensitivePatterns: DEFAULT_SENSITIVE_HEADER_PATTERNS,
303
+ strategy: HeaderRedactionStrategy.REPLACE,
304
+ redactionString: "[REDACTED]",
305
+ visibleChars: 4,
306
+ enabled: true
307
+ };
308
+ /**
309
+ * Checks if a header name matches any sensitive pattern
310
+ * Uses case-insensitive matching with exact match, prefix/suffix, and substring matching
311
+ *
312
+ * @param headerName - The header name to check
313
+ * @param patterns - Array of patterns to match against (defaults to DEFAULT_SENSITIVE_HEADER_PATTERNS)
314
+ * @returns true if the header matches any sensitive pattern
315
+ */
316
+ function isSensitiveHeader(headerName, patterns = DEFAULT_SENSITIVE_HEADER_PATTERNS) {
317
+ if (!headerName || typeof headerName !== "string") return false;
318
+ if (!patterns || patterns.length === 0) return false;
319
+ const normalizedName = headerName.toLowerCase().trim();
320
+ if (normalizedName.length === 0) return false;
321
+ return patterns.some((pattern) => {
322
+ if (!pattern || typeof pattern !== "string") return false;
323
+ const normalizedPattern = pattern.toLowerCase().trim();
324
+ if (normalizedPattern.length === 0) return false;
325
+ if (normalizedName === normalizedPattern) return true;
326
+ if (normalizedName.includes(normalizedPattern)) return true;
327
+ if (normalizedPattern.includes(normalizedName)) return true;
328
+ return false;
329
+ });
330
+ }
331
+ /**
332
+ * Redacts a header value based on the configuration
333
+ */
334
+ function redactHeaderValue(value, config) {
335
+ if (value === void 0 || value === null) return value;
336
+ if (Array.isArray(value)) return value.map((v) => redactSingleValue(v, config));
337
+ return redactSingleValue(value, config);
338
+ }
339
+ /**
340
+ * Redacts a single string value based on the configured strategy
341
+ *
342
+ * @param value - The value to redact
343
+ * @param config - Redaction configuration
344
+ * @returns Redacted value
345
+ */
346
+ function redactSingleValue(value, config) {
347
+ if (!value || typeof value !== "string") return value;
348
+ const visibleChars = Math.max(0, Math.floor(config.visibleChars || 0));
349
+ const trimmedValue = value.trim();
350
+ if (trimmedValue.length === 0) return config.redactionString;
351
+ switch (config.strategy) {
352
+ case HeaderRedactionStrategy.REPLACE: return config.redactionString;
353
+ case HeaderRedactionStrategy.PARTIAL:
354
+ if (trimmedValue.length <= visibleChars) return config.redactionString;
355
+ return trimmedValue.substring(0, visibleChars) + config.redactionString;
356
+ case HeaderRedactionStrategy.PARTIAL_END:
357
+ if (trimmedValue.length <= visibleChars) return config.redactionString;
358
+ return config.redactionString + trimmedValue.substring(trimmedValue.length - visibleChars);
359
+ case HeaderRedactionStrategy.REMOVE: return config.redactionString;
360
+ default: return config.redactionString;
361
+ }
362
+ }
363
+
364
+ //#endregion
365
+ //#region src/filtering/header-filter.ts
366
+ /**
367
+ * Header filtering logic - applies allow/deny list rules and redaction
368
+ */
369
+ const log = createLogger("[PingOps HeaderFilter]");
370
+ /**
371
+ * Normalizes header name to lowercase for case-insensitive matching
372
+ */
373
+ function normalizeHeaderName(name) {
374
+ return name.toLowerCase();
375
+ }
376
+ /**
377
+ * Merges redaction config with defaults
378
+ */
379
+ function mergeRedactionConfig(config) {
380
+ if (!config) return DEFAULT_REDACTION_CONFIG;
381
+ if (config.enabled === false) return {
382
+ ...DEFAULT_REDACTION_CONFIG,
383
+ enabled: false
384
+ };
385
+ return {
386
+ sensitivePatterns: config.sensitivePatterns ?? DEFAULT_REDACTION_CONFIG.sensitivePatterns,
387
+ strategy: config.strategy ?? DEFAULT_REDACTION_CONFIG.strategy,
388
+ redactionString: config.redactionString ?? DEFAULT_REDACTION_CONFIG.redactionString,
389
+ visibleChars: config.visibleChars ?? DEFAULT_REDACTION_CONFIG.visibleChars,
390
+ enabled: config.enabled ?? DEFAULT_REDACTION_CONFIG.enabled
391
+ };
392
+ }
393
+ /**
394
+ * Filters headers based on allow/deny lists and applies redaction to sensitive headers
395
+ * - Deny list always wins (if header is in deny list, exclude it)
396
+ * - Allow list filters included headers (if specified, only include these)
397
+ * - Sensitive headers are redacted after filtering (if redaction is enabled)
398
+ * - Case-insensitive matching
399
+ *
400
+ * @param headers - Headers to filter
401
+ * @param headersAllowList - Optional allow list of header names to include
402
+ * @param headersDenyList - Optional deny list of header names to exclude
403
+ * @param redactionConfig - Optional configuration for header value redaction
404
+ * @returns Filtered and redacted headers
405
+ */
406
+ function filterHeaders(headers, headersAllowList, headersDenyList, redactionConfig) {
407
+ const originalCount = Object.keys(headers).length;
408
+ const redaction = mergeRedactionConfig(redactionConfig);
409
+ log.debug("Filtering headers", {
410
+ originalHeaderCount: originalCount,
411
+ hasAllowList: !!headersAllowList && headersAllowList.length > 0,
412
+ hasDenyList: !!headersDenyList && headersDenyList.length > 0,
413
+ allowListCount: headersAllowList?.length || 0,
414
+ denyListCount: headersDenyList?.length || 0,
415
+ redactionEnabled: redaction.enabled,
416
+ redactionStrategy: redaction.strategy
417
+ });
418
+ const normalizedDenyList = headersDenyList?.map(normalizeHeaderName) ?? [];
419
+ const normalizedAllowList = headersAllowList?.map(normalizeHeaderName) ?? [];
420
+ const filtered = {};
421
+ const deniedHeaders = [];
422
+ const excludedHeaders = [];
423
+ const redactedHeaders = [];
424
+ for (const [name, value] of Object.entries(headers)) {
425
+ const normalizedName = normalizeHeaderName(name);
426
+ if (normalizedDenyList.includes(normalizedName)) {
427
+ deniedHeaders.push(name);
428
+ log.debug("Header denied by deny list", { headerName: name });
429
+ continue;
430
+ }
431
+ if (normalizedAllowList.length > 0) {
432
+ if (!normalizedAllowList.includes(normalizedName)) {
433
+ excludedHeaders.push(name);
434
+ log.debug("Header excluded (not in allow list)", { headerName: name });
435
+ continue;
436
+ }
437
+ }
438
+ let finalValue = value;
439
+ if (redaction.enabled) try {
440
+ if (isSensitiveHeader(name, redaction.sensitivePatterns)) {
441
+ if (redaction.strategy === HeaderRedactionStrategy.REMOVE) {
442
+ log.debug("Header removed by redaction strategy", { headerName: name });
443
+ continue;
444
+ }
445
+ finalValue = redactHeaderValue(value, redaction);
446
+ redactedHeaders.push(name);
447
+ log.debug("Header value redacted", {
448
+ headerName: name,
449
+ strategy: redaction.strategy
450
+ });
451
+ }
452
+ } catch (error) {
453
+ log.warn("Error redacting header value", {
454
+ headerName: name,
455
+ error: error instanceof Error ? error.message : String(error)
456
+ });
457
+ finalValue = value;
458
+ }
459
+ filtered[name] = finalValue;
460
+ }
461
+ const filteredCount = Object.keys(filtered).length;
462
+ log.info("Header filtering complete", {
463
+ originalCount,
464
+ filteredCount,
465
+ deniedCount: deniedHeaders.length,
466
+ excludedCount: excludedHeaders.length,
467
+ redactedCount: redactedHeaders.length,
468
+ deniedHeaders: deniedHeaders.length > 0 ? deniedHeaders : void 0,
469
+ excludedHeaders: excludedHeaders.length > 0 ? excludedHeaders : void 0,
470
+ redactedHeaders: redactedHeaders.length > 0 ? redactedHeaders : void 0
471
+ });
472
+ return filtered;
473
+ }
474
+ /**
475
+ * Extracts and normalizes headers from OpenTelemetry span attributes
476
+ *
477
+ * Handles two formats:
478
+ * 1. Flat array format (e.g., 'http.request.header.0', 'http.request.header.1')
479
+ * - 'http.request.header.0': 'Content-Type'
480
+ * - 'http.request.header.1': 'application/json'
481
+ * 2. Direct key-value format (e.g., 'http.request.header.date', 'http.request.header.content-type')
482
+ * - 'http.request.header.date': 'Mon, 12 Jan 2026 20:22:38 GMT'
483
+ * - 'http.request.header.content-type': 'application/json'
484
+ *
485
+ * This function converts them to:
486
+ * - { 'Content-Type': 'application/json', 'date': 'Mon, 12 Jan 2026 20:22:38 GMT' }
487
+ */
488
+ function extractHeadersFromAttributes(attributes, headerPrefix) {
489
+ const headerMap = {};
490
+ const headerKeys = [];
491
+ const directKeyValueHeaders = [];
492
+ const prefixPattern = `${headerPrefix}.`;
493
+ const numericPattern = /* @__PURE__ */ new RegExp(`^${headerPrefix.replace(/\./g, "\\.")}\\.(\\d+)$`);
494
+ for (const key in attributes) if (key.startsWith(prefixPattern) && key !== headerPrefix) {
495
+ const numericMatch = key.match(numericPattern);
496
+ if (numericMatch) {
497
+ const index = parseInt(numericMatch[1], 10);
498
+ headerKeys.push(index);
499
+ } else {
500
+ const headerName = key.substring(prefixPattern.length);
501
+ if (headerName.length > 0) directKeyValueHeaders.push({
502
+ key,
503
+ headerName
504
+ });
505
+ }
506
+ }
507
+ if (headerKeys.length > 0) {
508
+ headerKeys.sort((a, b) => a - b);
509
+ for (let i = 0; i < headerKeys.length; i += 2) {
510
+ const nameIndex = headerKeys[i];
511
+ const valueIndex = headerKeys[i + 1];
512
+ if (valueIndex !== void 0) {
513
+ const nameKey = `${headerPrefix}.${nameIndex}`;
514
+ const valueKey = `${headerPrefix}.${valueIndex}`;
515
+ const headerName = attributes[nameKey];
516
+ const headerValue = attributes[valueKey];
517
+ if (headerName && headerValue !== void 0) {
518
+ const normalizedName = headerName.toLowerCase();
519
+ const existingKey = Object.keys(headerMap).find((k) => k.toLowerCase() === normalizedName);
520
+ if (existingKey) {
521
+ const existing = headerMap[existingKey];
522
+ headerMap[existingKey] = Array.isArray(existing) ? [...existing, headerValue] : [existing, headerValue];
523
+ } else headerMap[headerName] = headerValue;
524
+ }
525
+ }
526
+ }
527
+ }
528
+ if (directKeyValueHeaders.length > 0) for (const { key, headerName } of directKeyValueHeaders) {
529
+ const headerValue = attributes[key];
530
+ if (headerValue !== void 0 && headerValue !== null) {
531
+ const stringValue = typeof headerValue === "string" ? headerValue : String(headerValue);
532
+ const normalizedName = headerName.toLowerCase();
533
+ const existingKey = Object.keys(headerMap).find((k) => k.toLowerCase() === normalizedName);
534
+ if (existingKey) {
535
+ const existing = headerMap[existingKey];
536
+ headerMap[existingKey] = Array.isArray(existing) ? [...existing, stringValue] : [existing, stringValue];
537
+ } else headerMap[headerName] = stringValue;
538
+ }
539
+ }
540
+ return Object.keys(headerMap).length > 0 ? headerMap : null;
541
+ }
542
+ /**
543
+ * Type guard to check if value is a Headers-like object
544
+ */
545
+ function isHeadersLike(headers) {
546
+ return typeof headers === "object" && headers !== null && "entries" in headers && typeof headers.entries === "function";
547
+ }
548
+ /**
549
+ * Normalizes headers from various sources into a proper key-value object
550
+ */
551
+ function normalizeHeaders(headers) {
552
+ const result = {};
553
+ if (!headers) return result;
554
+ try {
555
+ if (isHeadersLike(headers)) {
556
+ for (const [key, value] of headers.entries()) if (result[key]) {
557
+ const existing = result[key];
558
+ result[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
559
+ } else result[key] = value;
560
+ return result;
561
+ }
562
+ if (typeof headers === "object" && !Array.isArray(headers)) {
563
+ for (const [key, value] of Object.entries(headers)) if (!/^\d+$/.test(key)) result[key] = value;
564
+ return result;
565
+ }
566
+ if (Array.isArray(headers)) {
567
+ for (let i = 0; i < headers.length; i += 2) if (i + 1 < headers.length) {
568
+ const key = String(headers[i]);
569
+ result[key] = headers[i + 1];
570
+ }
571
+ return result;
572
+ }
573
+ } catch {}
574
+ return result;
575
+ }
576
+
577
+ //#endregion
578
+ //#region src/utils/span-extractor.ts
579
+ /**
580
+ * Extracts domain from URL
581
+ */
582
+ function extractDomainFromUrl(url) {
583
+ try {
584
+ return new URL(url).hostname;
585
+ } catch {
586
+ const match = url.match(/^(?:https?:\/\/)?([^/]+)/);
587
+ return match ? match[1] : "";
588
+ }
589
+ }
590
+ /**
591
+ * Gets domain rule configuration for a given URL
592
+ */
593
+ function getDomainRule(url, domainAllowList) {
594
+ if (!domainAllowList) return;
595
+ const domain = extractDomainFromUrl(url);
596
+ for (const rule of domainAllowList) if (domain === rule.domain || domain.endsWith(`.${rule.domain}`) || domain === rule.domain.slice(1)) return rule;
597
+ }
598
+ /**
599
+ * Determines if body should be captured based on priority:
600
+ * domain rule > global config > default (false)
601
+ */
602
+ function shouldCaptureBody(domainRule, globalConfig, bodyType) {
603
+ if (domainRule) {
604
+ const domainValue = bodyType === "request" ? domainRule.captureRequestBody : domainRule.captureResponseBody;
605
+ if (domainValue !== void 0) return domainValue;
606
+ }
607
+ if (globalConfig !== void 0) return globalConfig;
608
+ return false;
609
+ }
610
+ /**
611
+ * Extracts structured payload from a span
612
+ */
613
+ function extractSpanPayload(span, domainAllowList, globalHeadersAllowList, globalHeadersDenyList, globalCaptureRequestBody, globalCaptureResponseBody, headerRedaction) {
614
+ const attributes = span.attributes;
615
+ const url = attributes["http.url"] || attributes["url.full"];
616
+ const domainRule = url ? getDomainRule(url, domainAllowList) : void 0;
617
+ const headersAllowList = domainRule?.headersAllowList ?? globalHeadersAllowList;
618
+ const headersDenyList = domainRule?.headersDenyList ?? globalHeadersDenyList;
619
+ const shouldCaptureReqBody = shouldCaptureBody(domainRule, globalCaptureRequestBody, "request");
620
+ const shouldCaptureRespBody = shouldCaptureBody(domainRule, globalCaptureResponseBody, "response");
621
+ let requestHeaders = {};
622
+ let responseHeaders = {};
623
+ const flatRequestHeaders = extractHeadersFromAttributes(attributes, "http.request.header");
624
+ const flatResponseHeaders = extractHeadersFromAttributes(attributes, "http.response.header");
625
+ const httpRequestHeadersValue = attributes["http.request.header"];
626
+ const httpResponseHeadersValue = attributes["http.response.header"];
627
+ const isHeadersRecord = (value) => {
628
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((v) => typeof v === "string" || Array.isArray(v) && v.every((item) => typeof item === "string") || v === void 0);
629
+ };
630
+ if (flatRequestHeaders) requestHeaders = filterHeaders(flatRequestHeaders, headersAllowList, headersDenyList, headerRedaction);
631
+ else if (isHeadersRecord(httpRequestHeadersValue)) requestHeaders = filterHeaders(httpRequestHeadersValue, headersAllowList, headersDenyList, headerRedaction);
632
+ if (flatResponseHeaders) responseHeaders = filterHeaders(flatResponseHeaders, headersAllowList, headersDenyList, headerRedaction);
633
+ else if (isHeadersRecord(httpResponseHeadersValue)) responseHeaders = filterHeaders(httpResponseHeadersValue, headersAllowList, headersDenyList, headerRedaction);
634
+ const extractedAttributes = { ...attributes };
635
+ for (const key in extractedAttributes) if (key.startsWith("http.request.header.") && key !== "http.request.header" || key.startsWith("http.response.header.") && key !== "http.response.header") delete extractedAttributes[key];
636
+ if (Object.keys(requestHeaders).length > 0) extractedAttributes["http.request.header"] = requestHeaders;
637
+ if (Object.keys(responseHeaders).length > 0) extractedAttributes["http.response.header"] = responseHeaders;
638
+ if (!shouldCaptureReqBody) delete extractedAttributes["http.request.body"];
639
+ if (!shouldCaptureRespBody) delete extractedAttributes["http.response.body"];
640
+ const spanContext = span.spanContext();
641
+ const parentSpanId = "parentSpanId" in span ? span.parentSpanId : void 0;
642
+ return {
643
+ traceId: spanContext.traceId,
644
+ spanId: spanContext.spanId,
645
+ parentSpanId,
646
+ name: span.name,
647
+ kind: span.kind.toString(),
648
+ startTime: (/* @__PURE__ */ new Date(span.startTime[0] * 1e3 + span.startTime[1] / 1e6)).toISOString(),
649
+ endTime: (/* @__PURE__ */ new Date(span.endTime[0] * 1e3 + span.endTime[1] / 1e6)).toISOString(),
650
+ duration: (span.endTime[0] - span.startTime[0]) * 1e3 + (span.endTime[1] - span.startTime[1]) / 1e6,
651
+ attributes: extractedAttributes,
652
+ status: {
653
+ code: span.status.code.toString(),
654
+ message: span.status.message
655
+ }
656
+ };
657
+ }
658
+
659
+ //#endregion
660
+ //#region src/context-keys.ts
661
+ /**
662
+ * OpenTelemetry context keys for PingOps
663
+ */
664
+ /**
665
+ * Context key for enabling HTTP instrumentation.
666
+ * When set to true, HTTP requests will be automatically instrumented.
667
+ * This allows wrapHttp to control which HTTP calls are captured.
668
+ */
669
+ const PINGOPS_HTTP_ENABLED = (0, _opentelemetry_api.createContextKey)("pingops-http-enabled");
670
+ /**
671
+ * Context key for user ID attribute.
672
+ * Used to propagate user identifier to all spans in the context.
673
+ */
674
+ const PINGOPS_USER_ID = (0, _opentelemetry_api.createContextKey)("pingops-user-id");
675
+ /**
676
+ * Context key for session ID attribute.
677
+ * Used to propagate session identifier to all spans in the context.
678
+ */
679
+ const PINGOPS_SESSION_ID = (0, _opentelemetry_api.createContextKey)("pingops-session-id");
680
+ /**
681
+ * Context key for tags attribute.
682
+ * Used to propagate tags array to all spans in the context.
683
+ */
684
+ const PINGOPS_TAGS = (0, _opentelemetry_api.createContextKey)("pingops-tags");
685
+ /**
686
+ * Context key for metadata attribute.
687
+ * Used to propagate metadata object to all spans in the context.
688
+ */
689
+ const PINGOPS_METADATA = (0, _opentelemetry_api.createContextKey)("pingops-metadata");
690
+ /**
691
+ * Context key for capturing request body.
692
+ * When set, controls whether request bodies should be captured for HTTP spans.
693
+ * This allows wrapHttp to control body capture per-request.
694
+ */
695
+ const PINGOPS_CAPTURE_REQUEST_BODY = (0, _opentelemetry_api.createContextKey)("pingops-capture-request-body");
696
+ /**
697
+ * Context key for capturing response body.
698
+ * When set, controls whether response bodies should be captured for HTTP spans.
699
+ * This allows wrapHttp to control body capture per-request.
700
+ */
701
+ const PINGOPS_CAPTURE_RESPONSE_BODY = (0, _opentelemetry_api.createContextKey)("pingops-capture-response-body");
702
+
703
+ //#endregion
704
+ //#region src/utils/context-extractor.ts
705
+ /**
706
+ * Extracts propagated attributes from the given context and returns them
707
+ * as span attributes that can be set on a span.
708
+ *
709
+ * @param parentContext - The OpenTelemetry context to extract attributes from
710
+ * @returns Record of attribute key-value pairs to set on spans
711
+ */
712
+ function getPropagatedAttributesFromContext(parentContext) {
713
+ const attributes = {};
714
+ const userId = parentContext.getValue(PINGOPS_USER_ID);
715
+ if (userId !== void 0 && typeof userId === "string") attributes["pingops.user_id"] = userId;
716
+ const sessionId = parentContext.getValue(PINGOPS_SESSION_ID);
717
+ if (sessionId !== void 0 && typeof sessionId === "string") attributes["pingops.session_id"] = sessionId;
718
+ const tags = parentContext.getValue(PINGOPS_TAGS);
719
+ if (tags !== void 0 && Array.isArray(tags)) attributes["pingops.tags"] = tags;
720
+ const metadata = parentContext.getValue(PINGOPS_METADATA);
721
+ if (metadata !== void 0 && typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)) {
722
+ for (const [key, value] of Object.entries(metadata)) if (typeof value === "string") attributes[`pingops.metadata.${key}`] = value;
723
+ }
724
+ return attributes;
725
+ }
726
+
727
+ //#endregion
728
+ //#region src/wrap-http.ts
729
+ /**
730
+ * wrapHttp - Wraps a function to set attributes on HTTP spans created within the wrapped block.
731
+ *
732
+ * This function sets attributes (userId, sessionId, tags, metadata) in the OpenTelemetry
733
+ * context, which are automatically propagated to all spans created within the wrapped function.
734
+ *
735
+ * Instrumentation behavior:
736
+ * - If `initializePingops` was called: All HTTP requests are instrumented by default.
737
+ * `wrapHttp` only adds attributes to spans created within the wrapped block.
738
+ * - If `initializePingops` was NOT called: Only HTTP requests within `wrapHttp` blocks
739
+ * are instrumented. Requests outside `wrapHttp` are not instrumented.
740
+ */
741
+ const logger = createLogger("[PingOps wrapHttp]");
742
+ /**
743
+ * Wraps a function to set attributes on HTTP spans created within the wrapped block.
744
+ *
745
+ * This function sets attributes (userId, sessionId, tags, metadata) in the OpenTelemetry
746
+ * context, which are automatically propagated to all spans created within the wrapped function.
747
+ *
748
+ * Instrumentation behavior:
749
+ * - If `initializePingops` was called: All HTTP requests are instrumented by default.
750
+ * `wrapHttp` only adds attributes to spans created within the wrapped block.
751
+ * - If `initializePingops` was NOT called: Only HTTP requests within `wrapHttp` blocks
752
+ * are instrumented. Requests outside `wrapHttp` are not instrumented.
753
+ *
754
+ * Note: This is the low-level API. For a simpler API with automatic setup,
755
+ * use `wrapHttp` from `@pingops/sdk` instead.
756
+ *
757
+ * @param options - Options including attributes and required callbacks
758
+ * @param fn - Function to execute within the attribute context
759
+ * @returns The result of the function
760
+ */
761
+ function wrapHttp(options, fn) {
762
+ logger.debug("wrapHttp called", {
763
+ hasAttributes: !!options.attributes,
764
+ hasUserId: !!options.attributes?.userId,
765
+ hasSessionId: !!options.attributes?.sessionId,
766
+ hasTags: !!options.attributes?.tags,
767
+ hasMetadata: !!options.attributes?.metadata
768
+ });
769
+ const normalizedOptions = "checkInitialized" in options && "isGlobalInstrumentationEnabled" in options ? options : (() => {
770
+ throw new Error("wrapHttp requires checkInitialized and isGlobalInstrumentationEnabled callbacks. Use wrapHttp from @pingops/sdk for automatic setup.");
771
+ })();
772
+ const { checkInitialized, ensureInitialized } = normalizedOptions;
773
+ if (checkInitialized()) {
774
+ logger.debug("SDK already initialized, executing wrapHttp synchronously");
775
+ return executeWrapHttpWithContext(normalizedOptions, fn);
776
+ }
777
+ if (ensureInitialized) {
778
+ logger.debug("SDK not initialized, using provided ensureInitialized callback");
779
+ return ensureInitialized().then(() => {
780
+ logger.debug("SDK initialized, executing wrapHttp");
781
+ return executeWrapHttpWithContext(normalizedOptions, fn);
782
+ }).catch((error) => {
783
+ logger.error("Failed to initialize SDK for wrapHttp", { error: error instanceof Error ? error.message : String(error) });
784
+ throw error;
785
+ });
786
+ }
787
+ logger.debug("SDK not initialized and no ensureInitialized callback provided, executing wrapHttp");
788
+ return executeWrapHttpWithContext(normalizedOptions, fn);
789
+ }
790
+ function executeWrapHttpWithContext(options, fn) {
791
+ const { attributes, isGlobalInstrumentationEnabled } = options;
792
+ const globalInstrumentationEnabled = isGlobalInstrumentationEnabled();
793
+ logger.debug("Executing wrapHttp context", {
794
+ hasAttributes: !!attributes,
795
+ globalInstrumentationEnabled
796
+ });
797
+ let contextWithAttributes = _opentelemetry_api.context.active();
798
+ if (!globalInstrumentationEnabled) contextWithAttributes = contextWithAttributes.setValue(PINGOPS_HTTP_ENABLED, true);
799
+ if (attributes) {
800
+ if (attributes.userId !== void 0) contextWithAttributes = contextWithAttributes.setValue(PINGOPS_USER_ID, attributes.userId);
801
+ if (attributes.sessionId !== void 0) contextWithAttributes = contextWithAttributes.setValue(PINGOPS_SESSION_ID, attributes.sessionId);
802
+ if (attributes.tags !== void 0) contextWithAttributes = contextWithAttributes.setValue(PINGOPS_TAGS, attributes.tags);
803
+ if (attributes.metadata !== void 0) contextWithAttributes = contextWithAttributes.setValue(PINGOPS_METADATA, attributes.metadata);
804
+ if (attributes.captureRequestBody !== void 0) contextWithAttributes = contextWithAttributes.setValue(PINGOPS_CAPTURE_REQUEST_BODY, attributes.captureRequestBody);
805
+ if (attributes.captureResponseBody !== void 0) contextWithAttributes = contextWithAttributes.setValue(PINGOPS_CAPTURE_RESPONSE_BODY, attributes.captureResponseBody);
806
+ }
807
+ return _opentelemetry_api.context.with(contextWithAttributes, () => {
808
+ try {
809
+ const result = fn();
810
+ if (result instanceof Promise) return result.catch((err) => {
811
+ logger.error("Error in wrapHttp async execution", { error: err instanceof Error ? err.message : String(err) });
812
+ throw err;
813
+ });
814
+ return result;
815
+ } catch (err) {
816
+ logger.error("Error in wrapHttp sync execution", { error: err instanceof Error ? err.message : String(err) });
817
+ throw err;
818
+ }
819
+ });
820
+ }
821
+
822
+ //#endregion
823
+ exports.DEFAULT_REDACTION_CONFIG = DEFAULT_REDACTION_CONFIG;
824
+ exports.DEFAULT_SENSITIVE_HEADER_PATTERNS = DEFAULT_SENSITIVE_HEADER_PATTERNS;
825
+ exports.HeaderRedactionStrategy = HeaderRedactionStrategy;
826
+ exports.PINGOPS_CAPTURE_REQUEST_BODY = PINGOPS_CAPTURE_REQUEST_BODY;
827
+ exports.PINGOPS_CAPTURE_RESPONSE_BODY = PINGOPS_CAPTURE_RESPONSE_BODY;
828
+ exports.PINGOPS_HTTP_ENABLED = PINGOPS_HTTP_ENABLED;
829
+ exports.PINGOPS_METADATA = PINGOPS_METADATA;
830
+ exports.PINGOPS_SESSION_ID = PINGOPS_SESSION_ID;
831
+ exports.PINGOPS_TAGS = PINGOPS_TAGS;
832
+ exports.PINGOPS_USER_ID = PINGOPS_USER_ID;
833
+ exports.createLogger = createLogger;
834
+ exports.extractHeadersFromAttributes = extractHeadersFromAttributes;
835
+ exports.extractSpanPayload = extractSpanPayload;
836
+ exports.filterHeaders = filterHeaders;
837
+ exports.getPropagatedAttributesFromContext = getPropagatedAttributesFromContext;
838
+ exports.isSensitiveHeader = isSensitiveHeader;
839
+ exports.isSpanEligible = isSpanEligible;
840
+ exports.normalizeHeaders = normalizeHeaders;
841
+ exports.redactHeaderValue = redactHeaderValue;
842
+ exports.shouldCaptureSpan = shouldCaptureSpan;
843
+ exports.wrapHttp = wrapHttp;
844
+ //# sourceMappingURL=index.cjs.map