-
Notifications
You must be signed in to change notification settings - Fork 575
/
index.ts
378 lines (339 loc) · 11.9 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
import { GraphQLError, ResponsePath } from 'graphql';
import {
createGraphQLError,
isAsyncIterable,
Plugin,
YogaInitialContext,
YogaLogger,
} from 'graphql-yoga';
import { google, Trace } from '@apollo/usage-reporting-protobuf';
import { useOnResolve } from '@envelop/on-resolve';
import { btoa } from '@whatwg-node/fetch';
export interface ApolloInlineRequestTraceContext {
startHrTime: [number, number];
traceStartTimestamp: google.protobuf.Timestamp;
traces: Map<YogaInitialContext, ApolloInlineGraphqlTraceContext>;
/**
* 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 ApolloInlineGraphqlTraceContext {
rootNode: Trace.Node;
trace: Trace;
nodes: Map<string, Trace.Node>;
}
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;
/**
* Allows to entirely disable tracing based on the HTTP request
* @param request HTTP request from the execution context
* @returns If true is returned (either as is or wrapped in Promise), traces for this request will
* not be generated.
*/
ignoreRequest?: (request: Request) => Promise<boolean> | boolean;
}
/**
* 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<YogaInitialContext> {
const [instrumentation, ctxForReq] = useApolloInstrumentation({
ignoreRequest: request => request.headers.get('apollo-federation-include-trace') !== 'ftv1',
...options,
});
return {
onPluginInit({ addPlugin }) {
addPlugin(instrumentation);
addPlugin({
onExecutionResult({ request, result, context, setResult }) {
// TODO: should handle streaming results? how?
if (!result || isAsyncIterable(result)) {
return;
}
if (result.extensions?.ftv1 !== undefined) {
throw new Error('The `ftv1` extension is already present');
}
const reqCtx = ctxForReq.get(request);
if (!reqCtx) {
return;
}
const ctx = reqCtx.traces.get(context);
if (!ctx) {
return;
}
const encodedUint8Array = Trace.encode(ctx.trace).finish();
const base64 = btoa(String.fromCharCode(...encodedUint8Array));
setResult({
...result,
extensions: {
...result.extensions,
ftv1: base64,
},
});
},
});
},
};
}
/**
* Instrument GraphQL request processing pipeline and creates Apollo compatible tracing data.
*
* This is meant as a helper, do not use it directly. Use `useApolloInlineTrace` or `useApolloUsageReport` instead.
* @param options
* @returns A tuple with the instrumentation plugin and a WeakMap containing the tracing data
*/
export function useApolloInstrumentation(options: ApolloInlineTracePluginOptions) {
const ctxForReq = new WeakMap<Request, ApolloInlineRequestTraceContext>();
let logger: YogaLogger;
function createContext() {
return {
startHrTime: process.hrtime(),
traceStartTimestamp: nowTimestamp(),
traces: new Map(),
stopped: false,
};
}
function setNewContext(request: Request) {
try {
ctxForReq.set(request, createContext());
} catch (err) {
logger.error('Apollo inline error:', err);
}
}
const plugin: Plugin = {
onYogaInit({ yoga }) {
logger = yoga.logger;
},
onPluginInit: ({ addPlugin }) => {
addPlugin(
useOnResolve(({ context, info }) => {
const reqCtx = ctxForReq.get(context.request);
if (!reqCtx) return;
// result was already shipped (see ApolloInlineTraceContext.stopped)
if (reqCtx.stopped) {
return;
}
const ctx = reqCtx.traces.get(context);
if (!ctx) {
return;
}
const node = newTraceNode(ctx, info.path);
node.type = info.returnType.toString();
node.parentType = info.parentType.toString();
node.startTime = hrTimeToDurationInNanos(process.hrtime(reqCtx.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(reqCtx.startHrTime));
};
}),
);
},
onRequest({ request }): void | Promise<void> {
if (options.ignoreRequest) {
const res$ = options.ignoreRequest(request);
if (typeof res$ === 'boolean') {
if (res$) {
return;
}
} else {
return res$.then(shouldIgnore => {
if (!shouldIgnore) {
setNewContext(request);
}
});
}
}
setNewContext(request);
},
onEnveloped({ context }) {
if (!context) {
return;
}
const reqCtx = ctxForReq.get(context.request);
if (!reqCtx) return;
const rootNode = new Trace.Node();
const ctx = {
rootNode,
trace: new 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: reqCtx.traceStartTimestamp,
}),
nodes: new Map([[responsePathToString(), rootNode]]),
};
reqCtx.traces.set(context, ctx);
},
onExecutionResult({ context, request, result }) {
// TODO: should handle streaming results? how?
if (result == null || isAsyncIterable(result)) {
return;
}
const reqCtx = ctxForReq.get(request);
const ctx = reqCtx?.traces.get(context);
if (!reqCtx || !ctx || reqCtx.stopped) {
return;
}
if (result.errors?.length && reqCtx && ctx) {
handleErrors(reqCtx, ctx, result.errors, options.rewriteError);
}
ctx.trace.durationNs = hrTimeToDurationInNanos(process.hrtime(reqCtx.startHrTime));
ctx.trace.endTime = nowTimestamp();
},
onResultProcess({ request, result }) {
// TODO: should handle streaming results? how?
if (isAsyncIterable(result)) return;
const reqCtx = ctxForReq.get(request);
if (!reqCtx) return;
// onResultProcess will be called only once since we disallow async iterables
if (reqCtx.stopped) throw new Error('Trace stopped multiple times');
reqCtx.stopped = true;
},
};
return [plugin, ctxForReq] as const;
}
/**
* 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(): google.protobuf.Timestamp {
const totalMillis = Date.now();
const millis = totalMillis % 1000;
return new 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: ApolloInlineGraphqlTraceContext,
path: ResponsePath,
): 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
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return newTraceNode(ctx, path.prev!);
}
function newTraceNode(ctx: ApolloInlineGraphqlTraceContext, path: ResponsePath) {
const node = new 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(
reqCtx: ApolloInlineRequestTraceContext,
ctx: ApolloInlineGraphqlTraceContext,
errors: readonly GraphQLError[],
rewriteError: ApolloInlineTracePluginOptions['rewriteError'],
) {
if (reqCtx.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 Trace.Error({
message: errToReport.message,
location: (errToReport.locations || []).map(
({ line, column }) => new Trace.Location({ line, column }),
),
json: JSON.stringify(errToReport),
}),
);
}
}