Hive Hive
Sign in

feat(server): build/test duration metrics API + Grafana data source plugin

GitHub issue · Closed

Metadata
Source
tuist/tuist #11210
Updated
Jun 24, 2026
Details

Purpose

Make build and test run durations queryable as time series with percentile metrics (p50/p90/p99 and average) over a chosen time range, and ship a Grafana data source plugin that consumes them. This came out of a request to measure how much CI pipelines are getting optimized over time and to feed those numbers into Grafana dashboards.

The percentile math already existed internally (it powers the dashboard); the gap was a public, queryable API and a client. This PR closes both.

What changed

Server: build/test duration metrics API

  • New TuistWeb.API.MetricsController exposing, per project:
    • GET .../builds/metrics/duration and .../tests/metrics/duration — time-bucketed average + p50/p90/p99 series.
    • .../builds/metrics/schemes, .../builds/metrics/configurations, .../tests/metrics/schemes — to back filter dropdowns and template variables.
  • New Builds.Analytics.build_duration_percentiles_analytics/2 returning average + p50/p90/p99 in a single grouped ClickHouse query. The tests equivalent (test_run_duration_analytics/2) already returned this shape and is reused as-is.
  • New DurationMetrics OpenApiSpex response schema: { dates, average{values,total}, p50, p90, p99, trend }, durations in ms, dates as Unix seconds.

New component: grafana-datasource/ — a Grafana data source plugin (Go backend + React), a thin client over the endpoints above:

  • QueryData dispatches on queryType (buildDuration/testDuration) and renders a wide time-series frame (one field per selected series, unit ms). Default series is average + p50/p90/p99, matching the dashboard.
  • CallResource + metricFindQuery proxy GET /api/projects and the .../metrics/schemes/configurations endpoints, so query-editor dropdowns and dashboard variables resolve without exposing the token.
  • Filters: Project and Environment (CI/Local) are both exposed per-panel and as dashboard variables (via applyTemplateVariables), so one dashboard works across every project the account token can reach, mirroring the builds page filters. Scheme/configuration filters too.
  • ConfigEditor stores the server URL in jsonData and the account token in secureJsonData (encrypted, never sent to the browser).
  • Built on the standard @grafana/create-plugin harness. The .config/ directory is vendored, auto-generated scaffolding (webpack/jest/eslint/tsconfig + the Docker dev-server) carrying a DO-NOT-EDIT banner and pinned via .config/.cprc.json — it is refreshed with npx @grafana/create-plugin@latest update, not hand-edited, so it is not worth reviewing line by line. What is actually ours: pkg/ (Go backend), src/ (React/TS), plugin.json, the root config-extension files, and the release/CI wiring. The Go backend lives in its own nested go.work (see SDK isolation below); the component is added to the root AGENTS.md repository map.

Why these choices

  • Endpoint placement — the metrics hang off the existing /builds and /tests resources behind a /metrics infix (GitHub {entity}/stats style) instead of a dedicated /analytics namespace, which is already overloaded by the ingest POST /analytics and the page-view AnalyticsPlug. The infix also avoids colliding with /:build_id.
  • Auth — reuses the existing AuthorizationPlug (:build/:test), so account tokens with project:builds:read / project:tests:read work with no new authorization category. The plugin authenticates with an account token, which gives the account-wide project dropdown for free via GET /api/projects.
  • ClickHouse loadfrom/to come from the consumer’s time range; the server derives bucket granularity (hour/day/month) from the range, which bounds the point count (~60 buckets max). A max-range guard rejects ranges over 366 days. Reads go through the read replica (Tuist.ClickHouseRepo). A 60s response cache (KeyValueStore.get_or_update, keyed per project/range/filters) collapses the Grafana refresh storm to one query, and a per-account rate-limit plug (MetricsRateLimitPlug, 300 req/min, in-memory token bucket) caps abuse. build_runs is ORDER BY (project_id, inserted_at, id) PARTITION BY toYYYYMM(inserted_at), so each query is an indexed range scan with partition pruning, not a full scan.
  • Backend plugin (Go, like grafana/sentry-datasource) keeps the token server-side and enables Grafana alerting.
  • OpenApiSpex only; the CLI client is intentionally not regenerated (these endpoints are not for the CLI).

How to test locally

Server:

cd server
mix test test/tuist/builds/analytics_test.exs test/tuist_web/controllers/api/metrics_controller_test.exs

Then, with the dev server running, hit (account-token auth):

GET /api/projects/<account>/<project>/builds/metrics/duration?from=<unix>&to=<unix>&is_ci=true
GET /api/projects/<account>/<project>/tests/metrics/duration?from=<unix>&to=<unix>

Plugin:

cd grafana-datasource # Node pinned to 24 via mise.toml
npm install
npm run typecheck && npm run lint && npm run build
go build ./... && go test ./...

Validation run

  • Server: 2 new analytics unit tests + 8 controller tests (user and account-token paths, is_ci filter, 400 range validation, 403 forbidden); full builds-analytics + controller suites green (80 tests). Compiles with --warnings-as-errors, credo clean.
  • Plugin backend: go build / go vet / go test green, gofmt clean.
  • Plugin frontend: npm run typecheck, npm run lint (0 errors), npm run build (production) all green against @grafana/* 12.4.2.
  • End to end against a running Grafana → plugin → local server: datasource health “Connected to Tuist”; queries return real series (build p50 ~38s / p90 ~89s / p99 ~99s); the Environment filter changes results (CI ~41.6s vs Local ~35.0s); switching Project changes the series. CI coverage for the backend is mocked (httptest): CallResource path dispatch + CheckHealth scope validation in datasource_test.go, frame building in frames_test.go.

Screenshot

The Grafana dashboard rendering build and test duration percentiles (average, p50, p90, p99) from a Tuist server, with the Project and Environment filters as dashboard variables:

Grafana dashboard with Project and Environment dropdowns showing Tuist build and test duration percentiles over the last 30 days

A marketing changelog entry ships with this change at server/priv/marketing/changelog/2026.06.10-grafana-data-source.md.

Follow-ups (not in this PR)

  • Grafana catalog signing/publishing.
  • Optional build_runs/test_runs daily-stats materialized views if raw scans get hot.

Publishing the plugin

Prepared in this PR (catalog readiness, verified with @grafana/plugin-validator):

  • plugin.json with the catalog 3-part id tuist-metrics-datasource, the Tuist logo, a dashboard screenshot, and description/keywords/links.
  • A catalog README (what it does, requirements, configuration, and unsigned/private/catalog install paths) with absolute image/link URLs.
  • Release wiring follows the repo convention (git-cliff + push-to-main, like every other component): grafana-datasource is registered in mise/tasks/release/components.json with its own cliff.toml, and .github/workflows/grafana-datasource-release.yml triggers on a push to main touching grafana-datasource/**, computes the next version with git-cliff, and — only when there are releasable commits — builds the frontend + multi-platform Go binaries, signs when GRAFANA_ACCESS_POLICY_TOKEN is set, validates, tags grafana-datasource@x.y.z, and publishes a GitHub release with the zip + SHA1. Checks run separately on PRs in .github/workflows/grafana-datasource.yml (typecheck/lint/test/build for the frontend, gofmt/vet/test for the backend), one job per check, mirroring the other components.

Post-merge / pre-submission (need a grafana.com account + maintainer action, so not doable in the PR):

  1. Register/confirm the tuist org on grafana.com — the plugin id prefix must match it.
  2. Create a GRAFANA_ACCESS_POLICY_TOKEN (plugins:write) and add it to repo secrets — do this before merge if you want the first release signed.
  3. Merging this PR auto-releases grafana-datasource@0.1.0 (push-to-main, via the workflow above): signed if the token from step 2 is set, otherwise an unsigned build. Later feat:/fix: commits touching the plugin bump it automatically.
  4. Submit at grafana.com → My Plugins → Submit New Plugin (zip URL + SHA1 + public source URL). Grafana reviews (code + security + install testing), grants the Community signature, and lists it.

SDK / dependency isolation: the plugin lives in its own go.work (use ., go 1.26.3), separate from the repo-root infra workspace, so it tracks the latest grafana-plugin-sdk-go without forcing its k8s/transitive deps onto the infra controllers (sharing the workspace had broken their build via MVS — apimachinery structured-merge-diff v4-vs-v6). With the latest SDK, @grafana/plugin-validator no longer flags the “SDK older than 5 months” check or the grpc/otel/kin-openapi CVEs. Verified: the plugin builds and tests on go 1.26.3 with the latest SDK, and the infra modules build unchanged.

Using it before the catalog listing: self-hosted Grafana via the unsigned allowlist (GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=tuist-metrics-datasource) or a private signature; Grafana Cloud needs the published catalog version.

Flights

Investigate, reproduce, or fix this item in an isolated repository. Each Flight preserves its outcome and agent session.

New Flights are paused Configure model inference, GitHub, and a sandbox provider to start another Flight. Existing results remain available below.
No Flights yet

Start a Flight and preserve its objective, outcome, and session here.

Comments
TA
tuist-atlas[bot] Jun 13, 2026

The feature from this pull request is now available. Update to xcresult-processor-image@0.20.0 to use it.

TA
tuist-atlas[bot] Jun 13, 2026

This feature is now available in server@1.211.0. Update to ghcr.io/tuist/tuist:1.211.0 to use the build/test duration metrics API and Grafana data source plugin.