DEV Community

137Foundry
137Foundry

Posted on

How to Wire Up Deployment Frequency and Lead Time Metrics in a Node.js Project

Two of the four DORA metrics (deployment frequency and lead time for changes) are straightforward to measure on a Node.js project deployed through a CI pipeline. The instrumentation does not require any commercial tooling, and the data structure is simple enough to fit in a SQLite database.

This walks through the implementation for a Node.js service that deploys via GitHub Actions, with the SQL schemas, the API call patterns, and the aggregation queries that turn raw data into the metric values that go on a dashboard.

terminal screen close monospace code
Photo by Ekaterina Belinskaya on Pexels

Step 1: Decide Where the Metric Data Lives

The metric data is separate from the application data. Even for a small project, the metric store benefits from being its own database, because the metric data has different access patterns (write-once, read-many, time-series queries) and different retention requirements (years, not days).

For a single-team project, SQLite works fine. The metric data is small (a few thousand rows after a year of measurement), and SQLite handles that volume effortlessly. For a multi-team setup, Postgres or a managed time-series database (TimescaleDB, InfluxDB) makes more sense.

The minimal schema:

CREATE TABLE deploys (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  service TEXT NOT NULL,
  sha TEXT NOT NULL,
  workflow_run_id TEXT NOT NULL,
  deployed_at TIMESTAMP NOT NULL,
  status TEXT NOT NULL,
  UNIQUE(workflow_run_id)
);

CREATE TABLE commit_lead_times (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  deploy_id INTEGER NOT NULL,
  commit_sha TEXT NOT NULL,
  author_timestamp TIMESTAMP NOT NULL,
  lead_time_seconds INTEGER NOT NULL,
  FOREIGN KEY (deploy_id) REFERENCES deploys(id)
);

CREATE INDEX idx_deploys_service_deployed_at ON deploys(service, deployed_at);
CREATE INDEX idx_lead_times_deploy ON commit_lead_times(deploy_id);
Enter fullscreen mode Exit fullscreen mode

The UNIQUE(workflow_run_id) constraint is the idempotency key that lets the same deploy record be inserted multiple times without producing duplicates, which matters because retry logic may re-process the same workflow run.

Step 2: Write the Deploy Recording Script

The deploy recording script runs as the last step of the production deployment workflow. It reads GitHub Actions environment variables and writes a row to the deploys table.

import Database from 'better-sqlite3';
import { Octokit } from '@octokit/rest';

const db = new Database('./metrics.db');
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });

async function recordDeploy() {
  const sha = process.env.GITHUB_SHA;
  const runId = process.env.GITHUB_RUN_ID;
  const service = process.env.SERVICE_NAME;
  const deployedAt = new Date().toISOString();

  const stmt = db.prepare(`
    INSERT OR IGNORE INTO deploys (service, sha, workflow_run_id, deployed_at, status)
    VALUES (?, ?, ?, ?, ?)
  `);

  const result = stmt.run(service, sha, runId, deployedAt, 'success');

  if (result.changes === 0) {
    console.log('Deploy already recorded (idempotent), skipping');
    return;
  }

  console.log(`Recorded deploy ${sha} at ${deployedAt}`);
  return result.lastInsertRowid;
}

recordDeploy().catch(err => {
  console.error('Failed to record deploy:', err);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

The use of INSERT OR IGNORE with the unique workflow_run_id constraint handles the case where a deploy is re-recorded by manual trigger; the second insert silently no-ops.

Step 3: Compute Lead Times for the Commits in the Deploy

After recording the deploy, identify which commits were included and compute lead time for each.

async function computeLeadTimes(deployId, currentSha, previousSha, service) {
  const owner = process.env.GITHUB_REPO_OWNER;
  const repo = process.env.GITHUB_REPO_NAME;

  const { data: comparison } = await octokit.repos.compareCommits({
    owner,
    repo,
    base: previousSha,
    head: currentSha,
  });

  const deployedAt = new Date();
  const insertStmt = db.prepare(`
    INSERT INTO commit_lead_times (deploy_id, commit_sha, author_timestamp, lead_time_seconds)
    VALUES (?, ?, ?, ?)
  `);

  const insertMany = db.transaction((commits) => {
    for (const commit of commits) {
      const authorTimestamp = new Date(commit.commit.author.date);
      const leadTimeSeconds = Math.floor((deployedAt - authorTimestamp) / 1000);

      insertStmt.run(
        deployId,
        commit.sha,
        authorTimestamp.toISOString(),
        leadTimeSeconds
      );
    }
  });

  insertMany(comparison.commits);

  console.log(`Recorded lead times for ${comparison.commits.length} commits`);
}
Enter fullscreen mode Exit fullscreen mode

The db.transaction() wrapper from better-sqlite3 batches the inserts into a single transaction, which is significantly faster than inserting one row at a time for deploys that include many commits.

Finding previousSha requires querying the deploys table for the most recent prior deploy. A practical approach:

function getPreviousDeploySha(service) {
  const stmt = db.prepare(`
    SELECT sha FROM deploys
    WHERE service = ? AND status = 'success'
    ORDER BY deployed_at DESC
    LIMIT 1 OFFSET 1
  `);
  const row = stmt.get(service);
  return row ? row.sha : null;
}
Enter fullscreen mode Exit fullscreen mode

The OFFSET 1 skips the deploy we just recorded and returns the one before it.

Step 4: Aggregate Deployment Frequency

Deployment frequency is the count of successful deploys per unit of time. A 30-day rolling window is the standard, computed daily.

function getDeploymentFrequency(service, days = 30) {
  const stmt = db.prepare(`
    SELECT COUNT(*) as deploy_count
    FROM deploys
    WHERE service = ?
      AND status = 'success'
      AND deployed_at >= datetime('now', '-' || ? || ' days')
  `);
  const row = stmt.get(service, days);
  return {
    service,
    deploys_per_day: row.deploy_count / days,
    deploys_in_window: row.deploy_count,
    window_days: days,
  };
}
Enter fullscreen mode Exit fullscreen mode

The DORA cluster boundaries for deployment frequency from the DORA research at dora.dev are: low performers fewer than once per month, medium 1-7 per week, high 1-7 per day, elite multiple times per day.

Step 5: Aggregate Lead Time for Changes

Lead time is best reported as both median and 90th percentile because the distribution is typically right-skewed (a few long-tail commits with weeks of lead time hidden behind a fast median).

function getLeadTimeMetrics(service, days = 30) {
  const stmt = db.prepare(`
    SELECT lead_time_seconds
    FROM commit_lead_times lt
    JOIN deploys d ON lt.deploy_id = d.id
    WHERE d.service = ?
      AND d.status = 'success'
      AND d.deployed_at >= datetime('now', '-' || ? || ' days')
    ORDER BY lead_time_seconds ASC
  `);

  const rows = stmt.all(service, days);
  if (rows.length === 0) return null;

  const seconds = rows.map(r => r.lead_time_seconds);
  const median = seconds[Math.floor(seconds.length * 0.5)];
  const p90 = seconds[Math.floor(seconds.length * 0.9)];

  return {
    service,
    median_lead_time_hours: median / 3600,
    p90_lead_time_hours: p90 / 3600,
    sample_size: seconds.length,
  };
}
Enter fullscreen mode Exit fullscreen mode

Computing percentiles in SQL is awkward in SQLite; doing the sort in SQL and the percentile pick in JavaScript is simpler and the volume is small enough that it does not matter for performance.

DORA cluster boundaries for lead time: low more than 6 months, medium 1 week to 1 month, high 1 day to 1 week, elite less than 1 day.

Step 6: Schedule the Aggregation

The recording happens at deploy time. The aggregation should happen on a schedule, typically daily, so the dashboard always shows fresh numbers.

name: Compute DORA Metrics
on:
  schedule:
    - cron: '0 6 * * *'  # Daily at 6am UTC

jobs:
  compute:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm install
      - run: node scripts/compute-metrics.js
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Enter fullscreen mode Exit fullscreen mode

The compute-metrics.js script reads the metric tables, produces the aggregated values, and writes them to a separate metric_snapshots table for the dashboard to query.

Common Pitfalls

A few patterns to watch for when running this in production:

Time zone confusion. Use UTC consistently in the database and the dashboard. Mixing local time and UTC produces off-by-day errors in the daily aggregations.

Counting non-production deploys. The metrics only describe production. If a single workflow runs staging and production in sequence, only the production step counts as a deploy.

Skipped commit metadata. The GitHub API uses the author timestamp and the committer timestamp, which can differ if commits are rebased. Use the author timestamp for lead time (it represents when the work was actually done).

Missing commits from squash merges. When PRs are squash-merged, the resulting commit has the merge timestamp but no original-commit history. The lead time then describes the squash, not the original work. To capture the original lead time, query the PR's first-commit timestamp instead.

The Node.js documentation has the reference for Date and timestamp handling that the lead time calculation relies on. For the underlying GitHub Actions workflow context variables used by the recording scripts, the GitHub documentation covers the available environment variables and event payload structure.

data center cooling pipes server hardware
Photo by Vitali Adutskevich on Pexels

Where the Metrics Take You

Once deployment frequency and lead time are measured, the next decision is what to do with the numbers. A team in the "low" cluster on either metric usually has a specific bottleneck (PR queue length, slow CI, manual approval gates, large batch sizes) that an investment can target.

The longer reference How to Establish Engineering Productivity Metrics That Drive Real Improvements covers the framework for choosing what to invest in based on the metric pattern, including the relationship between deployment frequency and the other DORA metrics.

If you want help with the data integration side (pulling metrics from multiple CI/CD systems, incident trackers, and code review tools into a unified dashboard), this engineering firm at the services hub takes on that kind of work for teams that want internal metrics tooling without building it from scratch.

The implementation above is sufficient to start measuring. The harder work is making the measurements lead to decisions, which is the part of the metric program that distinguishes teams that improve their metrics over time from teams that just have a dashboard.

Top comments (0)