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.
- package/index.d.ts +7 -0
- package/initialize.mjs +4 -2
- package/package.json +3 -2
- package/packages/datadog-instrumentations/src/helpers/hooks.js +2 -0
- package/packages/datadog-instrumentations/src/helpers/rewriter/index.js +5 -1
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +1 -0
- package/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/mercurius.js +31 -0
- package/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js +19 -5
- package/packages/datadog-instrumentations/src/mercurius.js +11 -0
- package/packages/datadog-instrumentations/src/otel-sdk-trace.js +15 -0
- package/packages/datadog-instrumentations/src/vitest-main.js +0 -2
- package/packages/datadog-instrumentations/src/vitest-util.js +0 -3
- package/packages/datadog-instrumentations/src/vitest-worker.js +0 -1
- package/packages/datadog-plugin-graphql/src/execute.js +4 -15
- package/packages/datadog-plugin-graphql/src/index.js +6 -0
- package/packages/datadog-plugin-graphql/src/request.js +104 -0
- package/packages/datadog-plugin-graphql/src/utils.js +177 -0
- package/packages/datadog-plugin-graphql/src/validate.js +24 -1
- package/packages/datadog-plugin-vitest/src/index.js +0 -8
- package/packages/dd-trace/src/appsec/lambda.js +8 -8
- package/packages/dd-trace/src/datastreams/writer.js +14 -2
- package/packages/dd-trace/src/encode/0.4.js +23 -2
- package/packages/dd-trace/src/encode/0.5.js +12 -1
- package/packages/dd-trace/src/encode/agentless-ci-visibility.js +11 -1
- package/packages/dd-trace/src/encode/coverage-ci-visibility.js +20 -2
- package/packages/dd-trace/src/exporters/common/writer.js +17 -1
- package/packages/dd-trace/src/guardrails/index.js +3 -1
- package/packages/dd-trace/src/guardrails/telemetry.js +40 -3
- package/packages/dd-trace/src/msgpack/chunk.js +33 -1
- package/packages/dd-trace/src/msgpack/index.js +6 -1
- package/packages/dd-trace/src/plugins/index.js +3 -0
- package/packages/dd-trace/src/service-naming/schemas/v0/graphql.js +6 -0
- package/packages/dd-trace/src/service-naming/schemas/v1/graphql.js +8 -0
- 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
|
-
|
|
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.
|
|
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": "^
|
|
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' +
|
|
73
|
+
return code + '\n' + SOURCE_MAP_PREFIX + inlineMap
|
|
70
74
|
} catch (e) {
|
|
71
75
|
log.error(e)
|
|
72
76
|
}
|
|
@@ -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
|
|
33
|
-
)
|
|
35
|
+
const returnIndex = statements.findIndex(statement =>
|
|
36
|
+
statement.type === 'ReturnStatement' && statement.argument
|
|
37
|
+
)
|
|
34
38
|
|
|
35
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 },
|
|
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 },
|
|
121
|
+
waf.run({ persistent }, req, undefined, span)
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
waf.disposeContext(
|
|
124
|
+
waf.disposeContext(req)
|
|
125
125
|
|
|
126
|
-
Reporter.finishRequest(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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),`
|