diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/instrumentation.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/instrumentation.ts index d8b2db0628..a5819b1e87 100644 --- a/plugins/node/opentelemetry-instrumentation-graphql/src/instrumentation.ts +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/instrumentation.ts @@ -229,8 +229,8 @@ export class GraphQLInstrumentation extends InstrumentationBase { processedArgs, ]); }, - err => { - endSpan(span, err); + (err, result) => { + instrumentation._handleExecutionResult(span, err, result); } ); }); @@ -238,6 +238,50 @@ export class GraphQLInstrumentation extends InstrumentationBase { }; } + private _handleExecutionResult( + span: api.Span, + err?: Error, + result?: PromiseOrValue + ) { + const config = this._getConfig(); + if ( + typeof config.responseHook !== 'function' || + result === undefined || + err + ) { + endSpan(span, err); + return; + } + + if (result.constructor.name === 'Promise') { + (result as Promise).then(resultData => { + this._executeResponseHook(span, resultData); + }); + } else { + this._executeResponseHook(span, result as graphqlTypes.ExecutionResult); + } + } + + private _executeResponseHook( + span: api.Span, + result: graphqlTypes.ExecutionResult + ) { + const config = this._getConfig(); + safeExecuteInTheMiddle( + () => { + config.responseHook(span, result); + }, + err => { + if (err) { + api.diag.error('Error running response hook', err); + } + + endSpan(span, undefined); + }, + true + ); + } + private _patchParse(): (original: parseType) => parseType { const instrumentation = this; return function parse(original) { diff --git a/plugins/node/opentelemetry-instrumentation-graphql/src/types.ts b/plugins/node/opentelemetry-instrumentation-graphql/src/types.ts index 39bbebf17f..26265cc372 100644 --- a/plugins/node/opentelemetry-instrumentation-graphql/src/types.ts +++ b/plugins/node/opentelemetry-instrumentation-graphql/src/types.ts @@ -29,6 +29,10 @@ import { OTEL_GRAPHQL_DATA_SYMBOL, OTEL_PATCHED_SYMBOL } from './symbols'; export const OPERATION_NOT_SUPPORTED = 'Operation$operationName$not' + ' supported'; +export interface GraphQLInstrumentationExecutionResponseHook { + (span: api.Span, data: graphqlTypes.ExecutionResult): void; +} + export interface GraphQLInstrumentationConfig extends InstrumentationConfig { /** * When set to true it will not remove attributes values from schema source. @@ -53,6 +57,14 @@ export interface GraphQLInstrumentationConfig extends InstrumentationConfig { * @default false */ mergeItems?: boolean; + + /** + * Hook that allows adding custom span attributes based on the data + * returned from "execute" GraphQL action. + * + * @default undefined + */ + responseHook?: GraphQLInstrumentationExecutionResponseHook; } /** diff --git a/plugins/node/opentelemetry-instrumentation-graphql/test/graphql.test.ts b/plugins/node/opentelemetry-instrumentation-graphql/test/graphql.test.ts index 64dc6c72af..51ac5a9bee 100644 --- a/plugins/node/opentelemetry-instrumentation-graphql/test/graphql.test.ts +++ b/plugins/node/opentelemetry-instrumentation-graphql/test/graphql.test.ts @@ -20,11 +20,16 @@ import { ReadableSpan, SimpleSpanProcessor, } from '@opentelemetry/tracing'; +import { Span } from '@opentelemetry/api'; import * as assert from 'assert'; +import type * as graphqlTypes from 'graphql'; import { GraphQLInstrumentation } from '../src'; import { SpanNames } from '../src/enum'; import { AttributeNames } from '../src/enums/AttributeNames'; -import { GraphQLInstrumentationConfig } from '../src/types'; +import { + GraphQLInstrumentationConfig, + GraphQLInstrumentationExecutionResponseHook, +} from '../src/types'; import { assertResolveSpan } from './helper'; const defaultConfig: GraphQLInstrumentationConfig = {}; @@ -971,6 +976,74 @@ describe('graphql', () => { }); }); + describe('responseHook', () => { + let spans: ReadableSpan[]; + let graphqlResult: graphqlTypes.ExecutionResult; + const dataAttributeName = 'graphql_data'; + + afterEach(() => { + exporter.reset(); + graphQLInstrumentation.disable(); + spans = []; + }); + + describe('when responseHook is valid', () => { + beforeEach(async () => { + create({ + responseHook: (span: Span, data: graphqlTypes.ExecutionResult) => { + span.setAttribute(dataAttributeName, JSON.stringify(data)); + }, + }); + graphqlResult = await graphql(schema, sourceList1); + spans = exporter.getFinishedSpans(); + }); + + it('should attach response hook data to the resulting spans', () => { + const querySpan = spans.find( + span => span.attributes['graphql.operation.name'] == 'query' + ); + const instrumentationResult = querySpan?.attributes[dataAttributeName]; + assert.deepStrictEqual( + instrumentationResult, + JSON.stringify(graphqlResult) + ); + }); + }); + + describe('when responseHook throws an error', () => { + beforeEach(async () => { + create({ + responseHook: (_span: Span, _data: graphqlTypes.ExecutionResult) => { + throw 'some kind of failure!'; + }, + }); + graphqlResult = await graphql(schema, sourceList1); + spans = exporter.getFinishedSpans(); + }); + + it('should not do any harm', () => { + assert.deepStrictEqual(graphqlResult.data?.books?.length, 13); + }); + }); + + describe('when responseHook is not a function', () => { + beforeEach(async () => { + // Cast to unknown so that it's possible to cast to GraphQLInstrumentationExecutionResponseHook later + const invalidTypeHook = 1234 as unknown; + create({ + responseHook: + invalidTypeHook as GraphQLInstrumentationExecutionResponseHook, + }); + graphqlResult = await graphql(schema, sourceList1); + spans = exporter.getFinishedSpans(); + }); + + it('should not do any harm', () => { + assert.deepStrictEqual(graphqlResult.data?.books?.length, 13); + }); + }); + }); + describe('when query operation is not supported', () => { let spans: ReadableSpan[];