diff --git a/docs/getting-started.md b/docs/getting-started.md index 67a52be..f960251 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -210,6 +210,14 @@ curl -fsS http://localhost:8080/ \ If you set `ENABLE_GRAPHIQL=true`, open in a browser for the in-page query explorer. +### Metrics + +Prometheus metrics are exposed at `/metrics` — RED metrics (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`) plus standard Node process metrics. + +```sh +curl -fsS http://localhost:8080/metrics | head +``` + ### Confirm the DB is wired up This query returns the latest indexed block height — compare it with [MinaScan](https://minascan.io/mainnet/home) to confirm you're looking at the network you think you are: diff --git a/package-lock.json b/package-lock.json index 27e256c..bccb9d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@o1-labs/mina-archive-node-graphql", - "version": "0.0.5", + "version": "0.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@o1-labs/mina-archive-node-graphql", - "version": "0.0.5", + "version": "0.0.6", "license": "ISC", "dependencies": { "@envelop/core": "^4.0.0", @@ -24,7 +24,8 @@ "dotenv": "^16.3.1", "graphql": "^16.8.0", "graphql-yoga": "4.0.4", - "postgres": "^3.3.5" + "postgres": "^3.3.5", + "prom-client": "^15.1.3" }, "bin": { "mina-archive-node-graphql": "build/src/index.js" @@ -11307,6 +11308,19 @@ "uuid": "^8.3.2" } }, + "node_modules/artillery-plugin-publish-metrics/node_modules/prom-client": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-14.2.0.tgz", + "integrity": "sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tdigest": "^0.1.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/artillery-plugin-publish-metrics/node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", @@ -11617,7 +11631,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", - "dev": true, "license": "MIT" }, "node_modules/bl": { @@ -18907,16 +18920,16 @@ } }, "node_modules/prom-client": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-14.2.0.tgz", - "integrity": "sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==", - "dev": true, + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" }, "engines": { - "node": ">=10" + "node": "^16 || ^18 || >=20" } }, "node_modules/promise-inflight": { @@ -20636,7 +20649,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", - "dev": true, "license": "MIT", "dependencies": { "bintrees": "1.0.2" diff --git a/package.json b/package.json index 3c71d02..ddf57fb 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,15 @@ "compose": "docker compose up", "prepublishOnly": "npm run build" }, - "keywords": ["mina", "mina-protocol", "archive-node", "graphql", "o1js", "zkapp", "blockchain"], + "keywords": [ + "mina", + "mina-protocol", + "archive-node", + "graphql", + "o1js", + "zkapp", + "blockchain" + ], "author": "O(1) Labs ", "license": "ISC", "devDependencies": { @@ -77,7 +85,8 @@ "dotenv": "^16.3.1", "graphql": "^16.8.0", "graphql-yoga": "4.0.4", - "postgres": "^3.3.5" + "postgres": "^3.3.5", + "prom-client": "^15.1.3" }, "volta": { "node": "20.18.0" diff --git a/src/server/metrics.ts b/src/server/metrics.ts new file mode 100644 index 0000000..a08c6ee --- /dev/null +++ b/src/server/metrics.ts @@ -0,0 +1,103 @@ +import type { Plugin } from 'graphql-yoga'; +import { + Registry, + collectDefaultMetrics, + Counter, + Histogram, + Gauge, +} from 'prom-client'; + +export { createMetrics, useMetrics, METRICS_PATH }; +export type { Metrics }; + +const METRICS_PATH = '/metrics'; + +/** Known routes — anything else is bucketed as `other` to bound label cardinality. */ +const KNOWN_ROUTES = new Set(['/', '/healthcheck', '/readiness', METRICS_PATH]); + +interface Metrics { + registry: Registry; + requestsTotal: Counter<'method' | 'route' | 'status'>; + requestDuration: Histogram<'method' | 'route' | 'status'>; + inFlight: Gauge; +} + +/** + * Build a Prometheus registry with RED HTTP metrics (rate, errors, duration) and, + * unless disabled, the standard Node process metrics (CPU, memory, event loop, + * GC). `collectDefault` is off in tests to avoid leaving background collectors. + */ +function createMetrics({ collectDefault = true } = {}): Metrics { + const registry = new Registry(); + if (collectDefault) collectDefaultMetrics({ register: registry }); + + const requestsTotal = new Counter({ + name: 'http_requests_total', + help: 'Total HTTP requests', + labelNames: ['method', 'route', 'status'] as const, + registers: [registry], + }); + const requestDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'HTTP request duration in seconds', + labelNames: ['method', 'route', 'status'] as const, + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + registers: [registry], + }); + const inFlight = new Gauge({ + name: 'http_requests_in_flight', + help: 'HTTP requests currently being processed', + registers: [registry], + }); + + return { registry, requestsTotal, requestDuration, inFlight }; +} + +function routeOf(rawUrl: string): string { + let path: string; + try { + path = new URL(rawUrl).pathname; + } catch { + return 'other'; + } + return KNOWN_ROUTES.has(path) ? path : 'other'; +} + +/** + * Yoga plugin that serves the Prometheus exposition at `/metrics` and records RED + * metrics for every other request. The `/metrics` scrape itself is not counted. + */ +function useMetrics(metrics: Metrics = createMetrics()): Plugin { + const startTimes = new WeakMap(); + return { + async onRequest({ request, url, endResponse, fetchAPI }) { + if (url.pathname === METRICS_PATH) { + endResponse( + new fetchAPI.Response(await metrics.registry.metrics(), { + status: 200, + headers: { 'content-type': metrics.registry.contentType }, + }) + ); + return; + } + metrics.inFlight.inc(); + startTimes.set(request, Date.now()); + }, + onResponse({ request, response }) { + const route = routeOf(request.url); + if (route === METRICS_PATH) return; + + metrics.inFlight.dec(); + const labels = { + method: request.method, + route, + status: String(response.status), + }; + metrics.requestsTotal.inc(labels); + const start = startTimes.get(request); + if (start !== undefined) { + metrics.requestDuration.observe(labels, (Date.now() - start) / 1000); + } + }, + }; +} diff --git a/src/server/plugins.ts b/src/server/plugins.ts index 43313dd..f2023cc 100644 --- a/src/server/plugins.ts +++ b/src/server/plugins.ts @@ -5,12 +5,17 @@ import { useOpenTelemetry } from '@envelop/opentelemetry'; import { inspect } from 'node:util'; import { initJaegerProvider } from '../tracing/jaeger-tracing.js'; +import { useMetrics } from './metrics.js'; export { buildPlugins }; async function buildPlugins() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const plugins: any[] = []; + + // Prometheus /metrics endpoint + RED metrics for every request. + plugins.push(useMetrics()); + plugins.push(useGraphQlJit()); if (process.env.ENABLE_LOGGING) { diff --git a/tests/unit/metrics.test.ts b/tests/unit/metrics.test.ts new file mode 100644 index 0000000..d7b7dc9 --- /dev/null +++ b/tests/unit/metrics.test.ts @@ -0,0 +1,66 @@ +import { describe, test } from 'node:test'; +import assert from 'node:assert'; +import { createYoga } from 'graphql-yoga'; +import { createMetrics, useMetrics } from '../../src/server/metrics.js'; +import { schema } from '../../src/resolvers.js'; + +function serverWithFreshMetrics() { + const metrics = createMetrics({ collectDefault: false }); + return createYoga({ + schema, + graphqlEndpoint: '/', + plugins: [useMetrics(metrics)], + }); +} + +async function graphql(yoga: ReturnType) { + return yoga.fetch('http://localhost/', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: '{ __typename }' }), + }); +} + +describe('Prometheus metrics', () => { + test('serves the exposition format at /metrics', async () => { + const yoga = serverWithFreshMetrics(); + const response = await yoga.fetch('http://localhost/metrics'); + assert.strictEqual(response.status, 200); + assert.match( + response.headers.get('content-type') ?? '', + /text\/plain/ + ); + const body = await response.text(); + assert.match(body, /http_requests_total/); + assert.match(body, /http_request_duration_seconds/); + assert.match(body, /http_requests_in_flight/); + }); + + test('counts requests by route and status', async () => { + const yoga = serverWithFreshMetrics(); + await graphql(yoga); + await graphql(yoga); + + const body = await (await yoga.fetch('http://localhost/metrics')).text(); + const line = body + .split('\n') + .find( + (l) => + l.startsWith('http_requests_total{') && + l.includes('route="/"') && + l.includes('status="200"') + ); + assert.ok(line, `expected a counter line for route="/", got:\n${body}`); + assert.strictEqual(line?.trim().endsWith(' 2'), true); + }); + + test('does not count the /metrics scrape itself', async () => { + const yoga = serverWithFreshMetrics(); + await yoga.fetch('http://localhost/metrics'); + const body = await (await yoga.fetch('http://localhost/metrics')).text(); + assert.ok( + !body.includes('route="/metrics"'), + 'the /metrics scrape must not be counted' + ); + }); +});