Skip to content

Commit

Permalink
feat(graphql): add ignoreTrivialResolveSpans config option (open-tele…
Browse files Browse the repository at this point in the history
  • Loading branch information
blumamir authored Nov 6, 2022
1 parent c9923e3 commit aff84bb
Show file tree
Hide file tree
Showing 5 changed files with 212 additions and 11 deletions.
48 changes: 47 additions & 1 deletion plugins/node/opentelemetry-instrumentation-graphql/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ provider.register();
registerInstrumentations({
instrumentations: [
new GraphQLInstrumentation({
// optional params
// optional params
// allowValues: true,
// depth: 2,
// mergeItems: true,
// ignoreTrivialResolveSpans: true,
}),
],
});
Expand All @@ -55,8 +56,53 @@ registerInstrumentations({
| mergeItems | boolean | false | Whether to merge list items into a single element. example: `users.*.name` instead of `users.0.name`, `users.1.name` | |
| depth | number | -1 | The maximum depth of fields/resolvers to instrument. When set to 0 it will not instrument fields and resolvers. When set to -1 it will instrument all fields and resolvers. | |
| allowValues | boolean | false | When set to true it will not remove attributes values from schema source. By default all values that can be sensitive are removed and replaced with "*" | |
| ignoreTrivialResolveSpans | boolean | false | Don't create spans for the execution of the default resolver on object properties. |
| responseHook | GraphQLInstrumentationExecutionResponseHook | undefined | Hook that allows adding custom span attributes based on the data returned from "execute" GraphQL action. | |

## Verbosity

The instrumentation by default will create a span for each invocation of a resolver.

A resolver is run by graphql for each field in the query response, which can be a lot of spans for objects with many properties, or when lists are involved.

There are few config options which can be used to reduce the verbosity of the instrumentations.

They are all disabled by default. User can opt in to any combination of them to contol the amount of spans.

### ignoreTrivialResolveSpans

When a resolver function is not defined on the schema for a field, graphql will use the default resolver which just looks for a property with that name on the object. If the property is not a function, it's not very interesting to trace.

### depth

The depth is the number of nesting levels of the field, and the following is a query with a depth of 3:

```json
{
a {
b {
c
}
}
}
```

You can limit the instrumentation to stop recording "resolve" spans after a specific depth is reached.

- `-1` means no limit.
- `0` means don't record any "resolve" spans.
- `2` for the example above will record a span for resolving "a" and "b" but not "c".

### mergeItems

When resolving a field to a list, graphql will execute a resolver for every field in the query on every object in the list.

When setting mergeItems to `true` it will only record a span for the first invocation of a resolver on each field in the list, marking it's path as "foo.*.bar" instead of "foo.0.bar", "foo.1.bar".

Notice that all span data only reflects the invocation on the first element. That includes timing, events and status.

Downstream spans in the context of all resolvers will be child of the first span.

## Examples

Can be found [here](https://github.com/open-telemetry/opentelemetry-js-contrib/tree/main/examples/graphql)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,9 @@ export class GraphQLInstrumentation extends InstrumentationBase {
args[3],
args[4],
args[5],
args[6] || defaultFieldResolved,
args[7]
args[6],
args[7],
defaultFieldResolved
);
} else {
const args = arguments[0] as graphqlTypes.ExecutionArgs;
Expand All @@ -195,8 +196,9 @@ export class GraphQLInstrumentation extends InstrumentationBase {
args.contextValue,
args.variableValues,
args.operationName,
args.fieldResolver || defaultFieldResolved,
args.typeResolver
args.fieldResolver,
args.typeResolver,
defaultFieldResolved
);
}

Expand Down Expand Up @@ -446,7 +448,8 @@ export class GraphQLInstrumentation extends InstrumentationBase {
variableValues: Maybe<{ [key: string]: any }>,
operationName: Maybe<string>,
fieldResolver: Maybe<graphqlTypes.GraphQLFieldResolver<any, any>>,
typeResolver: Maybe<graphqlTypes.GraphQLTypeResolver<any, any>>
typeResolver: Maybe<graphqlTypes.GraphQLTypeResolver<any, any>>,
defaultFieldResolved: graphqlTypes.GraphQLFieldResolver<any, any>
): OtelExecutionArgs {
if (!contextValue) {
contextValue = {};
Expand All @@ -463,10 +466,16 @@ export class GraphQLInstrumentation extends InstrumentationBase {
typeResolver,
};
}

const isUsingDefaultResolver = fieldResolver == null;
// follows graphql implementation here:
// https://github.com/graphql/graphql-js/blob/0b7daed9811731362c71900e12e5ea0d1ecc7f1f/src/execution/execute.ts#L494
const fieldResolverForExecute = fieldResolver ?? defaultFieldResolved;
fieldResolver = wrapFieldResolver(
this.tracer,
this._getConfig.bind(this),
fieldResolver
fieldResolverForExecute,
isUsingDefaultResolver
);

if (schema) {
Expand Down
14 changes: 14 additions & 0 deletions plugins/node/opentelemetry-instrumentation-graphql/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,27 @@ export interface GraphQLInstrumentationConfig extends InstrumentationConfig {
* @default false
*/
allowValues?: boolean;

/**
* The maximum depth of fields/resolvers to instrument.
* When set to 0 it will not instrument fields and resolvers
*
* @default undefined
*/
depth?: number;

/**
* Don't create spans for the execution of the default resolver on object properties.
*
* When a resolver function is not defined on the schema for a field, graphql will
* use the default resolver which just looks for a property with that name on the object.
* If the property is not a function, it's not very interesting to trace.
* This option can reduce noise and number of spans created.
*
* @default false
*/
ignoreTrivialResolveSpans?: boolean;

/**
* Whether to merge list items into a single element.
*
Expand Down
23 changes: 22 additions & 1 deletion plugins/node/opentelemetry-instrumentation-graphql/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export const isPromise = (value: any): value is Promise<unknown> => {
return typeof value?.then === 'function';
};

// https://github.com/graphql/graphql-js/blob/main/src/jsutils/isObjectLike.ts
const isObjectLike = (value: unknown): value is { [key: string]: unknown } => {
return typeof value == 'object' && value !== null;
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function addInputVariableAttribute(span: api.Span, key: string, variable: any) {
if (Array.isArray(variable)) {
Expand Down Expand Up @@ -365,7 +370,8 @@ export function wrapFieldResolver<TSource = any, TContext = any, TArgs = any>(
getConfig: () => Required<GraphQLInstrumentationConfig>,
fieldResolver: Maybe<
graphqlTypes.GraphQLFieldResolver<TSource, TContext, TArgs> & OtelPatched
>
>,
isDefaultResolver = false
): graphqlTypes.GraphQLFieldResolver<TSource, TContext, TArgs> & OtelPatched {
if (
(wrappedFieldResolver as OtelPatched)[OTEL_PATCHED_SYMBOL] ||
Expand All @@ -386,6 +392,21 @@ export function wrapFieldResolver<TSource = any, TContext = any, TArgs = any>(
}
const config = getConfig();

// follows what graphql is doing to decied if this is a trivial resolver
// for which we don't need to create a resolve span
if (
config.ignoreTrivialResolveSpans &&
isDefaultResolver &&
(isObjectLike(source) || typeof source === 'function')
) {
const property = (source as any)[info.fieldName];
// a function execution is not trivial and should be recorder.
// property which is not a function is just a value and we don't want a "resolve" span for it
if (typeof property !== 'function') {
return fieldResolver.call(this, source, args, contextValue, info);
}
}

if (!contextValue[OTEL_GRAPHQL_DATA_SYMBOL]) {
return fieldResolver.call(this, source, args, contextValue, info);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,15 @@ graphQLInstrumentation.enable();
graphQLInstrumentation.disable();

// now graphql can be required

import { buildSchema } from 'graphql';
import {
GraphQLSchema,
GraphQLObjectType,
GraphQLString,
buildSchema,
graphqlSync,
} from 'graphql';
import { buildTestSchema } from './schema';
import { graphql, graphqlSync } from './graphql-adaptor';
import { graphql } from './graphql-adaptor';
// Construct a schema, using GraphQL schema language
const schema = buildTestSchema();

Expand Down Expand Up @@ -578,6 +583,112 @@ describe('graphql', () => {
});
});

describe('when ignoreTrivialResolveSpans is set to true', () => {
beforeEach(() => {
create({
ignoreTrivialResolveSpans: true,
});
});

afterEach(() => {
exporter.reset();
graphQLInstrumentation.disable();
});

it('should create span for resolver defined on schema', async () => {
const simpleSchemaWithResolver = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'RootQueryType',
fields: {
hello: {
type: GraphQLString,
resolve() {
return 'world';
},
},
},
}),
});

await graphql({ schema: simpleSchemaWithResolver, source: '{ hello }' });
const resovleSpans = exporter
.getFinishedSpans()
.filter(span => span.name === SpanNames.RESOLVE);
assert.deepStrictEqual(resovleSpans.length, 1);
const resolveSpan = resovleSpans[0];
assert(resolveSpan.attributes[AttributeNames.FIELD_PATH] === 'hello');
});

it('should create span for resolver function', async () => {
const schema = buildSchema(`
type Query {
hello: String
}
`);

const rootValue = {
hello: () => 'world',
};

await graphql({ schema, source: '{ hello }', rootValue });
const resovleSpans = exporter
.getFinishedSpans()
.filter(span => span.name === SpanNames.RESOLVE);
assert.deepStrictEqual(resovleSpans.length, 1);
const resolveSpan = resovleSpans[0];
assert(resolveSpan.attributes[AttributeNames.FIELD_PATH] === 'hello');
});

it('should NOT create span for resolver property', async () => {
const schema = buildSchema(`
type Query {
hello: String
}
`);

const rootValue = {
hello: 'world', // regular property, not a function
};

await graphql({ schema, source: '{ hello }', rootValue });
const resovleSpans = exporter
.getFinishedSpans()
.filter(span => span.name === SpanNames.RESOLVE);
assert.deepStrictEqual(resovleSpans.length, 0);
});

it('should create resolve span for custom field resolver', async () => {
const schema = buildSchema(`
type Query {
hello: String
}
`);

const rootValue = {
hello: 'world', // regular property, not a function
};

// since we use a custom field resolver, we record a span
// even though the field is a property
const fieldResolver = (
source: any,
args: any,
context: any,
info: any
) => {
return source[info.fieldName];
};

await graphql({ schema, source: '{ hello }', rootValue, fieldResolver });
const resovleSpans = exporter
.getFinishedSpans()
.filter(span => span.name === SpanNames.RESOLVE);
assert.deepStrictEqual(resovleSpans.length, 1);
const resolveSpan = resovleSpans[0];
assert(resolveSpan.attributes[AttributeNames.FIELD_PATH] === 'hello');
});
});

describe('when allowValues is set to true', () => {
describe('AND source is query with param', () => {
let spans: ReadableSpan[];
Expand Down

0 comments on commit aff84bb

Please sign in to comment.