Skip to content

Commit 37b372c

Browse files
author
David Tanner
authored
Merge pull request #65 from lifeomic/circularContext
Handle synchronous resolver errors
2 parents cba8644 + 884be95 commit 37b372c

11 files changed

Lines changed: 1234 additions & 113 deletions

.eslintrc.yml

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,20 @@
11
{
2-
"overrides": [
3-
{
4-
"files": ["**/*.js"],
5-
"extends": [
6-
"plugin:@lifeomic/eslint-plugin-node/recommended"
7-
]
8-
},
9-
{
10-
"files": ["**/*.ts"],
11-
"extends": [
12-
"plugin:@lifeomic/eslint-plugin-node/recommended",
13-
"eslint:recommended",
14-
"plugin:@typescript-eslint/eslint-recommended",
15-
"plugin:@typescript-eslint/recommended"
16-
],
17-
"parser": "@typescript-eslint/parser",
18-
"plugins": ["@typescript-eslint"],
19-
"rules": {
20-
"@typescript-eslint/no-explicit-any": "off",
21-
"@typescript-eslint/ban-ts-ignore": "off",
22-
"@typescript-eslint/explicit-function-return-type": "off",
23-
"@typescript-eslint/no-non-null-assertion": "off",
24-
"@typescript-eslint/no-var-requires": "off"
25-
}
26-
}
27-
]
2+
"ignorePatterns": [
3+
"**/__generated__/**/*"
4+
],
5+
"extends": [
6+
"plugin:@lifeomic/eslint-plugin-node/recommended",
7+
"eslint:recommended",
8+
"plugin:@typescript-eslint/eslint-recommended",
9+
"plugin:@typescript-eslint/recommended"
10+
],
11+
"parser": "@typescript-eslint/parser",
12+
"plugins": ["@typescript-eslint"],
13+
"rules": {
14+
"@typescript-eslint/no-explicit-any": "off",
15+
"@typescript-eslint/ban-ts-ignore": "off",
16+
"@typescript-eslint/explicit-function-return-type": "off",
17+
"@typescript-eslint/no-non-null-assertion": "off",
18+
"@typescript-eslint/no-var-requires": "off"
19+
}
2820
}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ node_modules/
33

44
src/**/*.js
55
test/**/*.js
6+
**/__generated__/

.npmignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,7 @@ test/
33
.travis*
44
TODO.MD
55
.nyc_output/
6-
images/
6+
images/
7+
.github/
8+
.eslintrc.yml
9+
.codegen.yml

codegen.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
overwrite: true
2+
schema: ./test/**/*.graphql
3+
generates:
4+
test/__generated__/graphql.ts:
5+
plugins:
6+
- typescript
7+
config:
8+
useIndexSignature: true
9+
namingConvention: keep

package.json

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lifeomic/graphql-resolvers-xray-tracing",
3-
"version": "4.0.0",
3+
"version": "5.0.0",
44
"description": "A GraphQL middleware to enable X-Ray tracing subsegments for GraphQL resolvers",
55
"homepage": "https://github.com/lifeomic/graphql-resolvers-xray-tracing#readme",
66
"bugs": {
@@ -12,10 +12,15 @@
1212
},
1313
"scripts": {
1414
"lint": "eslint . -f codeframe",
15+
"pretypes": "yarn cleanTypes",
16+
"types": "graphql-codegen",
17+
"pretest": "yarn types",
1518
"test": "nyc ava",
1619
"posttest": "yarn lint && tsc --noEmit",
20+
"postinstall": "if [ -d test ]; then yarn types; fi",
1721
"coverage": "nyc report --reporter=text-lcov > ./.nyc_output/lcov.info",
18-
"prepublishOnly": "yarn tsc"
22+
"prepublishOnly": "yarn tsc",
23+
"cleanTypes": "rm -rf test/__generated__"
1924
},
2025
"main": "src/traceResolvers.js",
2126
"types": "src/traceResolvers.ts",
@@ -32,6 +37,9 @@
3237
"author": "LifeOmic <development@lifeomic.com>",
3338
"license": "MIT",
3439
"devDependencies": {
40+
"@graphql-codegen/cli": "1.2.1",
41+
"@graphql-codegen/typescript": "1.2.1",
42+
"@graphql-toolkit/file-loading": "^0.10.7",
3543
"@lifeomic/eslint-plugin-node": "^2.0.1",
3644
"@types/graphql": "^14.0.7",
3745
"@types/promise-retry": "^1.1.3",
@@ -81,6 +89,6 @@
8189
"access": "public"
8290
},
8391
"peerDependencies": {
84-
"aws-xray-sdk-core": "^2.0.0 || ^3.0.0"
92+
"aws-xray-sdk-core": "^2.5.0 || ^3.0.0"
8593
}
8694
}

src/traceResolvers.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { applyMiddlewareToDeclaredResolvers } from 'graphql-middleware';
22
import AWSXRay from 'aws-xray-sdk-core';
33
import { GraphQLResolveInfo, GraphQLSchema, ResponsePath } from 'graphql';
44
import { GraphQLSchemaWithFragmentReplacements, IMiddlewareResolver } from 'graphql-middleware/dist/types';
5-
const isPromise = require('is-promise');
65

76
function fieldPathFromInfo (info: GraphQLResolveInfo) {
87
let path: ResponsePath | undefined = info.path;
@@ -19,20 +18,15 @@ export default <TSource = any, TContext = any, TArgs = any>(schema: GraphQLSchem
1918
const tracer: IMiddlewareResolver<TSource, TContext, TArgs> = async (resolver, parent, args, ctx, info) => {
2019
const fieldPath = fieldPathFromInfo(info);
2120
return AWSXRay.captureAsyncFunc(`GraphQL ${fieldPath}`, async (subsegment) => {
22-
const result = resolver();
23-
24-
// When AWS_XRAY_CONTEXT_MISSING is set to LOG_MISSING and no context was
25-
// found, then the subsegment will be null and nothing should be done
26-
if (subsegment) {
27-
if (isPromise(result)) {
28-
result.then(() => subsegment.close(), (error: Error | any) => {
29-
subsegment.close(error);
30-
});
31-
} else {
32-
subsegment.close();
33-
}
21+
// When AWS_XRAY_CONTEXT_MISSING is set to LOG_MISSING and no context is found then subsegment is undefined.
22+
try {
23+
const result = await resolver();
24+
subsegment?.close();
25+
return result;
26+
} catch (error: any) {
27+
subsegment?.close(error);
28+
throw error;
3429
}
35-
return result;
3630
});
3731
};
3832

test/helpers/schema.graphql

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
type Parent {
2+
name: String!
3+
}
4+
5+
type Query {
6+
hello: String!
7+
throwsSynchronously: String!
8+
throwsAsynchronously: String!
9+
waitFor(id: String!): Boolean
10+
parent: Parent!
11+
}
12+
13+
type Mutation {
14+
createBlocking: String!
15+
resolve(id: String!): Boolean
16+
reject(id: String!): Boolean
17+
}

test/helpers/schema.ts

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { makeExecutableSchema } from 'graphql-tools';
22
import { v4 as uuid } from 'uuid';
3+
import { loadFiles } from '@graphql-toolkit/file-loading';
4+
import path from 'path';
5+
import traceResolvers from '../../src/traceResolvers';
36

47
const blocked = new Map();
58

@@ -11,6 +14,10 @@ function throwsSynchronously () {
1114
throw new Error('Some error');
1215
}
1316

17+
function throwsAsynchronously () {
18+
throw new Error('Some error');
19+
}
20+
1421
function createBlocking () {
1522
const id = uuid();
1623

@@ -62,6 +69,7 @@ const resolvers = {
6269
Query: {
6370
hello,
6471
throwsSynchronously,
72+
throwsAsynchronously,
6573
waitFor,
6674
parent
6775
},
@@ -72,23 +80,10 @@ const resolvers = {
7280
}
7381
};
7482

75-
const typeDefs = `
76-
type Parent {
77-
name: String!
78-
}
79-
80-
type Query {
81-
hello: String!
82-
throwsSynchronously: String!
83-
waitFor(id: String!): Boolean
84-
parent: Parent!
85-
}
86-
87-
type Mutation {
88-
createBlocking: String!
89-
resolve(id: String!): Boolean
90-
reject(id: String!): Boolean
83+
export function traceSchema () {
84+
const schema = makeExecutableSchema({
85+
typeDefs: loadFiles(path.join(__dirname, '**/*.graphql')),
86+
resolvers
87+
});
88+
return traceResolvers(schema);
9189
}
92-
`;
93-
94-
export default makeExecutableSchema({ typeDefs, resolvers });

test/traceResolvers-contextMissing.test.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import traceResolvers from '../src/traceResolvers';
2-
import { graphql } from 'graphql';
3-
import schema from './helpers/schema';
1+
import { graphql, GraphQLError, Source } from 'graphql';
2+
import { traceSchema } from './helpers/schema';
43
import nock from 'nock';
54
import anyTest, { TestInterface } from 'ava';
65
import AWSXRay from 'aws-xray-sdk-core';
6+
import { ExecutionResult, ExecutionResultDataDefault } from 'graphql/execution/execute';
77
AWSXRay.setContextMissingStrategy('LOG_ERROR');
88
AWSXRay.setLogger({
99
debug: () => undefined,
@@ -13,17 +13,24 @@ AWSXRay.setLogger({
1313
});
1414
AWSXRay.capturePromise();
1515

16+
type GraphQlQuery = Parameters<typeof graphql>[1];
17+
18+
const source = new Source('', '', {
19+
column: 3,
20+
line: 1
21+
});
22+
1623
interface TestContext {
17-
graphql: (query: Parameters<typeof graphql>[1]) => ReturnType<typeof graphql>;
24+
graphql: <TData = ExecutionResultDataDefault>(query: GraphQlQuery) => Promise<ExecutionResult<TData>>;
1825
}
1926

2027
const test = anyTest as TestInterface<TestContext>;
2128

2229
test.beforeEach((t) => {
2330
nock.disableNetConnect();
2431
nock.enableNetConnect('127.0.0.1');
32+
const schema = traceSchema();
2533

26-
traceResolvers(schema);
2734
t.context.graphql = (query) => graphql(schema, query);
2835
});
2936

@@ -45,3 +52,21 @@ test('Traced resolvers can return a value', async (t) => {
4552
}
4653
});
4754
});
55+
56+
test('Traced resolvers will throw exceptions when throwsSynchronously', async (t) => {
57+
const { graphql } = t.context;
58+
const result = await graphql('{ throwsSynchronously }');
59+
60+
const expected = new GraphQLError('Some error', undefined, source, [2], ['throwsSynchronously']);
61+
t.is(result.errors?.length, 1);
62+
t.deepEqual(result.errors, [expected]);
63+
});
64+
65+
test('Traced resolvers will throw exceptions when throwsAsynchronously', async (t) => {
66+
const { graphql } = t.context;
67+
const result = await graphql('{ throwsAsynchronously }');
68+
69+
const expected = new GraphQLError('Some error', undefined, source, [2], ['throwsAsynchronously']);
70+
t.is(result.errors?.length, 1);
71+
t.deepEqual(result.errors, [expected]);
72+
});

test/traceResolvers.test.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,43 @@
1-
import traceResolvers from '../src/traceResolvers';
21
import { graphql } from 'graphql';
3-
import schema from './helpers/schema';
2+
import { traceSchema } from './helpers/schema';
43
import nock from 'nock';
54
import anyTest, { ExecutionContext, TestInterface } from 'ava';
65
import AWSXRay, { Segment, Subsegment } from 'aws-xray-sdk-core';
76
import retryPromise from 'promise-retry';
7+
import { ExecutionResult, ExecutionResultDataDefault } from 'graphql/execution/execute';
8+
import { Mutation } from './__generated__/graphql';
89

910
AWSXRay.capturePromise();
1011

12+
type GraphQlQuery = Parameters<typeof graphql>[1];
13+
type Namespace = ReturnType<typeof AWSXRay.getNamespace>;
14+
1115
interface TestContext {
16+
ns: Namespace;
1217
segment: Segment;
13-
graphql: (query: string) => Promise<any>;
18+
graphql: <TData = ExecutionResultDataDefault>(query: GraphQlQuery) => Promise<ExecutionResult<TData>>;
1419
}
1520

1621
const test = anyTest as TestInterface<TestContext>;
1722

1823
test.beforeEach(function (test) {
24+
const schema = traceSchema();
1925
nock.disableNetConnect();
2026
nock.enableNetConnect('127.0.0.1');
2127

22-
const ns = AWSXRay.getNamespace();
23-
const segment = new AWSXRay.Segment('parent');
28+
const ns: Namespace = AWSXRay.getNamespace();
29+
test.context.ns = ns;
2430

31+
const segment = new AWSXRay.Segment('parent');
2532
test.context.segment = segment;
26-
traceResolvers(schema);
27-
test.context.graphql = ns.bind(function (query: string) {
33+
34+
test.context.graphql = ns.bind(function (query: GraphQlQuery) {
2835
AWSXRay.setSegment(segment);
29-
return graphql(schema, query);
36+
try {
37+
return graphql(schema, query);
38+
} finally {
39+
segment.close();
40+
}
3041
});
3142
});
3243

@@ -84,11 +95,22 @@ test('Trace segments are reported as errors when resolver throws an error synchr
8495
test.truthy(segment.subsegments![0].fault);
8596
});
8697

98+
test('Trace segments are reported as errors when resolver throws an error asynchronously', async function (test) {
99+
const { segment, graphql } = test.context;
100+
await graphql('{ throwsAsynchronously }');
101+
102+
test.is(segment.subsegments?.length, 1);
103+
test.is(segment.subsegments![0].name, 'GraphQL throwsAsynchronously');
104+
test.falsy(segment.subsegments![0].in_progress);
105+
// @ts-ignore
106+
test.truthy(segment.subsegments![0].fault);
107+
});
108+
87109
async function testAsyncResolver (test: ExecutionContext<TestContext>, unblockQueryBuilder: (id: string) => string): Promise<Subsegment> {
88110
const { segment, graphql } = test.context;
89111

90-
const createResult = await graphql('mutation { createBlocking }');
91-
const blockedId = createResult.data.createBlocking;
112+
const createResult = await graphql<Mutation>('mutation { createBlocking }');
113+
const blockedId = createResult.data!.createBlocking;
92114

93115
test.is(segment.subsegments?.length, 1);
94116
test.is(segment.subsegments![0].name, 'GraphQL createBlocking');

0 commit comments

Comments
 (0)