diff --git a/README.md b/README.md index 577fb89..29df124 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,27 @@ Each answer lists the tools it called, and **Activity** shows the matching audit - **Access:** invite members, and issue or revoke API keys. - **Activity:** the audit log of every tool call, showing user, server, tool, time, duration and outcome. +### Metrics + +Administrators see each server's calls, error rate and average and p95 latency for the last 24 hours on its **Connections** card. The same summary is available as JSON from `GET /api/metrics?hours=<1-168>`. + +The gateway serves Prometheus metrics on port `9464` at `/metrics`. The port is reachable inside the cluster only; the Ingress does not route it, and the pod carries `prometheus.io/scrape` annotations. To inspect it locally: + +```sh +kubectl -n storelink port-forward svc/storelink-gateway 9464:9464 +curl -s localhost:9464/metrics +``` + +| Metric | Type | Labels | +| --------------------------------------------------- | --------- | ----------------------------------- | +| `storelink_tool_calls_total` | counter | `team`, `server`, `slug`, `outcome` | +| `storelink_tool_call_duration_seconds` | histogram | `team`, `server`, `slug` | +| `storelink_upstream_up` | gauge | `team`, `server`, `slug` | +| `storelink_upstream_status` | gauge | `team`, `server`, `slug`, `status` | +| `storelink_upstream_last_failure_timestamp_seconds` | gauge | `team`, `server`, `slug` | + +For example, the error rate per server over five minutes is `sum by (server) (rate(storelink_tool_calls_total{outcome=~"failed|timeout|uncertain"}[5m])) / sum by (server) (rate(storelink_tool_calls_total{outcome!="denied"}[5m]))`. + ## Configuration Common chart values: @@ -165,6 +186,7 @@ Common chart values: | `chat.limits.perUserConcurrent` / `perTeamConcurrent` / `globalConcurrent` | `1` / `4` / `16` | Concurrent chat turns | | `gateway.extraUpstreamOrigins` | `[]` | Cluster-internal origins allowed as upstreams | | `gateway.healthIntervalMs` | `1000` | Interval between upstream health probes | +| `gateway.metrics.enabled`, `gateway.metrics.port` | `true`, `9464` | Prometheus metrics listener inside the cluster | | `store.host`, `gateway.host` | `store.storelink.localhost`, `dashboard.storelink.localhost` | Public hostnames | | `store.timezone` | `Asia/Kolkata` | Store timezone, which defines "today" | | `storage.className` | `local-path` | Storage class for the persistent volumes | @@ -297,7 +319,7 @@ The smoke tests drive Google Chrome through Playwright. `npm run dev:dashboard` | #1 VPS deployment | Documented and templated, not deployed | | #6 Security hardening | Partial | | #7 Scaling | Partial: rate and concurrency limits | -| #8 Observability | Partial: health and audit history | +| #8 Observability | Implemented | | #9 Upgrades and rollback | Documented and rehearsed | | #2–#5, multi-store bonus | Not attempted | diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index deba989..c996ec9 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -26,6 +26,15 @@ type Server = { lastSuccessAt?: string; lastFailureAt?: string; lastError?: string; + metrics?: { + calls: number; + errors: number; + timeouts: number; + denied: number; + errorRate: number; + avgLatencyMs: number; + p95LatencyMs: number; + }; }; type Member = { id: string; @@ -441,6 +450,28 @@ function Connections({ me, run }: { me: Me; run: Run }) { )} + {server.metrics && ( + <> +
+
Calls (24h)
+
{server.metrics.calls}
+
+
+
Error rate
+
+ {server.metrics.errorRate}% · {server.metrics.errors}{' '} + errors, {server.metrics.timeouts} timeouts +
+
+
+
Latency
+
+ {server.metrics.avgLatencyMs} ms avg ·{' '} + {server.metrics.p95LatencyMs} ms p95 +
+
+ + )} {me.user.role === 'admin' && (
diff --git a/charts/storelink/templates/apps.yaml b/charts/storelink/templates/apps.yaml index 61ab41e..6eed635 100644 --- a/charts/storelink/templates/apps.yaml +++ b/charts/storelink/templates/apps.yaml @@ -9,6 +9,9 @@ spec: template: metadata: labels: { storelink/component: gateway, app.kubernetes.io/instance: {{ .Release.Name }} } + {{- if .Values.gateway.metrics.enabled }} + annotations: { prometheus.io/scrape: "true", prometheus.io/port: {{ .Values.gateway.metrics.port | quote }}, prometheus.io/path: /metrics } + {{- end }} spec: serviceAccountName: {{ include "storelink.name" . }}-gateway automountServiceAccountToken: true @@ -49,13 +52,20 @@ spec: - { name: CHAT_PER_TEAM_CONCURRENT, value: {{ .Values.chat.limits.perTeamConcurrent | quote }} } - { name: CHAT_GLOBAL_CONCURRENT, value: {{ .Values.chat.limits.globalConcurrent | quote }} } - { name: HEALTH_INTERVAL_MS, value: {{ .Values.gateway.healthIntervalMs | quote }} } + {{- if .Values.gateway.metrics.enabled }} + - { name: METRICS_PORT, value: {{ .Values.gateway.metrics.port | quote }} } + {{- end }} {{- if .Values.chat.enabled }} - { name: OPENAI_API_KEY_FILE, value: /secrets/openai-api-key } {{- end }} - { name: WOO_MCP_URL, value: "http://{{ include "storelink.name" . }}-woo-mcp:3001/mcp" } - { name: GATEWAY_ORIGIN, value: {{ .Values.gateway.publicUrl | quote }} } - { name: TEAM_ID, value: {{ .Values.teamId | quote }} } - ports: [{ name: http, containerPort: 3000 }] + ports: + - { name: http, containerPort: 3000 } + {{- if .Values.gateway.metrics.enabled }} + - { name: metrics, containerPort: {{ .Values.gateway.metrics.port }} } + {{- end }} startupProbe: { httpGet: { path: /health/live, port: http }, periodSeconds: 2, failureThreshold: 60 } readinessProbe: { httpGet: { path: /health/ready, port: http }, periodSeconds: 5, timeoutSeconds: 5 } livenessProbe: { httpGet: { path: /health/live, port: http }, periodSeconds: 10 } @@ -88,7 +98,11 @@ metadata: name: {{ include "storelink.name" . }}-gateway spec: selector: { storelink/component: gateway, app.kubernetes.io/instance: {{ .Release.Name }} } - ports: [{ port: 3000, targetPort: http }] + ports: + - { name: http, port: 3000, targetPort: http } + {{- if .Values.gateway.metrics.enabled }} + - { name: metrics, port: {{ .Values.gateway.metrics.port }}, targetPort: metrics } + {{- end }} --- apiVersion: apps/v1 kind: Deployment diff --git a/charts/storelink/values.yaml b/charts/storelink/values.yaml index 7f4e17a..7723f1a 100644 --- a/charts/storelink/values.yaml +++ b/charts/storelink/values.yaml @@ -27,6 +27,9 @@ store: gateway: extraUpstreamOrigins: [] healthIntervalMs: 1000 + metrics: + enabled: true + port: 9464 host: dashboard.storelink.localhost publicUrl: http://dashboard.storelink.localhost resources: diff --git a/docs/system-design.md b/docs/system-design.md index 5a24951..db4dd91 100644 --- a/docs/system-design.md +++ b/docs/system-design.md @@ -41,6 +41,7 @@ The gateway (`services/gateway/src/gateway.ts`) uses `@modelcontextprotocol/sdk` - **Namespacing and collision handling:** Public tool names follow `__` (capped at 64 characters). Each upstream has a unique slug per team (up to 16 characters), and tool names with special characters or length over 46 characters receive a deterministic `_` alias. The gateway stores the `:__` route map in memory and dispatches calls by looking up the target upstream and original tool name. Colliding upstream tool names such as `get_status` on both `time` and `ops` are exposed as `time__get_status` and `ops__get_status`. - **Timeouts and bounds:** Upstream TCP connections have a 2-second timeout (`undici.Agent`), tool discovery has a 3-second deadline (up to 5 pages / 200 tools), and tool calls have a 15-second deadline (256 KiB response cap). Arguments are validated against the upstream's JSON Schema with Ajv before dispatch. - **Health probing and failure isolation:** The gateway pings each connected upstream every second (`gateway.healthIntervalMs`, 1-second deadline) and refreshes full tool catalogs every 15 seconds. If a health probe, discovery or tool call fails with a transport or timeout error, only that upstream is marked `Unreachable`, its routes are removed from the active catalog, and the `Unreachable` state is held for a 2-second retry window before rediscovery. Upstream MCP client transports reset on probe or discovery failure so a restarted pod receives a fresh session. Status transitions and timestamps are persisted to PostgreSQL (`upstream_events` and `upstream_servers`). +- **Metrics:** Call metrics are derived from `audit_calls`, which already records every call's server, outcome and duration (`services/gateway/src/metrics.ts`). One aggregation query, shared by PostgreSQL and the SQLite test store, counts outcomes and fills a fixed latency histogram (5 ms to 15 s buckets). The gateway serves the result in Prometheus format on a separate in-cluster port (`gateway.metrics.port`, default `9464`) that the Ingress does not route, so tool names and call volumes are not public. Administrators also see a 24-hour summary per server (calls, error rate, average and p95 latency, with p95 interpolated from the histogram buckets) in the dashboard and at `GET /api/metrics`. Members do not receive team-wide metrics, matching the audit log's visibility. - **Downstream sessions and catalog notifications:** `/mcp` uses stateful Streamable HTTP sessions (`services/gateway/src/mcp.ts`) with JSON POST responses and a GET SSE stream. When a team's tool catalog changes (server added, edited, disabled, removed, failed or recovered), the gateway sends `notifications/tools/list_changed` only to active sessions belonging to that team. Every request rechecks user and API key status; sessions expire after 15 minutes idle (capped at 8 per user / 256 globally). - **Mutations and auditing:** Write tools (`create_product`, `update_order_status`) are annotated with `readOnlyHint: false` and are never automatically retried. If a connection drops or times out after a write request is sent, the adapter and gateway return and audit the outcome as `uncertain` so callers check state before retrying. Every tool call writes a `started` audit record to PostgreSQL before dispatch and updates it with the duration and outcome (`success`, `failed`, `timeout`, `uncertain`, or `denied`). If the audit write fails, the tool call is aborted. diff --git a/docs/verification.md b/docs/verification.md index ac0f494..2091332 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -2,7 +2,7 @@ ## Automated test suite -`npm test` runs 37 deterministic unit and protocol tests across ten test files without making paid OpenAI calls: +`npm test` runs 40 deterministic unit and protocol tests across eleven test files without making paid OpenAI calls: - **`tests/gateway.test.ts`:** API key HMAC digest lookup, revoked-key attribution after restart, `__` tool collision routing (`time__get_status` vs `ops__get_status`), immediate route removal on disable, hung upstream timeout isolation, uncertain mutation auditing without retries, argument schema validation, cross-team route isolation, audit-failure fail-closed behavior, and full MCP SDK Streamable HTTP exchanges. - **`tests/mcp-notifications.test.ts`:** Stateful Streamable HTTP + GET SSE sessions, team-scoped `notifications/tools/list_changed` delivery, suppression of unchanged catalog refreshes, cross-user/cross-key session rejection (`403`), revoked-key notification cutoff, per-user/global session limits (`429`), `DELETE` cleanup, and idle session expiry (`404`). @@ -10,6 +10,7 @@ - **`tests/upstream-policy.test.ts` & `tests/upstream-network.test.ts`:** Public HTTPS registration without a Helm allowlist change, rejection of loopback/private/link-local/metadata/mixed DNS answers, socket-level DNS rebinding prevention, and 2-second TCP connect timeouts that do not truncate long-running tool calls. - **`tests/woocommerce.test.ts`:** OAuth 1.0a HMAC-SHA256 canonical host signing, direct HTTP transport for both HTTP and HTTPS public store URLs, redirect/HTML/auth error rejection without mutation retry, and service token / CORS enforcement. - **`tests/chat.test.ts`, `tests/chat-admission.test.ts` & `tests/dashboard-api.test.ts`:** Scripted OpenAI Responses multi-round tool continuation, mid-turn catalog/authz rechecks, round/call/context bounds, DST-aware store day windows, categorized provider/timeout errors, atomic conversation persistence, and per-user/team/global admission limits that do not charge rejected requests against the hourly quota. +- **`tests/metrics.test.ts`:** Per-server outcome counts and latency aggregation from the audit log, team and time-window filters, histogram-based p95 interpolation, and Prometheus exposition (counters, cumulative histogram buckets, upstream gauges, label escaping). - **`tests/scripts.test.ts`:** `kubectl` binary resolution order and host-wide `flock` cluster lock contention (`exit 73`) and release. ## Cluster smoke checks @@ -38,14 +39,14 @@ Claude Code discovered the tools of all three servers (`mcp__storelink__woo__*`, Mapping against the assignment brief's numbered stand-out items: -| Item | Status | Implementation | -| -------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **#1 Production-like VPS deployment** | Documented & templated (not deployed to a live VPS) | `charts/storelink/values-prod.yaml`, [README production guide](../README.md#production-deployment-k3s), [system design](system-design.md#production-changes) | -| **#6 Security hardening** | Partial | SSRF & socket-level DNS rebinding checks (`services/gateway/src/upstream-policy.ts`, `tests/upstream-policy.test.ts`); single-Secret least-privilege RBAC (`charts/storelink/templates/registry-rbac.yaml`, `services/gateway/src/credentials.ts`); ingress `NetworkPolicy` isolation on MCP servers and databases (`charts/storelink/templates/network-policies.yaml`); non-root app containers (`runAsNonRoot: true`, UID `1000`). Default-deny egress, non-root WordPress/DB images, and cluster Secret encryption at rest are not enabled by default. | -| **#7 Scaling** | Partial (rate limits only; single gateway replica) | Per-user hourly chat quota (`15/hr`), per-user/team/global concurrent turn limits (`services/gateway/src/chat-admission.ts`), per-user API request throttling (`180/min`), and login/invite rate limits (`services/gateway/src/dashboard-api.ts`). | -| **#8 Observability** | Partial (health & audit history; no Prometheus metrics) | 1-second upstream ping probes, persisted `lastError` / `lastFailureAt` / `lastSuccessAt` status fields, `upstream_events` transition history in the Connections UI (`services/gateway/src/registry.ts`), and per-call duration/outcome/reason tracking in `audit_calls`. | -| **#9 Upgrades and rollback** | Implemented & rehearsed | Revision-scoped store bootstrap Job (`charts/storelink/templates/store.yaml`), backward-compatible additive SQL migrations (`services/gateway/migrations/`), and an upgrade/rollback procedure rehearsed on the local cluster, with backups verified by restoring them into scratch databases, in [README](../README.md#upgrades-and-rollback) and [system design](system-design.md#upgrades-and-rollback). | -| **Bonus (Multi-store provisioning) & #2–#5** | Not attempted | Deferred in favor of completing the core platform and the items above. | +| Item | Status | Implementation | +| -------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **#1 Production-like VPS deployment** | Documented & templated (not deployed to a live VPS) | `charts/storelink/values-prod.yaml`, [README production guide](../README.md#production-deployment-k3s), [system design](system-design.md#production-changes) | +| **#6 Security hardening** | Partial | SSRF & socket-level DNS rebinding checks (`services/gateway/src/upstream-policy.ts`, `tests/upstream-policy.test.ts`); single-Secret least-privilege RBAC (`charts/storelink/templates/registry-rbac.yaml`, `services/gateway/src/credentials.ts`); ingress `NetworkPolicy` isolation on MCP servers and databases (`charts/storelink/templates/network-policies.yaml`); non-root app containers (`runAsNonRoot: true`, UID `1000`). Default-deny egress, non-root WordPress/DB images, and cluster Secret encryption at rest are not enabled by default. | +| **#7 Scaling** | Partial (rate limits only; single gateway replica) | Per-user hourly chat quota (`15/hr`), per-user/team/global concurrent turn limits (`services/gateway/src/chat-admission.ts`), per-user API request throttling (`180/min`), and login/invite rate limits (`services/gateway/src/dashboard-api.ts`). | +| **#8 Observability** | Implemented | Prometheus `/metrics` on in-cluster port `9464` with per-server call counters by outcome, a latency histogram, and upstream up/status/last-failure gauges (`services/gateway/src/metrics.ts`, `services/gateway/src/main.ts`); 24-hour per-server calls, error rate and avg/p95 latency on dashboard cards and `GET /api/metrics`; 1-second health probes with persisted `lastError` and `upstream_events` history; per-call outcome, reason and duration in `audit_calls`. Tests: `tests/metrics.test.ts`. | +| **#9 Upgrades and rollback** | Implemented & rehearsed | Revision-scoped store bootstrap Job (`charts/storelink/templates/store.yaml`), backward-compatible additive SQL migrations (`services/gateway/migrations/`), and an upgrade/rollback procedure rehearsed on the local cluster, with backups verified by restoring them into scratch databases, in [README](../README.md#upgrades-and-rollback) and [system design](system-design.md#upgrades-and-rollback). | +| **Bonus (Multi-store provisioning) & #2–#5** | Not attempted | Deferred in favor of completing the core platform and the items above. | Additional platform features: diff --git a/services/gateway/src/dashboard-api.ts b/services/gateway/src/dashboard-api.ts index e36b49e..2edb115 100644 --- a/services/gateway/src/dashboard-api.ts +++ b/services/gateway/src/dashboard-api.ts @@ -16,6 +16,7 @@ import { CredentialInputError } from './credentials.ts'; import type { Gateway } from './gateway.ts'; import { runChat, ChatInputError, validateChatInput } from './chat.ts'; import type { Model } from './chat.ts'; +import { summarizeCalls } from './metrics.ts'; class HttpError extends Error { status: number; @@ -237,6 +238,32 @@ export function dashboardApi(options: Options) { ).rows, ); }); + router.get('/metrics', async (req, res) => { + admin(res); + const hours = z.coerce + .number() + .int() + .min(1) + .max(168) + .default(24) + .parse(req.query.hours); + const { teamId } = actor(res); + const stats = await store.callStats({ + teamId, + since: new Date(Date.now() - hours * 60 * 60 * 1000), + }); + res.json({ + hours, + servers: [...gateway.upstreams.values()] + .filter((upstream) => upstream.teamId === teamId) + .map((upstream) => ({ + id: upstream.id, + slug: upstream.slug, + status: upstream.status, + ...summarizeCalls(stats.find((s) => s.serverId === upstream.id)), + })), + }); + }); router.get('/tools', async (_req, res) => res.json(await gateway.listTools(actor(res))), ); diff --git a/services/gateway/src/main.ts b/services/gateway/src/main.ts index f55b0fc..f8f0da3 100644 --- a/services/gateway/src/main.ts +++ b/services/gateway/src/main.ts @@ -1,4 +1,5 @@ import express from 'express'; +import { createServer } from 'node:http'; import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { expressMcpApp } from '../../../packages/shared/src/mcp-http.ts'; @@ -12,6 +13,7 @@ import { FileCredentials, KubernetesCredentials } from './credentials.ts'; import { Registry } from './registry.ts'; import { dashboardApi } from './dashboard-api.ts'; import { openaiModel } from './chat.ts'; +import { renderPrometheus } from './metrics.ts'; const usePostgres = Boolean( process.env.DATABASE_URL || process.env.DATABASE_URL_FILE, @@ -129,6 +131,31 @@ server.on('error', () => { console.error('Unable to bind gateway listener'); process.exitCode = 1; }); +// Prometheus metrics on a separate port that the Ingress does not route. +const metricsPort = Number(process.env.METRICS_PORT ?? 0); +const metricsServer = metricsPort + ? createServer((req, res) => { + if (req.method !== 'GET' || req.url !== '/metrics') { + res.writeHead(404).end(); + return; + } + void (async () => { + try { + const body = renderPrometheus(await store.callStats(), [ + ...gateway.upstreams.values(), + ]); + res + .writeHead(200, { + 'content-type': 'text/plain; version=0.0.4; charset=utf-8', + }) + .end(body); + } catch { + console.error('Metrics collection failed'); + res.writeHead(503).end(); + } + })(); + }).listen(metricsPort, process.env.BIND_ADDRESS ?? '127.0.0.1') + : undefined; let refreshing = false; const refresh = setInterval(() => { if (refreshing) return; @@ -180,6 +207,7 @@ for (const signal of ['SIGINT', 'SIGTERM']) clearInterval(refresh); clearInterval(health); server.close(); + metricsServer?.close(); await mcpHandler.close(); await registry?.close(); await developmentClient?.close(); diff --git a/services/gateway/src/metrics.ts b/services/gateway/src/metrics.ts new file mode 100644 index 0000000..cdf207a --- /dev/null +++ b/services/gateway/src/metrics.ts @@ -0,0 +1,234 @@ +// Tool-call metrics derived from the audit log, plus Prometheus text exposition. + +// Upper bounds (ms) of the latency histogram buckets; the gateway call deadline is 15 s. +export const latencyBucketsMs = [ + 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 15000, +] as const; + +const completedOutcomes = `('success','failed','timeout','uncertain')`; + +export type CallStatsFilter = { teamId?: string; since?: Date }; + +export type ServerCallStats = { + teamId: string; + serverId: string; + success: number; + failed: number; + timeout: number; + uncertain: number; + denied: number; + durationSumMs: number; + // Cumulative counts of completed calls at or below each latencyBucketsMs bound. + buckets: number[]; +}; + +export type ServerCallSummary = { + calls: number; + errors: number; + timeouts: number; + denied: number; + errorRate: number; + avgLatencyMs: number; + p95LatencyMs: number; +}; + +export type UpstreamState = { + id: string; + teamId: string; + slug: string; + status: string; + lastFailureAt?: string; +}; + +// One query for both PostgreSQL ($n placeholders) and SQLite (? placeholders). +export function callStatsQuery( + style: 'postgres' | 'sqlite', + filter: CallStatsFilter, +): { text: string; values: string[] } { + const values: string[] = []; + const param = (value: string) => { + values.push(value); + return style === 'postgres' ? `$${values.length}` : '?'; + }; + const where = ['server_id IS NOT NULL', `outcome <> 'started'`]; + if (filter.teamId) where.push(`team_id = ${param(filter.teamId)}`); + if (filter.since) + where.push(`timestamp >= ${param(filter.since.toISOString())}`); + const count = (condition: string) => + `SUM(CASE WHEN ${condition} THEN 1 ELSE 0 END)`; + const buckets = latencyBucketsMs.map( + (bound, index) => + `${count(`outcome IN ${completedOutcomes} AND duration_ms <= ${bound}`)} AS le_${index}`, + ); + return { + text: `SELECT team_id, server_id, + ${count(`outcome = 'success'`)} AS success, + ${count(`outcome = 'failed'`)} AS failed, + ${count(`outcome = 'timeout'`)} AS timeout, + ${count(`outcome = 'uncertain'`)} AS uncertain, + ${count(`outcome = 'denied'`)} AS denied, + SUM(CASE WHEN outcome IN ${completedOutcomes} THEN duration_ms ELSE 0 END) AS duration_sum, + ${buckets.join(',\n ')} + FROM audit_calls + WHERE ${where.join(' AND ')} + GROUP BY team_id, server_id + ORDER BY team_id, server_id`, + values, + }; +} + +export function parseCallStats( + rows: Record[], +): ServerCallStats[] { + const number = (value: unknown) => Number(value ?? 0); + return rows.map((row) => ({ + teamId: String(row.team_id ?? ''), + serverId: String(row.server_id), + success: number(row.success), + failed: number(row.failed), + timeout: number(row.timeout), + uncertain: number(row.uncertain), + denied: number(row.denied), + durationSumMs: number(row.duration_sum), + buckets: latencyBucketsMs.map((_, index) => number(row[`le_${index}`])), + })); +} + +const completed = (stats: ServerCallStats) => + stats.success + stats.failed + stats.timeout + stats.uncertain; + +// Linear interpolation inside the matching bucket, as Prometheus histogram_quantile does. +export function estimateQuantile( + quantile: number, + buckets: number[], + total: number, +): number { + if (total === 0) return 0; + const rank = quantile * total; + for (let index = 0; index < buckets.length; index++) { + if (buckets[index] >= rank) { + const lower = index === 0 ? 0 : latencyBucketsMs[index - 1]; + const below = index === 0 ? 0 : buckets[index - 1]; + const inBucket = buckets[index] - below; + const fraction = inBucket === 0 ? 1 : (rank - below) / inBucket; + return Math.round(lower + (latencyBucketsMs[index] - lower) * fraction); + } + } + return latencyBucketsMs[latencyBucketsMs.length - 1]; +} + +export function summarizeCalls(stats?: ServerCallStats): ServerCallSummary { + if (!stats) + return { + calls: 0, + errors: 0, + timeouts: 0, + denied: 0, + errorRate: 0, + avgLatencyMs: 0, + p95LatencyMs: 0, + }; + const calls = completed(stats); + const errors = stats.failed + stats.timeout + stats.uncertain; + return { + calls, + errors, + timeouts: stats.timeout, + denied: stats.denied, + errorRate: calls ? Math.round((errors / calls) * 1000) / 10 : 0, + avgLatencyMs: calls ? Math.round(stats.durationSumMs / calls) : 0, + p95LatencyMs: estimateQuantile(0.95, stats.buckets, calls), + }; +} + +const escapeLabel = (value: string) => + value.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"'); +const labels = (pairs: Record) => + `{${Object.entries(pairs) + .map(([key, value]) => `${key}="${escapeLabel(value)}"`) + .join(',')}}`; + +export function renderPrometheus( + stats: ServerCallStats[], + upstreams: UpstreamState[], +): string { + const slugs = new Map(upstreams.map((u) => [`${u.teamId}:${u.id}`, u.slug])); + const series = (s: ServerCallStats) => ({ + team: s.teamId, + server: s.serverId, + slug: slugs.get(`${s.teamId}:${s.serverId}`) ?? s.serverId, + }); + const lines: string[] = []; + const metric = (name: string, type: string, help: string) => + lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`); + + metric( + 'storelink_tool_calls_total', + 'counter', + 'Tool calls routed to an upstream, by outcome.', + ); + for (const s of stats) + for (const outcome of [ + 'success', + 'failed', + 'timeout', + 'uncertain', + 'denied', + ] as const) + lines.push( + `storelink_tool_calls_total${labels({ ...series(s), outcome })} ${s[outcome]}`, + ); + + metric( + 'storelink_tool_call_duration_seconds', + 'histogram', + 'Duration of completed tool calls.', + ); + for (const s of stats) { + const base = series(s); + latencyBucketsMs.forEach((bound, index) => + lines.push( + `storelink_tool_call_duration_seconds_bucket${labels({ ...base, le: String(bound / 1000) })} ${s.buckets[index]}`, + ), + ); + lines.push( + `storelink_tool_call_duration_seconds_bucket${labels({ ...base, le: '+Inf' })} ${completed(s)}`, + `storelink_tool_call_duration_seconds_sum${labels(base)} ${s.durationSumMs / 1000}`, + `storelink_tool_call_duration_seconds_count${labels(base)} ${completed(s)}`, + ); + } + + metric( + 'storelink_upstream_up', + 'gauge', + 'Whether the upstream is connected (1) or not (0).', + ); + for (const u of upstreams) + lines.push( + `storelink_upstream_up${labels({ team: u.teamId, server: u.id, slug: u.slug })} ${u.status === 'Connected' ? 1 : 0}`, + ); + + metric( + 'storelink_upstream_status', + 'gauge', + 'Current upstream status; the matching status label is 1.', + ); + for (const u of upstreams) + for (const status of ['Connected', 'Unreachable', 'Disabled']) + lines.push( + `storelink_upstream_status${labels({ team: u.teamId, server: u.id, slug: u.slug, status })} ${u.status === status ? 1 : 0}`, + ); + + metric( + 'storelink_upstream_last_failure_timestamp_seconds', + 'gauge', + 'Unix time of the most recent upstream failure.', + ); + for (const u of upstreams) + if (u.lastFailureAt) + lines.push( + `storelink_upstream_last_failure_timestamp_seconds${labels({ team: u.teamId, server: u.id, slug: u.slug })} ${Math.floor(Date.parse(u.lastFailureAt) / 1000)}`, + ); + + return `${lines.join('\n')}\n`; +} diff --git a/services/gateway/src/postgres-store.ts b/services/gateway/src/postgres-store.ts index 6f8f886..768d6d1 100644 --- a/services/gateway/src/postgres-store.ts +++ b/services/gateway/src/postgres-store.ts @@ -3,6 +3,8 @@ import { readFileSync, readdirSync } from 'node:fs'; import { Pool } from 'pg'; import type { Identity } from '../../../packages/shared/src/api-keys.ts'; import type { AuditResult, GatewayStore, KeyRecord } from './store.ts'; +import { callStatsQuery, parseCallStats } from './metrics.ts'; +import type { CallStatsFilter, ServerCallStats } from './metrics.ts'; export class PostgresStore implements GatewayStore { pool: Pool; @@ -137,6 +139,11 @@ export class PostgresStore implements GatewayStore { ); } + async callStats(filter: CallStatsFilter = {}): Promise { + const { text, values } = callStatsQuery('postgres', filter); + return parseCallStats((await this.pool.query(text, values)).rows); + } + async auditRows(teamId: string): Promise[]> { const result = await this.pool.query( 'SELECT * FROM audit_calls WHERE team_id=$1 ORDER BY sequence DESC LIMIT 200', diff --git a/services/gateway/src/registry.ts b/services/gateway/src/registry.ts index 439ab92..c420d7e 100644 --- a/services/gateway/src/registry.ts +++ b/services/gateway/src/registry.ts @@ -5,6 +5,7 @@ import type { Gateway } from './gateway.ts'; import { upstreamClient } from './upstream.ts'; import type { Credential, CredentialStore } from './credentials.ts'; import { validateCredential } from './credentials.ts'; +import { summarizeCalls } from './metrics.ts'; import { UpstreamPolicy } from './upstream-policy.ts'; export { RegistryInputError } from './upstream-policy.ts'; @@ -147,6 +148,18 @@ export class Registry { 'SELECT * FROM upstream_servers WHERE team_id=$1 ORDER BY slug', [actor.teamId], ); + // Team-wide call volume is administrator information, like the audit log. + const stats = + actor.role === 'admin' + ? new Map( + ( + await this.store.callStats({ + teamId: actor.teamId, + since: new Date(Date.now() - 24 * 60 * 60 * 1000), + }) + ).map((s) => [s.serverId, s]), + ) + : undefined; return rows.map((row) => { const runtime = this.gateway.upstreams.get(row.id); return { @@ -159,6 +172,7 @@ export class Registry { lastSuccessAt: runtime?.lastSuccessAt ?? row.last_success_at, lastFailureAt: runtime?.lastFailureAt ?? row.last_failure_at, lastError: runtime?.lastError ?? row.last_error, + metrics: stats ? summarizeCalls(stats.get(row.id)) : undefined, tools: [...this.gateway.routes.values()] .filter((route) => route.upstream.id === row.id) .map((route) => route.tool.name), diff --git a/services/gateway/src/store.ts b/services/gateway/src/store.ts index cd2cdc8..197dc5e 100644 --- a/services/gateway/src/store.ts +++ b/services/gateway/src/store.ts @@ -3,6 +3,8 @@ import { chmodSync, mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import type { Identity } from '../../../packages/shared/src/api-keys.ts'; +import { callStatsQuery, parseCallStats } from './metrics.ts'; +import type { CallStatsFilter, ServerCallStats } from './metrics.ts'; export type KeyRecord = Identity & { revokedAt: string | null }; export type AuditOutcome = @@ -158,6 +160,11 @@ export class DevelopmentStore { return this.db.prepare('SELECT * FROM audit_calls ORDER BY rowid').all(); } + callStats(filter: CallStatsFilter = {}): ServerCallStats[] { + const { text, values } = callStatsQuery('sqlite', filter); + return parseCallStats(this.db.prepare(text).all(...values)); + } + close(): void { this.db.close(); } diff --git a/tests/metrics.test.ts b/tests/metrics.test.ts new file mode 100644 index 0000000..8392b19 --- /dev/null +++ b/tests/metrics.test.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { test } from 'node:test'; +import { generateApiKey, hashApiKey } from '../packages/shared/src/api-keys.ts'; +import { DevelopmentStore } from '../services/gateway/src/store.ts'; +import { Gateway } from '../services/gateway/src/gateway.ts'; +import type { ToolClient } from '../services/gateway/src/gateway.ts'; +import { + estimateQuantile, + latencyBucketsMs, + renderPrometheus, + summarizeCalls, +} from '../services/gateway/src/metrics.ts'; + +const tool = (name: string) => ({ + name, + inputSchema: { + type: 'object' as const, + properties: {}, + additionalProperties: false, + }, + annotations: { readOnlyHint: true }, +}); + +function client(behaviour: 'ok' | 'fail' | 'hang'): ToolClient { + return { + async listTools() { + return { tools: [tool('get_status')] }; + }, + async callTool() { + if (behaviour === 'fail') throw new TypeError('fetch failed'); + if (behaviour === 'hang') await new Promise(() => {}); + return { content: [{ type: 'text', text: 'ok' }] }; + }, + }; +} + +async function setup() { + const store = new DevelopmentStore(':memory:'); + const pepper = randomBytes(32); + const gateway = new Gateway(store, pepper, { discoveryMs: 50, callMs: 40 }); + const key = generateApiKey(); + store.addUser('admin-a', 'team', 'admin'); + store.addKey('admin-a', hashApiKey(key, pepper)); + for (const [id, behaviour] of [ + ['time', 'ok'], + ['ops', 'fail'], + ['slow', 'hang'], + ] as const) + gateway.register({ + id, + teamId: 'team', + slug: id, + client: client(behaviour), + }); + await gateway.refreshAll(); + return { store, gateway, actor: await gateway.authenticate(key) }; +} + +test('call statistics aggregate outcomes and latency per upstream from the audit log', async (t) => { + const { store, gateway, actor } = await setup(); + t.after(() => store.close()); + for (let i = 0; i < 3; i++) await gateway.call(actor, 'time__get_status', {}); + await gateway.call(actor, 'ops__get_status', {}); + await gateway.call(actor, 'slow__get_status', {}); + + const stats = new Map(store.callStats().map((s) => [s.serverId, s])); + assert.equal(stats.get('time')?.success, 3); + assert.equal(stats.get('ops')?.failed, 1); + assert.equal(stats.get('slow')?.timeout, 1); + + const time = summarizeCalls(stats.get('time')); + assert.equal(time.calls, 3); + assert.equal(time.errorRate, 0); + const slow = summarizeCalls(stats.get('slow')); + assert.equal(slow.errors, 1); + assert.equal(slow.timeouts, 1); + assert.equal(slow.errorRate, 100); + assert.ok(slow.avgLatencyMs >= 40); + assert.ok(slow.p95LatencyMs > 0); + + // Rows outside the window and other teams are excluded. + assert.equal(store.callStats({ teamId: 'other' }).length, 0); + assert.equal( + store.callStats({ since: new Date(Date.now() + 60_000) }).length, + 0, + ); + assert.equal(store.callStats({ teamId: 'team' }).length, 3); +}); + +test('p95 is interpolated within the matching histogram bucket', () => { + // 100 calls: 90 at <=5 ms, 10 between 50 and 100 ms. + const buckets = latencyBucketsMs.map((bound) => + bound < 100 ? 90 : 100, + ) as number[]; + assert.equal(estimateQuantile(0.95, buckets, 100), 75); + assert.equal(estimateQuantile(0.5, buckets, 100), 3); + assert.equal( + estimateQuantile( + 0.95, + buckets.map(() => 0), + 0, + ), + 0, + ); + // Calls slower than the largest bound report that bound. + assert.equal( + estimateQuantile( + 0.95, + buckets.map(() => 0), + 10, + ), + latencyBucketsMs[latencyBucketsMs.length - 1], + ); + assert.deepEqual(summarizeCalls(undefined), { + calls: 0, + errors: 0, + timeouts: 0, + denied: 0, + errorRate: 0, + avgLatencyMs: 0, + p95LatencyMs: 0, + }); +}); + +test('Prometheus output exposes counters, a cumulative histogram and upstream state', async (t) => { + const { store, gateway, actor } = await setup(); + t.after(() => store.close()); + await gateway.call(actor, 'time__get_status', {}); + await gateway.call(actor, 'ops__get_status', {}); + gateway.upstreams.get('slow')!.slug = 'slow "lane"'; + const text = renderPrometheus(store.callStats(), [ + ...gateway.upstreams.values(), + ]); + + assert.match(text, /# TYPE storelink_tool_calls_total counter/); + assert.match( + text, + /storelink_tool_calls_total\{team="team",server="time",slug="time",outcome="success"\} 1/, + ); + assert.match( + text, + /storelink_tool_calls_total\{team="team",server="ops",slug="ops",outcome="failed"\} 1/, + ); + assert.match(text, /# TYPE storelink_tool_call_duration_seconds histogram/); + assert.match( + text, + /storelink_tool_call_duration_seconds_bucket\{team="team",server="time",slug="time",le="\+Inf"\} 1/, + ); + assert.match( + text, + /storelink_tool_call_duration_seconds_count\{team="team",server="time",slug="time"\} 1/, + ); + // Bucket counts never decrease as the bound grows. + const timeBuckets = [ + ...text.matchAll( + /storelink_tool_call_duration_seconds_bucket\{team="team",server="time",slug="time",le="[^"]+"\} (\d+)/g, + ), + ].map((match) => Number(match[1])); + assert.equal(timeBuckets.length, latencyBucketsMs.length + 1); + assert.deepEqual( + timeBuckets, + [...timeBuckets].sort((a, b) => a - b), + ); + assert.match( + text, + /storelink_upstream_up\{team="team",server="time",slug="time"\} 1/, + ); + assert.match( + text, + /storelink_upstream_up\{team="team",server="ops",slug="ops"\} 0/, + ); + assert.match( + text, + /storelink_upstream_status\{team="team",server="ops",slug="ops",status="Unreachable"\} 1/, + ); + assert.match( + text, + /storelink_upstream_last_failure_timestamp_seconds\{team="team",server="ops",slug="ops"\} \d+/, + ); + // Label values are escaped. + assert.match(text, /slug="slow \\"lane\\""/); + assert.ok(text.endsWith('\n')); +});