diff --git a/.env.example b/.env.example index afb47dd356..43689465fd 100644 --- a/.env.example +++ b/.env.example @@ -523,6 +523,16 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review # data source after Grafana is up with: ./scripts/setup-github-datasource.sh (reads GITHUB_TOKEN below). # GITHUB_TOKEN= # fine-grained PAT: Pull requests:read, Issues:read, Contents:read # +# In-Grafana Sentry error/issue visualization (#5369, distinct from SENTRY_DSN above, which is for error +# REPORTING). Install is automatic (GF_INSTALL_PLUGINS); add the data source after Grafana is up with: +# ./scripts/setup-sentry-datasource.sh (reads the two vars below). SENTRY_DSN is NOT reusable here -- a DSN +# authenticates event ingestion, not the read/query API this datasource needs. +# SENTRY_API_TOKEN= # Sentry -> Settings -> Developer Settings -> Custom +# # Integrations -> New Internal Integration, with Read access on +# # Project, Issue & Event, and Organization scopes +# SENTRY_ORG_SLUG= +# SENTRY_API_URL=https://sentry.io # override only for a self-hosted Sentry instance +# # Discord notifications. Alertmanager → Discord (system/stack alerts) is configured in alertmanager/alertmanager.yml. # The ENGINE posts a per-repo review summary when it publishes a review — set a per-repo map and/or a global fallback: # DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... # global fallback for any repo without its own 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 3991255243..9962358ef9 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-operations.tsx @@ -790,6 +790,50 @@ SENTRY_RELEASE=gittensory-selfhost@2026.07.05 same subsystem.

+

Grafana Sentry data source (in-Grafana issue visualization)

+

+ Query recent Sentry issues, top issues by event count, and error-volume trend directly in + Grafana — no more switching tabs to check Sentry, and errors line up in time with the rest + of the stack's metrics/logs/traces. This is read-only visualization; alert routing to + Sentry/Discord/Slack is a separate, unrelated concern covered above. +

+ + The SENTRY_DSN above authenticates event ingestion (sending + errors to Sentry), not the read/query API this data source needs. You need a separate{" "} + Sentry Internal Integration token: Sentry → Settings → Developer Settings → + Custom Integrations → New Internal Integration (requires an Admin/Manager/Owner role in + Sentry), with Read access on the Project,{" "} + Issue & Event, and Organization resource scopes. + +

+ The{" "} + + grafana-sentry-datasource + {" "} + plugin installs automatically (GF_INSTALL_PLUGINS, same mechanism as the GitHub + data source below). Add the data source itself after Grafana is up — a backend datasource + whose token isn't ready at Grafana's own boot time would crash file-based + provisioning, so this one is added over the API instead, exactly like the GitHub data + source: +

+ +SENTRY_ORG_SLUG= +# SENTRY_API_URL=https://sentry.io # override only for a self-hosted Sentry instance`} + /> + +

+ The script is idempotent — safe to re-run after rotating the token. Open the{" "} + Sentry issues dashboard once it succeeds. Same trade-off as the GitHub data + source: this one is API-managed, so it stays editable via the Grafana UI rather than locked + read-only like the file-provisioned data sources. +

+

Re-gate sweeps (agent-regate-sweep)

Live PR review is webhook-driven, but open PRs still need periodic re-evaluation — the base diff --git a/docker-compose.yml b/docker-compose.yml index 8d0c91ad7c..71b5be7edc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -762,7 +762,9 @@ services: # check lives in entrypoint below. Runtime still fails closed unless the operator sets GRAFANA_ADMIN_PASSWORD. GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-${GRAFANA_LOCAL_SMOKE_PASSWORD:-}} GF_USERS_ALLOW_SIGN_UP: "false" - GF_INSTALL_PLUGINS: frser-sqlite-datasource,grafana-github-datasource + # grafana-sentry-datasource (#5369): first-party Grafana Labs plugin, confirmed Grafana-signed and + # installs cleanly on this pinned Grafana version (verified live, not assumed). + GF_INSTALL_PLUGINS: frser-sqlite-datasource,grafana-github-datasource,grafana-sentry-datasource # Read-only fine-grained PAT for the GitHub data source provisioning ($GITHUB_TOKEN expansion). From .env. GITHUB_TOKEN: "${GITHUB_TOKEN:-}" deploy: diff --git a/grafana/dashboards/resource-hub.json b/grafana/dashboards/resource-hub.json index c90e895c83..8448daaa73 100644 --- a/grafana/dashboards/resource-hub.json +++ b/grafana/dashboards/resource-hub.json @@ -66,7 +66,7 @@ "gridPos": { "h": 13, "w": 12, "x": 12, "y": 0 }, "options": { "mode": "markdown", - "content": "## 📊 Dashboards\n- **[Upstream PRs & issues (GitHub)](/d/gittensory-github)** — live, accurate census + open-PR triage (GitHub API).\n- **[Reviews & PRs (maintainer)](/d/gittensory-maintainer)** — gittensory's own review activity + reviewed-PR log.\n- **[AI usage](/d/gittensory-ai-usage)** — durable cross-provider ai_usage_events (filterable by provider/feature/model), live Prometheus counters, and Claude Code's own OTEL session telemetry, all in one place.\n- **[Gittensory (infra)](/d/gittensory)** — queue, jobs, HTTP, GitHub API cache/rate limits.\n- **[GPU metrics](/d/gittensory-gpu)** — utilization/VRAM for a self-hosted Ollama GPU box.\n- **[Infra health](/d/gittensory-infra-health)** — host CPU/mem/disk/network (node-exporter), per-container resource usage (cAdvisor), Redis, Qdrant, and whether the observability stack itself is up.\n- **[REES (review-enrichment)](/d/gittensory-rees)** — request outcomes/latency and per-analyzer run/timeout/degrade rates for the optional `--profile rees` service.\n- **[Browserless (visual review)](/d/gittensory-browserless)** — queue depth, concurrency, and rejection/error/timeout rate for the optional `--profile visual-review` screenshot service.\n\n## 📈 Metrics & logs\n- **Prometheus** — [targets](http://localhost:9090/targets) · [graph](http://localhost:9090)\n- **Alertmanager** — [alerts](http://localhost:9093)\n- **Loki** — query in [Explore](/explore) (pick the *Loki* datasource), e.g. `{compose_service=\"gittensory\"}`\n- **Sentry** — release/source-map enriched errors. Edit the dashboard link if your project URL differs.\n\n## 🩺 Quick health checks\n| What | Where |\n|---|---|\n| App serving | `GET /ready` → 200 |\n| AI wired | boot log `selfhost_ai_provider` |\n| Embeds wired | boot log `selfhost_embed_provider` |\n| Vectors wired | boot log `selfhost_vectorize` |\n| Token spend | **[AI usage](/d/gittensory-ai-usage)** dashboard |\n\n## 📚 Docs\n- [Maintainer self-hosting](https://gittensory.aethereal.dev/docs/maintainer-self-hosting) — setup, configuration, AI, REES, RAG, operations, and troubleshooting." + "content": "## 📊 Dashboards\n- **[Upstream PRs & issues (GitHub)](/d/gittensory-github)** — live, accurate census + open-PR triage (GitHub API).\n- **[Reviews & PRs (maintainer)](/d/gittensory-maintainer)** — gittensory's own review activity + reviewed-PR log.\n- **[AI usage](/d/gittensory-ai-usage)** — durable cross-provider ai_usage_events (filterable by provider/feature/model), live Prometheus counters, and Claude Code's own OTEL session telemetry, all in one place.\n- **[Gittensory (infra)](/d/gittensory)** — queue, jobs, HTTP, GitHub API cache/rate limits.\n- **[GPU metrics](/d/gittensory-gpu)** — utilization/VRAM for a self-hosted Ollama GPU box.\n- **[Infra health](/d/gittensory-infra-health)** — host CPU/mem/disk/network (node-exporter), per-container resource usage (cAdvisor), Redis, Qdrant, and whether the observability stack itself is up.\n- **[REES (review-enrichment)](/d/gittensory-rees)** — request outcomes/latency and per-analyzer run/timeout/degrade rates for the optional `--profile rees` service.\n- **[Browserless (visual review)](/d/gittensory-browserless)** — queue depth, concurrency, and rejection/error/timeout rate for the optional `--profile visual-review` screenshot service.\n- **[Sentry issues](/d/gittensory-sentry)** — recent unresolved issues, top issues by event count, and error-volume trend, queried live from Sentry (`scripts/setup-sentry-datasource.sh`). The plain link below still opens Sentry itself for actions this read-only view can't do (resolving/assigning issues).\n\n## 📈 Metrics & logs\n- **Prometheus** — [targets](http://localhost:9090/targets) · [graph](http://localhost:9090)\n- **Alertmanager** — [alerts](http://localhost:9093)\n- **Loki** — query in [Explore](/explore) (pick the *Loki* datasource), e.g. `{compose_service=\"gittensory\"}`\n- **Sentry** — release/source-map enriched errors. Edit the dashboard link if your project URL differs.\n\n## 🩺 Quick health checks\n| What | Where |\n|---|---|\n| App serving | `GET /ready` → 200 |\n| AI wired | boot log `selfhost_ai_provider` |\n| Embeds wired | boot log `selfhost_embed_provider` |\n| Vectors wired | boot log `selfhost_vectorize` |\n| Token spend | **[AI usage](/d/gittensory-ai-usage)** dashboard |\n\n## 📚 Docs\n- [Maintainer self-hosting](https://gittensory.aethereal.dev/docs/maintainer-self-hosting) — setup, configuration, AI, REES, RAG, operations, and troubleshooting." } } ] diff --git a/grafana/dashboards/sentry-issues.json b/grafana/dashboards/sentry-issues.json new file mode 100644 index 0000000000..1ba9e63ba1 --- /dev/null +++ b/grafana/dashboards/sentry-issues.json @@ -0,0 +1,99 @@ +{ + "__inputs": [], + "__requires": [ + { "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" }, + { "type": "datasource", "id": "grafana-sentry-datasource", "name": "Sentry", "version": "2.2.4" } + ], + "annotations": { "list": [] }, + "editable": false, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "description": "Sentry error/issue data queried directly in Grafana (#5369) — no more leaving Grafana to check Sentry, and errors can be correlated in time with the rest of the stack's metrics/logs/traces. Requires the Sentry data source configured via scripts/setup-sentry-datasource.sh (SENTRY_API_TOKEN + SENTRY_ORG_SLUG — a separate credential from SENTRY_DSN). Distinct from #5007 (Alertmanager/Sentry notification routing) — this is read-only visualization.", + "panels": [ + { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, "id": 1, "title": "Issue volume", "type": "row" }, + { + "datasource": { "type": "grafana-sentry-datasource", "uid": "${DS_SENTRY}" }, + "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 2, "fillOpacity": 10 } } }, + "gridPos": { "h": 9, "w": 24, "x": 0, "y": 1 }, + "id": 2, + "options": { "legend": { "calcs": ["mean", "max"], "displayMode": "table", "placement": "bottom" }, "tooltip": { "mode": "multi", "sort": "desc" } }, + "title": "Error events over time", + "description": "Event count trend for event.type:error, scoped to the dashboard's own time range.", + "type": "timeseries", + "targets": [ + { + "refId": "A", + "datasource": { "type": "grafana-sentry-datasource", "uid": "${DS_SENTRY}" }, + "queryType": "eventsStats", + "projectIds": [], + "environments": [], + "eventsStatsQuery": "event.type:error", + "eventsStatsYAxis": ["count()"], + "eventsStatsGroups": [] + } + ] + }, + + { "collapsed": false, "gridPos": { "h": 1, "w": 24, "x": 0, "y": 10 }, "id": 10, "title": "Issues", "type": "row" }, + { + "datasource": { "type": "grafana-sentry-datasource", "uid": "${DS_SENTRY}" }, + "gridPos": { "h": 10, "w": 12, "x": 0, "y": 11 }, + "id": 11, + "title": "Recent unresolved issues", + "description": "Most recently seen unresolved issues. Add a project filter directly to this panel's target (projectIds) once you know your Sentry numeric project id(s) -- left empty here queries across every project the token can read.", + "type": "table", + "options": { "showHeader": true, "cellHeight": "sm" }, + "fieldConfig": { "defaults": { "custom": { "align": "auto", "filterable": true } } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "grafana-sentry-datasource", "uid": "${DS_SENTRY}" }, + "queryType": "issues", + "projectIds": [], + "environments": [], + "issuesQuery": "is:unresolved", + "issuesSort": "date", + "issuesLimit": 20 + } + ] + }, + { + "datasource": { "type": "grafana-sentry-datasource", "uid": "${DS_SENTRY}" }, + "gridPos": { "h": 10, "w": 12, "x": 12, "y": 11 }, + "id": 12, + "title": "Top issues by event count", + "description": "Unresolved issues sorted by total event volume -- the highest-impact issues to triage first, not just the newest.", + "type": "table", + "options": { "showHeader": true, "cellHeight": "sm" }, + "fieldConfig": { "defaults": { "custom": { "align": "auto", "filterable": true } } }, + "targets": [ + { + "refId": "A", + "datasource": { "type": "grafana-sentry-datasource", "uid": "${DS_SENTRY}" }, + "queryType": "issues", + "projectIds": [], + "environments": [], + "issuesQuery": "is:unresolved", + "issuesSort": "freq", + "issuesLimit": 10 + } + ] + } + ], + "refresh": "5m", + "schemaVersion": 38, + "tags": ["gittensory", "sentry", "observability"], + "templating": { + "list": [ + { "current": {}, "hide": 0, "includeAll": false, "name": "DS_SENTRY", "options": [], "query": "grafana-sentry-datasource", "refresh": 1, "type": "datasource" } + ] + }, + "time": { "from": "now-24h", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "Gittensory — Sentry issues", + "uid": "gittensory-sentry", + "version": 1 +} diff --git a/scripts/setup-sentry-datasource.sh b/scripts/setup-sentry-datasource.sh new file mode 100755 index 0000000000..7408311be1 --- /dev/null +++ b/scripts/setup-sentry-datasource.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Add (or update) the Sentry data source in Grafana via the API — in-Grafana error/issue visualization, +# correlated in time with the rest of the stack (#5369). Distinct from #5007 (Alertmanager/Sentry +# notification routing): this is read-only visualization, not paging. +# +# Done over the API rather than file-provisioning, for the exact reason scripts/setup-github-datasource.sh +# already documents: a backend datasource whose plugin/token isn't ready at boot would crash Grafana's +# provisioning, so we add it after Grafana is up. +# +# IMPORTANT: SENTRY_DSN (already used for this stack's own error REPORTING to Sentry, see the self-hosting +# docs) is NOT sufficient here and cannot be reused — a DSN authenticates event *ingestion*, not the +# read/query API this datasource needs. You need a separate Sentry "Internal Integration" token (Sentry → +# Settings → Developer Settings → Custom Integrations → New Internal Integration, requires an Admin/Manager/ +# Owner role) with Read access on the Project, Issue & Event, and Organization resource scopes. +# +# KNOWN, ACCEPTED trade-off: same as the GitHub datasource — API-managed, so NOT locked read-only via +# Grafana's `readOnly` field (that flag can only be set by file provisioning, not the datasource API). +# +# Prereqs: --profile observability running, the grafana-sentry-datasource plugin installed +# (GF_INSTALL_PLUGINS, confirmed Grafana-signed and Grafana-13.x-compatible), and a Sentry Internal +# Integration auth token with the scopes above. +# +# Usage: +# SENTRY_API_TOKEN= SENTRY_ORG_SLUG= \ +# GRAFANA_ADMIN_PASSWORD=... ./scripts/setup-sentry-datasource.sh +# # or rely on values already in ./.env (SENTRY_API_TOKEN, SENTRY_ORG_SLUG, GRAFANA_ADMIN_PASSWORD) +set -euo pipefail + +GRAFANA_URL="${GRAFANA_URL:-http://localhost:3000}" +[ -f .env ] && { set -a; . ./.env; set +a; } +: "${SENTRY_API_TOKEN:?Set SENTRY_API_TOKEN (a Sentry Internal Integration token, NOT your SENTRY_DSN) in the environment or .env}" +: "${SENTRY_ORG_SLUG:?Set SENTRY_ORG_SLUG (your Sentry organization slug) in the environment or .env}" +: "${GRAFANA_ADMIN_PASSWORD:?Set GRAFANA_ADMIN_PASSWORD in the environment or .env}" +AUTH="admin:${GRAFANA_ADMIN_PASSWORD}" +# https://sentry.io for Sentry SaaS; override for a self-hosted Sentry instance. +SENTRY_API_URL="${SENTRY_API_URL:-https://sentry.io}" + +payload() { + cat </dev/null 2>&1; then + echo "Updating existing Sentry data source…" + curl -sf -u "$AUTH" -H 'content-type: application/json' -X PUT \ + "$GRAFANA_URL/api/datasources/uid/sentry" -d "$(payload)" >/dev/null +else + echo "Creating Sentry data source…" + curl -sf -u "$AUTH" -H 'content-type: application/json' -X POST \ + "$GRAFANA_URL/api/datasources" -d "$(payload)" >/dev/null +fi + +echo "Done. Verifying health…" +curl -sf -u "$AUTH" -X POST "$GRAFANA_URL/api/datasources/uid/sentry/health" 2>/dev/null \ + | grep -q '"status":"OK"' && echo "✓ Sentry data source healthy" || echo "⚠ Added, but health check did not return OK — verify SENTRY_API_TOKEN's scopes and SENTRY_ORG_SLUG." diff --git a/test/unit/docs-selfhost-sentry-observability.test.ts b/test/unit/docs-selfhost-sentry-observability.test.ts index cf75379b06..fa91a5b1b2 100644 --- a/test/unit/docs-selfhost-sentry-observability.test.ts +++ b/test/unit/docs-selfhost-sentry-observability.test.ts @@ -45,4 +45,13 @@ describe("self-host Sentry observability docs (#1824)", () => { expect(operations).toContain("selfhost_backup_advisory"); expect(operations).toContain("scheduled-loop"); }); + + it("documents the in-Grafana Sentry data source (#5369) as a separate credential from SENTRY_DSN", () => { + expect(operations).toContain("Grafana Sentry data source"); + expect(operations).toContain("SENTRY_API_TOKEN"); + expect(operations).toContain("SENTRY_ORG_SLUG"); + expect(operations).toContain("setup-sentry-datasource.sh"); + expect(operations).toContain("Internal Integration"); + expect(operations).toMatch(/SENTRY_DSN.*NOT reusable|not reusable.*SENTRY_DSN/i); + }); }); diff --git a/test/unit/selfhost-grafana-sentry-datasource.test.ts b/test/unit/selfhost-grafana-sentry-datasource.test.ts new file mode 100644 index 0000000000..eb7a18ed1f --- /dev/null +++ b/test/unit/selfhost-grafana-sentry-datasource.test.ts @@ -0,0 +1,126 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +function readYaml(path: string): unknown { + return parse(readFileSync(join(process.cwd(), path), "utf8")); +} + +function record(value: unknown): Record { + expect(value).toBeTruthy(); + expect(typeof value).toBe("object"); + return value as Record; +} + +type DashboardTarget = { + expr?: string; + queryType?: string; + projectIds?: string[]; + issuesQuery?: string; + issuesSort?: string; + eventsStatsQuery?: string; + eventsStatsYAxis?: string[]; +}; +type DashboardPanel = { + title?: string; + type?: string; + datasource?: { type?: string; uid?: string }; + targets?: DashboardTarget[]; +}; +type Dashboard = { uid: string; title: string; tags: string[]; panels: DashboardPanel[] }; + +const dashboardPath = join(process.cwd(), "grafana/dashboards/sentry-issues.json"); +function readDashboard(): Dashboard { + return JSON.parse(readFileSync(dashboardPath, "utf8")) as Dashboard; +} +function allTargets(dashboard = readDashboard()): DashboardTarget[] { + return dashboard.panels.flatMap((panel) => panel.targets ?? []); +} + +describe("Grafana Sentry data source (#5369)", () => { + it("installs the grafana-sentry-datasource plugin, confirmed Grafana-signed and installable on the pinned Grafana version", () => { + const compose = record(readYaml("docker-compose.yml")); + const grafana = record(record(compose.services).grafana); + expect(grafana.environment?.GF_INSTALL_PLUGINS).toContain("grafana-sentry-datasource"); + // Never regress the two plugins already relied on elsewhere. + expect(grafana.environment?.GF_INSTALL_PLUGINS).toContain("frser-sqlite-datasource"); + expect(grafana.environment?.GF_INSTALL_PLUGINS).toContain("grafana-github-datasource"); + }); + + it("is API-provisioned (no file-based YAML datasource), matching the GitHub data source's own boot-crash-avoidance rationale", () => { + const datasourceFiles = readdirSync(join(process.cwd(), "grafana/provisioning/datasources")); + for (const file of datasourceFiles) { + const contents = readFileSync(join(process.cwd(), "grafana/provisioning/datasources", file), "utf8"); + expect(contents, file).not.toContain("grafana-sentry-datasource"); + } + }); + + it("ships an idempotent setup-sentry-datasource.sh that requires a token DISTINCT from SENTRY_DSN", () => { + const script = readFileSync(join(process.cwd(), "scripts/setup-sentry-datasource.sh"), "utf8"); + + expect(script).toContain("SENTRY_API_TOKEN"); + expect(script).toContain("SENTRY_ORG_SLUG"); + expect(script).toContain("grafana-sentry-datasource"); + // The whole point of this script's own doc comment: a DSN cannot substitute for the API token. + expect(script).toMatch(/SENTRY_DSN.*NOT sufficient|not sufficient.*SENTRY_DSN|NOT reusable|cannot be reused/i); + // Idempotent update-vs-create, mirroring setup-github-datasource.sh's own pattern. + expect(script).toContain("api/datasources/uid/sentry"); + expect(script).toMatch(/-X PUT/); + expect(script).toMatch(/-X POST/); + expect(script).toContain("secureJsonData"); + expect(script).toContain("authToken"); + expect(script).toContain("orgSlug"); + // Health check, same shape as the GitHub script. + expect(script).toContain("/health"); + }); + + it("setup-sentry-datasource.sh is executable, matching setup-github-datasource.sh's own mode", () => { + const mode = statSync(join(process.cwd(), "scripts/setup-sentry-datasource.sh")).mode; + // Owner-execute bit (0o100). + expect(mode & 0o100).not.toBe(0); + }); + + it("documents SENTRY_API_TOKEN/SENTRY_ORG_SLUG in .env.example, distinct from the existing SENTRY_DSN block", () => { + const env = readFileSync(join(process.cwd(), ".env.example"), "utf8"); + expect(env).toContain("SENTRY_API_TOKEN"); + expect(env).toContain("SENTRY_ORG_SLUG"); + expect(env).toContain("setup-sentry-datasource.sh"); + }); + + it("declares the expected uid/title/tags and a $DS_SENTRY datasource-type template variable", () => { + const dashboard = readDashboard(); + expect(dashboard.uid).toBe("gittensory-sentry"); + expect(dashboard.title).toBe("Gittensory — Sentry issues"); + expect(dashboard.tags).toEqual(["gittensory", "sentry", "observability"]); + }); + + it("every panel uses the grafana-sentry-datasource type and the ${DS_SENTRY} variable, never a hardcoded uid", () => { + const dashboard = readDashboard(); + for (const panel of dashboard.panels) { + if (panel.type === "row") continue; + expect(panel.datasource?.type, panel.title).toBe("grafana-sentry-datasource"); + expect(panel.datasource?.uid, panel.title).toBe("${DS_SENTRY}"); + } + }); + + it("uses the real query schema confirmed against the live plugin (queryType/issuesQuery/issuesSort/eventsStatsYAxis)", () => { + const targets = allTargets(); + expect(targets.some((t) => t.queryType === "eventsStats" && t.eventsStatsQuery === "event.type:error" && t.eventsStatsYAxis?.includes("count()"))).toBe(true); + expect(targets.some((t) => t.queryType === "issues" && t.issuesQuery === "is:unresolved" && t.issuesSort === "date")).toBe(true); + expect(targets.some((t) => t.queryType === "issues" && t.issuesQuery === "is:unresolved" && t.issuesSort === "freq")).toBe(true); + for (const target of targets) { + expect(Array.isArray(target.projectIds), JSON.stringify(target)).toBe(true); + } + }); + + it("links the new in-Grafana Sentry dashboard from resource-hub.json, without dropping the existing external Sentry link", () => { + const hub = JSON.parse(readFileSync(join(process.cwd(), "grafana/dashboards/resource-hub.json"), "utf8")) as { + links: Array<{ title?: string; url?: string }>; + panels: Array<{ options?: { content?: string } }>; + }; + expect(hub.links.some((l) => l.title === "Sentry — errors")).toBe(true); + const markdown = hub.panels.map((p) => p.options?.content ?? "").join("\n"); + expect(markdown).toContain("/d/gittensory-sentry"); + }); +});