From 0f71bc6660e0f4b8655fddb2b6988b5d14b80521 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:24:50 -0700 Subject: [PATCH] feat(selfhost): GPU + AI-provider observability (exporter, Prometheus, dashboard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gpu-exporter service (utkuozdemir/nvidia_gpu_exporter:1.3.2), own --profile gpu, matching the ollama/postgres-exporter style - gpu Prometheus scrape job, absent/down gracefully on non-GPU installs - New gittensory_ai_provider_request_duration_seconds histogram and gittensory_ai_provider_request_errors_total counter, labeled by provider and request_kind, instrumented around the existing withReviewSpan call site in runProviderWithOtel — the error counter mirrors gittensory_ai_provider_failures_total's exemption for expected embedding-routing fallbacks - grafana/dashboards/gpu-metrics.json: GPU Summary (utilization/VRAM/ temp/power stats), AI Request Performance (rate/p50-p95-p99 latency/ error rate), and GPU Trends rows - Resource-footprint table entry in the self-hosting operations docs This is the baseline the rest of the GPU-optimization work (embedding cache, advisory routing, vision-verify) needs to measure real impact against, rather than one-off manual benchmarks. --- .../routes/docs.self-hosting-operations.tsx | 15 ++ docker-compose.yml | 26 +++ grafana/dashboards/gpu-metrics.json | 196 ++++++++++++++++++ prometheus/prometheus.yml | 7 + src/selfhost/ai.ts | 15 +- src/selfhost/metrics.ts | 2 + test/unit/selfhost-ai.test.ts | 36 ++++ 7 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 grafana/dashboards/gpu-metrics.json diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx index 4b53cab205..ebcb74aad3 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx @@ -307,6 +307,21 @@ DISCORD_REPO_WEBHOOKS={"owner/repoA":"https://discord.com/api/webhooks/...","own the file. +
--profile gpu
+ nvidia-smi
+ gpu-exporter (nvidia_gpu_exporter) feeding the{" "}
+ gpu Prometheus job and the GPU metrics Grafana dashboard — requires the
+ NVIDIA Container Toolkit on the host; a device reservation only takes effect once
+ this profile is activated, so a non-GPU host is unaffected either way.
+ --profile backup
diff --git a/docker-compose.yml b/docker-compose.yml
index 8ab7100efb..4005e7fdbe 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,6 +15,8 @@
# --profile pgbouncer PgBouncer connection pooler in front of Postgres
# --profile qdrant Qdrant vector database for RAG
# --profile ollama Local Ollama AI backend
+# --profile gpu GPU metrics exporter (nvidia-smi -> Prometheus -> Grafana), requires the NVIDIA
+# Container Toolkit; combine with --profile ollama on a GPU host
# --profile visual-review Headless Chromium (browserless) for before/after PR screenshot capture
# --profile rees Review-enrichment service (REES) for heavier PR analysis, in-network only
# --profile litestream Continuous SQLite backup to S3/B2/R2 via Litestream
@@ -369,6 +371,30 @@ services:
limits:
memory: "${OLLAMA_MEM_LIMIT:-20g}"
+ # ── GPU metrics exporter (--profile gpu, #4367) ─────────────────────────────
+ # Shells to `nvidia-smi` via the NVIDIA Container Toolkit -- DCGM is built for datacenter fleets and is
+ # the wrong fit for a single consumer/workstation-class card like the one this deployment targets. The
+ # device reservation below only takes effect once this profile is actually activated, so a host with no
+ # GPU (and no --profile gpu) never evaluates it.
+ gpu-exporter:
+ image: utkuozdemir/nvidia_gpu_exporter:1.3.2
+ restart: unless-stopped
+ <<: *default-logging
+ profiles: ["gpu"]
+ environment:
+ # The exporter only shells out to nvidia-smi -- it never runs CUDA workloads itself, so the
+ # lightest capability tier is sufficient (matches the upstream project's own recommended config).
+ NVIDIA_DRIVER_CAPABILITIES: utility
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: all
+ capabilities: [gpu]
+ expose:
+ - "9835"
+
# ── Browserless / visual review (--profile visual-review) ──────────────────
# Headless Chromium for automated before/after PR screenshot capture (src/review/visual/**).
# Set BOTH of these in .env (browserless always requires a token; there is no way around setting
diff --git a/grafana/dashboards/gpu-metrics.json b/grafana/dashboards/gpu-metrics.json
new file mode 100644
index 0000000000..36685d17b7
--- /dev/null
+++ b/grafana/dashboards/gpu-metrics.json
@@ -0,0 +1,196 @@
+{
+ "__inputs": [],
+ "__requires": [
+ { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" },
+ { "type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "1.0.0" }
+ ],
+ "annotations": { "list": [] },
+ "editable": false,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 1,
+ "id": null,
+ "links": [],
+ "panels": [
+ { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "GPU Summary", "type": "row" },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "mappings": [],
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 0.7 }, { "color": "red", "value": 0.9 }] },
+ "unit": "percentunit"
+ }
+ },
+ "gridPos": { "h": 4, "w": 6, "x": 0, "y": 1 },
+ "id": 2,
+ "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
+ "title": "GPU Utilization",
+ "type": "stat",
+ "targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "nvidia_smi_utilization_gpu_ratio", "legendFormat": "util" }]
+ },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "mappings": [],
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
+ "unit": "bytes"
+ }
+ },
+ "gridPos": { "h": 4, "w": 6, "x": 6, "y": 1 },
+ "id": 3,
+ "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
+ "title": "VRAM Used",
+ "description": "See the VRAM Used vs Total trend panel below for headroom against the card's total capacity.",
+ "type": "stat",
+ "targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "nvidia_smi_memory_used_bytes", "legendFormat": "used" }]
+ },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "mappings": [],
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "yellow", "value": 75 }, { "color": "red", "value": 85 }] },
+ "unit": "celsius"
+ }
+ },
+ "gridPos": { "h": 4, "w": 6, "x": 12, "y": 1 },
+ "id": 4,
+ "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
+ "title": "GPU Temperature",
+ "type": "stat",
+ "targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "nvidia_smi_temperature_gpu", "legendFormat": "temp" }]
+ },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": {
+ "defaults": {
+ "color": { "mode": "thresholds" },
+ "mappings": [],
+ "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] },
+ "unit": "watt"
+ }
+ },
+ "gridPos": { "h": 4, "w": 6, "x": 18, "y": 1 },
+ "id": 5,
+ "options": { "colorMode": "background", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
+ "title": "Power Draw",
+ "type": "stat",
+ "targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "nvidia_smi_power_draw_watts", "legendFormat": "power" }]
+ },
+
+ { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 5 }, "id": 10, "title": "AI Request Performance", "type": "row" },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 2, "fillOpacity": 10 }, "unit": "reqps" } },
+ "gridPos": { "h": 8, "w": 8, "x": 0, "y": 6 },
+ "id": 11,
+ "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } },
+ "title": "AI Request Rate",
+ "description": "Request rate by provider and request kind (embedding vs review), derived from the new request-duration histogram's observation count.",
+ "type": "timeseries",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "expr": "sum by (provider, request_kind) (rate(gittensory_ai_provider_request_duration_seconds_count[5m]))",
+ "legendFormat": "{{provider}} / {{request_kind}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 2, "fillOpacity": 10 }, "unit": "s" } },
+ "gridPos": { "h": 8, "w": 8, "x": 8, "y": 6 },
+ "id": 12,
+ "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } },
+ "title": "AI Request Latency (p50 / p95 / p99)",
+ "type": "timeseries",
+ "targets": [
+ { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "histogram_quantile(0.50, sum by (le) (rate(gittensory_ai_provider_request_duration_seconds_bucket[5m])))", "legendFormat": "p50", "refId": "A" },
+ { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "histogram_quantile(0.95, sum by (le) (rate(gittensory_ai_provider_request_duration_seconds_bucket[5m])))", "legendFormat": "p95", "refId": "B" },
+ { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "histogram_quantile(0.99, sum by (le) (rate(gittensory_ai_provider_request_duration_seconds_bucket[5m])))", "legendFormat": "p99", "refId": "C" }
+ ]
+ },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 2, "fillOpacity": 10 }, "unit": "reqps" } },
+ "gridPos": { "h": 8, "w": 8, "x": 16, "y": 6 },
+ "id": 13,
+ "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } },
+ "title": "AI Request Error Rate",
+ "description": "Excludes expected embedding-routing fallbacks (those never increment this counter -- see gittensory_ai_provider_request_errors_total's HELP text).",
+ "type": "timeseries",
+ "targets": [
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "expr": "sum by (provider, request_kind) (rate(gittensory_ai_provider_request_errors_total[5m]))",
+ "legendFormat": "{{provider}} / {{request_kind}}",
+ "refId": "A"
+ }
+ ]
+ },
+
+ { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 14 }, "id": 20, "title": "GPU Trends", "type": "row" },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 2, "fillOpacity": 10 }, "unit": "percentunit", "max": 1, "min": 0 } },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 15 },
+ "id": 21,
+ "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } },
+ "title": "GPU Utilization Over Time",
+ "type": "timeseries",
+ "targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "nvidia_smi_utilization_gpu_ratio", "legendFormat": "util", "refId": "A" }]
+ },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 2, "fillOpacity": 10 }, "unit": "bytes" } },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 15 },
+ "id": 22,
+ "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } },
+ "title": "VRAM Used vs Total",
+ "type": "timeseries",
+ "targets": [
+ { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "nvidia_smi_memory_used_bytes", "legendFormat": "used", "refId": "A" },
+ { "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "nvidia_smi_memory_total_bytes", "legendFormat": "total", "refId": "B" }
+ ]
+ },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 2, "fillOpacity": 10 }, "unit": "celsius" } },
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 23 },
+ "id": 23,
+ "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } },
+ "title": "GPU Temperature Over Time",
+ "type": "timeseries",
+ "targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "nvidia_smi_temperature_gpu", "legendFormat": "temp", "refId": "A" }]
+ },
+ {
+ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
+ "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 2, "fillOpacity": 10 }, "unit": "watt" } },
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 23 },
+ "id": 24,
+ "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } },
+ "title": "Power Draw Over Time",
+ "type": "timeseries",
+ "targets": [{ "datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" }, "expr": "nvidia_smi_power_draw_watts", "legendFormat": "power", "refId": "A" }]
+ }
+ ],
+ "refresh": "30s",
+ "schemaVersion": 38,
+ "tags": ["gittensory", "gpu"],
+ "templating": {
+ "list": [
+ { "current": {}, "hide": 0, "includeAll": false, "name": "DS_PROMETHEUS", "options": [], "query": "prometheus", "refresh": 1, "type": "datasource" }
+ ]
+ },
+ "time": { "from": "now-6h", "to": "now" },
+ "timepicker": {},
+ "timezone": "browser",
+ "title": "Gittensory — GPU Metrics",
+ "uid": "gittensory-gpu",
+ "version": 1
+}
diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml
index b2715e8e2f..44b330c3f3 100644
--- a/prometheus/prometheus.yml
+++ b/prometheus/prometheus.yml
@@ -30,6 +30,13 @@ scrape_configs:
- targets: ["postgres-exporter:9187"]
scrape_interval: 30s
+ # GPU utilization/VRAM/temp/power from nvidia_gpu_exporter (#4367). The exporter starts only with
+ # --profile gpu; on a non-GPU install this target just shows as absent/down, no alert fires on it.
+ - job_name: gpu
+ static_configs:
+ - targets: ["gpu-exporter:9835"]
+ scrape_interval: 15s
+
# Backup freshness from the read-only backup-exporter sidecar. The metrics exist only when the backup
# profile is active, which keeps backup alerts opt-in with the backup feature.
- job_name: gittensory-backup
diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts
index 699ffc8a90..1d6cfaba42 100644
--- a/src/selfhost/ai.ts
+++ b/src/selfhost/ai.ts
@@ -9,7 +9,7 @@
import type { AiContentBlock, CombineStrategy, OnMerge } from "../services/ai-review";
import { isConfiguredSelfHostProvider, resolveConfiguredProviderNames } from "./ai-config";
export { assertNoLegacySharedAiEnv } from "./ai-config";
-import { incr } from "./metrics";
+import { incr, observe } from "./metrics";
import { withReviewSpan } from "./tracing";
import { delimiter } from "node:path";
@@ -1096,12 +1096,18 @@ async function runProviderWithOtel(
`circuit_open: provider "${provider.name}" is in cooldown after ${AI_PROVIDER_FAILURE_THRESHOLD} consecutive failures — skipping this attempt`,
);
}
+ const requestKindLabel = requestKind(options);
+ const startedAtMs = Date.now();
try {
const result = await withReviewSpan(
"selfhost.ai.provider",
- { "ai.provider": provider.name, "ai.model": model || "default", "ai.request_kind": requestKind(options) },
+ { "ai.provider": provider.name, "ai.model": model || "default", "ai.request_kind": requestKindLabel },
() => provider.ai.run(model, options),
);
+ observe("gittensory_ai_provider_request_duration_seconds", (Date.now() - startedAtMs) / 1000, {
+ provider: provider.name,
+ request_kind: requestKindLabel,
+ });
aiProviderCircuits.delete(provider.name);
if (result.usage) {
return {
@@ -1115,8 +1121,13 @@ async function runProviderWithOtel(
}
return result;
} catch (error) {
+ observe("gittensory_ai_provider_request_duration_seconds", (Date.now() - startedAtMs) / 1000, {
+ provider: provider.name,
+ request_kind: requestKindLabel,
+ });
if (isExpectedEmbeddingRoutingError(options, error)) throw error;
incr("gittensory_ai_provider_failures_total", { provider: provider.name });
+ incr("gittensory_ai_provider_request_errors_total", { provider: provider.name, request_kind: requestKindLabel });
// Re-read the map here rather than reusing the `circuit` captured above: that read happened BEFORE the
// `await` on the real provider call, so under concurrent same-provider calls it can be stale by the time
// this catch runs, and computing `failures` from it would clobber a sibling call's write (lost-update race)
diff --git a/src/selfhost/metrics.ts b/src/selfhost/metrics.ts
index 260b9f685e..00e31cc138 100644
--- a/src/selfhost/metrics.ts
+++ b/src/selfhost/metrics.ts
@@ -111,6 +111,8 @@ const DEFAULT_METRIC_META: readonly (readonly [string, MetricMeta])[] = [
["gittensory_ai_total_tokens_total", { help: "AI provider total tokens observed.", type: "counter" }],
["gittensory_ai_provider_circuit_open_total", { help: "AI provider circuit-open events.", type: "counter" }],
["gittensory_ai_provider_failures_total", { help: "AI provider failures by provider.", type: "counter" }],
+ ["gittensory_ai_provider_request_duration_seconds", { help: "AI provider request duration in seconds, by provider and request kind.", type: "histogram" }],
+ ["gittensory_ai_provider_request_errors_total", { help: "AI provider request errors, by provider and request kind (excludes expected embedding-routing fallbacks).", type: "counter" }],
["gittensory_ai_review_cache_hit_total", { help: "AI review cache hits.", type: "counter" }],
["gittensory_ai_review_cache_miss_total", { help: "AI review cache misses.", type: "counter" }],
["gittensory_ai_review_cache_write_error_total", { help: "AI review cache write errors.", type: "counter" }],
diff --git a/test/unit/selfhost-ai.test.ts b/test/unit/selfhost-ai.test.ts
index 661ec61810..54013d99f4 100644
--- a/test/unit/selfhost-ai.test.ts
+++ b/test/unit/selfhost-ai.test.ts
@@ -437,6 +437,42 @@ describe("createChainAi (fallback)", () => {
});
});
+describe("AI provider request duration/error metrics (#4367)", () => {
+ it("records a duration observation and no error on a successful call", async () => {
+ const provider = { name: "gpu-metrics-ok-provider", ai: { run: async () => ({ response: "ok" }) } };
+ await createChainAi([provider]).run("m", { prompt: "review this" });
+ const metrics = await renderMetrics();
+ expect(metrics).toContain('gittensory_ai_provider_request_duration_seconds_count{provider="gpu-metrics-ok-provider",request_kind="review"} 1');
+ expect(metrics).not.toContain('gittensory_ai_provider_request_errors_total{provider="gpu-metrics-ok-provider"');
+ });
+
+ it("labels an embedding call's duration by request_kind=embedding", async () => {
+ const provider = { name: "gpu-metrics-embed-provider", ai: { run: async () => ({ response: "ok" }) } };
+ await createChainAi([provider]).run("m", { text: ["chunk one", "chunk two"] });
+ const metrics = await renderMetrics();
+ expect(metrics).toContain('gittensory_ai_provider_request_duration_seconds_count{provider="gpu-metrics-embed-provider",request_kind="embedding"} 1');
+ });
+
+ it("records duration AND increments the error counter on a real failure", async () => {
+ const provider = { name: "gpu-metrics-fail-provider", ai: { run: async () => { throw new Error("boom"); } } };
+ await expect(createChainAi([provider]).run("m", { prompt: "review this" })).rejects.toThrow(/boom/);
+ const metrics = await renderMetrics();
+ expect(metrics).toContain('gittensory_ai_provider_request_duration_seconds_count{provider="gpu-metrics-fail-provider",request_kind="review"} 1');
+ expect(metrics).toContain('gittensory_ai_provider_request_errors_total{provider="gpu-metrics-fail-provider",request_kind="review"} 1');
+ });
+
+ it("records duration but NOT the error counter for an expected embedding-routing fallback (matches gittensory_ai_provider_failures_total's exemption)", async () => {
+ const provider = {
+ name: "gpu-metrics-routing-provider",
+ ai: { run: async () => { throw new Error("claude_code_no_embed"); } },
+ };
+ await expect(createChainAi([provider]).run("m", { text: ["chunk"] })).rejects.toThrow();
+ const metrics = await renderMetrics();
+ expect(metrics).toContain('gittensory_ai_provider_request_duration_seconds_count{provider="gpu-metrics-routing-provider",request_kind="embedding"} 1');
+ expect(metrics).not.toContain('gittensory_ai_provider_request_errors_total{provider="gpu-metrics-routing-provider"');
+ });
+});
+
describe("per-provider circuit breaker (#2540 — skip fast during a sustained outage)", () => {
afterEach(() => {
vi.useRealTimers();