FRACTIONAL CTO · 25 MIN READ ·

DORA-metrics voor Rails-teams: Deployment Frequency, Lead Time en Change Failure Rate meten

DORA-metrics voor Rails-teams: meet deployment frequency, lead time en change failure rate met GitHub Actions en ActiveRecord. Met werkende code.

DORA-metrics voor Rails-teams: Deployment Frequency, Lead Time en Change Failure Rate meten

Een paar maanden geleden werd ik gevraagd om een team van veertig mensen te beoordelen dat trots was op zijn proces. Twee Jira-borden vol met groene vinkjes. Afgesloten sprints overal. “We shippen elke twee weken,” zei de CTO, met de overtuiging van iemand die net een retro had gehad die goed liep.

Daarna trok ik de data.

Gemiddelde tijd van eerste commit op een branch tot die commit in productie: drieëntwintig dagen. Change failure rate over het afgelopen kwartaal: 41 procent. Mean time to restore na een incident: vier uur en twaalf minuten. Ze dachten dat ze goed presteerden. Gemeten met DORA-metrics zaten ze stevig in de Low-band, op z’n best aan de grens van Medium. De sprints waren groen omdat engineers Jira-tickets sloten, niet omdat software werd uitgeleverd. Dat zijn twee verschillende dingen.

De DORA-metrics — Deployment Frequency, Lead Time for Changes, Change Failure Rate en Time to Restore Service — komen uit het DORA-onderzoeksprogramma en werden gepubliceerd door Nicole Forsgren, Jez Humble en Gene Kim in “Accelerate” (2018). Het zijn de meest rigoureus gevalideerde softwareleveringsmetingen die we hebben. Het onderzoek toont aan dat ze niet alleen correleren met leveringsprestaties, maar ook met organisatorische uitkomsten: omzetgroei, marktaandeel en of engineers bij het bedrijf blijven of vertrekken naar een plek die wél levert. Voor elke startup waarvoor ik als fractional CTO werk, is het instrumenteren van deze vier cijfers een van de eerste dingen die ik doe. Niet omdat ik dashboards wil bijhouden, maar omdat je geen eerlijk gesprek kunt voeren over de gezondheid van een engineeringteam totdat je weet waar het werkelijk staat.

Dit artikel beschrijft de Rails-implementatie: het datamodel, de GitHub Actions-integratie, de ActiveRecord-queries voor elke metric en het minimale admin-dashboard dat je in zestig seconden vertelt of je team vooruitgaat of achteruitgaat.

Wat DORA-metrics zijn (en wat ze niet zijn)

DORA-metrics zijn uitkomsten, geen activiteiten. Ze tellen geen commits, story points, regels code of testdekking in procenten. Ze meten wat de shipmachine produceert: hoe vaak het levert, hoe snel, hoe betrouwbaar en hoe snel het herstelt. Dat onderscheid is belangrijk, want activiteitsmetrics zijn eenvoudig te manipuleren. Een team kan duizend commits per maand produceren en nooit iets uitleveren dat een gebruiker ziet. Uitkomstmetrics beschrijven wat er werkelijk in productie is gebeurd.

De vier metrics en hun prestatiebanden, uit het State of DevOps-rapport van 2024:

Metric Elite High Medium Low
Deployment Frequency Meerdere keren per dag Eén keer per dag tot één keer per week Één keer per week tot één keer per maand Minder dan één keer per maand
Lead Time for Changes Minder dan een uur Tussen één dag en één week Tussen één week en één maand Tussen één maand en zes maanden
Change Failure Rate Minder dan 5% 5–10% 11–15% Meer dan 15%
Time to Restore Service Minder dan een uur Minder dan een dag Minder dan een dag Tussen een dag en een week

De meeste vroege Rails-teams scoren Medium op deployment frequency en Low op lead time. Change failure rate is de metric waar niemand naar wil kijken, omdat die vereist dat je toegeeft dat je incidenten hebt. Begin daar toch mee.

Deployment Frequency: Elke Productie-deploy Bijhouden

Deployment frequency is niet “hoe vaak we pull requests mergen.” Het is hoe vaak code de omgeving bereikt waar echte gebruikers op zitten. Op een Rails-team dat GitHub Actions voor CI/CD gebruikt, werkt een webhook-stap aan het einde van je deploy-workflow het beste: die stuurt een melding naar je eigen Rails-applicatie.

# .github/workflows/deploy.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # ... jouw build-, test- en deploystappen ...

      - name: Record deployment
        if: success()
        run: |
          curl -s -X POST "$/webhooks/deployments" \
            -H "Authorization: Bearer $" \
            -H "Content-Type: application/json" \
            -d '{
              "sha":    "$",
              "ref":    "$",
              "actor":  "$",
              "run_id": "$"
            }'

      - name: Record rollback
        if: failure()
        run: |
          curl -s -X POST "$/webhooks/deployments" \
            -H "Authorization: Bearer $" \
            -H "Content-Type: application/json" \
            -d '{
              "sha":    "$",
              "ref":    "$",
              "actor":  "$",
              "run_id": "$",
              "status": "failed"
            }'

Aan de Rails-kant slaat een Deployment-model elke gebeurtenis op:

# db/migrate/20260630000001_create_deployments.rb
class CreateDeployments < ActiveRecord::Migration[8.0]
  def change
    create_table :deployments do |t|
      t.string   :sha,           null: false
      t.string   :ref,           null: false, default: "main"
      t.string   :actor
      t.string   :github_run_id
      t.string   :status,        null: false, default: "success" # success, failed, rollback
      t.integer  :lead_time_seconds
      t.integer  :commit_count
      t.jsonb    :metadata,      null: false, default: {}
      t.datetime :deployed_at,   null: false
      t.timestamps
    end

    add_index :deployments, :deployed_at
    add_index :deployments, :sha, unique: true
    add_index :deployments, [:status, :deployed_at]
  end
end

De webhook-controller authenticeert met een bearer token uit credentials en maakt het record aan:

# app/controllers/webhooks/deployments_controller.rb
class Webhooks::DeploymentsController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :authenticate_deploy_token!

  def create
    deployment = Deployment.create!(
      sha:           params[:sha],
      ref:           params[:ref],
      actor:         params[:actor],
      github_run_id: params[:run_id],
      status:        params[:status] || "success",
      deployed_at:   Time.current
    )

    DeploymentEnricherJob.perform_later(deployment.id)

    render json: { id: deployment.id }, status: :created
  rescue ActiveRecord::RecordNotUnique
    head :ok  # idempotent: zelfde SHA al geregistreerd
  end

  private

  def authenticate_deploy_token!
    token = request.headers["Authorization"].to_s.delete_prefix("Bearer ")
    head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(
      token, Rails.application.credentials.deploy_webhook_token!
    )
  end
end

Deployment frequency is daarna één query:

# app/models/concerns/dora/deployment_frequency.rb
module Dora
  module DeploymentFrequency
    def self.deploys_per_day(period: 30.days.ago..Time.current)
      count = Deployment.where(deployed_at: period, status: "success").count
      days  = (period.end - period.begin) / 1.day
      count.to_f / days.to_f
    end

    def self.band(deploys_per_day)
      case deploys_per_day
      when (1.0..)              then "Elite"
      when (1.0 / 7..1.0)      then "High"
      when (1.0 / 30..1.0 / 7) then "Medium"
      else "Low"
      end
    end
  end
end

Lead Time for Changes: Waar de Echte Knelpunten Zitten

Lead Time for Changes meet de tijd van de eerste commit van een wijziging tot die commit in productie staat. Niet “hoe lang duurt een sprint.” De wandkloktijd van “een engineer schreef een regel code” tot “een gebruiker kan het zien.” Lange lead times betekenen bijna altijd batch-releases, langlevende branches of een reviewproces dat niemand meer serieus neemt.

Om dit te meten heb je informatie nodig over welke commits er in elke deploy zaten. Een achtergrondtaak haalt dit op via de GitHub API nadat elke deploy is geregistreerd:

# app/jobs/deployment_enricher_job.rb
class DeploymentEnricherJob < ApplicationJob
  queue_as :default

  def perform(deployment_id)
    deployment   = Deployment.find(deployment_id)
    previous_sha = Deployment
      .where(deployed_at: ...deployment.deployed_at, status: "success")
      .order(deployed_at: :desc)
      .pick(:sha)

    return unless previous_sha

    commits = GithubClient.commits_between(
      base: previous_sha,
      head: deployment.sha
    )

    return if commits.empty?

    first_commit_at = commits.map { |c| c[:authored_at] }.min
    return unless first_commit_at

    deployment.update!(
      lead_time_seconds: (deployment.deployed_at - first_commit_at).to_i,
      commit_count:      commits.size,
      metadata:          deployment.metadata.merge(
                           "first_commit_sha" => commits.last[:sha],
                           "first_commit_at"  => first_commit_at.iso8601
                         )
    )
  end
end

De GitHub compare-API doet het zware werk:

# app/services/github_client.rb
class GithubClient
  REPO  = Rails.application.credentials.dig(:github, :repo)
  TOKEN = Rails.application.credentials.dig(:github, :token)

  def self.commits_between(base:, head:)
    conn = Faraday.new("https://api.github.com") do |f|
      f.response :raise_error
      f.response :json
    end

    response = conn.get(
      "/repos/#{REPO}/compare/#{base}...#{head}",
      {},
      {
        "Authorization"        => "Bearer #{TOKEN}",
        "Accept"               => "application/vnd.github+json",
        "X-GitHub-Api-Version" => "2022-11-28"
      }
    )

    response.body.fetch("commits", []).map do |c|
      {
        sha:         c["sha"],
        authored_at: Time.zone.parse(c.dig("commit", "author", "date"))
      }
    end
  rescue Faraday::Error => e
    Rails.logger.error("GithubClient#commits_between mislukt: #{e.message}")
    []
  end
end

Lead time-queries:

module Dora
  module LeadTime
    def self.median_seconds(period: 30.days.ago..Time.current)
      times = Deployment
        .where(deployed_at: period, status: "success")
        .where.not(lead_time_seconds: nil)
        .order(:lead_time_seconds)
        .pluck(:lead_time_seconds)

      return nil if times.empty?
      times[times.size / 2]
    end

    def self.band(seconds)
      return "Onbekend" if seconds.nil?
      case seconds
      when (0...3_600)        then "Elite"   # minder dan een uur
      when (3_600...86_400)   then "High"    # minder dan een dag
      when (86_400...604_800) then "Medium"  # minder dan een week
      else "Low"
      end
    end
  end
end

Gebruik de mediaan, niet het gemiddelde. Één deploy die drie weken commits heeft gebundeld blaast je gemiddelde op en verbergt hoe een normale deploy er eigenlijk uitziet.

De uitsplitsing die ik als eerste bekijk is niet de totale lead time, maar waar de tijd naartoe gaat: codetijd (eerste commit tot PR geopend), reviewtijd (PR geopend tot gemerged) en wachttijd (merge tot productie). De meeste Rails-teams hebben normale code- en wachttijden en een enorme reviewtijd — pull requests die drie dagen open staan omdat niemand context heeft, iedereen het druk heeft en er geen cultuur is van code review als urgente taak. Dat aanpakken kost niets en halveert je lead time gewoonlijk binnen een maand.

Change Failure Rate: De Metric Waar Niemand Naar Wil Kijken

Change Failure Rate is het percentage deploys dat een incident veroorzaakt, een hotfix vereist of teruggedraaid moet worden. Teams tellen dit systematisch te laag in, omdat ze alleen de grote incidenten loggen — de incidenten die iemand om 3 uur ‘s nachts wakker maken. Maar de terugdraaiactie om 3 uur is het gevolg van de change failure; de upstream-gebeurtenis is een deploy waarbij iets stilletjes kapot ging en een klant het de volgende ochtend meldde. Als dat een hotfix-PR genereerde, telt het mee.

Voeg een manier toe om een deploy achteraf als mislukt te markeren. Ik doe dit via een aparte webhook-aanroep vanuit een rollback-actie of een Slack-incidentcommando:

# app/controllers/webhooks/deployment_failures_controller.rb
class Webhooks::DeploymentFailuresController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :authenticate_deploy_token!

  def create
    deployment = Deployment.find_by!(sha: params[:sha])
    deployment.update!(
      status:   "rollback",
      metadata: deployment.metadata.merge("failure_reason" => params[:reason])
    )

    Incident.create!(
      deployment:  deployment,
      severity:    params[:severity] || "p2",
      source:      "deploy",
      detected_at: deployment.deployed_at
    )

    head :ok
  end

  private

  def authenticate_deploy_token!
    token = request.headers["Authorization"].to_s.delete_prefix("Bearer ")
    head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(
      token, Rails.application.credentials.deploy_webhook_token!
    )
  end
end

Change failure rate-query:

module Dora
  module ChangeFailureRate
    def self.call(period: 30.days.ago..Time.current)
      scope    = Deployment.where(deployed_at: period)
      total    = scope.count
      failures = scope.where(status: "rollback").count
      return 0.0 if total.zero?
      (failures.to_f / total * 100).round(1)
    end

    def self.band(rate)
      case rate
      when (0...5)  then "Elite"
      when (5...10) then "High"
      when (10..15) then "Medium"
      else "Low"
      end
    end
  end
end

Een change failure rate van 10% klinkt beheersbaar totdat je de wiskunde doet. Bij wekelijkse deploys zijn dat vijf of zes incidenten per kwartaal, elk met een hotfix-cyclus, postmortem en klantcommunicatie. Bij dagelijkse deploys is dat je fulltime baan. Het contra-intuïtieve resultaat uit het Accelerate-onderzoek — en ik heb dit herhaaldelijk bij klantteams zien spelen — is dat teams die vaker deployen een lagere change failure rate hebben, niet hoger. Kleinere batches betekenen een kleiner risicogebied. Een wijziging van twee bestanden is gemakkelijker te reviewen, gemakkelijker te testen en sneller terug te draaien dan een feature van 47 bestanden die drie weken is opgebouwd.

Time to Restore Service: Veerkracht Meten

Time to Restore Service (TTRS) is hoe lang het duurt om het systeem terug te brengen naar normaal na een storing. Voor een Rails-team betekent dit gewoonlijk het interval van “eerste alert afgaat of eerste klant meldt een probleem” tot “de fix of rollback is uitgeleverd en de monitors staan op groen.” Sla dit op in een aparte incidents-tabel, want niet elk incident wordt veroorzaakt door een deploy en je wilt de flexibiliteit om beide typen te registreren:

# db/migrate/20260630000002_create_incidents.rb
class CreateIncidents < ActiveRecord::Migration[8.0]
  def change
    create_table :incidents do |t|
      t.references :deployment, foreign_key: true
      t.string   :severity,    null: false, default: "p2"     # p1, p2, p3
      t.string   :source,      null: false, default: "manual" # deploy, alert, customer
      t.datetime :detected_at, null: false
      t.datetime :resolved_at
      t.integer  :ttrs_seconds
      t.jsonb    :metadata,    null: false, default: {}
      t.timestamps
    end

    add_index :incidents, :detected_at
    add_index :incidents, :resolved_at
  end
end

Een before_save callback berekent TTRS wanneer het incident wordt gesloten:

# app/models/incident.rb
class Incident < ApplicationRecord
  belongs_to :deployment, optional: true

  before_save :calculate_ttrs, if: -> { resolved_at_changed? && resolved_at.present? }

  scope :p1,       -> { where(severity: "p1") }
  scope :resolved, -> { where.not(resolved_at: nil) }

  private

  def calculate_ttrs
    self.ttrs_seconds = (resolved_at - detected_at).to_i
  end
end

TTRS-query:

module Dora
  module TimeToRestore
    def self.median_seconds(period: 30.days.ago..Time.current, severity: nil)
      scope = Incident.where(detected_at: period).resolved
      scope = scope.where(severity: severity) if severity

      times = scope.order(:ttrs_seconds).pluck(:ttrs_seconds).compact
      return nil if times.empty?
      times[times.size / 2]
    end

    def self.band(seconds)
      return "Onbekend" if seconds.nil?
      case seconds
      when (0...3_600)  then "Elite"  # minder dan een uur
      when (0...86_400) then "High"   # minder dan een dag
      else "Low"
      end
    end
  end
end

Volg P1-incidenten apart van P2 en P3. Een TTRS van vier uur voor een P3 (klein visueel probleem) en een TTRS van vier uur voor een P1 (betalingen liggen stil) zijn heel verschillende verhalen. De DORA-band geldt voor incidenten die de dienst voor gebruikers verstoren; kleine problemen die worden verholpen in de volgende normale deploy tellen niet mee.

Een Minimaal DORA-dashboard in Rails

Een admin-controller die alle vier de metrics tegelijk ophaalt — vier queries, allemaal gedekt door de indexes, allemaal onder de 100 ms op een tabel met miljoenen rijen:

# app/controllers/admin/dora_controller.rb
class Admin::DoraController < Admin::BaseController
  PERIOD = 30.days.ago..Time.current

  def show
    df  = Dora::DeploymentFrequency.deploys_per_day(period: PERIOD)
    lt  = Dora::LeadTime.median_seconds(period: PERIOD)
    cfr = Dora::ChangeFailureRate.call(period: PERIOD)
    ttr = Dora::TimeToRestore.median_seconds(period: PERIOD)

    @metrics = [
      {
        name:  "Deployment Frequency",
        value: "#{df.round(2)} / dag",
        band:  Dora::DeploymentFrequency.band(df)
      },
      {
        name:  "Lead Time for Changes",
        value: lt ? ActiveSupport::Duration.build(lt).inspect : "n.v.t.",
        band:  Dora::LeadTime.band(lt)
      },
      {
        name:  "Change Failure Rate",
        value: "#{cfr}%",
        band:  Dora::ChangeFailureRate.band(cfr)
      },
      {
        name:  "Time to Restore",
        value: ttr ? ActiveSupport::Duration.build(ttr).inspect : "n.v.t.",
        band:  Dora::TimeToRestore.band(ttr)
      }
    ]
  end
end

In de view kleur je de bandkolom: groen voor Elite, turkoois voor High, amber voor Medium, rood voor Low. Een tabel die je in één oogopslag kunt lezen tijdens de wekelijkse engineeringmeeting is beter dan een custom analytics-dashboard dat je eeuwig onderhoudt. Ik koppelde dit onlangs aan OpenTelemetry-tracing voor een klant — OpenTelemetry beantwoordt vragen over je draaiende systeem (latency, foutpercentage, doorvoer), DORA beantwoordt vragen over je leveringsproces. Samen dekken ze de volledige cyclus.

Benchmarks en Wat Je Met de Cijfers Doet

De vier banden zijn een startpunt, geen eindpunt. Teams belanden in Low om verschillende redenen en de oplossing is elke keer anders.

Een team met lage deployment frequency en lage change failure rate heeft meestal zijn goedkeuringsprocessen overdreven. Ze zijn zorgvuldig maar traag, en “zorgvuldig” doet minder veiligheidswerk dan ze denken, omdat infrequente deploys risico’s concentreren. De eerste tien commits van een sprint zijn misschien prima; de tweeënveertigste commit — die twee weken later tegelijk met alles andere wordt uitgeleverd — is de commit die iets kapot maakt dat niemand heeft getest.

Een team met hoge deployment frequency en een change failure rate van 25% heeft het omgekeerde probleem. Ze shippen snel, maken regelmatig iets stuk en hun vertrouwen is misplaatst. De oplossing hier is bijna altijd testdekking voor de specifieke paden die steeds falen, niet het vertragen van de deploys.

De lead time-uitsplitsing is het eerste dat ik bekijk na het ophalen van de basismetingen. Splits je lead time op in drie onderdelen: tijd van eerste commit tot PR geopend, tijd van PR geopend tot gemerged en tijd van merge tot productie. De meeste Rails-teams hebben normale codetijd, een CI-pipeline van 10–20 minuten en een reviewvenster van 48–72 uur omdat iedereen het druk heeft en niemand code review als urgent behandelt. Dat laatste getal is het gemakkelijkst aan te pakken — een teamafspraak dat PR’s van onder de 200 regels dezelfde dag worden gereviewd, halveert de totale lead time gewoonlijk zonder dat er verder iets verandert.

Analyse van de change failure rate moet verder gaan dan “we hadden X rollbacks.” Welke deploys mislukken? Als 80% van je mislukkingen één specifiek domeinmodel of één legacy-service raken, is dat een ander probleem dan mislukkingen die gelijkmatig verdeeld zijn over de codebase. Het technical due diligence-framework dat ik bij klanten gebruik, omvat het ophalen van deze correlatie uit deploy- en incidentdata als onderdeel van de eerste sessie.

Eén ding dat het Accelerate-onderzoek aantoonde — en dat teams nog steeds verrast als ik het laat zien — is dat vaker deployen de change failure rate verbetert. De causaliteit loopt beide kanten op. Teams die gedwongen worden kleinere stukken te shippen door een cultuur van dagelijkse deploys, produceren wijzigingen die gemakkelijker te reviewen, gemakkelijker te testen, gemakkelijker terug te draaien en dus minder waarschijnlijk te falen zijn. De shipmentdiscipline is het veiligheidsmechanisme.

Veelgestelde Vragen Over DORA-metrics voor Rails-teams

Hoe lang duurt het om zinvolle DORA-data te verzamelen?

Dertig dagen geeft je een basislijn, negentig dagen geeft je een trend. De eerste twee weken zijn rommelig — vakantieperiodes, een grote geplande migratie, een week waarbij het team volledig op een lancering was gericht. Commit je aan meten gedurende een volledig kwartaal voordat je conclusies trekt of doelen stelt.

Moeten we DORA-metrics per service of per team bijhouden?

Beide, maar begin per team. Als je één Rails-monoliet en één cross-functioneel team hebt, is dat je eenheid. Als je meerdere services of subteams hebt, volg ze dan apart en aggregeer niet — een hoge failure rate in de betalingsservice die verdund wordt door een lage failure rate in de contentservice is precies het soort signaal dat je duidelijk wilt zien.

Wat telt als een deployment voor DORA-doeleinden?

Elke wijziging die de omgeving bereikt waar echte gebruikers op zitten. Hotfixes tellen mee. Configuratiewijzigingen die via je normale deploypipeline worden doorgevoerd, tellen mee. Feature flags die nieuwe codepaden inschakelen, tellen mee als de vlagwijziging zelf via een deploy gaat. Zero-downtime database-migraties die samen met applicatiecode worden uitgeleverd, tellen mee. Wat niet meetelt: wijzigingen die alleen naar staging gaan, CMS-contentupdates die je deploypipeline omzeilen, of infrastructuurwijzigingen waarbij je applicatiecode niet betrokken is.

Hoe ga ik om met teams die meerdere keren per dag deployen?

Mooi probleem om te hebben. Bij meerdere deploys per dag wordt lead time de meest informatieve metric, omdat deployment frequency al Elite is en de bottleneck ergens in je workflow vóór de deployknop zit. Splits lead time op per auteur, per PR-grootte en per tijdstip van de dag. De bottleneck zit bijna altijd in reviewlatency of in een CI-stap die niemand heeft geoptimaliseerd sinds die twee jaar geleden werd toegevoegd.

Die CTO die ik aan het begin noemde — 23 dagen lead time, 41% change failure rate — nam de cijfers terug naar zijn team en had de meest productieve engineeringretro die ze ooit hadden gehad. Zes maanden later was de lead time onder de vijf dagen en de change failure rate onder de 10%. Geen Elite. Niet eens High op elke metric. Maar een team dat de waarheid over zichzelf kende en in de goede richting bewoog, wat meer is dan de meeste teams.

Iemand nodig die je DORA-metrics instrumenteert en je eerlijk vertelt wat ze betekenen? TTB Software is gespecialiseerd in engineering-assessments en fractional technisch leiderschap voor Rails-teams bij groeibedrijven. We doen dit al negentien jaar.

#dora-metrics-rails #deployment-frequency-tracking #lead-time-for-changes #change-failure-rate #engineering-metrics #github-actions-deployment-tracking #rails-team-performance

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software