UNPKG

@instana/core

Version:
370 lines (331 loc) 14.3 kB
/* * (c) Copyright IBM Corp. 2021 * (c) Copyright Instana Inc. and contributors 2019 */ 'use strict'; const shimmer = require('../../shimmer'); const hook = require('../../../util/hook'); const tracingUtil = require('../../tracingUtil'); const constants = require('../../constants'); const cls = require('../../cls'); let logger; let isActive = false; const queryOperationType = 'query'; const mutationOperationType = 'mutation'; const subscriptionOperationType = 'subscription'; const operationTypes = [queryOperationType, mutationOperationType, subscriptionOperationType]; const subscriptionUpdate = 'subscription-update'; exports.init = function init(config) { logger = config.logger; hook.onFileLoad(/\/graphql\/execution\/execute.js/, instrumentExecute); hook.onFileLoad(/\/@apollo\/gateway\/dist\/executeQueryPlan.js/, instrumentApolloGatewayExecuteQueryPlan); hook.onModuleLoad('@apollo/federation', logDeprecatedWarning); }; function instrumentExecute(executeModule) { shimmer.wrap(executeModule, 'execute', shimExecuteFunction.bind(null)); } function shimExecuteFunction(originalFunction) { return function instrumentedExecute() { if (!isActive || cls.tracingSuppressed()) { // If this GraphQL query has been triggered by an HTTP request and it had X-INSTANA-L: 0, we have already set the // tracing level in the current cls context. return originalFunction.apply(this, arguments); } const originalThis = this; const originalArgs = arguments; let doc; let operationName; if (originalArgs.length === 1 && typeof originalArgs[0] === 'object') { doc = originalArgs[0].document; operationName = originalArgs[0].operationName; } else { doc = originalArgs[1]; operationName = originalArgs[5]; } const operationDefinition = findOperationDefinition(doc, operationName); if (!operationDefinition) { logger.debug('No operation definition, GraphQL call will not be traced.'); return originalFunction.apply(this, arguments); } if (!operationDefinition.operation) { logger.debug( `Operation definition has no operation, GraphQL call will not be traced. ${JSON.stringify(operationDefinition)}` ); return originalFunction.apply(this, arguments); } if (operationDefinition.operation === subscriptionOperationType) { return traceSubscriptionUpdate( originalFunction, originalThis, originalArgs, instrumentedExecute, operationDefinition, operationName ); } else { return traceQueryOrMutation( originalFunction, originalThis, originalArgs, instrumentedExecute, operationDefinition, operationName ); } }; } function traceQueryOrMutation( originalFunction, originalThis, originalArgs, stackTraceRef, operationDefinition, operationName ) { const activeEntrySpan = cls.getCurrentSpan(true); let span; if (activeEntrySpan && activeEntrySpan.k === constants.ENTRY && activeEntrySpan.n !== 'graphql.server') { // For now, we assume that the GraphQL operation is the only relevant operation that is happening while processing // the incoming request. // // With these assumptions in mind, we overwrite the entry span that has already been started (if any) and turn it // into a GraphQL entry span. // // HTTP is by far the most common transport for GraphQL, but others are possible and are actually used (AMQP for // example) (GraphQL is transport-layer agnostic). // // Possible consequences: // 1) If a customer does GraphQL over any transport that we trace as an entry, we will repurpose this // transport/protocol level entry span to a GraphQL entry by overwriting span.n. The attributes captured by the // protocol level tracing (the initial timestamp and for example HTTP attributes like url and method) will be kept. // But if the customer is using a transport that we do _not_ trace, we will start a new root entry span here. We // will still trace the GraphQL entry but might lose trace continuity, as X-Instana-T andX-Instana-S are not // transported at the GraphQL layer but rather in the underlying transport layer (HTTP, AMQP, ...) // // 2) If a customer does multiple things in an HTTP, AMQP, GRPC, ... call, only one of which is running a GraphQL // query, it is possible that they would rather see this call as the HTTP/AMQP/GRPC/... call instead of a GraphQL // call. But since we give GraphQL preference over protocol level entry spans, it will show up as a GraphQL call. // Replace generic node.http.server/rabbitmq/... span by a more specific graphql.server span. We change the values // in the active span in-situ. This way, other intermediate and exit spans that have already been created as // children of the current entry span still have the the correct parent span ID. span = activeEntrySpan; span.n = 'graphql.server'; // Mark this span so that the GraphQL instrumentation will not transmit it, instead we wait for the protocol level // instrumentation to finish, which will then transmit it. This will ensure that we also capture attributes that are // only written by the protocol level instrumentation at the end (like the HTTP status code). // (This property is defined as non-enumerable in the InstanaSpan class and will not be serialized to JSON.) span.postponeTransmit = true; } return cls.ns.runAndReturn(() => { if (!span) { // If there hasn't been an entry span that we repurposed into a graphql.server entry span, we need to start a new // root entry span here. span = cls.startSpan({ spanName: 'graphql.server', kind: constants.ENTRY }); } span.stack = tracingUtil.getStackTrace(stackTraceRef); span.data.graphql = { operationType: operationDefinition.operation, operationName: operationDefinition.name ? operationDefinition.name.value : operationName, fields: {}, args: {} }; addFieldsAndArguments(span, operationDefinition); return runOriginalAndFinish(originalFunction, originalThis, originalArgs, span); }); } function traceSubscriptionUpdate( originalFunction, originalThis, originalArgs, stackTraceRef, operationDefinition, operationName ) { if (!isActive) { return originalFunction.apply(originalThis, originalArgs); } // WE DO NOT MAKE USE OF `cls.skipExitTracing` in the graphql instrumentation, // because we have a special usage of `cls.getReducedSpan(true)`. Feel free to refactor this. // CASE 1: We pass `fallbackToSharedContext: true` to getCurrentSpan to access the GraphQL query context, which then // triggered this subscription query. We need to connect them. // CASE 2: If there is no active entry span, we fall back to the reduced span of the most recent entry span. See // comment in packages/core/src/tracing/clsHooked/unset.js#storeReducedSpan. // CASE 3: We can ignore `allowRootExitSpan` is enabled, because subscriptions only work with the // apollo-server-express and it doesn't make sense to enable `allowRootExitSpan` for this case. const parentSpan = cls.getCurrentSpan(true) || cls.getReducedSpan(true); if (parentSpan && !constants.isExitSpan(parentSpan) && parentSpan.t && parentSpan.s) { return cls.ns.runAndReturn(() => { const span = cls.startSpan({ spanName: 'graphql.client', kind: constants.EXIT, traceId: parentSpan.t, parentSpanId: parentSpan.s }); span.ts = Date.now(); span.stack = tracingUtil.getStackTrace(stackTraceRef); span.data.graphql = { operationType: subscriptionUpdate, operationName: operationDefinition.name ? operationDefinition.name.value : operationName, fields: {}, args: {} }; addFieldsAndArguments(span, operationDefinition); return runOriginalAndFinish(originalFunction, originalThis, originalArgs, span); }); } else { return originalFunction.apply(originalThis, originalArgs); } } function findOperationDefinition(doc, operationNameFromArgs) { if (doc && Array.isArray(doc.definitions)) { if (operationNameFromArgs) { return doc.definitions .filter(definition => operationTypes.indexOf(definition.operation) !== -1) .find(definition => { const name = definition.name ? definition.name.value : null; return name && operationNameFromArgs === name; }); } else { return doc.definitions.find(definition => operationTypes.indexOf(definition.operation) !== -1); } } return null; } function addFieldsAndArguments(span, definition) { traverseSelections(definition, entities => { entities.forEach(function (entity) { const entityName = entity.name.value; traverseSelections(entity, fields => { span.data.graphql.fields[entityName] = fields.map(field => field.name.value); }); if (Array.isArray(entity.arguments) && entity.arguments.length > 0) { span.data.graphql.args[entityName] = entity.arguments.map(arg => arg.name && typeof arg.name.value === 'string' ? arg.name.value : '?' ); } }); }); } function traverseSelections(definition, selectionPostProcessor) { if ( !definition.selectionSet || typeof definition.selectionSet !== 'object' || !Array.isArray(definition.selectionSet.selections) ) { return null; } const candidates = definition.selectionSet.selections.filter( selection => selection && selection.kind === 'Field' && selection.name && typeof selection.name.value === 'string' ); return selectionPostProcessor(candidates); } function runOriginalAndFinish(originalFunction, originalThis, originalArgs, span) { let result; try { result = originalFunction.apply(originalThis, originalArgs); } catch (e) { // A synchronous exception happened when resolving the GraphQL query, finish immediately. finishWithException(span, e); return result; } // A graphql resolver can yield a value, a promise or an array of promises. Fortunately, the "array of promises" // case is handled internally (the array is merged into one promise) so we only need to differntiate between // the promise and value cases. if (result && typeof result.then === 'function') { return result.then( promiseResult => { finishSpan(span, promiseResult); return promiseResult; }, err => { finishWithException(span, err); throw err; } ); } else { // The GraphQL operation returned a value instead of a promise - that means, the resolver finished synchronously. We // can finish the span immediately. finishSpan(span, result); return result; } } function finishSpan(span, result) { span.ec = result.errors && result.errors.length >= 1 ? 1 : 0; span.d = Date.now() - span.ts; if (Array.isArray(result.errors)) { span.data.graphql.errors = result.errors .map(singleError => (typeof singleError.message === 'string' ? singleError.message : null)) .filter(msg => !!msg) .join(', '); } if (!span.postponeTransmit && !span.postponeTransmitApolloGateway) { span.transmit(); } } function finishWithException(span, err) { span.ec = 1; span.d = Date.now() - span.ts; span.data.graphql.errors = err.message; if (!span.postponeTransmit) { span.transmit(); } } function instrumentApolloGatewayExecuteQueryPlan(apolloGatewayExecuteQueryPlanModule) { shimmer.wrap( apolloGatewayExecuteQueryPlanModule, 'executeQueryPlan', shimApolloGatewayExecuteQueryPlanFunction.bind(null) ); } function shimApolloGatewayExecuteQueryPlanFunction(originalFunction) { return function instrumentedExecuteQueryPlan() { if (!isActive || cls.tracingSuppressed()) { return originalFunction.apply(this, arguments); } const activeEntrySpan = cls.getCurrentSpan(); if (activeEntrySpan && activeEntrySpan.k === constants.ENTRY) { // Most of the heavy lifting to trace Apollo Federation gateways (implemented by @apollo/gateway) is done by our // standard GraphQL tracing, because those gateway queries are all also run through normal resolvers, which we // instrument. There is one case that requires extra instrumentation, though. @apollo/gateway does something funky // with errors that come back from individual services: We would normally finish and transmit the span in // shimExecuteFunction/traceQueryOrMutation/runOriginalAndFinish, but when Apollo Gateway is involved the errors // (if any) are not part of the response. Therefore we mark the span so that transmitting it will be postponed, // that is, it won't happen in runOriginalAndFinish. Once the call returns from @apollo/gateway#executeQueryPlan // the errors have been merged back into the response and only then will we record the errors and finally transmit // the span. This is implemented via the marker property postponeTransmitApolloGateway. // (This property is defined as non-enumerable in the InstanaSpan class and will not be serialized to JSON.) activeEntrySpan.postponeTransmitApolloGateway = true; } const resultPromise = originalFunction.apply(this, arguments); if (resultPromise && typeof resultPromise.then === 'function') { return resultPromise.then( promiseResult => { delete activeEntrySpan.postponeTransmitApolloGateway; finishSpan(activeEntrySpan, promiseResult); return promiseResult; }, err => { delete activeEntrySpan.postponeTransmitApolloGateway; finishWithException(activeEntrySpan, err); throw err; } ); } return resultPromise; }; } function logDeprecatedWarning() { logger.warn( // eslint-disable-next-line max-len '[Deprecation Warning] The support for @apollo-federation is deprecated and will be removed in the next major release. Please consider migrating to an appropriate package.' ); } exports.activate = function activate() { isActive = true; }; exports.deactivate = function () { isActive = false; };