dd-trace
Version:
Datadog APM tracing client for JavaScript
803 lines (682 loc) • 28.5 kB
JavaScript
'use strict'
const dc = require('dc-polyfill')
const { storage } = require('../../datadog-core')
const TracingPlugin = require('../../dd-trace/src/plugins/tracing')
const GraphQLParsePlugin = require('./parse')
const { extractErrorIntoSpanEvent, getOperation, getSignature, isApolloHealthCheck } = require('./utils')
const legacyStorage = storage('legacy')
const iastResolveCh = dc.channel('apm:graphql:resolve:start')
const resolverStartCh = dc.channel('datadog:graphql:resolver:start')
const updateFieldCh = dc.channel('apm:graphql:resolve:updateField')
// AppSec/WAF abort gate. Published synchronously from bindStart with a
// payload carrying the abortController; subscribers that call
// `payload.abortController.abort()` signal a pre-execute abort. bindStart
// observes the aborted signal by replacing `ctx.arguments[0]` with a Proxy
// whose getters throw AbortError — the orchestrion-emitted wrapper's
// `try { __apm$traced() } catch { ...; throw err }` block then propagates
// the AbortError to the caller of graphql.execute.
const startExecuteCh = dc.channel('apm:graphql:execute:start')
const contexts = new WeakMap()
const instrumentedArgs = new WeakSet()
const patchedResolvers = new WeakSet()
// Visited types per caller-owned schema. The walk reaches union members and
// interface implementations through the schema (`getTypes`/`getPossibleTypes`),
// so it differs per schema: a global guard would stop the second schema at any
// type the first already walked and leave its own implementations unwrapped.
// `patchedResolvers` keeps wrapping idempotent, so re-walking a shared type is
// safe and this set only terminates cycles.
const walkedTypes = new WeakMap()
// Module-level fast path: skip the resolver-side WeakMap lookup entirely
// when depth=0 disables resolver instrumentation.
let depthDisabled = false
// Initial key for the per-operation variables-filter cache. A unique sentinel
// so the first #filterVariables call never falsely matches, even when the
// operation's variableValues is undefined.
const NO_VARIABLES_CACHED = Symbol('noVariablesCached')
class AbortError extends Error {
constructor (message) {
super(message)
this.name = 'AbortError'
}
}
class GraphQLExecutePlugin extends TracingPlugin {
static id = 'graphql'
static operation = 'execute'
static type = 'graphql'
static kind = 'server'
static prefix = 'tracing:orchestrion:graphql:apm:graphql:execute'
// @graphql-tools/executor (used by graphql-yoga) emits on a different channel
// prefix because the module name differs. Subscribe to both so Yoga execution
// produces graphql.execute spans.
static extraPrefixes = [
'tracing:orchestrion:@graphql-tools/executor:apm:graphql:execute',
]
/**
* @param {{ depth?: number }} config
*/
configure (config) {
super.configure(config)
depthDisabled = config.depth === 0
}
addTraceSubs () {
super.addTraceSubs()
for (const prefix of this.constructor.extraPrefixes) {
const events = ['start', 'end', 'asyncStart', 'asyncEnd', 'error', 'finish']
for (const event of events) {
const bindName = `bind${event.charAt(0).toUpperCase()}${event.slice(1)}`
if (this[event]) {
this.addSub(`${prefix}:${event}`, message => {
this[event](message)
})
}
if (this[bindName]) {
this.addBind(`${prefix}:${event}`, message => this[bindName](message))
}
}
}
}
bindStart (ctx) {
const rawArgs = ctx.arguments
const objectForm = isObjectForm(rawArgs)
const args = readArgs(rawArgs, objectForm)
// Re-entrant execute() short-circuit (yoga's normalizedExecutor calls
// execute internally with the same arguments object — without this we'd
// double-span). The contextValue check catches object contexts; the args
// check also catches primitive contexts.
if (instrumentedArgs.has(rawArgs?.[0])) {
ctx.ddSkipped = true
return ctx.currentStore
}
const { contextValue } = args
if (contextValue && typeof contextValue === 'object' && contexts.has(contextValue)) {
ctx.ddSkipped = true
return ctx.currentStore
}
const document = args.document
const docSource = document ? GraphQLParsePlugin.documentSources.get(document) : undefined
const operation = getOperation(document, args.operationName)
const type = operation?.operation
const name = operation?.name?.value
const source = this.config.source && docSource
// Apollo Server may execute a cached document without parsing it first.
// Match the full gateway operation here so caller-owned AST transformations
// cannot suppress execute/resolver AppSec and IAST channels.
if (name === '__ApolloServiceHealthCheck__' &&
document.definitions.length === 1 &&
isApolloHealthCheck(operation)) {
ctx.ddSkipped = true
return ctx.currentStore
}
ctx.collapse = this.config.collapse
const signature = getSignature(document, name, type, this.config.signature)
const span = this.startSpan(this.operationName(), {
service: this.config.service || this.serviceName(),
resource: signature,
kind: this.constructor.kind,
type: this.constructor.type,
meta: {
'graphql.operation.type': type,
'graphql.operation.name': name,
'graphql.source': source,
},
}, ctx)
addVariableTags(this.config, span, args.variableValues)
const abortController = new AbortController()
// AppSec/WAF synchronous-abort gate. Publish before any resolver-wrapping
// work — if a subscriber aborts, none of that work matters because we'll
// make execute's body throw AbortError before it reaches any resolvers.
// bindStart runs as a bindStore transform on
// tracing:orchestrion:graphql:apm:graphql:execute:start, which fires
// BEFORE the orchestrion-emitted wrapper publishes its own :start and
// BEFORE the wrapped fn runs. The subscriber gets the abortController
// synchronously; on return we observe `signal.aborted` and act.
if (startExecuteCh.hasSubscribers) {
startExecuteCh.publish({ abortController, args })
if (abortController.signal.aborted) {
// Replace ctx.arguments[0] with a Proxy that throws AbortError on any
// property access. The orchestrion wrapper calls
// `__apm$wrapped.apply(this, ctx.arguments)`; graphql.execute's body
// begins with `const { schema, document, ... } = args` so the
// destructure triggers the trap immediately. The wrapper catches and
// rethrows, propagating AbortError to graphql.execute's caller.
ctx.arguments[0] = new Proxy({}, {
get () { throw new AbortError('Aborted') },
has () { throw new AbortError('Aborted') },
})
ctx.ddAborted = true
return ctx.currentStore
}
}
ctx.ddArgs = setWrappedFieldResolver(rawArgs, args, objectForm, defaultFieldResolver)
if (ctx.ddArgs && typeof ctx.ddArgs === 'object') {
instrumentedArgs.add(ctx.ddArgs)
ctx.ddInstrumentedArgs = ctx.ddArgs
}
const schema = args.schema
if (schema) {
wrapFields(schema._queryType, schema)
wrapFields(schema._mutationType, schema)
wrapFields(schema._subscriptionType, schema)
}
const rootCtx = {
source: docSource,
config: this.config,
fields: new Map(),
pathCache: new Map(),
abortController,
executeSpan: span,
plugin: this,
// graphql.resolve variable tags: memoize the filtered result for the
// last-seen variableValues object (see #filterVariables).
filteredVariablesKey: NO_VARIABLES_CACHED,
filteredVariables: undefined,
}
ctx.ddRootCtx = rootCtx
if (isWeakMapKey(contextValue)) {
contexts.set(contextValue, rootCtx)
ctx.ddContextValue = contextValue
} else {
// Primitive / non-keyable contextValue: stash rootCtx on the
// orchestrion-scoped store that runStores enters for execute. Wrapped
// resolvers read it via legacyStorage.getStore() and it unwinds with the
// frame — no enterWith store that leaks past execute.
ctx.currentStore.graphqlRootCtx = rootCtx
}
return ctx.currentStore
}
end (ctx) {
if (ctx.ddSkipped) return ctx.parentStore
const span = ctx?.currentStore?.span || this.activeSpan
if (!span) return
// Synchronous execute() throw (e.g. execute(null, doc)) — error handler
// already tagged the span.
if (ctx.error) {
if (ctx.ddAborted) {
span.finish()
} else {
this.#finishSpan(ctx, span)
}
return ctx.parentStore
}
const result = ctx.result
if (typeof result?.then === 'function') {
result.then(
(res) => this.#finishSpan(ctx, span, res),
(err) => this.#finishSpan(ctx, span, undefined, err)
)
} else {
this.#finishSpan(ctx, span, result)
}
return ctx.parentStore
}
error (ctx) {
// Pre-execute WAF abort isn't an error condition — opSpan.error must
// stay 0 per master's contract.
if (ctx.ddAborted) return
const span = ctx?.currentStore?.span || this.activeSpan
if (span && ctx?.error) {
span.setTag('error', ctx.error)
}
}
/**
* @param {object} ctx
* @param {import('../../dd-trace/src/opentracing/span')} span
* @param {import('graphql').ExecutionResult} [res]
* @param {unknown} [error]
*/
#finishSpan (ctx, span, res, error) {
if (error !== undefined) {
span.setTag('error', error)
}
if (res?.errors?.length) {
span.setTag('error', res.errors[0])
for (const err of res.errors) {
extractErrorIntoSpanEvent(this.config, span, err)
}
}
if (ctx.ddContextValue) {
contexts.delete(ctx.ddContextValue)
}
if (ctx.ddInstrumentedArgs) {
instrumentedArgs.delete(ctx.ddInstrumentedArgs)
}
this.config.hooks.execute(span, ctx.ddArgs, res)
span.finish()
}
// Public — called from wrapResolve (free function, crosses class boundary).
// Resolve-span creation is inline at first-encounter; deferring to a batch
// produces a bursty encoder stall when many spans finish together.
startResolveSpan (field, rootCtx, executeSpan, startTime) {
const { fieldNode, fieldName, returnType, baseTypeName, variableValues, collapsedKey } = field
const parent = getParentField(rootCtx, field)
const childOf = parent?.span || executeSpan
const document = rootCtx.source
const loc = this.config.source && document && fieldNode && fieldNode.loc
const source = loc && document.slice(loc.start, loc.end)
// ctx form: startSpan sets field.currentStore = { ...activeStore, span }
// without entering it. Only the field's first resolver call runs in that
// store (isFirst check in wrapResolve); siblings use field.parentStore.
const span = this.startSpan('graphql.resolve', {
service: this.config.service,
resource: `${fieldName}:${returnType}`,
childOf,
type: 'graphql',
startTime,
meta: {
'graphql.field.coordinates': `${field.parentTypeName}.${fieldName}`,
'graphql.field.name': fieldName,
'graphql.field.path': collapsedKey,
'graphql.field.type': baseTypeName,
'graphql.source': source,
},
}, field)
field.span = span
if (fieldNode && this.config.variables && fieldNode.arguments) {
const variables = this.#filterVariables(rootCtx, variableValues)
for (const arg of fieldNode.arguments) {
if (arg.value?.name && arg.value.kind === 'Variable' && variables[arg.value.name.value]) {
const name = arg.value.name.value
span.setTag(`graphql.variables.${name}`, variables[name])
}
}
}
return span
}
// Memoize the user variables filter against the last-seen variableValues
// object. graphql hands every resolver in one execute the same coerced
// variableValues object, so all arg-bearing fields hit the identity fast
// path and the filter runs once per operation. A nested execute() sharing
// the same object contextValue reuses the outer rootCtx but carries its own
// variableValues; comparing by identity recomputes for it (and any later
// fields on that inner object reuse the slot), so each field's tags stay
// correct. A single slot beats a WeakMap here: no per-operation allocation,
// and the common single-object case is a bare `===` (see the microbenchmark
// numbers in the commit body).
#filterVariables (rootCtx, variableValues) {
if (rootCtx.filteredVariablesKey === variableValues) {
return rootCtx.filteredVariables
}
const filtered = this.config.variables(variableValues)
rootCtx.filteredVariablesKey = variableValues
rootCtx.filteredVariables = filtered
return filtered
}
// Public — called from wrapResolve. endTime reflects when the resolver
// actually completed, not when the field record was created.
finishResolveSpan (span, field, error, result, endTime) {
if (error) span.setTag('error', error)
if (this.config.hooks.resolve) {
this.config.hooks.resolve(span, {
fieldName: field.fieldName,
path: field.pathString,
error: error || null,
// Any thenable from any realm — keep undefined so the hook doesn't
// accidentally see the unresolved promise.
result: typeof result?.then === 'function' ? undefined : result,
})
}
span.finish(endTime)
}
}
// --- resolver wrapping --------------------------------------------------------
function wrapResolve (resolve) {
if (typeof resolve !== 'function' || patchedResolvers.has(resolve)) return resolve
function resolveAsync (source, args, contextValue, info) {
const hasIastSub = iastResolveCh.hasSubscribers
const hasResolverSub = resolverStartCh.hasSubscribers
// Combined fast-path: depth=0 AND no IAST/AppSec subscriber means nothing
// to do — skip rootCtx lookup, path walk, publish gates.
if (depthDisabled && !hasIastSub && !hasResolverSub) {
return resolve.apply(this, arguments)
}
const rootCtx = contexts.get(contextValue) ?? legacyStorage.getStore()?.graphqlRootCtx
if (!rootCtx) return resolve.apply(this, arguments)
const infoPath = info?.path
const config = rootCtx.config
// pathString built incrementally off the parent's cached value
// (rootCtx.pathCache, keyed by path node) — avoids re-walking the whole
// path linked-list on every resolver call, which is O(depth) per call for
// deeply nested resolvers. Shared between the IAST publish and the field
// record. Collapse-aware: list-index segments become '*'.
let pathString
let collapsedKey
if (infoPath) {
pathString = buildCachedPathString(infoPath, rootCtx.pathCache, config.collapse)
if (config.collapse) collapsedKey = pathString
}
// IAST and AppSec subscribers see EVERY resolver call, regardless of
// depth or collapse. The depth knob caps span creation only.
if (hasIastSub) {
iastResolveCh.publish({ rootCtx, args, info, path: pathToArray(infoPath), pathString })
}
if (hasResolverSub) {
resolverStartCh.publish({
abortController: rootCtx.abortController,
resolverInfo: getResolverInfo(info, args),
})
}
if (depthDisabled || !shouldInstrumentNode(config, infoPath)) {
if (rootCtx.abortController?.signal.aborted) {
throw new AbortError('Aborted')
}
return resolve.apply(this, arguments)
}
const fieldKey = config.collapse ? pathString : infoPath
const parentTypeName = info.parentType.name
let field = rootCtx.fields.get(fieldKey)
const collapsedField = field
if (config.collapse && field !== undefined && field.parentTypeName !== parentTypeName) {
const parentTypeFields = field.parentTypeFields
if (parentTypeFields?.parentTypeName === undefined) {
field = parentTypeFields?.get(parentTypeName)
} else if (parentTypeFields.parentTypeName === parentTypeName) {
field = parentTypeFields
} else {
field = undefined
}
if (field && infoPath.typename === undefined) {
cacheFieldByPath(rootCtx, infoPath, field)
}
}
const isFirst = !field
if (isFirst) {
field = {
fieldNode: info.fieldNodes?.[0],
fieldName: info.fieldName,
parentTypeName,
returnType: info.returnType,
baseTypeName: getBaseTypeName(info.returnType),
variableValues: info.variableValues,
args,
infoPath,
pathString,
collapsedKey: collapsedKey ?? pathString,
span: null,
// Set by startResolveSpan; currentStore is used by the first resolver
// call only, siblings use parentStore (see the isFirst check below).
parentStore: null,
currentStore: null,
}
if (config.collapse && collapsedField) {
const parentTypeFields = collapsedField.parentTypeFields
if (parentTypeFields === undefined) {
collapsedField.parentTypeFields = field
} else if (parentTypeFields.parentTypeName === undefined) {
parentTypeFields.set(parentTypeName, field)
} else {
const fieldsByParentType = new Map()
.set(collapsedField.parentTypeName, collapsedField)
.set(parentTypeFields.parentTypeName, parentTypeFields)
.set(parentTypeName, field)
collapsedField.parentTypeFields = fieldsByParentType
}
if (infoPath.typename === undefined) {
cacheFieldByPath(rootCtx, infoPath, field)
}
} else {
rootCtx.fields.set(fieldKey, field)
}
}
// Collapsed siblings still publish updateField (master's contract: one
// publish per resolver call, even when the span is collapsed) and route
// through callInAsyncScope so the abort signal stops them mid-flight. They
// run in the parent store, not field.currentStore: the first sibling's
// synchronous resolver already finished the shared graphql.resolve span, so
// re-entering its store would parent user spans to a closed span.
if (!isFirst) {
return callInAsyncScope(resolve, this, arguments, rootCtx.abortController, field.parentStore, (err) => {
if (updateFieldCh.hasSubscribers) {
updateFieldCh.publish({ rootCtx, field, error: err, pathString: field.pathString })
}
})
}
const executeSpan = rootCtx.executeSpan
const startTime = executeSpan._getTime()
const span = rootCtx.plugin.startResolveSpan(field, rootCtx, executeSpan, startTime)
return callInAsyncScope(resolve, this, arguments, rootCtx.abortController, field.currentStore, (err, res) => {
const endTime = executeSpan._getTime()
rootCtx.plugin.finishResolveSpan(span, field, err, res, endTime || startTime)
if (updateFieldCh.hasSubscribers) {
updateFieldCh.publish({ rootCtx, field, error: err, pathString: field.pathString })
}
})
}
patchedResolvers.add(resolveAsync)
return resolveAsync
}
function wrapFields (type, schema) {
if (!type || !markWalked(schema, type)) return
const tag = type[Symbol.toStringTag]
// Union types (e.g. Apollo Federation's `_Entity`) hold their members on
// `_types`, not `_fields`. Their member object types are reachable only here,
// so descend into each to wrap the entity resolvers a `_entities` query runs.
if (tag === 'GraphQLUnionType') {
for (const member of type.getTypes()) wrapFields(member, schema)
return
}
if (type._fields) {
for (const field of Object.values(type._fields)) {
wrapFieldResolve(field)
wrapFieldType(field, schema)
}
}
// Interface implementations carry their own resolvers and are reachable only
// through `getPossibleTypes`; an interface return type alone never wraps them.
if (schema && tag === 'GraphQLInterfaceType') {
for (const impl of schema.getPossibleTypes(type)) wrapFields(impl, schema)
}
}
// Marks the guard on entry so recursive types (a field looping back to its own
// type, an interface an implementation returns) terminate the walk.
function markWalked (schema, type) {
let walked = walkedTypes.get(schema)
if (walked === undefined) {
walked = new WeakSet()
walkedTypes.set(schema, walked)
}
if (walked.has(type)) return false
walked.add(type)
return true
}
function wrapFieldResolve (field) {
if (!field?.resolve) return
field.resolve = wrapResolve(field.resolve)
}
function wrapFieldType (field, schema) {
if (!field?.type) return
let unwrapped = field.type
while (unwrapped.ofType) unwrapped = unwrapped.ofType
wrapFields(unwrapped, schema)
}
// Runs the resolver inside `store`, including any code after an internal
// `await`. A `.then()` the caller attaches afterward runs outside `store`.
function callInAsyncScope (fn, thisArg, args, abortController, store, cb) {
if (abortController?.signal.aborted) {
cb(null, null)
throw new AbortError('Aborted')
}
try {
const result = legacyStorage.run(store, () => fn.apply(thisArg, args))
if (typeof result?.then === 'function') {
return result.then(
res => { cb(null, res); return res },
err => { cb(err); throw err }
)
}
cb(null, result)
return result
} catch (err) {
cb(err)
throw err
}
}
function pathToArray (path) {
let length = 0
for (let curr = path; curr; curr = curr.prev) {
length += 1
}
const flattened = new Array(length)
let index = length
for (let curr = path; curr; curr = curr.prev) {
flattened[--index] = curr.key
}
return flattened
}
// Build the dotted pathString for a resolver's path node, caching per node on
// rootCtx.pathCache so each call reuses the parent's already-built string
// instead of re-walking the whole path linked-list (O(1) amortized per call).
// Collapse-aware: numeric (list-index) segments become '*'. The recursion
// handles the cold path where a parent node never hit a resolver (graphql
// inserts a synthetic array-index node between a list field and its items).
function buildCachedPathString (path, cache, collapse) {
const cached = cache.get(path)
if (cached !== undefined) return cached
const key = path.key
const segment = collapse && typeof key !== 'string' ? '*' : key
const prev = path.prev
const pathString = prev === undefined
? String(segment)
: `${buildCachedPathString(prev, cache, collapse)}.${segment}`
cache.set(path, pathString)
return pathString
}
/**
* @param {{ hasFieldsByPath?: boolean, fields: Map<string|object, object> }} rootCtx
* @param {object} path
* @param {object} field
*/
function cacheFieldByPath (rootCtx, path, field) {
// Leaf fields cannot parent resolver spans, so their concrete paths are never read.
if (field.fieldNode?.selectionSet === undefined) return
// Concrete info path objects cannot collide with collapsed path string keys.
rootCtx.hasFieldsByPath = true
rootCtx.fields.set(path, field)
}
// Depth filtering directly on the linked-list node — no array allocation needed.
// config.depth < 0 means no limit. Only selection-set segments (string keys)
// count toward depth; list indices are execution artifacts and are transparent.
// On the v5 line `countListIndices` keeps the legacy behaviour of counting every
// node when collapsing folds the numeric indices into '*'.
function shouldInstrumentNode (config, path) {
if (config.depth < 0) return true
let depth = 0
if (config.countListIndices) {
for (let curr = path; curr; curr = curr.prev) depth++
} else {
for (let curr = path; curr; curr = curr.prev) {
if (typeof curr.key === 'string') depth++
}
}
return config.depth >= depth
}
function getParentField (rootCtx, field) {
for (let curr = field.infoPath?.prev; curr; curr = curr.prev) {
const fieldKey = rootCtx.config.collapse ? rootCtx.pathCache.get(curr) : curr
const innerField = rootCtx.fields.get(fieldKey)
if (innerField) {
if (curr.typename === undefined) {
if (rootCtx.hasFieldsByPath) {
const fieldByPath = rootCtx.fields.get(curr)
if (fieldByPath) return fieldByPath
}
return innerField
}
if (innerField.parentTypeName === curr.typename) return innerField
const parentTypeFields = innerField.parentTypeFields
if (parentTypeFields.parentTypeName === undefined) return parentTypeFields.get(curr.typename)
return parentTypeFields
}
}
return null
}
// Build the resolverInfo payload that AppSec's datadog:graphql:resolver:start
// subscriber expects: { [fieldName]: { ...args, ...directives } }.
function getResolverInfo (info, args) {
let resolverVars = args ? { ...args } : undefined
const directives = info.fieldNodes?.[0]?.directives
if (Array.isArray(directives)) {
for (const directive of directives) {
if (directive.arguments.length === 0) continue
const argList = {}
for (const argument of directive.arguments) {
argList[argument.name.value] = argument.value.value
}
resolverVars ??= {}
resolverVars[directive.name.value] = argList
}
}
return resolverVars === undefined ? null : { [info.fieldName]: resolverVars }
}
// --- arg / context normalization ---------------------------------------------
// graphql.execute accepts either a single args object or positional arguments;
// the object form is a lone non-array object in slot 0.
function isObjectForm (args) {
return args?.length === 1 && args[0] && typeof args[0] === 'object' && !Array.isArray(args[0])
}
function readArgs (args, objectForm) {
if (!args || args.length === 0) return {}
if (objectForm) {
return args[0]
}
return {
schema: args[0],
document: args[1],
rootValue: args[2],
contextValue: args[3],
variableValues: args[4],
operationName: args[5],
fieldResolver: args[6],
}
}
// No user input may be modified. Object-form clones rawArgs[0]; positional
// form rewrites its own arguments slots (no caller-observable mutation).
// Returns the readArgs-shaped view of the (possibly cloned) args so the caller
// doesn't have to re-readArgs after the swap.
function setWrappedFieldResolver (rawArgs, args, objectForm, defaultFieldResolver) {
if (!rawArgs || rawArgs.length === 0) return args
if (objectForm) {
const clone = {
...args,
fieldResolver: wrapResolve(args.fieldResolver || defaultFieldResolver),
}
rawArgs[0] = clone
return clone
}
rawArgs[6] = wrapResolve(args.fieldResolver || defaultFieldResolver)
if (rawArgs.length < 7) rawArgs.length = 7
args.fieldResolver = rawArgs[6]
return args
}
function isWeakMapKey (value) {
return value !== null && (typeof value === 'object' || typeof value === 'function')
}
// Unwrap GraphQL List/NonNull wrappers to get the underlying named type's name.
// e.g. [Human] → 'Human', [Pet!] → 'Pet', String → 'String'
function getBaseTypeName (type) {
let cursor = type
while (cursor && cursor.ofType) cursor = cursor.ofType
return cursor?.name
}
// Fallback resolver used when graphql.execute() is called without an explicit
// fieldResolver and the schema field has no .resolve. Mirrors graphql's own
// defaultFieldResolver: property access on source, calling it if it's a function.
// Defined locally so it survives dd-trace plugin-manager reloads (agent.load()
// recreates globalThis[Symbol.for('dd-trace')], so capturing defaultFieldResolver
// via ddGlobal at IITM hook time would lose the reference across test suites).
function defaultFieldResolver (source, args, contextValue, info) {
if ((typeof source === 'object' && source !== null) || typeof source === 'function') {
const property = source[info.fieldName]
if (typeof property === 'function') return source[info.fieldName](args, contextValue, info)
return property
}
}
function addVariableTags (config, span, variableValues) {
if (!variableValues || !config.variables) return
const tags = {}
const variables = config.variables(variableValues)
for (const [param, value] of Object.entries(variables)) {
tags[`graphql.variables.${param}`] = value
}
span.addTags(tags)
}
module.exports = GraphQLExecutePlugin