diff --git a/.env.example b/.env.example index 9a952fdb25..68b959a90b 100644 --- a/.env.example +++ b/.env.example @@ -661,18 +661,6 @@ 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). Self-host no longer sends new events to Sentry -# (error tracking now goes through PostHog, above) -- this panel only remains useful for an operator's own -# PRE-EXISTING Sentry issue history in an org they still separately maintain; pending removal (epic #8286). -# 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). A Sentry event-ingestion DSN is NOT -# reusable here -- this datasource needs a separate read/query API token instead. -# 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/loopover-ui/content/docs/self-hosting-operations.mdx b/apps/loopover-ui/content/docs/self-hosting-operations.mdx index 0dd2c8ece3..363877a07d 100644 --- a/apps/loopover-ui/content/docs/self-hosting-operations.mdx +++ b/apps/loopover-ui/content/docs/self-hosting-operations.mdx @@ -842,47 +842,6 @@ means the process may still be alive but the recurring work is not checking in o Pair the monitor with queue depth, dead-job counts, and the structured error log for the same subsystem. -## Grafana Sentry data source (in-Grafana issue visualization) - - - Self-host no longer sends new events to Sentry (error tracking now goes through PostHog, above) - -- this panel only remains useful for an operator's own PRE-EXISTING Sentry issue history in an - org they still separately maintain. It is pending removal (tracked under epic #8286). - - -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. - - - This data source needs a separate **Sentry Internal Integration token**, not an event-ingestion - DSN: 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](https://grafana.com/grafana/plugins/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/apps/loopover-ui/src/lib/selfhost-docs-audit.ts b/apps/loopover-ui/src/lib/selfhost-docs-audit.ts index fdd2a4ca2c..6645dcb338 100644 --- a/apps/loopover-ui/src/lib/selfhost-docs-audit.ts +++ b/apps/loopover-ui/src/lib/selfhost-docs-audit.ts @@ -170,7 +170,7 @@ export const SELFHOST_SOURCE_OF_TRUTH_ROWS: readonly SelfHostSourceOfTruthRow[] runtimeSources: [".github/workflows/release-selfhost.yml", "Dockerfile"], docsPath: "/docs/self-hosting-releases", notes: - "Release workflow uploads source maps to Sentry; maps never ship inside the runtime image.", + "Release workflow uploads source maps to PostHog; maps never ship inside the runtime image.", }, { topic: "Release smoke matrix and image-contents audit", diff --git a/docker-compose.yml b/docker-compose.yml index 245afa2959..5f3bc5c75e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -927,9 +927,7 @@ 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" - # 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 + GF_INSTALL_PLUGINS: frser-sqlite-datasource,grafana-github-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 5a64463eff..12dfafdab6 100644 --- a/grafana/dashboards/resource-hub.json +++ b/grafana/dashboards/resource-hub.json @@ -41,7 +41,7 @@ { "title": "Sentry — errors", "type": "link", - "url": "https://jsonbored.sentry.io/projects/gittensory/", + "url": "https://jsonbored.sentry.io/projects/loopover/", "icon": "bolt", "targetBlank": true, "tooltip": "Crash & error tracking (uncaught, dead-letter jobs, review failures)" @@ -66,7 +66,7 @@ "gridPos": { "h": 13, "w": 12, "x": 12, "y": 0 }, "options": { "mode": "markdown", - "content": "## 📊 Dashboards\n- **[Upstream PRs & issues (GitHub)](/d/loopover-github)** — live, accurate census + open-PR triage (GitHub API).\n- **[Reviews & PRs (maintainer)](/d/loopover-maintainer)** — loopover's own review activity + reviewed-PR log.\n- **[AI usage](/d/loopover-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- **[LoopOver (infra)](/d/loopover-selfhost)** — queue, jobs, HTTP, GitHub API cache/rate limits.\n- **[GPU metrics](/d/loopover-gpu)** — utilization/VRAM for a self-hosted Ollama GPU box.\n- **[Infra health](/d/loopover-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/loopover-rees)** — request outcomes/latency and per-analyzer run/timeout/degrade rates for the optional `--profile rees` service.\n- **[Browserless (visual review)](/d/loopover-browserless)** — queue depth, concurrency, and rejection/error/timeout rate for the optional `--profile visual-review` screenshot service.\n- **[Sentry issues](/d/loopover-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- **[Miner usage (AMS)](/d/loopover-miner-usage)** — attempt outcomes, cost/token spend, and per-provider usage from the local `loopover-miner` AMS ledger — the miner-side counterpart to the ORB review-service dashboards above (see the AMS panel below for wiring its datasources up).\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=\"loopover\"}`\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/loopover-ai-usage)** dashboard |\n\n## 📚 Docs\n- [Maintainer self-hosting](https://loopover.ai/docs/maintainer-self-hosting) — setup, configuration, AI, REES, RAG, operations, and troubleshooting." + "content": "## 📊 Dashboards\n- **[Upstream PRs & issues (GitHub)](/d/loopover-github)** — live, accurate census + open-PR triage (GitHub API).\n- **[Reviews & PRs (maintainer)](/d/loopover-maintainer)** — loopover's own review activity + reviewed-PR log.\n- **[AI usage](/d/loopover-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- **[LoopOver (infra)](/d/loopover-selfhost)** — queue, jobs, HTTP, GitHub API cache/rate limits.\n- **[GPU metrics](/d/loopover-gpu)** — utilization/VRAM for a self-hosted Ollama GPU box.\n- **[Infra health](/d/loopover-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/loopover-rees)** — request outcomes/latency and per-analyzer run/timeout/degrade rates for the optional `--profile rees` service.\n- **[Browserless (visual review)](/d/loopover-browserless)** — queue depth, concurrency, and rejection/error/timeout rate for the optional `--profile visual-review` screenshot service.\n- **[Miner usage (AMS)](/d/loopover-miner-usage)** — attempt outcomes, cost/token spend, and per-provider usage from the local `loopover-miner` AMS ledger — the miner-side counterpart to the ORB review-service dashboards above (see the AMS panel below for wiring its datasources up).\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=\"loopover\"}`\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/loopover-ai-usage)** dashboard |\n\n## 📚 Docs\n- [Maintainer self-hosting](https://loopover.ai/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 deleted file mode 100644 index 1ea7e36d26..0000000000 --- a/grafana/dashboards/sentry-issues.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "__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": ["loopover", "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": "LoopOver — Sentry issues", - "uid": "loopover-sentry", - "version": 1 -} diff --git a/scripts/setup-sentry-datasource.sh b/scripts/setup-sentry-datasource.sh deleted file mode 100755 index 327a5d556b..0000000000 --- a/scripts/setup-sentry-datasource.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/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 ] && . ./.env -: "${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}" -# https://sentry.io for Sentry SaaS; override for a self-hosted Sentry instance. -SENTRY_API_URL="${SENTRY_API_URL:-https://sentry.io}" - -TMP_DIR="$(mktemp -d)" -NETRC_FILE="$TMP_DIR/netrc" -trap 'rm -rf "$TMP_DIR"' EXIT -GRAFANA_HOSTPORT="${GRAFANA_URL#*://}" -GRAFANA_HOSTPORT="${GRAFANA_HOSTPORT%%/*}" -GRAFANA_HOST="${GRAFANA_HOSTPORT%%:*}" -printf 'machine %s login %s password %s\n' "$GRAFANA_HOST" admin "$GRAFANA_ADMIN_PASSWORD" >"$NETRC_FILE" -chmod 600 "$NETRC_FILE" - -grafana_curl() { - env -u GRAFANA_ADMIN_PASSWORD -u SENTRY_API_TOKEN curl -sf --netrc-file "$NETRC_FILE" "$@" -} - -payload() { - cat </dev/null 2>&1; then - echo "Updating existing Sentry data source…" - payload | grafana_curl -H 'content-type: application/json' -X PUT \ - "$GRAFANA_URL/api/datasources/uid/sentry" --data-binary @- >/dev/null -else - echo "Creating Sentry data source…" - payload | grafana_curl -H 'content-type: application/json' -X POST \ - "$GRAFANA_URL/api/datasources" --data-binary @- >/dev/null -fi - -echo "Done. Verifying health…" -grafana_curl -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/selfhost-grafana-github-datasource.test.ts b/test/unit/selfhost-grafana-github-datasource.test.ts index 5c448bc8d0..919469496f 100644 --- a/test/unit/selfhost-grafana-github-datasource.test.ts +++ b/test/unit/selfhost-grafana-github-datasource.test.ts @@ -2,15 +2,12 @@ import { readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -// scripts/setup-github-datasource.sh had no dedicated coverage before this (only referenced in passing by -// selfhost-grafana-sentry-datasource.test.ts's own comparison assertions). Mirrors that file's credential-safety -// test: this script shares the exact same GRAFANA_ADMIN_PASSWORD-via-curl-argv/env leak this PR fixes. describe("scripts/setup-github-datasource.sh", () => { function readScript(): string { return readFileSync(join(process.cwd(), "scripts/setup-github-datasource.sh"), "utf8"); } - it("is idempotent (update-vs-create) and ships a health check, matching setup-sentry-datasource.sh's own shape", () => { + it("is idempotent (update-vs-create) and ships a health check", () => { const script = readScript(); expect(script).toContain("GITHUB_TOKEN"); diff --git a/test/unit/selfhost-grafana-sentry-datasource.test.ts b/test/unit/selfhost-grafana-sentry-datasource.test.ts deleted file mode 100644 index 299519ba6e..0000000000 --- a/test/unit/selfhost-grafana-sentry-datasource.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -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("keeps Sentry and Grafana credentials out of curl argv and child environments", () => { - const script = readFileSync(join(process.cwd(), "scripts/setup-sentry-datasource.sh"), "utf8"); - - expect(script).not.toContain("set -a"); - expect(script).not.toContain('AUTH="admin:${GRAFANA_ADMIN_PASSWORD}"'); - expect(script).not.toContain('-u "$AUTH"'); - expect(script).not.toContain('-d "$(payload)"'); - expect(script).toContain('--netrc-file "$NETRC_FILE"'); - expect(script).toContain('--data-binary @-'); - expect(script).toMatch(/env -u GRAFANA_ADMIN_PASSWORD -u SENTRY_API_TOKEN curl/); - }); - - 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("loopover-sentry"); - expect(dashboard.title).toBe("LoopOver — Sentry issues"); - expect(dashboard.tags).toEqual(["loopover", "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/loopover-sentry"); - }); -});