Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: graphql responseHook support #508

Merged
merged 20 commits into from Jun 8, 2021
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -229,15 +229,55 @@ export class GraphQLInstrumentation extends InstrumentationBase {
processedArgs,
]);
},
err => {
endSpan(span, err);
(err, result) => {
instrumentation._handleExecutionResult(span, err, result);
}
);
});
};
};
}

private _handleExecutionResult(
span: api.Span,
err?: Error,
result?: PromiseOrValue<graphqlTypes.ExecutionResult>
) {
const config = this._getConfig();
if (typeof config.responseHook !== 'function' || result === undefined || err) {
endSpan(span, err);
return;
}

if (result.constructor.name === 'Promise') {
(result as Promise<graphqlTypes.ExecutionResult>).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) {
Expand Down
12 changes: 12 additions & 0 deletions plugins/node/opentelemetry-instrumentation-graphql/src/types.ts
Expand Up @@ -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.
Expand All @@ -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;
}

/**
Expand Down
Expand Up @@ -20,11 +20,13 @@ 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 = {};
Expand Down Expand Up @@ -971,6 +973,73 @@ 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[];

Expand Down