Skip to content

feat(apollo-usage-report): report referenced field by types #4020

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

Merged
merged 21 commits into from
Jun 17, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@graphql-yoga/plugin-apollo-usage-report": patch
---
dependencies updates:
- Added dependency [`@apollo/server-gateway-interface@^1.1.1` ↗︎](https://www.npmjs.com/package/@apollo/server-gateway-interface/v/1.1.1) (to `dependencies`)
- Added dependency [`@apollo/utils.usagereporting@^2.1.0` ↗︎](https://www.npmjs.com/package/@apollo/utils.usagereporting/v/2.1.0) (to `dependencies`)
- Added dependency [`@graphql-tools/utils@10.9.0-alpha-20250601230319-2f5f24f393b56915c1c913d3f057531f79991587` ↗︎](https://www.npmjs.com/package/@graphql-tools/utils/v/10.9.0) (to `dependencies`)
5 changes: 5 additions & 0 deletions .changeset/plain-eggs-win.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-yoga/plugin-apollo-usage-report': minor
---

:construction: Improve report with per field request count, trace batching and report compression
5 changes: 5 additions & 0 deletions .changeset/slimy-toys-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-yoga/plugin-apollo-usage-report': minor
---

Report referenced field by types
2 changes: 2 additions & 0 deletions .mise.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tools]
node = "23"
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
import { createSchema, createYoga, DisposableSymbols } from 'graphql-yoga';
import { Report } from '@apollo/usage-reporting-protobuf';
import {
ApolloUsageReportOptions,
useApolloUsageReport,
} from '@graphql-yoga/plugin-apollo-usage-report';
import { createDeferredPromise } from '@whatwg-node/promise-helpers';
import { Reporter } from '../src/reporter';

describe('apollo-usage-report', () => {
it('should send compressed traces', async () => {
const testEnv = createTestEnv({ options: { alwaysSend: true } });

await testEnv.query();

const report = await testEnv.reportSent;
const { ['# -\n{hello}']: traces } = report.tracesPerQuery;
expect(traces).toBeDefined();
expect(traces?.referencedFieldsByType?.['Query']?.fieldNames).toEqual(['hello']);
expect(traces?.trace).toHaveLength(1);

await testEnv[DisposableSymbols.asyncDispose]();
});

it('should handle operation name when defined', async () => {
const testEnv = createTestEnv({ options: { alwaysSend: true } });
await testEnv.query('query test { hello }');

const report = await testEnv.reportSent;
expect(report.tracesPerQuery['# test\nquery test{hello}']).toBeDefined();

await testEnv[DisposableSymbols.asyncDispose]();
});

it('should trace unparsable requests', async () => {
const testEnv = createTestEnv({
options: { alwaysSend: true, sendUnexecutableOperationDocuments: true },
});
await testEnv.query('this is an invalid request', 'test');

const report = await testEnv.reportSent;
expect(Object.keys(report.tracesPerQuery).length).toBe(1);
const [key, traces] = Object.entries(report.tracesPerQuery)[0]!;
expect(key).toBe('## GraphQLParseFailure\n');
expect(traces).toBeDefined();
expect(traces?.trace).toHaveLength(1);
expect(traces?.trace?.[0]).toMatchObject({
unexecutedOperationName: 'test',
unexecutedOperationBody: 'this is an invalid request',
});

await testEnv[DisposableSymbols.asyncDispose]();
});

it('should trace invalid requests', async () => {
const testEnv = createTestEnv({
options: { alwaysSend: true, sendUnexecutableOperationDocuments: true },
});
await testEnv.query('query test {unknown_field}', 'test');

const report = await testEnv.reportSent;
expect(Object.keys(report.tracesPerQuery).length).toBe(1);
const [key, traces] = Object.entries(report.tracesPerQuery)[0]!;
expect(key).toBe('## GraphQLValidationFailure\n');
expect(traces).toBeDefined();
expect(traces?.trace).toHaveLength(1);
expect(traces?.trace?.[0]).toMatchObject({
unexecutedOperationName: 'test',
unexecutedOperationBody: 'query test {unknown_field}',
});

await testEnv[DisposableSymbols.asyncDispose]();
});

it('should trace unknown operation requests', async () => {
const testEnv = createTestEnv({
options: { alwaysSend: true, sendUnexecutableOperationDocuments: true },
});
await testEnv.query('query test { hello }', 'unknown');

const report = await testEnv.reportSent;
expect(Object.keys(report.tracesPerQuery).length).toBe(1);
const [key, traces] = Object.entries(report.tracesPerQuery)[0]!;
expect(key).toBe('## GraphQLUnknownOperationName\n');
expect(traces).toBeDefined();
expect(traces?.trace).toHaveLength(1);
expect(traces?.trace?.[0]).toMatchObject({
unexecutedOperationName: 'unknown',
unexecutedOperationBody: 'query test { hello }',
});

await testEnv[DisposableSymbols.asyncDispose]();
});

it('should not trace unparsable requests', async () => {
const testEnv = createTestEnv({
options: { alwaysSend: true },
});
await testEnv.query('this is an invalid request');
await testEnv.query();

const report = await testEnv.reportSent;
const { ['# -\n{hello}']: traces } = report.tracesPerQuery;
expect(traces).toBeDefined();
expect(traces?.trace).toHaveLength(1);

await testEnv[DisposableSymbols.asyncDispose]();
});

it('should not trace invalid requests', async () => {
const testEnv = createTestEnv({
options: { alwaysSend: true },
});
await testEnv.query('{unknown_field}');
await testEnv.query();

const report = await testEnv.reportSent;
const { ['# -\n{hello}']: traces } = report.tracesPerQuery;
expect(traces).toBeDefined();
expect(traces?.trace).toHaveLength(1);

await testEnv[DisposableSymbols.asyncDispose]();
});

it('should not trace unknown operation requests', async () => {
const testEnv = createTestEnv({
options: { alwaysSend: true },
});
await testEnv.query('query test { hello }', 'unknown');
await testEnv.query();

const report = await testEnv.reportSent;
const { ['# -\n{hello}']: traces } = report.tracesPerQuery;
expect(traces).toBeDefined();
expect(traces?.trace).toHaveLength(1);

await testEnv[DisposableSymbols.asyncDispose]();
});

it('should batch traces and send once maxBatchDelay is reached', async () => {
const testEnv = createTestEnv({ options: { maxBatchDelay: 500 } });

const start = performance.now();
await testEnv.query();
await testEnv.query();
const report = await testEnv.reportSent;
const end = performance.now();
expect(report.tracesPerQuery['# -\n{hello}']?.trace).toHaveLength(2);

const elapsed = end - start;
expect(elapsed).toBeGreaterThan(500);
expect(elapsed).toBeLessThan(550);

await testEnv[DisposableSymbols.asyncDispose]();
});

it('should send traces when size threshold is reached', async () => {
const testEnv = createTestEnv({
options: { maxBatchUncompressedSize: 150, maxBatchDelay: 500 },
});

const start = performance.now();
await testEnv.query();
await testEnv.query();
const report = await testEnv.reportSent;
const end = performance.now();

expect(report.tracesPerQuery['# -\n{hello}']?.trace).toHaveLength(2);
const elapsed = end - start;
expect(elapsed).toBeLessThan(500);

await testEnv[DisposableSymbols.asyncDispose]();
});

it('should wait for all traces to be sent on shutdown', async () => {
const testEnv = createTestEnv({ options: { maxBatchDelay: 500 } });

const start = performance.now();
await testEnv.query();

await testEnv[DisposableSymbols.asyncDispose]();
const report = await testEnv.reportSent;
const end = performance.now();

expect(report.tracesPerQuery['# -\n{hello}']?.trace).toHaveLength(1);

const elapsed = end - start;
expect(elapsed).toBeLessThan(500);
await testEnv[DisposableSymbols.asyncDispose]();
});

it('should not leak trace sending promises', async () => {
let reporter: Reporter;
const testEnv = createTestEnv({
options: {
alwaysSend: true,
reporter: (...args) => {
reporter = new Reporter(...args);
return reporter;
},
},
});

await testEnv.query();
await testEnv.reportSent;
// @ts-expect-error Accessing a private field
expect(reporter!.sending).toHaveLength(0);

await testEnv[DisposableSymbols.asyncDispose]();
});
});

function createTestEnv(
options: { graphosFetch?: typeof fetch; options?: ApolloUsageReportOptions } = {},
) {
const reportSent = createDeferredPromise<Report>();
const yoga = createYoga({
schema,
plugins: [
useApolloUsageReport({
graphRef: 'graphref',
apiKey: 'apikey',
...options.options,
}),
],
maskedErrors: false,
fetchAPI: {
fetch: async (url, init) => {
if (url.toString().includes('usage-reporting.api.apollographql.com')) {
try {
const bodyStream = init?.body as ReadableStream;
const body = await streamToUint8Array(
bodyStream.pipeThrough(new DecompressionStream('gzip')),
);
reportSent.resolve(Report.decode(body));

return options.graphosFetch
? options.graphosFetch(url, init)
: new Response(null, { status: 200 });
} catch (err) {
reportSent.reject(err);
}
}
return fetch(url, init);
},
},
});

return {
yoga,
reportSent: reportSent.promise,
query: (query = '{ hello }', operationName?: string) => {
return yoga.fetch('http://yoga/graphql', {
headers: {
'Content-Type': 'application/json',
'apollo-federation-include-trace': 'ftv1',
},
method: 'POST',
body: JSON.stringify({ query, operationName }),
});
},
[DisposableSymbols.asyncDispose]: () => yoga.dispose() as Promise<void>,
};
}

const schema = createSchema({
typeDefs: /* GraphQL */ `
type Query {
hello: String!
boom: String!
person: Person!
people: [Person!]!
}
type Subscription {
hello: String!
}
type Person {
name: String!
}
`,
resolvers: {
Query: {
async hello() {
return 'world';
},
async boom() {
throw new Error('bam');
},
async person() {
return { name: 'John' };
},
async people() {
return [{ name: 'John' }, { name: 'Jane' }];
},
},
Subscription: {
hello: {
async *subscribe() {
yield { hello: 'world' };
},
},
},
},
});

async function streamToUint8Array(stream: ReadableStream): Promise<Uint8Array> {
const chunks = [];
let size = 0;
for await (const chunk of stream) {
size += chunk.length;
chunks.push(chunk);
}

const result = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}

return result;
}
3 changes: 3 additions & 0 deletions packages/plugins/apollo-usage-report/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@
"graphql-yoga": "workspace:^"
},
"dependencies": {
"@apollo/server-gateway-interface": "^1.1.1",
"@apollo/usage-reporting-protobuf": "^4.1.1",
"@apollo/utils.usagereporting": "^2.1.0",
"@graphql-tools/utils": "10.9.0-alpha-20250601230319-2f5f24f393b56915c1c913d3f057531f79991587",
Copy link
Contributor

Choose a reason for hiding this comment

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

@EmrysMyrddin FYI: I just noticed that this PR was merged with this alpha version in package.json.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Oh yes you are right, I have to release this before merging. Thank you !

"@graphql-yoga/plugin-apollo-inline-trace": "workspace:^",
"@whatwg-node/promise-helpers": "^1.2.4",
"tslib": "^2.8.1"
Expand Down
Loading
Loading