Skip to content

Repository files navigation

sentry-lighthouse

Self-hosted Lighthouse lab for getsentry/sentry-javascript. Accepts prebuilt SDK test-app bundles from GitHub Actions, runs Lighthouse on stable hardware, ships every run to Sentry as a distribution metric.

Hosted

Liveness check: curl https://lighthouse.sentry.gg/healthz.

Why a dedicated service?

Google's own Lighthouse docs are explicit that shared-tenancy CI runners are the #1 cause of measurement variance. Running Lighthouse on ubuntu-latest produced run-to-run jitter (+2 / -5 on the same code) that drowned the signal we care about. Moving the measurement onto a single-tenant Northflank instance with a pinned Chrome version makes the noise go away — a recent fixture run on the deployed service had a 5-run LCP spread of 80 ms vs ~200 ms+ typical on shared runners.

Architecture

 ┌──────────────────────────────────────────┐
│ sentry-lighthouse container │
│ │
GitHub CI ─► │ src/server.js │
POST bundle │ • Fastify HTTP (8080) │
│ • bearer-auth on uploads │
│ • SQLite queue │
│ • Worker: lhci collect, 1 cell at a │
│ time, writes LHR JSON + HTML to │
│ /data/reports/<runId>/ │
│ • Periodic bundle retention sweep │
│ │
│ src/publisher.js │
│ • Polls cells WHERE published_at IS │
│ NULL │
│ • @sentry/node Sentry.metrics.* │
│ • One distribution envelope per run │
│ per metric (score/lcp/fcp/tbt/cls/ │
│ bytes) │
│ • Marks published_at after flush │
│ │
│ src/supervisor.js │
│ • PID 1; spawns both children, fans │
│ out SIGTERM, exits if either dies │
│ │
│ Persistent volume /data │
│ ├─ db.sqlite │
│ ├─ builds/<buildId>/<app>-<mode>.tgz │
│ └─ reports/<runId>/{lhr-N.json, │
│ report.html} │
└──────────────────────────────────────────┘
│
▼ metrics over HTTPS
┌────────────────┐
│ Sentry org │
│ (dashboards) │
└────────────────┘

The container is one image, two long-running Node processes, one SQLite file. No Postgres, no Redis, no message queue, no SPA.

Quick start (local)

# 1. Install deps (Node 22.22.2 pinned via .node-version)
pnpm install
# 2. Configure
cp .env.example .env
echo"UPLOAD_TOKEN=$(openssl rand -hex 32)">> .env
# Optionally set SENTRY_DSN to actually ship to Sentry. Without one, the# publisher still runs but emits nothing (useful when iterating).# 3. Run (server + publisher under one supervisor)
pnpm dev
# → http://localhost:8080/healthz# 4. Trigger an end-to-end fixture run (one tiny static cell)
pnpm fixture:upload

To run the fixture against the deployed service instead of localhost:

LAB_URL=https://lighthouse.sentry.gg \
UPLOAD_TOKEN=<token from Northflank env> \
pnpm fixture:upload

Individual processes if you're debugging:

pnpm dev:server # just the HTTP server + worker
pnpm dev:publisher # just the publisher (against an existing DB)

Quick start (Docker)

pnpm docker:build # bakes `git rev-parse --short HEAD` in as GIT_SHA
pnpm docker:run

docker:run mounts ./data so SQLite + reports persist between container restarts.

HTTP API

All write endpoints require Authorization: Bearer $UPLOAD_TOKEN.

Every route is rate-limited to 100 requests per minute per client IP (RATE_LIMIT_MAX / RATE_LIMIT_WINDOW_MS). Over the limit returns 429 with { "error": "rate_limited" }.

Buckets are keyed on req.ip, which is derived from X-Forwarded-For using a hop count (TRUST_PROXY_HOPS, default 1) rather than by trusting the whole chain — otherwise a caller could prepend their own XFF entry and get a fresh bucket per request. If you put this behind additional proxies, raise the count to match; see .env.example.

MethodPathAuthPurpose
GET/healthznoneLiveness + queue depth (queue.queued, running, pendingPublish)
POST/api/buildsbearerUpload (multipart): metadata JSON field + one tar.gz per cell
GET/api/buildsnonePaginated list of recent builds
GET/api/builds/:buildIdnoneBuild detail with every cell's runs + reportUrls
POST/api/builds/:buildId/rerunbearerRe-enqueue all cells using the stored tarballs
GET/api/runs/:runId/report.htmlnoneLighthouse HTML report for one run

Metrics shipped to Sentry

Per Lighthouse run (default 5 per cell):

MetricTypeUnit
lighthouse.scoredistributionratio
lighthouse.lcpdistributionmillisecond
lighthouse.fcpdistributionmillisecond
lighthouse.tbtdistributionmillisecond
lighthouse.clsdistribution
lighthouse.bytesdistributionbyte
lighthouse.element_timingdistributionmillisecond
lighthouse.cell.completedcounter

Attributes attached to every envelope: app, mode, branch, commit, serve_mode, throttle_method, run_index. Sentry's distribution histograms compute p50/p90/p99 in the dashboard — we don't pre-aggregate.

lighthouse.element_timing is emitted once per performance.measure('element-timing-<label>') the instrumented app records (surfaced via Lighthouse's user-timings audit). It carries an extra element attribute (the measure name with the element-timing- prefix stripped, e.g. hero-image) so you can slice/compare individual elements within an app/mode. A single run can emit many of these; runs without the instrumentation emit none.

Config (env vars)

See .env.example. Required: UPLOAD_TOKEN. Recommended: SENTRY_DSN. Everything else has sensible defaults.

Deployment (Northflank)

The live deployment is configured per the table below. To stand up another instance, point a Northflank service at this Dockerfile and match these settings:

SettingValue
Imagethis Dockerfile (build from repo)
Build argsNF_GIT_SHA=${NF_GIT_SHA}, NF_GIT_BRANCH=${NF_GIT_BRANCH}, NF_PREVIOUS_BUILD_GIT_SHA=${NF_PREVIOUS_BUILD_GIT_SHA}, NF_BUILD_ID=${NF_BUILD_ID} — Northflank substitutes these automatically. They surface on /healthz under build.
Ports8080 → public HTTPS
Volume20 GB persistent volume mounted at /data — not optional, holds SQLite + bundles + reports
Resources2 vCPU / 4 GB RAM (matches Lighthouse's hardware recommendation)
HealthcheckGET /healthz every 30s
SecretsUPLOAD_TOKEN, SENTRY_DSN
EnvSENTRY_ENVIRONMENT=production (the rest have sensible defaults in the Dockerfile). If Northflank's ingress ever adds a second X-Forwarded-For hop, bump TRUST_PROXY_HOPS to match — verify with curl -H 'X-Forwarded-For: 1.2.3.4' /healthz and confirm the logged ip is your real address, not 1.2.3.4.

The CI side (the sentry-javascript workflow that builds test apps and POSTs them here) lives in a separate PR. The full hand-off doc for that work — API contract, bundle format, file-by-file checklist of what to change in sentry-javascript — is in docs/sentry-javascript-handoff.md.

About

Self-hosted Lighthouse lab for sentry-javascript: receives prebuilt test-app bundles from CI, runs Lighthouse on stable hardware, ships every run to Sentry as a distribution metric

Topics

Resources

Code of conduct

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages