dd-trace 5.109.0 → 5.110.0

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 (128) hide show
  1. package/ci/init.js +4 -3
  2. package/index.d.ts +66 -24
  3. package/loader-hook.mjs +88 -2
  4. package/package.json +18 -14
  5. package/packages/datadog-esbuild/index.js +26 -0
  6. package/packages/datadog-esbuild/src/utils.js +46 -3
  7. package/packages/datadog-instrumentations/src/connect.js +4 -3
  8. package/packages/datadog-instrumentations/src/fastify.js +4 -12
  9. package/packages/datadog-instrumentations/src/graphql.js +17 -6
  10. package/packages/datadog-instrumentations/src/helpers/check-require-cache.js +81 -8
  11. package/packages/datadog-instrumentations/src/helpers/instrument.js +27 -2
  12. package/packages/datadog-instrumentations/src/helpers/optional-peer-bundler.js +65 -0
  13. package/packages/datadog-instrumentations/src/helpers/register.js +6 -4
  14. package/packages/datadog-instrumentations/src/helpers/require-optional-peer.js +17 -0
  15. package/packages/datadog-instrumentations/src/helpers/rewriter/loader.mjs +18 -2
  16. package/packages/datadog-instrumentations/src/koa.js +3 -2
  17. package/packages/datadog-instrumentations/src/mariadb.js +4 -2
  18. package/packages/datadog-instrumentations/src/mocha/main.js +3 -3
  19. package/packages/datadog-instrumentations/src/next.js +10 -10
  20. package/packages/datadog-instrumentations/src/otel-sdk-trace.js +7 -6
  21. package/packages/datadog-instrumentations/src/playwright.js +1 -1
  22. package/packages/datadog-instrumentations/src/restify.js +4 -3
  23. package/packages/datadog-instrumentations/src/router.js +18 -5
  24. package/packages/datadog-instrumentations/src/selenium.js +1 -2
  25. package/packages/datadog-instrumentations/src/tedious.js +28 -0
  26. package/packages/datadog-instrumentations/src/vitest.js +264 -7
  27. package/packages/datadog-plugin-avsc/src/schema_iterator.js +2 -2
  28. package/packages/datadog-plugin-aws-sdk/src/base.js +13 -9
  29. package/packages/datadog-plugin-aws-sdk/src/services/eventbridge.js +43 -6
  30. package/packages/datadog-plugin-aws-sdk/src/services/sqs.js +83 -91
  31. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +9 -1
  32. package/packages/datadog-plugin-http/src/client.js +8 -3
  33. package/packages/datadog-plugin-http2/src/client.js +5 -2
  34. package/packages/datadog-plugin-mysql/src/index.js +7 -2
  35. package/packages/datadog-plugin-protobufjs/src/schema_iterator.js +2 -2
  36. package/packages/datadog-plugin-undici/src/index.js +5 -2
  37. package/packages/datadog-plugin-vitest/src/index.js +7 -1
  38. package/packages/datadog-webpack/index.js +17 -1
  39. package/packages/datadog-webpack/src/optional-peer-loader.js +17 -0
  40. package/packages/dd-trace/src/aiguard/sdk.js +13 -1
  41. package/packages/dd-trace/src/appsec/api_security/index.js +55 -0
  42. package/packages/dd-trace/src/appsec/api_security/sampler.js +147 -0
  43. package/packages/dd-trace/src/appsec/index.js +10 -6
  44. package/packages/dd-trace/src/appsec/reporter.js +1 -0
  45. package/packages/dd-trace/src/appsec/telemetry/api_security.js +34 -0
  46. package/packages/dd-trace/src/appsec/telemetry/index.js +26 -0
  47. package/packages/dd-trace/src/ci-visibility/requests/request.js +1 -17
  48. package/packages/dd-trace/src/config/defaults.js +22 -3
  49. package/packages/dd-trace/src/config/generated-config-types.d.ts +535 -1
  50. package/packages/dd-trace/src/config/helper.js +133 -33
  51. package/packages/dd-trace/src/config/index.js +4 -1
  52. package/packages/dd-trace/src/config/parsers.js +19 -1
  53. package/packages/dd-trace/src/config/supported-configurations.json +21 -3
  54. package/packages/dd-trace/src/debugger/devtools_client/breakpoints.js +47 -35
  55. package/packages/dd-trace/src/debugger/devtools_client/index.js +31 -66
  56. package/packages/dd-trace/src/debugger/devtools_client/probe_sampler.js +83 -0
  57. package/packages/dd-trace/src/debugger/devtools_client/state.js +1 -0
  58. package/packages/dd-trace/src/debugger/index.js +5 -0
  59. package/packages/dd-trace/src/debugger/probe_sampler.js +112 -0
  60. package/packages/dd-trace/src/debugger/probe_sampler_constants.js +19 -0
  61. package/packages/dd-trace/src/encode/agentless-ci-visibility.js +77 -26
  62. package/packages/dd-trace/src/exporters/common/request.js +1 -20
  63. package/packages/dd-trace/src/exporters/common/url.js +34 -0
  64. package/packages/dd-trace/src/exporters/span-stats/writer.js +1 -3
  65. package/packages/dd-trace/src/guardrails/index.js +8 -5
  66. package/packages/dd-trace/src/index.js +6 -4
  67. package/packages/dd-trace/src/llmobs/constants/tags.js +6 -0
  68. package/packages/dd-trace/src/llmobs/index.js +16 -5
  69. package/packages/dd-trace/src/llmobs/plugins/genai/util.js +3 -3
  70. package/packages/dd-trace/src/llmobs/plugins/langchain/index.js +5 -1
  71. package/packages/dd-trace/src/llmobs/sdk.js +5 -3
  72. package/packages/dd-trace/src/llmobs/span_processor.js +4 -0
  73. package/packages/dd-trace/src/llmobs/tagger.js +55 -0
  74. package/packages/dd-trace/src/llmobs/telemetry.js +1 -0
  75. package/packages/dd-trace/src/log/index.js +18 -20
  76. package/packages/dd-trace/src/openfeature/flagging_provider.js +2 -7
  77. package/packages/dd-trace/src/opentelemetry/bridge-span-base.js +5 -4
  78. package/packages/dd-trace/src/opentelemetry/metrics/instruments.js +2 -1
  79. package/packages/dd-trace/src/opentelemetry/metrics/periodic_metric_reader.js +4 -3
  80. package/packages/dd-trace/src/opentelemetry/metrics/time.js +19 -0
  81. package/packages/dd-trace/src/opentelemetry/otlp/common.proto +2 -2
  82. package/packages/dd-trace/src/opentelemetry/otlp/logs.proto +1 -1
  83. package/packages/dd-trace/src/opentelemetry/otlp/logs_service.proto +1 -1
  84. package/packages/dd-trace/src/opentelemetry/otlp/metrics.proto +1 -1
  85. package/packages/dd-trace/src/opentelemetry/otlp/metrics_service.proto +1 -1
  86. package/packages/dd-trace/src/opentelemetry/otlp/otlp_http_exporter_base.js +11 -4
  87. package/packages/dd-trace/src/opentelemetry/span-helpers.js +29 -13
  88. package/packages/dd-trace/src/opentelemetry/span.js +14 -3
  89. package/packages/dd-trace/src/opentelemetry/span_context.js +1 -0
  90. package/packages/dd-trace/src/opentelemetry/trace/index.js +2 -1
  91. package/packages/dd-trace/src/opentelemetry/trace/otlp_http_trace_exporter.js +3 -2
  92. package/packages/dd-trace/src/opentelemetry/trace/otlp_transformer.js +26 -13
  93. package/packages/dd-trace/src/opentracing/propagation/text_map.js +3 -3
  94. package/packages/dd-trace/src/opentracing/span_context.js +17 -0
  95. package/packages/dd-trace/src/plugin_manager.js +7 -1
  96. package/packages/dd-trace/src/plugins/ci_plugin.js +9 -1
  97. package/packages/dd-trace/src/plugins/util/http-otel-semantics.js +276 -0
  98. package/packages/dd-trace/src/plugins/util/test.js +80 -0
  99. package/packages/dd-trace/src/plugins/util/url.js +68 -1
  100. package/packages/dd-trace/src/plugins/util/web.js +19 -36
  101. package/packages/dd-trace/src/priority_sampler.js +1 -13
  102. package/packages/dd-trace/src/profiling/config.js +129 -198
  103. package/packages/dd-trace/src/profiling/constants.js +4 -7
  104. package/packages/dd-trace/src/profiling/exporter_cli.js +10 -10
  105. package/packages/dd-trace/src/profiling/exporters/agent.js +23 -16
  106. package/packages/dd-trace/src/profiling/exporters/event_serializer.js +45 -14
  107. package/packages/dd-trace/src/profiling/exporters/file.js +16 -5
  108. package/packages/dd-trace/src/profiling/oom.js +71 -0
  109. package/packages/dd-trace/src/profiling/profiler.js +35 -23
  110. package/packages/dd-trace/src/profiling/profilers/events.js +14 -7
  111. package/packages/dd-trace/src/profiling/profilers/space.js +27 -18
  112. package/packages/dd-trace/src/profiling/profilers/wall.js +35 -25
  113. package/packages/dd-trace/src/ritm.js +4 -2
  114. package/packages/dd-trace/src/serverless.js +1 -2
  115. package/packages/dd-trace/src/service-naming/schemas/util.js +36 -5
  116. package/packages/dd-trace/src/service-naming/schemas/v1/web.js +5 -3
  117. package/packages/dd-trace/src/span_format.js +2 -1
  118. package/packages/dd-trace/src/span_processor.js +6 -0
  119. package/packages/dd-trace/src/telemetry/send-data.js +12 -10
  120. package/packages/dd-trace/src/tracer.js +15 -1
  121. package/packages/dd-trace/src/util.js +14 -0
  122. package/register.js +58 -1
  123. package/vendor/dist/@apm-js-collab/code-transformer/index.js +1 -1
  124. package/vendor/dist/@datadog/sketches-js/index.js +1 -1
  125. package/vendor/dist/pprof-format/index.js +1 -1
  126. package/vendor/dist/protobufjs/index.js +1 -1
  127. package/vendor/dist/protobufjs/minimal/index.js +1 -1
  128. package/packages/dd-trace/src/appsec/api_security_sampler.js +0 -117
@@ -74,6 +74,9 @@ const EXCLUDED_META_KEYS = new Set([
74
74
  TRACE_ID_128,
75
75
  ])
76
76
 
77
+ // DD-only error tags that should not appear as attributes when OTel trace semantics are enabled.
78
+ const DD_ERROR_META_KEYS = new Set(['error.message'])
79
+
77
80
  /**
78
81
  * OtlpTraceTransformer transforms DD-formatted spans to OTLP trace JSON format.
79
82
  *
@@ -87,13 +90,17 @@ const EXCLUDED_META_KEYS = new Set([
87
90
  * @augments OtlpTransformerBase
88
91
  */
89
92
  class OtlpTraceTransformer extends OtlpTransformerBase {
93
+ #otelTraceSemanticsEnabled
94
+
90
95
  /**
91
96
  * Creates a new OtlpTraceTransformer instance.
92
97
  *
93
98
  * @param {import('@opentelemetry/api').Attributes} resourceAttributes - Resource attributes
99
+ * @param {boolean} [otelTraceSemanticsEnabled] - When true, do not emit Datadog-only attributes as span attributes
94
100
  */
95
- constructor (resourceAttributes) {
101
+ constructor (resourceAttributes, otelTraceSemanticsEnabled) {
96
102
  super(resourceAttributes, 'http/json', 'traces')
103
+ this.#otelTraceSemanticsEnabled = otelTraceSemanticsEnabled
97
104
  }
98
105
 
99
106
  /**
@@ -184,24 +191,30 @@ class OtlpTraceTransformer extends OtlpTransformerBase {
184
191
  #buildAttributes (span) {
185
192
  const attributes = []
186
193
 
187
- // Add top-level DD span fields as OTLP attributes
188
- if (span.service) {
189
- attributes.push({ key: 'service.name', value: { stringValue: span.service } })
190
- }
191
- if (span.name) {
192
- attributes.push({ key: 'operation.name', value: { stringValue: span.name } })
193
- }
194
- if (span.resource) {
195
- attributes.push({ key: 'resource.name', value: { stringValue: span.resource } })
196
- }
197
- if (span.type) {
198
- attributes.push({ key: 'span.type', value: { stringValue: span.type } })
194
+ // Add top-level DD span fields as OTLP attributes.
195
+ // When OTel trace semantics are enabled, these Datadog-only concepts
196
+ // are not added to the span attributes so the output conforms to pure
197
+ // OpenTelemetry semantics.
198
+ if (!this.#otelTraceSemanticsEnabled) {
199
+ if (span.service) {
200
+ attributes.push({ key: 'service.name', value: { stringValue: span.service } })
201
+ }
202
+ if (span.name) {
203
+ attributes.push({ key: 'operation.name', value: { stringValue: span.name } })
204
+ }
205
+ if (span.resource) {
206
+ attributes.push({ key: 'resource.name', value: { stringValue: span.resource } })
207
+ }
208
+ if (span.type) {
209
+ attributes.push({ key: 'span.type', value: { stringValue: span.type } })
210
+ }
199
211
  }
200
212
 
201
213
  // Add meta string tags, skipping keys that map to dedicated OTLP fields
202
214
  if (span.meta) {
203
215
  for (const [key, value] of Object.entries(span.meta)) {
204
216
  if (EXCLUDED_META_KEYS.has(key)) continue
217
+ if (this.#otelTraceSemanticsEnabled && DD_ERROR_META_KEYS.has(key)) continue
205
218
  attributes.push({ key, value: { stringValue: value } })
206
219
  }
207
220
  }
@@ -371,8 +371,8 @@ class TextMapPropagator {
371
371
 
372
372
  _hasTraceIdConflict (w3cSpanContext, firstSpanContext) {
373
373
  return w3cSpanContext !== undefined &&
374
- firstSpanContext.toTraceId(true) === w3cSpanContext.toTraceId(true) &&
375
- firstSpanContext.toSpanId() !== w3cSpanContext.toSpanId()
374
+ firstSpanContext.toTraceId(true) === w3cSpanContext.toTraceId(true) &&
375
+ firstSpanContext.toSpanId() !== w3cSpanContext.toSpanId()
376
376
  }
377
377
 
378
378
  _hasParentIdInTags (spanContext) {
@@ -875,7 +875,7 @@ class TextMapPropagator {
875
875
  (!tracestateSamplingPriority || tracestateSamplingPriority >= 0)) {
876
876
  samplingPriority = AUTO_REJECT
877
877
  } else if (traceparentSampled === 1 &&
878
- (!tracestateSamplingPriority || tracestateSamplingPriority < 0)) {
878
+ (!tracestateSamplingPriority || tracestateSamplingPriority < 0)) {
879
879
  samplingPriority = AUTO_KEEP
880
880
  }
881
881
  }
@@ -63,6 +63,7 @@ class DatadogSpanContext {
63
63
  }
64
64
 
65
65
  toTraceparent () {
66
+ this._ensureSamplingPriority()
66
67
  const flags = this._sampling.priority >= AUTO_KEEP ? '01' : '00'
67
68
  const traceId = this.toTraceId(true)
68
69
  const spanId = this.toSpanId(true)
@@ -70,6 +71,22 @@ class DatadogSpanContext {
70
71
  return `${version}-${traceId}-${spanId}-${flags}`
71
72
  }
72
73
 
74
+ /**
75
+ * Materialize the lazy priority-sampling decision for this trace, the same
76
+ * way {@link DatadogTracer#inject} does before propagation. The auto priority
77
+ * is otherwise computed at flush time, so the W3C sampled flag reads "drop"
78
+ * for a freshly started, not-yet-flushed span — see
79
+ * https://github.com/DataDog/dd-trace-js/issues/2547.
80
+ *
81
+ * The root span is only used to reach the priority sampler; `this` is passed
82
+ * to `sample()` so a manual sampling tag set directly on this context is
83
+ * honored, matching what `inject(this)` would decide.
84
+ */
85
+ _ensureSamplingPriority () {
86
+ if (this._sampling.priority !== undefined) return
87
+ this._trace.started[0]?._prioritySampler?.sample(this)
88
+ }
89
+
73
90
  /**
74
91
  * Set a tag value.
75
92
  * @param {string} key - Tag key
@@ -60,7 +60,11 @@ function maybeEnable (Plugin) {
60
60
 
61
61
  function getEnabled (Plugin) {
62
62
  const envName = `DD_TRACE_${Plugin.id.toUpperCase()}_ENABLED`
63
- return getValueFromEnvSources(normalizePluginEnvName(envName))
63
+ // skipDefault: only an explicitly configured value should drive enablement here. A registered
64
+ // default of `false` (e.g. an experimental plugin like `nats`) must not be read as an explicit
65
+ // "disabled via configuration option" — that path both logs a misleading line and nulls the
66
+ // plugin class, bypassing the experimental opt-in handled by `loadPlugin`.
67
+ return getValueFromEnvSources(normalizePluginEnvName(envName), true)
64
68
  }
65
69
 
66
70
  // TODO this must always be a singleton.
@@ -164,6 +168,7 @@ module.exports = class PluginManager {
164
168
  clientIpEnabled,
165
169
  clientIpHeader,
166
170
  DD_TRACE_MEMCACHED_COMMAND_ENABLED,
171
+ DD_TRACE_OTEL_SEMANTICS_ENABLED,
167
172
  DD_TEST_SESSION_NAME,
168
173
  DD_AGENTLESS_LOG_SUBMISSION_ENABLED,
169
174
  isTestDynamicInstrumentationEnabled,
@@ -181,6 +186,7 @@ module.exports = class PluginManager {
181
186
  dbmPropagationMode,
182
187
  dsmEnabled,
183
188
  DD_TRACE_MEMCACHED_COMMAND_ENABLED,
189
+ DD_TRACE_OTEL_SEMANTICS_ENABLED,
184
190
  site,
185
191
  url,
186
192
  headers: headerTags || [],
@@ -31,6 +31,7 @@ const Plugin = require('./plugin')
31
31
  const { getRepositoryRoot } = require('./util/git')
32
32
  const {
33
33
  getTestEnvironmentMetadata,
34
+ getTestLevelsMetadataTags,
34
35
  getTestSessionName,
35
36
  getCodeOwnersFileEntries,
36
37
  getTestParentSpan,
@@ -44,6 +45,7 @@ const {
44
45
  TEST_MODULE_ID,
45
46
  TEST_SESSION_ID,
46
47
  TEST_COMMAND,
48
+ TEST_LEVELS_METADATA,
47
49
  TEST_MODULE,
48
50
  TEST_SESSION_NAME,
49
51
  getTestSuiteCommonTags,
@@ -219,7 +221,13 @@ module.exports = class CiPlugin extends Plugin {
219
221
  this.testEnvironmentMetadata
220
222
  )
221
223
 
222
- const metadataTags = { '*': { [TEST_COMMAND]: command, [TEST_SESSION_NAME]: testSessionName } }
224
+ const metadataTags = {
225
+ [TEST_LEVELS_METADATA]: {
226
+ [TEST_COMMAND]: command,
227
+ [TEST_SESSION_NAME]: testSessionName,
228
+ ...getTestLevelsMetadataTags(this.testEnvironmentMetadata),
229
+ },
230
+ }
223
231
  // tracer might not be initialized correctly
224
232
  if (this.tracer._exporter.addMetadataTags) {
225
233
  this.tracer._exporter.addMetadataTags(metadataTags)
@@ -0,0 +1,276 @@
1
+ 'use strict'
2
+
3
+ const { extractPathFromUrl } = require('./url')
4
+
5
+ // OpenTelemetry HTTP semantic-convention attribute names, emitted in place of
6
+ // the Datadog ones when `DD_TRACE_OTEL_SEMANTICS_ENABLED` is set.
7
+ // See https://opentelemetry.io/docs/specs/semconv/http/http-spans/
8
+ const HTTP_REQUEST_METHOD = 'http.request.method'
9
+ const HTTP_RESPONSE_STATUS_CODE = 'http.response.status_code'
10
+ const URL_FULL = 'url.full'
11
+ const URL_PATH = 'url.path'
12
+ const URL_SCHEME = 'url.scheme'
13
+ const URL_QUERY = 'url.query'
14
+ const SERVER_ADDRESS = 'server.address'
15
+ const SERVER_PORT = 'server.port'
16
+ const USER_AGENT_ORIGINAL = 'user_agent.original'
17
+ const CLIENT_ADDRESS = 'client.address'
18
+ const NETWORK_PEER_ADDRESS = 'network.peer.address'
19
+ const HTTP_REQUEST_METHOD_ORIGINAL = 'http.request.method_original'
20
+
21
+ // Known HTTP methods (RFC 9110 + PATCH RFC 5789 + QUERY httpbis draft). A verb
22
+ // outside this set is reported as `_OTHER` with the raw value preserved on
23
+ // `http.request.method_original`, per the OTel HTTP semantic conventions.
24
+ const KNOWN_METHODS = new Set([
25
+ 'CONNECT', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'QUERY', 'TRACE',
26
+ ])
27
+
28
+ // Datadog HTTP meta keys replaced by OTel names — omitted when rebuilding meta.
29
+ const DD_HTTP_META_KEYS = new Set([
30
+ 'http.method', 'http.status_code', 'http.useragent', 'http.client_ip', 'http.endpoint', 'http.url', 'out.host',
31
+ ])
32
+ const NETWORK_DESTINATION_PORT = 'network.destination.port'
33
+
34
+ // IPv6 literals arrive bracketed (URL.hostname / out.host = `[::1]`); OTel
35
+ // `server.address` is the bare address.
36
+ function stripIpv6Brackets (host) {
37
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
38
+ }
39
+
40
+ /**
41
+ * @typedef {object} ServerUrlParts
42
+ * @property {string} [scheme] value for `url.scheme`
43
+ * @property {string} [address] value for `server.address`
44
+ * @property {number} [port] value for `server.port`
45
+ * @property {string} path value for `url.path`
46
+ * @property {string} [query] value for `url.query` (omitted when empty)
47
+ */
48
+
49
+ /**
50
+ * Decompose a server request URL into the OpenTelemetry `url.*` / `server.*`
51
+ * parts. Structural fields (scheme, address, port, path) are read from the raw
52
+ * URL; the query is taken from the already-obfuscated URL so the configured
53
+ * query-string obfuscation is preserved.
54
+ *
55
+ * @param {string} rawUrl full request URL (`scheme://host[:port]/path?query`)
56
+ * @param {string} obfuscatedUrl same URL with its query string obfuscated
57
+ * @returns {ServerUrlParts}
58
+ */
59
+ function decomposeServerUrl (rawUrl, obfuscatedUrl) {
60
+ let scheme
61
+ let address
62
+ let port
63
+ let path
64
+
65
+ try {
66
+ const parsed = new URL(rawUrl)
67
+ scheme = parsed.protocol.length > 1 ? parsed.protocol.slice(0, -1) : undefined
68
+ // `extractURL` builds `http://undefined/...` when the Host header is absent; skip that.
69
+ const hostname = parsed.hostname
70
+ if (hostname && hostname !== 'undefined') {
71
+ address = stripIpv6Brackets(hostname)
72
+ }
73
+ if (parsed.port) {
74
+ const parsedPort = Number.parseInt(parsed.port)
75
+ if (parsedPort > 0) port = parsedPort
76
+ }
77
+ path = parsed.pathname || '/'
78
+ } catch {
79
+ // Malformed or relative URL: fall back to a best-effort path only.
80
+ path = extractPathFromUrl(rawUrl)
81
+ }
82
+
83
+ let query
84
+ const queryIndex = obfuscatedUrl.indexOf('?')
85
+ if (queryIndex !== -1) {
86
+ const rawQuery = obfuscatedUrl.slice(queryIndex + 1)
87
+ if (rawQuery) query = rawQuery
88
+ }
89
+
90
+ return { scheme, address, port, path, query }
91
+ }
92
+
93
+ const ERROR_TYPE = 'error.type'
94
+
95
+ function toHttpScheme (scheme) {
96
+ if (scheme === 'ws') return 'http'
97
+ if (scheme === 'wss') return 'https'
98
+ return scheme
99
+ }
100
+
101
+ /**
102
+ * Redact any userinfo embedded in a URL's authority, since `url.full` must not
103
+ * leak credentials: `user:pass@host` -> `REDACTED:REDACTED@host`, `user@host` ->
104
+ * `REDACTED@host`. Returns the URL unchanged when no userinfo is present.
105
+ *
106
+ * @param {string} url
107
+ * @returns {string}
108
+ */
109
+ function redactUrlCredentials (url) {
110
+ const schemeEnd = url.indexOf('://')
111
+ if (schemeEnd === -1) return url
112
+ const authorityStart = schemeEnd + 3
113
+
114
+ let authorityEnd = url.length
115
+ for (let i = authorityStart; i < url.length; i++) {
116
+ const char = url[i]
117
+ if (char === '/' || char === '?' || char === '#') {
118
+ authorityEnd = i
119
+ break
120
+ }
121
+ }
122
+
123
+ // userinfo runs to the LAST '@' in the authority (WHATWG); using the first
124
+ // '@' would leak the remainder, e.g. `user:p@ss@host`.
125
+ const at = url.lastIndexOf('@', authorityEnd - 1)
126
+ if (at < authorityStart) return url
127
+
128
+ const redacted = url.slice(authorityStart, at).includes(':') ? 'REDACTED:REDACTED' : 'REDACTED'
129
+ return url.slice(0, authorityStart) + redacted + url.slice(at)
130
+ }
131
+
132
+ /**
133
+ * The scheme's default port, used as the `server.port` fallback for client spans
134
+ * (the attribute is required for clients but the explicit port is absent for
135
+ * default-port requests).
136
+ *
137
+ * @param {string} [url]
138
+ * @returns {number | undefined}
139
+ */
140
+ function defaultPortForUrl (url) {
141
+ if (url === undefined) return
142
+ if (url.startsWith('https:') || url.startsWith('wss:')) return 443
143
+ if (url.startsWith('http:') || url.startsWith('ws:')) return 80
144
+ }
145
+
146
+ /**
147
+ * @typedef {object} FormattedHttpSpan
148
+ * @property {Record<string, string>} meta
149
+ * @property {Record<string, number>} metrics
150
+ * @property {number} error
151
+ */
152
+
153
+ /**
154
+ * Rewrite a formatted span's Datadog HTTP tags to OpenTelemetry HTTP
155
+ * semantic-convention names, in place. Called at serialization time (from
156
+ * `span_format`) when `DD_TRACE_OTEL_SEMANTICS_ENABLED` is set, so every HTTP
157
+ * integration is covered from one place. No-op for non-HTTP spans. The span
158
+ * keeps the Datadog tag names throughout its lifetime — only the serialized
159
+ * output is renamed — so runtime consumers (peer.service, AppSec, trace stats)
160
+ * are unaffected.
161
+ *
162
+ * @param {FormattedHttpSpan} formattedSpan
163
+ */
164
+ function applyHttpOtelSemantics (formattedSpan) {
165
+ const meta = formattedSpan.meta
166
+ const metrics = formattedSpan.metrics
167
+ const method = meta['http.method']
168
+ const url = meta['http.url']
169
+ if (method === undefined && url === undefined) return
170
+
171
+ // Rebuild meta/metrics as fresh objects that omit the renamed Datadog HTTP
172
+ // keys. Deleting them in place demotes the formatted span to V8 dictionary
173
+ // mode (~40% slower than this rebuild, measured); a fresh object keeps fast
174
+ // properties and can't leak a renamed key as `undefined` on the OTLP path.
175
+ const newMeta = {}
176
+ for (const key of Object.keys(meta)) {
177
+ if (!DD_HTTP_META_KEYS.has(key)) newMeta[key] = meta[key]
178
+ }
179
+ const newMetrics = {}
180
+ for (const key of Object.keys(metrics)) {
181
+ if (key !== NETWORK_DESTINATION_PORT) newMetrics[key] = metrics[key]
182
+ }
183
+
184
+ const kind = meta['span.kind']
185
+
186
+ if (method !== undefined) {
187
+ if (KNOWN_METHODS.has(method)) {
188
+ newMeta[HTTP_REQUEST_METHOD] = method
189
+ } else {
190
+ // Unknown verb: bucket to `_OTHER`, preserve the raw value, and use the
191
+ // literal "HTTP" in the span name (the spec forbids the URL path there).
192
+ // Known-method names are already `{method} {route}`.
193
+ newMeta[HTTP_REQUEST_METHOD] = '_OTHER'
194
+ newMeta[HTTP_REQUEST_METHOD_ORIGINAL] = method
195
+ const resource = formattedSpan.resource
196
+ if (typeof resource === 'string') {
197
+ if (resource === method) {
198
+ formattedSpan.resource = 'HTTP'
199
+ } else if (resource.startsWith(`${method} `)) {
200
+ formattedSpan.resource = `HTTP${resource.slice(method.length)}`
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ const status = meta['http.status_code']
207
+ let statusCode
208
+ if (status !== undefined) {
209
+ // OTel types http.response.status_code as an int, so emit it as a numeric
210
+ // metric (the OTLP exporter serializes meta as stringValue but metrics as
211
+ // intValue) — mirroring how server.port is handled below. Guard against a
212
+ // non-numeric status, which would otherwise write a NaN metric.
213
+ statusCode = Number.parseInt(status)
214
+ if (Number.isFinite(statusCode)) newMetrics[HTTP_RESPONSE_STATUS_CODE] = statusCode
215
+ }
216
+
217
+ const userAgent = meta['http.useragent']
218
+ if (userAgent !== undefined) newMeta[USER_AGENT_ORIGINAL] = userAgent
219
+
220
+ const clientIp = meta['http.client_ip']
221
+ if (clientIp !== undefined) newMeta[CLIENT_ADDRESS] = clientIp
222
+
223
+ // http.endpoint is Datadog-only (omitted above); it has no OTel equivalent.
224
+
225
+ if (kind === 'server') {
226
+ // FIXME: some server frameworks (e.g. Next.js — `packages/datadog-plugin-next`)
227
+ // never populate `http.url`, so the OTel `url.*` / `server.*` attributes below
228
+ // can't be derived and are omitted for those spans. This needs a fix; the
229
+ // short-term option is to set `http.url` in those integrations so they emit
230
+ // the full server attribute set.
231
+ if (url !== undefined) {
232
+ // The query in `http.url` is already obfuscated per config, so it is preserved.
233
+ const { scheme, address, port, path, query } = decomposeServerUrl(url, url)
234
+ if (path !== undefined) newMeta[URL_PATH] = path
235
+ if (scheme !== undefined) newMeta[URL_SCHEME] = toHttpScheme(scheme)
236
+ if (query !== undefined) newMeta[URL_QUERY] = query
237
+ if (address !== undefined) newMeta[SERVER_ADDRESS] = address
238
+ if (port !== undefined) newMetrics[SERVER_PORT] = port
239
+ }
240
+ } else {
241
+ if (url !== undefined) {
242
+ // url.full must not carry embedded credentials.
243
+ newMeta[URL_FULL] = redactUrlCredentials(url)
244
+ }
245
+ const outHost = meta['out.host']
246
+ if (outHost !== undefined) newMeta[SERVER_ADDRESS] = stripIpv6Brackets(outHost)
247
+ const clientPort = metrics[NETWORK_DESTINATION_PORT]
248
+ if (clientPort === undefined) {
249
+ // server.port is required for client spans; fall back to the scheme default.
250
+ const defaultPort = defaultPortForUrl(url)
251
+ if (defaultPort !== undefined) newMetrics[SERVER_PORT] = defaultPort
252
+ } else {
253
+ newMetrics[SERVER_PORT] = clientPort
254
+ }
255
+ }
256
+
257
+ // OTel error semantics for an error response (no-clobber on an exception-derived
258
+ // type): server spans are errors on 5xx only (4xx MUST be left unset per the
259
+ // spec); client spans on any status >= 400.
260
+ if (status !== undefined && newMeta[ERROR_TYPE] === undefined) {
261
+ const isError = kind === 'server' ? statusCode >= 500 : statusCode >= 400
262
+ if (isError) {
263
+ newMeta[ERROR_TYPE] = status
264
+ formattedSpan.error = 1
265
+ }
266
+ }
267
+
268
+ formattedSpan.meta = newMeta
269
+ formattedSpan.metrics = newMetrics
270
+ }
271
+
272
+ module.exports = {
273
+ NETWORK_PEER_ADDRESS, // imported by web.js (set from req.socket, not at serialization)
274
+ decomposeServerUrl, // exercised directly by the helper spec
275
+ applyHttpOtelSemantics,
276
+ }
@@ -22,17 +22,40 @@ const { SAMPLING_RULE_DECISION } = require('../../constants')
22
22
  const { AUTO_KEEP } = require('../../../../../ext/priority')
23
23
  const { version: ddTraceVersion } = require('../../../../../package.json')
24
24
  const {
25
+ CI_JOB_ID,
25
26
  GIT_BRANCH,
26
27
  GIT_COMMIT_SHA,
27
28
  GIT_REPOSITORY_URL,
28
29
  GIT_TAG,
30
+ GIT_COMMIT_AUTHOR_DATE,
29
31
  GIT_COMMIT_AUTHOR_EMAIL,
30
32
  GIT_COMMIT_AUTHOR_NAME,
33
+ GIT_COMMIT_COMMITTER_DATE,
34
+ GIT_COMMIT_COMMITTER_EMAIL,
35
+ GIT_COMMIT_COMMITTER_NAME,
31
36
  GIT_COMMIT_MESSAGE,
37
+ GIT_COMMIT_HEAD_AUTHOR_DATE,
38
+ GIT_COMMIT_HEAD_AUTHOR_EMAIL,
39
+ GIT_COMMIT_HEAD_AUTHOR_NAME,
40
+ GIT_COMMIT_HEAD_COMMITTER_DATE,
41
+ GIT_COMMIT_HEAD_COMMITTER_EMAIL,
42
+ GIT_COMMIT_HEAD_COMMITTER_NAME,
43
+ GIT_COMMIT_HEAD_MESSAGE,
32
44
  CI_WORKSPACE_PATH,
45
+ CI_PIPELINE_ID,
46
+ CI_PIPELINE_NAME,
47
+ CI_PIPELINE_NUMBER,
33
48
  CI_PIPELINE_URL,
34
49
  CI_JOB_NAME,
50
+ CI_JOB_URL,
51
+ CI_NODE_LABELS,
52
+ CI_NODE_NAME,
53
+ CI_PROVIDER_NAME,
54
+ CI_STAGE_NAME,
35
55
  GIT_COMMIT_HEAD_SHA,
56
+ GIT_PULL_REQUEST_BASE_BRANCH,
57
+ GIT_PULL_REQUEST_BASE_BRANCH_HEAD_SHA,
58
+ GIT_PULL_REQUEST_BASE_BRANCH_SHA,
36
59
  } = require('./tags')
37
60
  const { getRuntimeAndOSMetadata } = require('./env')
38
61
  const { getCIMetadata } = require('./ci')
@@ -198,6 +221,43 @@ const TEST_LEVEL_EVENT_TYPES = [
198
221
  'test_module_end',
199
222
  'test_session_end',
200
223
  ]
224
+ const TEST_LEVELS_METADATA = 'test_levels'
225
+ const TEST_LEVELS_METADATA_TAGS = [
226
+ CI_JOB_ID,
227
+ CI_JOB_NAME,
228
+ CI_JOB_URL,
229
+ CI_NODE_LABELS,
230
+ CI_NODE_NAME,
231
+ CI_PIPELINE_ID,
232
+ CI_PIPELINE_NAME,
233
+ CI_PIPELINE_NUMBER,
234
+ CI_PIPELINE_URL,
235
+ CI_PROVIDER_NAME,
236
+ CI_STAGE_NAME,
237
+ CI_WORKSPACE_PATH,
238
+ GIT_BRANCH,
239
+ GIT_COMMIT_AUTHOR_DATE,
240
+ GIT_COMMIT_AUTHOR_EMAIL,
241
+ GIT_COMMIT_AUTHOR_NAME,
242
+ GIT_COMMIT_COMMITTER_DATE,
243
+ GIT_COMMIT_COMMITTER_EMAIL,
244
+ GIT_COMMIT_COMMITTER_NAME,
245
+ GIT_COMMIT_HEAD_AUTHOR_DATE,
246
+ GIT_COMMIT_HEAD_AUTHOR_EMAIL,
247
+ GIT_COMMIT_HEAD_AUTHOR_NAME,
248
+ GIT_COMMIT_HEAD_COMMITTER_DATE,
249
+ GIT_COMMIT_HEAD_COMMITTER_EMAIL,
250
+ GIT_COMMIT_HEAD_COMMITTER_NAME,
251
+ GIT_COMMIT_HEAD_MESSAGE,
252
+ GIT_COMMIT_HEAD_SHA,
253
+ GIT_COMMIT_MESSAGE,
254
+ GIT_COMMIT_SHA,
255
+ GIT_PULL_REQUEST_BASE_BRANCH,
256
+ GIT_PULL_REQUEST_BASE_BRANCH_HEAD_SHA,
257
+ GIT_PULL_REQUEST_BASE_BRANCH_SHA,
258
+ GIT_REPOSITORY_URL,
259
+ GIT_TAG,
260
+ ]
201
261
  const TEST_RETRY_REASON_TYPES = {
202
262
  efd: 'early_flake_detection',
203
263
  atr: 'auto_test_retry',
@@ -398,6 +458,7 @@ module.exports = {
398
458
  getCodeOwnersFileEntries,
399
459
  getCodeOwnersForFilename,
400
460
  getTestCommonTags,
461
+ getTestLevelsMetadataTags,
401
462
  getTestSessionCommonTags,
402
463
  getTestModuleCommonTags,
403
464
  getTestSuiteCommonTags,
@@ -448,6 +509,7 @@ module.exports = {
448
509
  DD_CAPABILITIES_TEST_MANAGEMENT_ATTEMPT_TO_FIX,
449
510
  DD_CAPABILITIES_FAILED_TEST_REPLAY,
450
511
  TEST_LEVEL_EVENT_TYPES,
512
+ TEST_LEVELS_METADATA,
451
513
  TEST_RETRY_REASON_TYPES,
452
514
  getNumFromKnownTests,
453
515
  getFileAndLineNumberFromError,
@@ -700,6 +762,24 @@ function getTestParametersString (parametersByTestName, testName) {
700
762
  }
701
763
  }
702
764
 
765
+ /**
766
+ * Extracts CI and Git tags that apply to every test level.
767
+ *
768
+ * @param {TestEnvironmentMetadata} testEnvironmentMetadata
769
+ * @returns {Record<string, string|number>}
770
+ */
771
+ function getTestLevelsMetadataTags (testEnvironmentMetadata) {
772
+ const testLevelsMetadataTags = {}
773
+ for (let i = 0; i < TEST_LEVELS_METADATA_TAGS.length; i++) {
774
+ const key = TEST_LEVELS_METADATA_TAGS[i]
775
+ const value = testEnvironmentMetadata[key]
776
+ if (value !== undefined) {
777
+ testLevelsMetadataTags[key] = value
778
+ }
779
+ }
780
+ return testLevelsMetadataTags
781
+ }
782
+
703
783
  function getTestTypeFromFramework (testFramework) {
704
784
  if (testFramework === 'playwright' || testFramework === 'cypress') {
705
785
  return 'browser'
@@ -2,6 +2,8 @@
2
2
 
3
3
  const { URL } = require('url')
4
4
 
5
+ const log = require('../../log')
6
+
5
7
  const HTTP2_HEADER_AUTHORITY = ':authority'
6
8
  const HTTP2_HEADER_SCHEME = ':scheme'
7
9
  const HTTP2_HEADER_PATH = ':path'
@@ -60,6 +62,69 @@ function obfuscateQs (config, url) {
60
62
  return `${path}?${qs}`
61
63
  }
62
64
 
65
+ const qsObfuscatorCache = new Map()
66
+
67
+ /**
68
+ * Compile the configured query-string obfuscator (a regex string, or a boolean)
69
+ * into the boolean / RegExp form that `obfuscateQs` consumes. The compiled regex
70
+ * is cached, since the configuration is stable for the process lifetime.
71
+ *
72
+ * @param {{ queryStringObfuscation?: boolean | string }} config
73
+ * @returns {boolean | RegExp}
74
+ */
75
+ function getQsObfuscator (config) {
76
+ const obfuscator = config.queryStringObfuscation
77
+
78
+ if (typeof obfuscator === 'boolean') return obfuscator
79
+
80
+ if (typeof obfuscator === 'string') {
81
+ const cached = qsObfuscatorCache.get(obfuscator)
82
+ if (cached !== undefined) return cached
83
+
84
+ let compiled = true
85
+ if (obfuscator === '') {
86
+ compiled = false // disable obfuscator
87
+ } else if (obfuscator !== '.*') { // '.*' optimizes to a full redact (true)
88
+ try {
89
+ compiled = new RegExp(obfuscator, 'gi')
90
+ } catch (err) {
91
+ log.error('Error getting qs obfuscator', err)
92
+ }
93
+ }
94
+
95
+ qsObfuscatorCache.set(obfuscator, compiled)
96
+ return compiled
97
+ }
98
+
99
+ if (Object.hasOwn(config, 'queryStringObfuscation')) {
100
+ log.error('Expected `queryStringObfuscation` to be a regex string or boolean.')
101
+ }
102
+
103
+ return true
104
+ }
105
+
106
+ /**
107
+ * Build a client span's `http.url` with its query retained but obfuscated per
108
+ * `config.queryStringObfuscation` (OTel `url.full` is the absolute URL including
109
+ * the redacted query). Falls back to `strippedUrl` when there is no query.
110
+ * Callers gate this behind `DD_TRACE_OTEL_SEMANTICS_ENABLED`, so the default
111
+ * (flag off) hot path stays a plain tag assignment.
112
+ *
113
+ * @param {{ queryStringObfuscation?: boolean | string }} config
114
+ * @param {string} base `scheme://host[:port]`
115
+ * @param {string} [pathname] raw request path, may include `?query`
116
+ * @param {string} strippedUrl `base` + query-stripped path (used when there is no query)
117
+ * @returns {string}
118
+ */
119
+ function buildClientHttpUrl (config, base, pathname, strippedUrl) {
120
+ if (pathname?.includes('?')) {
121
+ // `config.queryStringObfuscation` is the raw config value here (client plugins
122
+ // don't normalize it the way the server does), so compile it first.
123
+ return obfuscateQs({ queryStringObfuscation: getQsObfuscator(config) }, `${base}${pathname}`)
124
+ }
125
+ return strippedUrl
126
+ }
127
+
63
128
  /**
64
129
  * Extract URL path from URL using regex pattern instead of Node.js URL API because:
65
130
  *
@@ -139,7 +204,9 @@ function filterSensitiveInfoFromRepository (repositoryUrl) {
139
204
  module.exports = {
140
205
  extractURL,
141
206
  obfuscateQs,
207
+ getQsObfuscator,
208
+ buildClientHttpUrl,
142
209
  calculateHttpEndpoint,
143
210
  filterSensitiveInfoFromRepository,
144
- extractPathFromUrl, // test only
211
+ extractPathFromUrl, // used by http-otel-semantics decomposeServerUrl fallback (and tests)
145
212
  }