@graphql-yoga/plugin-apollo-usage-report
Version:
Apollo's GraphOS usage report plugin for GraphQL Yoga.
183 lines (182 loc) • 9.38 kB
JavaScript
import { getOperationAST, Kind } from 'graphql';
import { isAsyncIterable, } from 'graphql-yoga';
import { calculateReferencedFieldsByType, usageReportingSignature, } from '@apollo/utils.usagereporting';
import { printSchemaWithDirectives } from '@graphql-tools/utils';
import { useApolloInstrumentation, } from '@graphql-yoga/plugin-apollo-inline-trace';
import { getEnvVar, Reporter } from './reporter.js';
export function useApolloUsageReport(options = {}) {
const [instrumentation, ctxForReq] = useApolloInstrumentation(options);
const makeReporter = options.reporter ?? ((...args) => new Reporter(...args));
let schemaIdSet$;
let currentSchema;
let yoga;
let reporter;
const setCurrentSchema = async (schema) => {
try {
currentSchema = {
id: await hashSHA256(printSchemaWithDirectives(schema), yoga.fetchAPI),
schema,
};
}
catch (error) {
logger.error('Failed to calculate schema hash: ', error);
}
// We don't want to block server start even if we failed to compute schema id
schemaIdSet$ = undefined;
};
const logger = Object.fromEntries(['error', 'warn', 'info', 'debug'].map(level => [
level,
(...messages) => yoga.logger[level]('[ApolloUsageReport]', ...messages),
]));
let clientNameFactory = req => req.headers.get('apollographql-client-name');
if (typeof options.clientName === 'function') {
clientNameFactory = options.clientName;
}
let clientVersionFactory = req => req.headers.get('apollographql-client-version');
if (typeof options.clientVersion === 'function') {
clientVersionFactory = options.clientVersion;
}
return {
onPluginInit({ addPlugin }) {
addPlugin(instrumentation);
addPlugin({
onYogaInit(args) {
yoga = args.yoga;
reporter = makeReporter(options, yoga, logger);
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.`);
}
if (!getEnvVar('APOLLO_GRAPH_REF', options.graphRef)) {
throw new Error(`[ApolloUsageReport] Missing Graph Ref. Please provide one in plugin options or with 'APOLLO_GRAPH_REF' environment variable.`);
}
if (!schemaIdSet$ && !currentSchema) {
// When the schema is static, the `onSchemaChange` hook is called before initialization
// We have to handle schema loading here in this case.
const { schema } = yoga.getEnveloped();
if (schema) {
schemaIdSet$ = setCurrentSchema(schema);
}
}
},
onSchemaChange({ schema }) {
// When the schema is static, this hook is called before yoga initialization
// Since we need yoga.fetchAPI for id calculation, we need to wait for Yoga init
if (schema && yoga) {
schemaIdSet$ = setCurrentSchema(schema);
}
},
onRequestParse() {
return schemaIdSet$;
},
onParse() {
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.');
return;
}
ctx.schemaId = currentSchema.id;
if (isDocumentNode(result)) {
if (getOperationAST(result, context.params.operationName)) {
return;
}
ctx.operationKey = `## GraphQLUnknownOperationName\n`;
}
else {
ctx.operationKey = '## GraphQLParseFailure\n';
}
if (!options.sendUnexecutableOperationDocuments) {
// To make sure the trace will not be sent, remove request's tracing context
ctxForReq.delete(context.request);
return;
}
ctx.trace.unexecutedOperationName = context.params.operationName || '';
ctx.trace.unexecutedOperationBody = context.params.query || '';
};
},
onValidate({ params: { documentAST: document } }) {
return ({ valid, 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.');
return;
}
if (valid) {
if (!currentSchema) {
throw new Error("should not happen: schema doesn't exists");
}
const opName = getOperationAST(document, context.params.operationName)?.name?.value;
ctx.referencedFieldsByType = calculateReferencedFieldsByType({
document,
schema: currentSchema.schema,
resolvedOperationName: opName ?? null,
});
ctx.operationKey = `# ${opName || '-'}\n${usageReportingSignature(document, opName ?? '')}`;
}
else if (options.sendUnexecutableOperationDocuments) {
ctx.operationKey = '## GraphQLValidationFailure\n';
ctx.trace.unexecutedOperationName = context.params.operationName ?? '';
ctx.trace.unexecutedOperationBody = context.params.query ?? '';
}
else {
// To make sure the trace will not be sent, remove request's tracing context
ctxForReq.delete(context.request);
}
};
},
onResultProcess({ request, result, serverContext }) {
// TODO: Handle async iterables ?
if (isAsyncIterable(result)) {
logger.debug('async iterable results not implemented for now');
return;
}
const reqCtx = ctxForReq.get(request);
if (!reqCtx) {
logger.debug('operation tracing context not found, this operation will not be traced.');
return;
}
for (const trace of reqCtx.traces.values()) {
if (!trace.schemaId || !trace.operationKey) {
logger.debug('Misformed trace, missing operation key or schema id');
continue;
}
const clientName = clientNameFactory(request);
if (clientName) {
trace.trace.clientName = clientName;
}
const clientVersion = clientVersionFactory(request);
if (clientVersion) {
trace.trace.clientVersion = clientVersion;
}
serverContext.waitUntil(reporter.addTrace(currentSchema.id, {
statsReportKey: trace.operationKey,
trace: trace.trace,
referencedFieldsByType: trace.referencedFieldsByType ?? {},
asTrace: true, // TODO: allow to not always send traces
nonFtv1ErrorPaths: [],
maxTraceBytes: options.maxTraceSize,
}));
}
},
async onDispose() {
await reporter?.flush();
},
});
},
};
}
export async function hashSHA256(text, api = globalThis) {
const inputUint8Array = new api.TextEncoder().encode(text);
const arrayBuf = await api.crypto.subtle.digest({ name: 'SHA-256' }, inputUint8Array);
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;
}
function isDocumentNode(data) {
const isObject = (data) => !!data && typeof data === 'object';
return isObject(data) && data['kind'] === Kind.DOCUMENT;
}