dd-trace 6.1.0 → 6.3.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 (58) hide show
  1. package/README.md +1 -1
  2. package/index.d.ts +7 -0
  3. package/initialize.mjs +4 -2
  4. package/package.json +5 -5
  5. package/packages/datadog-instrumentations/src/cookie.js +7 -1
  6. package/packages/datadog-instrumentations/src/helpers/hooks.js +3 -1
  7. package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +5 -1
  8. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +1 -0
  9. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/mercurius.js +31 -0
  10. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/modelcontextprotocol-sdk.js +30 -54
  11. package/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js +19 -5
  12. package/packages/datadog-instrumentations/src/http/server.js +27 -0
  13. package/packages/datadog-instrumentations/src/mercurius.js +11 -0
  14. package/packages/datadog-instrumentations/src/modelcontextprotocol-sdk.js +248 -1
  15. package/packages/datadog-instrumentations/src/otel-sdk-trace.js +15 -0
  16. package/packages/datadog-instrumentations/src/playwright.js +40 -3
  17. package/packages/datadog-instrumentations/src/vitest-main.js +0 -2
  18. package/packages/datadog-instrumentations/src/vitest-util.js +0 -3
  19. package/packages/datadog-instrumentations/src/vitest-worker.js +0 -1
  20. package/packages/datadog-plugin-azure-functions/src/index.js +2 -1
  21. package/packages/datadog-plugin-graphql/src/execute.js +4 -15
  22. package/packages/datadog-plugin-graphql/src/index.js +6 -0
  23. package/packages/datadog-plugin-graphql/src/request.js +104 -0
  24. package/packages/datadog-plugin-graphql/src/utils.js +177 -0
  25. package/packages/datadog-plugin-graphql/src/validate.js +24 -1
  26. package/packages/datadog-plugin-modelcontextprotocol-sdk/src/tracing.js +144 -26
  27. package/packages/datadog-plugin-modelcontextprotocol-sdk/src/utils.js +30 -0
  28. package/packages/datadog-plugin-playwright/src/index.js +1 -1
  29. package/packages/datadog-plugin-vitest/src/index.js +0 -8
  30. package/packages/datadog-shimmer/src/shimmer.js +9 -2
  31. package/packages/dd-trace/src/appsec/channels.js +1 -0
  32. package/packages/dd-trace/src/appsec/index.js +6 -3
  33. package/packages/dd-trace/src/appsec/lambda.js +8 -8
  34. package/packages/dd-trace/src/appsec/waf/waf_manager.js +1 -1
  35. package/packages/dd-trace/src/ci-visibility/dynamic-instrumentation/worker/index.js +23 -16
  36. package/packages/dd-trace/src/config/config-types.d.ts +8 -0
  37. package/packages/dd-trace/src/config/defaults.js +20 -15
  38. package/packages/dd-trace/src/config/index.js +13 -3
  39. package/packages/dd-trace/src/config/parsers.js +79 -0
  40. package/packages/dd-trace/src/datastreams/writer.js +14 -2
  41. package/packages/dd-trace/src/encode/0.4.js +23 -2
  42. package/packages/dd-trace/src/encode/0.5.js +12 -1
  43. package/packages/dd-trace/src/encode/agentless-ci-visibility.js +11 -1
  44. package/packages/dd-trace/src/encode/coverage-ci-visibility.js +20 -2
  45. package/packages/dd-trace/src/exporters/common/writer.js +17 -1
  46. package/packages/dd-trace/src/guardrails/index.js +3 -1
  47. package/packages/dd-trace/src/guardrails/telemetry.js +40 -3
  48. package/packages/dd-trace/src/llmobs/constants/tags.js +1 -0
  49. package/packages/dd-trace/src/llmobs/tagger.js +15 -1
  50. package/packages/dd-trace/src/msgpack/chunk.js +33 -1
  51. package/packages/dd-trace/src/msgpack/index.js +6 -1
  52. package/packages/dd-trace/src/opentelemetry/span_processor.js +7 -5
  53. package/packages/dd-trace/src/opentelemetry/tracer_provider.js +32 -16
  54. package/packages/dd-trace/src/plugins/index.js +3 -0
  55. package/packages/dd-trace/src/runtime_metrics/runtime_metrics.js +20 -11
  56. package/packages/dd-trace/src/service-naming/schemas/v0/graphql.js +6 -0
  57. package/packages/dd-trace/src/service-naming/schemas/v1/graphql.js +8 -0
  58. package/vendor/dist/@apm-js-collab/code-transformer/index.js +5 -6
@@ -105,6 +105,7 @@ const efdStartedOriginalTestKeys = new Set()
105
105
  const efdSlowAbortedTests = new Set()
106
106
  const ddPropertiesByTestId = new Map()
107
107
  const ddPropertiesRequestsByTestId = new Map()
108
+ const disabledTestIds = new Set()
108
109
  let rootDir = ''
109
110
  let sessionProjects = []
110
111
 
@@ -113,6 +114,8 @@ const EFD_RETRY_COUNT_REQUEST = 'ddEfdRetryCountRequest'
113
114
  const EFD_RETRY_COUNT_RESPONSE = 'ddEfdRetryCountResponse'
114
115
  const DD_PROPERTIES_REQUEST = 'ddPropertiesRequest'
115
116
  const DD_PROPERTIES_RESPONSE = 'ddProperties'
117
+ const kDdPlaywrightDisabledTestIds = Symbol('ddPlaywrightDisabledTestIds')
118
+ const kDdPlaywrightWorkerHostInstrumented = Symbol('ddPlaywrightWorkerHostInstrumented')
116
119
  const kDdPlaywrightWorkerInstrumented = Symbol('ddPlaywrightWorkerInstrumented')
117
120
 
118
121
  function isValidKnownTests (receivedKnownTests) {
@@ -991,14 +994,36 @@ function onDispatcherCreateWorker (dispatcher, worker) {
991
994
  const projects = getProjectsFromDispatcher(dispatcher)
992
995
  sessionProjects = projects
993
996
 
997
+ if (disabledTestIds.size && !worker[kDdPlaywrightWorkerHostInstrumented] &&
998
+ typeof worker.runTestGroup === 'function') {
999
+ Object.defineProperty(worker, kDdPlaywrightWorkerHostInstrumented, { value: true })
1000
+ shimmer.wrap(worker, 'runTestGroup', runTestGroup => function (runPayload) {
1001
+ // Serial retries can restore disabled tests that were filtered from the initial dispatcher groups.
1002
+ let disabledIds
1003
+ for (const { testId } of runPayload.entries) {
1004
+ if (disabledTestIds.has(testId)) {
1005
+ disabledIds ??= []
1006
+ disabledIds.push(testId)
1007
+ }
1008
+ }
1009
+ if (disabledIds) {
1010
+ runPayload._ddDisabledTestIds = disabledIds
1011
+ }
1012
+ return runTestGroup.apply(this, arguments)
1013
+ })
1014
+ }
1015
+
994
1016
  worker.on('testBegin', ({ testId }) => {
995
1017
  const test = getTestByTestId(dispatcher, testId)
1018
+ if (!test) return
1019
+
996
1020
  const browser = getBrowserNameFromProjects(projects, test)
997
1021
  const shouldCreateTestSpan = test.expectedStatus === 'skipped'
998
1022
  testBeginHandler(test, browser, shouldCreateTestSpan)
999
1023
  })
1000
1024
  worker.on('testEnd', ({ testId, status, errors, annotations }) => {
1001
1025
  const test = getTestByTestId(dispatcher, testId)
1026
+ if (!test) return
1002
1027
 
1003
1028
  const isTimeout = status === 'timedOut'
1004
1029
  const testStatus = STATUS_TO_TEST_STATUS[status]
@@ -1308,6 +1333,7 @@ function runAllTestsWrapper (runAllTests, playwrightVersion) {
1308
1333
  efdSlowAbortedTests.clear()
1309
1334
  ddPropertiesByTestId.clear()
1310
1335
  ddPropertiesRequestsByTestId.clear()
1336
+ disabledTestIds.clear()
1311
1337
 
1312
1338
  // TODO: we can trick playwright into thinking the session passed by returning
1313
1339
  // 'passed' here. We might be able to use this for both EFD and Test Management tests.
@@ -1552,6 +1578,7 @@ function processRootSuite (createRootSuiteReturnValue) {
1552
1578
  if (testProperties.disabled) {
1553
1579
  test._ddIsDisabled = true
1554
1580
  if (!testProperties.attemptToFix) {
1581
+ disabledTestIds.add(test.id)
1555
1582
  test.expectedStatus = 'skipped'
1556
1583
  // setting test.expectedStatus to 'skipped' does not work for every case,
1557
1584
  // so we need to filter out disabled tests in dispatcherRunWrapperNew,
@@ -1795,7 +1822,17 @@ function instrumentWorkerMainMethods (workerMain) {
1795
1822
  let steps = []
1796
1823
  const stepInfoByStepId = {}
1797
1824
 
1825
+ shimmer.wrap(workerMain, 'runTestGroup', runTestGroup => function (runPayload) {
1826
+ const disabledIds = runPayload._ddDisabledTestIds
1827
+ this[kDdPlaywrightDisabledTestIds] = disabledIds ? new Set(disabledIds) : undefined
1828
+ return runTestGroup.apply(this, arguments)
1829
+ })
1830
+
1798
1831
  shimmer.wrap(workerMain, '_runTest', _runTest => async function (test) {
1832
+ if (this[kDdPlaywrightDisabledTestIds]?.has(test.id)) {
1833
+ test._ddIsDisabled = true
1834
+ test.expectedStatus = 'skipped'
1835
+ }
1799
1836
  await waitForEfdRetryCount(test)
1800
1837
  if (shouldSkipEfdRetry(test)) {
1801
1838
  test._ddShouldSkipEfdRetry = true
@@ -2042,9 +2079,9 @@ function generateSummaryWrapper (generateSummary) {
2042
2079
  return function (...args) {
2043
2080
  for (const test of this.suite.allTests()) {
2044
2081
  // https://github.com/microsoft/playwright/blob/bf92ffecff6f30a292b53430dbaee0207e0c61ad/packages/playwright/src/reporters/base.ts#L279
2045
- const didNotRun = test.outcome() === 'skipped' &&
2046
- (!test.results.length || test.expectedStatus !== 'skipped')
2047
- if (didNotRun && !testsReportedInGenerateSummary.has(test)) {
2082
+ const shouldReportAsSkipped = test.outcome() === 'skipped' &&
2083
+ (disabledTestIds.has(test.id) || !test.results.length || test.expectedStatus !== 'skipped')
2084
+ if (shouldReportAsSkipped && !testsReportedInGenerateSummary.has(test)) {
2048
2085
  testsReportedInGenerateSummary.add(test)
2049
2086
  const {
2050
2087
  _requireFile: testSuiteAbsolutePath,
@@ -457,7 +457,6 @@ async function runMainProcessSetup (ctx, frameworkVersion, testSpecifications, s
457
457
  testCommand,
458
458
  repositoryRoot: receivedRepositoryRoot,
459
459
  codeOwnersEntries,
460
- testEnvironmentMetadata,
461
460
  } = testSessionConfiguration
462
461
  repositoryRoot = receivedRepositoryRoot || repositoryRoot
463
462
  if (!shouldInstallNoWorkerInit) {
@@ -467,7 +466,6 @@ async function runMainProcessSetup (ctx, frameworkVersion, testSpecifications, s
467
466
  _ddTestCommand: testCommand,
468
467
  _ddRepositoryRoot: repositoryRoot,
469
468
  _ddCodeOwnersEntries: codeOwnersEntries,
470
- _ddTestEnvironmentMetadata: testEnvironmentMetadata,
471
469
  }, 'Could not send test session configuration to workers.')
472
470
  }
473
471
  }
@@ -116,7 +116,6 @@ function getProvidedContext () {
116
116
  _ddTestCommand: testCommand,
117
117
  _ddRepositoryRoot: repositoryRoot,
118
118
  _ddCodeOwnersEntries: codeOwnersEntries,
119
- _ddTestEnvironmentMetadata: testEnvironmentMetadata,
120
119
  } = globalThis.__vitest_worker__.providedContext
121
120
 
122
121
  return {
@@ -138,7 +137,6 @@ function getProvidedContext () {
138
137
  testCommand,
139
138
  repositoryRoot,
140
139
  codeOwnersEntries,
141
- testEnvironmentMetadata,
142
140
  }
143
141
  } catch {
144
142
  log.error('Vitest workers could not parse provided context, so some features will not work.')
@@ -161,7 +159,6 @@ function getProvidedContext () {
161
159
  testCommand: undefined,
162
160
  repositoryRoot: undefined,
163
161
  codeOwnersEntries: undefined,
164
- testEnvironmentMetadata: undefined,
165
162
  }
166
163
  }
167
164
  }
@@ -657,7 +657,6 @@ addHook({
657
657
  testCommand: providedContext.testCommand,
658
658
  repositoryRoot: providedContext.repositoryRoot,
659
659
  codeOwnersEntries: providedContext.codeOwnersEntries,
660
- testEnvironmentMetadata: providedContext.testEnvironmentMetadata,
661
660
  }
662
661
  testSuiteStartCh.runStores(testSuiteCtx, () => {})
663
662
  const startTestsResponse = await startTests.apply(this, arguments)
@@ -65,6 +65,7 @@ class AzureFunctionsPlugin extends TracingPlugin {
65
65
  service: this.serviceName(),
66
66
  type: 'serverless',
67
67
  meta,
68
+ childOf: null,
68
69
  }, ctx)
69
70
 
70
71
  if (isMessagingService) {
@@ -142,7 +143,7 @@ function mapTriggerTag (methodName) {
142
143
  // message & messages & batch with cardinality of 1 == applicationProperties
143
144
  // messages with cardinality of many == applicationPropertiesArray
144
145
  function setSpanLinks (triggerType, tracer, span, ctx) {
145
- const cardinality = ctx.invocationContext.options.trigger.cardinality
146
+ const cardinality = ctx.invocationContext.options.trigger.cardinality ?? 'one'
146
147
  const triggerMetadata = ctx.invocationContext.triggerMetadata
147
148
  const isServiceBus = triggerType === 'ServiceBus'
148
149
 
@@ -5,12 +5,10 @@ const dc = require('dc-polyfill')
5
5
  const { storage } = require('../../datadog-core')
6
6
  const TracingPlugin = require('../../dd-trace/src/plugins/tracing')
7
7
  const GraphQLParsePlugin = require('./parse')
8
- const { extractErrorIntoSpanEvent, getSignature } = require('./utils')
8
+ const { extractErrorIntoSpanEvent, getOperation, getSignature } = require('./utils')
9
9
 
10
10
  const legacyStorage = storage('legacy')
11
11
 
12
- const types = new Set(['query', 'mutation', 'subscription'])
13
-
14
12
  const iastResolveCh = dc.channel('apm:graphql:resolve:start')
15
13
  const resolverStartCh = dc.channel('datadog:graphql:resolver:start')
16
14
  const updateFieldCh = dc.channel('apm:graphql:resolve:updateField')
@@ -115,9 +113,11 @@ class GraphQLExecutePlugin extends TracingPlugin {
115
113
 
116
114
  ctx.collapse = this.config.collapse
117
115
 
116
+ const signature = getSignature(document, name, type, this.config.signature)
117
+
118
118
  const span = this.startSpan(this.operationName(), {
119
119
  service: this.config.service || this.serviceName(),
120
- resource: getSignature(document, name, type, this.config.signature),
120
+ resource: signature,
121
121
  kind: this.constructor.kind,
122
122
  type: this.constructor.type,
123
123
  meta: {
@@ -669,17 +669,6 @@ function defaultFieldResolver (source, args, contextValue, info) {
669
669
  }
670
670
  }
671
671
 
672
- function getOperation (document, operationName) {
673
- if (!document || !Array.isArray(document.definitions)) return
674
-
675
- for (const definition of document.definitions) {
676
- if (definition && types.has(definition.operation) &&
677
- (!operationName || definition.name?.value === operationName)) {
678
- return definition
679
- }
680
- }
681
- }
682
-
683
672
  function addVariableTags (config, span, variableValues) {
684
673
  if (!variableValues || !config.variables) return
685
674
 
@@ -6,6 +6,7 @@ const CompositePlugin = require('../../dd-trace/src/plugins/composite')
6
6
  const log = require('../../dd-trace/src/log')
7
7
  const GraphQLExecutePlugin = require('./execute')
8
8
  const GraphQLParsePlugin = require('./parse')
9
+ const GraphQLRequestPlugin = require('./request')
9
10
  const GraphQLValidatePlugin = require('./validate')
10
11
 
11
12
  class GraphQLPlugin extends CompositePlugin {
@@ -14,6 +15,11 @@ class GraphQLPlugin extends CompositePlugin {
14
15
  return {
15
16
  execute: GraphQLExecutePlugin,
16
17
  parse: GraphQLParsePlugin,
18
+ // Top-level request span for drivers (mercurius) that funnel through a
19
+ // single entry point and parse/execute internally. graphql-js, apollo,
20
+ // and yoga produce no such channel, so the plugin simply never fires for
21
+ // them.
22
+ request: GraphQLRequestPlugin,
17
23
  validate: GraphQLValidatePlugin,
18
24
  // resolve plugin is absorbed into execute: per-field data is recorded
19
25
  // synchronously in wrapResolve, and all graphql.resolve spans are
@@ -0,0 +1,104 @@
1
+ 'use strict'
2
+
3
+ const TracingPlugin = require('../../dd-trace/src/plugins/tracing')
4
+ const { extractErrorIntoSpanEvent, getCachedRequestOperation } = require('./utils')
5
+
6
+ // Top-level GraphQL request span for drivers that funnel every operation
7
+ // through a single entry point but parse/validate/execute internally (mercurius
8
+ // today). It parents the `graphql.parse`/`graphql.validate`/`graphql.execute`
9
+ // (or JIT) sub-spans and carries the request text + operation name/type, which
10
+ // otherwise have no home when the query is JIT-compiled and `graphql.execute`
11
+ // never fires.
12
+ //
13
+ // The entry boundary only hands us the raw `source` (string or pre-parsed AST)
14
+ // and `operationName`; the parsed document — and therefore the precise
15
+ // operation signature — is only known once mercurius parses internally. On the
16
+ // cold path the `validate` sub-plugin refines the resource/operation tags onto
17
+ // this span via `ctx.currentStore.graphqlRequestSpan` once the document is
18
+ // available, so we never re-parse on the hot path. On the JIT warm path no
19
+ // sub-span fires, so we recover the same tags from the cache the cold path
20
+ // populated, keyed by source + operationName.
21
+ class GraphQLRequestPlugin extends TracingPlugin {
22
+ static id = 'graphql'
23
+ static operation = 'request'
24
+ static type = 'graphql'
25
+ static kind = 'server'
26
+ static prefix = 'tracing:orchestrion:mercurius:apm:graphql:request'
27
+
28
+ bindStart (ctx) {
29
+ // fastifyGraphQl(source, context, variables, operationName)
30
+ const source = ctx.arguments?.[0]
31
+ const operationName = ctx.arguments?.[3]
32
+
33
+ // `source` is the request text on the common path, but mercurius also
34
+ // accepts a pre-parsed document AST; only a string is the query text, and
35
+ // `graphql.source` carries only the text form.
36
+ const docSource = typeof source === 'string' ? source : undefined
37
+
38
+ // Warm (JIT-compiled) path: execute never fires, so recover the operation
39
+ // signature/type the cold path cached, keyed by source + operationName —
40
+ // by query text for a string, by document identity for a pre-parsed AST.
41
+ // Empty on the cold path — validate hasn't refined yet — where the request
42
+ // span is refined from the parsed document instead.
43
+ const cached = getCachedRequestOperation(source, operationName)
44
+
45
+ const span = this.startSpan(this.operationName({ id: 'request' }), {
46
+ service: this.config.service || this.serviceName(),
47
+ // The cached signature is the precise resource; otherwise provisional and
48
+ // refined by the validate sub-plugin once the document is parsed.
49
+ // `operationName` is the best name at the boundary; falls back to the
50
+ // operation signature once validate sees the document.
51
+ resource: cached?.signature || operationName || undefined,
52
+ kind: this.constructor.kind,
53
+ type: this.constructor.type,
54
+ meta: {
55
+ 'graphql.operation.type': cached?.type,
56
+ 'graphql.operation.name': cached?.name || operationName,
57
+ 'graphql.source': this.config.source ? docSource : undefined,
58
+ },
59
+ }, ctx)
60
+
61
+ // Hand the span, the requested operation name, and the raw source to the
62
+ // validate sub-plugin running inside this store so it can refine the
63
+ // resource + operation tags from the parsed document (validate is the first
64
+ // boundary that has it) and cache them keyed by the source the request
65
+ // boundary saw. The raw source is the cache key — validate sees mercurius's
66
+ // internally parsed document, not the caller's source, and for a pre-parsed
67
+ // AST the two are different objects.
68
+ ctx.currentStore.graphqlRequestSpan = span
69
+ ctx.currentStore.graphqlRequestOperationName = operationName
70
+ ctx.currentStore.graphqlRequestSource = source
71
+
72
+ return ctx.currentStore
73
+ }
74
+
75
+ asyncEnd (ctx) {
76
+ /* istanbul ignore next: currentStore is populated for the request lifecycle; activeSpan is base-plugin fallback. */
77
+ const span = ctx?.currentStore?.span || this.activeSpan
78
+ /* istanbul ignore if: startSpan always populates currentStore for the request lifecycle. */
79
+ if (!span) return ctx.parentStore
80
+
81
+ const result = ctx.result
82
+ if (result?.errors?.length) {
83
+ span.setTag('error', result.errors[0])
84
+ for (const error of result.errors) {
85
+ extractErrorIntoSpanEvent(this.config, span, error)
86
+ }
87
+ }
88
+
89
+ span.finish()
90
+
91
+ return ctx.parentStore
92
+ }
93
+
94
+ error (ctx) {
95
+ /* istanbul ignore next: currentStore is populated for request errors; activeSpan is base-plugin fallback. */
96
+ const span = ctx?.currentStore?.span || this.activeSpan
97
+ /* istanbul ignore else: errors are only routed after the request span has started. */
98
+ if (span && ctx?.error) {
99
+ span.setTag('error', ctx.error)
100
+ }
101
+ }
102
+ }
103
+
104
+ module.exports = GraphQLRequestPlugin
@@ -1,5 +1,179 @@
1
1
  'use strict'
2
2
 
3
+ const { LRUCache } = require('../../../vendor/dist/lru-cache')
4
+
5
+ /**
6
+ * @typedef {{ signature?: string, type?: string, name?: string }} RequestOperation
7
+ */
8
+
9
+ const operationTypes = new Set(['query', 'mutation', 'subscription'])
10
+
11
+ // Mercurius funnels every operation through `fastifyGraphQl`, but the parsed
12
+ // document — and therefore the operation signature/type/name — is only known
13
+ // once mercurius parses internally. The top-level request span opens before
14
+ // that, and on the JIT warm path neither parse/validate nor execute fires, so
15
+ // the span would otherwise be left with only the provisional resource. The cold
16
+ // path caches the computed metadata; the request boundary reads it back on the
17
+ // warm path. Bounded so a flood of distinct queries can't grow it without limit.
18
+ //
19
+ // The key is the operation name plus the raw query text, not the source alone:
20
+ // mercurius keys its document LRU by source but compiles the JIT for a single
21
+ // `operationName`, and the compiled query then serves that operation for every
22
+ // later request that shares the source — regardless of the `operationName` those
23
+ // requests ask for. A source-only key would hand a warm request for operation B
24
+ // the metadata of whichever operation was cached last for that source (A),
25
+ // mislabeling the span. Operation names cannot contain a newline, so it is a
26
+ // safe separator that keeps the two parts from colliding.
27
+ const requestOperationCache = new LRUCache({ max: 500 })
28
+
29
+ // Mercurius also accepts a pre-parsed document AST as the source, which reaches
30
+ // the request boundary as an object rather than query text — so there is no
31
+ // string to key the LRU by. Mercurius keys its own document LRU by that source
32
+ // object's identity, and the same object reaches the boundary on the warm path,
33
+ // so a WeakMap keyed by the caller-owned document recovers the metadata without
34
+ // mutating the document and releases with it. The value carries the requested
35
+ // operation name so a JIT-only sibling selection is not handed another
36
+ // operation's metadata (same reason the string cache keys by operation name).
37
+ /** @type {WeakMap<object, Map<string | undefined, RequestOperation>>} */
38
+ const documentOperationCache = new WeakMap()
39
+
40
+ /**
41
+ * @param {string} source - The raw query text; the same key mercurius uses.
42
+ * @param {string | undefined} operationName - The requested operation name.
43
+ * @returns {string}
44
+ */
45
+ function requestOperationKey (source, operationName) {
46
+ return `${operationName ?? ''}\n${source}`
47
+ }
48
+
49
+ /**
50
+ * @param {unknown} source - Query text on the common path; a pre-parsed
51
+ * document AST otherwise. Any other shape (mercurius rejects it before
52
+ * execute) has no cache entry and yields undefined.
53
+ * @param {string | undefined} operationName - The requested operation name.
54
+ * @returns {RequestOperation | undefined}
55
+ */
56
+ function getCachedRequestOperation (source, operationName) {
57
+ if (typeof source === 'string') {
58
+ return requestOperationCache.get(requestOperationKey(source, operationName))
59
+ }
60
+ if (source === null || typeof source !== 'object') return
61
+ return documentOperationCache.get(source)?.get(operationName)
62
+ }
63
+
64
+ /**
65
+ * A string source keys the text LRU; a document AST keys the WeakMap. Any other
66
+ * shape has no usable key — mercurius rejects it before execute, so the warm
67
+ * path never reaches the request span for it either.
68
+ *
69
+ * @param {unknown} source
70
+ * @returns {source is string | object}
71
+ */
72
+ function isCacheableSource (source) {
73
+ return typeof source === 'string' || (source !== null && typeof source === 'object')
74
+ }
75
+
76
+ /**
77
+ * Select the operation definition matching `operationName`, or the first one
78
+ * when no name is given (graphql/mercurius default selection).
79
+ *
80
+ * @param {import('graphql').DocumentNode | undefined} document
81
+ * @param {string | undefined} operationName
82
+ * @returns {import('graphql').OperationDefinitionNode | undefined}
83
+ */
84
+ function getOperation (document, operationName) {
85
+ /* istanbul ignore if: validate/execute only call this with a parsed GraphQL document. */
86
+ if (!document || !Array.isArray(document.definitions)) return
87
+
88
+ for (const definition of document.definitions) {
89
+ if (operationTypes.has(definition?.operation) &&
90
+ (!operationName || definition.name?.value === operationName)) {
91
+ return definition
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Refine the top-level graphql.request span (mercurius) from the parsed
98
+ * document and cache the metadata so the JIT warm path — where no sub-span
99
+ * fires — can recover the same tags at the request boundary.
100
+ *
101
+ * This runs at the first boundary that has the document (validate on the cold
102
+ * path, which also precedes a pre-execute validation failure). It is idempotent
103
+ * across the later execute boundary via the `ddRequestRefined` flag, and a
104
+ * no-op for graphql-js/apollo/yoga, which never open a request span.
105
+ *
106
+ * Every named operation in the document is cached, not just the selected one:
107
+ * a multi-operation document parses once, and a later request may select a
108
+ * sibling operation that mercurius then serves exclusively through its JIT path
109
+ * (no execute span), so its metadata has to be ready before that happens.
110
+ *
111
+ * @param {import('../../dd-trace/src/opentracing/span') | undefined} requestSpan
112
+ * @param {import('graphql').DocumentNode | undefined} document
113
+ * @param {unknown} requestSource - The raw source the request boundary saw:
114
+ * query text on the common path, a pre-parsed document AST otherwise. The
115
+ * cache is keyed by it, not by the parsed document, so the request boundary
116
+ * recovers the metadata on the warm path from the same value mercurius keys
117
+ * its own document LRU by. Any other shape has no usable key and is not
118
+ * cached (the warm path never reaches this span for it either).
119
+ * @param {string | undefined} operationName - The requested operation name.
120
+ * @param {boolean} calculateSignature - The graphql plugin's `signature` config.
121
+ */
122
+ function refineRequestSpan (requestSpan, document, requestSource, operationName, calculateSignature) {
123
+ /* istanbul ignore if: validate only refines after the request span and parsed document exist. */
124
+ if (!requestSpan || requestSpan.ddRequestRefined || !document) return
125
+ requestSpan.ddRequestRefined = true
126
+
127
+ const operation = getOperation(document, operationName)
128
+ const type = operation?.operation
129
+ const name = operation?.name?.value
130
+ const signature = getSignature(document, name, type, calculateSignature)
131
+
132
+ if (signature) requestSpan.setTag('resource.name', signature)
133
+ if (type) requestSpan.setTag('graphql.operation.type', type)
134
+ if (name) requestSpan.setTag('graphql.operation.name', name)
135
+
136
+ if (!isCacheableSource(requestSource)) return
137
+
138
+ // Cache the selected operation under the requested name (undefined selects
139
+ // the document's first operation, so it shares the entry with that name).
140
+ cacheRequestOperation(requestSource, operationName, { signature, type, name })
141
+
142
+ // Cache every named operation so a JIT-only sibling selection is labeled from
143
+ // this single parse instead of falling back to a bare operation name.
144
+ for (const definition of document.definitions) {
145
+ const definitionName = definition?.name?.value
146
+ if (definitionName === undefined || !operationTypes.has(definition.operation)) continue
147
+ if (definitionName === operationName) continue
148
+
149
+ cacheRequestOperation(requestSource, definitionName, {
150
+ signature: getSignature(document, definitionName, definition.operation, calculateSignature),
151
+ type: definition.operation,
152
+ name: definitionName,
153
+ })
154
+ }
155
+ }
156
+
157
+ /**
158
+ * @param {string | import('graphql').DocumentNode} source - Query text keys the
159
+ * text LRU; a caller-owned document AST keys the WeakMap (never mutated).
160
+ * @param {string | undefined} operationName - The requested operation name.
161
+ * @param {RequestOperation} operation
162
+ */
163
+ function cacheRequestOperation (source, operationName, operation) {
164
+ if (typeof source === 'string') {
165
+ requestOperationCache.set(requestOperationKey(source, operationName), operation)
166
+ return
167
+ }
168
+
169
+ let operations = documentOperationCache.get(source)
170
+ if (operations === undefined) {
171
+ operations = new Map()
172
+ documentOperationCache.set(source, operations)
173
+ }
174
+ operations.set(operationName, operation)
175
+ }
176
+
3
177
  /**
4
178
  * @param {{ errorExtensions?: string[] }} config Resolved plugin config; `errorExtensions` lists the
5
179
  * GraphQL error `extensions` keys to copy onto the span event.
@@ -84,5 +258,8 @@ function getSignature (document, operationName, operationType, calculate) {
84
258
 
85
259
  module.exports = {
86
260
  extractErrorIntoSpanEvent,
261
+ getCachedRequestOperation,
262
+ getOperation,
87
263
  getSignature,
264
+ refineRequestSpan,
88
265
  }
@@ -1,8 +1,11 @@
1
1
  'use strict'
2
2
 
3
+ const { storage } = require('../../datadog-core')
3
4
  const TracingPlugin = require('../../dd-trace/src/plugins/tracing')
4
5
  const GraphQLParsePlugin = require('./parse')
5
- const { extractErrorIntoSpanEvent } = require('./utils')
6
+ const { extractErrorIntoSpanEvent, refineRequestSpan } = require('./utils')
7
+
8
+ const legacyStorage = storage('legacy')
6
9
 
7
10
  class GraphQLValidatePlugin extends TracingPlugin {
8
11
  static id = 'graphql'
@@ -15,6 +18,26 @@ class GraphQLValidatePlugin extends TracingPlugin {
15
18
  const docSource = document ? GraphQLParsePlugin.documentSources.get(document) : undefined
16
19
  const source = this.config.source && document && docSource
17
20
 
21
+ // Refine the top-level graphql.request span (mercurius) from the parsed
22
+ // document. validate is the first boundary that has it and precedes both
23
+ // execute and any pre-execute rejection (unknown field, GET mutation), so a
24
+ // failing request still ends up with a resource and operation tags. The
25
+ // request span, its operation name, and the raw source ride the active
26
+ // store the request boundary entered (validate's own `ctx.currentStore` is
27
+ // not populated yet). The cache is keyed by that raw source, not the parsed
28
+ // document — for a pre-parsed AST mercurius validates a structuredClone, so
29
+ // the document here is a different object from the one the boundary saw and
30
+ // recovers on the warm path. No-op for graphql-js/apollo/yoga, which never
31
+ // open a request span.
32
+ const requestStore = legacyStorage.getStore()
33
+ refineRequestSpan(
34
+ requestStore?.graphqlRequestSpan,
35
+ document,
36
+ requestStore?.graphqlRequestSource,
37
+ requestStore?.graphqlRequestOperationName,
38
+ this.config.signature
39
+ )
40
+
18
41
  this.startSpan('graphql.validate', {
19
42
  service: this.config.service,
20
43
  type: 'graphql',