%3Aquality(100)%2F&w=3840&q=75)
How to monitor Cypress tests with Grafana Cloud
If your Cypress suite has tests that fail more often or run slower, you know it can be hard to figure out the pattern from a single job. It could be one spec that slowed down, or a single test that fails, or maybe the entire suite is trending slower. The root cause could be a bug in the app, or a flaky test, or something else. Your terminal output and CI log will tell you what happened on a single run, but that doesn't help you spot any larger trends—especially since you lose that data as soon as the job finishes.
Thankfully, Cypress, a front-end automated test framework built for web applications, already exposes everything you need through its plugin hooks. After each spec finishes, Cypress hands you a results object with pass and fail counts, per-test durations, and states. You just need to turn that into metrics and ship it somewhere durable.
In this post, you'll learn how to monitor your Cypress tests by converting those results into Prometheus metrics inside a Cypress hook, pushing them to a Prometheus Pushgateway, and letting Alloy scrape the gateway and forward everything to Grafana Cloud Metrics—using nothing but the free tiers.
By the end you’ll have a pipeline running with the following architecture:
%3Aquality(100)%2F&w=3840&q=75)
What you’ll need
This walkthrough runs everything alongside your existing Cypress project. Before you start, make sure you have:
- A Cypress project (this example uses Cypress 14.x) with a cypress.config.js you can edit
- A Prometheus Pushgateway. Cypress runs are short-lived batch jobs, so they can’t be scraped directly—the Pushgateway holds the metrics between runs so a scraper can pick them up. Set up a Prometheus Pushgateway in your infrastructure
- Alloy, our open source collector we use to scrape the Pushgateway and remote-write to Grafana Cloud
- A Grafana Cloud account. The free tier includes Grafana Cloud Metrics and a Prometheus remote-write endpoint. If you don't have an account, you can sign up here
- Your Grafana Cloud remote-write URL, numeric user ID, and an access policy token with the
metrics:writescope
Emit metrics from Cypress hooks
1a. The hooks that make it work
Cypress plugins run in Node.js and can subscribe to lifecycle events in setupNodeEvents. Two hooks give us everything we need:
before:runfires once, before any spec runs. We use it to stamp a singlerun_idfor the whole suite and to clear any state from a previous run. In this examplerun_idis the epoch timestamp of the test suite start time.after:specfires after each spec file finishes and receives that spec’s results object. This is where the test counts, states, and durations live, so this is where we build and push metrics.
// cypress.config.js
module.exports = defineConfig({
e2e: {
setupNodeEvents(on) {
on("before:run", () => {
runEpoch = String(Date.now());
specMetrics.clear();
});
on("after:spec", async (spec, results) => {
try {
await pushSpecToPrometheus(spec, results);
} catch (err) {
console.error(
`Prometheus push failed for ${spec.relative || spec.name}:`,
err.message
);
}
});
},
},
});The push is wrapped in try/catch so a monitoring outage never turns a green test run red—telemetry is a side effect, not a gate. Second, the run_id is set once in before:run and reused by every spec, so all the specs from a single suite execution share one identifier and can be grouped together later for each run.
1b. Turn a Cypress results object into Prometheus metrics
The results object Cypress passes to after:spec includes a stats block (passes, failures, pending, skipped, tests, and duration in milliseconds) and an array of tests, each with title, state, and duration. We map those onto a small set of metrics, using labels to slice by spec, run, and individual test:
function metricLinesForSpec(specName, runId, githubRunId, results) {
const stats = results.stats || {};
const passed = stats.passes ?? 0;
const failed = stats.failures ?? 0;
const pending = stats.pending ?? 0;
const skipped = stats.skipped ?? 0;
const total = stats.tests ?? passed + failed + pending + skipped;
const durationSec = (stats.duration ?? 0) / 1000;
const labels = [
`spec="${escapeLabelValue(specName)}"`,
`run_id="${escapeLabelValue(runId)}"`,
`github_run_id="${escapeLabelValue(githubRunId)}"`,
].join(",");
const lines = [
`cypress_tests_total{${labels},result="passed"} ${passed}`,
`cypress_tests_total{${labels},result="failed"} ${failed}`,
`cypress_tests_total{${labels},result="pending"} ${pending}`,
`cypress_tests_total{${labels},result="skipped"} ${skipped}`,
`cypress_tests_run_total{${labels}} ${total}`,
`cypress_spec_duration_seconds{${labels}} ${durationSec}`,
`cypress_spec_success{${labels}} ${failed === 0 ? 1 : 0}`,
];
for (const test of results.tests || []) {
const testName = (test.title || []).join(" -- ") || "unknown";
const testLabels = `${labels},test="${escapeLabelValue(testName)}"`;
const state = test.state || "unknown";
const testDurationSec = (test.duration ?? 0) / 1000;
lines.push(
`cypress_test_success{${testLabels}} ${state === "passed" ? 1 : 0}`,
`cypress_test_duration_seconds{${testLabels},result="${escapeLabelValue(state)}"} ${testDurationSec}`
);
}
return lines.join("\n");
}Every series carries a common set of labelsspec (which file), run_id (the epoch stamped in before:run), and ci_run_id (the CI run, when present)—so you can filter a dashboard down to one spec or one CI run. The result is a compact set of metrics:
cypress_tests_total: Count of tests by outcome (passed, failed, pending, skipped) per speccypress_tests_run_total: Total tests executed per speccypress_spec_duration_seconds: How long each spec tookcypress_spec_success: 1 if a spec had zero failures; 0 if a spec had any failurescypress_test_successandcypress_test_duration_seconds: The same idea at the individual-test level, so you can trend one flaky test over time
1c. Push to the Pushgateway
A normal Prometheus setup scrapes long-running services on an interval. A Cypress run is the opposite. It's a short-lived batch job that exits before any scraper can reach it. The Prometheus Pushgateway exists for exactly this case: your job pushes its metrics to the gateway, the gateway holds them, and Prometheus (or Alloy) scrapes the gateway on its own schedule.
The push itself is a plain HTTP POST of the metrics in Prometheus text format:
// cypress.config.js — push one accumulated body to the Pushgatewayasync function pushSpecToPrometheus(spec, results) {
const baseUrl = process.env.PROMETHEUS_PUSHGATEWAY_URL;
if (!baseUrl) {
console.log(
"Skipping Prometheus push (set PROMETHEUS_PUSHGATEWAY_URL to enable)."
);
return;
}
const job = process.env.PROMETHEUS_JOB || "cypress";
const instance = process.env.PROMETHEUS_INSTANCE || "local";
const runId = runEpoch || (runEpoch = String(Date.now()));
const githubRunId = process.env.GITHUB_RUN_ID || "localrun";
const specName = spec.relative || spec.name || "unknown";
specMetrics.set(
specName,
metricLinesForSpec(specName, runId, githubRunId, results)
);
const url = new URL(
`/metrics/job/${encodeURIComponent(job)}/instance/${encodeURIComponent(instance)}`,
baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`
);
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "text/plain; charset=utf-8",
"ngrok-skip-browser-warning": "true",
},
body: buildBody(),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(
`Pushgateway returned ${res.status} ${res.statusText} for ${url.href}${detail ? `: ${detail}` : ""}`
);
}
console.log(
`Pushed metrics for spec "${specName}" (run_id="${runId}", github_run_id="${githubRunId}") to Pushgateway`
);
}The job and instance in the path form the Pushgateway grouping key (here, job="cypress" and instance="local"; which could be assigned with any values based on the project or module from your running suite).
Because we accumulate every spec’s metrics into a specMetrics map and re-POST the full body after each spec, the gateway always holds a complete, current snapshot of the suite. And clearing that map in before:run means a new run starts clean instead of carrying stale specs forward.
The whole integration lives in cypress.config.js and activates only when a PROMETHEUS_PUSHGATEWAY_URL is set, so local cypress run invocations stay untouched unless you opt in:
# package.json scriptPROMETHEUS_PUSHGATEWAY_URL=http://localhost:9091 cypress run// config.alloy — scrape the Pushgateway and ship to Grafana Cloud Metricsprometheus.scrape "pushgateway" { targets = [ { "__address__" = coalesce(sys.env("PUSHGATEWAY_ADDRESS"), "localhost:9091") }, ] honor_labels = true metrics_path = "/metrics" scrape_interval = "60s" forward_to = [prometheus.remote_write.grafana_cloud.receiver]}prometheus.remote_write "grafana_cloud" { endpoint { url = sys.env("GRAFANA_CLOUD_RW_URL") basic_auth { username = sys.env("GRAFANA_CLOUD_USER") password = sys.env("GRAFANA_CLOUD_TOKEN") } } external_labels = { source = "cypress-pushgateway", }}Provide your Grafana Cloud credentials as environment variables and start Alloy:
export GRAFANA_CLOUD_RW_URL=https://prometheus-prod-XX-XXX.grafana.net/api/prom/pushexport GRAFANA_CLOUD_USER=<your-user-id>export GRAFANA_CLOUD_TOKEN=<your-access-policy-token>alloy run config.alloy# 1. Pushgateway (holds metrics between runs)docker run -d -p 9091:9091 prom/pushgateway# 2. Alloy (scrapes the gateway, ships to Grafana Cloud) — see Part 2alloy run config.alloy# 3. Cypress, with the push enablednpm run test:prometheusAs each spec finishes, Cypress logs a line like Pushed metrics for spec "login.cy.js" (run_id="1753100000000") to Pushgateway. Within a scrape interval, those series appear in Grafana Cloud.
%3Aquality(100)%2F&w=3840&q=75)
From here you can build a dashboard that identifies trends in pass and fail counts, spec duration, and per-test success. You can also set up Grafana Alerting to page you when the suite success rate drops or a critical spec starts failing.
%3Aquality(100)%2F&w=3840&q=75)
Based on the collected metrics, we could visualize each spec’s execution time and also individual test’s.
%3Aquality(100)%2F&w=3840&q=75)
# .github/workflows/main.yml (excerpt)env: PROMETHEUS_PUSHGATEWAY_URL: ${{ secrets.PROMETHEUS_PUSHGATEWAY_URL }} # GITHUB_RUN_ID is provided automatically and used as the ci_run_id labelBecause the code reads GITHUB_RUN_ID when it’s present, every CI run is tagged with its ci_run_id—so you can jump from a metric spike straight to the workflow run that produced it.
%3Aquality(100)%2F&w=3840&q=75)
A few things that will save you time:
- Use a Pushgateway, not a scrape target. Cypress runs exit in seconds; a scraper would never catch them. The Pushgateway is the buffer that makes short-lived jobs observable.
- Never let telemetry fail the run. Wrap the push in try/catch. A monitoring outage should never turn a passing suite red.
- Escape your label values. Test titles are free text and can contain quotes and newlines that break the Prometheus text format. Escape them before writing metrics.
- Stamp one
run_idper suite. Setting it in before:run and reusing it across specs is what lets you group and compare whole runs later.
Grafana Assistant is the easiest way to get started with metrics, logs, traces, dashboards, and more in Grafana Cloud. We have a generous forever-free tier and plans for every use case. Sign up for free now!
%3Aquality(100)%2F&w=3840&q=75)