From 6b433418a6af776e551e79604e67a1e8a05cf080 Mon Sep 17 00:00:00 2001 From: zereight Date: Sun, 21 Jun 2026 13:38:40 +0900 Subject: [PATCH 1/2] refactor: extract metrics formatter --- index.ts | 92 +--------------------------- server/metrics.ts | 116 ++++++++++++++++++++++++++++++++++++ test/server/metrics.test.ts | 50 ++++++++++++++++ 3 files changed, 168 insertions(+), 90 deletions(-) create mode 100644 server/metrics.ts create mode 100644 test/server/metrics.test.ts diff --git a/index.ts b/index.ts index 98ea120c..5e06cca9 100644 --- a/index.ts +++ b/index.ts @@ -199,6 +199,7 @@ import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; import { ipKeyGenerator } from "express-rate-limit"; import { normalizeProxyClientIpForRateLimit } from "./utils/proxy-client-ip.js"; import { getForwardedPublicBaseUrl } from "./utils/forwarded-public-base-url.js"; +import { formatPrometheusMetrics } from "./server/metrics.js"; import { normalizeGitLabApiUrl } from "./utils/url.js"; import { estimateMergeCommitCount, @@ -13088,98 +13089,9 @@ async function startStreamableHTTPServer(): Promise { }, }); - const escapePrometheusLabel = (value: unknown) => - String(value).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"'); - - const formatPrometheusMetrics = () => { - const snapshot = getMetricsSnapshot(); - const configLabels = Object.entries({ - max_sessions: snapshot.config.maxSessions, - max_requests_per_minute: snapshot.config.maxRequestsPerMinute, - session_timeout_seconds: snapshot.config.sessionTimeoutSeconds, - remote_auth_enabled: snapshot.config.remoteAuthEnabled, - mcp_oauth_enabled: snapshot.config.mcpOAuthEnabled, - stateless_mode_enabled: snapshot.config.statelessModeEnabled, - stateless_rotation_key: snapshot.config.statelessRotationKey, - }) - .map(([key, value]) => `${key}="${escapePrometheusLabel(value)}"`) - .join(","); - - return [ - "# HELP gitlab_mcp_requests_processed_total Total MCP requests processed", - "# TYPE gitlab_mcp_requests_processed_total counter", - `gitlab_mcp_requests_processed_total ${snapshot.requestsProcessed}`, - "", - "# HELP gitlab_mcp_requests_rejected_total Requests rejected, by reason", - "# TYPE gitlab_mcp_requests_rejected_total counter", - `gitlab_mcp_requests_rejected_total{reason="rate_limit"} ${snapshot.rejectedByRateLimit}`, - `gitlab_mcp_requests_rejected_total{reason="capacity"} ${snapshot.rejectedByCapacity}`, - "", - "# HELP gitlab_mcp_auth_failures_total Authentication failures", - "# TYPE gitlab_mcp_auth_failures_total counter", - `gitlab_mcp_auth_failures_total ${snapshot.authFailures}`, - "", - "# HELP gitlab_mcp_sessions_total Total sessions created", - "# TYPE gitlab_mcp_sessions_total counter", - `gitlab_mcp_sessions_total ${snapshot.totalSessions}`, - "", - "# HELP gitlab_mcp_sessions_expired_total Sessions expired due to inactivity", - "# TYPE gitlab_mcp_sessions_expired_total counter", - `gitlab_mcp_sessions_expired_total ${snapshot.expiredSessions}`, - "", - "# HELP gitlab_mcp_active_sessions Currently active sessions", - "# TYPE gitlab_mcp_active_sessions gauge", - `gitlab_mcp_active_sessions ${snapshot.activeSessions}`, - "", - "# HELP gitlab_mcp_authenticated_sessions Currently authenticated sessions", - "# TYPE gitlab_mcp_authenticated_sessions gauge", - `gitlab_mcp_authenticated_sessions ${snapshot.authenticatedSessions}`, - "", - "# HELP gitlab_mcp_client_pool_size Current GitLab client pool size", - "# TYPE gitlab_mcp_client_pool_size gauge", - `gitlab_mcp_client_pool_size ${snapshot.gitlabClientPool.size}`, - "", - "# HELP gitlab_mcp_client_pool_max_size Maximum GitLab client pool size", - "# TYPE gitlab_mcp_client_pool_max_size gauge", - `gitlab_mcp_client_pool_max_size ${snapshot.gitlabClientPool.maxSize}`, - "", - "# HELP gitlab_mcp_uptime_seconds Process uptime in seconds", - "# TYPE gitlab_mcp_uptime_seconds gauge", - `gitlab_mcp_uptime_seconds ${snapshot.uptime}`, - "", - "# HELP gitlab_mcp_memory_usage_bytes Node.js memory usage by type", - "# TYPE gitlab_mcp_memory_usage_bytes gauge", - ...Object.entries(snapshot.memoryUsage).map( - ([key, value]) => `gitlab_mcp_memory_usage_bytes{type="${escapePrometheusLabel(key)}"} ${value}` - ), - "", - "# HELP gitlab_mcp_stateless_requests_total Stateless MCP requests processed", - "# TYPE gitlab_mcp_stateless_requests_total counter", - `gitlab_mcp_stateless_requests_total ${snapshot.statelessRequests}`, - "", - "# HELP gitlab_mcp_stateless_auth_total Stateless auth successes, by source", - "# TYPE gitlab_mcp_stateless_auth_total counter", - `gitlab_mcp_stateless_auth_total{source="header"} ${snapshot.statelessAuthFromHeader}`, - `gitlab_mcp_stateless_auth_total{source="sealed_session_id"} ${snapshot.statelessAuthFromSealedSid}`, - "", - "# HELP gitlab_mcp_stateless_auth_failures_total Stateless auth failures", - "# TYPE gitlab_mcp_stateless_auth_failures_total counter", - `gitlab_mcp_stateless_auth_failures_total ${snapshot.statelessAuthFailures}`, - "", - "# HELP gitlab_mcp_stateless_session_id_rotations_total Stateless session id rotations", - "# TYPE gitlab_mcp_stateless_session_id_rotations_total counter", - `gitlab_mcp_stateless_session_id_rotations_total ${snapshot.statelessSidRotated}`, - "", - "# HELP gitlab_mcp_config_info Static configuration (value is always 1)", - "# TYPE gitlab_mcp_config_info gauge", - `gitlab_mcp_config_info{${configLabels}} 1`, - "", - ].join("\n"); - }; - // Metrics endpoint app.get("/metrics", (_req: Request, res: Response) => { - res.type("text/plain; version=0.0.4").send(formatPrometheusMetrics()); + res.type("text/plain; version=0.0.4").send(formatPrometheusMetrics(getMetricsSnapshot())); }); app.get("/metrics.json", (_req: Request, res: Response) => { diff --git a/server/metrics.ts b/server/metrics.ts new file mode 100644 index 00000000..7968ac78 --- /dev/null +++ b/server/metrics.ts @@ -0,0 +1,116 @@ +export interface MetricsSnapshot { + requestsProcessed: number; + rejectedByRateLimit: number; + rejectedByCapacity: number; + authFailures: number; + totalSessions: number; + expiredSessions: number; + activeSessions: number; + authenticatedSessions: number; + gitlabClientPool: { size: number; maxSize: number }; + uptime: number; + memoryUsage: NodeJS.MemoryUsage; + statelessRequests: number; + statelessAuthFromHeader: number; + statelessAuthFromSealedSid: number; + statelessAuthFailures: number; + statelessSidRotated: number; + config: { + maxSessions: number; + maxRequestsPerMinute: number; + sessionTimeoutSeconds: number; + remoteAuthEnabled: boolean; + mcpOAuthEnabled: boolean; + statelessModeEnabled: boolean; + statelessRotationKey: boolean; + }; +} + +export function escapePrometheusLabel(value: unknown): string { + return String(value).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"'); +} + +export function formatPrometheusMetrics(snapshot: MetricsSnapshot): string { + const configLabels = Object.entries({ + max_sessions: snapshot.config.maxSessions, + max_requests_per_minute: snapshot.config.maxRequestsPerMinute, + session_timeout_seconds: snapshot.config.sessionTimeoutSeconds, + remote_auth_enabled: snapshot.config.remoteAuthEnabled, + mcp_oauth_enabled: snapshot.config.mcpOAuthEnabled, + stateless_mode_enabled: snapshot.config.statelessModeEnabled, + stateless_rotation_key: snapshot.config.statelessRotationKey, + }) + .map(([key, value]) => `${key}="${escapePrometheusLabel(value)}"`) + .join(","); + + return [ + "# HELP gitlab_mcp_requests_processed_total Total MCP requests processed", + "# TYPE gitlab_mcp_requests_processed_total counter", + `gitlab_mcp_requests_processed_total ${snapshot.requestsProcessed}`, + "", + "# HELP gitlab_mcp_requests_rejected_total Requests rejected, by reason", + "# TYPE gitlab_mcp_requests_rejected_total counter", + `gitlab_mcp_requests_rejected_total{reason="rate_limit"} ${snapshot.rejectedByRateLimit}`, + `gitlab_mcp_requests_rejected_total{reason="capacity"} ${snapshot.rejectedByCapacity}`, + "", + "# HELP gitlab_mcp_auth_failures_total Authentication failures", + "# TYPE gitlab_mcp_auth_failures_total counter", + `gitlab_mcp_auth_failures_total ${snapshot.authFailures}`, + "", + "# HELP gitlab_mcp_sessions_total Total sessions created", + "# TYPE gitlab_mcp_sessions_total counter", + `gitlab_mcp_sessions_total ${snapshot.totalSessions}`, + "", + "# HELP gitlab_mcp_sessions_expired_total Sessions expired due to inactivity", + "# TYPE gitlab_mcp_sessions_expired_total counter", + `gitlab_mcp_sessions_expired_total ${snapshot.expiredSessions}`, + "", + "# HELP gitlab_mcp_active_sessions Currently active sessions", + "# TYPE gitlab_mcp_active_sessions gauge", + `gitlab_mcp_active_sessions ${snapshot.activeSessions}`, + "", + "# HELP gitlab_mcp_authenticated_sessions Currently authenticated sessions", + "# TYPE gitlab_mcp_authenticated_sessions gauge", + `gitlab_mcp_authenticated_sessions ${snapshot.authenticatedSessions}`, + "", + "# HELP gitlab_mcp_client_pool_size Current GitLab client pool size", + "# TYPE gitlab_mcp_client_pool_size gauge", + `gitlab_mcp_client_pool_size ${snapshot.gitlabClientPool.size}`, + "", + "# HELP gitlab_mcp_client_pool_max_size Maximum GitLab client pool size", + "# TYPE gitlab_mcp_client_pool_max_size gauge", + `gitlab_mcp_client_pool_max_size ${snapshot.gitlabClientPool.maxSize}`, + "", + "# HELP gitlab_mcp_uptime_seconds Process uptime in seconds", + "# TYPE gitlab_mcp_uptime_seconds gauge", + `gitlab_mcp_uptime_seconds ${snapshot.uptime}`, + "", + "# HELP gitlab_mcp_memory_usage_bytes Node.js memory usage by type", + "# TYPE gitlab_mcp_memory_usage_bytes gauge", + ...Object.entries(snapshot.memoryUsage).map( + ([key, value]) => `gitlab_mcp_memory_usage_bytes{type="${escapePrometheusLabel(key)}"} ${value}` + ), + "", + "# HELP gitlab_mcp_stateless_requests_total Stateless MCP requests processed", + "# TYPE gitlab_mcp_stateless_requests_total counter", + `gitlab_mcp_stateless_requests_total ${snapshot.statelessRequests}`, + "", + "# HELP gitlab_mcp_stateless_auth_total Stateless auth successes, by source", + "# TYPE gitlab_mcp_stateless_auth_total counter", + `gitlab_mcp_stateless_auth_total{source="header"} ${snapshot.statelessAuthFromHeader}`, + `gitlab_mcp_stateless_auth_total{source="sealed_session_id"} ${snapshot.statelessAuthFromSealedSid}`, + "", + "# HELP gitlab_mcp_stateless_auth_failures_total Stateless auth failures", + "# TYPE gitlab_mcp_stateless_auth_failures_total counter", + `gitlab_mcp_stateless_auth_failures_total ${snapshot.statelessAuthFailures}`, + "", + "# HELP gitlab_mcp_stateless_session_id_rotations_total Stateless session id rotations", + "# TYPE gitlab_mcp_stateless_session_id_rotations_total counter", + `gitlab_mcp_stateless_session_id_rotations_total ${snapshot.statelessSidRotated}`, + "", + "# HELP gitlab_mcp_config_info Static configuration (value is always 1)", + "# TYPE gitlab_mcp_config_info gauge", + `gitlab_mcp_config_info{${configLabels}} 1`, + "", + ].join("\n"); +} diff --git a/test/server/metrics.test.ts b/test/server/metrics.test.ts new file mode 100644 index 00000000..115aa642 --- /dev/null +++ b/test/server/metrics.test.ts @@ -0,0 +1,50 @@ +import { describe, test } from "node:test"; +import assert from "node:assert"; +import { escapePrometheusLabel, formatPrometheusMetrics } from "../../server/metrics.js"; + +describe("Prometheus metrics formatting", () => { + test("escapes label values", () => { + assert.strictEqual(escapePrometheusLabel('a"b\\c\nd'), 'a\\"b\\\\c\\nd'); + }); + + test("formats current MCP metrics", () => { + const body = formatPrometheusMetrics({ + requestsProcessed: 2, + rejectedByRateLimit: 1, + rejectedByCapacity: 0, + authFailures: 3, + totalSessions: 4, + expiredSessions: 5, + activeSessions: 6, + authenticatedSessions: 7, + gitlabClientPool: { size: 8, maxSize: 100 }, + uptime: 9, + memoryUsage: { + rss: 10, + heapTotal: 11, + heapUsed: 12, + external: 13, + arrayBuffers: 14, + }, + statelessRequests: 15, + statelessAuthFromHeader: 16, + statelessAuthFromSealedSid: 17, + statelessAuthFailures: 18, + statelessSidRotated: 19, + config: { + maxSessions: 1000, + maxRequestsPerMinute: 60, + sessionTimeoutSeconds: 3600, + remoteAuthEnabled: true, + mcpOAuthEnabled: false, + statelessModeEnabled: false, + statelessRotationKey: false, + }, + }); + + assert.match(body, /# HELP gitlab_mcp_requests_processed_total/); + assert.match(body, /gitlab_mcp_requests_processed_total 2/); + assert.match(body, /gitlab_mcp_requests_rejected_total\{reason="rate_limit"\} 1/); + assert.match(body, /gitlab_mcp_config_info\{[^}]*remote_auth_enabled="true"/); + }); +}); From a13bbdeac8cf397403b0209c6c6e6b3cf25a99ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Jun 2026 04:40:06 +0000 Subject: [PATCH 2/2] Add metrics.test.ts to test:mock script This ensures the new Prometheus formatter test runs in CI with npm run test:all --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 080d5d8b..9fc2e6a0 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "changelog": "auto-changelog -p", "test": "npm run test:all", "test:all": "npm run build && npm run test:mock && npm run test:live", - "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts", + "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts && node --import tsx/esm --test test/server/metrics.test.ts", "test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts", "test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts", "test:live": "node test/validate-api.js",