dd-trace 6.1.0 → 6.2.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 (34) hide show
  1. package/index.d.ts +7 -0
  2. package/initialize.mjs +4 -2
  3. package/package.json +3 -2
  4. package/packages/datadog-instrumentations/src/helpers/hooks.js +2 -0
  5. package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +5 -1
  6. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +1 -0
  7. package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/mercurius.js +31 -0
  8. package/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js +19 -5
  9. package/packages/datadog-instrumentations/src/mercurius.js +11 -0
  10. package/packages/datadog-instrumentations/src/otel-sdk-trace.js +15 -0
  11. package/packages/datadog-instrumentations/src/vitest-main.js +0 -2
  12. package/packages/datadog-instrumentations/src/vitest-util.js +0 -3
  13. package/packages/datadog-instrumentations/src/vitest-worker.js +0 -1
  14. package/packages/datadog-plugin-graphql/src/execute.js +4 -15
  15. package/packages/datadog-plugin-graphql/src/index.js +6 -0
  16. package/packages/datadog-plugin-graphql/src/request.js +104 -0
  17. package/packages/datadog-plugin-graphql/src/utils.js +177 -0
  18. package/packages/datadog-plugin-graphql/src/validate.js +24 -1
  19. package/packages/datadog-plugin-vitest/src/index.js +0 -8
  20. package/packages/dd-trace/src/appsec/lambda.js +8 -8
  21. package/packages/dd-trace/src/datastreams/writer.js +14 -2
  22. package/packages/dd-trace/src/encode/0.4.js +23 -2
  23. package/packages/dd-trace/src/encode/0.5.js +12 -1
  24. package/packages/dd-trace/src/encode/agentless-ci-visibility.js +11 -1
  25. package/packages/dd-trace/src/encode/coverage-ci-visibility.js +20 -2
  26. package/packages/dd-trace/src/exporters/common/writer.js +17 -1
  27. package/packages/dd-trace/src/guardrails/index.js +3 -1
  28. package/packages/dd-trace/src/guardrails/telemetry.js +40 -3
  29. package/packages/dd-trace/src/msgpack/chunk.js +33 -1
  30. package/packages/dd-trace/src/msgpack/index.js +6 -1
  31. package/packages/dd-trace/src/plugins/index.js +3 -0
  32. package/packages/dd-trace/src/service-naming/schemas/v0/graphql.js +6 -0
  33. package/packages/dd-trace/src/service-naming/schemas/v1/graphql.js +8 -0
  34. package/vendor/dist/@apm-js-collab/code-transformer/index.js +5 -6
package/index.d.ts CHANGED
@@ -2514,6 +2514,13 @@ declare namespace tracer {
2514
2514
  * This plugin automatically instruments the
2515
2515
  * [graphql](https://github.com/graphql/graphql-js) module.
2516
2516
  *
2517
+ * It also instruments [mercurius](https://github.com/mercurius-js/mercurius)
2518
+ * (the Fastify GraphQL adapter): every request through `app.graphql` /
2519
+ * `reply.graphql` opens a top-level `graphql.request` span that parents the
2520
+ * `graphql.parse`/`graphql.validate`/`graphql.execute` spans and carries the
2521
+ * request text. This span is produced even when mercurius serves the query
2522
+ * from its JIT-compiled path, where `graphql.execute` does not run.
2523
+ *
2517
2524
  * The `graphql` integration uses the operation name as the span resource name.
2518
2525
  * If no operation name is set, the resource name will always be just `query`,
2519
2526
  * `mutation` or `subscription`.
package/initialize.mjs CHANGED
@@ -75,8 +75,10 @@ export const resolve = brokenLoaders ? undefined : hookResolve
75
75
 
76
76
  if (isMainThread) {
77
77
  const require = Module.createRequire(import.meta.url)
78
- require('./init.js')
79
- if (Module.register) {
78
+ const initialized = require('./init.js')
79
+ // Only register the loader hook when instrumentation initialized. On a bailout the
80
+ // loader has nothing to instrument and can keep a short-lived process from exiting.
81
+ if (Module.register && initialized) {
80
82
  // The loader builds its own include/exclude matcher in `initialize`, so no
81
83
  // options need to cross the registration boundary.
82
84
  Module.register('./loader-hook.mjs', import.meta.url)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dd-trace",
3
- "version": "6.1.0",
3
+ "version": "6.2.0",
4
4
  "description": "Datadog APM tracing client for JavaScript",
5
5
  "main": "index.js",
6
6
  "typings": "index.d.ts",
@@ -191,7 +191,7 @@
191
191
  "@stylistic/eslint-plugin": "^5.10.0",
192
192
  "@types/mocha": "^10.0.10",
193
193
  "@types/node": "^18.19.106",
194
- "@types/sinon": "^21.0.1",
194
+ "@types/sinon": "^22.0.0",
195
195
  "axios": "^1.18.1",
196
196
  "benchmark": "^2.1.4",
197
197
  "body-parser": "^2.3.0",
@@ -230,6 +230,7 @@
230
230
  "semifies": "^1.0.0",
231
231
  "semver": "^7.8.5",
232
232
  "sinon": "^22.0.0",
233
+ "source-map-support": "^0.5.21",
233
234
  "test-exclude": "^8.0.0",
234
235
  "tiktoken": "^1.0.21",
235
236
  "typescript": "^6.0.3",
@@ -48,6 +48,7 @@ module.exports = {
48
48
  '@langchain/core': { esmFirst: true, fn: () => require('../langchain') },
49
49
  '@node-redis/client': () => require('../redis'),
50
50
  '@opensearch-project/opensearch': () => require('../opensearch'),
51
+ '@opentelemetry/sdk-trace': () => require('../otel-sdk-trace'),
51
52
  '@opentelemetry/sdk-trace-node': () => require('../otel-sdk-trace'),
52
53
  '@prisma/client': { esmFirst: true, fn: () => require('../prisma') },
53
54
  './runtime/library.js': () => require('../prisma'),
@@ -105,6 +106,7 @@ module.exports = {
105
106
  lodash: () => require('../lodash'),
106
107
  mariadb: () => require('../mariadb'),
107
108
  memcached: () => require('../memcached'),
109
+ mercurius: () => require('../mercurius'),
108
110
  'microgateway-core': () => require('../microgateway-core'),
109
111
  mocha: () => require('../mocha'),
110
112
  'mocha-each': () => require('../mocha'),
@@ -37,6 +37,10 @@ for (const matcher of [matcherCjs, matcherEsm]) {
37
37
  matcher.addTransform('waitForAsyncEnd', waitForAsyncEnd)
38
38
  }
39
39
 
40
+ // Keep the marker split: source-map scanners can read a contiguous token in
41
+ // string literals as this file's own inline map.
42
+ const SOURCE_MAP_PREFIX = '//# sourceMapping' + 'URL=data:application/json;base64,'
43
+
40
44
  function rewrite (content, filename, format) {
41
45
  if (!content) return content
42
46
  if (!filename.includes('node_modules')) return content
@@ -66,7 +70,7 @@ function rewrite (content, filename, format) {
66
70
 
67
71
  const inlineMap = Buffer.from(map).toString('base64')
68
72
 
69
- return code + '\n' + `//# sourceMappingURL=data:application/json;base64,${inlineMap}`
73
+ return code + '\n' + SOURCE_MAP_PREFIX + inlineMap
70
74
  } catch (e) {
71
75
  log.error(e)
72
76
  }
@@ -8,6 +8,7 @@ module.exports = [
8
8
  ...require('./graphql'),
9
9
  ...require('./langchain'),
10
10
  ...require('./langgraph'),
11
+ ...require('./mercurius'),
11
12
  ...require('./modelcontextprotocol-sdk'),
12
13
  ...require('./playwright'),
13
14
  ...require('./aws-durable-execution-sdk-js'),
@@ -0,0 +1,31 @@
1
+ 'use strict'
2
+
3
+ // Mercurius funnels every GraphQL request through the named async function
4
+ // `fastifyGraphQl` (decorated as both `app.graphql` and, via `reply.graphql`,
5
+ // the per-request entry point). Wrapping that one function gives a single
6
+ // top-level span per operation regardless of how the query reaches mercurius
7
+ // — HTTP POST/GET, batched queries, or a programmatic `app.graphql()` call —
8
+ // and regardless of whether the query runs through graphql's `execute` (cold)
9
+ // or a JIT-compiled query (warm), where no `graphql.execute` span fires.
10
+ //
11
+ // The function name and signature `(source, context, variables, operationName)`
12
+ // are stable across the supported major range (verified against 10.x and 16.x),
13
+ // so a `functionName` match needs no per-version file paths.
14
+ module.exports = [
15
+ {
16
+ module: {
17
+ // Floor at 13: it is the oldest major whose fastify-plugin peer (^4)
18
+ // accepts fastify 4, which installs and runs on the oldest supported Node
19
+ // (18). 15+ requires Node 20 and fastify 5, covered on the latest-Node CI
20
+ // leg. The `fastifyGraphQl` funnel is unchanged across this whole range.
21
+ name: 'mercurius',
22
+ versionRange: '>=13',
23
+ filePath: 'index.js',
24
+ },
25
+ functionQuery: {
26
+ functionName: 'fastifyGraphQl',
27
+ kind: 'Async',
28
+ },
29
+ channelName: 'apm:graphql:request',
30
+ },
31
+ ]
@@ -8,6 +8,10 @@
8
8
  // the library, replace the custom registration with the built-in option and
9
9
  // remove the entry here.
10
10
 
11
+ const assert = require('node:assert')
12
+
13
+ const clone = require('../../../../../vendor/dist/rfdc')({ proto: false, circles: false })
14
+
11
15
  const { parse, query } = require('./compiler')
12
16
 
13
17
  module.exports = { waitForAsyncEnd }
@@ -28,20 +32,30 @@ function waitForAsyncEnd (_state, node) {
28
32
  return
29
33
  }
30
34
 
31
- const returnIndex = statements.findIndex(statement => (
32
- statement.type === 'ReturnStatement' && statement.argument?.name === 'result'
33
- ))
35
+ const returnIndex = statements.findIndex(statement =>
36
+ statement.type === 'ReturnStatement' && statement.argument
37
+ )
34
38
 
35
- if (returnIndex === -1) return
39
+ // The generated fulfillment handler always ends in a return; a miss means the
40
+ // upstream template changed and the caller's try/catch falls back to the
41
+ // unwrapped source.
42
+ assert(returnIndex !== -1, 'waitForAsyncEnd: no return statement to wait on')
36
43
 
37
44
  const waitStatements = parse(`
38
45
  function wrapper () {
39
46
  const __apm$asyncEndPromise = __apm$ctx.asyncEndPromise;
40
47
  if (__apm$asyncEndPromise && typeof __apm$asyncEndPromise.then === 'function') {
41
- return __apm$asyncEndPromise.then(() => result, () => result);
48
+ return __apm$asyncEndPromise.then(() => __apm$result, () => __apm$result);
42
49
  }
43
50
  }
44
51
  `).body[0].body.body
45
52
 
53
+ // Resolve to whatever the fulfillment handler returns (its return argument),
54
+ // so a subscriber that reassigned `__apm$ctx.result` in `asyncEnd` still wins.
55
+ const returnArgument = statements[returnIndex].argument
56
+ const { arguments: onSettled } = waitStatements[1].consequent.body[0].argument
57
+ onSettled[0].body = clone(returnArgument)
58
+ onSettled[1].body = clone(returnArgument)
59
+
46
60
  statements.splice(returnIndex, 0, ...waitStatements)
47
61
  }
@@ -0,0 +1,11 @@
1
+ // AUTO-GENERATED by dd-compile
2
+ // Orchestrion rewriter handles the actual instrumentation via JSON config.
3
+ // This file registers the module hooks for the rewriter to process.
4
+
5
+ 'use strict'
6
+
7
+ const { addHook, getHooks } = require('./helpers/instrument')
8
+
9
+ for (const hook of getHooks('mercurius')) {
10
+ addHook(hook, exports => exports)
11
+ }
@@ -16,6 +16,21 @@ if (isOtelSdkEnabled()) {
16
16
  })
17
17
  return mod
18
18
  })
19
+
20
+ // As of @opentelemetry/sdk-node 0.220.0, NodeSDK builds its provider from
21
+ // @opentelemetry/sdk-trace's TracerProvider instead of sdk-trace-node's
22
+ // NodeTracerProvider, so the hook above no longer intercepts it. Wrap this
23
+ // export too, otherwise DD_TRACE_OTEL_ENABLED spans never reach the tracer.
24
+ addHook({
25
+ name: '@opentelemetry/sdk-trace',
26
+ file: 'build/src/TracerProvider.js',
27
+ versions: ['*'],
28
+ }, (mod) => {
29
+ shimmer.wrap(mod, 'TracerProvider', () => {
30
+ return tracer.TracerProvider
31
+ })
32
+ return mod
33
+ })
19
34
  }
20
35
 
21
36
  function isOtelSdkEnabled () {
@@ -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)
@@ -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._tracerConfig, 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',
@@ -67,7 +67,6 @@ class VitestPlugin extends CiPlugin {
67
67
  testCommand: this.command,
68
68
  repositoryRoot: this.repositoryRoot,
69
69
  codeOwnersEntries: this.codeOwnersEntries,
70
- testEnvironmentMetadata: this.testEnvironmentMetadata,
71
70
  })
72
71
  })
73
72
 
@@ -324,7 +323,6 @@ class VitestPlugin extends CiPlugin {
324
323
  const {
325
324
  codeOwnersEntries,
326
325
  repositoryRoot,
327
- testEnvironmentMetadata,
328
326
  requestErrorTags,
329
327
  testSuiteAbsolutePath,
330
328
  frameworkVersion,
@@ -334,12 +332,6 @@ class VitestPlugin extends CiPlugin {
334
332
 
335
333
  const testCommand = ctx.testCommand || 'vitest run'
336
334
  const { testSessionId, testModuleId } = ctx
337
- if (testEnvironmentMetadata) {
338
- this.testEnvironmentMetadata = {
339
- ...this.testEnvironmentMetadata,
340
- ...testEnvironmentMetadata,
341
- }
342
- }
343
335
  this._setRepositoryRoot(repositoryRoot, codeOwnersEntries)
344
336
  this.command = testCommand
345
337
  this.frameworkVersion = frameworkVersion
@@ -7,9 +7,7 @@ const addresses = require('./addresses')
7
7
  const Reporter = require('./reporter')
8
8
  const waf = require('./waf')
9
9
 
10
- // Tracks spans for which start-invocation has been processed, so that
11
- // end-invocation can gate correctly
12
- const activeInvocations = new WeakSet()
10
+ const activeInvocations = new WeakMap()
13
11
 
14
12
  /**
15
13
  * Maps pre-extracted HTTP data from the Lambda event to WAF addresses,
@@ -31,7 +29,8 @@ function onLambdaStartInvocation (data) {
31
29
  return
32
30
  }
33
31
 
34
- activeInvocations.add(span)
32
+ const req = { headers: headers ?? {} }
33
+ activeInvocations.set(span, req)
35
34
 
36
35
  span.addTags({
37
36
  '_dd.appsec.enabled': 1,
@@ -74,7 +73,7 @@ function onLambdaStartInvocation (data) {
74
73
  persistent[addresses.HTTP_INCOMING_COOKIES] = cookies
75
74
  }
76
75
 
77
- waf.run({ persistent }, span, undefined, span)
76
+ waf.run({ persistent }, req, undefined, span)
78
77
  } catch (err) {
79
78
  log.error('[ASM] Error in Lambda start-invocation handler', err)
80
79
  }
@@ -100,6 +99,7 @@ function onLambdaEndInvocation (data) {
100
99
  return
101
100
  }
102
101
 
102
+ const req = activeInvocations.get(span)
103
103
  activeInvocations.delete(span)
104
104
 
105
105
  let hasPersistentData = false
@@ -118,12 +118,12 @@ function onLambdaEndInvocation (data) {
118
118
  }
119
119
 
120
120
  if (hasPersistentData) {
121
- waf.run({ persistent }, span, undefined, span)
121
+ waf.run({ persistent }, req, undefined, span)
122
122
  }
123
123
 
124
- waf.disposeContext(span)
124
+ waf.disposeContext(req)
125
125
 
126
- Reporter.finishRequest(span, null, {}, undefined, span)
126
+ Reporter.finishRequest(req, null, {}, undefined, span)
127
127
  } catch (err) {
128
128
  log.error('[ASM] Error in Lambda end-invocation handler', err)
129
129
  }
@@ -4,7 +4,7 @@ const zlib = require('zlib')
4
4
  const pkg = require('../../../../package.json')
5
5
  const log = require('../log')
6
6
  const request = require('../exporters/common/request')
7
- const { encode: encodeMsgpack } = require('../msgpack')
7
+ const { encode: encodeMsgpack, MAX_SIZE: MAX_CHUNK_SIZE } = require('../msgpack')
8
8
 
9
9
  function makeRequest (data, url, cb) {
10
10
  const options = {
@@ -36,7 +36,19 @@ class DataStreamsWriter {
36
36
  log.debug('Maximum number of active requests reached. Payload discarded: %j', payload)
37
37
  return
38
38
  }
39
- const encodedPayload = encodeMsgpack(payload)
39
+
40
+ let encodedPayload
41
+ try {
42
+ encodedPayload = encodeMsgpack(payload)
43
+ } catch (error) {
44
+ if (error.code !== 'ERR_MSGPACK_CHUNK_OVERFLOW') throw error
45
+ // The msgpack-encoded pipeline-stats payload exceeded the agent
46
+ // intake cap. Dropping it locally is safer than letting the
47
+ // RangeError crash the host process; the agent would reject the
48
+ // oversized payload at the network boundary anyway.
49
+ log.error('DataStreamsWriter dropped a payload that exceeded the %d byte chunk cap', MAX_CHUNK_SIZE)
50
+ return
51
+ }
40
52
 
41
53
  zlib.gzip(encodedPayload, { level: 1 }, (err, compressedData) => {
42
54
  if (err) {
@@ -1,7 +1,7 @@
1
1
  'use strict'
2
2
 
3
3
  const getConfig = require('../config')
4
- const { MsgpackChunk } = require('../msgpack')
4
+ const { MsgpackChunk, MAX_SIZE: MAX_CHUNK_SIZE } = require('../msgpack')
5
5
  const log = require('../log')
6
6
  const { normalizeSpan, eventTimeNano } = require('./tags-processors')
7
7
 
@@ -265,7 +265,28 @@ class AgentEncoder {
265
265
 
266
266
  this._traceCount++
267
267
 
268
- this._encode(bytes, trace)
268
+ try {
269
+ this._encode(bytes, trace)
270
+ } catch (error) {
271
+ if (error.code !== 'ERR_MSGPACK_CHUNK_OVERFLOW') throw error
272
+ // The trace, or the queued payload it joined, hit the chunk cap.
273
+ // Rolling back just the in-flight trace is unsafe: the string cache
274
+ // may already hold subarrays / indices pointing at bytes we'd
275
+ // discard, and the next encode would emit stale bytes against a
276
+ // smaller string table. Drop the whole queued payload so the next
277
+ // encode starts from a clean state. Emitting a partial buffer would
278
+ // corrupt the msgpack wire. The virtual `reset()` is called (not
279
+ // `_reset()`) so subclasses can clear their own per-payload state
280
+ // (e.g. `AgentlessCiVisibilityEncoder._eventCount`).
281
+ const dropped = this._traceCount
282
+ this.reset()
283
+ log.error(
284
+ 'Trace encoder reset after exceeding the %d byte chunk cap; dropped %d trace(s)',
285
+ MAX_CHUNK_SIZE,
286
+ dropped
287
+ )
288
+ return
289
+ }
269
290
 
270
291
  if (this.#debugEncoding) {
271
292
  const end = bytes.length
@@ -1,5 +1,6 @@
1
1
  'use strict'
2
2
 
3
+ const { MAX_SIZE, OverflowError } = require('../msgpack')
3
4
  const { normalizeSpan } = require('./tags-processors')
4
5
  const { AgentEncoder: BaseEncoder, stringifySpanEvents } = require('./0.4')
5
6
 
@@ -30,7 +31,17 @@ class AgentEncoder extends BaseEncoder {
30
31
  const prefixSize = 1
31
32
  const stringSize = this._stringBytes.length + 5
32
33
  const traceSize = this._traceBytes.length + 5
33
- const buffer = Buffer.allocUnsafe(prefixSize + stringSize + traceSize)
34
+ const payloadSize = prefixSize + stringSize + traceSize
35
+
36
+ // The string table and the trace bytes are capped independently, so both
37
+ // can sit just under the cap while their concatenation crosses it. `encode`
38
+ // never sees this overflow — it only exists once the two are summed here —
39
+ // so the writer's flush-time catch drops the payload.
40
+ if (payloadSize > MAX_SIZE) {
41
+ throw new OverflowError(payloadSize)
42
+ }
43
+
44
+ const buffer = Buffer.allocUnsafe(payloadSize)
34
45
 
35
46
  buffer[0] = ARRAY_OF_TWO
36
47
 
@@ -7,7 +7,7 @@ const {
7
7
  TELEMETRY_ENDPOINT_PAYLOAD_SERIALIZATION_MS,
8
8
  TELEMETRY_ENDPOINT_PAYLOAD_EVENTS_COUNT,
9
9
  } = require('../ci-visibility/telemetry')
10
- const { MsgpackChunk } = require('../msgpack')
10
+ const { MsgpackChunk, MAX_SIZE, OverflowError } = require('../msgpack')
11
11
  const { AgentEncoder } = require('./0.4')
12
12
  const {
13
13
  truncateSpanTestOpt,
@@ -371,6 +371,16 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder {
371
371
 
372
372
  const eventsBytes = this._traceBytes
373
373
  const totalSize = prefixBytes.length + eventsBytes.length
374
+
375
+ // The metadata prefix (built here, not during `encode`) and the events are
376
+ // capped independently, so both can stay under the cap while the assembled
377
+ // payload crosses it. An oversized metadata tag also overflows the prefix
378
+ // chunk itself inside `_encodePayloadStart` above; either way the tagged
379
+ // error propagates to the writer's flush-time catch to drop the payload.
380
+ if (totalSize > MAX_SIZE) {
381
+ throw new OverflowError(totalSize)
382
+ }
383
+
374
384
  const buffer = Buffer.allocUnsafe(totalSize)
375
385
  prefixBytes.buffer.copy(buffer, 0, 0, prefixBytes.length)
376
386
  eventsBytes.buffer.copy(buffer, prefixBytes.length, 0, eventsBytes.length)
@@ -1,11 +1,12 @@
1
1
  'use strict'
2
- const { MsgpackChunk } = require('../msgpack')
2
+ const { MsgpackChunk, MAX_SIZE: MAX_CHUNK_SIZE } = require('../msgpack')
3
3
 
4
4
  const {
5
5
  distributionMetric,
6
6
  TELEMETRY_ENDPOINT_PAYLOAD_SERIALIZATION_MS,
7
7
  TELEMETRY_ENDPOINT_PAYLOAD_EVENTS_COUNT,
8
8
  } = require('../ci-visibility/telemetry')
9
+ const log = require('../log')
9
10
  const FormData = require('../exporters/common/form-data')
10
11
  const { AgentEncoder } = require('./0.4')
11
12
 
@@ -40,7 +41,24 @@ class CoverageCIVisibilityEncoder extends AgentEncoder {
40
41
  const startTime = Date.now()
41
42
 
42
43
  this._coveragesCount++
43
- this.encodeCodeCoverage(this._coverageBytes, coverage)
44
+ try {
45
+ this.encodeCodeCoverage(this._coverageBytes, coverage)
46
+ } catch (error) {
47
+ if (error.code !== 'ERR_MSGPACK_CHUNK_OVERFLOW') throw error
48
+ // The coverage payload hit the chunk cap. `_coverageBytes` may
49
+ // hold a partial entry and a stale `coverages` array prefix. Drop
50
+ // the whole queued payload so the next encode starts from a clean
51
+ // state; the parent `AgentEncoder` does the equivalent for its own
52
+ // bytes / string cache via the inherited `_reset()`.
53
+ const dropped = this._coveragesCount
54
+ this.reset()
55
+ log.error(
56
+ 'Coverage encoder reset after exceeding the %d byte chunk cap; dropped %d coverage(s)',
57
+ MAX_CHUNK_SIZE,
58
+ dropped
59
+ )
60
+ return
61
+ }
44
62
 
45
63
  distributionMetric(
46
64
  TELEMETRY_ENDPOINT_PAYLOAD_SERIALIZATION_MS,
@@ -3,6 +3,7 @@
3
3
  const { channel } = require('dc-polyfill')
4
4
 
5
5
  const log = require('../../log')
6
+ const { MAX_SIZE: MAX_CHUNK_SIZE } = require('../../msgpack')
6
7
  const request = require('./request')
7
8
  const { safeJSONStringify } = require('./util')
8
9
 
@@ -27,7 +28,22 @@ class Writer {
27
28
  this.#isFirstFlush = false
28
29
  this._beforeFirstFlush()
29
30
  }
30
- const payload = this._encoder.makePayload()
31
+ let payload
32
+ try {
33
+ payload = this._encoder.makePayload()
34
+ } catch (error) {
35
+ if (error.code !== 'ERR_MSGPACK_CHUNK_OVERFLOW') throw error
36
+ // Multi-chunk encoders (v0.5, CI Visibility) only learn the assembled
37
+ // payload exceeds the cap when `makePayload` stitches the chunks
38
+ // together, after `encode` already returned — so the encode-time catch
39
+ // never sees it. Drop the queued payload here instead of letting the
40
+ // RangeError escape into the host application; the agent would reject
41
+ // the oversized payload at the network boundary anyway.
42
+ this._encoder.reset()
43
+ log.error('Writer dropped %d trace(s) that exceeded the %d byte chunk cap', count, MAX_CHUNK_SIZE)
44
+ done()
45
+ return
46
+ }
31
47
  this._sendPayload(payload, count, done)
32
48
  } else {
33
49
  done()
@@ -48,6 +48,8 @@ function guard (fn) {
48
48
  if (!clobberBailout && (NODE_MAJOR < minMajor || NODE_MAJOR >= nextMajor)) {
49
49
  initBailout = true
50
50
  var runtimeInfo = 'Incompatible runtime Node.js ' + version + ', supported runtimes: Node.js ' + supportedRange
51
+ // When not forced, the process bails out here and may call process.exit() right away;
52
+ // forward synchronously so the telemetry child can't outlive us and wedge the exit.
51
53
  telemetry([
52
54
  { name: 'abort', tags: ['reason:incompatible_runtime'] },
53
55
  { name: 'abort.runtime', tags: [] }
@@ -55,7 +57,7 @@ function guard (fn) {
55
57
  result: 'abort',
56
58
  result_class: 'incompatible_runtime',
57
59
  result_reason: runtimeInfo
58
- })
60
+ }, !forced)
59
61
  log.info('Aborting application instrumentation due to incompatible_runtime.')
60
62
  log.info('Found incompatible runtime Node.js %s, Supported runtimes: Node.js %s.', version, supportedRange)
61
63
  if (forced) {
@@ -1,7 +1,13 @@
1
1
  'use strict'
2
2
 
3
3
  var fs = require('fs')
4
- var spawn = require('child_process').spawn
4
+ // Capture the child_process functions at load time, before the tracer wraps the module.
5
+ // Reaching through require('child_process') at send time would route the forwarder through
6
+ // the tracer's own child_process instrumentation once the tracer is initialized.
7
+ var childProcess = require('child_process')
8
+ var spawn = childProcess.spawn
9
+ // eslint-disable-next-line n/no-unsupported-features/node-builtins
10
+ var spawnSync = childProcess.spawnSync
5
11
  var tracerVersion = require('../../../../package.json').version
6
12
  var log = require('./log')
7
13
 
@@ -47,7 +53,7 @@ function shouldSend (point) {
47
53
  return true
48
54
  }
49
55
 
50
- function sendTelemetry (name, tags, resultMetadata) {
56
+ function sendTelemetry (name, tags, resultMetadata, synchronous) {
51
57
  var points = name
52
58
  if (typeof name === 'string') {
53
59
  points = [{ name: name, tags: tags || [] }]
@@ -74,6 +80,37 @@ function sendTelemetry (name, tags, resultMetadata) {
74
80
  }
75
81
  }
76
82
 
83
+ var payload = JSON.stringify({ metadata: currentMetadata, points: points })
84
+
85
+ // A forwarder spawned asynchronously can still be tearing down its stdio pipes when the
86
+ // injected app calls process.exit(); on Node 24.0.0/24.1.x that deadlocks the exit
87
+ // (fixed upstream in 24.2), hanging short-lived single-step-install processes. On the
88
+ // bailout path the caller passes synchronous=true, so the child is fully reaped before we
89
+ // return and nothing survives to race the exit. spawnSync is only reached on that path,
90
+ // before any instrumentation is active, so it never traces the forwarder. It exists since
91
+ // Node 0.11.12; the guardrails still target >=0.8, which predates the exit bug anyway.
92
+ if (synchronous && spawnSync) {
93
+ // Bound the blocking send: this telemetry is best-effort and the whole point of the
94
+ // synchronous path is to avoid a hung exit, so a forwarder that wedges must not become a
95
+ // new hard hang. On timeout spawnSync kills the child and returns error.code ETIMEDOUT.
96
+ var result = spawnSync(telemetryForwarderPath, ['library_entrypoint'], {
97
+ input: payload,
98
+ stdio: ['pipe', 'ignore', 'ignore'],
99
+ timeout: 1000,
100
+ killSignal: 'SIGKILL'
101
+ })
102
+ if (result.error) {
103
+ if (result.error.code === 'ETIMEDOUT') {
104
+ log.error('Telemetry forwarder timed out')
105
+ } else {
106
+ log.error('Failed to spawn telemetry forwarder')
107
+ }
108
+ } else if (result.status) {
109
+ log.error('Telemetry forwarder exited with code', result.status)
110
+ }
111
+ return
112
+ }
113
+
77
114
  var proc = spawn(telemetryForwarderPath, ['library_entrypoint'], {
78
115
  stdio: 'pipe'
79
116
  })
@@ -88,5 +125,5 @@ function sendTelemetry (name, tags, resultMetadata) {
88
125
  proc.stdin.on('error', function () {
89
126
  log.error('Failed to write telemetry data to telemetry forwarder')
90
127
  })
91
- proc.stdin.end(JSON.stringify({ metadata: currentMetadata, points: points }))
128
+ proc.stdin.end(payload)
92
129
  }
@@ -10,6 +10,33 @@ const SHRINK_AFTER_FLUSHES = 32
10
10
  // shape: after a halving step the post-shrink fill is the prior peak doubled,
11
11
  // still under 50 %.
12
12
  const SHRINK_USAGE_RATIO = 4
13
+ // Hard cap on chunk growth. The agent's trace intake rejects payloads over
14
+ // 50 MiB, so anything past that is dead on arrival anyway. A pathological
15
+ // single trace (unsanitized meta tag the size of a media file, multi-MB
16
+ // stack trace) used to grow the buffer without limit until either the
17
+ // allocation failed or the process tripped its memory ceiling. `reserve`
18
+ // now refuses the growth with a tagged `RangeError`; `AgentEncoder` catches
19
+ // it, resets the in-flight payload, and logs.
20
+ const MAX_SIZE = 50 * 1024 * 1024 // 50 MiB
21
+
22
+ /**
23
+ * Thrown when a chunk — or an assembled payload stitched from several chunks —
24
+ * would cross `MAX_SIZE`. Shared so the `reserve` cap and the per-encoder
25
+ * assembled-size guards (`0.5`, agentless CI Visibility) raise the same `code`,
26
+ * which every writer's `flush` recognises to drop the payload instead of
27
+ * crashing the host.
28
+ */
29
+ class OverflowError extends RangeError {
30
+ code = 'ERR_MSGPACK_CHUNK_OVERFLOW'
31
+
32
+ /**
33
+ * @param {number} needed Requested total byte size.
34
+ */
35
+ constructor (needed) {
36
+ super(`MsgpackChunk capped at ${MAX_SIZE} bytes; requested ${needed}`)
37
+ this.name = 'OverflowError'
38
+ }
39
+ }
13
40
 
14
41
  /**
15
42
  * Resizable msgpack write buffer. Owns the byte-layout primitives the encoder
@@ -100,11 +127,14 @@ class MsgpackChunk {
100
127
  const needed = this.length + size
101
128
 
102
129
  if (needed > this.buffer.length) {
130
+ if (needed > MAX_SIZE) {
131
+ throw new OverflowError(needed)
132
+ }
103
133
  let newSize = this.buffer.length
104
134
  // `*= 2` instead of `<<= 1`: `1073741824 << 1` is negative as int32,
105
135
  // and msgpack values can legitimately reach the multi-GiB range.
106
136
  while (newSize < needed) newSize *= 2
107
- this.#resize(newSize)
137
+ this.#resize(Math.min(newSize, MAX_SIZE))
108
138
  }
109
139
 
110
140
  this.length += size
@@ -454,3 +484,5 @@ class MsgpackChunk {
454
484
  }
455
485
 
456
486
  module.exports = MsgpackChunk
487
+ module.exports.MAX_SIZE = MAX_SIZE
488
+ module.exports.OverflowError = OverflowError
@@ -97,4 +97,9 @@ function writeMap (bytes, value) {
97
97
  }
98
98
  }
99
99
 
100
- module.exports = { MsgpackChunk, encode }
100
+ module.exports = {
101
+ MsgpackChunk,
102
+ encode,
103
+ MAX_SIZE: MsgpackChunk.MAX_SIZE,
104
+ OverflowError: MsgpackChunk.OverflowError,
105
+ }
@@ -84,6 +84,9 @@ const plugins = {
84
84
  get langchain () { return require('../../../datadog-plugin-langchain/src') },
85
85
  get mariadb () { return require('../../../datadog-plugin-mariadb/src') },
86
86
  get memcached () { return require('../../../datadog-plugin-memcached/src') },
87
+ // mercurius is traced under the graphql plugin: its instrumentation opens the
88
+ // top-level graphql.request span handled by the graphql CompositePlugin.
89
+ get mercurius () { return require('../../../datadog-plugin-graphql/src') },
87
90
  get 'microgateway-core' () { return require('../../../datadog-plugin-microgateway-core/src') },
88
91
  get mocha () { return require('../../../datadog-plugin-mocha/src') },
89
92
  get 'mocha-each' () { return require('../../../datadog-plugin-mocha/src') },
@@ -8,6 +8,12 @@ const graphql = {
8
8
  opName: () => 'graphql.execute',
9
9
  serviceName: identityService,
10
10
  },
11
+ // Top-level request span for drivers that funnel through a single entry
12
+ // point (mercurius). Matches the cross-tracer `graphql.request` v0 name.
13
+ request: {
14
+ opName: () => 'graphql.request',
15
+ serviceName: identityService,
16
+ },
11
17
  },
12
18
  }
13
19
 
@@ -8,6 +8,14 @@ const graphql = {
8
8
  opName: () => 'graphql.server.request',
9
9
  serviceName: identityService,
10
10
  },
11
+ // Top-level request span for drivers that funnel through a single entry
12
+ // point (mercurius). Matches the cross-tracer `graphql.server.request` v1
13
+ // name. The v1 overlap with the execute span's name above is a known wart
14
+ // tracked for a separate cross-tracer unification (breaking) change.
15
+ request: {
16
+ opName: () => 'graphql.server.request',
17
+ serviceName: identityService,
18
+ },
11
19
  },
12
20
  }
13
21
 
@@ -8,7 +8,7 @@
8
8
  const __apm$${T} = this["${T}"]
9
9
  this["${T}"] = function () {}
10
10
  if (typeof __apm$${T} === 'function') {
11
- Object.defineProperty(this["${T}"], 'length', {
11
+ Object.defineProperty(this["${T}"], 'length', {
12
12
  value: __apm$${T}.length,
13
13
  configurable: true
14
14
  })
@@ -94,7 +94,7 @@
94
94
  if (typeof promise?.then !== 'function') {
95
95
  __apm$ctx.result = promise;
96
96
  ${k}
97
- return promise;
97
+ return __apm$ctx.result;
98
98
  }
99
99
  // Mirror Node.js core diagnostics_channel behaviour: for native Promise
100
100
  // instances, chain normally (safe since there is no subclass API to
@@ -109,7 +109,7 @@
109
109
  ${k}
110
110
  ${w}.asyncStart.publish(__apm$ctx);
111
111
  ${w}.asyncEnd.publish(__apm$ctx);
112
- return result;
112
+ return __apm$ctx.result;
113
113
  },
114
114
  err => {
115
115
  __apm$ctx.error = err;
@@ -151,10 +151,8 @@
151
151
 
152
152
  return ${w}.start.runStores(__apm$ctx, () => {
153
153
  try {
154
- const result = __apm$traced();
155
- __apm$ctx.result = result;
154
+ __apm$ctx.result = __apm$traced();
156
155
  ${k}
157
- return result;
158
156
  } catch (err) {
159
157
  __apm$ctx.error = err;
160
158
  ${w}.error.publish(__apm$ctx);
@@ -163,6 +161,7 @@
163
161
  __apm$ctx.self ??= this;
164
162
  ${w}.end.publish(__apm$ctx);
165
163
  }
164
+ return __apm$ctx.result;
166
165
  });
167
166
  }
168
167
  `)}function declareIteratorChannel(i,s){let{channelName:k,module:{name:S}}=i,w=formatChannelVariable(k+":next");if(s.body.some(i=>i.declarations?.[0]?.id?.name===w))return;let C=formatChannelVariable(k),T=s.body.findIndex(i=>i.declarations?.[0]?.id?.name===C),_=`const ${w} = tr_ch_apm_tracingChannel("orchestrion:${S}:${k}:next")`;s.body.splice(T+1,0,E(_).body[0])}function generateIterPatch(i,s,k){let{channelName:S}=i,E=formatChannelVariable(S+":next");return declareIteratorChannel(i,k),`