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'
+ );
+ });
+});