| 'use strict' | ||
| // Mercurius funnels every GraphQL request through the named async function | ||
| // `fastifyGraphQl` (decorated as both `app.graphql` and, via `reply.graphql`, | ||
| // the per-request entry point). Wrapping that one function gives a single | ||
| // top-level span per operation regardless of how the query reaches mercurius | ||
| // — HTTP POST/GET, batched queries, or a programmatic `app.graphql()` call — | ||
| // and regardless of whether the query runs through graphql's `execute` (cold) | ||
| // or a JIT-compiled query (warm), where no `graphql.execute` span fires. | ||
| // | ||
| // The function name and signature `(source, context, variables, operationName)` | ||
| // are stable across the supported major range (verified against 10.x and 16.x), | ||
| // so a `functionName` match needs no per-version file paths. | ||
| module.exports = [ | ||
| { | ||
| module: { | ||
| // Floor at 13: it is the oldest major whose fastify-plugin peer (^4) | ||
| // accepts fastify 4, which installs and runs on the oldest supported Node | ||
| // (18). 15+ requires Node 20 and fastify 5, covered on the latest-Node CI | ||
| // leg. The `fastifyGraphQl` funnel is unchanged across this whole range. | ||
| name: 'mercurius', | ||
| versionRange: '>=13', | ||
| filePath: 'index.js', | ||
| }, | ||
| functionQuery: { | ||
| functionName: 'fastifyGraphQl', | ||
| kind: 'Async', | ||
| }, | ||
| channelName: 'apm:graphql:request', | ||
| }, | ||
| ] |
| // AUTO-GENERATED by dd-compile | ||
| // Orchestrion rewriter handles the actual instrumentation via JSON config. | ||
| // This file registers the module hooks for the rewriter to process. | ||
| 'use strict' | ||
| const { addHook, getHooks } = require('./helpers/instrument') | ||
| for (const hook of getHooks('mercurius')) { | ||
| addHook(hook, exports => exports) | ||
| } |
| 'use strict' | ||
| const TracingPlugin = require('../../dd-trace/src/plugins/tracing') | ||
| const { extractErrorIntoSpanEvent, getCachedRequestOperation } = require('./utils') | ||
| // Top-level GraphQL request span for drivers that funnel every operation | ||
| // through a single entry point but parse/validate/execute internally (mercurius | ||
| // today). It parents the `graphql.parse`/`graphql.validate`/`graphql.execute` | ||
| // (or JIT) sub-spans and carries the request text + operation name/type, which | ||
| // otherwise have no home when the query is JIT-compiled and `graphql.execute` | ||
| // never fires. | ||
| // | ||
| // The entry boundary only hands us the raw `source` (string or pre-parsed AST) | ||
| // and `operationName`; the parsed document — and therefore the precise | ||
| // operation signature — is only known once mercurius parses internally. On the | ||
| // cold path the `validate` sub-plugin refines the resource/operation tags onto | ||
| // this span via `ctx.currentStore.graphqlRequestSpan` once the document is | ||
| // available, so we never re-parse on the hot path. On the JIT warm path no | ||
| // sub-span fires, so we recover the same tags from the cache the cold path | ||
| // populated, keyed by source + operationName. | ||
| class GraphQLRequestPlugin extends TracingPlugin { | ||
| static id = 'graphql' | ||
| static operation = 'request' | ||
| static type = 'graphql' | ||
| static kind = 'server' | ||
| static prefix = 'tracing:orchestrion:mercurius:apm:graphql:request' | ||
| bindStart (ctx) { | ||
| // fastifyGraphQl(source, context, variables, operationName) | ||
| const source = ctx.arguments?.[0] | ||
| const operationName = ctx.arguments?.[3] | ||
| // `source` is the request text on the common path, but mercurius also | ||
| // accepts a pre-parsed document AST; only a string is the query text, and | ||
| // `graphql.source` carries only the text form. | ||
| const docSource = typeof source === 'string' ? source : undefined | ||
| // Warm (JIT-compiled) path: execute never fires, so recover the operation | ||
| // signature/type the cold path cached, keyed by source + operationName — | ||
| // by query text for a string, by document identity for a pre-parsed AST. | ||
| // Empty on the cold path — validate hasn't refined yet — where the request | ||
| // span is refined from the parsed document instead. | ||
| const cached = getCachedRequestOperation(source, operationName) | ||
| const span = this.startSpan(this.operationName({ id: 'request' }), { | ||
| service: this.config.service || this.serviceName(), | ||
| // The cached signature is the precise resource; otherwise provisional and | ||
| // refined by the validate sub-plugin once the document is parsed. | ||
| // `operationName` is the best name at the boundary; falls back to the | ||
| // operation signature once validate sees the document. | ||
| resource: cached?.signature || operationName || undefined, | ||
| kind: this.constructor.kind, | ||
| type: this.constructor.type, | ||
| meta: { | ||
| 'graphql.operation.type': cached?.type, | ||
| 'graphql.operation.name': cached?.name || operationName, | ||
| 'graphql.source': this.config.source ? docSource : undefined, | ||
| }, | ||
| }, ctx) | ||
| // Hand the span, the requested operation name, and the raw source to the | ||
| // validate sub-plugin running inside this store so it can refine the | ||
| // resource + operation tags from the parsed document (validate is the first | ||
| // boundary that has it) and cache them keyed by the source the request | ||
| // boundary saw. The raw source is the cache key — validate sees mercurius's | ||
| // internally parsed document, not the caller's source, and for a pre-parsed | ||
| // AST the two are different objects. | ||
| ctx.currentStore.graphqlRequestSpan = span | ||
| ctx.currentStore.graphqlRequestOperationName = operationName | ||
| ctx.currentStore.graphqlRequestSource = source | ||
| return ctx.currentStore | ||
| } | ||
| asyncEnd (ctx) { | ||
| /* istanbul ignore next: currentStore is populated for the request lifecycle; activeSpan is base-plugin fallback. */ | ||
| const span = ctx?.currentStore?.span || this.activeSpan | ||
| /* istanbul ignore if: startSpan always populates currentStore for the request lifecycle. */ | ||
| if (!span) return ctx.parentStore | ||
| const result = ctx.result | ||
| if (result?.errors?.length) { | ||
| span.setTag('error', result.errors[0]) | ||
| for (const error of result.errors) { | ||
| extractErrorIntoSpanEvent(this._tracerConfig, span, error) | ||
| } | ||
| } | ||
| span.finish() | ||
| return ctx.parentStore | ||
| } | ||
| error (ctx) { | ||
| /* istanbul ignore next: currentStore is populated for request errors; activeSpan is base-plugin fallback. */ | ||
| const span = ctx?.currentStore?.span || this.activeSpan | ||
| /* istanbul ignore else: errors are only routed after the request span has started. */ | ||
| if (span && ctx?.error) { | ||
| span.setTag('error', ctx.error) | ||
| } | ||
| } | ||
| } | ||
| module.exports = GraphQLRequestPlugin |
+4
-2
@@ -78,4 +78,6 @@ /** | ||
| const require = Module.createRequire(import.meta.url) | ||
| require('./init.js') | ||
| if (Module.register) { | ||
| const initialized = require('./init.js') | ||
| // Only register the loader hook when instrumentation initialized. On a bailout the | ||
| // loader has nothing to instrument and can keep a short-lived process from exiting. | ||
| if (Module.register && initialized) { | ||
| // The loader builds its own include/exclude matcher in `initialize`, so no | ||
@@ -82,0 +84,0 @@ // options need to cross the registration boundary. |
+3
-2
| { | ||
| "name": "dd-trace", | ||
| "version": "5.112.0", | ||
| "version": "5.113.0", | ||
| "description": "Datadog APM tracing client for JavaScript", | ||
@@ -194,3 +194,3 @@ "main": "index.js", | ||
| "@types/node": "^18.19.106", | ||
| "@types/sinon": "^21.0.1", | ||
| "@types/sinon": "^22.0.0", | ||
| "axios": "^1.18.1", | ||
@@ -234,2 +234,3 @@ "benchmark": "^2.1.4", | ||
| "sinon": "^22.0.0", | ||
| "source-map-support": "^0.5.21", | ||
| "test-exclude": "^8.0.0", | ||
@@ -236,0 +237,0 @@ "tiktoken": "^1.0.21", |
@@ -51,2 +51,3 @@ 'use strict' | ||
| '@opensearch-project/opensearch': () => require('../opensearch'), | ||
| '@opentelemetry/sdk-trace': () => require('../otel-sdk-trace'), | ||
| '@opentelemetry/sdk-trace-node': () => require('../otel-sdk-trace'), | ||
@@ -109,2 +110,3 @@ '@prisma/client': { esmFirst: true, fn: () => require('../prisma') }, | ||
| memcached: () => require('../memcached'), | ||
| mercurius: () => require('../mercurius'), | ||
| 'microgateway-core': () => require('../microgateway-core'), | ||
@@ -111,0 +113,0 @@ mocha: () => require('../mocha'), |
@@ -40,2 +40,6 @@ 'use strict' | ||
| // Keep the marker split: source-map scanners can read a contiguous token in | ||
| // string literals as this file's own inline map. | ||
| const SOURCE_MAP_PREFIX = '//# sourceMapping' + 'URL=data:application/json;base64,' | ||
| function rewrite (content, filename, format) { | ||
@@ -70,3 +74,3 @@ if (!content) return content | ||
| return code + '\n' + `//# sourceMappingURL=data:application/json;base64,${inlineMap}` | ||
| return code + '\n' + SOURCE_MAP_PREFIX + inlineMap | ||
| } catch (e) { | ||
@@ -73,0 +77,0 @@ log.error(e) |
@@ -11,2 +11,3 @@ 'use strict' | ||
| ...require('./langgraph'), | ||
| ...require('./mercurius'), | ||
| ...require('./modelcontextprotocol-sdk'), | ||
@@ -13,0 +14,0 @@ ...require('./playwright'), |
@@ -11,2 +11,6 @@ 'use strict' | ||
| const assert = require('node:assert') | ||
| const clone = require('../../../../../vendor/dist/rfdc')({ proto: false, circles: false }) | ||
| const { parse, query } = require('./compiler') | ||
@@ -32,7 +36,10 @@ | ||
| const returnIndex = statements.findIndex(statement => ( | ||
| statement.type === 'ReturnStatement' && statement.argument?.name === 'result' | ||
| )) | ||
| const returnIndex = statements.findIndex(statement => | ||
| statement.type === 'ReturnStatement' && statement.argument | ||
| ) | ||
| if (returnIndex === -1) return | ||
| // The generated fulfillment handler always ends in a return; a miss means the | ||
| // upstream template changed and the caller's try/catch falls back to the | ||
| // unwrapped source. | ||
| assert(returnIndex !== -1, 'waitForAsyncEnd: no return statement to wait on') | ||
@@ -43,3 +50,3 @@ const waitStatements = parse(` | ||
| if (__apm$asyncEndPromise && typeof __apm$asyncEndPromise.then === 'function') { | ||
| return __apm$asyncEndPromise.then(() => result, () => result); | ||
| return __apm$asyncEndPromise.then(() => __apm$result, () => __apm$result); | ||
| } | ||
@@ -49,3 +56,10 @@ } | ||
| // Resolve to whatever the fulfillment handler returns (its return argument), | ||
| // so a subscriber that reassigned `__apm$ctx.result` in `asyncEnd` still wins. | ||
| const returnArgument = statements[returnIndex].argument | ||
| const { arguments: onSettled } = waitStatements[1].consequent.body[0].argument | ||
| onSettled[0].body = clone(returnArgument) | ||
| onSettled[1].body = clone(returnArgument) | ||
| statements.splice(returnIndex, 0, ...waitStatements) | ||
| } |
@@ -19,2 +19,17 @@ 'use strict' | ||
| }) | ||
| // As of @opentelemetry/sdk-node 0.220.0, NodeSDK builds its provider from | ||
| // @opentelemetry/sdk-trace's TracerProvider instead of sdk-trace-node's | ||
| // NodeTracerProvider, so the hook above no longer intercepts it. Wrap this | ||
| // export too, otherwise DD_TRACE_OTEL_ENABLED spans never reach the tracer. | ||
| addHook({ | ||
| name: '@opentelemetry/sdk-trace', | ||
| file: 'build/src/TracerProvider.js', | ||
| versions: ['*'], | ||
| }, (mod) => { | ||
| shimmer.wrap(mod, 'TracerProvider', () => { | ||
| return tracer.TracerProvider | ||
| }) | ||
| return mod | ||
| }) | ||
| } | ||
@@ -21,0 +36,0 @@ |
@@ -460,3 +460,2 @@ 'use strict' | ||
| codeOwnersEntries, | ||
| testEnvironmentMetadata, | ||
| } = testSessionConfiguration | ||
@@ -471,3 +470,2 @@ repositoryRoot = receivedRepositoryRoot || repositoryRoot | ||
| _ddCodeOwnersEntries: codeOwnersEntries, | ||
| _ddTestEnvironmentMetadata: testEnvironmentMetadata, | ||
| }, 'Could not send test session configuration to workers.') | ||
@@ -474,0 +472,0 @@ } |
@@ -119,3 +119,2 @@ 'use strict' | ||
| _ddCodeOwnersEntries: codeOwnersEntries, | ||
| _ddTestEnvironmentMetadata: testEnvironmentMetadata, | ||
| } = globalThis.__vitest_worker__.providedContext | ||
@@ -142,3 +141,2 @@ | ||
| codeOwnersEntries, | ||
| testEnvironmentMetadata, | ||
| } | ||
@@ -166,3 +164,2 @@ } catch { | ||
| codeOwnersEntries: undefined, | ||
| testEnvironmentMetadata: undefined, | ||
| } | ||
@@ -169,0 +166,0 @@ } |
@@ -660,3 +660,2 @@ 'use strict' | ||
| codeOwnersEntries: providedContext.codeOwnersEntries, | ||
| testEnvironmentMetadata: providedContext.testEnvironmentMetadata, | ||
| } | ||
@@ -663,0 +662,0 @@ testSuiteStartCh.runStores(testSuiteCtx, () => {}) |
@@ -8,8 +8,6 @@ 'use strict' | ||
| const GraphQLParsePlugin = require('./parse') | ||
| const { extractErrorIntoSpanEvent, getSignature } = require('./utils') | ||
| const { extractErrorIntoSpanEvent, getOperation, getSignature } = require('./utils') | ||
| const legacyStorage = storage('legacy') | ||
| const types = new Set(['query', 'mutation', 'subscription']) | ||
| const iastResolveCh = dc.channel('apm:graphql:resolve:start') | ||
@@ -119,5 +117,7 @@ const resolverStartCh = dc.channel('datadog:graphql:resolver:start') | ||
| const signature = getSignature(document, name, type, this.config.signature) | ||
| const span = this.startSpan(this.operationName(), { | ||
| service: this.config.service || this.serviceName(), | ||
| resource: getSignature(document, name, type, this.config.signature), | ||
| resource: signature, | ||
| kind: this.constructor.kind, | ||
@@ -674,13 +674,2 @@ type: this.constructor.type, | ||
| function getOperation (document, operationName) { | ||
| if (!document || !Array.isArray(document.definitions)) return | ||
| for (const definition of document.definitions) { | ||
| if (definition && types.has(definition.operation) && | ||
| (!operationName || definition.name?.value === operationName)) { | ||
| return definition | ||
| } | ||
| } | ||
| } | ||
| function addVariableTags (config, span, variableValues) { | ||
@@ -687,0 +676,0 @@ if (!variableValues || !config.variables) return |
@@ -9,2 +9,3 @@ 'use strict' | ||
| const GraphQLParsePlugin = require('./parse') | ||
| const GraphQLRequestPlugin = require('./request') | ||
| const GraphQLValidatePlugin = require('./validate') | ||
@@ -18,2 +19,7 @@ | ||
| parse: GraphQLParsePlugin, | ||
| // Top-level request span for drivers (mercurius) that funnel through a | ||
| // single entry point and parse/execute internally. graphql-js, apollo, | ||
| // and yoga produce no such channel, so the plugin simply never fires for | ||
| // them. | ||
| request: GraphQLRequestPlugin, | ||
| validate: GraphQLValidatePlugin, | ||
@@ -20,0 +26,0 @@ // resolve plugin is absorbed into execute: per-field data is recorded |
| 'use strict' | ||
| const { LRUCache } = require('../../../vendor/dist/lru-cache') | ||
| /** | ||
| * @typedef {{ signature?: string, type?: string, name?: string }} RequestOperation | ||
| */ | ||
| const operationTypes = new Set(['query', 'mutation', 'subscription']) | ||
| // Mercurius funnels every operation through `fastifyGraphQl`, but the parsed | ||
| // document — and therefore the operation signature/type/name — is only known | ||
| // once mercurius parses internally. The top-level request span opens before | ||
| // that, and on the JIT warm path neither parse/validate nor execute fires, so | ||
| // the span would otherwise be left with only the provisional resource. The cold | ||
| // path caches the computed metadata; the request boundary reads it back on the | ||
| // warm path. Bounded so a flood of distinct queries can't grow it without limit. | ||
| // | ||
| // The key is the operation name plus the raw query text, not the source alone: | ||
| // mercurius keys its document LRU by source but compiles the JIT for a single | ||
| // `operationName`, and the compiled query then serves that operation for every | ||
| // later request that shares the source — regardless of the `operationName` those | ||
| // requests ask for. A source-only key would hand a warm request for operation B | ||
| // the metadata of whichever operation was cached last for that source (A), | ||
| // mislabeling the span. Operation names cannot contain a newline, so it is a | ||
| // safe separator that keeps the two parts from colliding. | ||
| const requestOperationCache = new LRUCache({ max: 500 }) | ||
| // Mercurius also accepts a pre-parsed document AST as the source, which reaches | ||
| // the request boundary as an object rather than query text — so there is no | ||
| // string to key the LRU by. Mercurius keys its own document LRU by that source | ||
| // object's identity, and the same object reaches the boundary on the warm path, | ||
| // so a WeakMap keyed by the caller-owned document recovers the metadata without | ||
| // mutating the document and releases with it. The value carries the requested | ||
| // operation name so a JIT-only sibling selection is not handed another | ||
| // operation's metadata (same reason the string cache keys by operation name). | ||
| /** @type {WeakMap<object, Map<string | undefined, RequestOperation>>} */ | ||
| const documentOperationCache = new WeakMap() | ||
| /** | ||
| * @param {string} source - The raw query text; the same key mercurius uses. | ||
| * @param {string | undefined} operationName - The requested operation name. | ||
| * @returns {string} | ||
| */ | ||
| function requestOperationKey (source, operationName) { | ||
| return `${operationName ?? ''}\n${source}` | ||
| } | ||
| /** | ||
| * @param {unknown} source - Query text on the common path; a pre-parsed | ||
| * document AST otherwise. Any other shape (mercurius rejects it before | ||
| * execute) has no cache entry and yields undefined. | ||
| * @param {string | undefined} operationName - The requested operation name. | ||
| * @returns {RequestOperation | undefined} | ||
| */ | ||
| function getCachedRequestOperation (source, operationName) { | ||
| if (typeof source === 'string') { | ||
| return requestOperationCache.get(requestOperationKey(source, operationName)) | ||
| } | ||
| if (source === null || typeof source !== 'object') return | ||
| return documentOperationCache.get(source)?.get(operationName) | ||
| } | ||
| /** | ||
| * A string source keys the text LRU; a document AST keys the WeakMap. Any other | ||
| * shape has no usable key — mercurius rejects it before execute, so the warm | ||
| * path never reaches the request span for it either. | ||
| * | ||
| * @param {unknown} source | ||
| * @returns {source is string | object} | ||
| */ | ||
| function isCacheableSource (source) { | ||
| return typeof source === 'string' || (source !== null && typeof source === 'object') | ||
| } | ||
| /** | ||
| * Select the operation definition matching `operationName`, or the first one | ||
| * when no name is given (graphql/mercurius default selection). | ||
| * | ||
| * @param {import('graphql').DocumentNode | undefined} document | ||
| * @param {string | undefined} operationName | ||
| * @returns {import('graphql').OperationDefinitionNode | undefined} | ||
| */ | ||
| function getOperation (document, operationName) { | ||
| /* istanbul ignore if: validate/execute only call this with a parsed GraphQL document. */ | ||
| if (!document || !Array.isArray(document.definitions)) return | ||
| for (const definition of document.definitions) { | ||
| if (operationTypes.has(definition?.operation) && | ||
| (!operationName || definition.name?.value === operationName)) { | ||
| return definition | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Refine the top-level graphql.request span (mercurius) from the parsed | ||
| * document and cache the metadata so the JIT warm path — where no sub-span | ||
| * fires — can recover the same tags at the request boundary. | ||
| * | ||
| * This runs at the first boundary that has the document (validate on the cold | ||
| * path, which also precedes a pre-execute validation failure). It is idempotent | ||
| * across the later execute boundary via the `ddRequestRefined` flag, and a | ||
| * no-op for graphql-js/apollo/yoga, which never open a request span. | ||
| * | ||
| * Every named operation in the document is cached, not just the selected one: | ||
| * a multi-operation document parses once, and a later request may select a | ||
| * sibling operation that mercurius then serves exclusively through its JIT path | ||
| * (no execute span), so its metadata has to be ready before that happens. | ||
| * | ||
| * @param {import('../../dd-trace/src/opentracing/span') | undefined} requestSpan | ||
| * @param {import('graphql').DocumentNode | undefined} document | ||
| * @param {unknown} requestSource - The raw source the request boundary saw: | ||
| * query text on the common path, a pre-parsed document AST otherwise. The | ||
| * cache is keyed by it, not by the parsed document, so the request boundary | ||
| * recovers the metadata on the warm path from the same value mercurius keys | ||
| * its own document LRU by. Any other shape has no usable key and is not | ||
| * cached (the warm path never reaches this span for it either). | ||
| * @param {string | undefined} operationName - The requested operation name. | ||
| * @param {boolean} calculateSignature - The graphql plugin's `signature` config. | ||
| */ | ||
| function refineRequestSpan (requestSpan, document, requestSource, operationName, calculateSignature) { | ||
| /* istanbul ignore if: validate only refines after the request span and parsed document exist. */ | ||
| if (!requestSpan || requestSpan.ddRequestRefined || !document) return | ||
| requestSpan.ddRequestRefined = true | ||
| const operation = getOperation(document, operationName) | ||
| const type = operation?.operation | ||
| const name = operation?.name?.value | ||
| const signature = getSignature(document, name, type, calculateSignature) | ||
| if (signature) requestSpan.setTag('resource.name', signature) | ||
| if (type) requestSpan.setTag('graphql.operation.type', type) | ||
| if (name) requestSpan.setTag('graphql.operation.name', name) | ||
| if (!isCacheableSource(requestSource)) return | ||
| // Cache the selected operation under the requested name (undefined selects | ||
| // the document's first operation, so it shares the entry with that name). | ||
| cacheRequestOperation(requestSource, operationName, { signature, type, name }) | ||
| // Cache every named operation so a JIT-only sibling selection is labeled from | ||
| // this single parse instead of falling back to a bare operation name. | ||
| for (const definition of document.definitions) { | ||
| const definitionName = definition?.name?.value | ||
| if (definitionName === undefined || !operationTypes.has(definition.operation)) continue | ||
| if (definitionName === operationName) continue | ||
| cacheRequestOperation(requestSource, definitionName, { | ||
| signature: getSignature(document, definitionName, definition.operation, calculateSignature), | ||
| type: definition.operation, | ||
| name: definitionName, | ||
| }) | ||
| } | ||
| } | ||
| /** | ||
| * @param {string | import('graphql').DocumentNode} source - Query text keys the | ||
| * text LRU; a caller-owned document AST keys the WeakMap (never mutated). | ||
| * @param {string | undefined} operationName - The requested operation name. | ||
| * @param {RequestOperation} operation | ||
| */ | ||
| function cacheRequestOperation (source, operationName, operation) { | ||
| if (typeof source === 'string') { | ||
| requestOperationCache.set(requestOperationKey(source, operationName), operation) | ||
| return | ||
| } | ||
| let operations = documentOperationCache.get(source) | ||
| if (operations === undefined) { | ||
| operations = new Map() | ||
| documentOperationCache.set(source, operations) | ||
| } | ||
| operations.set(operationName, operation) | ||
| } | ||
| /** | ||
| * @param {{ errorExtensions?: string[] }} config Resolved plugin config; `errorExtensions` lists the | ||
@@ -87,3 +261,6 @@ * GraphQL error `extensions` keys to copy onto the span event. | ||
| extractErrorIntoSpanEvent, | ||
| getCachedRequestOperation, | ||
| getOperation, | ||
| getSignature, | ||
| refineRequestSpan, | ||
| } |
| 'use strict' | ||
| const { storage } = require('../../datadog-core') | ||
| const TracingPlugin = require('../../dd-trace/src/plugins/tracing') | ||
| const GraphQLParsePlugin = require('./parse') | ||
| const { extractErrorIntoSpanEvent } = require('./utils') | ||
| const { extractErrorIntoSpanEvent, refineRequestSpan } = require('./utils') | ||
| const legacyStorage = storage('legacy') | ||
| class GraphQLValidatePlugin extends TracingPlugin { | ||
@@ -18,2 +21,22 @@ static id = 'graphql' | ||
| // Refine the top-level graphql.request span (mercurius) from the parsed | ||
| // document. validate is the first boundary that has it and precedes both | ||
| // execute and any pre-execute rejection (unknown field, GET mutation), so a | ||
| // failing request still ends up with a resource and operation tags. The | ||
| // request span, its operation name, and the raw source ride the active | ||
| // store the request boundary entered (validate's own `ctx.currentStore` is | ||
| // not populated yet). The cache is keyed by that raw source, not the parsed | ||
| // document — for a pre-parsed AST mercurius validates a structuredClone, so | ||
| // the document here is a different object from the one the boundary saw and | ||
| // recovers on the warm path. No-op for graphql-js/apollo/yoga, which never | ||
| // open a request span. | ||
| const requestStore = legacyStorage.getStore() | ||
| refineRequestSpan( | ||
| requestStore?.graphqlRequestSpan, | ||
| document, | ||
| requestStore?.graphqlRequestSource, | ||
| requestStore?.graphqlRequestOperationName, | ||
| this.config.signature | ||
| ) | ||
| this.startSpan('graphql.validate', { | ||
@@ -20,0 +43,0 @@ service: this.config.service, |
@@ -70,3 +70,2 @@ 'use strict' | ||
| codeOwnersEntries: this.codeOwnersEntries, | ||
| testEnvironmentMetadata: this.testEnvironmentMetadata, | ||
| }) | ||
@@ -328,3 +327,2 @@ }) | ||
| repositoryRoot, | ||
| testEnvironmentMetadata, | ||
| requestErrorTags, | ||
@@ -339,8 +337,2 @@ testSuiteAbsolutePath, | ||
| const { testSessionId, testModuleId } = ctx | ||
| if (testEnvironmentMetadata) { | ||
| this.testEnvironmentMetadata = { | ||
| ...this.testEnvironmentMetadata, | ||
| ...testEnvironmentMetadata, | ||
| } | ||
| } | ||
| this._setRepositoryRoot(repositoryRoot, codeOwnersEntries) | ||
@@ -347,0 +339,0 @@ this.command = testCommand |
@@ -10,5 +10,3 @@ 'use strict' | ||
| // Tracks spans for which start-invocation has been processed, so that | ||
| // end-invocation can gate correctly | ||
| const activeInvocations = new WeakSet() | ||
| const activeInvocations = new WeakMap() | ||
@@ -35,3 +33,4 @@ /** | ||
| activeInvocations.add(span) | ||
| const req = { headers: headers ?? {} } | ||
| activeInvocations.set(span, req) | ||
@@ -79,3 +78,3 @@ span.addTags({ | ||
| waf.run({ persistent }, span, undefined, span) | ||
| waf.run({ persistent }, req, undefined, span) | ||
| } catch (err) { | ||
@@ -106,2 +105,3 @@ log.error('[ASM] Error in Lambda start-invocation handler', err) | ||
| const req = activeInvocations.get(span) | ||
| activeInvocations.delete(span) | ||
@@ -125,8 +125,8 @@ | ||
| if (hasPersistentData) { | ||
| waf.run({ persistent }, span, undefined, span) | ||
| waf.run({ persistent }, req, undefined, span) | ||
| } | ||
| waf.disposeContext(span) | ||
| waf.disposeContext(req) | ||
| Reporter.finishRequest(span, null, {}, undefined, span) | ||
| Reporter.finishRequest(req, null, {}, undefined, span) | ||
| } catch (err) { | ||
@@ -133,0 +133,0 @@ log.error('[ASM] Error in Lambda end-invocation handler', err) |
@@ -7,3 +7,3 @@ 'use strict' | ||
| const request = require('../exporters/common/request') | ||
| const { encode: encodeMsgpack } = require('../msgpack') | ||
| const { encode: encodeMsgpack, MAX_SIZE: MAX_CHUNK_SIZE } = require('../msgpack') | ||
@@ -40,4 +40,16 @@ function makeRequest (data, url, cb) { | ||
| } | ||
| const encodedPayload = encodeMsgpack(payload) | ||
| let encodedPayload | ||
| try { | ||
| encodedPayload = encodeMsgpack(payload) | ||
| } catch (error) { | ||
| if (error.code !== 'ERR_MSGPACK_CHUNK_OVERFLOW') throw error | ||
| // The msgpack-encoded pipeline-stats payload exceeded the agent | ||
| // intake cap. Dropping it locally is safer than letting the | ||
| // RangeError crash the host process; the agent would reject the | ||
| // oversized payload at the network boundary anyway. | ||
| log.error('DataStreamsWriter dropped a payload that exceeded the %d byte chunk cap', MAX_CHUNK_SIZE) | ||
| return | ||
| } | ||
| zlib.gzip(encodedPayload, { level: 1 }, (err, compressedData) => { | ||
@@ -44,0 +56,0 @@ if (err) { |
| 'use strict' | ||
| const getConfig = require('../config') | ||
| const { MsgpackChunk } = require('../msgpack') | ||
| const { MsgpackChunk, MAX_SIZE: MAX_CHUNK_SIZE } = require('../msgpack') | ||
| const log = require('../log') | ||
@@ -268,3 +268,24 @@ const { normalizeSpan, eventTimeNano } = require('./tags-processors') | ||
| this._encode(bytes, trace) | ||
| try { | ||
| this._encode(bytes, trace) | ||
| } catch (error) { | ||
| if (error.code !== 'ERR_MSGPACK_CHUNK_OVERFLOW') throw error | ||
| // The trace, or the queued payload it joined, hit the chunk cap. | ||
| // Rolling back just the in-flight trace is unsafe: the string cache | ||
| // may already hold subarrays / indices pointing at bytes we'd | ||
| // discard, and the next encode would emit stale bytes against a | ||
| // smaller string table. Drop the whole queued payload so the next | ||
| // encode starts from a clean state. Emitting a partial buffer would | ||
| // corrupt the msgpack wire. The virtual `reset()` is called (not | ||
| // `_reset()`) so subclasses can clear their own per-payload state | ||
| // (e.g. `AgentlessCiVisibilityEncoder._eventCount`). | ||
| const dropped = this._traceCount | ||
| this.reset() | ||
| log.error( | ||
| 'Trace encoder reset after exceeding the %d byte chunk cap; dropped %d trace(s)', | ||
| MAX_CHUNK_SIZE, | ||
| dropped | ||
| ) | ||
| return | ||
| } | ||
@@ -271,0 +292,0 @@ if (this.#debugEncoding) { |
| 'use strict' | ||
| const { MAX_SIZE, OverflowError } = require('../msgpack') | ||
| const { normalizeSpan } = require('./tags-processors') | ||
@@ -33,4 +34,14 @@ const { AgentEncoder: BaseEncoder, stringifySpanEvents } = require('./0.4') | ||
| const traceSize = this._traceBytes.length + 5 | ||
| const buffer = Buffer.allocUnsafe(prefixSize + stringSize + traceSize) | ||
| const payloadSize = prefixSize + stringSize + traceSize | ||
| // The string table and the trace bytes are capped independently, so both | ||
| // can sit just under the cap while their concatenation crosses it. `encode` | ||
| // never sees this overflow — it only exists once the two are summed here — | ||
| // so the writer's flush-time catch drops the payload. | ||
| if (payloadSize > MAX_SIZE) { | ||
| throw new OverflowError(payloadSize) | ||
| } | ||
| const buffer = Buffer.allocUnsafe(payloadSize) | ||
| buffer[0] = ARRAY_OF_TWO | ||
@@ -37,0 +48,0 @@ |
@@ -10,3 +10,3 @@ 'use strict' | ||
| } = require('../ci-visibility/telemetry') | ||
| const { MsgpackChunk } = require('../msgpack') | ||
| const { MsgpackChunk, MAX_SIZE, OverflowError } = require('../msgpack') | ||
| const { AgentEncoder } = require('./0.4') | ||
@@ -375,2 +375,12 @@ const { | ||
| const totalSize = prefixBytes.length + eventsBytes.length | ||
| // The metadata prefix (built here, not during `encode`) and the events are | ||
| // capped independently, so both can stay under the cap while the assembled | ||
| // payload crosses it. An oversized metadata tag also overflows the prefix | ||
| // chunk itself inside `_encodePayloadStart` above; either way the tagged | ||
| // error propagates to the writer's flush-time catch to drop the payload. | ||
| if (totalSize > MAX_SIZE) { | ||
| throw new OverflowError(totalSize) | ||
| } | ||
| const buffer = Buffer.allocUnsafe(totalSize) | ||
@@ -377,0 +387,0 @@ prefixBytes.buffer.copy(buffer, 0, 0, prefixBytes.length) |
| 'use strict' | ||
| const { MsgpackChunk } = require('../msgpack') | ||
| const { MsgpackChunk, MAX_SIZE: MAX_CHUNK_SIZE } = require('../msgpack') | ||
@@ -9,2 +9,3 @@ const { | ||
| } = require('../ci-visibility/telemetry') | ||
| const log = require('../log') | ||
| const FormData = require('../exporters/common/form-data') | ||
@@ -44,3 +45,20 @@ const { AgentEncoder } = require('./0.4') | ||
| this._coveragesCount++ | ||
| this.encodeCodeCoverage(this._coverageBytes, coverage) | ||
| try { | ||
| this.encodeCodeCoverage(this._coverageBytes, coverage) | ||
| } catch (error) { | ||
| if (error.code !== 'ERR_MSGPACK_CHUNK_OVERFLOW') throw error | ||
| // The coverage payload hit the chunk cap. `_coverageBytes` may | ||
| // hold a partial entry and a stale `coverages` array prefix. Drop | ||
| // the whole queued payload so the next encode starts from a clean | ||
| // state; the parent `AgentEncoder` does the equivalent for its own | ||
| // bytes / string cache via the inherited `_reset()`. | ||
| const dropped = this._coveragesCount | ||
| this.reset() | ||
| log.error( | ||
| 'Coverage encoder reset after exceeding the %d byte chunk cap; dropped %d coverage(s)', | ||
| MAX_CHUNK_SIZE, | ||
| dropped | ||
| ) | ||
| return | ||
| } | ||
@@ -47,0 +65,0 @@ distributionMetric( |
@@ -6,2 +6,3 @@ 'use strict' | ||
| const log = require('../../log') | ||
| const { MAX_SIZE: MAX_CHUNK_SIZE } = require('../../msgpack') | ||
| const request = require('./request') | ||
@@ -31,3 +32,18 @@ const { safeJSONStringify } = require('./util') | ||
| } | ||
| const payload = this._encoder.makePayload() | ||
| let payload | ||
| try { | ||
| payload = this._encoder.makePayload() | ||
| } catch (error) { | ||
| if (error.code !== 'ERR_MSGPACK_CHUNK_OVERFLOW') throw error | ||
| // Multi-chunk encoders (v0.5, CI Visibility) only learn the assembled | ||
| // payload exceeds the cap when `makePayload` stitches the chunks | ||
| // together, after `encode` already returned — so the encode-time catch | ||
| // never sees it. Drop the queued payload here instead of letting the | ||
| // RangeError escape into the host application; the agent would reject | ||
| // the oversized payload at the network boundary anyway. | ||
| this._encoder.reset() | ||
| log.error('Writer dropped %d trace(s) that exceeded the %d byte chunk cap', count, MAX_CHUNK_SIZE) | ||
| done() | ||
| return | ||
| } | ||
| this._sendPayload(payload, count, done) | ||
@@ -34,0 +50,0 @@ } else { |
@@ -51,2 +51,4 @@ 'use strict' | ||
| var runtimeInfo = 'Incompatible runtime Node.js ' + version + ', supported runtimes: Node.js ' + supportedRange | ||
| // When not forced, the process bails out here and may call process.exit() right away; | ||
| // forward synchronously so the telemetry child can't outlive us and wedge the exit. | ||
| telemetry([ | ||
@@ -59,3 +61,3 @@ { name: 'abort', tags: ['reason:incompatible_runtime'] }, | ||
| result_reason: runtimeInfo | ||
| }) | ||
| }, !forced) | ||
| log.info('Aborting application instrumentation due to incompatible_runtime.') | ||
@@ -62,0 +64,0 @@ log.info('Found incompatible runtime Node.js %s, Supported runtimes: Node.js %s.', version, supportedRange) |
| 'use strict' | ||
| var fs = require('fs') | ||
| var spawn = require('child_process').spawn | ||
| // Capture the child_process functions at load time, before the tracer wraps the module. | ||
| // Reaching through require('child_process') at send time would route the forwarder through | ||
| // the tracer's own child_process instrumentation once the tracer is initialized. | ||
| var childProcess = require('child_process') | ||
| var spawn = childProcess.spawn | ||
| // eslint-disable-next-line n/no-unsupported-features/node-builtins | ||
| var spawnSync = childProcess.spawnSync | ||
| var tracerVersion = require('../../../../package.json').version | ||
@@ -50,3 +56,3 @@ var log = require('./log') | ||
| function sendTelemetry (name, tags, resultMetadata) { | ||
| function sendTelemetry (name, tags, resultMetadata, synchronous) { | ||
| var points = name | ||
@@ -78,2 +84,33 @@ if (typeof name === 'string') { | ||
| var payload = JSON.stringify({ metadata: currentMetadata, points: points }) | ||
| // A forwarder spawned asynchronously can still be tearing down its stdio pipes when the | ||
| // injected app calls process.exit(); on Node 24.0.0/24.1.x that deadlocks the exit | ||
| // (fixed upstream in 24.2), hanging short-lived single-step-install processes. On the | ||
| // bailout path the caller passes synchronous=true, so the child is fully reaped before we | ||
| // return and nothing survives to race the exit. spawnSync is only reached on that path, | ||
| // before any instrumentation is active, so it never traces the forwarder. It exists since | ||
| // Node 0.11.12; the guardrails still target >=0.8, which predates the exit bug anyway. | ||
| if (synchronous && spawnSync) { | ||
| // Bound the blocking send: this telemetry is best-effort and the whole point of the | ||
| // synchronous path is to avoid a hung exit, so a forwarder that wedges must not become a | ||
| // new hard hang. On timeout spawnSync kills the child and returns error.code ETIMEDOUT. | ||
| var result = spawnSync(telemetryForwarderPath, ['library_entrypoint'], { | ||
| input: payload, | ||
| stdio: ['pipe', 'ignore', 'ignore'], | ||
| timeout: 1000, | ||
| killSignal: 'SIGKILL' | ||
| }) | ||
| if (result.error) { | ||
| if (result.error.code === 'ETIMEDOUT') { | ||
| log.error('Telemetry forwarder timed out') | ||
| } else { | ||
| log.error('Failed to spawn telemetry forwarder') | ||
| } | ||
| } else if (result.status) { | ||
| log.error('Telemetry forwarder exited with code', result.status) | ||
| } | ||
| return | ||
| } | ||
| var proc = spawn(telemetryForwarderPath, ['library_entrypoint'], { | ||
@@ -93,3 +130,3 @@ stdio: 'pipe' | ||
| }) | ||
| proc.stdin.end(JSON.stringify({ metadata: currentMetadata, points: points })) | ||
| proc.stdin.end(payload) | ||
| } |
@@ -13,4 +13,31 @@ 'use strict' | ||
| const SHRINK_USAGE_RATIO = 4 | ||
| // Hard cap on chunk growth. The agent's trace intake rejects payloads over | ||
| // 50 MiB, so anything past that is dead on arrival anyway. A pathological | ||
| // single trace (unsanitized meta tag the size of a media file, multi-MB | ||
| // stack trace) used to grow the buffer without limit until either the | ||
| // allocation failed or the process tripped its memory ceiling. `reserve` | ||
| // now refuses the growth with a tagged `RangeError`; `AgentEncoder` catches | ||
| // it, resets the in-flight payload, and logs. | ||
| const MAX_SIZE = 50 * 1024 * 1024 // 50 MiB | ||
| /** | ||
| * Thrown when a chunk — or an assembled payload stitched from several chunks — | ||
| * would cross `MAX_SIZE`. Shared so the `reserve` cap and the per-encoder | ||
| * assembled-size guards (`0.5`, agentless CI Visibility) raise the same `code`, | ||
| * which every writer's `flush` recognises to drop the payload instead of | ||
| * crashing the host. | ||
| */ | ||
| class OverflowError extends RangeError { | ||
| code = 'ERR_MSGPACK_CHUNK_OVERFLOW' | ||
| /** | ||
| * @param {number} needed Requested total byte size. | ||
| */ | ||
| constructor (needed) { | ||
| super(`MsgpackChunk capped at ${MAX_SIZE} bytes; requested ${needed}`) | ||
| this.name = 'OverflowError' | ||
| } | ||
| } | ||
| /** | ||
| * Resizable msgpack write buffer. Owns the byte-layout primitives the encoder | ||
@@ -104,2 +131,5 @@ * layer dispatches into; callers reach the underlying `Buffer` only when they | ||
| if (needed > this.buffer.length) { | ||
| if (needed > MAX_SIZE) { | ||
| throw new OverflowError(needed) | ||
| } | ||
| let newSize = this.buffer.length | ||
@@ -109,3 +139,3 @@ // `*= 2` instead of `<<= 1`: `1073741824 << 1` is negative as int32, | ||
| while (newSize < needed) newSize *= 2 | ||
| this.#resize(newSize) | ||
| this.#resize(Math.min(newSize, MAX_SIZE)) | ||
| } | ||
@@ -460,1 +490,3 @@ | ||
| module.exports = MsgpackChunk | ||
| module.exports.MAX_SIZE = MAX_SIZE | ||
| module.exports.OverflowError = OverflowError |
@@ -100,2 +100,7 @@ 'use strict' | ||
| module.exports = { MsgpackChunk, encode } | ||
| module.exports = { | ||
| MsgpackChunk, | ||
| encode, | ||
| MAX_SIZE: MsgpackChunk.MAX_SIZE, | ||
| OverflowError: MsgpackChunk.OverflowError, | ||
| } |
@@ -87,2 +87,5 @@ 'use strict' | ||
| get memcached () { return require('../../../datadog-plugin-memcached/src') }, | ||
| // mercurius is traced under the graphql plugin: its instrumentation opens the | ||
| // top-level graphql.request span handled by the graphql CompositePlugin. | ||
| get mercurius () { return require('../../../datadog-plugin-graphql/src') }, | ||
| get 'microgateway-core' () { return require('../../../datadog-plugin-microgateway-core/src') }, | ||
@@ -89,0 +92,0 @@ get mocha () { return require('../../../datadog-plugin-mocha/src') }, |
@@ -11,2 +11,8 @@ 'use strict' | ||
| }, | ||
| // Top-level request span for drivers that funnel through a single entry | ||
| // point (mercurius). Matches the cross-tracer `graphql.request` v0 name. | ||
| request: { | ||
| opName: () => 'graphql.request', | ||
| serviceName: identityService, | ||
| }, | ||
| }, | ||
@@ -13,0 +19,0 @@ } |
@@ -11,2 +11,10 @@ 'use strict' | ||
| }, | ||
| // Top-level request span for drivers that funnel through a single entry | ||
| // point (mercurius). Matches the cross-tracer `graphql.server.request` v1 | ||
| // name. The v1 overlap with the execute span's name above is a known wart | ||
| // tracked for a separate cross-tracer unification (breaking) change. | ||
| request: { | ||
| opName: () => 'graphql.server.request', | ||
| serviceName: identityService, | ||
| }, | ||
| }, | ||
@@ -13,0 +21,0 @@ } |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 6 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 4 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
5864312
0.45%1022
0.29%128455
0.39%58
1.75%126
1.61%