Skip to content

fix(apollo-usage-report): fix issue 3952 #3956

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ packages/graphql-yoga/src/graphiql-html.ts
run/
website/public/_pagefind/
.helix/languages.toml

.qodo
1 change: 1 addition & 0 deletions packages/plugins/apollo-usage-report/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
},
"devDependencies": {
"@envelop/on-resolve": "^5.1.3",
"@graphql-tools/schema": "^10.0.23",
"@whatwg-node/fetch": "^0.10.1",
"graphql": "16.10.0",
"graphql-yoga": "workspace:*"
Expand Down
238 changes: 238 additions & 0 deletions packages/plugins/apollo-usage-report/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { createYoga, Plugin } from 'graphql-yoga';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { useApolloUsageReport } from '../src';

const mockFetch = jest.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve('{"success":true}'),
});

const mockCrypto = {
subtle: {
digest: jest.fn().mockResolvedValue(new Uint8Array([1, 2, 3, 4])),
},
};

class MockTextEncoder {
encode() {
return new Uint8Array([1, 2, 3, 4]);
}
}

const createTestSchema = (includeWorldField = false) => {
return makeExecutableSchema({
typeDefs: `
type Query {
hello: String
${includeWorldField ? 'world: String' : ''}
}
`,
});
};

const setupPluginAndGetInternalPlugins = (options = {}) => {
const apolloPlugin = useApolloUsageReport({
endpoint: 'http://test-endpoint.com',
...options,
});

const addedPlugins: Plugin[] = [];
const pluginInit = apolloPlugin.onPluginInit as (args: {
addPlugin: (plugin: Plugin) => void;
}) => void;
pluginInit({ addPlugin: plugin => addedPlugins.push(plugin) });

return {
apolloPlugin,
addedPlugins,
schemaChangePlugin: addedPlugins.find(plugin => 'onSchemaChange' in plugin),
yogaInitPlugin: addedPlugins.find(plugin => 'onYogaInit' in plugin),
requestParsePlugin: addedPlugins.find(plugin => 'onRequestParse' in plugin),
resultProcessPlugin: addedPlugins.find(plugin => 'onResultProcess' in plugin),
};
};

// @ts-expect-error: ??
const createTestYoga = (apolloPlugin, schema, fetchImpl = mockFetch, logger = undefined) => {
return createYoga({
schema,
plugins: [apolloPlugin],
fetchAPI: {
fetch: fetchImpl,
crypto: mockCrypto as unknown as Crypto,
TextEncoder: MockTextEncoder as unknown as typeof TextEncoder,
},
...(logger ? { logging: logger } : {}),
});
};

describe('useApolloUsageReport plugin', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env['APOLLO_KEY'] = 'test-api-key';
process.env['APOLLO_GRAPH_REF'] = 'test-graph-ref';
});

afterEach(() => {
delete process.env['APOLLO_KEY'];
delete process.env['APOLLO_GRAPH_REF'];
});

test('should correctly handle schema change before Yoga initialization', async () => {
const { apolloPlugin, schemaChangePlugin, yogaInitPlugin, requestParsePlugin } =
setupPluginAndGetInternalPlugins();

if (!schemaChangePlugin || !('onSchemaChange' in schemaChangePlugin)) {
throw new Error('Plugin with onSchemaChange not found');
}

if (!yogaInitPlugin || !('onYogaInit' in yogaInitPlugin)) {
throw new Error('Plugin with onYogaInit not found');
}

if (!requestParsePlugin || !('onRequestParse' in requestParsePlugin)) {
throw new Error('Plugin with onRequestParse not found');
}

const initialSchema = createTestSchema();
const updatedSchema = createTestSchema(true);

// @ts-expect-error: ??
schemaChangePlugin.onSchemaChange!({ schema: updatedSchema });

const yoga = createTestYoga(apolloPlugin, initialSchema);

yogaInitPlugin.onYogaInit!({ yoga });

// @ts-expect-error: ??
await requestParsePlugin.onRequestParse!();

expect(mockCrypto.subtle.digest).toHaveBeenCalled();
});

test('should correctly handle schema change after Yoga initialization', async () => {
const { apolloPlugin, schemaChangePlugin, yogaInitPlugin } = setupPluginAndGetInternalPlugins();

if (!schemaChangePlugin || !('onSchemaChange' in schemaChangePlugin)) {
throw new Error('Plugin with onSchemaChange not found');
}

if (!yogaInitPlugin || !('onYogaInit' in yogaInitPlugin)) {
throw new Error('Plugin with onYogaInit not found');
}

const initialSchema = createTestSchema();
const updatedSchema = createTestSchema(true);

const yoga = createTestYoga(apolloPlugin, initialSchema);

yogaInitPlugin.onYogaInit!({ yoga });

mockCrypto.subtle.digest.mockClear();

// @ts-expect-error: ??
schemaChangePlugin.onSchemaChange!({ schema: updatedSchema });

expect(mockCrypto.subtle.digest).toHaveBeenCalled();
});

test('should correctly handle schema change through event', async () => {
const { apolloPlugin, schemaChangePlugin } = setupPluginAndGetInternalPlugins();

if (!schemaChangePlugin || !('onSchemaChange' in schemaChangePlugin)) {
throw new Error('Plugin with onSchemaChange not found');
}

const initialSchema = createTestSchema();
const updatedSchema = createTestSchema(true);

const yoga = createTestYoga(apolloPlugin, initialSchema);

mockCrypto.subtle.digest.mockClear();

// @ts-expect-error: ??
schemaChangePlugin.onSchemaChange!({ schema: updatedSchema });

await yoga.fetch('http://yoga/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: '{ hello }',
}),
});

expect(mockCrypto.subtle.digest).toHaveBeenCalled();
});

test('should throw error when API key is missing', () => {
delete process.env['APOLLO_KEY'];

const schema = createTestSchema();

const apolloPlugin = useApolloUsageReport({
endpoint: 'http://test-endpoint.com',
});

expect(() => {
createTestYoga(apolloPlugin, schema);
}).toThrow('[ApolloUsageReport] Missing API key');
});

test('should throw error when Graph Ref is missing', () => {
delete process.env['APOLLO_GRAPH_REF'];

const schema = createTestSchema();

const apolloPlugin = useApolloUsageReport({
endpoint: 'http://test-endpoint.com',
apiKey: 'test-api-key',
});

expect(() => {
createTestYoga(apolloPlugin, schema);
}).toThrow('[ApolloUsageReport] Missing Graph Ref');
});

test('should correctly use logger when trace sending fails', async () => {
const schema = createTestSchema();

const mockLogger = {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};

const errorFetch = jest.fn().mockResolvedValue({
ok: false,
text: () => Promise.resolve('{"error":"Trace sending error"}'),
});

const apolloPlugin = useApolloUsageReport({
endpoint: 'http://test-endpoint.com',
});

// @ts-expect-error: ??
const yoga = createTestYoga(apolloPlugin, schema, errorFetch, mockLogger);

await yoga.fetch('http://yoga/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query: '{ hello }',
}),
});

await new Promise(resolve => setTimeout(resolve, 100));

expect(mockLogger.error).toHaveBeenCalledWith(
'[ApolloUsageReport]',
'Failed to send trace:',
'{"error":"Trace sending error"}',
);
});
});
62 changes: 41 additions & 21 deletions packages/plugins/apollo-usage-report/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,9 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl
let schemaIdSet$: MaybePromise<void> | undefined;
let schemaId: string;
let yoga: YogaServer<Record<string, unknown>, Record<string, unknown>>;
const logger = Object.fromEntries(
(['error', 'warn', 'info', 'debug'] as const).map(level => [
level,
(...messages: unknown[]) => yoga.logger[level]('[ApolloUsageReport]', ...messages),
]),
) as YogaLogger;
// eslint-disable-next-line
let pendingSchemaChange: { schema: any } | null = null;
let logger: YogaLogger;

let clientNameFactory: StringFromRequestFn = req => req.headers.get('apollographql-client-name');
if (typeof options.clientName === 'function') {
Expand All @@ -102,13 +99,33 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl
clientVersionFactory = options.clientVersion;
}

// eslint-disable-next-line
const processSchemaChange = (schema: any) => {
if (schema && yoga) {
schemaIdSet$ = handleMaybePromise(
() => hashSHA256(printSchema(schema), yoga.fetchAPI),
id => {
schemaId = id;
schemaIdSet$ = undefined;
},
);
}
};

return {
onPluginInit({ addPlugin }) {
addPlugin(instrumentation);
addPlugin({
onYogaInit(args) {
yoga = args.yoga;

logger = Object.fromEntries(
(['error', 'warn', 'info', 'debug'] as const).map(level => [
level,
(...messages: unknown[]) => yoga.logger[level]('[ApolloUsageReport]', ...messages),
]),
) as YogaLogger;

if (!getEnvVar('APOLLO_KEY', options.apiKey)) {
throw new Error(
`[ApolloUsageReport] Missing API key. Please provide one in plugin options or with 'APOLLO_KEY' environment variable.`,
Expand All @@ -120,16 +137,18 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl
`[ApolloUsageReport] Missing Graph Ref. Please provide one in plugin options or with 'APOLLO_GRAPH_REF' environment variable.`,
);
}

if (pendingSchemaChange) {
processSchemaChange(pendingSchemaChange.schema);
pendingSchemaChange = null;
}
},

onSchemaChange({ schema }) {
if (schema) {
schemaIdSet$ = handleMaybePromise(
() => hashSHA256(printSchema(schema), yoga.fetchAPI),
id => {
schemaId = id;
schemaIdSet$ = undefined;
},
);
if (yoga) {
processSchemaChange(schema);
} else {
pendingSchemaChange = { schema };
}
},

Expand All @@ -141,9 +160,11 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl
return function onParseEnd({ result, context }) {
const ctx = ctxForReq.get(context.request)?.traces.get(context);
if (!ctx) {
logger.debug(
'operation tracing context not found, this operation will not be traced.',
);
if (logger) {
logger.debug(
'operation tracing context not found, this operation will not be traced.',
);
}
return;
}

Expand Down Expand Up @@ -200,6 +221,7 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl
for (const schemaId in tracesPerSchema) {
const tracesPerQuery = tracesPerSchema[schemaId]!;
const agentVersion = options.agentVersion || `graphql-yoga@${yoga.version}`;

serverContext.waitUntil(
sendTrace(
options,
Expand All @@ -225,17 +247,16 @@ export function hashSHA256(
} = globalThis,
) {
const inputUint8Array = new api.TextEncoder().encode(text);

return handleMaybePromise(
() => api.crypto.subtle.digest({ name: 'SHA-256' }, inputUint8Array),
arrayBuf => {
const outputUint8Array = new Uint8Array(arrayBuf);

let hash = '';
for (const byte of outputUint8Array) {
const hex = byte.toString(16);
hash += '00'.slice(0, Math.max(0, 2 - hex.length)) + hex;
}

return hash;
},
);
Expand Down Expand Up @@ -264,14 +285,14 @@ function sendTrace(
operationCount: 1,
tracesPerQuery,
}).finish();

return handleMaybePromise(
() =>
fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/protobuf',
// The presence of the api key is already checked at Yoga initialization time

'x-api-key': apiKey!,
accept: 'application/json',
},
Expand All @@ -297,6 +318,5 @@ function sendTrace(
function isDocumentNode(data: unknown): data is DocumentNode {
const isObject = (data: unknown): data is Record<string, unknown> =>
!!data && typeof data === 'object';

return isObject(data) && data['kind'] === Kind.DOCUMENT;
}
Loading