How to monitor Cypress tests with Grafana Cloud

How to monitor Cypress tests with Grafana Cloud

2026-09-099 min
Twitter
Facebook
LinkedIn

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:

A flow chart showing remote-write from a test environment to Grafana Cloud

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:write scope

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:run fires once, before any spec runs. We use it to stamp a single run_id for the whole suite and to clear any state from a previous run. In this example run_id is the epoch timestamp of the test suite start time.
  • after:spec fires 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 spec
  • cypress_tests_run_total: Total tests executed per spec
  • cypress_spec_duration_seconds: How long each spec took
  • cypress_spec_success: 1 if a spec had zero failures; 0 if a spec had any failures
  • cypress_test_success and cypress_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

Ship to Grafana Cloud with Alloy

The Pushgateway now holds your metrics, but it’s a local buffer, not long-term storage. Alloy bridges the two: it scrapes the gateway and remote-writes to Grafana Cloud Metrics.

// 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

Run tests and verify

Start the Pushgateway and Alloy, then run your suite pointed at the gateway:

# 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:prometheus

As 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.

Series appearing in Grafana Cloud

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.

Grafana dashboard panel shows test results trend by run, with a spike at 11:30

Based on the collected metrics, we could visualize each spec’s execution time and also individual test’s.

Grafana dashboard panels showing spec excution time and the 10 slowest tests

Running it in CI

The same mechanism works unchanged in CI—the only difference is where the metrics come from.

For example, in a GitHub Actions workflow, set PROMETHEUS_PUSHGATEWAY_URL to a Pushgateway your runner can reach, and Cypress does the rest:

# .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 label

Because 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.

A Grafana dashboard panel showing pass and fail by run

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_id per 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!

Tags

Related content