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.

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.