Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,14 @@ curl -fsS http://localhost:8080/ \

If you set `ENABLE_GRAPHIQL=true`, open <http://localhost:8080/> 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:
Expand Down
32 changes: 22 additions & 10 deletions package-lock.json

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

13 changes: 11 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <build@o1labs.org>",
"license": "ISC",
"devDependencies": {
Expand Down Expand Up @@ -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"
Expand Down
103 changes: 103 additions & 0 deletions src/server/metrics.ts
Original file line number Diff line number Diff line change
@@ -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<Request, number>();
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);
}
},
};
}
5 changes: 5 additions & 0 deletions src/server/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/metrics.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof serverWithFreshMetrics>) {
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'
);
});
});
Loading