@graphql-yoga/plugin-apollo-usage-report 0.8.5 → 0.9.0-alpha-20250515114730-227992730df06d592514265ec4b8d341a03c3f88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/esm/index.js CHANGED
@@ -1,17 +1,14 @@
1
- import { getOperationAST, Kind, printSchema, stripIgnoredCharacters } from 'graphql';
1
+ import { getOperationAST, Kind, printSchema } from 'graphql';
2
2
  import { isAsyncIterable, } from 'graphql-yoga';
3
- import { Report } from '@apollo/usage-reporting-protobuf';
3
+ import { calculateReferencedFieldsByType, usageReportingSignature, } from '@apollo/utils.usagereporting';
4
4
  import { useApolloInstrumentation, } from '@graphql-yoga/plugin-apollo-inline-trace';
5
- import { handleMaybePromise } from '@whatwg-node/promise-helpers';
6
- function getEnvVar(name, defaultValue) {
7
- return globalThis.process?.env?.[name] || defaultValue || undefined;
8
- }
9
- const DEFAULT_REPORTING_ENDPOINT = 'https://usage-reporting.api.apollographql.com/api/ingress/traces';
5
+ import { getEnvVar, Reporter } from './reporter.js';
10
6
  export function useApolloUsageReport(options = {}) {
11
7
  const [instrumentation, ctxForReq] = useApolloInstrumentation(options);
12
8
  let schemaIdSet$;
13
- let schemaId;
9
+ let schema;
14
10
  let yoga;
11
+ let reporter;
15
12
  const logger = Object.fromEntries(['error', 'warn', 'info', 'debug'].map(level => [
16
13
  level,
17
14
  (...messages) => yoga.logger[level]('[ApolloUsageReport]', ...messages),
@@ -30,6 +27,7 @@ export function useApolloUsageReport(options = {}) {
30
27
  addPlugin({
31
28
  onYogaInit(args) {
32
29
  yoga = args.yoga;
30
+ reporter = new Reporter(options, yoga, logger);
33
31
  if (!getEnvVar('APOLLO_KEY', options.apiKey)) {
34
32
  throw new Error(`[ApolloUsageReport] Missing API key. Please provide one in plugin options or with 'APOLLO_KEY' environment variable.`);
35
33
  }
@@ -39,9 +37,13 @@ export function useApolloUsageReport(options = {}) {
39
37
  },
40
38
  onSchemaChange({ schema }) {
41
39
  if (schema) {
42
- schemaIdSet$ = handleMaybePromise(() => hashSHA256(printSchema(schema), yoga.fetchAPI), id => {
43
- schemaId = id;
40
+ schemaIdSet$ = hashSHA256(printSchema(schema), yoga.fetchAPI)
41
+ .then(id => {
42
+ schema = { id, schema };
44
43
  schemaIdSet$ = undefined;
44
+ })
45
+ .catch(error => {
46
+ logger.error('Failed to calculate schema hash: ', error);
45
47
  });
46
48
  }
47
49
  },
@@ -50,6 +52,9 @@ export function useApolloUsageReport(options = {}) {
50
52
  },
51
53
  onParse() {
52
54
  return function onParseEnd({ result, context }) {
55
+ if (!schema) {
56
+ throw new Error("should not happen: schema doesn't exists");
57
+ }
53
58
  const ctx = ctxForReq.get(context.request)?.traces.get(context);
54
59
  if (!ctx) {
55
60
  logger.debug('operation tracing context not found, this operation will not be traced.');
@@ -57,11 +62,16 @@ export function useApolloUsageReport(options = {}) {
57
62
  }
58
63
  const operationName = context.params.operationName ??
59
64
  (isDocumentNode(result) ? getOperationAST(result)?.name?.value : undefined);
60
- const signature = context.params.query
61
- ? stripIgnoredCharacters(context.params.query)
62
- : '';
65
+ const signature = operationName
66
+ ? usageReportingSignature(result, operationName)
67
+ : (context.params.query ?? '');
68
+ ctx.referencedFieldsByType = calculateReferencedFieldsByType({
69
+ document: result,
70
+ schema: schema.schema,
71
+ resolvedOperationName: operationName ?? null,
72
+ });
63
73
  ctx.operationKey = `# ${operationName || '-'}\n${signature}`;
64
- ctx.schemaId = schemaId;
74
+ ctx.schemaId = schema.id;
65
75
  };
66
76
  },
67
77
  onResultProcess({ request, result, serverContext }) {
@@ -75,9 +85,6 @@ export function useApolloUsageReport(options = {}) {
75
85
  logger.debug('operation tracing context not found, this operation will not be traced.');
76
86
  return;
77
87
  }
78
- // Each operation in a batched request can belongs to a different schema.
79
- // Apollo doesn't allow to send batch queries for multiple schemas in the same batch
80
- const tracesPerSchema = {};
81
88
  for (const trace of reqCtx.traces.values()) {
82
89
  if (!trace.schemaId || !trace.operationKey) {
83
90
  logger.debug('Misformed trace, missing operation key or schema id');
@@ -91,62 +98,33 @@ export function useApolloUsageReport(options = {}) {
91
98
  if (clientVersion) {
92
99
  trace.trace.clientVersion = clientVersion;
93
100
  }
94
- tracesPerSchema[trace.schemaId] ||= {};
95
- tracesPerSchema[trace.schemaId][trace.operationKey] ||= { trace: [] };
96
- tracesPerSchema[trace.schemaId][trace.operationKey].trace?.push(trace.trace);
97
- }
98
- for (const schemaId in tracesPerSchema) {
99
- const tracesPerQuery = tracesPerSchema[schemaId];
100
- const agentVersion = options.agentVersion || `graphql-yoga@${yoga.version}`;
101
- serverContext.waitUntil(sendTrace(options, logger, yoga.fetchAPI.fetch, schemaId, tracesPerQuery, agentVersion));
101
+ serverContext.waitUntil(reporter.addTrace(schema.id, {
102
+ statsReportKey: trace.operationKey,
103
+ trace: trace.trace,
104
+ referencedFieldsByType: trace.referencedFieldsByType,
105
+ asTrace: true, // TODO: allow to not always send traces
106
+ nonFtv1ErrorPaths: [],
107
+ maxTraceBytes: options.maxTraceSize,
108
+ }));
102
109
  }
103
110
  },
111
+ async onDispose() {
112
+ await reporter?.flush();
113
+ },
104
114
  });
105
115
  },
106
116
  };
107
117
  }
108
- export function hashSHA256(text, api = globalThis) {
118
+ export async function hashSHA256(text, api = globalThis) {
109
119
  const inputUint8Array = new api.TextEncoder().encode(text);
110
- return handleMaybePromise(() => api.crypto.subtle.digest({ name: 'SHA-256' }, inputUint8Array), arrayBuf => {
111
- const outputUint8Array = new Uint8Array(arrayBuf);
112
- let hash = '';
113
- for (const byte of outputUint8Array) {
114
- const hex = byte.toString(16);
115
- hash += '00'.slice(0, Math.max(0, 2 - hex.length)) + hex;
116
- }
117
- return hash;
118
- });
119
- }
120
- function sendTrace(options, logger, fetch, schemaId, tracesPerQuery, agentVersion) {
121
- const { graphRef = getEnvVar('APOLLO_GRAPH_REF'), apiKey = getEnvVar('APOLLO_KEY'), endpoint = DEFAULT_REPORTING_ENDPOINT, } = options;
122
- const body = Report.encode({
123
- header: {
124
- agentVersion,
125
- graphRef,
126
- executableSchemaId: schemaId,
127
- },
128
- operationCount: 1,
129
- tracesPerQuery,
130
- }).finish();
131
- return handleMaybePromise(() => fetch(endpoint, {
132
- method: 'POST',
133
- headers: {
134
- 'content-type': 'application/protobuf',
135
- // The presence of the api key is already checked at Yoga initialization time
136
- 'x-api-key': apiKey,
137
- accept: 'application/json',
138
- },
139
- body,
140
- }), response => handleMaybePromise(() => response.text(), responseText => {
141
- if (response.ok) {
142
- logger.debug('Traces sent:', responseText);
143
- }
144
- else {
145
- logger.error('Failed to send trace:', responseText);
146
- }
147
- }), err => {
148
- logger.error('Failed to send trace:', err);
149
- });
120
+ const arrayBuf = await api.crypto.subtle.digest({ name: 'SHA-256' }, inputUint8Array);
121
+ const outputUint8Array = new Uint8Array(arrayBuf);
122
+ let hash = '';
123
+ for (const byte of outputUint8Array) {
124
+ const hex = byte.toString(16);
125
+ hash += '00'.slice(0, Math.max(0, 2 - hex.length)) + hex;
126
+ }
127
+ return hash;
150
128
  }
151
129
  function isDocumentNode(data) {
152
130
  const isObject = (data) => !!data && typeof data === 'object';
@@ -0,0 +1,136 @@
1
+ import { google, Report, ReportHeader } from '@apollo/usage-reporting-protobuf';
2
+ import { OurReport } from './stats.js';
3
+ const DEFAULT_REPORTING_ENDPOINT = 'https://usage-reporting.api.apollographql.com/api/ingress/traces';
4
+ export class Reporter {
5
+ #yoga;
6
+ #logger = console;
7
+ #reportHeaders;
8
+ #options;
9
+ #reportsBySchema = {};
10
+ #nextSendAfterDelay;
11
+ #sending = [];
12
+ constructor(options, yoga, logger) {
13
+ this.#logger = logger;
14
+ this.#yoga = yoga;
15
+ this.#options = {
16
+ ...options,
17
+ maxBatchDelay: options.maxBatchDelay ?? 20 * 60 * 1000, // 20min
18
+ maxBatchUncompressedSize: options.maxBatchUncompressedSize ?? 4 * 1024 * 1024, // 4mb
19
+ maxTraceSize: options.maxTraceSize ?? 10 * 1024 * 1024, // 10mb
20
+ exportTimeout: options.exportTimeout ?? 30 * 1000, // 60s
21
+ };
22
+ this.#reportHeaders = {
23
+ graphRef: getGraphRef(options),
24
+ hostname: options.hostname ?? getEnvVar('HOSTNAME') ?? '',
25
+ uname: options.uname ?? '', // TODO: find a cross-platform way to get the uname
26
+ runtimeVersion: options.runtimeVersion ?? '',
27
+ agentVersion: options.agentVersion || `graphql-yoga@${yoga.version}`,
28
+ };
29
+ }
30
+ addTrace(schemaId, options) {
31
+ const report = this.#getReport(schemaId);
32
+ report.addTrace(options);
33
+ if (this.#options.alwaysSend ||
34
+ report.sizeEstimator.bytes >= this.#options.maxBatchUncompressedSize) {
35
+ return this.sendReport(schemaId);
36
+ }
37
+ this.#nextSendAfterDelay ||= setTimeout(() => this.flush(), this.#options.maxBatchDelay);
38
+ return;
39
+ }
40
+ async flush() {
41
+ return Promise.allSettled([
42
+ ...this.#sending, // When flushing, we want to also wait for previous traces to be sent, because it's mostly used for clean up
43
+ ...Object.keys(this.#reportsBySchema).map(schemaId => this.sendReport(schemaId)),
44
+ ]);
45
+ }
46
+ async sendReport(schemaId) {
47
+ const sending = this.#sendReport(schemaId);
48
+ this.#sending.push(sending);
49
+ sending.finally(() => this.#sending?.filter(p => p !== sending));
50
+ return sending;
51
+ }
52
+ async #sendReport(schemaId) {
53
+ const { fetchAPI: { fetch, CompressionStream, ReadableStream }, } = this.#yoga;
54
+ const report = this.#reportsBySchema[schemaId];
55
+ if (!report) {
56
+ throw new Error(`No report to send for schema ${schemaId}`);
57
+ }
58
+ if (this.#nextSendAfterDelay != null) {
59
+ clearTimeout(this.#nextSendAfterDelay);
60
+ }
61
+ delete this.#reportsBySchema[schemaId];
62
+ report.endTime = dateToProtoTimestamp(new Date());
63
+ report.ensureCountsAreIntegers();
64
+ const validationError = Report.verify(report);
65
+ if (!validationError) {
66
+ throw new TypeError(`Invalid report: ${validationError}`);
67
+ }
68
+ const encodedReport = Report.encode(report).finish();
69
+ const compressedReport = new ReadableStream({
70
+ start(controller) {
71
+ controller.enqueue(encodedReport);
72
+ controller.close();
73
+ },
74
+ }).pipeThrough(new CompressionStream('gzip'));
75
+ const { apiKey = getEnvVar('APOLLO_KEY'), endpoint = DEFAULT_REPORTING_ENDPOINT } = this.#options;
76
+ for (let tries = 0; tries < 5; tries++) {
77
+ try {
78
+ const abortCtl = new AbortController();
79
+ this.#logger?.debug(`Sending report (try ${tries}/5)`);
80
+ const timeout = setTimeout(() => abortCtl.abort(), this.#options.exportTimeout);
81
+ const response = await fetch(endpoint, {
82
+ method: 'POST',
83
+ headers: {
84
+ 'content-type': 'application/protobuf',
85
+ 'content-encoding': 'gzip',
86
+ // The presence of the api key is already checked at Yoga initialization time
87
+ 'x-api-key': apiKey,
88
+ accept: 'application/json',
89
+ },
90
+ body: compressedReport,
91
+ signal: abortCtl.signal,
92
+ });
93
+ clearTimeout(timeout);
94
+ const result = await response.text();
95
+ if (response.ok) {
96
+ this.#logger?.debug('Report sent:', result);
97
+ return;
98
+ }
99
+ throw result;
100
+ }
101
+ catch (err) {
102
+ this.#logger?.error('Failed to send report:', err);
103
+ }
104
+ }
105
+ throw new Error('Failed to send traces after 5 tries');
106
+ }
107
+ #getReport(schemaId) {
108
+ const report = this.#reportsBySchema[schemaId];
109
+ if (report) {
110
+ return report;
111
+ }
112
+ return (this.#reportsBySchema[schemaId] = new OurReport(new ReportHeader({
113
+ ...this.#reportHeaders,
114
+ executableSchemaId: schemaId,
115
+ })));
116
+ }
117
+ }
118
+ function getGraphRef(options) {
119
+ const graphRef = options.graphRef || getEnvVar('APOLLO_GRAPH_REF');
120
+ if (!graphRef) {
121
+ throw new Error('Missing GraphRef. Either provide `graphRef` option or `APOLLO_GRAPH_REF` environment variable');
122
+ }
123
+ return graphRef;
124
+ }
125
+ export function getEnvVar(name, defaultValue) {
126
+ return globalThis.process?.env?.[name] || defaultValue || undefined;
127
+ }
128
+ // Converts a JS Date into a Timestamp.
129
+ export function dateToProtoTimestamp(date) {
130
+ const totalMillis = date.getTime();
131
+ const millis = totalMillis % 1000;
132
+ return new google.protobuf.Timestamp({
133
+ seconds: (totalMillis - millis) / 1000,
134
+ nanos: millis * 1e6,
135
+ });
136
+ }