Repository navigation
🎃 Add an opt-in Prometheus /metrics endpoint for tool calls #1003
Copy link
Copy link
Open
Labels
enhancementNew feature or requestNew feature or requesthacktoberfestGood for Hacktoberfest contributorsGood for Hacktoberfest contributorshelp wantedExtra attention is neededExtra attention is needed
Description
Part of the October Challenge #997.
Why
Operators who self-host AnythingMCP want tool-call rates, error rates and latency in Grafana, next to the rest of their stack. Today they only have the Pino tool_call log lines, which they have to parse themselves, and the database (ToolInvocation). Our ROADMAP lists "per-tool latency histograms, error-rate dashboards". Most self-hosted gateways (Kong, Traefik, n8n) ship a Prometheus endpoint.
Self-hosted vs Cloud
- Self-hosted: off by default. When
METRICS_ENABLED=true, the backend starts a second, separate HTTP listener for/metrics(it does not use the main app port 4000). Available in Community; no licence gate. - Cloud (
DEPLOYMENT_MODE=cloud): the operator (us) uses the same code. Two extra rules: the listener refuses to start withoutMETRICS_TOKEN(it logs an error and metrics stay off), and per-tool/per-connector labels are always off. No series may carry an organization id, connector name, tool name or user. This prevents cross-tenant leaks and keeps cardinality bounded with thousands of workspaces. Per-org usage already exists in the database (AuditService.getBreakdowns). - Who can use it: whoever operates the instance (env vars). There are no app roles, no dashboard UI and no org scoping, because metrics are instance-wide by design. That is why they must never be reachable through the public port.
What to build
- Library: use
prom-client. Recommendation and reasons:@opentelemetry/exporter-prometheuswould hook into the existingNodeSDK(tracing.ts). But that SDK only starts whenOTEL_EXPORTER_OTLP_ENDPOINTis set, its 0.x packages must move in lockstep with@opentelemetry/sdk-node(^0.222.0), and Node runtime metrics need extra packages.prom-clientgivescollectDefaultMetrics()(event loop lag, heap, GC) for free and is trivial to unit-test (await register.metrics()returns a string). Wrap it in a small facade (src/common/metrics.ts:recordToolCall(...)), so a later switch to OTel metrics touches one file. - Env vars:
METRICS_ENABLED(defaultfalse),METRICS_PORT(default9464),METRICS_HOST(default0.0.0.0, because in Docker the Prometheus container must reach it over the compose network; the port is not published indocker-compose.yml),METRICS_TOKEN(optional on self-hosted, required on Cloud, compared withtimingSafeEqual),METRICS_TOOL_LABELS(defaultfalse, ignored on Cloud with a warning). - Listener: a plain
node:httpserver started frommain.tsafterapp.listen(), closed on shutdown. It serves onlyGET /metrics. Any other path returns 404, and a wrong or missing token returns 401. - Metrics (prefix
anythingmcp_):tool_calls_total{connector_type,status}counter (status =success|error)tool_call_duration_seconds{connector_type,status}histogram (buckets roughly 0.05 to 60 s; tool calls can take 20 s or more)tool_call_upstream_errors_total{connector_type,upstream_status_class}(4xx|5xx|none)- with
METRICS_TOOL_LABELS=true(self-hosted only), addconnector_idandtoollabels. Use the connector id (cuid), not the name. - gauges mirrored from the existing
processGauges(toolCallsInFlight,mcpRequestsInFlight,registeredTools…), using acollect()callback collectDefaultMetrics()
Where to look
packages/backend/src/mcp-server/dynamic-mcp-tools.ts:130:logToolCall()is called on both the success and the error path and already hasdurationMs,status,upstreamStatusandconnectorType. Record the metrics here.packages/backend/src/common/process-vitals.ts:246:ProcessGauges/processGauges.snapshot(), existing in-process gauges to expose.packages/backend/src/tracing.ts:26: the existing opt-in OTel setup (pattern for an env-gated, no-op-by-default module).packages/backend/src/main.ts:160:app.listen(port). Start the metrics listener after it.packages/backend/src/health/sentry-verify.controller.ts:27:sameSecret(), a constant-time token comparison to reuse.packages/backend/src/common/deployment.service.ts:10:isCloud().deploy/cloud/Caddyfile:91anddocs/deployment.md:221:/health*is proxied publicly on Cloud and self-hosted. That is why metrics must not live under/healthor on port 4000.
Acceptance criteria
- With
METRICS_ENABLEDunset, no listener starts andprom-clientdefault metrics are not collected. -
GET :9464/metricsreturns Prometheus text format, and the counters and histogram move after a tool call. - With
METRICS_TOKENset, a missing or wrongAuthorization: Bearerreturns 401. - With
DEPLOYMENT_MODE=cloudand no token, the listener does not start. WithMETRICS_TOOL_LABELS=trueon Cloud, notool/connector_idlabel appears. - Unit tests in
src/common/metrics.spec.ts(labels, Cloud rules, token check). Run withcd packages/backend && npx jest src/common/metrics. - Docs: new section "Prometheus metrics (opt-in)" in
docs/operations/observability.md, with a sample scrape config, plus the env vars in the table indocs/deployment.md(around line 338).
Out of scope
- Grafana dashboards or alert rules (a follow-up issue).
- Pushing metrics via OTLP.
- Per-organization metrics or any UI.
- Changing
docker-compose.cloud.ymlor the Cloud Caddyfile (maintainers wire scraping on Cloud).
Size
M (1–2 days)
How to claim
Comment "I'd like to work on this" and we'll assign you. Rules in #997.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or requesthacktoberfestGood for Hacktoberfest contributorsGood for Hacktoberfest contributorshelp wantedExtra attention is neededExtra attention is needed
Activity
rangarajan19 commentedon Oct 17, 2026
Hi @helpcodeai ,
I'd like to work on this.
Plan:
1.
packages/backend/src/common/metrics.ts(new) — a plain module-level facade, modeled ontracing.ts's opt-in pattern andprocess-vitals.ts's singleton style (no Nest DI, so it stays a one-file swap later if we ever move to OTel metrics):prom-client(15.x):collectDefaultMetrics(), plusanythingmcp_tool_calls_total{connector_type,status}(counter),anythingmcp_tool_call_duration_seconds{connector_type,status}(histogram, ~0.05–60s buckets),anythingmcp_tool_call_upstream_errors_total{connector_type,upstream_status_class}.processGaugessingleton (toolCallsInFlight,mcpRequestsInFlight,registeredTools) via a prom-clientGauge'scollect()option readingprocessGauges.get(name)/.snapshot()at scrape time — no changes needed toprocess-vitals.tsitself.recordToolCall({ connectorType, status, durationMs, upstreamStatus, connectorId?, tool? }): increments/observes the three metrics above; addsconnector_id/toollabels only whenMETRICS_TOOL_LABELS=trueand not Cloud (on Cloud, ignored with a one-time warning log, never added to a label set).startMetricsServer(): readsMETRICS_ENABLED(defaultfalse),METRICS_PORT(default9464),METRICS_HOST(default0.0.0.0),METRICS_TOKEN,DEPLOYMENT_MODEstraight fromprocess.env, same astracing.tsdoes. On Cloud, refuses to start withoutMETRICS_TOKENset (logs an error, returnsnull). Starts a plainnode:httpserver — a second listener, never touching the main Nest/Express app or port 4000. ServesGET /metricsonly (401 on a missing/wrong token, checked withtimingSafeEqual; 404 on anything else, including/metricson the wrong method).2. Small refactor: extract
sameSecret()out ofhealth/sentry-verify.controller.tsintocommon/secrets.util.ts(already imported bymain.tsforvalidateRequiredSecretsAtStartup), so the constant-time token comparison has one implementation instead of being copy-pasted intometrics.ts.sentry-verify.controller.tsswitches to the import; behavior unchanged.3.
dynamic-mcp-tools.ts— callrecordToolCall(...)from insidelogToolCall()(the single method both the success and error paths already call, withdurationMs/status/upstreamStatus/connectorTypeall in hand), not from the two call sites separately. One call site means the metric and thetool_calllog line can never drift out of sync.4.
main.ts— start the metrics server right afterconst server = await app.listen(port)(before the shutdown-hook block), and close it inside the existingSIGTERM/SIGINThandler alongside the main server's own shutdown.5. Docs — new
## 6. Prometheus metrics (opt-in)section indocs/operations/observability.md, right after the existing## 5. Connector-failure alertssection, matching that file's numbered-pipeline style, with a sample Prometheus scrape config (bearer token auth) and the Cloud-vs-self-hosted rules spelled out. Add the five env vars to the table indocs/deployment.md(next to the otherNo-required rows around line 345-361).METRICS_PORTis deliberately not added todocker-compose.yml's published ports.6. Tests —
src/common/metrics.spec.ts:METRICS_ENABLEDunset → no listener, no default-metrics collection.GET :PORT/metricsreturns Prometheus text format; counters/histogram move afterrecordToolCall.METRICS_TOKENset → missing/wrongAuthorization: Bearer→ 401.DEPLOYMENT_MODE=cloud+ no token → listener does not start.METRICS_TOOL_LABELS=trueon Cloud → notool/connector_idlabel appears, warning logged once.METRICS_TOOL_LABELS=true→ labels present.Run with
cd packages/backend && npx jest src/common/metrics, plus the fullnpx jest src/connectors src/mcp-serverandnpx tsc --noEmitto confirm no regression around thelogToolCallchange.Out of scope, per the issue: Grafana dashboards/alert rules, OTLP push, per-organization metrics or UI,
docker-compose.cloud.yml/Cloud Caddyfile changes.Open question for you: the issue's "Where to look" line for the gauges says "using a
collect()callback" — I'm reading that as prom-client's ownGauge({ collect })option (reading fromprocessGaugesat scrape time, not polling it on an interval). Let me know if you intended something else there.