From 131dfa3f1b2e644501de4cfbc261d4b384bd20b1 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Tue, 26 Nov 2024 19:11:36 +0100 Subject: [PATCH] feat: [plugins/prometheus] allow to skip request or phases for a metric (#3489) * feat: [plugins/prometheus] allow to skip request or phases for a metric * changeset * chore(dependencies): updated changesets for modified dependencies * re-export new config types for Mesh * chore(dependencies): updated changesets for modified dependencies * fix: add request and response to fillLabelParams, because it's typed * fix changeset * chore(dependencies): updated changesets for modified dependencies * fix tests * remove unnecessary changeset * chore(dependencies): updated changesets for modified dependencies * step down change to minor * add documentation * remove unrelated generated files --------- Co-authored-by: github-actions[bot] --- ...oga_plugin-prometheus-3489-dependencies.md | 8 ++ .changeset/lazy-dancers-confess.md | 103 ++++++++++++++++++ packages/plugins/prometheus/package.json | 2 +- packages/plugins/prometheus/src/index.ts | 100 +++++++++++------ .../prometheus/tests/prometheus.spec.ts | 49 ++++++++- pnpm-lock.yaml | 10 +- .../src/pages/docs/features/monitoring.mdx | 75 +++++++++++++ 7 files changed, 305 insertions(+), 42 deletions(-) create mode 100644 .changeset/@graphql-yoga_plugin-prometheus-3489-dependencies.md create mode 100644 .changeset/lazy-dancers-confess.md diff --git a/.changeset/@graphql-yoga_plugin-prometheus-3489-dependencies.md b/.changeset/@graphql-yoga_plugin-prometheus-3489-dependencies.md new file mode 100644 index 0000000000..fe7341cbd3 --- /dev/null +++ b/.changeset/@graphql-yoga_plugin-prometheus-3489-dependencies.md @@ -0,0 +1,8 @@ +--- +'@graphql-yoga/plugin-prometheus': patch +--- +dependencies updates: + - Updated dependency + [`@envelop/prometheus@11.1.0-alpha-20241122091727-adade563355e3d213f27427a9a1d86adf9431d41` + ↗︎](https://www.npmjs.com/package/@envelop/prometheus/v/11.1.0) (from `11.0.0`, in + `dependencies`) diff --git a/.changeset/lazy-dancers-confess.md b/.changeset/lazy-dancers-confess.md new file mode 100644 index 0000000000..f45a756001 --- /dev/null +++ b/.changeset/lazy-dancers-confess.md @@ -0,0 +1,103 @@ +--- +'@graphql-yoga/plugin-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'], + }, +}) +``` + +## Deprecation + +The `fillLabelFn` function was provided the `response` and `request` through the `context` argument. + +This is now deprecated, `request` and `response` are now available in the first `params` argument. +This change allows to provide better typing, since `context` is not typed. \ No newline at end of file diff --git a/packages/plugins/prometheus/package.json b/packages/plugins/prometheus/package.json index 7c16514955..f8bbd976e4 100644 --- a/packages/plugins/prometheus/package.json +++ b/packages/plugins/prometheus/package.json @@ -42,7 +42,7 @@ "prom-client": "^15.0.0" }, "dependencies": { - "@envelop/prometheus": "11.0.0" + "@envelop/prometheus": "11.1.0-alpha-20241122091727-adade563355e3d213f27427a9a1d86adf9431d41" }, "devDependencies": { "graphql-yoga": "workspace:*", diff --git a/packages/plugins/prometheus/src/index.ts b/packages/plugins/prometheus/src/index.ts index 53b821d967..ee2246465a 100644 --- a/packages/plugins/prometheus/src/index.ts +++ b/packages/plugins/prometheus/src/index.ts @@ -1,8 +1,9 @@ -import { getOperationAST } from 'graphql'; +import { getOperationAST, type DocumentNode } from 'graphql'; import { Plugin } from 'graphql-yoga'; import { register as defaultRegistry } from 'prom-client'; import { CounterAndLabels, + CounterMetricOption, createCounter, createHistogram, createSummary, @@ -12,7 +13,9 @@ import { getHistogramFromConfig, getSummaryFromConfig, HistogramAndLabels, + HistogramMetricOption, SummaryAndLabels, + SummaryMetricOption, usePrometheus as useEnvelopPrometheus, } from '@envelop/prometheus'; @@ -27,6 +30,9 @@ export { getHistogramFromConfig, getCounterFromConfig, getSummaryFromConfig, + HistogramMetricOption, + CounterMetricOption, + SummaryMetricOption, }; export type PrometheusTracingPluginConfig = Omit< @@ -63,7 +69,7 @@ export type PrometheusTracingPluginConfig = Omit< * - number[]: Enable the metric with custom buckets * - ReturnType: Enable the metric with custom configuration */ - graphql_yoga_http_duration?: boolean | string | number[] | ReturnType; + graphql_yoga_http_duration?: HistogramMetricOption<'request', string, HTTPFillLabelParams>; }; labels?: { @@ -88,6 +94,12 @@ export type PrometheusTracingPluginConfig = Omit< endpoint?: string | boolean; }; +type HTTPFillLabelParams = FillLabelsFnParams & { + document: DocumentNode; + request: Request; + response: Response; +}; + const DEFAULT_METRICS_CONFIG: PrometheusTracingPluginConfig['metrics'] = { graphql_envelop_deprecated_field: true, graphql_envelop_request: true, @@ -115,11 +127,36 @@ export function usePrometheus(options: PrometheusTracingPluginConfig): Plugin { }, }; + const basePlugin: Plugin = { + onPluginInit({ addPlugin }) { + addPlugin(useEnvelopPrometheus({ ...resolvedOptions, registry }) as Plugin); + addPlugin({ + onRequest({ url, fetchAPI, endResponse }) { + if (endpoint && url.pathname === endpoint) { + return registry.metrics().then(metrics => { + endResponse( + new fetchAPI.Response(metrics, { + headers: { + 'Content-Type': registry.contentType, + }, + }), + ); + }); + } + return undefined; + }, + }); + }, + }; + const httpHistogram = getHistogramFromConfig< - NonNullable + 'request', + NonNullable, + HTTPFillLabelParams >( resolvedOptions, 'graphql_yoga_http_duration', + ['request'], { help: 'Time spent on HTTP connection', labelNames: ['operationName', 'operationType', 'method', 'statusCode', 'url'], @@ -133,51 +170,44 @@ export function usePrometheus(options: PrometheusTracingPluginConfig): Plugin { }), ); + // We don't need to register any hooks if the metric is not enabled + if (!httpHistogram) { + return basePlugin; + } + const startByRequest = new WeakMap(); - const paramsByRequest = new WeakMap(); + const paramsByRequest = new WeakMap(); return { - onPluginInit({ addPlugin }) { - addPlugin(useEnvelopPrometheus({ ...resolvedOptions, registry }) as Plugin); - }, - onRequest({ request, url, fetchAPI, endResponse }) { + ...basePlugin, + onRequest({ request }) { startByRequest.set(request, Date.now()); - if (endpoint && url.pathname === endpoint) { - return registry.metrics().then(metrics => { - endResponse( - new fetchAPI.Response(metrics, { - headers: { - 'Content-Type': registry.contentType, - }, - }), - ); - }); - } - return undefined; }, onParse() { - return ({ result: document, context: { params, request } }) => { - const operationAST = getOperationAST(document, params.operationName); - paramsByRequest.set(request, { + return ({ result: document, context }) => { + const operationAST = getOperationAST(document, context.params.operationName); + const params = { document, operationName: operationAST?.name?.value, operationType: operationAST?.operation, - }); + }; + + paramsByRequest.set(context.request, params); }; }, onResponse({ request, response, serverContext }) { const start = startByRequest.get(request); - if (start) { - const duration = (Date.now() - start) / 1000; - const params = paramsByRequest.get(request); - httpHistogram?.histogram.observe( - httpHistogram.fillLabelsFn(params || {}, { - ...serverContext, - request, - response, - }), - duration, - ); + const params = paramsByRequest.get(request); + if (start && params) { + const context = { ...serverContext, request, response }; + const completeParams: HTTPFillLabelParams = { ...params, request, response }; + + if (httpHistogram.shouldObserve(completeParams, context)) { + httpHistogram.histogram.observe( + httpHistogram.fillLabelsFn(completeParams, context), + (Date.now() - start) / 1000, + ); + } } }, }; diff --git a/packages/plugins/prometheus/tests/prometheus.spec.ts b/packages/plugins/prometheus/tests/prometheus.spec.ts index 232f4499e2..1f0e9c9c9e 100644 --- a/packages/plugins/prometheus/tests/prometheus.spec.ts +++ b/packages/plugins/prometheus/tests/prometheus.spec.ts @@ -1,6 +1,6 @@ import { createSchema, createYoga } from 'graphql-yoga'; import { register as registry } from 'prom-client'; -import { usePrometheus } from '@graphql-yoga/plugin-prometheus'; +import { createHistogram, usePrometheus } from '@graphql-yoga/plugin-prometheus'; describe('Prometheus', () => { const schema = createSchema({ @@ -97,6 +97,53 @@ describe('Prometheus', () => { expect(metrics).toContain('method="POST"'); expect(metrics).toContain('statusCode="200"'); }); + + it('should allow to skip a request', async () => { + const yoga = createYoga({ + schema, + plugins: [ + usePrometheus({ + metrics: { + graphql_yoga_http_duration: createHistogram({ + fillLabelsFn: (params, { request, response }) => ({ + method: request.method, + statusCode: response.status, + operationType: params.operationType || 'unknown', + operationName: params.operationName || 'Anonymous', + url: request.url, + }), + histogram: { + help: 'test', + name: 'graphql_yoga_http_duration', + }, + phases: ['request'], + registry, + shouldObserve: () => false, + }), + }, + registry, + }), + ], + }); + const result = await yoga.fetch('http://localhost:4000/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-test': 'test', + }, + body: JSON.stringify({ + query: /* GraphQL */ ` + query TestProm { + hello + } + `, + }), + }); + await result.text(); + const metrics = await registry.metrics(); + expect(metrics).toContain('graphql_yoga_http_duration_count 0'); + }); + it('labels should be excluded', async () => { const yoga = createYoga({ schema, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2448cae06a..f7b63037a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2008,8 +2008,8 @@ importers: packages/plugins/prometheus: dependencies: '@envelop/prometheus': - specifier: 11.0.0 - version: 11.0.0(@envelop/core@5.0.2)(graphql@16.8.1)(prom-client@15.1.3) + specifier: 11.1.0-alpha-20241122091727-adade563355e3d213f27427a9a1d86adf9431d41 + version: 11.1.0-alpha-20241122091727-adade563355e3d213f27427a9a1d86adf9431d41(@envelop/core@5.0.2)(graphql@16.8.1)(prom-client@15.1.3) graphql: specifier: 16.8.1 version: 16.8.1 @@ -3773,8 +3773,8 @@ packages: '@envelop/core': 5.0.2 graphql: 16.8.1 - '@envelop/prometheus@11.0.0': - resolution: {integrity: sha512-gNnq3kxyIGlMzc9Ze4Vc23UC/08PiXLlAOkPwzDqCW+gz5IM7D/9CJLp8Vn7SyRHTWmAgog5lHYLJlB00EMJ0A==} + '@envelop/prometheus@11.1.0-alpha-20241122091727-adade563355e3d213f27427a9a1d86adf9431d41': + resolution: {integrity: sha512-OnLv2M7sBkTSOrl31uJCVeO6W9qOeE1Wn4Jk61alq2RrrnCF+M+LunQw+7Jxztx690Dz3Wbkj6NHPlAFl8nv1A==} engines: {node: '>=18.0.0'} peerDependencies: '@envelop/core': 5.0.2 @@ -19982,7 +19982,7 @@ snapshots: '@envelop/core': 5.0.2 graphql: 16.8.1 - '@envelop/prometheus@11.0.0(@envelop/core@5.0.2)(graphql@16.8.1)(prom-client@15.1.3)': + '@envelop/prometheus@11.1.0-alpha-20241122091727-adade563355e3d213f27427a9a1d86adf9431d41(@envelop/core@5.0.2)(graphql@16.8.1)(prom-client@15.1.3)': dependencies: '@envelop/core': 5.0.2 '@envelop/on-resolve': 4.1.1(@envelop/core@5.0.2)(graphql@16.8.1) diff --git a/website/src/pages/docs/features/monitoring.mdx b/website/src/pages/docs/features/monitoring.mdx index 82a286cfbc..0b8b06f1cd 100644 --- a/website/src/pages/docs/features/monitoring.mdx +++ b/website/src/pages/docs/features/monitoring.mdx @@ -620,6 +620,81 @@ const getEnveloped = envelop({ }) ``` +### Mitigate hight volume of exported metrics + +In some cases, the large variety of label values can lead to a huge amount of metrics being +exported. To save bandwidth or storage, you can reduce the amount of reported metrics by multiple +ways. + +#### Monitor only some phases + +Some metrics observe events in multiple phases of the graphql pipeline. The metric with the highest +chance causing large amount of metrics is `graphql_envelop_error_result`, because it can contain +information specific to the error reported. + +You can lower the amount of reported errors by changing the phases monitored by this metric. + +```ts +import { execute, parse, specifiedRules, subscribe, validate } from 'graphql' +import { Registry } from 'prom-client' +import { envelop, useEngine } from '@envelop/core' + +const myRegistry = new Registry() + +const getEnveloped = envelop({ + plugins: [ + useEngine({ parse, validate, specifiedRules, execute, subscribe }), + usePrometheus({ + metrics: { + // To ignore parsing and validation error, and only monitor errors happening during + // resolvers executions, you can enable only the `execute` and `subscribe` phases + graphql_envelop_error_result: ['execute', 'subscribe'] + } + }) + ] +}) +``` + +#### Skip observation based on request context + +To save bandwidth or storage, you can reduce the amount of reported values by filtering which events +are observed based on the request context. + +For example, you can only monitor a subset of operations, because they are critical or that you want +to debug it's performance: + +```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_yoga_http_duration', + help: 'Time spent on HTTP connection', + labelNames: ['operation_name'] + }, + fillLabelsFn: ({ operationName }, _rawContext) => ({ + operation_name: operationName + }), + shouldObserve: context => TRACKED_OPERATIONS.includes(context?.params?.operationName) + }) + } + }) + ] +}) +``` + ## Caveats Due to Prometheus client API limitations, if this plugin is initialized multiple times, only the