UNPKG

@graphql-yoga/plugin-apollo-inline-trace

Version:

Apollo's federated tracing plugin for GraphQL Yoga.

245 lines (244 loc) • 10.1 kB
import { GraphQLError } from 'graphql'; import { useOnResolve } from '@envelop/on-resolve'; import { btoa } from '@whatwg-node/fetch'; import ApolloReportingProtobuf from 'apollo-reporting-protobuf'; import { createGraphQLError, isAsyncIterable, } from 'graphql-yoga'; const asArray = (x) => (Array.isArray(x) ? x : [x]); /** * Produces Apollo's base64 trace protocol containing timing, resolution and * errors information. * * The output is placed in `extensions.ftv1` of the GraphQL result. * * The Apollo Gateway utilizes this data to construct the full trace and submit * it to Apollo's usage reporting ingress. */ export function useApolloInlineTrace(options = {}) { const ctxForReq = new WeakMap(); return { onPluginInit: ({ addPlugin }) => { addPlugin(useOnResolve(({ context: { request }, info }) => { const ctx = ctxForReq.get(request); if (!ctx) return; // result was already shipped (see ApolloInlineTraceContext.stopped) if (ctx.stopped) { return () => { // noop }; } const node = newTraceNode(ctx, info.path); node.type = info.returnType.toString(); node.parentType = info.parentType.toString(); node.startTime = hrTimeToDurationInNanos(process.hrtime(ctx.startHrTime)); if (typeof info.path.key === 'string' && info.path.key !== info.fieldName) { // field was aliased, send the original field name too node.originalFieldName = info.fieldName; } return () => { node.endTime = hrTimeToDurationInNanos(process.hrtime(ctx.startHrTime)); }; })); }, onRequest({ request }) { // must be ftv1 tracing protocol if (request.headers.get('apollo-federation-include-trace') !== 'ftv1') { return; } const startHrTime = process.hrtime(); const rootNode = new ApolloReportingProtobuf.Trace.Node(); ctxForReq.set(request, { startHrTime, rootNode, trace: new ApolloReportingProtobuf.Trace({ root: rootNode, fieldExecutionWeight: 1, startTime: nowTimestamp(), }), nodes: new Map([[responsePathToString(), rootNode]]), stopped: false, }); }, onParse() { return ({ context: { request }, result }) => { const ctx = ctxForReq.get(request); if (!ctx) return; if (result instanceof GraphQLError) { handleErrors(ctx, [result], options.rewriteError); } else if (result instanceof Error) { handleErrors(ctx, [ createGraphQLError(result.message, { originalError: result, }), ], options.rewriteError); } }; }, onValidate() { return ({ context: { request }, result: errors }) => { if (errors.length) { const ctx = ctxForReq.get(request); if (ctx) // Envelop doesn't give GraphQLError type since it is agnostic handleErrors(ctx, errors, options.rewriteError); } }; }, onExecute() { return { onExecuteDone({ args: { contextValue: { request }, }, result, }) { // TODO: should handle streaming results? how? if (!isAsyncIterable(result) && result.errors?.length) { const ctx = ctxForReq.get(request); if (ctx) handleErrors(ctx, result.errors, options.rewriteError); } }, }; }, onResultProcess({ request, result }) { const ctx = ctxForReq.get(request); if (!ctx) return; // TODO: should handle streaming results? how? if (isAsyncIterable(result)) return; for (const singleResult of asArray(result)) { if (singleResult.extensions?.ftv1 !== undefined) { throw new Error('The `ftv1` extension is already present'); } // onResultProcess will be called only once since we disallow async iterables if (ctx.stopped) throw new Error('Trace stopped multiple times'); ctx.stopped = true; ctx.trace.durationNs = hrTimeToDurationInNanos(process.hrtime(ctx.startHrTime)); ctx.trace.endTime = nowTimestamp(); const encodedUint8Array = ApolloReportingProtobuf.Trace.encode(ctx.trace).finish(); const base64 = btoa(String.fromCharCode(...encodedUint8Array)); singleResult.extensions = { ...singleResult.extensions, ftv1: base64, }; } }, }; } /** * Converts an hrtime array (as returned from process.hrtime) to nanoseconds. * * The entire point of the hrtime data structure is that the JavaScript Number * type can't represent all int64 values without loss of precision. * * Reference: https://github.com/apollographql/apollo-server/blob/9389da785567a56e989430962564afc71e93bd7f/packages/apollo-server-core/src/plugin/traceTreeBuilder.ts#L269-L285 */ function hrTimeToDurationInNanos(hrtime) { return hrtime[0] * 1e9 + hrtime[1]; } /** * Current time from Date.now() as a google.protobuf.Timestamp. * * Reference: https://github.com/apollographql/apollo-server/blob/9389da785567a56e989430962564afc71e93bd7f/packages/apollo-server-core/src/plugin/traceTreeBuilder.ts#L315-L323 */ function nowTimestamp() { const totalMillis = Date.now(); const millis = totalMillis % 1000; return new ApolloReportingProtobuf.google.protobuf.Timestamp({ seconds: (totalMillis - millis) / 1000, nanos: millis * 1e6, }); } /** * Convert from the linked-list ResponsePath format to a dot-joined * string. Includes the full path (field names and array indices). * * Reference: https://github.com/apollographql/apollo-server/blob/9389da785567a56e989430962564afc71e93bd7f/packages/apollo-server-core/src/plugin/traceTreeBuilder.ts#L287-L303 */ function responsePathToString(path) { if (path === undefined) { return ''; } // `responsePathAsArray` from `graphql-js/execution` created new arrays unnecessarily let res = String(path.key); while ((path = path.prev) !== undefined) { res = `${path.key}.${res}`; } return res; } function ensureParentTraceNode(ctx, path) { const parentNode = ctx.nodes.get(responsePathToString(path.prev)); if (parentNode) return parentNode; // path.prev isn't undefined because we set up the root path in ctx.nodes // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return newTraceNode(ctx, path.prev); } function newTraceNode(ctx, path) { const node = new ApolloReportingProtobuf.Trace.Node(); const id = path.key; if (typeof id === 'number') { node.index = id; } else { node.responseName = id; } ctx.nodes.set(responsePathToString(path), node); const parentNode = ensureParentTraceNode(ctx, path); parentNode.child.push(node); return node; } function handleErrors(ctx, errors, rewriteError) { if (ctx.stopped) { throw new Error('Handling errors after tracing was stopped'); } for (const err of errors) { /** * This is an error from a federated service. We will already be reporting * it in the nested Trace in the query plan. * * Reference: https://github.com/apollographql/apollo-server/blob/9389da785567a56e989430962564afc71e93bd7f/packages/apollo-server-core/src/plugin/traceTreeBuilder.ts#L133-L141 */ if (err.extensions?.serviceName) { continue; } let errToReport = err; // errors can be rewritten through `rewriteError` if (rewriteError) { // clone error to avoid users mutating the original one const clonedErr = Object.assign(Object.create(Object.getPrototypeOf(err)), err); const rewrittenError = rewriteError(clonedErr); if (!rewrittenError) { // return nullish to skip reporting continue; } errToReport = rewrittenError; } // only message and extensions can be rewritten errToReport = createGraphQLError(errToReport.message, { extensions: errToReport.extensions || err.extensions, nodes: err.nodes, source: err.source, positions: err.positions, path: err.path, originalError: err.originalError, }); // put errors on the root node by default let node = ctx.rootNode; if (Array.isArray(errToReport.path)) { const specificNode = ctx.nodes.get(errToReport.path.join('.')); if (specificNode) { node = specificNode; } else { throw new Error(`Could not find node with path ${errToReport.path.join('.')}`); } } node.error.push(new ApolloReportingProtobuf.Trace.Error({ message: errToReport.message, location: (errToReport.locations || []).map(({ line, column }) => new ApolloReportingProtobuf.Trace.Location({ line, column })), json: JSON.stringify(errToReport), })); } }