feat(graphql): add mercurius support with a top-level graphql.request span#9142
Conversation
Overall package sizeSelf size: 6.68 MB Dependency sizes| name | version | self size | total size | |------|---------|-----------|------------| | import-in-the-middle | 3.3.1 | 122.62 kB | 437.94 kB | | opentracing | 0.14.7 | 194.81 kB | 194.81 kB | | dc-polyfill | 0.1.11 | 25.74 kB | 25.74 kB |🤖 This report was automatically generated by heaviest-objects-in-the-universe |
🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: b6c2dbe | Docs | Datadog PR Page | Give us feedback! |
BenchmarksBenchmark execution time: 2026-07-08 16:14:39 Comparing candidate commit b6c2dbe in PR branch Found 0 performance improvements and 0 performance regressions! Performance is the same for 2311 metrics, 47 unstable metrics.
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #9142 +/- ##
========================================
Coverage 96.54% 96.55%
========================================
Files 915 918 +3
Lines 121018 121394 +376
Branches 20840 20990 +150
========================================
+ Hits 116835 117207 +372
- Misses 4183 4187 +4
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b9e20ef6d1
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
e9fe94c to
0c33ee0
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ecc38dc2d2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
… span Mercurius funnels every operation through one async function and parses, validates, and executes internally, so a query served from its JIT-compiled path runs no `graphql.execute` and produced no span at all. A top-level `graphql.request` span now opens at that funnel (`fastifyGraphQl`) via orchestrion, parents the parse/validate/execute sub-spans, and carries the request text plus operation name/type. It survives the JIT warm path, where no execute span fires. 1. The request boundary only sees the raw source and operationName; the execute sub-plugin backfills the precise operation signature onto the request span once the document is parsed, so the hot path never re-parses. 2. v0 keeps the cross-tracer `graphql.request` name; v1 uses `graphql.server.request`. The v1 overlap with the execute span's name is a known cross-tracer wart left for a separate breaking change. 3. The funnel yields one request span per operation, so a batched request produces one span per operation rather than one per HTTP request. Fixes: #1141
This makes sure the top-level graphql.request span reports the operation signature, type, and name for JIT-compiled mercurius queries. Mercurius compiles a query after its first run and serves later requests from the compiled path, which never calls graphql.execute. The execute sub-plugin is where the parsed document yields the operation signature, so on that warm path the request span was left with only the provisional operationName as its resource and no graphql.operation.type. The cold run now caches the computed signature/type/name keyed by the raw query text (the same key mercurius uses for its own document LRU, bounded so a flood of distinct queries can't grow it without limit). The request boundary reads it back on the warm path, so it never re-parses on the hot path.
The existing plugin spec loads mercurius, fastify, and dd-trace directly from this repo's node_modules, so it never exercises the orchestrion rewriter's real path: an ESM app importing mercurius from an npm-installed tarball, instrumented via the `--loader` hook. A packaging gap (a missing `files` entry, a broken rewriter transform under real module resolution) would pass the plugin spec but break for every actual consumer. This spawns a sandboxed ESM app across all default/star/destructure import shapes and the full withVersions() mercurius matrix, asserting a graphql.request span reaches the agent for a query served through app.graphql.
…equest span The execute sub-plugin populated the source-keyed LRU on every parsed document, storing the raw query text as the key even for graphql-js, Apollo, and Yoga executions that never open a graphql.request span and can never read the cache back. That bypassed the `source` config's privacy gate and made the majority of GraphQL users pay an LRU set on the every-execute hot path for entries nothing consumes. Only a mercurius request span reads the cache on its JIT warm path, so the write now sits behind the existing request-span presence check — graphql-js, Apollo, and Yoga no longer touch the LRU. Moving it after the ddRequestRefined guard also stops a re-entrant execute (Yoga's normalizedExecutor) from writing the same entry twice.
The ESM integration spec iterated the full withVersions matrix, so the oldest-maintenance-LTS CI leg (Node 18) sandboxed the mercurius 15+ variants. Those ship fastify 5, which requires Node 20.9+, so the leg failed before reaching the latest-Node run. Restrict to the 13/14 line on older Node, mirroring the non-ESM spec's supportedOnThisNode gate.
The JIT warm-path cache was keyed by source alone. Mercurius keys its own document LRU by source but compiles the JIT for a single operationName, and the compiled query then serves that operation for every later request sharing the source. When a document holds two named operations, running the first cold and the second on the compiled path left the second request's top-level graphql.request span carrying the first operation's resource, name, and type. Keying the cache by source plus operationName keeps the two apart, so the warm request reports the operation mercurius actually ran.
bb69c4a to
a907fca
Compare
The top-level graphql.request span was labeled only from graphql.execute, but mercurius has two paths where execute never fires for the selected operation: 1. A multi-operation document parses once. If one operation runs cold and a sibling is then served exclusively through mercurius's JIT-compiled path, execute never runs for the sibling, so its warm request spans fell back to a bare operation name with no type or signature. The cold parse now caches every named operation in the document, so a JIT-only selection is labeled. 2. When mercurius rejects after parse/validate but before execute (unknown field, GET mutation), the execute-only labeling never happened. A named query sent without a separate operationName argument then finished its error span with an empty resource and no operation tags. The refinement moves to the validate boundary, which is the first place the parsed document exists and precedes both execute and every pre-execute rejection. validate reads the request span and the requested operation name off the active store the request boundary entered, since its own bindStore ctx is not populated yet.
a907fca to
61631e8
Compare
crysmags
left a comment
There was a problem hiding this comment.
blocking: pre-parsed DocumentNode requests lose their operation metadata on the Mercurius JIT warm path.
request.js turns non-string sources into docSource = undefined, and utils.js returns before caching when docSource === undefined. That means the cold parsed-AST call can be refined by validate, but the second call can hit Mercurius's JIT path where graphql.execute does not fire. The graphql.request span then has no cached operation metadata to recover.
Verified with:
unset OTEL_TRACES_EXPORTER OTEL_LOGS_EXPORTER OTEL_METRICS_EXPORTER
NODE_PATH=/Users/crystal.magloire/Node_Tracer/dd-trace-js/node_modules RANGE=16 \
/Users/crystal.magloire/Node_Tracer/dd-trace-js/node_modules/.bin/mocha \
packages/datadog-plugin-mercurius/test/index.spec.js \
--grep "carries the operation signature for a pre-parsed document on the JIT warm path"The focused test shape I used warms the same parsed document once, then asserts the second call:
it('carries the operation signature for a pre-parsed document on the JIT warm path', async () => {
const query = 'query ParsedAstWarm { hello }'
const document = require('../../../versions/graphql').get().parse(query)
const warmup = agent.assertSomeTraces(traces => {
const execute = traces[0].find(span => span.name === 'graphql.execute')
assert.ok(execute)
}, { spanResourceMatch: /ParsedAstWarm/ })
await Promise.all([warmup, app.graphql(document)])
const assertion = agent.assertSomeTraces(traces => {
const request = traces[0].find(span => span.name === expectedSchema.server.opName)
const execute = traces[0].find(span => span.name === 'graphql.execute')
assert.ok(request)
assert.strictEqual(execute, undefined)
assertObjectContains(request, {
meta: {
'graphql.operation.type': 'query',
'graphql.operation.name': 'ParsedAstWarm'
}
})
assert.match(request.resource, /ParsedAstWarm/)
})
return Promise.all([assertion, app.graphql(document)])
})Failure result: the warm request span is resource: "graphql.request" and does not include graphql.operation.name or graphql.operation.type. We should either cache parsed-AST metadata for the warm path too, or make the request boundary able to recover it some other way, and keep a focused regression test for this case.
A pre-parsed document AST reaches fastifyGraphQl as a non-string source, so the request boundary had no query text to key the warm-path cache by and validate returned before caching. The cold call still refined the live request span, but mercurius keys its own document LRU by the source object's identity and serves later requests for it from the JIT-compiled path, where neither validate nor execute fires. Those warm request spans finished with a bare resource and no operation tags. The cache now keys a pre-parsed source by document identity in a WeakMap the request boundary reads back on the warm path — mercurius hands the boundary the same document object it keys its own LRU by, and the WeakMap releases with the caller's document without mutating it. validate is handed the raw source the boundary saw, since it only sees mercurius's structuredClone and the two are different objects for a pre-parsed AST. A source that is neither query text nor a document AST (a number reaches validate as a truthy non-document) has no usable key; caching now skips it rather than keying a WeakMap by a primitive, which threw a TypeError inside the validate boundary and replaced mercurius's own rejection. Fixes: #1141
… span (#9142) feat(graphql): add mercurius request span support Mercurius can serve operations from its JIT path without calling graphql.execute, which left those requests with no GraphQL span. This opens a top-level request span at the Mercurius request boundary and refines it from the parsed document before execute-only paths can be skipped. The request span recovers operation metadata for JIT-warm queries, multi-operation documents, validation failures before execute, and pre-parsed DocumentNode sources without reparsing on the hot path. Fixes: #1141
… span (#9142) feat(graphql): add mercurius request span support Mercurius can serve operations from its JIT path without calling graphql.execute, which left those requests with no GraphQL span. This opens a top-level request span at the Mercurius request boundary and refines it from the parsed document before execute-only paths can be skipped. The request span recovers operation metadata for JIT-warm queries, multi-operation documents, validation failures before execute, and pre-parsed DocumentNode sources without reparsing on the hot path. Fixes: #1141
Summary
Mercurius funnels every operation through one async function and parses,
validates, and executes internally, so a query served from its JIT-compiled
path runs no
graphql.executeand produced no span at all. A top-levelgraphql.requestspan now opens at that funnel (fastifyGraphQl) viaorchestrion, parents the parse/validate/execute sub-spans, and carries the
request text plus operation name/type. It survives the JIT warm path, where no
execute span fires.
sub-plugin backfills the precise operation signature onto the request span
once the document is parsed, so the hot path never re-parses.
graphql.requestname; v1 usesgraphql.server.request. The v1 overlap with the execute span's name is aknown cross-tracer wart left for a separate breaking change.
produces one span per operation rather than one per HTTP request.
Test plan
New
mercuriusCI job runs the plugin spec across majors 13-16 (15+ gated toNode 20+). Coverage: request-span source/operation tags, execute parented under
request with a shared resource, resolver and pre-execute validation errors,
anonymous and programmatic
app.graphql()calls, the JIT warm path, batchedqueries, and
sourceon/off.Fixes: #1141