-
Notifications
You must be signed in to change notification settings - Fork 2k
/
plugin.ts
888 lines (807 loc) · 35.2 KB
/
plugin.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
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
import { Report, ReportHeader, Trace } from '@apollo/usage-reporting-protobuf';
import type { Fetcher, FetcherResponse } from '@apollo/utils.fetcher';
import {
usageReportingSignature,
calculateReferencedFieldsByType,
type ReferencedFieldsByType,
} from '@apollo/utils.usagereporting';
import retry from 'async-retry';
import { type GraphQLSchema, printSchema } from 'graphql';
import type LRUCache from 'lru-cache';
import { AbortController } from 'node-abort-controller';
import fetch from 'node-fetch';
import os from 'os';
import { gzip } from 'zlib';
import type {
ApolloServerPlugin,
BaseContext,
GraphQLRequestContext,
GraphQLRequestContextDidResolveOperation,
GraphQLRequestContextWillSendResponse,
GraphQLRequestListener,
GraphQLServerListener,
} from '../../externalTypes/index.js';
import { internalPlugin } from '../../internalPlugin.js';
import { dateToProtoTimestamp, TraceTreeBuilder } from '../traceTreeBuilder.js';
import { defaultSendOperationsAsTrace } from './defaultSendOperationsAsTrace.js';
import {
createOperationDerivedDataCache,
type OperationDerivedData,
operationDerivedDataCacheKey,
} from './operationDerivedDataCache.js';
import type {
ApolloServerPluginUsageReportingOptions,
SendValuesBaseOptions,
} from './options.js';
import { OurReport } from './stats.js';
import { makeTraceDetails } from './traceDetails.js';
import { packageVersion } from '../../generated/packageVersion.js';
import { computeCoreSchemaHash } from '../../utils/computeCoreSchemaHash.js';
import type { HeaderMap } from '../../utils/HeaderMap.js';
import { schemaIsSubgraph } from '../schemaIsSubgraph.js';
const reportHeaderDefaults = {
hostname: os.hostname(),
agentVersion: `@apollo/server@${packageVersion}`,
runtimeVersion: `node ${process.version}`,
// XXX not actually uname, but what node has easily.
uname: `${os.platform()}, ${os.type()}, ${os.release()}, ${os.arch()})`,
};
export function ApolloServerPluginUsageReporting<TContext extends BaseContext>(
options: ApolloServerPluginUsageReportingOptions<TContext> = Object.create(
null,
),
): ApolloServerPlugin<TContext> {
// Note: We'd like to change the default to false in Apollo Server 4, so that
// the default usage reporting experience doesn't include *anything* that
// could potentially be PII (like error messages) --- just operations and
// numbers.
const fieldLevelInstrumentationOption = options.fieldLevelInstrumentation;
const fieldLevelInstrumentation =
typeof fieldLevelInstrumentationOption === 'number'
? async () =>
Math.random() < fieldLevelInstrumentationOption
? 1 / fieldLevelInstrumentationOption
: 0
: fieldLevelInstrumentationOption
? fieldLevelInstrumentationOption
: async () => true;
let requestDidStartHandler:
| ((
requestContext: GraphQLRequestContext<TContext>,
) => GraphQLRequestListener<TContext>)
| null = null;
return internalPlugin({
__internal_plugin_id__: 'UsageReporting',
__is_disabled_plugin__: false,
// We want to be able to access locals from `serverWillStart` in our `requestDidStart`, thus
// this little hack. (Perhaps we should also allow GraphQLServerListener to contain
// a requestDidStart?)
async requestDidStart(requestContext: GraphQLRequestContext<TContext>) {
if (requestDidStartHandler) {
return requestDidStartHandler(requestContext);
}
// This happens if usage reporting is disabled (eg because this is a
// subgraph).
return {};
},
async serverWillStart({
logger: serverLogger,
apollo,
startedInBackground,
schema,
}): Promise<GraphQLServerListener> {
// Use the plugin-specific logger if one is provided; otherwise the general server one.
const logger = options.logger ?? serverLogger;
const { key, graphRef } = apollo;
if (!(key && graphRef)) {
throw new Error(
"You've enabled usage reporting via ApolloServerPluginUsageReporting, " +
'but you also need to provide your Apollo API key and graph ref, via ' +
'the APOLLO_KEY/APOLLO_GRAPH_REF environment ' +
'variables or via `new ApolloServer({apollo: {key, graphRef})`.',
);
}
if (schemaIsSubgraph(schema)) {
if (options.__onlyIfSchemaIsNotSubgraph) {
logger.warn(
'You have specified an Apollo API key and graph ref but this server appears ' +
'to be a subgraph. Typically usage reports are sent to Apollo by your Router ' +
'or Gateway, not directly from your subgraph; usage reporting is disabled. To ' +
'enable usage reporting anyway, explicitly install `ApolloServerPluginUsageReporting`. ' +
'To disable this warning, install `ApolloServerPluginUsageReportingDisabled`.',
);
// This early return means we don't start background timers, don't
// register serverDidStart, don't assign requestDidStartHandler, etc.
return {};
} else {
// This is just a warning; usage reporting is still enabled. If it
// turns out there are lots of people who really need to have this odd
// setup and they don't like the warning, we can provide a new option
// to disable the warning (or they can filter in their `logger`).
logger.warn(
'You have installed `ApolloServerPluginUsageReporting` but this server appears to ' +
'be a subgraph. Typically usage reports are sent to Apollo by your Router ' +
'or Gateway, not directly from your subgraph. If this was unintentional, remove ' +
"`ApolloServerPluginUsageReporting` from your server's `plugins` array.",
);
}
}
logger.info(
'Apollo usage reporting starting! See your graph at ' +
`https://studio.apollographql.com/graph/${encodeURI(graphRef)}/`,
);
// If sendReportsImmediately is not specified, we default to true if we're running
// with the ApolloServer designed for Lambda or similar. That's because these
// environments aren't designed around letting us run a background task to
// send reports later or hook into container destruction to flush buffered reports.
const sendReportsImmediately =
options.sendReportsImmediately ?? startedInBackground;
// Since calculating the signature and referenced fields for usage
// reporting is potentially an expensive operation, we'll cache the data
// we generate and re-use them for repeated operations for the same
// `queryHash`. However, because referenced fields depend on the current
// schema, we want to throw it out entirely any time the schema changes.
let operationDerivedDataCache: {
forSchema: GraphQLSchema;
cache: LRUCache<string, OperationDerivedData>;
} | null = null;
// This map maps from executable schema ID (schema hash, basically) to the
// report we'll send about it. That's because when we're using a gateway,
// the schema can change over time, but each report needs to be about a
// single schema. We avoid having this function be a memory leak by
// removing values from it when we're in the process of sending reports.
// That means we have to be very careful never to pull a Report out of it
// and hang on to it for a while before writing to it, because the report
// might have gotten sent and discarded in the meantime. So you should
// only access the values of this Map via
// getReportWhichMustBeUsedImmediately and getAndDeleteReport, and never
// hang on to the value returned by getReportWhichMustBeUsedImmediately.
const reportByExecutableSchemaId = new Map<string, OurReport>();
const getReportWhichMustBeUsedImmediately = (
executableSchemaId: string,
): OurReport => {
const existing = reportByExecutableSchemaId.get(executableSchemaId);
if (existing) {
return existing;
}
const report = new OurReport(
new ReportHeader({
...reportHeaderDefaults,
executableSchemaId,
graphRef,
}),
);
reportByExecutableSchemaId.set(executableSchemaId, report);
return report;
};
const getAndDeleteReport = (
executableSchemaId: string,
): OurReport | null => {
const report = reportByExecutableSchemaId.get(executableSchemaId);
if (report) {
reportByExecutableSchemaId.delete(executableSchemaId);
return report;
}
return null;
};
const overriddenExecutableSchemaId = options.overrideReportedSchema
? computeCoreSchemaHash(options.overrideReportedSchema)
: undefined;
let lastSeenExecutableSchemaToId:
| {
executableSchema: GraphQLSchema;
executableSchemaId: string;
}
| undefined;
let reportTimer: NodeJS.Timeout | undefined;
if (!sendReportsImmediately) {
reportTimer = setInterval(
() => sendAllReportsAndReportErrors(),
options.reportIntervalMs || 10 * 1000,
);
}
// We don't send traces if the user set `sendTraces: false`. We also may
// set this to false later if the usage-reporting ingress informs us that
// this graph does not support viewing traces.
let sendTraces = options.sendTraces ?? true;
const sendOperationAsTrace =
options.experimental_sendOperationAsTrace ??
defaultSendOperationsAsTrace();
let stopped = false;
function executableSchemaIdForSchema(schema: GraphQLSchema) {
if (lastSeenExecutableSchemaToId?.executableSchema === schema) {
return lastSeenExecutableSchemaToId.executableSchemaId;
}
const id = computeCoreSchemaHash(printSchema(schema));
// We override this variable every time we get a new schema so we cache
// the last seen value. It is a single-entry cache.
lastSeenExecutableSchemaToId = {
executableSchema: schema,
executableSchemaId: id,
};
return id;
}
async function sendAllReportsAndReportErrors(): Promise<void> {
await Promise.all(
[...reportByExecutableSchemaId.keys()].map((executableSchemaId) =>
sendReportAndReportErrors(executableSchemaId),
),
);
}
async function sendReportAndReportErrors(
executableSchemaId: string,
): Promise<void> {
return sendReport(executableSchemaId).catch((err) => {
// This catch block is primarily intended to catch network errors from
// the retried request itself, which include network errors and non-2xx
// HTTP errors.
if (options.reportErrorFunction) {
options.reportErrorFunction(err);
} else {
logger.error(err.message);
}
});
}
// Needs to be an arrow function to be confident that key is defined.
const sendReport = async (executableSchemaId: string): Promise<void> => {
let report = getAndDeleteReport(executableSchemaId);
if (
!report ||
(Object.keys(report.tracesPerQuery).length === 0 &&
report.operationCount === 0)
) {
return;
}
// Set the report's overall end time. This is the timestamp that will be
// associated with the summarized statistics.
report.endTime = dateToProtoTimestamp(new Date());
report.ensureCountsAreIntegers();
const protobufError = Report.verify(report);
if (protobufError) {
throw new Error(`Error verifying report: ${protobufError}`);
}
let message: Uint8Array | null = Report.encode(report).finish();
// Let the original protobuf object be garbage collected (helpful if the
// HTTP request hangs).
report = null;
// Potential follow-up: we can compare message.length to
// report.sizeEstimator.bytes and use it to "learn" if our estimation is
// off and adjust it based on what we learn.
if (options.debugPrintReports) {
// We decode the report rather than printing the original `report`
// so that it includes all of the pre-encoded traces.
const decodedReport = Report.decode(message);
logger.info(
`Apollo usage report: ${JSON.stringify(decodedReport.toJSON())}`,
);
}
const compressed = await new Promise<Buffer>((resolve, reject) => {
gzip(message!, (error, result) => {
error ? reject(error) : resolve(result);
});
});
// Let the uncompressed message be garbage collected (helpful if the
// HTTP request is slow).
message = null;
// Wrap fetcher with async-retry for automatic retrying
const fetcher: Fetcher = options.fetcher ?? fetch;
const response: FetcherResponse = await retry(
// Retry on network errors and 5xx HTTP
// responses.
async () => {
// Note that once we require Node v16 we can use its global
// AbortController instead of the one from `node-abort-controller`.
const controller = new AbortController();
const abortTimeout = setTimeout(() => {
controller.abort();
}, options.requestTimeoutMs ?? 30_000);
let curResponse;
try {
curResponse = await fetcher(
(options.endpointUrl ||
'https://usage-reporting.api.apollographql.com') +
'/api/ingress/traces',
{
method: 'POST',
headers: {
'user-agent': 'ApolloServerPluginUsageReporting',
'x-api-key': key,
'content-encoding': 'gzip',
accept: 'application/json',
},
body: compressed,
signal: controller.signal,
},
);
} finally {
clearTimeout(abortTimeout);
}
if (curResponse.status >= 500 && curResponse.status < 600) {
throw new Error(
`HTTP status ${curResponse.status}, ${
(await curResponse.text()) || '(no body)'
}`,
);
} else {
return curResponse;
}
},
{
retries: (options.maxAttempts || 5) - 1,
minTimeout: options.minimumRetryDelayMs || 100,
factor: 2,
},
).catch((err: Error) => {
throw new Error(
`Error sending report to Apollo servers: ${err.message}`,
);
});
if (response.status < 200 || response.status >= 300) {
// Note that we don't expect to see a 3xx here because request follows
// redirects.
throw new Error(
`Error sending report to Apollo servers: HTTP status ${
response.status
}, ${(await response.text()) || '(no body)'}`,
);
}
if (
sendTraces &&
response.status === 200 &&
response.headers
.get('content-type')
?.match(/^\s*application\/json\s*(?:;|$)/i)
) {
const body = await response.text();
let parsedBody;
try {
parsedBody = JSON.parse(body);
} catch (e) {
throw new Error(`Error parsing response from Apollo servers: ${e}`);
}
if (parsedBody.tracesIgnored === true) {
logger.debug(
"This graph's organization does not have access to traces; sending all " +
'subsequent operations as stats.',
);
sendTraces = false;
}
}
if (options.debugPrintReports) {
logger.info(`Apollo usage report: status ${response.status}`);
}
};
requestDidStartHandler = ({
metrics,
schema,
request: { http, variables },
}): GraphQLRequestListener<TContext> => {
const treeBuilder: TraceTreeBuilder = new TraceTreeBuilder({
maskedBy: 'ApolloServerPluginUsageReporting',
sendErrors: options.sendErrors,
});
treeBuilder.startTiming();
metrics.startHrTime = treeBuilder.startHrTime;
let graphqlValidationFailure = false;
let graphqlUnknownOperationName = false;
let includeOperationInUsageReporting: boolean | null = null;
if (http) {
treeBuilder.trace.http = new Trace.HTTP({
method:
Trace.HTTP.Method[
http.method as keyof typeof Trace.HTTP.Method
] || Trace.HTTP.Method.UNKNOWN,
});
if (options.sendHeaders) {
makeHTTPRequestHeaders(
treeBuilder.trace.http,
http.headers,
options.sendHeaders,
);
}
}
// After this function completes, includeOperationInUsageReporting is
// defined.
async function maybeCallIncludeRequestHook(
requestContext:
| GraphQLRequestContextDidResolveOperation<TContext>
| GraphQLRequestContextWillSendResponse<TContext>,
): Promise<void> {
// If this is the second call in `willSendResponse` after
// `didResolveOperation`, we're done.
if (includeOperationInUsageReporting !== null) return;
if (typeof options.includeRequest !== 'function') {
// Default case we always report
includeOperationInUsageReporting = true;
return;
}
includeOperationInUsageReporting =
await options.includeRequest(requestContext);
// Help the user understand they've returned an unexpected value,
// which might be a subtle mistake.
if (typeof includeOperationInUsageReporting !== 'boolean') {
logger.warn(
"The 'includeRequest' async predicate function must return a boolean value.",
);
includeOperationInUsageReporting = true;
}
}
// Our usage reporting groups everything by operation, so we don't
// actually report about any issues that prevent us from getting an
// operation string (eg, a missing operation, or APQ problems).
// This is effectively bypassing the reporting of:
// - PersistedQueryNotFoundError
// - PersistedQueryNotSupportedError
// - Missing `query` error
// We may want to report them some other way later!
let didResolveSource = false;
return {
async didResolveSource(requestContext) {
didResolveSource = true;
if (metrics.persistedQueryHit) {
treeBuilder.trace.persistedQueryHit = true;
}
if (metrics.persistedQueryRegister) {
treeBuilder.trace.persistedQueryRegister = true;
}
if (variables) {
treeBuilder.trace.details = makeTraceDetails(
variables,
options.sendVariableValues,
requestContext.source,
);
}
const clientInfo = (
options.generateClientInfo || defaultGenerateClientInfo
)(requestContext);
if (clientInfo) {
// While there is a clientAddress protobuf field, the backend
// doesn't pay attention to it yet, so we'll ignore it for now.
const { clientName, clientVersion } = clientInfo;
treeBuilder.trace.clientVersion = clientVersion || '';
treeBuilder.trace.clientName = clientName || '';
}
},
async validationDidStart() {
return async (validationErrors?: ReadonlyArray<Error>) => {
graphqlValidationFailure = validationErrors
? validationErrors.length !== 0
: false;
};
},
async didResolveOperation(requestContext) {
// If operation is undefined then `getOperationAST` returned null
// and an unknown operation was specified.
graphqlUnknownOperationName =
requestContext.operation === undefined;
await maybeCallIncludeRequestHook(requestContext);
if (
includeOperationInUsageReporting &&
// No need to capture traces if the operation is going to
// immediately fail due to unknown operation name.
!graphqlUnknownOperationName
) {
if (metrics.captureTraces === undefined) {
// We're not completely ignoring the operation. But should we
// calculate a detailed trace of every field while we do so (either
// directly in this plugin, or in a subgraph by sending the
// apollo-federation-include-trace header)? That will allow this
// operation to contribute to the "field executions" column in the
// Studio Fields page, to the timing hints in Explorer and
// vscode-graphql, and to the traces visible under Operations. (Note
// that `true` here does not imply that this operation will
// necessarily be *sent* to the usage-reporting endpoint in the form
// of a trace --- it still might be aggregated into stats first. But
// capturing a trace will mean we can understand exactly what fields
// were executed and what their performance was, at the tradeoff of
// some overhead for tracking the trace (and transmitting it between
// subgraph and gateway).
const rawWeight =
await fieldLevelInstrumentation(requestContext);
treeBuilder.trace.fieldExecutionWeight =
typeof rawWeight === 'number' ? rawWeight : rawWeight ? 1 : 0;
metrics.captureTraces =
!!treeBuilder.trace.fieldExecutionWeight;
}
}
},
async executionDidStart() {
// If we're not capturing traces, don't return a willResolveField so
// that we don't build up a detailed trace inside treeBuilder. (We still
// will use treeBuilder as a convenient place to put top-level facts
// about the operation which can end up aggregated as stats, and we do
// eventually put *errors* onto the trace tree.)
if (!metrics.captureTraces) return;
return {
willResolveField({ info }) {
return treeBuilder.willResolveField(info);
// We could save the error into the trace during the end handler, but
// it won't have all the information that graphql-js adds to it later,
// like 'locations'.
},
};
},
async didEncounterSubsequentErrors(_requestContext, errors) {
treeBuilder.didEncounterErrors(errors);
},
async willSendSubsequentPayload(requestContext, payload) {
if (!payload.hasNext) {
await operationFinished(requestContext);
}
},
async willSendResponse(requestContext) {
// Search above for a comment about "didResolveSource" to see which
// of the pre-source-resolution errors we are intentionally avoiding.
if (!didResolveSource) return;
if (requestContext.errors) {
treeBuilder.didEncounterErrors(requestContext.errors);
}
// If there isn't any defer/stream coming later, we're done.
// Otherwise willSendSubsequentPayload will trigger
// operationFinished.
if (requestContext.response.body.kind === 'single') {
await operationFinished(requestContext);
}
},
};
async function operationFinished(
requestContext: GraphQLRequestContextWillSendResponse<TContext>,
) {
const resolvedOperation = !!requestContext.operation;
// If we got an error before we called didResolveOperation (eg parse or
// validation error), check to see if we should include the request.
await maybeCallIncludeRequestHook(requestContext);
treeBuilder.stopTiming();
const executableSchemaId =
overriddenExecutableSchemaId ?? executableSchemaIdForSchema(schema);
if (includeOperationInUsageReporting === false) {
if (resolvedOperation) {
getReportWhichMustBeUsedImmediately(executableSchemaId)
.operationCount++;
}
return;
}
treeBuilder.trace.fullQueryCacheHit = !!metrics.responseCacheHit;
treeBuilder.trace.forbiddenOperation = !!metrics.forbiddenOperation;
treeBuilder.trace.registeredOperation = !!metrics.registeredOperation;
const policyIfCacheable =
requestContext.overallCachePolicy.policyIfCacheable();
if (policyIfCacheable) {
treeBuilder.trace.cachePolicy = new Trace.CachePolicy({
scope:
policyIfCacheable.scope === 'PRIVATE'
? Trace.CachePolicy.Scope.PRIVATE
: policyIfCacheable.scope === 'PUBLIC'
? Trace.CachePolicy.Scope.PUBLIC
: Trace.CachePolicy.Scope.UNKNOWN,
// Convert from seconds to ns.
maxAgeNs: policyIfCacheable.maxAge * 1e9,
});
}
// If this was a federated operation and we're the gateway, add the query plan
// to the trace.
if (metrics.queryPlanTrace) {
treeBuilder.trace.queryPlan = metrics.queryPlanTrace;
}
// Intentionally un-awaited so as not to block the response. Any
// errors will be logged, but will not manifest a user-facing error.
// The logger in this case is a request specific logger OR the logger
// defined by the plugin if that's unavailable. The request-specific
// logger is preferred since this is very much coupled directly to a
// client-triggered action which might be more granularly tagged by
// logging implementations.
addTrace().catch(logger.error);
async function addTrace(): Promise<void> {
// Ignore traces that come in after stop().
if (stopped) {
return;
}
// Ensure that the caller of addTrace (which does not await it) is
// not blocked. We use setImmediate rather than process.nextTick or
// just relying on the Promise microtask queue because setImmediate
// comes after IO, which is what we want.
await new Promise((res) => setImmediate(res));
const executableSchemaId =
overriddenExecutableSchemaId ??
executableSchemaIdForSchema(schema);
const { trace } = treeBuilder;
let statsReportKey: string | undefined = undefined;
let referencedFieldsByType: ReferencedFieldsByType;
if (!requestContext.document) {
statsReportKey = `## GraphQLParseFailure\n`;
} else if (graphqlValidationFailure) {
statsReportKey = `## GraphQLValidationFailure\n`;
} else if (graphqlUnknownOperationName) {
statsReportKey = `## GraphQLUnknownOperationName\n`;
}
const isExecutable = statsReportKey === undefined;
if (statsReportKey) {
if (options.sendUnexecutableOperationDocuments) {
trace.unexecutedOperationBody = requestContext.source;
// Get the operation name from the request (which might not
// correspond to an actual operation).
trace.unexecutedOperationName =
requestContext.request.operationName || '';
}
referencedFieldsByType = Object.create(null);
} else {
const operationDerivedData = getOperationDerivedData();
statsReportKey = `# ${requestContext.operationName || '-'}\n${
operationDerivedData.signature
}`;
referencedFieldsByType =
operationDerivedData.referencedFieldsByType;
}
const protobufError = Trace.verify(trace);
if (protobufError) {
throw new Error(`Error encoding trace: ${protobufError}`);
}
if (resolvedOperation) {
getReportWhichMustBeUsedImmediately(executableSchemaId)
.operationCount++;
}
getReportWhichMustBeUsedImmediately(executableSchemaId).addTrace({
statsReportKey,
trace,
// We include the operation as a trace (rather than aggregated into stats) only if:
// * the user didn't set `sendTraces: false` AND
// * it's possible that the organization's plan allows for viewing traces AND
// * we captured this as a full trace AND
// * gateway reported no errors missing ftv1 data AND
// * sendOperationAsTrace says so
//
// (As an edge case, if the reason metrics.captureTraces is
// falsey is that this is an unexecutable operation and thus we
// never ran the code in didResolveOperation that sets
// metrics.captureTrace, we allow it to be sent as a trace. This
// means we'll still send some parse and validation failures as
// traces, for the sake of the Errors page.)
asTrace:
sendTraces &&
(!isExecutable || !!metrics.captureTraces) &&
!metrics.nonFtv1ErrorPaths?.length &&
sendOperationAsTrace(trace, statsReportKey),
referencedFieldsByType,
nonFtv1ErrorPaths: metrics.nonFtv1ErrorPaths ?? [],
});
// If the buffer gets big (according to our estimate), send.
if (
sendReportsImmediately ||
getReportWhichMustBeUsedImmediately(executableSchemaId)
.sizeEstimator.bytes >=
(options.maxUncompressedReportSize || 4 * 1024 * 1024)
) {
await sendReportAndReportErrors(executableSchemaId);
}
}
// Calculates signature and referenced fields for the current document.
// Only call this when the document properly parses and validates and
// the given operation name (if any) is known!
function getOperationDerivedData(): OperationDerivedData {
if (!requestContext.document) {
// This shouldn't happen: no document means parse failure, which
// uses its own special statsReportKey.
throw new Error('No document?');
}
const cacheKey = operationDerivedDataCacheKey(
requestContext.queryHash,
requestContext.operationName || '',
);
// Ensure that the cache we have is for the right schema.
if (
!operationDerivedDataCache ||
operationDerivedDataCache.forSchema !== schema
) {
operationDerivedDataCache = {
forSchema: schema,
cache: createOperationDerivedDataCache({ logger }),
};
}
// If we didn't have the signature in the cache, we'll resort to
// calculating it.
const cachedOperationDerivedData =
operationDerivedDataCache.cache.get(cacheKey);
if (cachedOperationDerivedData) {
return cachedOperationDerivedData;
}
const generatedSignature = (
options.calculateSignature || usageReportingSignature
)(requestContext.document, requestContext.operationName || '');
const generatedOperationDerivedData: OperationDerivedData = {
signature: generatedSignature,
referencedFieldsByType: calculateReferencedFieldsByType({
document: requestContext.document,
schema,
resolvedOperationName: requestContext.operationName ?? null,
}),
};
// Note that this cache is always an in-memory cache.
// If we replace it with a more generic async cache, we should
// not await the write operation.
operationDerivedDataCache.cache.set(
cacheKey,
generatedOperationDerivedData,
);
return generatedOperationDerivedData;
}
}
};
return {
async serverWillStop() {
if (reportTimer) {
clearInterval(reportTimer);
reportTimer = undefined;
}
stopped = true;
await sendAllReportsAndReportErrors();
},
};
},
});
}
export function makeHTTPRequestHeaders(
http: Trace.IHTTP,
headers: HeaderMap,
sendHeaders?: SendValuesBaseOptions,
): void {
if (
!sendHeaders ||
('none' in sendHeaders && sendHeaders.none) ||
('all' in sendHeaders && !sendHeaders.all)
) {
return;
}
for (const [key, value] of headers) {
// Note that HeaderMap keys are already lower-case.
if (
('exceptNames' in sendHeaders &&
// We assume that most users only have a few headers to hide, or will
// just set {none: true} ; we can change this linear-time
// operation if it causes real performance issues.
sendHeaders.exceptNames.some((exceptHeader) => {
// Headers are case-insensitive, and should be compared as such.
return exceptHeader.toLowerCase() === key;
})) ||
('onlyNames' in sendHeaders &&
!sendHeaders.onlyNames.some((header) => {
return header.toLowerCase() === key;
}))
) {
continue;
}
switch (key) {
case 'authorization':
case 'cookie':
case 'set-cookie':
break;
default:
http!.requestHeaders![key] = new Trace.HTTP.Values({
value: [value],
});
}
}
}
function defaultGenerateClientInfo<TContext extends BaseContext>({
request,
}: GraphQLRequestContext<TContext>) {
const clientNameHeaderKey = 'apollographql-client-name';
const clientVersionHeaderKey = 'apollographql-client-version';
// Default to using the `apollo-client-x` header fields if present.
// If none are present, fallback on the `clientInfo` query extension
// for backwards compatibility.
// The default value if neither header values nor query extension is
// set is the empty String for all fields (as per protobuf defaults)
if (
request.http?.headers?.get(clientNameHeaderKey) ||
request.http?.headers?.get(clientVersionHeaderKey)
) {
return {
clientName: request.http?.headers?.get(clientNameHeaderKey),
clientVersion: request.http?.headers?.get(clientVersionHeaderKey),
};
} else if (request.extensions?.clientInfo) {
return request.extensions.clientInfo;
} else {
return {};
}
}