diff --git a/.changeset/cold-seals-play.md b/.changeset/cold-seals-play.md new file mode 100644 index 000000000..46b82427d --- /dev/null +++ b/.changeset/cold-seals-play.md @@ -0,0 +1,96 @@ +--- +'@envelop/prometheus': minor +--- + +Allow to explicitly control which events and timing should be observe. + +Each metric can now be configured to observe events and timings only for certain GraphQL pipeline +phases, or depending on the request context. + +## Example: trace only execution and subscription errors + +```ts +import { execute, parse, specifiedRules, subscribe, validate } from 'graphql' +import { envelop, useEngine } from '@envelop/core' +import { usePrometheus } from '@envelop/prometheus' + +const TRACKED_OPERATION_NAMES = [ + // make a list of operation that you want to monitor +] + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, specifiedRules, execute, subscribe }), + usePrometheus({ + metrics: { + // Here, an array of phases can be provided to enable the metric only on certain phases. + // In this example, only error happening during the execute and subscribe phases will tracked + graphql_envelop_phase_error: ['execute', 'subscribe'] + } + }), + ], +}) +``` + +## Example: Monitor timing only of a set of operations by name + +```ts +import { execute, parse, specifiedRules, subscribe, validate } from 'graphql' +import { envelop, useEngine } from '@envelop/core' +import { usePrometheus } from '@envelop/prometheus' + +const TRACKED_OPERATION_NAMES = [ + // make a list of operation that you want to monitor +] + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, specifiedRules, execute, subscribe }), + usePrometheus({ + metrics: { + graphql_yoga_http_duration: createHistogram({ + registry, + histogram: { + name: 'graphql_envelop_request_duration', + help: 'Time spent on HTTP connection', + labelNames: ['operationName'] + }, + fillLabelsFn: ({ operationName }, _rawContext) => ({ operationName, }), + phases: ['execute', 'subscribe'], + + // Here `shouldObserve` control if the request timing should be observed, based on context + shouldObserve: ({ operationName }) => TRACKED_OPERATIONS.includes(operationName), + }) + }, + }) + ] +}) +``` + +## Default Behavior Change + +A metric is enabled using `true` value in metrics options will observe in every +phases available. + +Previously, which phase was observe was depending on which other metric were enabled. For example, +this config would only trace validation error: + +```ts +usePrometheus({ + metrics: { + graphql_envelop_phase_error: true, + graphql_envelop_phase_validate: true, + }, +}) +``` + +This is no longer the case. If you were relying on this behavior, please use an array of string to +restrict observed phases. + +```ts +usePrometheus({ + metrics: { + graphql_envelop_phase_error: ['validate'], + }, +}) +``` \ No newline at end of file diff --git a/packages/plugins/prometheus/src/config.ts b/packages/plugins/prometheus/src/config.ts index 872b1b8b8..ce9d25252 100644 --- a/packages/plugins/prometheus/src/config.ts +++ b/packages/plugins/prometheus/src/config.ts @@ -1,5 +1,13 @@ +import type { GraphQLResolveInfo } from 'graphql'; import { Registry } from 'prom-client'; -import { createCounter, createHistogram, createSummary, type AtLeastOne } from './utils.js'; +import { + createCounter, + createHistogram, + createSummary, + type AtLeastOne, + type DeprecatedFieldInfo, + type FillLabelsFnParams, +} from './utils.js'; export type PrometheusTracingPluginConfig = { /** @@ -54,7 +62,7 @@ export type MetricsConfig = { * - string[]: Enable the metric on a list of phases * - ReturnType: Enable the metric with custom configuration */ - graphql_envelop_request?: CounterMetricOption>; + graphql_envelop_request?: CounterMetricOption<'execute' | 'subscribe'>; /** * Tracks the duration of the complete GraphQL operation execution. @@ -67,7 +75,7 @@ export type MetricsConfig = { * - number[]: Enable the metric with custom buckets * - ReturnType: Enable the metric with custom configuration */ - graphql_envelop_request_duration?: HistogramMetricOption>; + graphql_envelop_request_duration?: HistogramMetricOption<'execute' | 'subscribe'>; /** * Provides a summary of the time spent on the GraphQL operation execution. * It reports the same timing than graphql_envelop_request_duration but as a summary. @@ -78,7 +86,7 @@ export type MetricsConfig = { * - string[]: Enable the metric on a list of phases * - ReturnType: Enable the metric with custom configuration */ - graphql_envelop_request_time_summary?: SummaryMetricOption>; + graphql_envelop_request_time_summary?: SummaryMetricOption<'execute' | 'subscribe'>; /** * Tracks the duration of the parse phase of the GraphQL execution. * It reports the time spent parsing the incoming GraphQL operation. @@ -91,7 +99,7 @@ export type MetricsConfig = { * - number[]: Enable the metric with custom buckets * - ReturnType: Enable the metric with custom configuration */ - graphql_envelop_phase_parse?: HistogramMetricOption<['parse']>; + graphql_envelop_phase_parse?: HistogramMetricOption<'parse'>; /** * Tracks the duration of the validate phase of the GraphQL execution. * It reports the time spent validating the incoming GraphQL operation. @@ -104,7 +112,7 @@ export type MetricsConfig = { * - number[]: Enable the metric with custom buckets * - ReturnType: Enable the metric with custom configuration */ - graphql_envelop_phase_validate?: HistogramMetricOption<['validate']>; + graphql_envelop_phase_validate?: HistogramMetricOption<'validate'>; /** * Tracks the duration of the context phase of the GraphQL execution. * It reports the time spent building the context object that will be passed to the executors. @@ -117,7 +125,7 @@ export type MetricsConfig = { * - number[]: Enable the metric with custom buckets * - ReturnType: Enable the metric with custom configuration */ - graphql_envelop_phase_context?: HistogramMetricOption<['context']>; + graphql_envelop_phase_context?: HistogramMetricOption<'context'>; /** * Tracks the duration of the execute phase of the GraphQL execution. * It reports the time spent actually resolving the response of the incoming operation. @@ -131,7 +139,7 @@ export type MetricsConfig = { * - number[]: Enable the metric with custom buckets * - ReturnType: Enable the metric with custom configuration */ - graphql_envelop_phase_execute?: HistogramMetricOption<['execute']>; + graphql_envelop_phase_execute?: HistogramMetricOption<'execute'>; /** * This metric tracks the duration of the subscribe phase of the GraphQL execution. * It reports the time spent initiating a subscription (which doesn’t include actually sending the first response). @@ -144,7 +152,7 @@ export type MetricsConfig = { * - number[]: Enable the metric with custom buckets * - ReturnType: Enable the metric with custom configuration */ - graphql_envelop_phase_subscribe?: HistogramMetricOption<['subscribe']>; + graphql_envelop_phase_subscribe?: HistogramMetricOption<'subscribe'>; /** * This metric tracks the number of errors that returned by the GraphQL execution. * It counts all errors found in response, but it also includes errors from other GraphQL @@ -158,7 +166,12 @@ export type MetricsConfig = { * - ReturnType: Enable the metric with custom configuration */ graphql_envelop_error_result?: CounterMetricOption< - AtLeastOne<'parse' | 'validate' | 'context' | 'execute' | 'subscribe'> + 'parse' | 'validate' | 'context' | 'execute' | 'subscribe', + string, + FillLabelsFnParams & { + error: unknown; + errorPhase: 'parse' | 'validate' | 'context' | 'execute' | 'subscribe'; + } >; /** * This metric tracks the number of deprecated fields used in the GraphQL operation. @@ -170,7 +183,11 @@ export type MetricsConfig = { * - string[]: Enable the metric on a list of phases * - ReturnType: Enable the metric with custom configuration */ - graphql_envelop_deprecated_field?: CounterMetricOption<['parse']>; + graphql_envelop_deprecated_field?: CounterMetricOption< + 'parse', + string, + FillLabelsFnParams & { deprecationInfo: DeprecatedFieldInfo } + >; /** * This metric tracks the number of schema changes that have occurred since the gateway started. * If you are using a plugin that modifies the schema on the fly, @@ -184,7 +201,7 @@ export type MetricsConfig = { * - string[]: Enable the metric on a list of phases * - ReturnType: Enable the metric with custom configuration */ - graphql_envelop_schema_change?: CounterMetricOption<['schema']>; + graphql_envelop_schema_change?: CounterMetricOption<'schema', string, {}>; /** * This metric tracks the duration of each resolver execution. * @@ -199,7 +216,13 @@ export type MetricsConfig = { * - number[]: Enable the metric with custom buckets * - ReturnType: Enable the metric with custom configuration */ - graphql_envelop_execute_resolver?: HistogramMetricOption>; + graphql_envelop_execute_resolver?: HistogramMetricOption< + 'subscribe' | 'execute', + string, + FillLabelsFnParams & { + info: GraphQLResolveInfo; + } + >; }; export type LabelsConfig = { @@ -247,22 +270,34 @@ export type LabelsConfig = { phase?: boolean; }; -export type HistogramMetricOption = +export type HistogramMetricOption< + Phases, + LabelNames extends string = string, + Params extends Record = FillLabelsFnParams, +> = | boolean | string | BucketsConfig - | Phases - | ReturnType>; + | AtLeastOne + | ReturnType>; export type BucketsConfig = AtLeastOne; -export type CounterMetricOption = +export type CounterMetricOption< + Phases, + LabelNames extends string = string, + Params extends Record = FillLabelsFnParams, +> = | boolean | string - | Phases - | ReturnType>; + | AtLeastOne + | ReturnType>; -export type SummaryMetricOption = +export type SummaryMetricOption< + Phases, + LabelNames extends string = string, + Params extends Record = FillLabelsFnParams, +> = | boolean | string - | Phases - | ReturnType>; + | AtLeastOne + | ReturnType>; diff --git a/packages/plugins/prometheus/src/index.ts b/packages/plugins/prometheus/src/index.ts index 5ad379097..85c7520ab 100644 --- a/packages/plugins/prometheus/src/index.ts +++ b/packages/plugins/prometheus/src/index.ts @@ -6,17 +6,22 @@ import { isIntrospectionOperationString, OnContextBuildingHook, OnExecuteHook, - OnExecuteHookResult, OnParseHook, OnSubscribeHook, - OnSubscribeHookResult, OnValidateHook, Plugin, type OnEnvelopedHook, type OnPluginInitHook, + type OnSchemaChangeHook, } from '@envelop/core'; import { useOnResolve } from '@envelop/on-resolve'; -import { PrometheusTracingPluginConfig, type MetricsConfig } from './config.js'; +import { + CounterMetricOption, + HistogramMetricOption, + PrometheusTracingPluginConfig, + SummaryMetricOption, + type MetricsConfig, +} from './config.js'; import { createCounter, createFillLabelFnParams, @@ -41,6 +46,9 @@ export { HistogramAndLabels, PrometheusTracingPluginConfig, SummaryAndLabels, + HistogramMetricOption, + CounterMetricOption, + SummaryMetricOption, createCounter, createHistogram, createSummary, @@ -52,11 +60,39 @@ export { export const fillLabelsFnParamsMap = new WeakMap(); export const execStartTimeMap = new WeakMap(); +type PhaseHandler = {}, Params = FillLabelsFnParams> = { + shouldHandle: (params: Params, context: unknown) => boolean; + handler: ( + args: OtherArgs & { + params: Params; + context: unknown; + totalTime: number; + }, + ) => void; +}; + export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => { - let typeInfo: TypeInfo | null = null; config.registry = instrumentRegistry(config.registry || defaultRegistry); - const parseHistogram = getHistogramFromConfig<['parse'], MetricsConfig>( + const phasesToHook = { + parse: [] as PhaseHandler[], + validate: [] as PhaseHandler[], + context: [] as PhaseHandler[], + execute: { + end: [] as PhaseHandler[], + result: [] as PhaseHandler<{ result: ExecutionResult }>[], + }, + subscribe: { + end: [] as PhaseHandler[], + result: [] as PhaseHandler<{ result: ExecutionResult }>[], + error: [] as PhaseHandler<{ error: unknown }>[], + }, + pluginInit: [] as OnPluginInitHook>[], + enveloped: [] as OnEnvelopedHook>[], + schema: [] as OnSchemaChangeHook[], + }; + + const parseHistogram = getHistogramFromConfig<'parse', MetricsConfig>( config, 'graphql_envelop_phase_parse', ['parse'], @@ -64,7 +100,16 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => help: 'Time spent on running GraphQL "parse" function', }, ); - const validateHistogram = getHistogramFromConfig<['validate'], MetricsConfig>( + if (parseHistogram) { + phasesToHook.parse.push({ + shouldHandle: parseHistogram.shouldObserve, + handler: ({ params, context, totalTime }) => { + parseHistogram.histogram.observe(parseHistogram.fillLabelsFn(params, context), totalTime); + }, + }); + } + + const validateHistogram = getHistogramFromConfig<'validate', MetricsConfig>( config, 'graphql_envelop_phase_validate', ['validate'], @@ -72,7 +117,17 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => help: 'Time spent on running GraphQL "validate" function', }, ); - const contextBuildingHistogram = getHistogramFromConfig<['context'], MetricsConfig>( + if (validateHistogram) { + phasesToHook.validate.push({ + shouldHandle: validateHistogram.shouldObserve, + handler: ({ params, context, totalTime }) => { + const labels = validateHistogram.fillLabelsFn(params, context); + validateHistogram.histogram.observe(labels, totalTime); + }, + }); + } + + const contextBuildingHistogram = getHistogramFromConfig<'context', MetricsConfig>( config, 'graphql_envelop_phase_context', ['context'], @@ -80,7 +135,17 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => help: 'Time spent on building the GraphQL context', }, ); - const executeHistogram = getHistogramFromConfig<['execute'], MetricsConfig>( + if (contextBuildingHistogram) { + phasesToHook.context.push({ + shouldHandle: contextBuildingHistogram.shouldObserve, + handler: ({ params, context, totalTime }) => { + const labels = contextBuildingHistogram.fillLabelsFn(params, context); + contextBuildingHistogram.histogram.observe(labels, totalTime); + }, + }); + } + + const executeHistogram = getHistogramFromConfig<'execute', MetricsConfig>( config, 'graphql_envelop_phase_execute', ['execute'], @@ -88,7 +153,17 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => help: 'Time spent on running the GraphQL "execute" function', }, ); - const subscribeHistogram = getHistogramFromConfig<['subscribe'], MetricsConfig>( + if (executeHistogram) { + phasesToHook.execute.end.push({ + shouldHandle: executeHistogram.shouldObserve, + handler: ({ params, context, totalTime }) => { + const labels = executeHistogram.fillLabelsFn(params, context); + executeHistogram.histogram.observe(labels, totalTime); + }, + }); + } + + const subscribeHistogram = getHistogramFromConfig<'subscribe', MetricsConfig>( config, 'graphql_envelop_phase_subscribe', ['subscribe'], @@ -96,8 +171,17 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => help: 'Time spent on running the GraphQL "subscribe" function', }, ); + if (subscribeHistogram) { + phasesToHook.subscribe.end.push({ + shouldHandle: subscribeHistogram.shouldObserve, + handler: ({ params, context, totalTime }) => { + const labels = subscribeHistogram.fillLabelsFn(params, context); + subscribeHistogram.histogram.observe(labels, totalTime); + }, + }); + } - const resolversHistogram = getHistogramFromConfig<['execute', 'subscribe'], MetricsConfig>( + const resolversHistogram = getHistogramFromConfig<'execute' | 'subscribe', MetricsConfig>( config, 'graphql_envelop_execute_resolver', ['execute', 'subscribe'], @@ -114,8 +198,42 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => returnType: params.info?.returnType.toString()!, }), ); + if (resolversHistogram) { + phasesToHook.pluginInit.push(({ addPlugin }) => { + addPlugin( + useOnResolve(({ info, context }) => { + const phase = info.operation.operation === 'subscription' ? 'subscribe' : 'execute'; + + if ( + !resolversHistogram.phases?.includes(phase) || + !shouldTraceFieldResolver(info, config.resolversWhitelist) + ) { + return undefined; + } + + const fillLabelsFnParams = fillLabelsFnParamsMap.get(context); + const paramsCtx = { ...fillLabelsFnParams, info }; + + if (!resolversHistogram.shouldObserve(paramsCtx, context)) { + return undefined; + } + + const startTime = Date.now(); + + return () => { + const totalTime = (Date.now() - startTime) / 1000; + + resolversHistogram.histogram.observe( + resolversHistogram.fillLabelsFn(paramsCtx, context), + totalTime, + ); + }; + }), + ); + }); + } - const requestTotalHistogram = getHistogramFromConfig<['execute', 'subscribe'], MetricsConfig>( + const requestTotalHistogram = getHistogramFromConfig<'execute' | 'subscribe', MetricsConfig>( config, 'graphql_envelop_request_duration', ['execute', 'subscribe'], @@ -123,8 +241,20 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => help: 'Time spent on running the GraphQL operation from parse to execute', }, ); + if (requestTotalHistogram) { + const handler: PhaseHandler = { + shouldHandle: requestTotalHistogram.shouldObserve, + handler: ({ params, context, totalTime }) => { + const labels = requestTotalHistogram!.fillLabelsFn(params, context); + requestTotalHistogram!.histogram.observe(labels, totalTime); + }, + }; + for (const phase of requestTotalHistogram.phases) { + phasesToHook[phase].end.push(handler); + } + } - const requestSummary = getSummaryFromConfig<['execute', 'subscribe'], MetricsConfig>( + const requestSummary = getSummaryFromConfig<'execute' | 'subscribe', MetricsConfig>( config, 'graphql_envelop_request_time_summary', ['execute', 'subscribe'], @@ -132,9 +262,29 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => help: 'Summary to measure the time to complete GraphQL operations', }, ); + if (requestSummary) { + phasesToHook.enveloped.push(({ context }) => { + if (!execStartTimeMap.has(context)) { + execStartTimeMap.set(context, Date.now()); + } + }); + const handler: PhaseHandler = { + shouldHandle: (params, context) => + requestSummary.shouldObserve(params, context) && execStartTimeMap.has(context), + handler: ({ params, context }) => { + const execStartTime = execStartTimeMap.get(context); + const summaryTime = (Date.now() - execStartTime!) / 1000; + const labels = requestSummary!.fillLabelsFn(params, context); + requestSummary!.summary.observe(labels, summaryTime); + }, + }; + for (const phase of requestSummary.phases) { + phasesToHook[phase].end.push(handler); + } + } const errorsCounter = getCounterFromConfig< - ['parse', 'validate', 'context', 'execute', 'subscribe'], + 'parse' | 'validate' | 'context' | 'execute' | 'subscribe', MetricsConfig >( config, @@ -158,8 +308,72 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => return filterFillParamsFnParams(config, labels); }, ); + if (errorsCounter) { + (['parse', 'validate'] as const) + .filter(phase => errorsCounter.phases.includes(phase)) + .forEach(phase => { + phasesToHook[phase].push({ + shouldHandle: (params, context) => + !!params.errorPhase && errorsCounter.shouldObserve(params, context), + handler: ({ params, context }) => { + const labels = errorsCounter.fillLabelsFn(params, context); + errorsCounter?.counter.labels(labels).inc(); + }, + }); + }); + + (['execute', 'subscribe'] as const) + .filter(phase => errorsCounter.phases.includes(phase)) + .forEach(phase => { + phasesToHook[phase].result.push({ + shouldHandle: errorsCounter.shouldObserve, + handler: ({ result, params, context }) => { + if (!result.errors?.length) { + return; + } + for (const error of result.errors) { + const labelParams = { ...params, errorPhase: 'execute', error }; + + if (errorsCounter!.shouldObserve(labelParams, context)) { + errorsCounter!.counter + .labels(errorsCounter!.fillLabelsFn(labelParams, context)) + .inc(); + } + } + }, + }); + }); + + if (errorsCounter.phases.includes('subscribe')) { + phasesToHook.subscribe.error.push({ + shouldHandle: errorsCounter.shouldObserve, + handler: ({ params, context, error }) => { + const labels = errorsCounter.fillLabelsFn(params, context); + errorsCounter.counter.labels(labels).inc(); + }, + }); + } + + if (errorsCounter.phases.includes('context')) { + phasesToHook.pluginInit.push(({ registerContextErrorHandler }) => { + registerContextErrorHandler(({ context, error }) => { + const fillLabelsFnParams = fillLabelsFnParamsMap.get(context); + // FIXME: unsafe cast here, but it's ok, fillabelfn is doing duck typing anyway + const params = { + error: error as GraphQLError, + errorPhase: 'context', + ...fillLabelsFnParams, + }; + + if (errorsCounter.shouldObserve(params, context)) { + errorsCounter.counter.labels(errorsCounter?.fillLabelsFn(params, context)).inc(); + } + }); + }); + } + } - const reqCounter = getCounterFromConfig<['execute', 'subscribe'], MetricsConfig>( + const reqCounter = getCounterFromConfig<'execute' | 'subscribe', MetricsConfig>( config, 'graphql_envelop_request', ['execute', 'subscribe'], @@ -167,8 +381,19 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => help: 'Counts the amount of GraphQL requests executed through Envelop', }, ); + if (reqCounter) { + const handler: PhaseHandler = { + shouldHandle: reqCounter.shouldObserve, + handler: ({ params, context }) => { + reqCounter!.counter.labels(reqCounter!.fillLabelsFn(params, context)).inc(); + }, + }; + for (const phase of reqCounter.phases) { + phasesToHook[phase].end.push(handler); + } + } - const deprecationCounter = getCounterFromConfig<['parse'], MetricsConfig>( + const deprecationCounter = getCounterFromConfig<'parse', MetricsConfig>( config, 'graphql_envelop_deprecated_field', ['parse'], @@ -184,8 +409,36 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => typeName: params.deprecationInfo?.typeName!, }), ); + if (deprecationCounter) { + let typeInfo: TypeInfo | null = null; + phasesToHook.schema.push(({ schema }) => { + typeInfo = new TypeInfo(schema); + }); + + phasesToHook.parse.push({ + shouldHandle: (params, context) => + // If parse error happens, we can't explore the query document + !!typeInfo && !params.errorPhase && deprecationCounter.shouldObserve(params, context), + handler: ({ params, context }) => { + const deprecatedFields = extractDeprecatedFields(params.document!, typeInfo!); + + for (const depField of deprecatedFields) { + const deprecationLabelParams = { + ...params, + deprecationInfo: depField, + }; - const schemaChangeCounter = getCounterFromConfig<['schema'], MetricsConfig>( + if (deprecationCounter.shouldObserve(deprecationLabelParams, context)) { + deprecationCounter.counter + .labels(deprecationCounter.fillLabelsFn(deprecationLabelParams, context)) + .inc(); + } + } + }, + }); + } + + const schemaChangeCounter = getCounterFromConfig<'schema', MetricsConfig>( config, 'graphql_envelop_schema_change', ['schema'], @@ -195,24 +448,15 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => }, () => ({}), ); + if (schemaChangeCounter) { + const countedSchemas = new WeakSet(); - // parse is mandatory, because it sets up label params for the whole request. - const phasesToHook = new Set(['parse']); - for (const metric of [ - parseHistogram, - validateHistogram, - contextBuildingHistogram, - executeHistogram, - subscribeHistogram, - resolversHistogram, - requestTotalHistogram, - requestSummary, - errorsCounter, - reqCounter, - deprecationCounter, - schemaChangeCounter, - ]) { - metric?.phases.forEach(phase => phasesToHook.add(phase)); + phasesToHook.schema.push(({ schema }) => { + if (schemaChangeCounter?.shouldObserve({}, null) && !countedSchemas.has(schema)) { + schemaChangeCounter.counter.inc(); + countedSchemas.add(schema); + } + }); } const onParse: OnParseHook<{}> = ({ context, params }) => { @@ -228,41 +472,15 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => ); fillLabelsFnParamsMap.set(context, fillLabelsFnParams); - if (!fillLabelsFnParams) { - // means that we got a parse error - if (errorsCounter?.shouldObserve({ error: params.result, errorPhase: 'parse' }, context)) { - // TODO: use fillLabelsFn - errorsCounter?.counter.labels({ phase: 'parse' }).inc(); - } - // TODO: We should probably always report parse timing, error or not. - return; - } - - const totalTime = (Date.now() - startTime) / 1000; - - if (parseHistogram?.shouldObserve(fillLabelsFnParams, context)) { - parseHistogram?.histogram.observe( - parseHistogram.fillLabelsFn(fillLabelsFnParams, context), - totalTime, - ); - } - - if (deprecationCounter && typeInfo) { - const deprecatedFields = extractDeprecatedFields(fillLabelsFnParams.document!, typeInfo); - - for (const depField of deprecatedFields) { - const deprecationLabelParams = { - ...fillLabelsFnParams, - deprecationInfo: depField, - }; + const args = { + context, + totalTime: (Date.now() - startTime) / 1000, + params: fillLabelsFnParams ?? { error: params.result, errorPhase: 'parse' }, + }; - if (deprecationCounter.shouldObserve(deprecationLabelParams, context)) { - deprecationCounter.counter - .labels(deprecationCounter.fillLabelsFn(deprecationLabelParams, context)) - .inc(); - } - } - } + phasesToHook.parse + .filter(({ shouldHandle }) => shouldHandle(args.params, context)) + .forEach(({ handler }) => handler(args)); }; }; @@ -275,344 +493,174 @@ export const usePrometheus = (config: PrometheusTracingPluginConfig): Plugin => const startTime = Date.now(); return ({ valid }) => { - const totalTime = (Date.now() - startTime) / 1000; - let labels; - if (validateHistogram?.shouldObserve(fillLabelsFnParams, context)) { - labels = validateHistogram.fillLabelsFn(fillLabelsFnParams, context); - validateHistogram.histogram.observe(labels, totalTime); - } + const args = { + params: valid ? fillLabelsFnParams : { ...fillLabelsFnParams, errorPhase: 'validate' }, + context, + totalTime: (Date.now() - startTime) / 1000, + }; + phasesToHook.validate + .filter(({ shouldHandle }) => shouldHandle(args.params, context)) + .forEach(({ handler }) => handler(args)); - if ( - !valid && - errorsCounter?.phases.includes('validate') && - errorsCounter?.shouldObserve(fillLabelsFnParams, context) - ) { - // TODO: we should probably iterate over validation errors to report each error. - errorsCounter?.counter - // TODO: Use fillLabelsFn - .labels( - errorsCounter.fillLabelsFn({ ...fillLabelsFnParams, errorPhase: 'validate' }, context), - ) - .inc(); - } + // TODO: we should probably iterate over validation errors to report each error. }; }; const onContextBuilding: OnContextBuildingHook<{}> | undefined = ({ context }) => { const fillLabelsFnParams = fillLabelsFnParamsMap.get(context); - if ( - !fillLabelsFnParams || - !contextBuildingHistogram?.shouldObserve(fillLabelsFnParams, context) - ) { + if (!fillLabelsFnParams) { return; } const startTime = Date.now(); - return () => { - const totalTime = (Date.now() - startTime) / 1000; - contextBuildingHistogram.histogram.observe( - contextBuildingHistogram.fillLabelsFn(fillLabelsFnParams, context), - totalTime, - ); + const args = { + context, + params: fillLabelsFnParams, + totalTime: (Date.now() - startTime) / 1000, }; + + phasesToHook.context + .filter(({ shouldHandle }) => shouldHandle(fillLabelsFnParams, context)) + .forEach(({ handler }) => handler(args)); }; - const onExecute: OnExecuteHook<{}> | undefined = ({ args }) => { - const fillLabelsFnParams = fillLabelsFnParamsMap.get(args.contextValue); + const onExecute: OnExecuteHook<{}> = ({ args: { contextValue: context } }) => { + const fillLabelsFnParams = fillLabelsFnParamsMap.get(context); if (!fillLabelsFnParams) { return; } - const shouldObserveRequsets = reqCounter?.shouldObserve(fillLabelsFnParams, args.contextValue); - const shouldObserveExecute = executeHistogram?.shouldObserve( - fillLabelsFnParams, - args.contextValue, + const endHandlers = phasesToHook.execute.end.filter(({ shouldHandle }) => + shouldHandle(fillLabelsFnParams, context), ); - const shouldObserveRequestTotal = requestTotalHistogram?.shouldObserve( - fillLabelsFnParams, - args.contextValue, + const resultHandlers = phasesToHook.execute.result.filter(({ shouldHandle }) => + shouldHandle(fillLabelsFnParams, context), ); - const shouldObserveSummary = requestSummary?.shouldObserve( - fillLabelsFnParams, - args.contextValue, - ); - - const shouldHandleEnd = - shouldObserveRequsets || - shouldObserveExecute || - shouldObserveRequestTotal || - shouldObserveSummary; - const shouldHandleResult = errorsCounter !== undefined; - - if (!shouldHandleEnd && !shouldHandleResult) { - return; + if (endHandlers.length + resultHandlers.length === 0) { + return undefined; } const startTime = Date.now(); - if (shouldObserveRequsets) { - reqCounter?.counter - .labels(reqCounter.fillLabelsFn(fillLabelsFnParams, args.contextValue)) - .inc(); - } - - function handleResult(result: ExecutionResult) { - if (result.errors && result.errors.length > 0) { - for (const error of result.errors) { - const labelParams = { - ...fillLabelsFnParams, - errorPhase: 'execute', - error, - }; - if (errorsCounter!.shouldObserve(labelParams, args.contextValue)) { - errorsCounter!.counter - .labels(errorsCounter!.fillLabelsFn(labelParams, args.contextValue)) - .inc(); - } - } - } + function handleResult({ result }: { result: ExecutionResult }) { + const totalTime = (Date.now() - startTime) / 1000; + const args = { params: fillLabelsFnParams!, context, totalTime, result }; + resultHandlers.forEach(({ handler }) => handler(args)); } - const result: OnExecuteHookResult<{}> = { - onExecuteDone: ({ result }) => { - const handleEnd = () => { - const totalTime = (Date.now() - startTime) / 1000; - if (shouldObserveExecute) { - executeHistogram!.histogram.observe( - executeHistogram!.fillLabelsFn(fillLabelsFnParams, args.contextValue), - totalTime, - ); - } - - if (shouldObserveRequestTotal) { - requestTotalHistogram!.histogram.observe( - requestTotalHistogram!.fillLabelsFn(fillLabelsFnParams, args.contextValue), - totalTime, - ); - } - - if (shouldObserveSummary) { - const execStartTime = execStartTimeMap.get(args.contextValue); - if (execStartTime) { - const summaryTime = (Date.now() - execStartTime) / 1000; - - requestSummary!.summary.observe( - requestSummary!.fillLabelsFn(fillLabelsFnParams, args.contextValue), - summaryTime, - ); - } - } - }; + const handleEnd = () => { + const totalTime = (Date.now() - startTime) / 1000; + const args = { params: fillLabelsFnParams, context, totalTime }; + endHandlers.forEach(({ handler }) => handler(args)); + }; - if (!isAsyncIterable(result)) { - shouldHandleResult && handleResult(result); - shouldHandleEnd && handleEnd(); - return undefined; - } else { + return { + onExecuteDone: ({ result }) => { + if (isAsyncIterable(result)) { return { - onNext: shouldHandleResult - ? ({ result }) => { - handleResult(result); - } - : undefined, - onEnd: shouldHandleEnd ? handleEnd : undefined, + onNext: resultHandlers.length ? handleResult : undefined, + onEnd: endHandlers.length ? handleEnd : undefined, }; + } else { + handleResult({ result }); + handleEnd(); + return undefined; } }, }; - - return result; }; - const onSubscribe: OnSubscribeHook<{}> | undefined = ({ args }) => { - const fillLabelsFnParams = fillLabelsFnParamsMap.get(args.contextValue); + const onSubscribe: OnSubscribeHook<{}> = ({ args: { contextValue: context } }) => { + const fillLabelsFnParams = fillLabelsFnParamsMap.get(context); if (!fillLabelsFnParams) { - return undefined; + return; } - const shouldObserveRequsets = reqCounter?.shouldObserve(fillLabelsFnParams, args.contextValue); - const shouldObserveExecute = executeHistogram?.shouldObserve( - fillLabelsFnParams, - args.contextValue, + const endHandlers = phasesToHook.subscribe.end.filter(({ shouldHandle }) => + shouldHandle(fillLabelsFnParams, context), ); - const shouldObserveRequestTotal = requestTotalHistogram?.shouldObserve( - fillLabelsFnParams, - args.contextValue, + const resultHandlers = phasesToHook.subscribe.result.filter(({ shouldHandle }) => + shouldHandle(fillLabelsFnParams, context), ); - const shouldObserveSummary = requestSummary?.shouldObserve( - fillLabelsFnParams, - args.contextValue, + const errorHandlers = phasesToHook.subscribe.error.filter(({ shouldHandle }) => + shouldHandle(fillLabelsFnParams, context), ); - const shouldHandleEnd = - shouldObserveRequsets || - shouldObserveExecute || - shouldObserveRequestTotal || - shouldObserveSummary; - - const shouldHandleResult = errorsCounter !== undefined; - - const startTime = Date.now(); - if (shouldObserveRequsets) { - reqCounter?.counter - .labels(reqCounter.fillLabelsFn(fillLabelsFnParams, args.contextValue)) - .inc(); + if (endHandlers.length + resultHandlers.length + errorHandlers.length === 0) { + return undefined; } - function handleResult(result: ExecutionResult) { - if (errorsCounter && result.errors && result.errors.length > 0) { - for (const error of result.errors) { - errorsCounter.counter - .labels( - errorsCounter.fillLabelsFn( - { - ...fillLabelsFnParams, - errorPhase: 'execute', - error, - }, - args.contextValue, - ), - ) - .inc(); - } - } - } + const startTime = Date.now(); - if (!shouldHandleEnd && !shouldHandleResult) { - return; + function handleResult({ result }: { result: ExecutionResult }) { + const totalTime = (Date.now() - startTime) / 1000; + const args = { params: fillLabelsFnParams!, context, totalTime, result }; + resultHandlers.forEach(({ handler }) => handler(args)); } - const result: OnSubscribeHookResult<{}> = { - onSubscribeResult: ({ result }) => { - const handleEnd = () => { - const totalTime = (Date.now() - startTime) / 1000; - if (shouldObserveExecute) { - subscribeHistogram!.histogram.observe( - subscribeHistogram!.fillLabelsFn(fillLabelsFnParams, args.contextValue), - totalTime, - ); - } - if (shouldObserveRequestTotal) { - requestTotalHistogram?.histogram.observe( - requestTotalHistogram.fillLabelsFn(fillLabelsFnParams, args.contextValue), - totalTime, - ); - } + const handleEnd = () => { + const totalTime = (Date.now() - startTime) / 1000; + const args = { params: fillLabelsFnParams, context, totalTime }; + endHandlers.forEach(({ handler }) => handler(args)); + }; - if (shouldObserveSummary) { - const execStartTime = execStartTimeMap.get(args.contextValue); - if (execStartTime) { - const summaryTime = (Date.now() - execStartTime) / 1000; + const handleError = ({ error }: { error: unknown }) => { + const totalTime = (Date.now() - startTime) / 1000; + const args = { params: fillLabelsFnParams, context, totalTime, error }; + errorHandlers.forEach(({ handler }) => handler(args)); + }; - requestSummary!.summary.observe( - requestSummary!.fillLabelsFn(fillLabelsFnParams, args.contextValue), - summaryTime, - ); - } - } - }; - if (!isAsyncIterable(result)) { - shouldHandleResult && handleResult(result); - shouldHandleEnd && handleEnd(); - return undefined; - } else { + return { + onSubscribeResult: ({ result }) => { + if (isAsyncIterable(result)) { return { - onNext: shouldHandleResult - ? ({ result }) => { - handleResult(result); - } - : undefined, - onEnd: shouldHandleEnd ? handleEnd : undefined, + onNext: resultHandlers.length ? handleResult : undefined, + onEnd: endHandlers.length ? handleEnd : undefined, }; + } else { + handleResult({ result }); + handleEnd(); + return undefined; } }, + onSubscribeError: errorHandlers.length ? handleError : undefined, }; - - return result; }; - const onPluginInit: OnPluginInitHook<{}> = ({ addPlugin, registerContextErrorHandler }) => { - if (resolversHistogram) { - addPlugin( - useOnResolve(({ info, context }) => { - const shouldTrace = shouldTraceFieldResolver(info, config.resolversWhitelist); - - if (!shouldTrace) { - return undefined; - } - - const startTime = Date.now(); - - return () => { - const totalTime = (Date.now() - startTime) / 1000; - const fillLabelsFnParams = fillLabelsFnParamsMap.get(context); - const paramsCtx = { - ...fillLabelsFnParams, - info, - }; - resolversHistogram.histogram.observe( - resolversHistogram.fillLabelsFn(paramsCtx, context), - totalTime, - ); - }; - }), - ); - } - if (errorsCounter) { - registerContextErrorHandler(({ context, error }) => { - const fillLabelsFnParams = fillLabelsFnParamsMap.get(context); - if ( - errorsCounter.shouldObserve( - { error: error as GraphQLError, errorPhase: 'context', ...fillLabelsFnParamsMap }, - context, - ) - ) { - errorsCounter.counter - .labels( - errorsCounter?.fillLabelsFn( - // FIXME: unsafe cast here, but it's ok, fillabelfn is doing duck typing anyway - { ...fillLabelsFnParams, errorPhase: 'context', error: error as GraphQLError }, - context, - ), - ) - .inc(); - } - }); + const onPluginInit: OnPluginInitHook<{}> = payload => { + for (const handler of phasesToHook.pluginInit) { + handler(payload); } }; - const onEnveloped: OnEnvelopedHook<{}> = ({ context }) => { - if (!execStartTimeMap.has(context)) { - execStartTimeMap.set(context, Date.now()); + const onEnveloped: OnEnvelopedHook<{}> = payload => { + for (const handler of phasesToHook.enveloped) { + handler(payload); } }; - function hookIf(phase: string, hook: T): T | undefined { - if (phasesToHook.has(phase)) { - return hook; + const onSchemaChange: OnSchemaChangeHook = payload => { + for (const handler of phasesToHook.schema) { + handler(payload); } - return undefined; - } + }; - const countedSchemas = new WeakSet(); return { - onSchemaChange({ schema }) { - typeInfo = new TypeInfo(schema); - - if (schemaChangeCounter?.shouldObserve({}, null) && !countedSchemas.has(schema)) { - schemaChangeCounter.counter.inc(); - countedSchemas.add(schema); - } - }, - onEnveloped: hookIf('execute', onEnveloped) ?? hookIf('subscribe', onEnveloped), - onPluginInit: - errorsCounter?.phases.includes('context') || resolversHistogram ? onPluginInit : undefined, - onParse: hookIf('parse', onParse), - onValidate: hookIf('validate', onValidate), - onContextBuilding: hookIf('context', onContextBuilding), - onExecute: hookIf('execute', onExecute), - onSubscribe: hookIf('subscribe', onSubscribe), + onParse, // onParse is required, because it sets up the label params WeakMap + onSchemaChange: phasesToHook.schema.length ? onSchemaChange : undefined, + onPluginInit: phasesToHook.pluginInit.length ? onPluginInit : undefined, + onEnveloped: phasesToHook.enveloped.length ? onEnveloped : undefined, + onValidate: phasesToHook.validate.length ? onValidate : undefined, + onContextBuilding: phasesToHook.context.length ? onContextBuilding : undefined, + onExecute: + phasesToHook.execute.end.length + phasesToHook.execute.result.length ? onExecute : undefined, + onSubscribe: + phasesToHook.subscribe.end.length + + phasesToHook.subscribe.result.length + + phasesToHook.subscribe.error.length + ? onSubscribe + : undefined, }; }; diff --git a/packages/plugins/prometheus/src/utils.ts b/packages/plugins/prometheus/src/utils.ts index a90bfef8a..dc9141f76 100644 --- a/packages/plugins/prometheus/src/utils.ts +++ b/packages/plugins/prometheus/src/utils.ts @@ -89,14 +89,14 @@ export type ShouldObservePredicate> = ( ) => boolean; export type HistogramAndLabels< - Phases extends string[], + Phases, LabelNames extends string, Params extends Record, > = { histogram: Histogram; fillLabelsFn: FillLabelsFn; - phases: Phases; - shouldObserve: ShouldObservePredicate; + phases?: AtLeastOne; + shouldObserve?: ShouldObservePredicate; }; export function registerHistogram( @@ -120,7 +120,7 @@ export function registerHistogram( * @returns */ export function createHistogram< - Phases extends string[], + Phases, LabelNames extends string, Params extends Record = FillLabelsFnParams, >(options: { @@ -142,8 +142,10 @@ export function createHistogram< * * The possible values accepted in this list depends on the metric, * please refer to metric type or documentation to know which phases ar available. + * + * By default, all available phases are observed */ - phases: Phases; + phases?: AtLeastOne; /** * A function called for each event that can be observed. * If it is provided, an event will be observed only if it returns true. @@ -161,14 +163,14 @@ export function createHistogram< } export type SummaryAndLabels< - Phases extends string[], + Phases, LabelNames extends string, Params extends Record, > = { summary: Summary; fillLabelsFn: FillLabelsFn; - phases: Phases; - shouldObserve: ShouldObservePredicate; + phases?: AtLeastOne; + shouldObserve?: ShouldObservePredicate; }; export function registerSummary( @@ -192,7 +194,7 @@ export function registerSummary( * @returns */ export function createSummary< - Phases extends string[], + Phases, LabelNames extends string, Params extends Record = FillLabelsFnParams, >(options: { @@ -214,8 +216,10 @@ export function createSummary< * * The possible values accepted in this list depends on the metric, * please refer to metric type or documentation to know which phases ar available. + * + * By default, all available phases are observed */ - phases: Phases; + phases?: AtLeastOne; /** * A function called for each event that can be observed. * If it is provided, an event will be observed only if it returns true. @@ -233,14 +237,14 @@ export function createSummary< } export type CounterAndLabels< - Phases extends string[], + Phases, LabelNames extends string, Params extends Record, > = { counter: Counter; fillLabelsFn: FillLabelsFn; - phases: Phases; - shouldObserve: ShouldObservePredicate; + phases?: AtLeastOne; + shouldObserve?: ShouldObservePredicate; }; /** @@ -264,7 +268,7 @@ export function registerCounter( } export function createCounter< - Phases extends string[], + Phases, LabelNames extends string, Params extends Record = FillLabelsFnParams, >(options: { @@ -286,8 +290,10 @@ export function createCounter< * * The possible values accepted in this list depends on the metric, * please refer to metric type or documentation to know which phases ar available. + * + * By default, all available phases are observed */ - phases: Phases; + phases?: AtLeastOne; /** * A function called for each event that can be observed. * If it is provided, an event will be observed only if it returns true. @@ -300,29 +306,36 @@ export function createCounter< counter: registerCounter(options.registry, options.counter), fillLabelsFn: options.fillLabelsFn, phases: options.phases, - shouldObserve: options.shouldObserve ?? (() => true), + shouldObserve: options.shouldObserve, }; } export function getHistogramFromConfig< - Phases extends string[], + Phases, MetricOptions, Params extends Record = FillLabelsFnParams, >( config: PrometheusTracingPluginConfig, phase: keyof MetricOptions, - availablePhases: Phases, + availablePhases: AtLeastOne, histogram: Omit, 'registers' | 'name'>, fillLabelsFn: FillLabelsFn = params => ({ operationName: params.operationName!, operationType: params.operationType!, }), -): HistogramAndLabels | undefined { +): Required> | undefined { const metric = (config.metrics as MetricOptions)[phase]; + if (!metric) { + return undefined; + } let phases = availablePhases; - if (Array.isArray(metric) && metric.length === 0) { - if (isBucketsList(metric)) { + if (Array.isArray(metric)) { + if (metric.length === 0) { + throw TypeError( + `Bad value provided for 'metrics.${phase.toString()}': the array must contain at least one element`, + ); + } else if (isBucketsList(metric)) { histogram.buckets = metric; } else if (isPhasesList(metric)) { phases = filterAvailablePhases(metric, availablePhases); @@ -332,11 +345,14 @@ export function getHistogramFromConfig< ); } } else if (typeof metric === 'object') { - return metric as HistogramAndLabels; - } - - if (metric !== true) { - return undefined; + const customMetric = metric as unknown as HistogramAndLabels; + if (!customMetric.phases) { + customMetric.phases = availablePhases; + } + if (!customMetric.shouldObserve) { + customMetric.shouldObserve = () => true; + } + return customMetric as Required>; } return createHistogram({ @@ -350,29 +366,38 @@ export function getHistogramFromConfig< }, fillLabelsFn: (...args) => filterFillParamsFnParams(config, fillLabelsFn(...args)), phases, - }); + shouldObserve: () => true, + }) as Required>; } export function getSummaryFromConfig< - Phases extends string[], + Phases, MetricOptions, Params extends Record = FillLabelsFnParams, >( config: PrometheusTracingPluginConfig, phase: keyof MetricOptions, - availablePhases: Phases, + availablePhases: AtLeastOne, summary: Omit, 'registers' | 'name'>, fillLabelsFn: FillLabelsFn = params => filterFillParamsFnParams(config, { operationName: params.operationName!, operationType: params.operationType!, }), -): SummaryAndLabels | undefined { +): Required> | undefined { const metric = (config.metrics as MetricOptions)[phase]; + if (!metric) { + return undefined; + } + let phases = availablePhases; if (Array.isArray(metric)) { - if (isPhasesList(metric)) { + if (metric.length === 0) { + throw TypeError( + `Bad value provided for 'metrics.${phase.toString()}': the array must contain at least one element`, + ); + } else if (isPhasesList(metric)) { phases = filterAvailablePhases(metric, availablePhases); } else { throw new TypeError( @@ -380,11 +405,14 @@ export function getSummaryFromConfig< ); } } else if (typeof metric === 'object') { - return metric as SummaryAndLabels; - } - - if (metric !== true) { - return undefined; + const customMetric = metric as unknown as SummaryAndLabels; + if (!customMetric.phases) { + customMetric.phases = availablePhases; + } + if (!customMetric.shouldObserve) { + customMetric.shouldObserve = () => true; + } + return customMetric as Required>; } return createSummary({ @@ -396,29 +424,38 @@ export function getSummaryFromConfig< }, fillLabelsFn, phases, - }); + shouldObserve: () => true, + }) as Required>; } export function getCounterFromConfig< - Phases extends string[], + Phases, MetricOptions, Params extends Record = FillLabelsFnParams, >( config: PrometheusTracingPluginConfig, phase: keyof MetricOptions, - availablePhases: Phases, + availablePhases: AtLeastOne, counter: Omit, 'registers' | 'name'>, fillLabelsFn: FillLabelsFn = params => filterFillParamsFnParams(config, { operationName: params.operationName!, operationType: params.operationType!, }), -): CounterAndLabels | undefined { +): Required> | undefined { const metric = (config.metrics as MetricOptions)[phase]; let phases = availablePhases; + if (!metric) { + return undefined; + } + if (Array.isArray(metric)) { - if (isPhasesList(metric)) { + if (metric.length === 0) { + throw TypeError( + `Bad value provided for 'metrics.${phase.toString()}': the array must contain at least one element`, + ); + } else if (isPhasesList(metric)) { phases = filterAvailablePhases(metric, availablePhases); } else { throw new TypeError( @@ -426,11 +463,14 @@ export function getCounterFromConfig< ); } } else if (typeof metric === 'object') { - return metric as CounterAndLabels; - } - - if (metric !== true) { - return undefined; + const customMetric = metric as unknown as CounterAndLabels; + if (!customMetric.phases) { + customMetric.phases = availablePhases; + } + if (!customMetric.shouldObserve) { + customMetric.shouldObserve = () => true; + } + return customMetric as Required>; } return createCounter({ @@ -442,7 +482,8 @@ export function getCounterFromConfig< }, fillLabelsFn, phases, - }); + shouldObserve: () => true, + }) as Required>; } export function extractDeprecatedFields(node: ASTNode, typeInfo: TypeInfo): DeprecatedFieldInfo[] { @@ -523,9 +564,9 @@ function isBucketsList(list: any[]): list is number[] { function isPhasesList(list: any[]): list is string[] { return list.every(item => typeof item === 'string'); } -function filterAvailablePhases( +function filterAvailablePhases( phases: string[], - availablePhases: Phases, -): Phases { - return phases.filter(phase => availablePhases.includes(phase)) as Phases; + availablePhases: AtLeastOne, +): AtLeastOne { + return availablePhases.filter(phase => phases.includes(phase as string)) as AtLeastOne; } diff --git a/packages/plugins/prometheus/test/prom.spec.ts b/packages/plugins/prometheus/test/prom.spec.ts index e2ba97e3a..58f82c934 100644 --- a/packages/plugins/prometheus/test/prom.spec.ts +++ b/packages/plugins/prometheus/test/prom.spec.ts @@ -1,21 +1,42 @@ import { ASTNode, buildSchema, print as graphQLPrint } from 'graphql'; -import { Registry } from 'prom-client'; -import { useExtendContext } from '@envelop/core'; +import { Registry, type MetricConfiguration } from 'prom-client'; +import { Plugin, useExtendContext } from '@envelop/core'; import { assertSingleExecutionValue, createTestkit } from '@envelop/testing'; import { makeExecutableSchema } from '@graphql-tools/schema'; +import type { + CounterMetricOption, + HistogramMetricOption, + MetricsConfig, + SummaryMetricOption, +} from '../src/config.js'; import { createCounter, createHistogram, PrometheusTracingPluginConfig, usePrometheus, } from '../src/index.js'; -import { registerHistogram } from '../src/utils.js'; +import { createSummary, registerHistogram, type FillLabelsFnParams } from '../src/utils.js'; // Graphql.js 16 and 15 produce different results // Graphql.js 16 output has not trailing \n // In order to produce the same output we remove any trailing white-space const print = (ast: ASTNode) => graphQLPrint(ast).replace(/^\s+|\s+$/g, ''); +const allMetrics: { [Name in keyof MetricsConfig]-?: true } = { + graphql_envelop_deprecated_field: true, + graphql_envelop_error_result: true, + graphql_envelop_phase_context: true, + graphql_envelop_phase_execute: true, + graphql_envelop_phase_parse: true, + graphql_envelop_phase_subscribe: true, + graphql_envelop_phase_validate: true, + graphql_envelop_request: true, + graphql_envelop_request_duration: true, + graphql_envelop_request_time_summary: true, + graphql_envelop_schema_change: true, + graphql_envelop_execute_resolver: true, +}; + describe('Prom Metrics plugin', () => { const schema = makeExecutableSchema({ typeDefs: /* GraphQL */ ` @@ -67,13 +88,22 @@ describe('Prom Metrics plugin', () => { }, }); - function prepare(config: PrometheusTracingPluginConfig, registry: Registry = new Registry()) { + function prepare( + config: PrometheusTracingPluginConfig, + registry: Registry = new Registry(), + plugins: Plugin[] = [], + ) { const plugin = usePrometheus({ ...config, registry, }); + const teskit = createTestkit( - [plugin, useExtendContext(() => new Promise(resolve => setTimeout(resolve, 250)))], + [ + plugin, + useExtendContext(() => new Promise(resolve => setTimeout(resolve, 250))), + ...plugins, + ], schema, ); @@ -114,86 +144,120 @@ describe('Prom Metrics plugin', () => { }; } - it('integration', async () => { - const { execute, allMetrics } = prepare({ - metrics: { - graphql_envelop_error_result: true, - graphql_envelop_phase_execute: true, - graphql_envelop_phase_parse: true, - graphql_envelop_phase_validate: true, - graphql_envelop_phase_context: true, - graphql_envelop_deprecated_field: true, - graphql_envelop_execute_resolver: true, + function testHistogram( + metricName: MetricNames>, + phases: [string, ...string[]], + ) { + const histogramFactory = (config: object) => + //@ts-ignore + createHistogram({ + ...config, + histogram: { + name: metricName, + help: 'test', + labelNames: ['operationName', 'operationType'], + }, + }); + + it.each<{ name: string; config: PrometheusTracingPluginConfig }>([ + { + name: 'given a buckets list', + config: { metrics: { [metricName]: [0.5, 1, 5, 10] } }, }, + ...metricEnabledTestCases(metricName, phases, histogramFactory), + ])(`should monitor timing when $name`, async ({ config }) => { + const { execute, metricCount, metricString } = prepare(config, config.registry); + + const result = await execute('query { regularField }'); + assertSingleExecutionValue(result); + + expect(result.errors).toBeUndefined(); + expect(await metricCount(metricName, 'count')).toBe(1); + const metricReport = await metricString(metricName); + expect(metricReport).toContain(`operationName="Anonymous"`); + expect(metricReport).toContain(`operationType="query"`); }); - const result = await execute('query { regularField longField deprecatedField }'); - assertSingleExecutionValue(result); - expect(result.errors).toBeUndefined(); - const metricsStr = await allMetrics(); + it.each<{ name: string; config: PrometheusTracingPluginConfig }>( + metricDisabledTestCases(metricName, histogramFactory), + )('should not monitor parse timing when $name', async ({ config }) => { + const { execute, metricCount } = prepare(config, config.registry); - expect(metricsStr).toContain('graphql_envelop_phase_parse_count{'); - expect(metricsStr).toContain('graphql_envelop_phase_validate_count{'); - expect(metricsStr).toContain('graphql_envelop_phase_context_count{'); - expect(metricsStr).toContain('graphql_envelop_phase_execute_count{'); - expect(metricsStr).toContain('graphql_envelop_execute_resolver_count{'); - expect(metricsStr).toContain('graphql_envelop_deprecated_field{'); - expect(metricsStr).not.toContain('graphql_envelop_error_result{'); - }); + const result = await execute('query { regularField }'); + assertSingleExecutionValue(result); - describe('parse', () => { - it('Should trace error during parse', async () => { - const { execute, metricCount, metricString } = prepare({ + expect(result.errors).toBeUndefined(); + expect(await metricCount(metricName, 'count')).toBe(0); + }); + + it('should not contain operationName and operationType if disabled', async () => { + const { execute, metricString } = prepare({ metrics: { - graphql_envelop_error_result: true, + [metricName]: true, + }, + labels: { + operationName: false, + operationType: false, }, }); - const result = await execute('query {'); + + const result = await execute('query { regularField }'); assertSingleExecutionValue(result); - expect(result.errors?.length).toBe(1); - expect(await metricString('graphql_envelop_error_result')).toContain( - 'graphql_envelop_error_result{phase="parse"} 1', - ); - expect(await metricCount('graphql_envelop_error_result')).toBe(1); - expect(await metricCount('graphql_envelop_phase_parse')).toBe(0); + expect(result.errors).toBeUndefined(); + const metricReport = await metricString(metricName); + expect(metricReport).not.toContain('operationName="Anonymous"'); + expect(metricReport).not.toContain('operationType="query"'); }); - it('Should trace valid parse result', async () => { - const { execute, metricCount, metricString } = prepare({ - metrics: { - graphql_envelop_phase_parse: true, - }, - }); + it('should allow to use a custom name', async () => { + const { execute, metricCount } = prepare({ metrics: { [metricName]: 'metric_test' } }); + const result = await execute('query { regularField }'); assertSingleExecutionValue(result); expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_error_result')).toBe(0); - expect(await metricCount('graphql_envelop_phase_parse', 'count')).toBe(1); - expect(await metricString('graphql_envelop_phase_parse')).toContain( - `graphql_envelop_phase_parse_count{operationName=\"Anonymous\",operationType=\"query\"} 1`, - ); + expect(await metricCount('metric_test', 'count')).toBe(1); }); - it('Should skip parse when parse = false', async () => { - const { execute, metricCount } = prepare({ metrics: { graphql_envelop_phase_parse: false } }); + it('should allow to use a custom name within a custom config', async () => { + const registry = new Registry(); + const { execute, metricCount } = prepare( + { + metrics: { + [metricName]: createHistogram({ + registry, + histogram: { + name: 'metric_test', + help: 'test', + labelNames: ['operationName', 'operationType'], + }, + fillLabelsFn: params => ({ + operationName: params.operationName!, + operationType: params.operationType!, + }), + }), + }, + }, + registry, + ); + const result = await execute('query { regularField }'); assertSingleExecutionValue(result); expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_phase_parse')).toBe(0); + expect(await metricCount('metric_test', 'count')).toBe(1); }); - it('Should allow to use custom Histogram and custom labelNames', async () => { + it('should allow to use custom labelNames', async () => { const registry = new Registry(); const { execute, metricCount, metricString } = prepare( { metrics: { - graphql_envelop_phase_parse: createHistogram({ + [metricName]: createHistogram({ registry, histogram: { - name: 'test_parse', + name: metricName, help: 'HELP ME', labelNames: ['opText'] as const, }, @@ -202,7 +266,6 @@ describe('Prom Metrics plugin', () => { opText: print(params.document!), }; }, - phases: ['parse'], }), }, }, @@ -212,23 +275,121 @@ describe('Prom Metrics plugin', () => { assertSingleExecutionValue(result); expect(result.errors).toBeUndefined(); - expect(await metricCount('test_parse', 'count')).toBe(1); - expect(await metricString('test_parse')).toContain( - `test_parse_count{opText=\"{\\n regularField\\n}\"} 1`, + expect(await metricCount(metricName, 'count')).toBe(1); + expect(await metricString(metricName)).toContain(`opText="{\\n regularField\\n}"`); + }); + } + + function testSummary( + metricName: MetricNames>, + phases: [string, ...string[]], + ) { + const summaryFactory = (config: object) => + //@ts-ignore + createSummary({ + ...config, + summary: { + name: metricName, + help: 'test', + labelNames: ['operationName', 'operationType'], + }, + }); + + it.each<{ name: string; config: PrometheusTracingPluginConfig }>( + metricEnabledTestCases(metricName, phases, summaryFactory), + )(`should monitor timing when $name`, async ({ config }) => { + const { execute, metricCount, metricString } = prepare(config, config.registry); + + const result = await execute('query { regularField }'); + assertSingleExecutionValue(result); + + expect(result.errors).toBeUndefined(); + expect(await metricCount(metricName, 'count')).toBe(1); + const metricReport = await metricString(metricName); + expect(metricReport).toContain(`operationName="Anonymous"`); + expect(metricReport).toContain(`operationType="query"`); + }); + + it.each<{ name: string; config: PrometheusTracingPluginConfig }>( + metricDisabledTestCases(metricName, summaryFactory), + )('should not monitor parse timing when $name', async ({ config }) => { + const { execute, metricCount } = prepare(config, config.registry); + + const result = await execute('query { regularField }'); + assertSingleExecutionValue(result); + + expect(result.errors).toBeUndefined(); + expect(await metricCount(metricName, 'count')).toBe(0); + }); + + it('should not contain operationName and operationType if disabled', async () => { + const { execute, metricString } = prepare({ + metrics: { + [metricName]: true, + }, + labels: { + operationName: false, + operationType: false, + }, + }); + + const result = await execute('query { regularField }'); + assertSingleExecutionValue(result); + + expect(result.errors).toBeUndefined(); + const metricReport = await metricString(metricName); + expect(metricReport).not.toContain('operationName="Anonymous"'); + expect(metricReport).not.toContain('operationType="query"'); + }); + + it('should allow to use a custom name', async () => { + const { execute, metricCount } = prepare({ metrics: { [metricName]: 'metric_test' } }); + + const result = await execute('query { regularField }'); + assertSingleExecutionValue(result); + + expect(result.errors).toBeUndefined(); + expect(await metricCount('metric_test', 'count')).toBe(1); + }); + + it('should allow to use a custom name within a custom config', async () => { + const registry = new Registry(); + const { execute, metricCount } = prepare( + { + metrics: { + [metricName]: createSummary({ + registry, + summary: { + name: 'metric_test', + help: 'test', + labelNames: ['operationName', 'operationType'], + }, + fillLabelsFn: params => ({ + operationName: params.operationName!, + operationType: params.operationType!, + }), + }), + }, + }, + registry, ); + + const result = await execute('query { regularField }'); + assertSingleExecutionValue(result); + + expect(result.errors).toBeUndefined(); + expect(await metricCount('metric_test', 'count')).toBe(1); }); - }); - describe('validate', () => { - it('Should allow to use custom Histogram and custom labelNames', async () => { + it('should allow to use custom labelNames', async () => { const registry = new Registry(); const { execute, metricCount, metricString } = prepare( { metrics: { - graphql_envelop_phase_validate: createHistogram({ + [metricName]: createSummary({ registry, - histogram: { - name: 'test_validate', + summary: { + name: metricName, help: 'HELP ME', labelNames: ['opText'] as const, }, @@ -237,7 +398,6 @@ describe('Prom Metrics plugin', () => { opText: print(params.document!), }; }, - phases: ['validate'], }), }, }, @@ -247,107 +407,203 @@ describe('Prom Metrics plugin', () => { assertSingleExecutionValue(result); expect(result.errors).toBeUndefined(); - expect(await metricCount('test_validate', 'count')).toBe(1); - expect(await metricString('test_validate')).toContain( - `test_validate_count{opText=\"{\\n regularField\\n}\"} 1`, - ); + expect(await metricCount(metricName, 'count')).toBe(1); + expect(await metricString(metricName)).toContain(`opText="{\\n regularField\\n}"`); }); + } - it('should not register to onValidate event when not needed', () => { - expect( - prepare({ - metrics: { - graphql_envelop_phase_validate: false, - graphql_envelop_error_result: ['context', 'execute', 'parse', 'subscribe'], + function testCounter( + metricName: MetricNames>, + phases: [string, ...string[]], + query = 'query { regularField }', + ) { + const counterFactory = (config: object) => + //@ts-ignore + createCounter({ + ...config, + counter: { + name: metricName, + help: 'HELP ME', + labelNames: ['operationName', 'operationType'], + }, + }); - graphql_envelop_deprecated_field: true, - graphql_envelop_execute_resolver: true, - graphql_envelop_phase_context: true, - graphql_envelop_phase_execute: true, - graphql_envelop_phase_parse: true, - graphql_envelop_phase_subscribe: true, - graphql_envelop_request: true, - graphql_envelop_request_duration: true, - graphql_envelop_request_time_summary: true, - graphql_envelop_schema_change: true, - }, - }).plugin.onValidate, - ).toBeUndefined(); + it.each<{ name: string; config: PrometheusTracingPluginConfig }>( + metricEnabledTestCases(metricName, phases, counterFactory), + )(`should monitor timing when $name`, async ({ config }) => { + const { execute, metricCount, metricString } = prepare(config, config.registry); + + const result = await execute(query); + assertSingleExecutionValue(result); + + expect(result.errors).toBeUndefined(); + expect(await metricCount(metricName)).toBe(1); + const metricReport = await metricString(metricName); + expect(metricReport).toContain(`operationName="Anonymous"`); + expect(metricReport).toContain(`operationType="query"`); }); - it('Should trace error during validate, and also trace timing', async () => { - const { execute, metricCount, metricString } = prepare({ - metrics: { - graphql_envelop_phase_validate: true, - graphql_envelop_error_result: true, - }, - }); - const result = await execute('query test($v: String!) { regularField }'); + it.each<{ name: string; config: PrometheusTracingPluginConfig }>( + metricDisabledTestCases(metricName, counterFactory), + )('should not monitor parse timing when $name', async ({ config }) => { + const { execute, metricCount } = prepare(config, config.registry); + + const result = await execute(query); assertSingleExecutionValue(result); - expect(result.errors?.length).toBe(1); - expect(await metricString('graphql_envelop_error_result')).toContain( - 'graphql_envelop_error_result{operationName="test",operationType="query",phase="validate"} 1', - ); - expect(await metricCount('graphql_envelop_error_result')).toBe(1); - expect(await metricCount('graphql_envelop_phase_validate', 'count')).toBe(1); + expect(result.errors).toBeUndefined(); + expect(await metricCount(metricName)).toBe(0); }); - it('should trace error during validate, even when not tracing timing', async () => { - const { execute, metricCount, metricString } = prepare({ + it('should not contain operationName and operationType if disabled', async () => { + const { execute, metricString } = prepare({ metrics: { - graphql_envelop_error_result: true, + [metricName]: true, + }, + labels: { + operationName: false, + operationType: false, }, }); - const result = await execute('query test($v: String!) { regularField }'); + + const result = await execute(query); assertSingleExecutionValue(result); - expect(result.errors?.length).toBe(1); - expect(await metricString('graphql_envelop_error_result')).toContain( - 'graphql_envelop_error_result{operationName="test",operationType="query",phase="validate"} 1', - ); - expect(await metricCount('graphql_envelop_error_result')).toBe(1); - expect(await metricCount('graphql_envelop_phase_validate', 'count')).toBe(0); + expect(result.errors).toBeUndefined(); + const metricReport = await metricString(metricName); + expect(metricReport).not.toContain('operationName="Anonymous"'); + expect(metricReport).not.toContain('operationType="query"'); }); - it('Should trace valid validations result', async () => { - const { execute, metricCount, metricString } = prepare({ - metrics: { - graphql_envelop_phase_validate: true, - }, - }); - const result = await execute('query test { regularField }'); + it('should allow to use a custom name', async () => { + const { execute, metricCount } = prepare({ metrics: { [metricName]: 'metric_test' } }); + + const result = await execute(query); assertSingleExecutionValue(result); expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_phase_validate', 'count')).toBe(1); - expect(await metricString('graphql_envelop_phase_validate')).toContain( - `graphql_envelop_phase_validate_count{operationName=\"test\",operationType=\"query\"} 1`, + expect(await metricCount('metric_test')).toBe(1); + }); + + it('should allow to use a custom name within a custom config', async () => { + const registry = new Registry(); + const { execute, metricCount } = prepare( + { + metrics: { + [metricName]: createCounter({ + registry, + counter: { + name: 'metric_test', + help: 'test', + labelNames: ['operationName', 'operationType'], + }, + fillLabelsFn: params => ({ + operationName: params.operationName!, + operationType: params.operationType!, + }), + }), + }, + }, + registry, ); + + const result = await execute(query); + assertSingleExecutionValue(result); + + expect(result.errors).toBeUndefined(); + expect(await metricCount('metric_test')).toBe(1); }); - it('Should skip validate when validate = false', async () => { - const { execute, metricCount } = prepare({ - metrics: { graphql_envelop_phase_validate: false }, - }); - const result = await execute('query { regularField }'); + it('should allow to use custom labelNames', async () => { + const registry = new Registry(); + const { execute, metricCount, metricString } = prepare( + { + metrics: { + [metricName]: createCounter({ + registry, + counter: { + name: metricName, + help: 'HELP ME', + labelNames: ['opText'] as const, + }, + fillLabelsFn: () => { + return { + opText: query, + }; + }, + }), + }, + }, + registry, + ); + const result = await execute(query); assertSingleExecutionValue(result); expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_phase_validate')).toBe(0); + expect(await metricCount(metricName)).toBe(1); + expect(await metricString(metricName)).toContain(`opText="${query}"`); + }); + } + + it('integration', async () => { + const { execute, allMetrics } = prepare({ + metrics: { + graphql_envelop_error_result: true, + graphql_envelop_phase_execute: true, + graphql_envelop_phase_parse: true, + graphql_envelop_phase_validate: true, + graphql_envelop_phase_context: true, + graphql_envelop_deprecated_field: true, + graphql_envelop_execute_resolver: true, + }, }); + const result = await execute('query { regularField longField deprecatedField }'); + assertSingleExecutionValue(result); + + expect(result.errors).toBeUndefined(); + const metricsStr = await allMetrics(); + + expect(metricsStr).toContain('graphql_envelop_phase_parse_count{'); + expect(metricsStr).toContain('graphql_envelop_phase_validate_count{'); + expect(metricsStr).toContain('graphql_envelop_phase_context_count{'); + expect(metricsStr).toContain('graphql_envelop_phase_execute_count{'); + expect(metricsStr).toContain('graphql_envelop_execute_resolver_count{'); + expect(metricsStr).toContain('graphql_envelop_deprecated_field{'); + expect(metricsStr).not.toContain('graphql_envelop_error_result{'); }); - describe('contextBuilding', () => { - it('Should allow to use custom Histogram and custom labelNames', async () => { + it(`should limit its impact on perf by not adding unnecessary hooks`, () => { + const plugin = usePrometheus({ + metrics: {}, + }); + + const hooks = Object.entries(plugin) + .filter(([, value]) => value) + .map(([key]) => key); + + // onParse is the only required hook, it sets up the params for most metric labels + expect(hooks).toEqual(['onParse']); + }); + + describe('parse', () => { + it('should not allow empty arrays', () => { + expect( + // @ts-expect-error Empty array should be disallowed + () => usePrometheus({ metrics: { graphql_envelop_phase_parse: [] } }), + ).toThrow(); + }); + + testHistogram('graphql_envelop_phase_parse', ['parse']); + + it('should allow to use custom Histogram and custom labelNames', async () => { const registry = new Registry(); const { execute, metricCount, metricString } = prepare( { metrics: { - graphql_envelop_phase_context: createHistogram({ + graphql_envelop_phase_parse: createHistogram({ registry, histogram: { - name: 'test_context', + name: 'test_parse', help: 'HELP ME', labelNames: ['opText'] as const, }, @@ -356,7 +612,6 @@ describe('Prom Metrics plugin', () => { opText: print(params.document!), }; }, - phases: ['context'], }), }, }, @@ -366,125 +621,185 @@ describe('Prom Metrics plugin', () => { assertSingleExecutionValue(result); expect(result.errors).toBeUndefined(); - expect(await metricCount('test_context', 'count')).toBe(1); - expect(await metricString('test_context')).toContain( - `test_context_count{opText=\"{\\n regularField\\n}\"} 1`, + expect(await metricCount('test_parse', 'count')).toBe(1); + expect(await metricString('test_parse')).toContain( + `test_parse_count{opText=\"{\\n regularField\\n}\"} 1`, ); }); + }); - it('Should trace contextBuilding timing', async () => { - const { execute, metricCount } = prepare({ - metrics: { graphql_envelop_phase_context: true }, - }); + describe('validate', () => { + it('should not allow empty arrays', () => { + expect( + // @ts-expect-error Empty array should be disallowed + () => usePrometheus({ metrics: { graphql_envelop_phase_validate: [] } }), + ).toThrow(); + }); + + testHistogram('graphql_envelop_phase_validate', ['validate']); + + it('should allow to use custom Histogram and custom labelNames', async () => { + const registry = new Registry(); + const { execute, metricCount, metricString } = prepare( + { + metrics: { + graphql_envelop_phase_validate: createHistogram({ + registry, + histogram: { + name: 'test_validate', + help: 'HELP ME', + labelNames: ['opText'] as const, + }, + fillLabelsFn: params => { + return { + opText: print(params.document!), + }; + }, + }), + }, + }, + registry, + ); const result = await execute('query { regularField }'); assertSingleExecutionValue(result); expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_phase_context', 'count')).toBe(1); + expect(await metricCount('test_validate', 'count')).toBe(1); + expect(await metricString('test_validate')).toContain( + `test_validate_count{opText=\"{\\n regularField\\n}\"} 1`, + ); }); - it('Should skip contextBuilding when contextBuilding = false', async () => { + it('should trace timing even when an error occurs', async () => { const { execute, metricCount } = prepare({ - metrics: { graphql_envelop_phase_context: false }, + metrics: { + graphql_envelop_phase_validate: true, + }, }); - const result = await execute('query { regularField }'); + const result = await execute('query test($v: String!) { regularField }'); assertSingleExecutionValue(result); - expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_phase_context')).toBe(0); + expect(result.errors?.length).toBe(1); + expect(await metricCount('graphql_envelop_phase_validate', 'count')).toBe(1); }); + }); - it('should trace error and timing during contextBuilding', async () => { + describe('contextBuilding', () => { + it('should not allow empty arrays', () => { + expect( + // @ts-expect-error Empty array should be disallowed + () => usePrometheus({ metrics: { graphql_envelop_phase_context: [] } }), + ).toThrow(); + }); + + testHistogram('graphql_envelop_phase_context', ['context']); + + it('should allow to use custom Histogram and custom labelNames', async () => { const registry = new Registry(); - const testKit = createTestkit( - [ - usePrometheus({ - metrics: { - graphql_envelop_error_result: true, - graphql_envelop_phase_context: true, - }, - registry, - }), - useExtendContext(() => { - throw new Error('error'); - }), - ], - schema, - ); - try { - await testKit.execute('query { regularField }'); - } catch (e) {} - const metrics = await registry.getMetricsAsJSON(); - expect(metrics).toEqual([ - { - help: 'Time spent on building the GraphQL context', - name: 'graphql_envelop_phase_context', - type: 'histogram', - values: [], - aggregator: 'sum', - }, + const { execute, metricCount, metricString } = prepare( { - help: 'Counts the amount of errors reported from all phases', - name: 'graphql_envelop_error_result', - type: 'counter', - values: [ - { - labels: { - operationName: 'Anonymous', - operationType: 'query', - phase: 'context', + metrics: { + graphql_envelop_phase_context: createHistogram({ + registry, + histogram: { + name: 'test_context', + help: 'HELP ME', + labelNames: ['opText'] as const, }, - value: 1, - }, - ], - aggregator: 'sum', + fillLabelsFn: params => { + return { + opText: print(params.document!), + }; + }, + }), + }, }, - ]); + registry, + ); + const result = await execute('query { regularField }'); + assertSingleExecutionValue(result); + + expect(result.errors).toBeUndefined(); + expect(await metricCount('test_context', 'count')).toBe(1); + expect(await metricString('test_context')).toContain( + `test_context_count{opText=\"{\\n regularField\\n}\"} 1`, + ); }); - it('should trace error during contextBuilding', async () => { + it('should trace timing even when an error occurs', async () => { const registry = new Registry(); - const testKit = createTestkit( + const { execute, metricValue } = prepare( + { + metrics: { + graphql_envelop_phase_context: true, + }, + }, + registry, [ - usePrometheus({ - metrics: { - graphql_envelop_error_result: true, - }, - registry, - }), useExtendContext(() => { throw new Error('error'); }), ], - schema, ); + try { - await testKit.execute('query { regularField }'); + await execute('query { regularField }'); } catch (e) {} - const metrics = await registry.getMetricsAsJSON(); - expect(metrics).toEqual([ - { - help: 'Counts the amount of errors reported from all phases', - name: 'graphql_envelop_error_result', - type: 'counter', - values: [ - { - labels: { - operationName: 'Anonymous', - operationType: 'query', - phase: 'context', - }, - value: 1, - }, - ], - aggregator: 'sum', - }, - ]); + expect(await metricValue('graphql_envelop_phase_context', 'count')).toBe(1); }); + + // it('should trace error during contextBuilding', async () => { + // const registry = new Registry(); + // const testKit = createTestkit( + // [ + // usePrometheus({ + // metrics: { + // graphql_envelop_error_result: true, + // }, + // registry, + // }), + // useExtendContext(() => { + // throw new Error('error'); + // }), + // ], + // schema, + // ); + // try { + // await testKit.execute('query { regularField }'); + // } catch (e) {} + // const metrics = await registry.getMetricsAsJSON(); + // expect(metrics).toEqual([ + // { + // help: 'Counts the amount of errors reported from all phases', + // name: 'graphql_envelop_error_result', + // type: 'counter', + // values: [ + // { + // labels: { + // operationName: 'Anonymous', + // operationType: 'query', + // phase: 'context', + // }, + // value: 1, + // }, + // ], + // aggregator: 'sum', + // }, + // ]); + // }); }); describe('execute', () => { - it('Should allow to use custom Histogram and custom labelNames', async () => { + it('should not allow empty arrays', () => { + expect( + // @ts-expect-error Empty array should be disallowed + () => usePrometheus({ metrics: { graphql_envelop_phase_execute: [] } }), + ).toThrow(); + }); + + testHistogram('graphql_envelop_phase_execute', ['execute']); + + it('should allow to use custom Histogram and custom labelNames', async () => { const registry = new Registry(); const { execute, metricCount, metricString } = prepare( { @@ -501,7 +816,6 @@ describe('Prom Metrics plugin', () => { opText: print(params.document!), }; }, - phases: ['execute'], }), }, }, @@ -517,105 +831,209 @@ describe('Prom Metrics plugin', () => { ); }); - it('Should trace error during execute with a single error', async () => { - const { execute, metricCount, metricString } = prepare({ - metrics: { - graphql_envelop_error_result: true, - }, - }); - const result = await execute('query { errorField }'); - assertSingleExecutionValue(result); - - expect(result.errors?.length).toBe(1); - expect(await metricString('graphql_envelop_error_result')).toContain( - 'graphql_envelop_error_result{operationName="Anonymous",operationType="query",phase="execute",path="errorField"} 1', - ); - expect(await metricCount('graphql_envelop_error_result')).toBe(1); - }); - - it('Should trace error during execute with a multiple errors', async () => { + // it('should trace error during execute with a single error', async () => { + // const { execute, metricCount, metricString } = prepare({ + // metrics: { + // graphql_envelop_error_result: true, + // }, + // }); + // const result = await execute('query { errorField }'); + // assertSingleExecutionValue(result); + + // expect(result.errors?.length).toBe(1); + // expect(await metricString('graphql_envelop_error_result')).toContain( + // 'graphql_envelop_error_result{operationName="Anonymous",operationType="query",phase="execute",path="errorField"} 1', + // ); + // expect(await metricCount('graphql_envelop_error_result')).toBe(1); + // }); + + // it('should trace error during execute with a multiple errors', async () => { + // const { execute, metricCount, metricString } = prepare({ + // metrics: { + // graphql_envelop_error_result: true, + // }, + // }); + // const result = await execute('query { errorField test: errorField }'); + // assertSingleExecutionValue(result); + + // expect(result.errors?.length).toBe(2); + // expect(await metricString('graphql_envelop_error_result')).toContain( + // 'graphql_envelop_error_result{operationName="Anonymous",operationType="query",phase="execute",path="errorField"} 1', + // ); + // expect(await metricCount('graphql_envelop_error_result')).toBe(2); + // }); + + it('should trace timing even if an error occurs', async () => { const { execute, metricCount, metricString } = prepare({ metrics: { - graphql_envelop_error_result: true, + graphql_envelop_phase_execute: true, }, }); const result = await execute('query { errorField test: errorField }'); assertSingleExecutionValue(result); expect(result.errors?.length).toBe(2); - expect(await metricString('graphql_envelop_error_result')).toContain( - 'graphql_envelop_error_result{operationName="Anonymous",operationType="query",phase="execute",path="errorField"} 1', - ); - expect(await metricCount('graphql_envelop_error_result')).toBe(2); + expect(await metricCount('graphql_envelop_phase_execute', 'count')).toBe(1); }); + }); - it('Should trace error and timing during execute', async () => { - const { execute, metricCount, metricString } = prepare({ - metrics: { - graphql_envelop_error_result: true, - graphql_envelop_phase_execute: true, + describe('errors', () => { + it('should not allow empty arrays', () => { + expect( + // @ts-expect-error Empty array should be disallowed + () => usePrometheus({ metrics: { graphql_envelop_error_result: [] } }), + ).toThrow(); + }); + + const errorMetricFactory = (config: any) => + createCounter({ + ...config, + counter: { + name: 'graphql_envelop_error_result', + help: 'HELP ME', + labelNames: ['operationType', 'operationName', 'phase'], }, + fillLabelsFn: params => ({ + operationName: params.operationName!, + operationType: params.operationType!, + phase: params.errorPhase!, + }), }); - const result = await execute('query { errorField test: errorField }'); - assertSingleExecutionValue(result); - expect(result.errors?.length).toBe(2); - expect(await metricString('graphql_envelop_error_result')).toContain( - 'graphql_envelop_error_result{operationName="Anonymous",operationType="query",phase="execute",path="errorField"} 1', - ); - expect(await metricCount('graphql_envelop_error_result')).toBe(2); - expect(await metricCount('graphql_envelop_phase_execute', 'count')).toBe(1); + describe('parse errors', () => { + it.each( + metricEnabledTestCases('graphql_envelop_error_result', ['parse'], errorMetricFactory), + )('should count errors when $name', async ({ config }) => { + const { execute, metricCount, metricString } = prepare(config, config.registry); + const result = await execute('query {'); + assertSingleExecutionValue(result); + + expect(result.errors?.length).toBe(1); + expect(await metricCount('graphql_envelop_error_result')).toBe(1); + expect(await metricString('graphql_envelop_error_result')).toContain('phase="parse"'); + }); + + it.each( + metricDisabledTestCases('graphql_envelop_error_result', errorMetricFactory, [ + 'validate', + 'context', + 'execute', + 'subscribe', + ]), + )('should not count error when $name', async ({ config }) => { + const { execute, metricCount } = prepare(config, config.registry); + const result = await execute('query {'); + assertSingleExecutionValue(result); + + expect(result.errors?.length).toBe(1); + expect(await metricCount('graphql_envelop_error_result')).toBe(0); + }); }); - it('Should trace valid execute result', async () => { - const { execute, metricCount } = prepare({ - metrics: { - graphql_envelop_phase_execute: true, - }, + describe('validate errors', () => { + it.each( + metricEnabledTestCases('graphql_envelop_error_result', ['validate'], errorMetricFactory), + )('should count errors when $name', async ({ config }) => { + const { execute, metricCount, metricString } = prepare(config, config.registry); + const result = await execute('query test($v: String!) { regularField }'); + assertSingleExecutionValue(result); + + expect(result.errors?.length).toBe(1); + expect(await metricCount('graphql_envelop_error_result')).toBe(1); + const errorReport = await metricString('graphql_envelop_error_result'); + expect(errorReport).toContain('phase="validate"'); }); - const result = await execute('query { regularField }'); - assertSingleExecutionValue(result); - expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_phase_execute', 'count')).toBe(1); + it.each( + metricDisabledTestCases('graphql_envelop_error_result', errorMetricFactory, [ + 'parse', + 'context', + 'execute', + 'subscribe', + ]), + )('should not count error when $name', async ({ config }) => { + const { execute, metricCount } = prepare(config, config.registry); + const result = await execute('query test($v: String!) { regularField }'); + assertSingleExecutionValue(result); + + expect(result.errors?.length).toBe(1); + expect(await metricCount('graphql_envelop_error_result')).toBe(0); + }); }); - it('Should skip execute when execute = false', async () => { - const { execute, metricCount } = prepare({ - metrics: { - graphql_envelop_phase_execute: false, - }, + describe('context errors', () => { + it.each( + metricEnabledTestCases('graphql_envelop_error_result', ['context'], errorMetricFactory), + )('should count errors when $name', async ({ config }) => { + const { execute, metricCount, metricString } = prepare(config, config.registry, [ + useExtendContext(() => { + throw new Error('error'); + }), + ]); + + await expect(execute('query { regularField }')).rejects.toThrow(); + + expect(await metricCount('graphql_envelop_error_result')).toBe(1); + const errorReport = await metricString('graphql_envelop_error_result'); + expect(errorReport).toContain('phase="context"'); }); - const result = await execute('query { regularField }'); - assertSingleExecutionValue(result); - expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_phase_execute', 'count')).toBe(0); + it.each( + metricDisabledTestCases('graphql_envelop_error_result', errorMetricFactory, [ + 'parse', + 'validate', + 'execute', + 'subscribe', + ]), + )('should not count error when $name', async ({ config }) => { + const { execute, metricCount } = prepare(config, config.registry, [ + useExtendContext(() => { + throw new Error('error'); + }), + ]); + await expect(execute('query { regularField }')).rejects.toThrow(); + expect(await metricCount('graphql_envelop_error_result')).toBe(0); + }); }); - it('should not contain operationName and operationType if disables', async () => { - const { execute, metricString } = prepare({ - metrics: { - graphql_envelop_error_result: true, - graphql_envelop_phase_execute: true, - }, - labels: { - operationName: false, - operationType: false, - }, + describe('execute errors', () => { + it.each( + metricEnabledTestCases('graphql_envelop_error_result', ['execute'], errorMetricFactory), + )('should count errors when $name', async ({ config }) => { + const { execute, metricCount, metricString } = prepare( + config, + config.registry ?? new Registry(), + ); + const result = await execute('query { errorField }'); + assertSingleExecutionValue(result); + + expect(result.errors?.length).toBe(1); + expect(await metricCount('graphql_envelop_error_result')).toBe(1); + + const errorReport = await metricString('graphql_envelop_error_result'); + expect(errorReport).toContain('phase="execute"'); + expect(errorReport).toContain('operationName="Anonymous'); + expect(errorReport).toContain('operationType="query"'); }); - const result = await execute('query { regularField }'); - assertSingleExecutionValue(result); - expect(result.errors).toBeUndefined(); - expect(await metricString('graphql_envelop_phase_execute')).not.toContain( - ',operationName="Anonymous",operationType="query"', - ); + it.each( + metricDisabledTestCases('graphql_envelop_error_result', errorMetricFactory, [ + 'parse', + 'validate', + 'context', + 'subscribe', + ]), + )('should not count error when $name', async ({ config }) => { + const { execute, metricCount } = prepare(config, config.registry); + const result = await execute('query { errorField }'); + assertSingleExecutionValue(result); + + expect(result.errors?.length).toBe(1); + expect(await metricCount('graphql_envelop_error_result')).toBe(0); + }); }); - }); - describe('errors', () => { - it('Should allow to use custom Counter and custom labelNames', async () => { + it('should allow to use custom Counter and custom labelNames', async () => { const registry = new Registry(); const { execute, metricCount, metricString } = prepare( { @@ -633,7 +1051,6 @@ describe('Prom Metrics plugin', () => { errorMessage: params.error!.message, }; }, - phases: ['context', 'execute', 'parse', 'subscribe', 'validate'], }), }, }, @@ -648,47 +1065,35 @@ describe('Prom Metrics plugin', () => { `test_error{opText=\"{\\n errorField\\n}\",errorMessage=\"error\"} 1`, ); }); - - it('Should not trace parse errors when not needed', async () => { - const { execute, metricCount } = prepare({ - metrics: { - graphql_envelop_error_result: false, - }, - }); - const result = await execute('query {'); - assertSingleExecutionValue(result); - - expect(result.errors?.length).toBe(1); - expect(await metricCount('graphql_envelop_error_result')).toBe(0); - }); - - it('Should not trace validate errors when not needed', async () => { - const { execute, metricCount } = prepare({ - metrics: { - graphql_envelop_error_result: false, + it('should allow to use custom name', async () => { + const registry = new Registry(); + const { execute, metricCount, metricString } = prepare( + { + metrics: { + graphql_envelop_error_result: 'test_error', + }, }, - }); - const result = await execute('query test($v: String!) { regularField }'); - assertSingleExecutionValue(result); - - expect(result.errors?.length).toBe(1); - expect(await metricCount('graphql_envelop_error_result')).toBe(0); - }); - - it('Should not trace execute errors when not needed', async () => { - const { execute, metricCount } = prepare({ - metrics: { graphql_envelop_error_result: false }, - }); + registry, + ); const result = await execute('query { errorField }'); assertSingleExecutionValue(result); expect(result.errors?.length).toBe(1); - expect(await metricCount('graphql_envelop_error_result')).toBe(0); + expect(await metricCount('test_error')).toBe(1); }); }); describe('resolvers', () => { - it('Should trace all resolvers times correctly', async () => { + it('should not allow empty arrays', () => { + expect( + // @ts-expect-error Empty array should be disallowed + () => usePrometheus({ metrics: { graphql_envelop_error_result: [] } }), + ).toThrow(); + }); + + testHistogram('graphql_envelop_execute_resolver', ['execute']); + + it('should trace all resolvers times correctly', async () => { const { execute, metricCount, metricString } = prepare({ metrics: { graphql_envelop_execute_resolver: true, @@ -704,7 +1109,37 @@ describe('Prom Metrics plugin', () => { ); }); - it('Should trace only specified resolvers when resolversWhitelist is used', async () => { + it('should allow custom metric options', async () => { + const registry = new Registry(); + const { execute, metricCount, metricString, allMetrics } = prepare( + { + metrics: { + graphql_envelop_execute_resolver: createHistogram({ + registry, + fillLabelsFn: ({ document }) => ({ + opText: print(document!), + }), + histogram: { + name: 'graphql_envelop_execute_resolver', + help: 'test', + labelNames: ['opText'] as const, + }, + }), + }, + }, + registry, + ); + const result = await execute('query { regularField }'); + assertSingleExecutionValue(result); + + expect(result.errors).toBeUndefined(); + expect(await metricCount('graphql_envelop_execute_resolver', 'count')).toBe(1); + expect(await metricString('graphql_envelop_execute_resolver')).toContain( + 'graphql_envelop_execute_resolver_count{opText="{\\n regularField\\n}"} 1', + ); + }); + + it('should trace only specified resolvers when resolversWhitelist is used', async () => { const { execute, metricCount, metricString } = prepare({ metrics: { graphql_envelop_execute_resolver: true, @@ -723,33 +1158,16 @@ describe('Prom Metrics plugin', () => { }); describe('deprecation', () => { - it('Should not trace deprecation when not needed', async () => { - const { execute, metricCount } = prepare({ - metrics: { - graphql_envelop_deprecated_field: false, - }, - }); - const result = await execute('query { regularField deprecatedField }'); - assertSingleExecutionValue(result); - - expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_deprecated_field', 'count')).toBe(0); + it('should not allow empty arrays', () => { + expect( + // @ts-expect-error Empty array should be disallowed + () => usePrometheus({ metrics: { graphql_envelop_deprecated_field: [] } }), + ).toThrow(); }); - it('Should trace all deprecated fields times correctly', async () => { - const { execute, metricCount } = prepare({ - metrics: { - graphql_envelop_deprecated_field: true, - }, - }); - const result = await execute('query { regularField deprecatedField }'); - assertSingleExecutionValue(result); - - expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_deprecated_field')).toBe(1); - }); + testCounter('graphql_envelop_deprecated_field', ['parse'], 'query { deprecatedField }'); - it('Should track deprecated arguments in mutation', async () => { + it('should track deprecated arguments in mutation', async () => { const { execute, metricCount, allMetrics, metricString } = prepare({ metrics: { graphql_envelop_deprecated_field: true, @@ -782,33 +1200,29 @@ describe('Prom Metrics plugin', () => { }); describe('requestCount', () => { - it('Should not trace requestCount when not needed', async () => { - const { execute, metricCount } = prepare({ - metrics: { - graphql_envelop_request: false, - }, - }); - const result = await execute('query { regularField }'); - assertSingleExecutionValue(result); - - expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_request')).toBe(0); + it('should not allow empty arrays', () => { + expect( + // @ts-expect-error Empty array should be disallowed + () => usePrometheus({ metrics: { graphql_envelop_request: [] } }), + ).toThrow(); }); - it('Should trace all successful requests', async () => { + testCounter('graphql_envelop_request', ['execute', 'subscribe']); + + it('should not count requests when execute phase is disabled', async () => { const { execute, metricCount } = prepare({ metrics: { - graphql_envelop_request: true, + graphql_envelop_request: ['subscribe'], }, }); const result = await execute('query { regularField }'); assertSingleExecutionValue(result); expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_request')).toBe(1); + expect(await metricCount('graphql_envelop_request')).toBe(0); }); - it('Should trace all successful requests, with multiple req', async () => { + it('should trace all successful requests, with multiple req', async () => { const { execute, metricValue } = prepare({ metrics: { graphql_envelop_request: true, @@ -826,34 +1240,24 @@ describe('Prom Metrics plugin', () => { }); describe('requestSummary', () => { - it('Should not trace requestSummary when not needed', async () => { - const { execute, metricCount } = prepare({ - metrics: { - graphql_envelop_request_time_summary: false, - }, - }); - const result = await execute('query { regularField }'); - assertSingleExecutionValue(result); - - expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_request')).toBe(0); + it('should not allow empty arrays', () => { + expect( + // @ts-expect-error Empty array should be disallowed + () => usePrometheus({ metrics: { graphql_envelop_request_time_summary: [] } }), + ).toThrow(); }); - it('Should trace all successful requests', async () => { - const { execute, metricCount } = prepare({ - metrics: { - graphql_envelop_request_time_summary: true, - }, - }); - const result = await execute('query { regularField }'); - assertSingleExecutionValue(result); - - expect(result.errors).toBeUndefined(); - expect(await metricCount('graphql_envelop_request_time_summary', 'count')).toBe(1); - }); + testSummary('graphql_envelop_request_time_summary', ['execute', 'subscribe']); }); describe('schema', () => { + it('should not allow empty arrays', () => { + expect( + // @ts-expect-error Empty array should be disallowed + () => usePrometheus({ metrics: { graphql_envelop_schema_change: [] } }), + ).toThrow(); + }); + it('should capture graphql schema changing', async () => { const registry = new Registry(); createTestkit( @@ -946,3 +1350,130 @@ describe('Prom Metrics plugin', () => { expect(await allMetrics()).toContain('graphql_envelop_phase_parse_count{'); }); }); + +function metricEnabledTestCases( + metricName: MetricNames, + phases: [string, ...string[]], + factory: (config: object) => object, +): TestCase[] { + return [ + { + name: 'enabled alone', + config: { metrics: { [metricName]: true } }, + }, + { + name: 'enabled with all metrics', + config: { metrics: allMetrics }, + }, + { + name: 'given a list of phase', + config: { metrics: { [metricName]: phases } }, + }, + ((registry: Registry) => ({ + name: 'given a custom configuration', + config: { + registry, + metrics: { + [metricName]: factory({ + registry, + fillLabelsFn: (params: FillLabelsFnParams) => ({ + operationName: params.operationName!, + operationType: params.operationType!, + }), + }), + }, + }, + }))(new Registry()), + ((registry: Registry) => ({ + name: 'given a shouldObserve', + config: { + registry, + metrics: { + [metricName]: factory({ + registry, + fillLabelsFn: (params: FillLabelsFnParams) => ({ + operationName: params.operationName!, + operationType: params.operationType!, + }), + shouldObserve: () => true, + }), + }, + }, + }))(new Registry()), + ((registry: Registry) => ({ + name: 'given a custom config and phases', + config: { + registry, + metrics: { + [metricName]: factory({ + registry, + fillLabelsFn: (params: FillLabelsFnParams) => ({ + operationName: params.operationName!, + operationType: params.operationType!, + }), + phases, + }), + }, + }, + }))(new Registry()), + ]; +} + +function metricDisabledTestCases( + metricName: MetricNames, + factory: (config: object) => object, + phases?: [string, ...string[]], +): TestCase[] { + const cases: TestCase[] = [ + { + name: 'disabled with false', + config: { metrics: { [metricName]: false } }, + }, + { + name: 'disabled with undefined', + config: { metrics: { [metricName]: undefined } }, + }, + { + name: 'disabled with all metrics', + config: { metrics: { ...allMetrics, [metricName]: false } }, + }, + { + name: 'disabled with all metrics', + config: { metrics: { ...allMetrics, [metricName]: undefined } }, + }, + ((registry: Registry) => ({ + name: 'given a shouldObserve', + config: { + registry, + metrics: { + [metricName]: factory({ + registry, + fillLabelsFn: (params: FillLabelsFnParams) => ({ + operationName: params.operationName!, + operationType: params.operationType!, + }), + shouldObserve: () => false, + }), + }, + }, + }))(new Registry()), + ]; + if (phases) { + cases.push({ + name: `enabled phases are ${phases}`, + config: { metrics: { [metricName]: phases } }, + }); + } + return cases; +} + +type MetricNames = { + [K in keyof MetricsConfig]-?: [V] extends [MetricsConfig[K]] ? K : never; +}[keyof MetricsConfig]; + +type H = MetricNames>; + +type TestCase = { + name: string; + config: PrometheusTracingPluginConfig; +};