Skip to content

🎃 Add an opt-in Prometheus /metrics endpoint for tool calls #1003

Open

Description

@helpcodeai

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 without METRICS_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

  1. Library: use prom-client. Recommendation and reasons: @opentelemetry/exporter-prometheus would hook into the existing NodeSDK (tracing.ts). But that SDK only starts when OTEL_EXPORTER_OTLP_ENDPOINT is set, its 0.x packages must move in lockstep with @opentelemetry/sdk-node (^0.222.0), and Node runtime metrics need extra packages. prom-client gives collectDefaultMetrics() (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.
  2. Env vars: METRICS_ENABLED (default false), METRICS_PORT (default 9464), METRICS_HOST (default 0.0.0.0, because in Docker the Prometheus container must reach it over the compose network; the port is not published in docker-compose.yml), METRICS_TOKEN (optional on self-hosted, required on Cloud, compared with timingSafeEqual), METRICS_TOOL_LABELS (default false, ignored on Cloud with a warning).
  3. Listener: a plain node:http server started from main.ts after app.listen(), closed on shutdown. It serves only GET /metrics. Any other path returns 404, and a wrong or missing token returns 401.
  4. 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), add connector_id and tool labels. Use the connector id (cuid), not the name.
    • gauges mirrored from the existing processGauges (toolCallsInFlight, mcpRequestsInFlight, registeredTools…), using a collect() 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 has durationMs, status, upstreamStatus and connectorType. 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:91 and docs/deployment.md:221: /health* is proxied publicly on Cloud and self-hosted. That is why metrics must not live under /health or on port 4000.

Acceptance criteria

  • With METRICS_ENABLED unset, no listener starts and prom-client default metrics are not collected.
  • GET :9464/metrics returns Prometheus text format, and the counters and histogram move after a tool call.
  • With METRICS_TOKEN set, a missing or wrong Authorization: Bearer returns 401.
  • With DEPLOYMENT_MODE=cloud and no token, the listener does not start. With METRICS_TOOL_LABELS=true on Cloud, no tool/connector_id label appears.
  • Unit tests in src/common/metrics.spec.ts (labels, Cloud rules, token check). Run with cd 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 in docs/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.yml or 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.

Activity

  1. rangarajan19 commented on Oct 17, 2026

    @rangarajan19
    Contributor

    Hi @helpcodeai ,

    I'd like to work on this.

    Plan:
    1. packages/backend/src/common/metrics.ts (new) — a plain module-level facade, modeled on tracing.ts's opt-in pattern and process-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(), plus anythingmcp_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}.
    • Gauges mirrored from the existing processGauges singleton (toolCallsInFlight, mcpRequestsInFlight, registeredTools) via a prom-client Gauge's collect() option reading processGauges.get(name)/.snapshot() at scrape time — no changes needed to process-vitals.ts itself.
    • recordToolCall({ connectorType, status, durationMs, upstreamStatus, connectorId?, tool? }): increments/observes the three metrics above; adds connector_id/tool labels only when METRICS_TOOL_LABELS=true and not Cloud (on Cloud, ignored with a one-time warning log, never added to a label set).
    • startMetricsServer(): reads METRICS_ENABLED (default false), METRICS_PORT (default 9464), METRICS_HOST (default 0.0.0.0), METRICS_TOKEN, DEPLOYMENT_MODE straight from process.env, same as tracing.ts does. On Cloud, refuses to start without METRICS_TOKEN set (logs an error, returns null). Starts a plain node:http server — a second listener, never touching the main Nest/Express app or port 4000. Serves GET /metrics only (401 on a missing/wrong token, checked with timingSafeEqual; 404 on anything else, including /metrics on the wrong method).

    2. Small refactor: extract sameSecret() out of health/sentry-verify.controller.ts into common/secrets.util.ts (already imported by main.ts for validateRequiredSecretsAtStartup), so the constant-time token comparison has one implementation instead of being copy-pasted into metrics.ts. sentry-verify.controller.ts switches to the import; behavior unchanged.

    3. dynamic-mcp-tools.ts — call recordToolCall(...) from inside logToolCall() (the single method both the success and error paths already call, with durationMs/status/upstreamStatus/connectorType all in hand), not from the two call sites separately. One call site means the metric and the tool_call log line can never drift out of sync.

    4. main.ts — start the metrics server right after const server = await app.listen(port) (before the shutdown-hook block), and close it inside the existing SIGTERM/SIGINT handler alongside the main server's own shutdown.

    5. Docs — new ## 6. Prometheus metrics (opt-in) section in docs/operations/observability.md, right after the existing ## 5. Connector-failure alerts section, 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 in docs/deployment.md (next to the other No-required rows around line 345-361). METRICS_PORT is deliberately not added to docker-compose.yml's published ports.

    6. Tests — src/common/metrics.spec.ts:

    • METRICS_ENABLED unset → no listener, no default-metrics collection.
    • enabled → GET :PORT/metrics returns Prometheus text format; counters/histogram move after recordToolCall.
    • METRICS_TOKEN set → missing/wrong Authorization: Bearer → 401.
    • DEPLOYMENT_MODE=cloud + no token → listener does not start.
    • METRICS_TOOL_LABELS=true on Cloud → no tool/connector_id label appears, warning logged once.
    • self-hosted + METRICS_TOOL_LABELS=true → labels present.

    Run with cd packages/backend && npx jest src/common/metrics, plus the full npx jest src/connectors src/mcp-server and npx tsc --noEmit to confirm no regression around the logToolCall change.

    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 own Gauge({ collect }) option (reading from processGauges at scrape time, not polling it on an interval). Let me know if you intended something else there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthacktoberfestGood for Hacktoberfest contributorshelp wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions