Skip to content

Commit

Permalink
feat: graphql responseHook support (#508)
Browse files Browse the repository at this point in the history
* feat: add responseHook config to redis instrumentation

* fix: lint fix

* feat: add responseHook config to redis instrumentation

* fix: lint fix

* chore(deps): update all non-major dependencies (#483)

* chore: generalize the instrumentation file name (#479)

* feat: add responseHook config to graphql instrumentation

* Apply suggestions from code review

Co-authored-by: Bartlomiej Obecny <bobecny@gmail.com>

* test: add a test for a responseHook that isn't a function

* fix: lint fix

Co-authored-by: WhiteSource Renovate <bot@renovateapp.com>
Co-authored-by: Rauno Viskus <Rauno56@users.noreply.github.com>
Co-authored-by: Bartlomiej Obecny <bobecny@gmail.com>
  • Loading branch information
4 people committed Jun 8, 2021
1 parent 4a0dab7 commit c7df125
Show file tree
Hide file tree
Showing 3 changed files with 132 additions and 3 deletions.
Expand Up @@ -229,15 +229,59 @@ 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,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 = {};
Expand Down Expand Up @@ -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[];

Expand Down

0 comments on commit c7df125

Please sign in to comment.