@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/cjs/index.js +45 -67
- package/cjs/reporter.js +142 -0
- package/cjs/stats.js +547 -0
- package/esm/index.js +44 -66
- package/esm/reporter.js +136 -0
- package/esm/stats.js +539 -0
- package/package.json +3 -1
- package/typings/index.d.cts +55 -3
- package/typings/index.d.ts +55 -3
- package/typings/reporter.d.cts +13 -0
- package/typings/reporter.d.ts +13 -0
- package/typings/stats.d.cts +136 -0
- package/typings/stats.d.ts +136 -0
package/esm/index.js
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
import { getOperationAST, Kind, printSchema
|
|
1
|
+
import { getOperationAST, Kind, printSchema } from 'graphql';
|
|
2
2
|
import { isAsyncIterable, } from 'graphql-yoga';
|
|
3
|
-
import {
|
|
3
|
+
import { calculateReferencedFieldsByType, usageReportingSignature, } from '@apollo/utils.usagereporting';
|
|
4
4
|
import { useApolloInstrumentation, } from '@graphql-yoga/plugin-apollo-inline-trace';
|
|
5
|
-
import {
|
|
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
|
|
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$ =
|
|
43
|
-
|
|
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 =
|
|
61
|
-
?
|
|
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 =
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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';
|
package/esm/reporter.js
ADDED
|
@@ -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
|
+
}
|