Skip to content

feat(apollo-usage-report): report referenced feild by types #4020

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plain-eggs-win.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-yoga/plugin-apollo-usage-report': minor
---

:construction: Improve report with per field request count, trace batching and report compression
5 changes: 5 additions & 0 deletions .changeset/slimy-toys-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-yoga/plugin-apollo-usage-report': minor
---

Report referenced field by types
3 changes: 3 additions & 0 deletions packages/plugins/apollo-usage-report/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@
"graphql-yoga": "workspace:^"
},
"dependencies": {
"@apollo/server-gateway-interface": "^1.1.1",
"@apollo/usage-reporting-protobuf": "^4.1.1",
"@apollo/utils.usagereporting": "^2.1.0",
"@graphql-tools/utils": "^10.8.6",
"@graphql-yoga/plugin-apollo-inline-trace": "workspace:^",
"@whatwg-node/promise-helpers": "^1.2.4",
"tslib": "^2.8.1"
Expand Down
215 changes: 105 additions & 110 deletions packages/plugins/apollo-usage-report/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import { DocumentNode, getOperationAST, Kind, printSchema, stripIgnoredCharacters } from 'graphql';
import { DocumentNode, getOperationAST, GraphQLSchema, Kind } from 'graphql';
import {
isAsyncIterable,
YogaLogger,
YogaServer,
type FetchAPI,
type Maybe,
type Plugin,
type PromiseOrValue,
type YogaInitialContext,
} from 'graphql-yoga';
import { Report } from '@apollo/usage-reporting-protobuf';
import {
calculateReferencedFieldsByType,
usageReportingSignature,
} from '@apollo/utils.usagereporting';
import { printSchemaWithDirectives } from '@graphql-tools/utils';
import {
ApolloInlineGraphqlTraceContext,
ApolloInlineRequestTraceContext,
ApolloInlineTracePluginOptions,
useApolloInstrumentation,
} from '@graphql-yoga/plugin-apollo-inline-trace';
import { handleMaybePromise, MaybePromise } from '@whatwg-node/promise-helpers';
import { MaybePromise } from '@whatwg-node/promise-helpers';
import { getEnvVar, Reporter } from './reporter.js';

type ApolloUsageReportOptions = ApolloInlineTracePluginOptions & {
export type ApolloUsageReportOptions = ApolloInlineTracePluginOptions & {
/**
* The graph ref of the managed federation graph.
* It is composed of the graph ID and the variant (`<YOUR_GRAPH_ID>@<VARIANT>`).
Expand Down Expand Up @@ -55,24 +59,68 @@ type ApolloUsageReportOptions = ApolloInlineTracePluginOptions & {
* Client version to report to the usage reporting API
*/
clientVersion?: StringFromRequestFn;
/**
* The version of the runtime (like 'node v23.7.0')
* @default empty string.
*/
runtimeVersion?: string;
/**
* The hostname of the machine running this server
* @default $HOSTNAME environment variable
*/
hostname?: string;
/**
* The OS identification string.
* The format is `${os.platform()}, ${os.type()}, ${os.release()}, ${os.arch()})`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

navigator.platform etc can be used in platform independent way, maybe?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I didn't know node had navigator API.
The thing I don't know is if Apollo is parsing this field or not. If it's paring the string, then we can't use navigator.platform.

* @default empty string
*/
uname?: string;
/**
* The maximum estimated size of each traces in bytes. If the estimated size is higher than this threshold,
* the complete trace will not be sent and will be reduced to aggregated stats.
*
* Note: GraphOS only allow for traces of 10mb maximum
* @default 10 * 1024 * 1024 (10mb)
*/
maxTraceSize?: number;
/**
* The maximum uncompressed size of a report in bytes.
* The report will be sent once this threshold is reached, even if the delay between send is not
* yet expired.
*
* @default 4Mb
*/
maxBatchUncompressedSize?: number;
/**
* The maximum time in ms between reports.
* @default 20s
*/
maxBatchDelay?: number;
/**
* Control if traces should be always sent.
* If false, the traces will be batched until a delay or size is reached.
* Note: This is highly not recommended in a production environment
*
* @default false
*/
alwaysSend?: boolean;
/**
* Timeout in ms of a trace export tentative
* @default 30s
*/
exportTimeout?: number;
};

export interface ApolloUsageReportRequestContext extends ApolloInlineRequestTraceContext {
traces: Map<YogaInitialContext, ApolloUsageReportGraphqlContext>;
}

export interface ApolloUsageReportGraphqlContext extends ApolloInlineGraphqlTraceContext {
referencedFieldsByType: ReturnType<typeof calculateReferencedFieldsByType>;
operationKey?: string;
schemaId?: string;
}

function getEnvVar<T>(name: string, defaultValue?: T) {
return globalThis.process?.env?.[name] || defaultValue || undefined;
}

const DEFAULT_REPORTING_ENDPOINT =
'https://usage-reporting.api.apollographql.com/api/ingress/traces';

type StringFromRequestFn = (req: Request) => Maybe<string>;

export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Plugin {
Expand All @@ -82,8 +130,10 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl
];

let schemaIdSet$: MaybePromise<void> | undefined;
let schemaId: string;
let currentSchema: { id: string; schema: GraphQLSchema } | undefined;
let yoga: YogaServer<Record<string, unknown>, Record<string, unknown>>;
let reporter: Reporter;

const logger = Object.fromEntries(
(['error', 'warn', 'info', 'debug'] as const).map(level => [
level,
Expand All @@ -108,6 +158,7 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl
addPlugin({
onYogaInit(args) {
yoga = args.yoga;
reporter = new Reporter(options, yoga, logger);

if (!getEnvVar('APOLLO_KEY', options.apiKey)) {
throw new Error(
Expand All @@ -121,15 +172,17 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl
);
}
},

onSchemaChange({ schema }) {
if (schema) {
schemaIdSet$ = handleMaybePromise(
() => hashSHA256(printSchema(schema), yoga.fetchAPI),
id => {
schemaId = id;
schemaIdSet$ = hashSHA256(printSchemaWithDirectives(schema), yoga.fetchAPI)
.then(id => {
currentSchema = { id, schema };
schemaIdSet$ = undefined;
},
);
})
.catch(error => {
logger.error('Failed to calculate schema hash: ', error);
});
}
},

Expand All @@ -139,6 +192,9 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl

onParse() {
return function onParseEnd({ result, context }) {
if (!currentSchema) {
throw new Error("should not happen: schema doesn't exists");
}
const ctx = ctxForReq.get(context.request)?.traces.get(context);
if (!ctx) {
logger.debug(
Expand All @@ -150,12 +206,17 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl
const operationName =
context.params.operationName ??
(isDocumentNode(result) ? getOperationAST(result)?.name?.value : undefined);
const signature = context.params.query
? stripIgnoredCharacters(context.params.query)
: '';

const signature = operationName
? usageReportingSignature(result, operationName)
: (context.params.query ?? '');

ctx.referencedFieldsByType = calculateReferencedFieldsByType({
document: result,
schema: currentSchema.schema,
resolvedOperationName: operationName ?? null,
});
ctx.operationKey = `# ${operationName || '-'}\n${signature}`;
ctx.schemaId = schemaId;
ctx.schemaId = currentSchema!.id;
};
},

Expand All @@ -172,10 +233,6 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl
return;
}

// Each operation in a batched request can belongs to a different schema.
// Apollo doesn't allow to send batch queries for multiple schemas in the same batch
const tracesPerSchema: Record<string, Report['tracesPerQuery']> = {};

for (const trace of reqCtx.traces.values()) {
if (!trace.schemaId || !trace.operationKey) {
logger.debug('Misformed trace, missing operation key or schema id');
Expand All @@ -192,106 +249,44 @@ export function useApolloUsageReport(options: ApolloUsageReportOptions = {}): Pl
trace.trace.clientVersion = clientVersion;
}

tracesPerSchema[trace.schemaId] ||= {};
tracesPerSchema[trace.schemaId]![trace.operationKey] ||= { trace: [] };
tracesPerSchema[trace.schemaId]![trace.operationKey]!.trace?.push(trace.trace);
}

for (const schemaId in tracesPerSchema) {
const tracesPerQuery = tracesPerSchema[schemaId]!;
const agentVersion = options.agentVersion || `graphql-yoga@${yoga.version}`;
serverContext.waitUntil(
sendTrace(
options,
logger,
yoga.fetchAPI.fetch,
schemaId,
tracesPerQuery,
agentVersion,
),
reporter.addTrace(currentSchema!.id, {
statsReportKey: trace.operationKey,
trace: trace.trace,
referencedFieldsByType: trace.referencedFieldsByType,
asTrace: true, // TODO: allow to not always send traces
nonFtv1ErrorPaths: [],
maxTraceBytes: options.maxTraceSize,
}),
);
}
},
async onDispose() {
await reporter?.flush();
},
});
},
};
}

export function hashSHA256(
export async function hashSHA256(
text: string,
api: {
crypto: Crypto;
TextEncoder: (typeof globalThis)['TextEncoder'];
} = globalThis,
) {
const inputUint8Array = new api.TextEncoder().encode(text);
return handleMaybePromise(
() => api.crypto.subtle.digest({ name: 'SHA-256' }, inputUint8Array),
arrayBuf => {
const outputUint8Array = new Uint8Array(arrayBuf);

let hash = '';
for (const byte of outputUint8Array) {
const hex = byte.toString(16);
hash += '00'.slice(0, Math.max(0, 2 - hex.length)) + hex;
}
const arrayBuf = await api.crypto.subtle.digest({ name: 'SHA-256' }, inputUint8Array);
const outputUint8Array = new Uint8Array(arrayBuf);

return hash;
},
);
}

function sendTrace(
options: ApolloUsageReportOptions,
logger: YogaLogger,
fetch: FetchAPI['fetch'],
schemaId: string,
tracesPerQuery: Report['tracesPerQuery'],
agentVersion: string,
) {
const {
graphRef = getEnvVar('APOLLO_GRAPH_REF'),
apiKey = getEnvVar('APOLLO_KEY'),
endpoint = DEFAULT_REPORTING_ENDPOINT,
} = options;

const body = Report.encode({
header: {
agentVersion,
graphRef,
executableSchemaId: schemaId,
},
operationCount: 1,
tracesPerQuery,
}).finish();
return handleMaybePromise(
() =>
fetch(endpoint, {
method: 'POST',
headers: {
'content-type': 'application/protobuf',
// The presence of the api key is already checked at Yoga initialization time
let hash = '';
for (const byte of outputUint8Array) {
const hex = byte.toString(16);
hash += '00'.slice(0, Math.max(0, 2 - hex.length)) + hex;
}

'x-api-key': apiKey!,
accept: 'application/json',
},
body,
}),
response =>
handleMaybePromise(
() => response.text(),
responseText => {
if (response.ok) {
logger.debug('Traces sent:', responseText);
} else {
logger.error('Failed to send trace:', responseText);
}
},
),
err => {
logger.error('Failed to send trace:', err);
},
);
return hash;
}

function isDocumentNode(data: unknown): data is DocumentNode {
Expand Down
Loading