Skip to content

Deploy report: measure every phase, build step and dash's own overhead, and turn it into advice #154

Description

@mhenrixon

Deploy report: measure every phase, every build step and dash's own overhead, and turn it into advice

Problem / Goal

A deploy prints one total (Finished all in 196.2 seconds) and, since #147, a per-phase table. That table stops at the phase boundary: "Build and push app image 140.0s" says nothing about which Dockerfile step burned the time, whether the cache hit, how many MB of build context were shipped to the builder, or how much of a 55s boot was dash issuing forty serial docker commands over SSH versus the app becoming healthy. Nothing measures dash's own overhead (gem load, config parse, secrets adapters, SSH connects, lock acquisition), so it cannot be improved with evidence. And the Dockerfile — the single biggest lever on build time and image size — is never looked at.

Done looks like:

  • Every deploy, redeploy, setup, rollback and standalone build push prints, with no flag, an extended table: dash overhead rows, per-build-step rows (cached / uncached / seconds / context MB), and per-phase SSH round-trip count and time-in-SSH.
  • Below the table, an Advice block: findings from a built-in Dockerfile analyzer (optionally augmented by hadolint when installed), several of which are correlated with the measured build ("RUN bundle install took 84.1s uncached; the COPY . . on line 14 above it busts that layer on every commit"), plus trend findings ("build 84s vs median 41s over the last 5 deploys of this destination").
  • The same data is exported: a JSON report per run under .dash/reports/, dash.phase / dash.build.step / dash.advice events through the existing OTel logger, and summary values in the post-deploy hook environment.
  • dash doctor runs the static Dockerfile rules. dash report prints the last saved report or a trend over the last N.
  • The measurement itself is free: zero additional SSH or docker commands, the build output is parsed from the stream dash already receives, and no failure in measurement or advice can ever fail a deploy.
  • A final, separate PR fixes the top overhead items the new table reveals, with before/after SSH round-trip counts (the gem has no bench harness by rule; round trips are the honest metric).

Generic by design: the rules below were derived from surveying three production Rails deploys, but they name no project and apply to any Dockerfile.

Context (read these first)

Measurement spine (exists — extend, do not replace):

  • lib/dash/timings.rbDash::Timings#phase(name, depth:) records wall time per phase, mutex-guarded, entries in start order; #lines renders the table. Explicit depth: because boot host phases run in SSHKit threads.
  • lib/dash/cli/base.rbprint_runtime (prints Finished all in and the table from the outermost level only; setup nests deploy), timed(name, depth:), modify(lock:), acquire_lock / acquire_server_lock (currently untimed), ensure_run_directory (runs on(DASH.hosts) for every lock acquire), run_hook(hook, **extra_details) (extra details become DASH_<KEY> / KAMAL_<KEY> env via Dash::Tags#env, lib/dash/tags.rb:31).
  • lib/dash/cli/main.rbdeploy, redeploy, setup, rollback: the timed(...) call sites and print_config_banner. validate_secrets! is called but not timed; this is where secrets adapters (1Password, Bitwarden, …) shell out.
  • lib/dash/cli/app/boot.rb — per-host DASH.timings.phase("#{role} #{host}", depth: 1) and timing_healthy (sets entry.detail = "healthy after Ns"). Note the SSH round trips per host: container_id_for_version is captured twice (once in old_version_renamed_if_clashing, again in start_new_version), each audit is its own SSH exec, plus ensure_env_directory, upload!, run, deploy/health polling, stop.
  • lib/dash/commander.rbDASH singleton, reset creates @timings, memoised @commands, modify instruments modify.kamal via ActiveSupport::Notifications, configure_output_with installs Dash::Output::Formatter + broadcast logger when output: is configured.
  • lib/dash/sshkit_with_ext.rb — every SSHKit monkey patch lives here: CommandEnvMerge (kwargs like interaction_handler: pass straight through to SSHKit::Command), SSHKit::Runner::Parallel::CompleteAll#execute (creates the per-host threads — the place to propagate a thread-local "current timing entry" into child threads), LimitConcurrentStartsInstance#connect_ssh (the SSH connect hook point), SSHKitDslRoles#on_roles (creates per-role threads the same way).
  • lib/dash.rb, bin/dash — process entry; require "dash" is the first thing bin/dash does, so a timestamp at the top of lib/dash.rb measures gem load + Zeitwerk + config for the Startup row.

Build pipeline:

  • lib/dash/cli/build.rbpush runs execute *push, env: DASH.builder.push_env inside run_locally at :debug verbosity (build output is streamed through SSHKit's local backend via Open3.popen3; the command already ends in 2>&1). deliver = push + pull. dev builds too. pull does registry login + per-host pull/validate.
  • lib/dash/commands/builder/base.rbpush builds the docker buildx build … argv; build_dockerfile already resolves the Dockerfile path (config.builder.dockerfile, raises BuilderError when missing); build_context = config.builder.context. Subclasses: local.rb, remote.rb, hybrid.rb, cloud.rb (all buildx), pack.rb (Cloud Native Buildpacks — no buildx progress output, only a total).
  • lib/dash/configuration/builder.rbdockerfile, context, cache_from/cache_to (the mode=max|min string lives in cache.options), secrets, args, arches, remote.
  • SSHKit 1.25 lib/sshkit/command.rb:240 call_interaction_handler — any object responding to on_data(command, stream_name, data, channel) passed as interaction_handler: sees every chunk of stdout/stderr as it streams. This is how build steps get parsed with no second process and no buffering change.

Advice surfaces:

  • lib/dash/cli/doctor.rb + lib/dash/cli/doctor/config_checks.rbResult(check, target, status, detail), CHECK_TITLES, STATUS_COLORS; ConfigChecks is the SSH-free, config-only check set (readiness today). The Dockerfile static check joins it. Dash::Cli::Main#doctor prints via print_doctor_report.
  • lib/dash/output/otel_logger.rb, lib/dash/otel_shipper.rbon_finish(payload, runtime) fires when the outermost modify completes (after print_runtime, so timings are final); OtelShipper#event(name, **attributes) with typed values. Existing attribute keys are kamal.* and stay as they are.
  • lib/dash/output/file_logger.rb — pattern for a per-run file under a path, filename_for(payload).
  • lib/dash/project_directory.rbDash::ProjectDirectory.join("secrets"); reports go under the same project directory.
  • lib/dash/commands/hook.rb + lib/dash/tags.rb — hook env is dual-emitted DASH_*/KAMAL_* from details; new keys need no plumbing.

Configuration + docs:

  • lib/dash/configuration.rb — top-level keys (output, boot, deploy_timeout, …) and Dash::Configuration::Validator wiring; lib/dash/configuration/output.rb is the smallest example of a validated sub-config with to_h.
  • lib/dash/configuration/docs/*.yml — commented-YAML docs that both dash docs and the docs site render; a new YAML must be registered in docs/app/models/doc.rb or docs/spec/config_docs_spec.rb fails.
  • lib/dash/configuration/validator.rb, lib/dash/configuration/validator/*.rb — per-key validators.

Tests:

  • test/timings_test.rb — current table format assertions (regexes like /\A Boot\s+\d+\.\ds\z/; the new columns must keep these passing or update them deliberately).
  • test/cli/cli_test_case.rbSSHKit::Backend::Printer swaps in for SSH; assertions are against printed command strings. test/cli/main_test.rb asserts the deploy output sequence. test/cli/build_test.rb (9 buildx build assertions) and test/commands/builder_test.rb (17) assert the exact buildx argv — adding --progress=plain touches all of them.
  • test/cli/doctor_test.rb — doctor result assertions.
  • test/fixtures/deploy_*.yml — deploy fixtures; a Dockerfile fixture directory does not exist yet.

Rules that bind this work: CLAUDE.md (layer cake, frozen server artifacts), .claude/rules/performance.md (no bench harness for the gem; reason about round trips; never claim a speedup without before/after), .claude/rules/testing.md, .claude/rules/coding-style.md.

Decision

Chosen approach: extend the existing Dash::Timings spine into a Dash::Report (timings + build steps + advice), fed by three zero-cost sources, printed always, exported through the loggers that already exist.

  1. SSH accounting by prepending SSHKit in sshkit_with_ext.rb: every create_command_and_execute and every connect_ssh attributes its wall time to the "current timing entry" held in a thread-local, propagated into SSHKit's per-host threads from the two places dash already creates them (CompleteAll#execute, on_roles). No SSH call is added; dash only stamps the ones it makes.
  2. Build steps by parsing the buildx stream dash already receives: add --progress=plain to the buildx argv (Commands layer) and pass an interaction_handler: (a Dash::Build::ProgressParser) to the existing execute in Cli::Build#push. No docker buildx history, no --metadata-file, no second process.
  3. Dockerfile analysis in-process with a small parser + rule set (Dash::Dockerfile::*). Rules take the build report when there is one, so measured facts (uncached seconds, context MB, cache-export seconds) upgrade static hints into specific advice. hadolint is folded in only when it is on PATH and not disabled.

Everything above is additive to output; the deploy's command sequence is byte-identical except for the one --progress=plain flag.

Alternatives considered

  • docker buildx history inspect --format json after the build for step timings — accurate and structured, but needs buildx ≥ 0.21 and a driver that records history (the cloud and older remote drivers vary), costs one extra docker command per build, and gives nothing for a build that fails mid-way. Rejected; the plain-progress stream is available on every buildx dash supports and yields the same per-step DONE/CACHED seconds. Left as a possible later upgrade behind the same Dash::Build::Report shape.
  • --progress=rawjson — cleaner to parse than plain text, but buildx ≥ 0.13 only and the plain format is what operators already see in -v output. Rejected for compatibility; the parser is a ~120-line line-oriented state machine either way.
  • hadolint as the only analyzer — no correlation with measured timings, absent on most CI runners, ~100 generic rules that are mostly style. Rejected as the primary; kept as an optional supplement (interview decision).
  • Opt-in --profile flag or a separate dash analyze command — rejected in interview: the operator should never have to remember a flag to learn why a deploy was slow. --profile is not introduced at all; the verbose detail lives in the JSON report.
  • A bench harness for gem-side Ruby — explicitly forbidden by .claude/rules/performance.md. The overhead-fix PR reports SSH round-trip counts and the table's own before/after instead.

Settled in interview:

  • Surface is always-on in deploy output. No --profile flag. Terminal output is the primary consumer; exports are additional, not alternatives.
  • Dockerfile analysis is built-in Ruby rules plus optional hadolint when it is on PATH (disable via config).
  • Tool performance covers all four: measure dash's own overhead per deploy; guard the measurement's own cost as an invariant; fix the overhead the measurement reveals (as the final PR of this issue, measured first); ongoing regression tracking via locally persisted reports.
  • Exports: OTel events via the existing OtelLogger, a JSON report file per run, and post-deploy hook env vars. All three.
  • The plan stays generic: no project, company or domain specifics anywhere in code, docs or fixtures.

Design decisions the executor must not reopen:

  • New OTel event names and attribute keys use the dash. prefix. Existing kamal.* keys are untouched (out of scope).
  • Reports are written to .dash/reports/ (via Dash::ProjectDirectory.join("reports")) with a generated .dash/reports/.gitignore containing * and !.gitignore, so a committed .dash/ never picks them up.
  • Measurement and advice never raise into the deploy: every parser/analyzer/writer entry point rescues StandardError, prints one yellow line (Deploy report unavailable: <class>: <message>, backtrace with VERBOSE=1) and the deploy continues with whatever was collected.
  • No new SSH or docker commands anywhere in slices 1–4. The only argv change is --progress=plain. A test enforces this (see gates).
  • No MINIMUM_VERSION change; ../kamal-proxy is not touched.

Implementation steps

Five PRs, in order. Each is independently mergeable and leaves main releasable. Branch each off fresh main (feat/report-<slice>), PR into main.

PR 1 — Measurement spine: overhead rows, SSH accounting, parent/child entries

Layer: Dash::Timings (utility), Dash::Cli::Base / Main (CLI), sshkit_with_ext.rb (Layer 0 patch).

  1. lib/dash/timings.rb
    • Entry gains parent, commands (Integer), command_seconds (Float), connect_seconds (Float), local (Boolean, true for run_locally commands). Keep name, seconds, detail, depth.
    • phase(name, depth:) sets entry.parent = Thread.current[:dash_timing_entry], then sets the thread-local to entry for the block's duration and restores it in ensure.
    • record(name, seconds, depth: 0, detail: nil) — appends a pre-measured entry (for Startup).
    • attribute_command(seconds, local:) and attribute_connect(seconds) — add to the thread-current entry (no-op when none); mutex-guarded.
    • current — the thread-current entry (used by SSHKit thread propagation).
    • lines — a parent's printed commands/command_seconds is the sum over its subtree (computed at render time, not at record time, so host rows keep their own numbers). Format: %s%-36s %6.1fs then, when the entry or its subtree issued commands, %3d ssh %5.1fs (or local in place of ssh for local commands), then the existing (detail). Update test/timings_test.rb regexes accordingly and add tests for: subtree sums, thread-local restore after a raising block, record, attribution with no current entry is a no-op.
    • to_h — array of entry hashes for the JSON report (PR 4 consumes it; add it here so the shape is tested once).
  2. lib/dash/sshkit_with_ext.rb
    • New module TimedCommands prepended to SSHKit::Backend::Abstract: wraps create_command_and_execute(args, options) with a monotonic clock and calls DASH.timings.attribute_command(elapsed, local: self.is_a?(SSHKit::Backend::Local)). Guard with defined?(DASH) so SSHKit used outside dash (tests that build backends directly) is unaffected.
    • Wrap connect_ssh in LimitConcurrentStartsInstance (or a sibling module prepended after it) to call DASH.timings.attribute_connect(elapsed). The pool only calls connect_ssh on a cache miss, so this naturally measures real connects only.
    • In CompleteAll#execute and SSHKitDslRoles#on_roles, capture parent = DASH.timings.current before Thread.new and set Thread.current[:dash_timing_entry] = parent as the first statement inside each thread. (Dash::Cli::App::Boot#run then opens its own host phase as a child of Boot.)
  3. lib/dash.rb — first line after the module Dash error class: Dash::PROCESS_STARTED_AT = Process.clock_gettime(Process::CLOCK_MONOTONIC) placed before the require "active_support" line so gem load is inside the measurement.
  4. lib/dash/cli/base.rb
    • print_runtime (outermost level only): before yield, DASH.timings.record("Startup (load, config)", now - Dash::PROCESS_STARTED_AT).
    • acquire_lock and acquire_server_lock: wrap the body in timed("Acquire deploy lock") / timed("Acquire server lock") (depth 0). Their ensure_run_directory SSH sweep is now visible as commands on that row.
  5. lib/dash/cli/main.rbdeploy and redeploy: timed("Validate config and secrets") { DASH.config.validate_secrets!(...) }. setup: same around the bootstrap it already times. Keep every existing say line unchanged (main_test asserts them).
  6. Tests: test/timings_test.rb (above); test/cli/main_test.rb — assert the table now contains Startup, Validate config and secrets, Acquire deploy lock rows and that Boot shows an ssh column; test/sshkit_with_ext_test.rb (new, or extend an existing sshkit test if one exists under test/) — a Printer-backed on attributes one command to the current entry, a run_locally command is marked local, and a thread spawned through SSHKit::Runner::Parallel inherits the parent entry.
  7. Cost-guard test (test/cli/main_test.rb): capture the full printed command list of a deploy on main before starting (paste into the test as the expected sequence, minus the timing lines) and assert the sequence is unchanged after PR 1. PR 2 edits exactly one expected line (--progress=plain). This test is the mechanical form of the "no new SSH/docker commands" invariant and stays for PRs 3–4.

PR 2 — Build step measurement

Layer: Dash::Commands::Builder::Base (Commands), Dash::Cli::Build (CLI), new Dash::Build::* (utility, no SSH).

  1. lib/dash/commands/builder/base.rb#push — insert "--progress=plain" immediately after "--output=type=#{export_action}". Update the 26 argv assertions in test/commands/builder_test.rb and test/cli/build_test.rb. pack.rb is untouched.
  2. New lib/dash/build/progress_parser.rbDash::Build::ProgressParser, an SSHKit interaction handler: on_data(_command, _stream, data, _channel) appends to a line buffer and feeds complete lines to parse_line; finish flushes the tail. Recognised lines (buildx plain format; N is the vertex number):
    • #N [internal] load build context → step kind :context
    • #N transferring context: 340.12MB 3.2s done / ... 1.23kB donecontext_bytes (parse unit suffixes B/kB/MB/GB) and seconds
    • #N [<stage> k/m] <INSTRUCTION> <args…> → step kind :instruction, stage, ordinal k, instruction = the text after k/m] with whitespace collapsed; unnamed stages appear as stage-0, stage-1
    • #N [internal] load metadata for <image>:metadata
    • #N [<stage> k/m] FROM … and #N resolve <image>:from
    • #N CACHEDcached = true, seconds 0
    • #N DONE 12.3sseconds
    • #N ERROR: …error = message
    • #N exporting to image / #N exporting layers / #N pushing layers / #N pushing manifest:export (name = the phrase); #N exporting cache to registry (and exporting cache variants) → :cache_export
    • #N ... continuation lines with a timestamp prefix (#N 12.34 …) are the step's own output → ignored
    • anything else → ignored (never raise)
      Multiple DONE lines for the same #N (buildx re-reports on multi-platform builds) take the last value. Steps are keyed by N in a Concurrent::Hash (concurrent-ruby is already a dependency) because SSHKit may call on_data from its reader thread.
  3. New lib/dash/build/report.rbDash::Build::Report (built by the parser's result): steps (ordered by first appearance), context_bytes, context_seconds, cached_steps, uncached_steps, slowest(n), instruction_steps (kind :instruction), export_seconds, cache_export_seconds, push_seconds, total_step_seconds, errors, to_h. Pure data, fully unit tested from a fixture log.
  4. lib/dash/cli/build.rb#push — after computing push, parser = Dash::Build::ProgressParser.new (skip when DASH.builder.pack?); execute *push, env: DASH.builder.push_env, interaction_handler: parser; in ensure, parser.finish and DASH.report.build = parser.result (also on failure: a partial report with errors is what an operator wants when the build breaks). dev gets the same treatment.
  5. lib/dash/commander.rbattr_reader :report, @report = Dash::Report.new(timings: @timings) in reset. Dash::Report (lib/dash/report.rb) holds timings, build, advice (PR 3), started_at, and lines: the timings table followed by build sub-rows and, later, the advice block. Cli::Base#print_runtime prints DASH.report.lines instead of DASH.timings.lines.
  6. Build sub-rows (rendered by Dash::Report, depth 1 under the build phase entry found by name Build and push app image — pass the entry explicitly rather than searching by name: Cli::Main does timed("Build and push app image") { |entry| DASH.report.build_entry = entry; … }):
    • build context 340.1MB 3.2s
    • the 5 slowest uncached instruction steps: [build 5/9] RUN bundle install 84.1s
    • cached steps 9 of 14
    • export + push 41.4s (cache export 29.4s) — omit rows whose value is zero
    • Standalone dash build push / dash build dev (no print_runtime): print the same sub-rows after the build, under a Build header, so CI pipelines that split build and deploy still see them.
  7. Fixtures: test/fixtures/build/progress_plain_success.log, progress_plain_cached.log, progress_plain_failed.log, progress_plain_multiplatform.log — captured from real docker buildx build --progress=plain 2>&1 runs of a generic multi-stage Rails Dockerfile (the executor generates these locally with Docker; scrub any registry host to registry.example.com). Tests: test/build/progress_parser_test.rb, test/build/report_test.rb, test/cli/build_test.rb (parser attached; pack builder skips; failure still stores a partial report), test/report_test.rb (rendering).

PR 3 — Dockerfile analysis, advice block, doctor check, report: config

Layer: new Dash::Dockerfile::* (utility, no SSH), Dash::Configuration::Report (Configuration), Dash::Cli::Doctor::ConfigChecks and Dash::Cli::Main (CLI), docs.

  1. lib/dash/dockerfile/parser.rbDash::Dockerfile::Parser.parse(text)Dash::Dockerfile::File with stages and instructions. Handles: comments and blank lines, # syntax= and # escape= directives, \ continuations, heredocs (<<EOFEOF, <<-EOF, quoted delimiters), instruction keywords case-insensitive, per-instruction flags (--mount=…, --from=…, --chown=…, --platform=…) separated from args, FROM <image>[:<tag>|@<digest>] [AS <name>] with ARG-interpolated tags ($RUBY_VERSION), ARG before the first FROM, JSON-array and shell forms of RUN/CMD/ENTRYPOINT. Each Instruction has name, args (raw string), flags (Hash), line (1-based, of the first physical line), stage. Each Stage has name (explicit AS name or stage-<i>), index, base (image or another stage's name), instructions, shipped? (true for the last stage and every stage it transitively FROMs — copied-from stages are not shipped).
  2. lib/dash/dockerfile/analyzer.rbDash::Dockerfile::Analyzer.new(file:, dockerignore:, context_dir:, build: nil, builder_config:).findingsDash::Dockerfile::Finding(rule, severity (:warn | :info), location, message, suggestion). location is Dockerfile:14, .dockerignore, build context, or builder.cache. Rules are small classes under lib/dash/dockerfile/rules/ each with id, run(ctx) → [Finding]; the analyzer runs them in order and applies ignore. Helper on the context: dependency_install?(instruction) matching bundle install, npm ci|install, yarn install, pnpm install, bun install, pip install, poetry install, go mod download, cargo build|fetch, composer install, mix deps.get, dotnet restore; broad_copy?(instruction) = COPY/ADD whose source is ., ./, *, or / (after the flags).
  3. Rules (ids are stable public strings; ignore: refers to them). Static unless marked measured; measured rules run only when a build report is present and otherwise fall back to the static form or stay silent:
    • copy-before-install (warn): in any stage, a broad copy precedes a dependency-install RUN. Measured: if that install step ran uncached, append (measured 84.1s uncached). Suggestion: copy manifests + lockfiles first, install, then copy the tree.
    • missing-dockerignore (warn) / dockerignore-gaps (info): no .dockerignore in context_dir; or one that does not cover .git, and for each of node_modules, tmp, log, storage, coverage, .env* that exists in context_dir, not covered. Measured: if context_bytes > 50 MB, severity warn and the size is named. Pattern coverage is checked with File.fnmatch against the ignore lines (no negation handling beyond skipping ! lines).
    • context-size (measured, warn): context_bytes > 50 MB regardless of .dockerignore state — names the MB and seconds.
    • no-cache-mount (info): a dependency-install RUN without --mount=type=cache. Suggestion names the conventional target per tool (bundler /usr/local/bundle/cache or BUNDLE_PATH, npm /root/.npm, bun /root/.bun/install/cache, pip /root/.cache/pip, apt /var/cache/apt).
    • apt-hygiene (info): a RUN with apt-get install lacking --no-install-recommends, or lacking rm -rf /var/lib/apt/lists in the same RUN, in a shipped stage only.
    • latest-base (warn): FROM with no tag or :latest (ARG-interpolated tags and digests pass).
    • single-stage-build-deps (warn): exactly one stage and it installs build-essential, gcc, g++, make, or *-dev packages.
    • root-user (info): the shipped final stage has no USER.
    • inline-env-blob (info): a RUN with more than 20 inline KEY=value assignments before the command. Suggestion: ARGs or an env file, so editing one value does not invalidate the layer.
    • curl-pipe-shell (info): curl|wget … | sh|bash in a RUN. Suggestion: pin a version and verify a checksum.
    • secret-in-build-arg (warn): ARG/ENV whose name matches /(PASSWORD|SECRET|TOKEN|_KEY)\b/i (except SECRET_KEY_BASE_DUMMY). Suggestion: --mount=type=secret with builder.secrets in deploy.yml.
    • cache-busting-arg (info): an ARG named like GIT_SHA|COMMIT|BUILD_DATE|BUILD_TIME|VERSION (excluding RUBY_VERSION/NODE_VERSION/*_VERSION that name a toolchain) referenced ($NAME/${NAME}) in an instruction before the last dependency-install RUN of its stage.
    • cache-export-cost (measured, info): cache_export_seconds > 20% of total_step_seconds and builder_config.cache_to contains mode=max. Suggestion: mode=min (persistent builders and registry caches often make max export pure overhead; state it as a measured fact, not a promise).
    • uncached-install (measured, info): a dependency-install step ran uncached with seconds > 10 and no copy-before-install finding explains it — lists the step so the operator can check what invalidated it.
    • hadolint (severity mapped from hadolint's error→warn, warning/info/style→info): see step 5.
    • trend-* rules land in PR 4.
  4. lib/dash/configuration/report.rbDash::Configuration::Report from the new top-level report: key, all optional:
    report:
      advice: true          # print the Advice block (default true; the table always prints)
      hadolint: auto        # auto = run when on PATH, false = never
      history: 20           # JSON reports kept per destination under .dash/reports (0 disables writing)
      ignore:               # rule ids to silence
        - root-user
    Validate with the existing Validation mixin (test/configuration/report_test.rb). Wire config.report in lib/dash/configuration.rb. Add lib/dash/configuration/docs/report.yml and register page "Report", group: "Configuration", slug: "report", view: "Config::Report" in docs/app/models/doc.rb (the drift spec requires it).
  5. lib/dash/dockerfile/hadolint.rb — when config.report.hadolint is auto and hadolint resolves on PATH (Dash::Utils helper using ENV["PATH"] + File.executable?, no shell), run hadolint --format json --no-fail <dockerfile> with Open3.capture2 (a local process, not SSHKit — keeps the printed command sequence unchanged), parse JSON, map to findings with location: "Dockerfile:<line>" and message "<code>: <message>". Any error → one info finding hadolint: could not run (<message>). hadolint rule ids in ignore (DL3008) are honoured.
  6. lib/dash/report.rbadvice list; Dash::Report#analyze!(build: @build) runs the analyzer for DASH.config.builder.dockerfile in DASH.config.builder.context (only when the context is a local directory — skip for a git-clone build directory? No: Clone prepares a local checkout; analyze the file at DASH.config.builder.build_directory). lines renders:
      Advice
        warn  Dockerfile:14   COPY . . runs before `bundle install` (line 21); gems reinstall on every commit (measured 84.1s uncached)
                              → copy Gemfile + Gemfile.lock first, bundle install, then COPY . .
        info  builder.cache   exporting cache took 29.4s of 140.0s with mode=max
                              → try mode=min
    
    warn in yellow, info uncoloured, via say-compatible ANSI (the table already uses plain puts; keep colours out of the JSON). Nothing prints when there are no findings and advice: true; the block is skipped entirely when advice: false.
  7. Call sites: Cli::Main#deploy/redeploy/setup run DASH.report.analyze! right after the build/pull phase (so advice appears even if boot later fails), inside the never-raise guard. Cli::Build#push and #dev run it too for standalone builds. rollback skips it (no build, no Dockerfile relevance).
  8. lib/dash/cli/doctor/config_checks.rb — add dockerfile_results: run the analyzer statically (no build report, hadolint per config); :dockerfile check with CHECK_TITLES[:dockerfile] = "Dockerfile"; one Result per finding (warn:warn, info:ok with the message so operators still see it), or one :ok "no findings" row. Missing Dockerfile → :fail with the path (the same condition Builder::Base#build_dockerfile raises on). Doctor's never-crash contract applies: rescue StandardError per check.
  9. Tests: test/dockerfile/parser_test.rb (each syntax feature above, with a fixture directory test/fixtures/dockerfiles/ holding rails_multistage.Dockerfile (a generic three-stage base/build/final Rails file with cache mounts, correct COPY order, non-root user), naive_single_stage.Dockerfile (COPY . . first, :latest, root, apt without cleanup, curl|bash, a 30-var inline env RUN), heredoc.Dockerfile); test/dockerfile/analyzer_test.rb (every rule fires on the naive file and is silent on the good one; measured rules with a stubbed Build::Report; ignore honoured; shipped? stage logic); test/dockerfile/hadolint_test.rb (stubbed Open3, PATH miss, JSON mapping, failure → info); test/configuration/report_test.rb; test/cli/doctor_test.rb (Dockerfile rows); test/cli/main_test.rb (advice block appears after the table; absent with advice: false; the cost-guard sequence is unchanged); docs/spec green after registering the page.
  10. Docs: docs/app/views/docs/pages/deploy_report.rb (or the docs-kit generator equivalent: rails g docs_kit:page "Deploy report" --group=Deploying) — a narrative page: what the table columns mean, the build rows, each rule id with the one-line fix, how to silence a rule, how to read the JSON. Keep it generic.

PR 4 — Export: JSON reports, trend advice, dash report, OTel events, hook env

Layer: Dash::Report (utility), Dash::Output::OtelLogger (output), Dash::Cli::Main + new Dash::Cli::Report (CLI).

  1. lib/dash/report/writer.rb — after the outermost print_runtime completes (success or failure), when config.report.history > 0: write Dash::ProjectDirectory.join("reports", "<UTC timestamp %Y-%m-%dT%H-%M-%SZ>-<destination or default>-<command>.json"), creating the directory and its .gitignore (* and !.gitignore) on first use; then prune to the newest history files for that destination. Schema ("schema": 1):
    {
      "schema": 1, "dash_version": "4.1.0", "command": "deploy", "service": "app", "destination": "production",
      "version": "abc1234", "started_at": "2026-09-10T12:00:00Z", "runtime": 196.2, "status": "succeeded",
      "phases": [ { "name": "Boot", "depth": 0, "seconds": 55.2, "detail": null, "commands": 12, "command_seconds": 41.0, "connect_seconds": 1.2, "local": false } ],
      "build": { "builder": "remote", "context_bytes": 356515840, "context_seconds": 3.2, "cached_steps": 9, "total_steps": 14,
                 "export_seconds": 12.0, "cache_export_seconds": 29.4, "push_seconds": 12.0,
                 "steps": [ { "stage": "build", "ordinal": 5, "instruction": "RUN bundle install", "seconds": 84.1, "cached": false, "kind": "instruction" } ] },
      "advice": [ { "rule": "copy-before-install", "severity": "warn", "location": "Dockerfile:14", "message": "", "suggestion": "" } ]
    }
    status is failed with "error": { "class": …, "message": … } when the deploy raised. The path is printed as Report written to .dash/reports/….json after the advice block.
  2. lib/dash/report/history.rbDash::Report::History.new(dir, destination:): recent(n) loads and validates schema 1 files (skip unreadable ones silently). Trend rules, run by the analyzer when history has ≥ 3 prior successful reports of the same command and destination:
    • trend-build (info): build phase seconds > 1.5× the median of the last n (n = min(5, available)) → build 84.1s vs median 41.0s over the last 5 deploys.
    • trend-boot (info): same for Boot.
    • trend-overhead (info): Startup + Validate config and secrets + lock rows summed > 10s or > 1.5× median → names the slowest of them (a slow secrets adapter is the usual culprit).
    • trend-total (info): total runtime > 1.5× median.
  3. lib/dash/cli/report.rbDash::Cli::Report < Dash::Cli::Base, registered in Main as subcommand "report": dash report prints the latest saved report (table + build rows + advice, re-rendered from JSON via Dash::Report.from_h); dash report --last N prints a trend table (one row per report: started_at, version, total, build, boot, advice count); dash report path prints the reports directory. Honour -d destination. Read-only: no modify, no SSH.
  4. lib/dash/output/otel_logger.rb#on_finish — after the existing complete/failed event, for every timing entry emit dash.phase (dash.phase.name, dash.phase.depth, dash.phase.seconds, dash.phase.detail, dash.phase.commands, dash.phase.command_seconds, dash.phase.connect_seconds), for every build step dash.build.step (dash.build.stage, dash.build.ordinal, dash.build.instruction, dash.build.seconds, dash.build.cached), one dash.build summary event (context bytes, cached/total, export/push seconds), and per finding dash.advice (dash.advice.rule, dash.advice.severity, dash.advice.location, dash.advice.message). All carry the existing deployment.id attrs. OtelShipper#event already handles typed values; booleans need a boolValue branch in typed_value (add it).
  5. lib/dash/cli/main.rbrun_hook "post-deploy", secrets: true, runtime: … gains build_runtime, boot_runtime, advice_count, advice_warnings, report_path (each rounded/stringified; omit keys whose source phase did not run). Dash::Tags#env dual-emits them as DASH_BUILD_RUNTIME / KAMAL_BUILD_RUNTIME etc. with no further change. Document the new variables on the existing Hooks docs page.
  6. Tests: test/report/writer_test.rb (writes under a tmp project dir, .gitignore created once, pruning to history, history: 0 writes nothing, failure status); test/report/history_test.rb (median math, ignores unreadable/foreign-schema files, destination scoping); test/dockerfile/analyzer_test.rb (trend rules with fixture history); test/cli/report_test.rb (latest, --last, empty directory message); test/output/otel_logger_test.rb or existing otel tests (events emitted with the right names; boolValue); test/cli/main_test.rb (post-deploy hook env contains the new keys — check the DASH_BUILD_RUNTIME line in the printed hook env, mirroring how DASH_RUNTIME is asserted today).

PR 5 — Fix the overhead the table reveals (measured first)

Layer: Dash::Cli::App::Boot, Dash::Cli::Base, Dash::Commands::*.

Run at least one real deploy (integration harness or a staging target) with PRs 1–4 merged and paste the table into the PR description as the baseline. Then, in descending order of measured cost, land only fixes whose SSH round-trip reduction the table proves. Candidates identified by reading the code (verify each against the numbers before touching it):

  1. Cli::App::Bootapp.container_id_for_version(version) is captured in old_version_renamed_if_clashing and again in start_new_version after app.run; the second is needed (the container was just created), the first exists only to detect a clash. Combine the two pre-run captures (container_id_for_version + current_running_version) into one docker ps --format call via Dash::Commands::App returning both, parsed locally.
  2. Audit records: every audit(...) is a standalone SSH echo >> audit.log before the action it describes. Where the action is itself an execute (not a capture), fold the audit into the same round trip with combine (Commands layer), preserving the "audit before action" order within the shell string. Never fold an audit into a capture.
  3. Cli::Base#ensure_run_directory runs an on(DASH.hosts) sweep for every lock acquire (deploy lock, then server lock in the same process). Memoise per process (DASH.run_directory_ensured), reset in Commander#reset.
  4. Cli::Build#pull_on_hostsclean + pull + validate_image are three round trips per host; clean and pull can be one combined command (clean already ignores failure).
  5. Anything the Detect stale containers and Prune rows show as disproportionate.

Each fix: unit tests updated to the new command strings, the cost-guard test sequence updated deliberately with the reduction explained in the commit body, and the PR description shows before/after rows (Boot 12 ssh → 8 ssh, seconds as measured). No fix that changes ordering semantics (audit before action, health barrier), no threading changes.

Release

Features land across 4.x minor releases: bin/release minor after PR 4 at the latest (PRs 1–2 can ship as 4.1.0 on their own). No manual version.rb edits; no MINIMUM_VERSION change.

Verification gates

Per PR:

  • bundle exec ruby -Itest -e 'Dir["test/**/*_test.rb"].grep_v(/integration/).each { |f| require File.expand_path(f) }' — all green (host-independent; a failure is real)
  • bundle exec rubocop --parallel — no offenses
  • cd docs && bundle exec rspec — green (PR 3 registers the new config page; PR 4 touches the Hooks page)
  • The cost-guard test in test/cli/main_test.rb passes with an unchanged command sequence in PRs 1, 3 and 4; changed by exactly the --progress=plain token in PR 2; changed only by the documented reductions in PR 5.
  • bin/test — full suite including integration, for PR 2 (buildx flag on a real build; confirm the build rows render from a real progress stream), PR 3 (a real deploy prints the advice block without affecting the deploy), and PR 5 (real deploy behaviour changed). Needs Docker and ghcr.io/zoolutions/dash-proxy:$MINIMUM_VERSION published (it is; MINIMUM_VERSION does not move).
  • Manual, once for PR 2/3: run dash build push -v against a real Dockerfile and confirm the parsed steps match what buildx printed (stage names, ordinals, seconds), and that a deliberately broken Dockerfile still yields a partial report with the error step named.

Out of scope

  • No direct pushes to main; no manual lib/dash/version.rb bumps; no MINIMUM_VERSION change; nothing in ../kamal-proxy.
  • No renames of frozen server artifacts (kamal-proxy container/network/volumes, KAMAL_* env vars, image title label) and no renaming of existing kamal.* OTel attribute keys.
  • No --profile flag, no dash analyze command; the surface is the always-on table plus dash report and dash doctor.
  • No bench harness or micro-benchmarks for gem-side Ruby; PR 5 reports SSH round-trip counts and table rows only.
  • No docker buildx history / --metadata-file / rawjson integration; no changes to how build output is displayed at -v.
  • No proxy-side metrics (request latency, cert expiry): that is the R1 metrics item in ROADMAP.md and lives in dash-proxy.
  • No automatic Dockerfile rewriting; advice is printed, never applied.
  • No upload of reports anywhere except the already-configured OTel endpoint; no telemetry to zoolutions.
  • No project-, company- or domain-specific rules, fixtures or examples.

Execution

Hand this issue to a fresh implementation session on the sonnet tier, one PR slice at a time, in order. Each slice's steps name the exact files; the Context section replaces re-discovery.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions