dd-trace 6.18.0 → 6.19.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 (55) hide show
  1. package/LICENSE-3rdparty.csv +1 -0
  2. package/ci/vitest-no-worker-init-setup.mjs +22 -12
  3. package/index.d.ts +3 -2
  4. package/package.json +6 -6
  5. package/packages/datadog-instrumentations/src/anthropic.js +111 -9
  6. package/packages/datadog-instrumentations/src/claude-agent-sdk.js +5 -1
  7. package/packages/datadog-instrumentations/src/cucumber.js +40 -4
  8. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js +8 -0
  9. package/packages/datadog-instrumentations/src/helpers/rewriter/targets.json +2 -0
  10. package/packages/datadog-instrumentations/src/jest/session-error.js +102 -0
  11. package/packages/datadog-instrumentations/src/jest.js +3 -10
  12. package/packages/datadog-instrumentations/src/playwright.js +26 -0
  13. package/packages/datadog-instrumentations/src/vitest-main.js +4 -1
  14. package/packages/datadog-instrumentations/src/vitest-worker.js +34 -3
  15. package/packages/datadog-plugin-aws-sdk/src/services/bedrockruntime/utils.js +83 -29
  16. package/packages/datadog-plugin-cucumber/src/index.js +19 -4
  17. package/packages/datadog-plugin-cypress/src/cypress-plugin.js +7 -0
  18. package/packages/datadog-plugin-fetch/src/index.js +3 -0
  19. package/packages/datadog-plugin-oracledb/src/connection-parser.js +3 -1
  20. package/packages/datadog-plugin-undici/src/index.js +5 -0
  21. package/packages/dd-trace/src/aiguard/client.js +38 -19
  22. package/packages/dd-trace/src/aiguard/integrations/anthropic.js +29 -4
  23. package/packages/dd-trace/src/aiguard/integrations/index.js +1 -1
  24. package/packages/dd-trace/src/aiguard/integrations/openai.js +20 -56
  25. package/packages/dd-trace/src/aiguard/integrations/stream.js +60 -0
  26. package/packages/dd-trace/src/aiguard/messages/anthropic.js +64 -0
  27. package/packages/dd-trace/src/config/generated-config-types.d.ts +4 -4
  28. package/packages/dd-trace/src/config/supported-configurations.json +5 -5
  29. package/packages/dd-trace/src/constants.js +2 -0
  30. package/packages/dd-trace/src/llmobs/constants/tags.js +31 -8
  31. package/packages/dd-trace/src/llmobs/gen-ai-tags.js +127 -0
  32. package/packages/dd-trace/src/llmobs/plugins/ai/ddTelemetry.js +18 -0
  33. package/packages/dd-trace/src/llmobs/plugins/ai/vercelTelemetry.js +33 -8
  34. package/packages/dd-trace/src/llmobs/plugins/anthropic/index.js +77 -41
  35. package/packages/dd-trace/src/llmobs/plugins/base.js +154 -15
  36. package/packages/dd-trace/src/llmobs/plugins/bedrockruntime.js +162 -10
  37. package/packages/dd-trace/src/llmobs/plugins/claude-agent-sdk/index.js +65 -16
  38. package/packages/dd-trace/src/llmobs/plugins/genai/index.js +14 -0
  39. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/chat_model.js +34 -35
  40. package/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js +10 -0
  41. package/packages/dd-trace/src/llmobs/plugins/langchain/index.js +25 -2
  42. package/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +8 -7
  43. package/packages/dd-trace/src/llmobs/plugins/openai/index.js +13 -0
  44. package/packages/dd-trace/src/llmobs/plugins/openai/realtime.js +5 -0
  45. package/packages/dd-trace/src/llmobs/plugins/vertexai.js +7 -0
  46. package/packages/dd-trace/src/llmobs/prompts/prompt.js +2 -1
  47. package/packages/dd-trace/src/llmobs/span_processor.js +10 -65
  48. package/packages/dd-trace/src/llmobs/tagger.js +2 -36
  49. package/packages/dd-trace/src/opentelemetry/trace/index.js +5 -0
  50. package/packages/dd-trace/src/plugins/util/test.js +4 -0
  51. package/packages/dd-trace/src/profiler.js +51 -5
  52. package/packages/dd-trace/src/profiling/profiler.js +24 -20
  53. package/packages/dd-trace/src/ritm.js +12 -9
  54. package/packages/dd-trace/src/span_processor.js +6 -1
  55. package/vendor/dist/@datadog/openfeature-node-server/index.js +1 -1
@@ -38,19 +38,6 @@ class LangChainLLMObsChatModelHandler extends LangChainLLMObsHandler {
38
38
  }
39
39
 
40
40
  const outputMessages = []
41
- let inputTokens = 0
42
- let outputTokens = 0
43
- let totalTokens = 0
44
- let tokensSetTopLevel = false
45
- const tokensPerRunId = {}
46
-
47
- if (!isWorkflow) {
48
- const tokens = this.checkTokenUsageChatOrLLMResult(results)
49
- inputTokens = tokens.inputTokens
50
- outputTokens = tokens.outputTokens
51
- totalTokens = tokens.totalTokens
52
- tokensSetTopLevel = totalTokens > 0
53
- }
54
41
 
55
42
  for (const messageSet of results.generations) {
56
43
  for (const chatCompletion of messageSet) {
@@ -59,35 +46,47 @@ class LangChainLLMObsChatModelHandler extends LangChainLLMObsHandler {
59
46
  const content = chatCompletionMessage.text || ''
60
47
  const toolCalls = this.extractToolCalls(chatCompletionMessage)
61
48
  outputMessages.push({ content, role, toolCalls })
62
-
63
- if (!isWorkflow && !tokensSetTopLevel) {
64
- const { tokens, runId } = this.checkTokenUsageFromAIMessage(chatCompletionMessage)
65
- if (tokensPerRunId[runId]) {
66
- tokensPerRunId[runId].inputTokens += tokens.inputTokens
67
- tokensPerRunId[runId].outputTokens += tokens.outputTokens
68
- tokensPerRunId[runId].totalTokens += tokens.totalTokens
69
- } else {
70
- tokensPerRunId[runId] = tokens
71
- }
72
- }
73
49
  }
74
50
  }
75
51
 
76
- if (!isWorkflow && !tokensSetTopLevel) {
77
- inputTokens = Object.values(tokensPerRunId).reduce((acc, val) => acc + val.inputTokens, 0)
78
- outputTokens = Object.values(tokensPerRunId).reduce((acc, val) => acc + val.outputTokens, 0)
79
- totalTokens = Object.values(tokensPerRunId).reduce((acc, val) => acc + val.totalTokens, 0)
80
- }
81
-
82
52
  if (isWorkflow) {
83
53
  this._tagger.tagTextIO(span, inputMessages, outputMessages)
84
54
  } else {
85
55
  this._tagger.tagLLMIO(span, inputMessages, outputMessages)
86
- this._tagger.tagMetrics(span, {
87
- inputTokens,
88
- outputTokens,
89
- totalTokens,
90
- })
56
+ this._tagger.tagMetrics(span, this.getTokenUsage(results))
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @override
62
+ */
63
+ getTokenUsage (results) {
64
+ const tokens = this.checkTokenUsageChatOrLLMResult(results)
65
+ if (tokens.totalTokens > 0) return tokens
66
+
67
+ // providers that report usage on each generated message instead of on `llmOutput`; counts are
68
+ // summed per run so a run split across generations is totalled once
69
+ if (!results.generations) return tokens
70
+
71
+ const tokensPerRunId = {}
72
+ for (const messageSet of results.generations) {
73
+ for (const chatCompletion of messageSet) {
74
+ const { tokens: messageTokens, runId } = this.checkTokenUsageFromAIMessage(chatCompletion.message)
75
+ if (tokensPerRunId[runId]) {
76
+ tokensPerRunId[runId].inputTokens += messageTokens.inputTokens
77
+ tokensPerRunId[runId].outputTokens += messageTokens.outputTokens
78
+ tokensPerRunId[runId].totalTokens += messageTokens.totalTokens
79
+ } else {
80
+ tokensPerRunId[runId] = messageTokens
81
+ }
82
+ }
83
+ }
84
+
85
+ const perRun = Object.values(tokensPerRunId)
86
+ return {
87
+ inputTokens: perRun.reduce((acc, val) => acc + val.inputTokens, 0),
88
+ outputTokens: perRun.reduce((acc, val) => acc + val.outputTokens, 0),
89
+ totalTokens: perRun.reduce((acc, val) => acc + val.totalTokens, 0),
91
90
  }
92
91
  }
93
92
 
@@ -12,6 +12,16 @@ class LangChainLLMObsHandler {
12
12
 
13
13
  setMetaTags () {}
14
14
 
15
+ /**
16
+ * Token usage for a chat or LLM result. Subclasses widen this where the provider reports usage
17
+ * somewhere other than `llmOutput`.
18
+ *
19
+ * @param {object} results
20
+ */
21
+ getTokenUsage (results) {
22
+ return this.checkTokenUsageChatOrLLMResult(results)
23
+ }
24
+
15
25
  checkTokenUsageChatOrLLMResult (results) {
16
26
  const llmOutput = results.llmOutput
17
27
  const tokens = {
@@ -3,8 +3,6 @@
3
3
  const log = require('../../../log')
4
4
  const LLMObsPlugin = require('../base')
5
5
 
6
- const pluginManager = require('../../../../../..')._pluginManager
7
-
8
6
  const ANTHROPIC_PROVIDER_NAME = 'anthropic'
9
7
  const BEDROCK_PROVIDER_NAME = 'amazon_bedrock'
10
8
  const OPENAI_PROVIDER_NAME = 'openai'
@@ -76,6 +74,28 @@ class BaseLangChainLLMObsPlugin extends LLMObsPlugin {
76
74
  }
77
75
  }
78
76
 
77
+ /**
78
+ * @override
79
+ */
80
+ getGenAiApmEndTags (ctx, spanKind) {
81
+ // the helper reports zeros when the result carries no usage, and zeroed token metrics would
82
+ // read as a real measurement
83
+ const tokens = this._handlers[ctx.type]?.getTokenUsage(ctx.result ?? {})
84
+ const metrics = tokens?.totalTokens ? tokens : undefined
85
+
86
+ // langchain-openai calls an untraced beta client when `response_format` is set, so this span is
87
+ // the only model span for the call. `handlers/chat_model.js` applies the same correction through
88
+ // `changeKind` while building the LLMObs payload.
89
+ if (spanKind !== WORKFLOW || ctx.type !== 'chat_model' || !ctx.arguments?.[1]?.response_format) {
90
+ return { metrics }
91
+ }
92
+
93
+ const provider = ctx.currentStore?.span?.context().getTags()['langchain.request.provider']
94
+ const isOpenAI = this.getIntegrationName(ctx.type, provider) === OPENAI_PROVIDER_NAME
95
+
96
+ return isOpenAI ? { spanKind: LLM, metrics } : { metrics }
97
+ }
98
+
79
99
  setLLMObsTags (ctx) {
80
100
  ctx.args = ctx.arguments
81
101
  ctx.instance = ctx.self
@@ -158,6 +178,9 @@ class BaseLangChainLLMObsPlugin extends LLMObsPlugin {
158
178
  }
159
179
 
160
180
  isLLMIntegrationEnabled (integration) {
181
+ // read off the owning manager rather than a module-scope capture: the tracer, and with it the
182
+ // plugin manager, can be rebuilt after this module is first loaded
183
+ const pluginManager = this._tracer?._pluginManager
161
184
  return SUPPORTED_INTEGRATIONS.has(integration) && pluginManager?._pluginsByName[integration]?.llmobs?._enabled
162
185
  }
163
186
  }
@@ -14,15 +14,16 @@ class PregelStreamLLMObsPlugin extends LLMObsPlugin {
14
14
  getLLMObsSpanRegisterOptions (ctx) {
15
15
  const name = ctx.self.name || 'LangGraph'
16
16
 
17
- const enabled = this._tracerConfig.llmobs.DD_LLMOBS_ENABLED
18
- if (!enabled) return
19
-
20
17
  const span = ctx.currentStore?.span
21
18
  if (!span) return
22
- streamDataMap.set(span, {
23
- streamInputs: ctx.arguments?.[0],
24
- chunks: [],
25
- })
19
+
20
+ // only the LLMObs payload consumes the accumulated stream
21
+ if (this._llmobsEnabledFor(ctx)) {
22
+ streamDataMap.set(span, {
23
+ streamInputs: ctx.arguments?.[0],
24
+ chunks: [],
25
+ })
26
+ }
26
27
 
27
28
  return {
28
29
  kind: 'workflow',
@@ -74,6 +74,19 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin {
74
74
  }
75
75
  }
76
76
 
77
+ /**
78
+ * @override
79
+ */
80
+ getGenAiApmEndTags (ctx) {
81
+ const response = ctx.result?.data
82
+
83
+ return {
84
+ // the response model is the resolved one, e.g. the dated version behind an alias
85
+ modelName: response?.model,
86
+ metrics: response && this._extractMetrics(response),
87
+ }
88
+ }
89
+
77
90
  setLLMObsTags (ctx) {
78
91
  const span = ctx.currentStore?.span
79
92
  const resource = ctx.methodName
@@ -155,6 +155,11 @@ class RealtimeLLMObsPlugin extends LLMObsPlugin {
155
155
  static integration = 'openai'
156
156
  static system = 'openai'
157
157
 
158
+ // The instrumentation retains a turn's audio only while these plugins are subscribed, and only
159
+ // the LLM Observability payload reads those bytes. Staying subscribed for the `gen_ai.*` tags
160
+ // alone would buffer megabytes per turn for no consumer, so realtime opts out.
161
+ static emitsGenAiApmTags = false
162
+
158
163
  /**
159
164
  * The instrumentation replays the turn with `traceSync`, which never publishes `asyncEnd`, so tag
160
165
  * on `end` instead — before the sibling tracing plugin's `end` finishes the span, since the LLM
@@ -23,6 +23,13 @@ class VertexAILLMObsPlugin extends LLMObsPlugin {
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * @override
28
+ */
29
+ getGenAiApmEndTags (ctx) {
30
+ return { metrics: extractMetrics(ctx.result) }
31
+ }
32
+
26
33
  setLLMObsTags (ctx) {
27
34
  const span = ctx.currentStore?.span
28
35
  if (!span) return
@@ -2,7 +2,8 @@
2
2
 
3
3
  /** @typedef {import('../../../../../index').llmobs.FormattedPromptMessage} FormattedPromptMessage */
4
4
 
5
- const VARIABLE_PATTERN = /(?<!\{)(?:\{\{\s*(\w+)\s*\}\}(?!\})|\{\s*(\w+)\s*\}(?!\}))/g
5
+ // Match double braces first; preserve surrounding braces such as the closing object in {"age": {age}}.
6
+ const VARIABLE_PATTERN = /\{\{\s*(\w+)\s*\}\}|\{\s*(\w+)\s*\}/g
6
7
 
7
8
  function isMessage (value) {
8
9
  return typeof value?.role === 'string' &&
@@ -38,41 +38,13 @@ const {
38
38
  SAMPLE_RATE,
39
39
  SAMPLING_DECISION,
40
40
  TRACE_ID,
41
- INPUT_TOKENS_METRIC_KEY,
42
- OUTPUT_TOKENS_METRIC_KEY,
43
- TOTAL_TOKENS_METRIC_KEY,
44
- CACHE_READ_INPUT_TOKENS_METRIC_KEY,
45
- CACHE_WRITE_INPUT_TOKENS_METRIC_KEY,
46
- REASONING_OUTPUT_TOKENS_METRIC_KEY,
47
- GEN_AI_OPERATION_NAME,
48
- GEN_AI_REQUEST_MODEL,
49
- GEN_AI_PROVIDER_NAME,
50
- GEN_AI_APPLICATION_NAME,
51
- GEN_AI_CONVERSATION_ID,
52
- GEN_AI_USAGE_INPUT_TOKENS_METRIC_KEY,
53
- GEN_AI_USAGE_OUTPUT_TOKENS_METRIC_KEY,
54
- GEN_AI_USAGE_TOTAL_TOKENS_METRIC_KEY,
55
- GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_METRIC_KEY,
56
- GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_METRIC_KEY,
57
- GEN_AI_USAGE_REASONING_OUTPUT_TOKENS_METRIC_KEY,
58
- ARTIFICIAL_GEN_AI_TAGS,
41
+ DEFAULT_MODEL,
59
42
  } = require('./constants/tags')
60
43
  const { UNSERIALIZABLE_VALUE_TEXT } = require('./constants/text')
44
+ const { setGenAiApmTags } = require('./gen-ai-tags')
61
45
  const telemetry = require('./telemetry')
62
46
  const LLMObsTagger = require('./tagger')
63
47
 
64
- const DEFAULT_MODEL = 'custom'
65
- const MODEL_BACKED_SPAN_KINDS = new Set(['llm', 'embedding'])
66
-
67
- const GEN_AI_TOKEN_METRIC_KEYS = [
68
- [INPUT_TOKENS_METRIC_KEY, GEN_AI_USAGE_INPUT_TOKENS_METRIC_KEY],
69
- [OUTPUT_TOKENS_METRIC_KEY, GEN_AI_USAGE_OUTPUT_TOKENS_METRIC_KEY],
70
- [TOTAL_TOKENS_METRIC_KEY, GEN_AI_USAGE_TOTAL_TOKENS_METRIC_KEY],
71
- [CACHE_READ_INPUT_TOKENS_METRIC_KEY, GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS_METRIC_KEY],
72
- [CACHE_WRITE_INPUT_TOKENS_METRIC_KEY, GEN_AI_USAGE_CACHE_WRITE_INPUT_TOKENS_METRIC_KEY],
73
- [REASONING_OUTPUT_TOKENS_METRIC_KEY, GEN_AI_USAGE_REASONING_OUTPUT_TOKENS_METRIC_KEY],
74
- ]
75
-
76
48
  class LLMObservabilitySpan {
77
49
  /**
78
50
  * @param {string} kind span kind
@@ -320,46 +292,19 @@ class LLMObsSpanProcessor {
320
292
  }
321
293
 
322
294
  /**
323
- * Writes the scalar `gen_ai.*` attributes onto the APM span, so model, provider, application,
324
- * conversation and token usage are searchable in APM. Message bodies stay off the APM span.
325
- *
326
295
  * @param {import('../opentracing/span')} span
327
296
  */
328
297
  #setGenAiApmTags (span) {
329
298
  const mlObsTags = LLMObsTagger.tagMap.get(span)
330
- const spanContext = span.context()
331
- const spanKind = mlObsTags[SPAN_KIND]
332
-
333
- if (spanKind) spanContext.setTag(GEN_AI_OPERATION_NAME, spanKind)
334
-
335
- const modelName = mlObsTags[MODEL_NAME]
336
- const modelProvider = mlObsTags[MODEL_PROVIDER]
337
- const modelBacked = MODEL_BACKED_SPAN_KINDS.has(spanKind)
338
- if (modelBacked) {
339
- spanContext.setTag(GEN_AI_REQUEST_MODEL, modelName || DEFAULT_MODEL)
340
- spanContext.setTag(GEN_AI_PROVIDER_NAME, (modelProvider || DEFAULT_MODEL).toLowerCase())
341
- } else {
342
- if (modelName) spanContext.setTag(GEN_AI_REQUEST_MODEL, modelName)
343
- if (modelProvider) spanContext.setTag(GEN_AI_PROVIDER_NAME, modelProvider.toLowerCase())
344
- }
345
-
346
- const mlApp = mlObsTags[ML_APP]
347
- if (mlApp) spanContext.setTag(GEN_AI_APPLICATION_NAME, mlApp)
348
-
349
- const sessionId = mlObsTags[SESSION_ID]
350
- if (sessionId) spanContext.setTag(GEN_AI_CONVERSATION_ID, sessionId)
351
-
352
- const metrics = mlObsTags[METRICS]
353
- // Other kinds carry unrelated metrics that would be misleading under a `gen_ai.usage.*` key.
354
- if (modelBacked && metrics) {
355
- for (const [metricKey, genAiKey] of GEN_AI_TOKEN_METRIC_KEYS) {
356
- const value = metrics[metricKey]
357
- if (value != null) spanContext.setTag(genAiKey, value)
358
- }
359
- }
360
299
 
361
- // matches the value dd-trace-py writes
362
- spanContext.setTag(ARTIFICIAL_GEN_AI_TAGS, 'true')
300
+ setGenAiApmTags(span, {
301
+ spanKind: mlObsTags[SPAN_KIND],
302
+ modelName: mlObsTags[MODEL_NAME],
303
+ modelProvider: mlObsTags[MODEL_PROVIDER],
304
+ mlApp: mlObsTags[ML_APP],
305
+ sessionId: mlObsTags[SESSION_ID],
306
+ metrics: mlObsTags[METRICS],
307
+ })
363
308
  }
364
309
 
365
310
  // For now, this only applies to metadata, as we let users annotate this field with any object
@@ -29,14 +29,7 @@ const {
29
29
  PROPAGATED_PARENT_AGENT_ID_KEY,
30
30
  PROPAGATED_PARENT_AGENT_NAME_KEY,
31
31
  ROOT_PARENT_ID,
32
- CACHE_READ_INPUT_TOKENS_METRIC_KEY,
33
- CACHE_WRITE_INPUT_TOKENS_METRIC_KEY,
34
- CACHE_WRITE_5M_INPUT_TOKENS_METRIC_KEY,
35
- CACHE_WRITE_1H_INPUT_TOKENS_METRIC_KEY,
36
- INPUT_TOKENS_METRIC_KEY,
37
- OUTPUT_TOKENS_METRIC_KEY,
38
- TOTAL_TOKENS_METRIC_KEY,
39
- REASONING_OUTPUT_TOKENS_METRIC_KEY,
32
+ METRIC_KEY_ALIASES,
40
33
  INTEGRATION,
41
34
  DECORATOR,
42
35
  PROPAGATED_ML_APP_KEY,
@@ -307,35 +300,8 @@ class LLMObsTagger {
307
300
  tagMetrics (span, metrics) {
308
301
  const filterdMetrics = {}
309
302
  for (const [key, value] of Object.entries(metrics)) {
310
- let processedKey = key
311
-
312
303
  // processing these specifically for our metrics ingestion
313
- switch (key) {
314
- case 'inputTokens':
315
- processedKey = INPUT_TOKENS_METRIC_KEY
316
- break
317
- case 'outputTokens':
318
- processedKey = OUTPUT_TOKENS_METRIC_KEY
319
- break
320
- case 'totalTokens':
321
- processedKey = TOTAL_TOKENS_METRIC_KEY
322
- break
323
- case 'cacheReadTokens':
324
- processedKey = CACHE_READ_INPUT_TOKENS_METRIC_KEY
325
- break
326
- case 'cacheWriteTokens':
327
- processedKey = CACHE_WRITE_INPUT_TOKENS_METRIC_KEY
328
- break
329
- case 'cacheWrite5mTokens':
330
- processedKey = CACHE_WRITE_5M_INPUT_TOKENS_METRIC_KEY
331
- break
332
- case 'cacheWrite1hTokens':
333
- processedKey = CACHE_WRITE_1H_INPUT_TOKENS_METRIC_KEY
334
- break
335
- case 'reasoningOutputTokens':
336
- processedKey = REASONING_OUTPUT_TOKENS_METRIC_KEY
337
- break
338
- }
304
+ const processedKey = METRIC_KEY_ALIASES[key] ?? key
339
305
 
340
306
  if (typeof value === 'number') {
341
307
  filterdMetrics[processedKey] = value
@@ -1,6 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const { VERSION } = require('../../../../../version')
4
+ const { SDK_OTLP_EXPORT_KEY, SDK_SEMANTICS_KEY } = require('../../constants')
4
5
  const { registerResourceAttributeRefresh } = require('../resource-attributes')
5
6
  const OtlpHttpTraceExporter = require('./otlp_http_trace_exporter')
6
7
 
@@ -46,6 +47,10 @@ function buildResourceAttributes (config) {
46
47
  const { service, version, env, ...filteredTags } = config.tags
47
48
  Object.assign(resourceAttributes, filteredTags)
48
49
 
50
+ // Tracer-owned adoption markers; set after the global tags so a user tag can't contradict them.
51
+ resourceAttributes[SDK_OTLP_EXPORT_KEY] = 'true'
52
+ resourceAttributes[SDK_SEMANTICS_KEY] = config.DD_TRACE_OTEL_SEMANTICS_ENABLED ? 'otel' : 'datadog'
53
+
49
54
  if (config.OTEL_TRACES_SPAN_METRICS_ENABLED) {
50
55
  resourceAttributes['_dd.stats_computed'] = 'true'
51
56
  }
@@ -2025,6 +2025,10 @@ function getModifiedFilesFromDiff (diff) {
2025
2025
  if (lineMatch && currentFile) {
2026
2026
  const start = Number(lineMatch.groups.start)
2027
2027
  const count = lineMatch.groups.count ? Number(lineMatch.groups.count) : 1
2028
+ if (count === 0) {
2029
+ // Keep the surviving line before a deletion so line-based consumers can match its scope.
2030
+ result[currentFile].push(start)
2031
+ }
2028
2032
  for (let j = 0; j < count; j++) {
2029
2033
  result[currentFile].push(start + j)
2030
2034
  }
@@ -12,8 +12,21 @@ let activeSSIHeuristics
12
12
  /** @type {import('./profiling') | undefined} */
13
13
  let profilingModule
14
14
 
15
+ /**
16
+ * Configuration published while the profiler was finishing a shutdown export, waiting for that
17
+ * export to settle before it is acted on.
18
+ *
19
+ * @type {import('./config/config-base') | undefined}
20
+ */
21
+ let deferredConfig
22
+
15
23
  function getProfilingModule () {
16
- profilingModule ??= require('./profiling')
24
+ if (profilingModule === undefined) {
25
+ profilingModule = require('./profiling')
26
+ // The profiling layer never restarts itself; it only reports that a shutdown export has
27
+ // settled, which is when a configuration deferred during that window gets its decision.
28
+ profilingModule.profiler.on('stopped', applyDeferredConfig)
29
+ }
17
30
  return profilingModule
18
31
  }
19
32
 
@@ -25,6 +38,12 @@ function isStarted () {
25
38
  return profilingModule !== undefined && profilingModule.profiler.enabled
26
39
  }
27
40
 
41
+ // A stopped profiler still exports one final profile, and until that settles it reports neither
42
+ // the state a decision would be based on nor any willingness to start again.
43
+ function isStopping () {
44
+ return profilingModule !== undefined && profilingModule.profiler.isStopping()
45
+ }
46
+
28
47
  /** @type {typeof import('./profiling/ssi-heuristics') | undefined} */
29
48
  let ssiHeuristicsModule
30
49
 
@@ -57,8 +76,11 @@ function start (config) {
57
76
  }
58
77
 
59
78
  function stop () {
60
- // A stop command for a profiler that has never been loaded is already satisfied. Once loaded,
61
- // always forward it so the profiling layer can also cancel a restart queued during shutdown.
79
+ // An explicit stop is the latest desired state, so it retracts a configuration still waiting
80
+ // for an in-flight shutdown to settle.
81
+ deferredConfig = undefined
82
+
83
+ // A stop command for a profiler that has never been loaded is already satisfied.
62
84
  if (profilingModule === undefined) return
63
85
 
64
86
  try {
@@ -93,7 +115,17 @@ function runWithLabels (labels, fn) {
93
115
  return getProfilingModule().profiler.runWithLabels(labels, fn)
94
116
  }
95
117
 
96
- configUpdateChannel.subscribe((config) => {
118
+ function applyDeferredConfig () {
119
+ const config = deferredConfig
120
+ if (config === undefined) return
121
+ deferredConfig = undefined
122
+ applyConfig(config)
123
+ }
124
+
125
+ /**
126
+ * @param {import('./config/config-base')} config - Tracer configuration
127
+ */
128
+ function applyConfig (config) {
97
129
  const enabled = config.profiling.DD_PROFILING_ENABLED
98
130
  if (enabled === 'true') {
99
131
  // A non-auto value means the SSI heuristics no longer get a say; disable them so a trigger that
@@ -118,7 +150,9 @@ configUpdateChannel.subscribe((config) => {
118
150
  heuristics.start()
119
151
  heuristics.onTriggered(() => {
120
152
  // Explicit true/false publishes disable the heuristics, so reaching this callback guarantees
121
- // the latest valid published value is still 'auto'.
153
+ // the latest valid published value is still 'auto'. A heuristic is only ever created for a
154
+ // profiler that is neither running nor stopping, and nothing can start that profiler behind
155
+ // an active heuristic's back, so this start can not collide with a shutdown in flight.
122
156
  if (!isStarted()) start(config)
123
157
  // The heuristic has made its decision, so release all of its listeners and timer before
124
158
  // dropping the module-level reference. This does not stop the profiler that was just
@@ -132,6 +166,18 @@ configUpdateChannel.subscribe((config) => {
132
166
  // like 'auto' or crash the customer application.
133
167
  log.warn('Unexpected DD_PROFILING_ENABLED value: %o', enabled)
134
168
  }
169
+ }
170
+
171
+ configUpdateChannel.subscribe((config) => {
172
+ // A shutdown export in flight makes every decision here unsafe: the profiler can not be
173
+ // started, and it does not yet report the state the auto branch would branch on. Defer instead,
174
+ // keeping only the latest configuration, exactly as a sequence of updates outside such a window
175
+ // would have left it.
176
+ if (isStopping()) {
177
+ deferredConfig = config
178
+ return
179
+ }
180
+ applyConfig(config)
135
181
  })
136
182
 
137
183
  globalThis[Symbol.for('dd-trace')].beforeExitHandlers.add(stop)
@@ -61,7 +61,6 @@ class Profiler extends EventEmitter {
61
61
  #exporters
62
62
  #flushInterval
63
63
  #lastStart
64
- #pendingStart
65
64
  #profileSeq = 0
66
65
  #profilers
67
66
  #spanFinishListener
@@ -87,6 +86,14 @@ class Profiler extends EventEmitter {
87
86
  return this.#enabled
88
87
  }
89
88
 
89
+ /**
90
+ * True while a stop()'s final collection is still encoding and exporting. The profiler no
91
+ * longer samples, but it also can not be started again until that settles and 'stopped' fires.
92
+ */
93
+ isStopping () {
94
+ return this.#stopping !== undefined
95
+ }
96
+
90
97
  /**
91
98
  * Declares the set of custom label keys that will be used with
92
99
  * {@link runWithLabels}. This is used for profile upload metadata and
@@ -184,12 +191,12 @@ class Profiler extends EventEmitter {
184
191
  if (this.#startFailed) return false
185
192
 
186
193
  // A prior stop()'s shutdown collection may still be encoding/exporting via #tags,
187
- // #exporters and #endpointCounts. Wait for it to finish before this start() overwrites
188
- // that shared state out from under it. Record the desired config rather than chaining
189
- // straight onto #stopping, so a stop() arriving before it settles can cancel this restart.
194
+ // #exporters and #endpointCounts, which this start() would overwrite out from under it.
195
+ // Refuse rather than queue: the caller owns the decision of what to do once the shutdown
196
+ // settles, and by then its configuration may no longer be the one requested here. This is
197
+ // thrown outside the setup try/catch below so it does not count as a permanent start failure.
190
198
  if (this.#stopping) {
191
- this.#pendingStart = config
192
- return true
199
+ throw new Error('Cannot start the profiler while a shutdown collection is still in flight')
193
200
  }
194
201
 
195
202
  try {
@@ -294,11 +301,12 @@ class Profiler extends EventEmitter {
294
301
  this._timeoutInterval = this.#flushInterval
295
302
  }
296
303
 
304
+ /**
305
+ * Stops the profiler after collecting and exporting one final profile. That export outlives
306
+ * this call: {@link isStopping} returns true and start() throws until it settles, at which
307
+ * point a 'stopped' event is emitted.
308
+ */
297
309
  stop () {
298
- // A stop() always reflects the latest desired state, so it cancels any restart queued by a
299
- // start() that arrived while a prior shutdown collection was still in flight.
300
- this.#pendingStart = undefined
301
-
302
310
  if (!this.enabled) return
303
311
 
304
312
  // collect and export current profiles
@@ -311,16 +319,12 @@ class Profiler extends EventEmitter {
311
319
  })
312
320
  .finally(() => {
313
321
  this.#stopping = undefined
314
- if (this.#pendingStart) {
315
- const config = this.#pendingStart
316
- this.#pendingStart = undefined
317
- // Unlike the synchronous entry point, nothing awaits this promise, so a throw here would
318
- // become an unhandled rejection instead of a caught error.
319
- try {
320
- this.start(config)
321
- } catch (error) {
322
- log.error(error)
323
- }
322
+ // Nothing awaits this promise, so a listener throwing here would become an unhandled
323
+ // rejection instead of reaching whoever requested the stop.
324
+ try {
325
+ this.emit('stopped')
326
+ } catch (error) {
327
+ log.error(error)
324
328
  }
325
329
  })
326
330
  this.#stop()
@@ -118,17 +118,20 @@ function Hook (modules, options, onrequire) {
118
118
  if (moduleLoadStartChannel.hasSubscribers) {
119
119
  moduleLoadStartChannel.publish(payload)
120
120
  }
121
- let exports = origRequire.apply(this, arguments)
122
- payload.module = exports
123
- if (moduleLoadEndChannel.hasSubscribers) {
124
- moduleLoadEndChannel.publish(payload)
125
- exports = payload.module
121
+ let exports
122
+ try {
123
+ exports = origRequire.apply(this, arguments)
124
+ payload.module = exports
125
+ } finally {
126
+ // Failed loads must close the subscriber's load stack too. Leave module
127
+ // unset on failure and preserve the original exception for the caller.
128
+ if (moduleLoadEndChannel.hasSubscribers) {
129
+ moduleLoadEndChannel.publish(payload)
130
+ exports = payload.module
131
+ }
132
+ delete patching[moduleId]
126
133
  }
127
134
 
128
- // The module has already been loaded,
129
- // so the patching mark can be cleaned up.
130
- delete patching[moduleId]
131
-
132
135
  if (builtin) {
133
136
  hooks = moduleHooks[moduleId]
134
137
  if (!hooks) return exports // abort if module name isn't on whitelist
@@ -7,7 +7,7 @@ const SpanSampler = require('./span_sampler')
7
7
  const GitMetadataTagger = require('./git_metadata_tagger')
8
8
  const processTags = require('./process-tags')
9
9
  const { applyHttpOtelSemantics } = require('./plugins/util/http-otel-semantics')
10
- const { APM_TRACING_ENABLED_KEY } = require('./constants')
10
+ const { APM_TRACING_ENABLED_KEY, SDK_OTLP_EXPORT_KEY } = require('./constants')
11
11
 
12
12
  const startedSpans = new WeakSet()
13
13
  const finishedSpans = new WeakSet()
@@ -31,6 +31,7 @@ class SpanProcessor {
31
31
  this._processTags = config.DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED
32
32
  ? processTags.serialized
33
33
  : false
34
+ this._nativeExport = config.OTEL_TRACES_EXPORTER !== 'otlp'
34
35
  }
35
36
 
36
37
  sample (span) {
@@ -80,6 +81,10 @@ class SpanProcessor {
80
81
  if (stampApmDisabled) {
81
82
  formattedSpan.metrics[APM_TRACING_ENABLED_KEY] = 0
82
83
  }
84
+ // Stamped after formatting so a span tag with the same key can't override it.
85
+ if (isFirstSpanInChunk && this._nativeExport) {
86
+ formattedSpan.meta[SDK_OTLP_EXPORT_KEY] = 'false'
87
+ }
83
88
  isFirstSpanInChunk = false
84
89
  // Span stats read Datadog HTTP tag names from the formatted span, so
85
90
  // record them before the OTel rename — an export-only transform.