Skip to content

Commit

Permalink
feat: [plugins/prometheus] allow to skip request or phases for a metr…
Browse files Browse the repository at this point in the history
…ic (#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] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
EmrysMyrddin and github-actions[bot] authored Nov 26, 2024
1 parent cb29c6c commit 131dfa3
Show file tree
Hide file tree
Showing 7 changed files with 305 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -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`)
103 changes: 103 additions & 0 deletions .changeset/lazy-dancers-confess.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/plugins/prometheus/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
100 changes: 65 additions & 35 deletions packages/plugins/prometheus/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,7 +13,9 @@ import {
getHistogramFromConfig,
getSummaryFromConfig,
HistogramAndLabels,
HistogramMetricOption,
SummaryAndLabels,
SummaryMetricOption,
usePrometheus as useEnvelopPrometheus,
} from '@envelop/prometheus';

Expand All @@ -27,6 +30,9 @@ export {
getHistogramFromConfig,
getCounterFromConfig,
getSummaryFromConfig,
HistogramMetricOption,
CounterMetricOption,
SummaryMetricOption,
};

export type PrometheusTracingPluginConfig = Omit<
Expand Down Expand Up @@ -63,7 +69,7 @@ export type PrometheusTracingPluginConfig = Omit<
* - number[]: Enable the metric with custom buckets
* - ReturnType<typeof createHistogram>: Enable the metric with custom configuration
*/
graphql_yoga_http_duration?: boolean | string | number[] | ReturnType<typeof createHistogram>;
graphql_yoga_http_duration?: HistogramMetricOption<'request', string, HTTPFillLabelParams>;
};

labels?: {
Expand All @@ -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,
Expand Down Expand Up @@ -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<PrometheusTracingPluginConfig['metrics']>
'request',
NonNullable<PrometheusTracingPluginConfig['metrics']>,
HTTPFillLabelParams
>(
resolvedOptions,
'graphql_yoga_http_duration',
['request'],
{
help: 'Time spent on HTTP connection',
labelNames: ['operationName', 'operationType', 'method', 'statusCode', 'url'],
Expand All @@ -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<Request, number>();
const paramsByRequest = new WeakMap<Request, FillLabelsFnParams>();
const paramsByRequest = new WeakMap<Request, FillLabelsFnParams & { document: DocumentNode }>();

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,
);
}
}
},
};
Expand Down
49 changes: 48 additions & 1 deletion packages/plugins/prometheus/tests/prometheus.spec.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 131dfa3

Please sign in to comment.