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

apollo-inline-trace - tracing should not fail if non-nullable field throw an error #3455

Merged
merged 2 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/changelog1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@graphql-yoga/plugin-apollo-usage-report": patch
---

- fixed: get specific or the nearest possible trace node if something fails at `non-nullable` GraphQL query field
6 changes: 6 additions & 0 deletions .changeset/changelog2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@graphql-yoga/plugin-apollo-inline-trace": patch
---

- updated: `@envelop/on-resolve@^4.1.1` dependency
- fixed: package `@envelop/core@^5.0.2` was added to devDependencies and `@envelop/on-resolve` was removed
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@
},
"overrides": {
"graphql": "16.8.1",
"@envelop/core": "5.0.1",
"@envelop/core": "5.0.2",
Copy link
Contributor Author

@kroupacz kroupacz Nov 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @EmrysMyrddin @ardatan,
I had to update the version of this package here as well, because it overrides the version for all other packages in this repository as well. In my case for the @graphql-yoga/plugin-apollo-inline-trace package.

"@changesets/assemble-release-plan": "5.2.1",
"@types/react": "18.2.55"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
import { FormattedExecutionResult, GraphQLFormattedError, versionInfo } from 'graphql';
import { createYoga, YogaServerInstance } from 'graphql-yoga';
import { Trace } from '@apollo/usage-reporting-protobuf';
import { useApolloInlineTrace } from '@graphql-yoga/plugin-apollo-inline-trace';
import { getStitchedSchemaFromLocalSchemas } from './fixtures/getStitchedSchemaFromLocalSchemas';
import { getSubgraph1Schema } from './fixtures/subgraph1';
import { getSubgraph2Schema } from './fixtures/subgraph2';

const describeIf = (condition: boolean) => (condition ? describe : describe.skip);

describeIf(versionInfo.major >= 16)('Inline Trace - Yoga gateway', () => {
let yoga: YogaServerInstance<Record<string, unknown>, Record<string, unknown>>;

beforeAll(async () => {
const gatewaySchema = await getStitchedSchemaFromLocalSchemas({
subgraph1: await getSubgraph1Schema(),
subgraph2: await getSubgraph2Schema(),
});

yoga = createYoga({
schema: gatewaySchema,
plugins: [useApolloInlineTrace()],
maskedErrors: false,
});
});

function expectTrace(trace: Trace) {
const expectedTrace: Partial<Trace> = {
root: {
child: expect.any(Array),
},
startTime: {
seconds: expect.any(Number),
nanos: expect.any(Number),
},
endTime: {
seconds: expect.any(Number),
nanos: expect.any(Number),
},
durationNs: expect.any(Number),
fieldExecutionWeight: expect.any(Number),
};

expect(trace).toMatchObject(expectedTrace);
// its ok to be "equal" since executions can happen in the same tick
expect(trace.startTime!.seconds).toBeLessThanOrEqual(trace.endTime!.seconds!);
if (trace.startTime!.seconds === trace.endTime!.seconds) {
expect(trace.startTime!.nanos).toBeLessThanOrEqual(trace.endTime!.nanos!);
}
}

function expectTraceNode(
node: Trace.INode,
responseName: string,
type: string,
parentType: string,
) {
const expectedTraceNode: Partial<Trace.INode> = {
responseName,
type,
parentType,
startTime: expect.any(Number),
endTime: expect.any(Number),
};

expect(node).toMatchObject(expectedTraceNode);
// its ok to be "equal" since executions can happen in the same tick
expect(node.startTime).toBeLessThanOrEqual(node.endTime!);
}

it('nullableFail - multi federated query - should return result with expected data and errors', async () => {
const query = /* GraphQL */ `
query {
testNestedField {
nullableFail {
id
email
sub1
}
subgraph2 {
id
email
sub2
}
}
}
`;

const expectedData = {
testNestedField: {
nullableFail: null,
subgraph2: {
email: 'user2@example.com',
id: 'user2',
sub2: true,
},
},
};

const expectedErrors: GraphQLFormattedError[] = [
{
message: 'My original subgraph error!',
path: ['testNestedField', 'nullableFail'],
},
];

const response = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
body: JSON.stringify({ query }),
headers: {
'content-type': 'application/json',
'apollo-federation-include-trace': 'ftv1',
},
});

const result: FormattedExecutionResult = await response.json();

expect(response.status).toBe(200);
expect(result.errors).toMatchObject(expectedErrors);
expect(result.data).toMatchObject(expectedData);
expect(result.extensions?.ftv1).toEqual(expect.any(String));

const ftv1 = result.extensions?.ftv1 as string;
const trace = Trace.decode(Buffer.from(ftv1, 'base64'));

expectTrace(trace);

const nullableFail = trace.root?.child?.[0].child?.[0] as Trace.INode;

expectTraceNode(nullableFail, 'nullableFail', 'TestUser1', 'TestNestedField');

expect(nullableFail.error).toHaveLength(1);
expect(JSON.parse(nullableFail.error![0].json!)).toMatchObject(expectedErrors[0]);
});

it('nullableFail - simple federated query - should return result with expected data and errors', async () => {
const query = /* GraphQL */ `
query {
testNestedField {
nullableFail {
id
email
sub1
}
}
}
`;

const expectedData = {
testNestedField: {
nullableFail: null,
},
};

const expectedErrors: GraphQLFormattedError[] = [
{
message: 'My original subgraph error!',
path: ['testNestedField', 'nullableFail'],
},
];

const response = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
body: JSON.stringify({ query }),
headers: {
'content-type': 'application/json',
'apollo-federation-include-trace': 'ftv1',
},
});

const result: FormattedExecutionResult = await response.json();

expect(response.status).toBe(200);
expect(result.errors).toMatchObject(expectedErrors);
expect(result.data).toMatchObject(expectedData);
expect(result.extensions?.ftv1).toEqual(expect.any(String));

const ftv1 = result.extensions?.ftv1 as string;
const trace = Trace.decode(Buffer.from(ftv1, 'base64'));

expectTrace(trace);

const nullableFail = trace.root?.child?.[0].child?.[0] as Trace.INode;

expectTraceNode(nullableFail, 'nullableFail', 'TestUser1', 'TestNestedField');

expect(nullableFail.error).toHaveLength(1);
expect(JSON.parse(nullableFail.error![0].json!)).toMatchObject(expectedErrors[0]);
});

it('nonNullableFail - multi federated query - should return result with expected data and errors', async () => {
const query = /* GraphQL */ `
query {
testNestedField {
nonNullableFail {
id
email
sub1
}
subgraph2 {
id
email
sub2
}
}
}
`;

/**
* The whole query result is { testNestedField: null } even if subgraph2 query not fail, but it should be ok according GraphQL documentation.
* "If the field which experienced an error was declared as Non-Null, the null result will bubble up to the next nullable field."
* https://spec.graphql.org/draft/#sel-GAPHRPTCAACEzBg6S
*/
const expectedData = {
testNestedField: null,
};

const expectedErrors: GraphQLFormattedError[] = [
{
message: 'Cannot return null for non-nullable field TestNestedField.nonNullableFail.',
path: ['testNestedField', 'nonNullableFail'],
},
];

const response = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
body: JSON.stringify({ query }),
headers: {
'content-type': 'application/json',
'apollo-federation-include-trace': 'ftv1',
},
});

const result: FormattedExecutionResult = await response.json();

expect(response.status).toBe(200);
expect(result.errors).toMatchObject(expectedErrors);
expect(result.data).toMatchObject(expectedData);
expect(result.extensions?.ftv1).toEqual(expect.any(String));

const ftv1 = result.extensions?.ftv1 as string;
const trace = Trace.decode(Buffer.from(ftv1, 'base64'));

expectTrace(trace);

const nonNullableFail = trace.root?.child?.[0].child?.[0] as Trace.INode;

expectTraceNode(nonNullableFail, 'nonNullableFail', 'TestUser1!', 'TestNestedField');

expect(nonNullableFail.error).toHaveLength(1);
expect(JSON.parse(nonNullableFail.error![0].json!)).toMatchObject(expectedErrors[0]);
});

it('nonNullableFail - simple federated query - should return result with expected data and errors', async () => {
const query = /* GraphQL */ `
query {
testNestedField {
nonNullableFail {
id
email
sub1
}
}
}
`;

const expectedData = {
testNestedField: null,
};

const expectedErrors: GraphQLFormattedError[] = [
{
message: 'My original subgraph error!',
path: ['testNestedField', 'nonNullableFail'],
},
];

const response = await yoga.fetch('http://yoga/graphql', {
method: 'POST',
body: JSON.stringify({ query }),
headers: {
'content-type': 'application/json',
'apollo-federation-include-trace': 'ftv1',
},
});

const result: FormattedExecutionResult = await response.json();

expect(response.status).toBe(200);
expect(result.errors).toMatchObject(expectedErrors);
expect(result.data).toMatchObject(expectedData);
expect(result.extensions?.ftv1).toEqual(expect.any(String));

const ftv1 = result.extensions?.ftv1 as string;
const trace = Trace.decode(Buffer.from(ftv1, 'base64'));

expectTrace(trace);

/**
* NOTE: nonNullableFail field is missing here in case of "simple federated query" where only one subgraph is called
* therefore the error is assigned to the nearest possible trace node which is testNestedField in this case.
*/
const testNestedField = trace.root?.child?.[0] as Trace.INode;

expectTraceNode(testNestedField, 'testNestedField', 'TestNestedField', 'Query');

expect(testNestedField.error).toHaveLength(1);
expect(JSON.parse(testNestedField.error![0].json!)).toMatchObject(expectedErrors[0]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* eslint-disable import/no-extraneous-dependencies */
import { GraphQLSchema } from 'graphql';
import { createDefaultExecutor } from '@graphql-tools/delegate';
import { getStitchedSchemaFromSupergraphSdl } from '@graphql-tools/federation';

export async function getStitchedSchemaFromLocalSchemas(
localSchemas: Record<string, GraphQLSchema>,
): Promise<GraphQLSchema> {
// dynamic import is used only due to incompatibility with graphql@15
const { IntrospectAndCompose, LocalGraphQLDataSource } = await import('@apollo/gateway');
const introspectAndCompose = await new IntrospectAndCompose({
subgraphs: Object.keys(localSchemas).map(name => ({ name, url: `http://localhost/${name}` })),
}).initialize({
healthCheck: async () => Promise.resolve(),
update: () => undefined,
getDataSource: ({ name }) => {
const [_name, schema] = Object.entries(localSchemas).find(([key]) => key === name) ?? [];
if (schema) {
return new LocalGraphQLDataSource(schema);
}
throw new Error(`Unknown subgraph ${name}`);
},
});

return getStitchedSchemaFromSupergraphSdl({
supergraphSdl: introspectAndCompose.supergraphSdl,
onSubschemaConfig: cofig => {
const [_name, schema] =
Object.entries(localSchemas).find(([key]) => key === cofig.name.toLowerCase()) ?? [];
if (schema) {
cofig.executor = createDefaultExecutor(schema);
} else {
throw new Error(`Unknown subgraph ${cofig.name}`);
}
},
});
}
Loading
Loading