diff --git a/.changeset/silent-ants-vanish.md b/.changeset/silent-ants-vanish.md new file mode 100644 index 0000000000..803cc36826 --- /dev/null +++ b/.changeset/silent-ants-vanish.md @@ -0,0 +1,5 @@ +--- +'@graphql-yoga/plugin-apollo-inline-trace': major +--- + +Implementation of Apollo's federated inline tracing diff --git a/package.json b/package.json index ac70c52871..74e111d0af 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "weak-napi": "" }, "resolutions": { - "@whatwg-node/fetch": "0.2.9", + "@whatwg-node/fetch": "0.3.2", "@changesets/apply-release-plan": "6.0.0", "graphql": "16.6.0", "@types/react": "17.0.39", diff --git a/packages/graphql-yoga/src/server.ts b/packages/graphql-yoga/src/server.ts index 01fb2fd284..b62dbb4f83 100644 --- a/packages/graphql-yoga/src/server.ts +++ b/packages/graphql-yoga/src/server.ts @@ -509,7 +509,8 @@ export class YogaServer< }) } return response - } catch (e) { + } catch (e: any) { + this.logger.error(e) return new this.fetchAPI.Response('Internal Server Error', { status: 500, }) diff --git a/packages/plugins/apollo-inline-trace/__tests__/apollo-inline-trace.spec.ts b/packages/plugins/apollo-inline-trace/__tests__/apollo-inline-trace.spec.ts new file mode 100644 index 0000000000..5dc834e6c5 --- /dev/null +++ b/packages/plugins/apollo-inline-trace/__tests__/apollo-inline-trace.spec.ts @@ -0,0 +1,482 @@ +import { createYoga, createSchema } from 'graphql-yoga' +import { useApolloInlineTrace } from '../src' +import { Trace } from 'apollo-reporting-protobuf' +import { GraphQLError } from 'graphql' + +describe('Inline Trace', () => { + 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() { + await new Promise((resolve) => setTimeout(resolve, 100)) + return 'world' + }, + async boom() { + await new Promise((resolve) => setTimeout(resolve, 100)) + throw new Error('bam') + }, + async person() { + await new Promise((resolve) => setTimeout(resolve, 100)) + return { name: 'John' } + }, + async people() { + await new Promise((resolve) => setTimeout(resolve, 100)) + return [{ name: 'John' }, { name: 'Jane' }] + }, + }, + Subscription: { + hello: { + async *subscribe() { + yield { hello: 'world' } + }, + }, + }, + }, + }) + + const yoga = createYoga({ + schema, + plugins: [useApolloInlineTrace()], + maskedErrors: false, + }) + + const FlatQuery = /* GraphQL */ ` + query FlatQuery { + hello + } + ` + const AliasedFlatQuery = /* GraphQL */ ` + query AliasedFlatQuery { + hi: hello + } + ` + const FlatQueryWithArrayField = /* GraphQL */ ` + query FlatQueryWithArrayField { + people { + name + } + } + ` + + const NestedQuery = /* GraphQL */ ` + query NestedQuery { + person { + name + } + } + ` + const BrokenQuery = `{ he` + + const FailingQuery = /* GraphQL */ ` + query FailingQuery { + boom + } + ` + + const InvalidQuery = /* GraphQL */ ` + query InvalidQuery { + henlo + } + ` + + const FlatSubscription = /* GraphQL */ ` + subscription FlatSubscription { + hello + } + ` + it('should add ftv1 tracing to result extensions', async () => { + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ query: FlatQuery }), + headers: { + 'Content-Type': 'application/json', + 'apollo-federation-include-trace': 'ftv1', + }, + }) + + const result = await response.json() + + expect(response.ok).toBeTruthy() + expect(result?.errors).toBeUndefined() + expect(result?.extensions?.ftv1).toBeDefined() + }) + + function expectTrace(trace: Trace) { + expect(trace.startTime).toBeDefined() + expect(typeof trace.startTime?.seconds).toBe('number') + expect(typeof trace.startTime?.nanos).toBe('number') + + expect(typeof trace.durationNs).toBe('number') + + expect(trace.endTime).toBeDefined() + expect(typeof trace.endTime?.seconds).toBe('number') + expect(typeof trace.endTime?.nanos).toBe('number') + + expect( + addSecondsAndNanos(trace.startTime!.seconds!, trace.startTime!.nanos!), + ).toBeLessThan( + addSecondsAndNanos(trace.endTime!.seconds!, trace.endTime!.nanos!), + ) + + expect(typeof trace.fieldExecutionWeight).toBe('number') + + expect(trace.root).toBeDefined() + expect(trace.root?.child).toBeInstanceOf(Array) + } + + function expectTraceNode( + node: Trace.INode | null | undefined, + field: string, + type: string, + parent: string, + ) { + expect(node).toBeDefined() + + expect(node!.responseName).toBe(field) + expect(node!.type).toBe(type) + expect(node!.parentType).toBe(parent) + + expect(typeof node!.startTime).toBe('number') + expect(typeof node!.endTime).toBe('number') + + expect(node!.startTime!).toBeLessThan(node!.endTime!) + } + + it('should have proto tracing on flat query', async () => { + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ query: FlatQuery }), + headers: { + 'Content-Type': 'application/json', + 'apollo-federation-include-trace': 'ftv1', + }, + }) + + const result = await response.json() + + expect(response.ok).toBeTruthy() + expect(result?.errors).toBeUndefined() + + // + + const ftv1 = result?.extensions?.ftv1 + const trace = Trace.decode(Buffer.from(ftv1, 'base64')) + + expectTrace(trace) + expect(trace.root?.error?.length).toBe(0) + + const hello = trace.root?.child?.[0] + expect(hello?.error?.length).toBe(0) + expectTraceNode(hello, 'hello', 'String!', 'Query') + }) + + it('should have proto tracing on aliased flat query', async () => { + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: AliasedFlatQuery, + }), + headers: { + 'Content-Type': 'application/json', + 'apollo-federation-include-trace': 'ftv1', + }, + }) + + const result = await response.json() + + expect(response.ok).toBeTruthy() + expect(result?.errors).toBeUndefined() + + // + + const ftv1 = result?.extensions?.ftv1 + const trace = Trace.decode(Buffer.from(ftv1, 'base64')) + + expectTrace(trace) + expect(trace.root?.error?.length).toBe(0) + + const hi = trace.root?.child?.[0] + expect(hi?.error?.length).toBe(0) + expectTraceNode(hi, 'hi', 'String!', 'Query') + expect(hi?.originalFieldName).toBe('hello') + }) + + it('should have proto tracing on flat query with array field', async () => { + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: FlatQueryWithArrayField, + }), + headers: { + 'Content-Type': 'application/json', + 'apollo-federation-include-trace': 'ftv1', + }, + }) + + const result = await response.json() + + expect(response.ok).toBeTruthy() + expect(result?.errors).toBeUndefined() + + // + + const ftv1 = result?.extensions?.ftv1 + const trace = Trace.decode(Buffer.from(ftv1, 'base64')) + + expectTrace(trace) + expect(trace.root?.error?.length).toBe(0) + + const people = trace.root?.child?.[0] + expect(people?.error?.length).toBe(0) + expectTraceNode(people, 'people', '[Person!]!', 'Query') + + const arr = people!.child! + for (let i = 0; i < arr.length; i++) { + const person = arr[i] + expect(person?.error?.length).toBe(0) + expect(person.index).toBe(i) + expectTraceNode(person.child?.[0], 'name', 'String!', 'Person') + } + }) + + it('should have proto tracing on nested query', async () => { + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: NestedQuery, + }), + headers: { + 'Content-Type': 'application/json', + 'apollo-federation-include-trace': 'ftv1', + }, + }) + + expect(response.ok).toBeTruthy() + + const result = await response.json() + expect(result?.errors).toBeUndefined() + + // + + const ftv1 = result?.extensions?.ftv1 + const trace = Trace.decode(Buffer.from(ftv1, 'base64')) + + expectTrace(trace) + expect(trace.root?.error?.length).toBe(0) + + const person = trace.root?.child?.[0] + expect(person?.error?.length).toBe(0) + expectTraceNode(person, 'person', 'Person!', 'Query') + + const personName = person?.child?.[0] + expect(personName?.error?.length).toBe(0) + expectTraceNode(personName, 'name', 'String!', 'Person') + }) + + function expectTraceNodeError(node: Trace.INode | null | undefined) { + expect(node).toBeDefined() + expect(node!.error).toBeDefined() + const error = node!.error! + expect(error).toBeInstanceOf(Array) + expect(error.length).toBeGreaterThan(0) + expect(typeof error[0].message).toBe('string') + expect(typeof error[0].location).toBeDefined() + expect(typeof error[0].location?.[0].line).toBe('number') + expect(typeof error[0].location?.[0].column).toBe('number') + expect(() => { + JSON.parse(error[0].json!) + }).not.toThrow() + } + + it('should have proto tracing on parse fail', async () => { + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: BrokenQuery, + }), + headers: { + 'Content-Type': 'application/json', + 'apollo-federation-include-trace': 'ftv1', + }, + }) + + const result = await response.json() + + expect(result?.errors).toBeDefined() + + // + + const ftv1 = result?.extensions?.ftv1 + const trace = Trace.decode(Buffer.from(ftv1, 'base64')) + + expectTrace(trace) + expectTraceNodeError(trace.root) + }) + + it('should have proto tracing on validation fail', async () => { + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: InvalidQuery, + }), + headers: { + 'Content-Type': 'application/json', + 'apollo-federation-include-trace': 'ftv1', + }, + }) + + const result = await response.json() + + expect(result?.errors).toBeDefined() + + // + + const ftv1 = result?.extensions?.ftv1 + const trace = Trace.decode(Buffer.from(ftv1, 'base64')) + + expectTrace(trace) + expectTraceNodeError(trace.root) + }) + + it('should have proto tracing on execution fail', async () => { + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: FailingQuery, + }), + headers: { + 'Content-Type': 'application/json', + 'apollo-federation-include-trace': 'ftv1', + }, + }) + + const result = await response.json() + + expect(result?.errors).toBeDefined() + + // + + const ftv1 = result?.extensions?.ftv1 + const trace = Trace.decode(Buffer.from(ftv1, 'base64')) + + expectTrace(trace) + expect(trace.root?.error?.length).toBe(0) + + const boom = trace.root?.child?.[0] + expectTraceNode(boom, 'boom', 'String!', 'Query') + expectTraceNodeError(boom) + }) + + it('should skip tracing errors through rewriteError', async () => { + const yoga = createYoga({ + schema, + plugins: [ + useApolloInlineTrace({ + rewriteError: () => null, + }), + ], + }) + + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: BrokenQuery, + }), + headers: { + 'Content-Type': 'application/json', + 'apollo-federation-include-trace': 'ftv1', + }, + }) + + const result = await response.json() + + expect(result?.errors).toBeDefined() + + // + + const ftv1 = result?.extensions?.ftv1 + const trace = Trace.decode(Buffer.from(ftv1, 'base64')) + + expectTrace(trace) + expect(trace.root?.error?.length).toBe(0) + }) + + it('should rewrite only error messages and extensions through rewriteError', async () => { + const yoga = createYoga({ + schema, + plugins: [ + useApolloInlineTrace({ + rewriteError: () => + new GraphQLError('bim', { extensions: { str: 'ing' } }), + }), + ], + }) + + const response = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: FailingQuery, + }), + headers: { + 'Content-Type': 'application/json', + 'apollo-federation-include-trace': 'ftv1', + }, + }) + + const result = await response.json() + + expect(result?.errors).toBeDefined() + + // + + const ftv1 = result?.extensions?.ftv1 + const trace = Trace.decode(Buffer.from(ftv1, 'base64')) + + expectTrace(trace) + expect(trace.root?.error?.length).toBe(0) + + const boom = trace.root?.child?.[0] + expectTraceNode(boom, 'boom', 'String!', 'Query') + expectTraceNodeError(boom) // will check for location + + const error = boom!.error! + expect(error[0].message).toBe('bim') // not 'bam' + + const errObj = JSON.parse(error[0].json!) + expect(errObj.extensions).toEqual({ str: 'ing' }) + }) + + it('should not trace subscriptions', async () => { + const response = await yoga.fetch( + 'http://yoga/graphql?query=' + encodeURIComponent('subscription{hello}'), + { + headers: { + Accept: 'text/event-stream', + }, + }, + ) + + expect(response.ok).toBe(true) + + const result = await response.text() + expect(result).toBe('data: {"data":{"hello":"world"}}\n\n') + }) +}) + +function addSecondsAndNanos(seconds: number, nanos: number): number { + return seconds + nanos / 1e9 +} diff --git a/packages/plugins/apollo-inline-trace/package.json b/packages/plugins/apollo-inline-trace/package.json new file mode 100644 index 0000000000..f87f43bf73 --- /dev/null +++ b/packages/plugins/apollo-inline-trace/package.json @@ -0,0 +1,50 @@ +{ + "name": "@graphql-yoga/plugin-apollo-inline-trace", + "version": "0.0.0", + "description": "Apollo's federated tracing plugin for GraphQL Yoga.", + "repository": { + "type": "git", + "url": "https://github.com/dotansimha/graphql-yoga.git", + "directory": "packages/plugins/apollo-inline-trace" + }, + "type": "module", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "scripts": { + "check": "tsc --pretty --noEmit" + }, + "author": "Denis Badurina ", + "license": "MIT", + "exports": { + ".": { + "require": { + "types": "./dist/typings/index.d.cts", + "default": "./dist/cjs/index.js" + }, + "import": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + }, + "default": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./package.json": "./package.json" + }, + "typings": "dist/typings/index.d.ts", + "typescript": { + "definition": "dist/typings/index.d.ts" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + }, + "peerDependencies": { + "graphql-yoga": "^3.0.0", + "@whatwg-node/fetch": "^0.3.2" + }, + "dependencies": { + "apollo-reporting-protobuf": "^3.3.2" + } +} diff --git a/packages/plugins/apollo-inline-trace/src/index.ts b/packages/plugins/apollo-inline-trace/src/index.ts new file mode 100644 index 0000000000..4fece481f9 --- /dev/null +++ b/packages/plugins/apollo-inline-trace/src/index.ts @@ -0,0 +1,313 @@ +import { isAsyncIterable, Plugin, YogaInitialContext } from 'graphql-yoga' +import { GraphQLError, ResponsePath } from 'graphql' +import ApolloReportingProtobuf from 'apollo-reporting-protobuf' +import { btoa } from '@whatwg-node/fetch' + +interface ApolloInlineTraceContext { + startHrTime: [number, number] + rootNode: ApolloReportingProtobuf.Trace.Node + trace: ApolloReportingProtobuf.Trace + nodes: Map + /** + * graphql-js can continue to execute more fields indefinitely after + * `execute()` resolves. That's because parallelism on a selection set + * is implemented using `Promise.all`, and as soon as one field + * throws an error, the combined Promise resolves, but there's no + * "cancellation" of the rest of Promises/fields in `Promise.all`. + */ + stopped: boolean +} + +export interface ApolloInlineTracePluginOptions { + /** + * Format errors before being sent for tracing. Beware that only the error + * `message` and `extensions` can be changed. + * + * Return `null` to skip reporting error. + */ + rewriteError?: (err: GraphQLError) => GraphQLError | null +} + +/** + * 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: ApolloInlineTracePluginOptions = {}, +): Plugin { + const ctxForReq = new WeakMap() + + return { + 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, // Why 1? See: https://github.com/apollographql/apollo-server/blob/9389da785567a56e989430962564afc71e93bd7f/packages/apollo-server-core/src/plugin/traceTreeBuilder.ts#L16-L23 + startTime: nowTimestamp(), + }), + nodes: new Map([[responsePathToString(), rootNode]]), + stopped: false, + }) + }, + onResolverCalled({ 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)) + } + }, + 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, + [ + new GraphQLError(result.message, { + originalError: result, + }), + ], + options.rewriteError, + ) + } + } + }, + onValidate() { + return ({ context: { request }, result: errors }) => { + if (errors.length) { + const ctx = ctxForReq.get(request) + if (ctx) 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 + + if (result.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)) + + result.extensions = { + ...result.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: [number, number]) { + 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(): ApolloReportingProtobuf.google.protobuf.Timestamp { + 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?: ResponsePath): string { + 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: ApolloInlineTraceContext, + path: ResponsePath, +): ApolloReportingProtobuf.Trace.Node { + 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 + return newTraceNode(ctx, path.prev!) +} + +function newTraceNode(ctx: ApolloInlineTraceContext, path: ResponsePath) { + 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: ApolloInlineTraceContext, + errors: readonly GraphQLError[], + rewriteError: ApolloInlineTracePluginOptions['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 = new GraphQLError(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), + }), + ) + } +} diff --git a/website/v3/docs/features/apollo-federation.mdx b/website/v3/docs/features/apollo-federation.mdx index 6f04405a4e..93e22a4a70 100644 --- a/website/v3/docs/features/apollo-federation.mdx +++ b/website/v3/docs/features/apollo-federation.mdx @@ -107,6 +107,41 @@ server.listen(4001, () => { }) ``` +## Federated tracing + +Inject additional metrics for [Apollo's federated tracing](https://www.apollographql.com/docs/federation/metrics/). + +You'll need the `@graphql-yoga/plugin-apollo-inline-trace` Yoga plugin for this. + +### Installation + + + +### Example Federated tracing + +```ts +import { createYoga } from 'graphql-yoga' +import { createServer } from 'http' +import { useApolloInlineTrace } from '@graphql-yoga/plugin-apollo-inline-trace' + +const yoga = createYoga({ + plugins: [ + useApolloInlineTrace(), + // ...rest of your Apollo federation plugins + ], +}) + +// Start the server and explore http://localhost:4000/graphql +const server = createServer(yoga) +server.listen(4000) +``` + ## Working Example Check our [working example](https://github.com/dotansimha/graphql-yoga/tree/master/examples/apollo-federation) to try it out. diff --git a/yarn.lock b/yarn.lock index 7113b773ba..ea50dabec7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6520,10 +6520,10 @@ "@webassemblyjs/ast" "1.11.1" "@xtuc/long" "4.2.2" -"@whatwg-node/fetch@0.2.9", "@whatwg-node/fetch@^0.2.3", "@whatwg-node/fetch@^0.2.9": - version "0.2.9" - resolved "https://registry.yarnpkg.com/@whatwg-node/fetch/-/fetch-0.2.9.tgz#0f0e72f79957a0544d2a9455082802d87be93ffe" - integrity sha512-h+ehuqE/ZqJdRy+xywHyKpBIPmST0ms8Itgf4gGSu10pJrmod3/t9DbG/GlATvLBE4pvqYHrxKAKo3NNQVJc3g== +"@whatwg-node/fetch@0.3.2", "@whatwg-node/fetch@^0.2.3", "@whatwg-node/fetch@^0.2.9": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@whatwg-node/fetch/-/fetch-0.3.2.tgz#da4323795c26c135563ba01d49dc16037bec4287" + integrity sha512-Bs5zAWQs0tXsLa4mRmLw7Psps1EN78vPtgcLpw3qPY8s6UYPUM67zFZ9cy+7tZ64PXhfwzxJn+m7RH2Lq48RNQ== dependencies: "@peculiar/webcrypto" "^1.4.0" abort-controller "^3.0.0" @@ -14526,10 +14526,10 @@ lowercase-keys@^2.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== -lru-cache@*, lru-cache@^7.10.1, lru-cache@^7.13.1, lru-cache@^7.7.1: - version "7.13.2" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.13.2.tgz#bb5d3f1deea3f3a7a35c1c44345566a612e09cd0" - integrity sha512-VJL3nIpA79TodY/ctmZEfhASgqekbT574/c4j3jn4bKXbSCnTTCH/KltZyvL2GlV+tGSMtsWyem8DCX7qKTMBA== +lru-cache@*: + version "7.14.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.0.tgz#21be64954a4680e303a09e9468f880b98a0b3c7f" + integrity sha512-EIRtP1GrSJny0dqb50QXRUNBxHJhcpxHC++M5tD7RYbvLLn5KVWKsbyswSSqDuU15UFi3bgTQIY8nhDMeF6aDQ== lru-cache@^4.0.1: version "4.1.5" @@ -14546,6 +14546,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@^7.10.1, lru-cache@^7.13.1, lru-cache@^7.7.1: + version "7.13.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.13.2.tgz#bb5d3f1deea3f3a7a35c1c44345566a612e09cd0" + integrity sha512-VJL3nIpA79TodY/ctmZEfhASgqekbT574/c4j3jn4bKXbSCnTTCH/KltZyvL2GlV+tGSMtsWyem8DCX7qKTMBA== + luxon@^1.28.0: version "1.28.0" resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.28.0.tgz#e7f96daad3938c06a62de0fb027115d251251fbf"