@wundergraph/apollo-to-cosmo-metrics
Version:
An apollo gateway plugin that exports schema usage metrics to cosmo
243 lines • 9.79 kB
JavaScript
import { Kind, OperationTypeNode, } from 'graphql';
import Queue from '@esm2cjs/yocto-queue'; // eslint-disable-line @typescript-eslint/naming-convention
import { ArgumentUsageInfo, ClientInfo, InputUsageInfo, OperationInfo, OperationType, RequestInfo, SchemaInfo, SchemaUsageInfo, SchemaUsageInfoAggregation, TypeFieldUsageInfo, } from '../generated/graphqlmetrics/v1/graphqlmetrics_pb.js';
const CLIENT_NAME_HEADER = 'apollographql-client-name';
const CLIENT_VERSION_HEADER = 'apollographql-client-version';
const INTERVAL_TO_FLASH_IN_MS = 20_000;
const DEFAULT_MAX_QUEUE_SIZE = 10_000;
let isReadingReports = false;
let reportQueue;
export function cosmoReportPlugin(client, reportIntervalMs = INTERVAL_TO_FLASH_IN_MS, maxQueueSize = DEFAULT_MAX_QUEUE_SIZE) {
const cosmoClient = client;
let interval;
return {
async serverWillStart() {
reportQueue = new Queue();
interval = setInterval(async () => processReports(cosmoClient), reportIntervalMs);
return {
async serverWillStop() {
clearInterval(interval);
await processReports(cosmoClient);
},
};
},
async requestDidStart() {
return {
async executionDidStart(context) {
// Should we report all operations?
if (context.operationName === 'IntrospectionQuery') {
return;
}
try {
const metrics = collectMetrics(context, context.operation.selectionSet);
enqueueMetrics(context, metrics);
if (reportQueue.size >= maxQueueSize) {
void processReports(cosmoClient);
clearInterval(interval);
interval = setInterval(async () => processReports(cosmoClient), reportIntervalMs);
}
}
catch (error) {
const query = context.source.replaceAll(/(\r\n|\n|\r)/gm, '');
const { variables } = context.request;
const errorMessage = error instanceof Error ? error.message : String(error);
console.error({ errorMessage, query, variables }, 'Cosmo Usage Report Plugin has failed on query');
}
},
};
},
};
}
async function processReports(cosmoClient) {
if (isReadingReports) {
return;
}
isReadingReports = true;
const reports = [];
while (reportQueue.size > 0) {
const dequeuedItem = reportQueue.dequeue();
if (dequeuedItem) {
reports.push(dequeuedItem);
}
}
if (reports.length === 0) {
isReadingReports = false;
return;
}
isReadingReports = false;
await cosmoClient.reportMetrics(reports);
}
function enqueueMetrics(context, metrics) {
const operationInfo = new OperationInfo({
Hash: context.queryHash,
Name: context.operationName,
Type: toCosmoOperationType(context.operation.operation),
});
const requestInfo = new RequestInfo({
StatusCode: 200,
error: false,
});
const reportMessage = new SchemaUsageInfoAggregation({
SchemaUsage: new SchemaUsageInfo({
RequestDocument: context.source.replaceAll(/(\r\n|\n|\r)/gm, ''),
TypeFieldMetrics: metrics.fields,
ArgumentMetrics: metrics.args,
InputMetrics: metrics.inputs,
OperationInfo: operationInfo,
ClientInfo: getClientInfo(context.request),
RequestInfo: requestInfo,
SchemaInfo: new SchemaInfo({ Version: 'v1' }),
}),
RequestCount: BigInt(1),
});
reportQueue.enqueue(reportMessage);
}
function getClientInfo(request) {
const clientName = request.http?.headers.get(CLIENT_NAME_HEADER);
const clientVersion = request.http?.headers.get(CLIENT_VERSION_HEADER);
return new ClientInfo({
Name: clientName,
Version: clientVersion,
});
}
function toCosmoOperationType(operation) {
switch (operation) {
case OperationTypeNode.QUERY: {
return OperationType.QUERY;
}
case OperationTypeNode.MUTATION: {
return OperationType.MUTATION;
}
case OperationTypeNode.SUBSCRIPTION: {
return OperationType.SUBSCRIPTION;
}
}
}
function collectMetrics(context, selectionSet, path = [], objectType = undefined) {
const metrics = new RequestMetrics();
if (!selectionSet)
return metrics;
for (const selection of selectionSet.selections) {
if (selection.kind === Kind.FIELD && selection.name.value !== '__typename') {
const updatedPath = [...path, selection.name.value];
let namedType;
let typeName;
if (objectType) {
const fieldDef = objectType.astNode.fields.find((f) => f.name.value === selection.name.value);
namedType = inferNamedType(fieldDef.type);
typeName = objectType.name;
}
else {
const { operation } = context.operation;
const rootFieldDef = operation === OperationTypeNode.QUERY
? context.schema.getQueryType()?.getFields()[selection.name.value]
: context.schema.getMutationType()?.getFields()[selection.name.value];
if (!rootFieldDef)
return metrics;
namedType = inferNamedType(rootFieldDef.astNode.type);
typeName = capitalize(context.operation.operation.toString());
}
metrics.fields.push(new TypeFieldUsageInfo({
Path: updatedPath,
TypeNames: [typeName],
NamedType: namedType,
Count: BigInt(1),
}));
if (selection.arguments?.length) {
metrics.append(collectArguments(context, updatedPath, selection, typeName));
}
metrics.append(collectMetrics(context, selection.selectionSet, updatedPath, context.schema.getType(namedType)));
}
else if (selection.kind === Kind.INLINE_FRAGMENT && selection.typeCondition) {
const namedType = selection.typeCondition.name.value;
metrics.append(collectMetrics(context, selection.selectionSet, path, context.schema.getType(namedType)));
}
}
return metrics;
}
function collectArguments(context, path, fieldNode, typeName) {
const metrics = new RequestMetrics();
if (!fieldNode.arguments)
return metrics;
const typenameObject = context.schema.getType(typeName);
const fieldName = path.at(-1);
const fieldDef = typenameObject.astNode.fields.find((f) => f.name.value === fieldName);
for (const argument of fieldNode.arguments) {
const argDef = fieldDef.arguments.find((arg) => arg.name.value === argument.name.value);
if (argDef) {
metrics.args.push(new ArgumentUsageInfo({
Path: [...path, argument.name.value],
TypeName: typeName,
NamedType: inferNamedType(argDef.type),
Count: BigInt(1),
}));
metrics.inputs.push(...collectInputs(argument, argDef, path, context));
}
}
return metrics;
}
function collectInputs(argument, argumentDef, currentPath, context) {
const inputMetrics = [];
const inputPath = currentPath.length > 1 ? [currentPath.at(-1)] : [];
if (argument.value.kind === Kind.VARIABLE) {
// Handle variable input
const variableDef = context.operation.variableDefinitions.find((v) => v.variable.name.value === argument.value.name.value);
const variableNamedType = inferNamedType(variableDef.type);
const varNamedTypeDef = context.schema.getType(variableNamedType);
// Object input
if (varNamedTypeDef.astNode?.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) {
if (context.request.variables) {
const varObjFields = varNamedTypeDef.astNode.fields;
for (const varField of varObjFields) {
inputMetrics.push(new InputUsageInfo({
Path: [variableNamedType, varField.name.value],
TypeName: variableNamedType,
NamedType: inferNamedType(varField.type),
}));
}
}
}
else {
// Input scalar
inputMetrics.push(new InputUsageInfo({
Path: [...inputPath, argument.name.value],
NamedType: variableNamedType,
Count: BigInt(1),
}));
}
}
else {
// Inline input
inputMetrics.push(new InputUsageInfo({
Path: [...inputPath, argument.name.value],
NamedType: inferNamedType(argumentDef.type),
Count: BigInt(1),
}));
}
return inputMetrics;
}
function inferNamedType(node) {
switch (node.kind) {
case Kind.NON_NULL_TYPE:
case Kind.LIST_TYPE: {
return inferNamedType(node.type);
}
case Kind.NAMED_TYPE: {
return node.name.value;
}
}
}
function capitalize(word) {
return word.at(0).toUpperCase() + word.slice(1);
}
class RequestMetrics {
fields = [];
args = [];
inputs = [];
append(metrics) {
this.fields.push(...metrics.fields);
this.args.push(...metrics.args);
this.inputs.push(...metrics.inputs);
}
}
//# sourceMappingURL=exporter.js.map