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.rb — Dash::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.rb — print_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.rb — deploy, 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.rb — DASH 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.rb — push 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.rb — push 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.rb — dockerfile, 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.rb — Result(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.rb — on_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.rb — Dash::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.rb — SSHKit::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.
- 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.
- 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.
- 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).
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).
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.)
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.
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.
lib/dash/cli/main.rb — deploy 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).
- 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.
- 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).
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.
- New
lib/dash/build/progress_parser.rb — Dash::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 done → context_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 CACHED → cached = true, seconds 0
#N DONE 12.3s → seconds
#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.
- New
lib/dash/build/report.rb — Dash::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.
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.
lib/dash/commander.rb — attr_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.
- 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.
- 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.
lib/dash/dockerfile/parser.rb — Dash::Dockerfile::Parser.parse(text) → Dash::Dockerfile::File with stages and instructions. Handles: comments and blank lines, # syntax= and # escape= directives, \ continuations, heredocs (<<EOF … EOF, <<-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).
lib/dash/dockerfile/analyzer.rb — Dash::Dockerfile::Analyzer.new(file:, dockerignore:, context_dir:, build: nil, builder_config:).findings → Dash::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).
- 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.
lib/dash/configuration/report.rb — Dash::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).
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.
lib/dash/report.rb — advice 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.
- 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).
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.
- 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.
- 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).
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.
lib/dash/report/history.rb — Dash::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.
lib/dash/cli/report.rb — Dash::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.
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).
lib/dash/cli/main.rb — run_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.
- 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):
Cli::App::Boot — app.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.
- 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.
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.
Cli::Build#pull_on_hosts — clean + pull + validate_image are three round trips per host; clean and pull can be one combined command (clean already ignores failure).
- 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.
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 serialdockercommands 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:
deploy,redeploy,setup,rollbackand standalonebuild pushprints, 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.hadolintwhen installed), several of which are correlated with the measured build ("RUN bundle installtook 84.1s uncached; theCOPY . .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")..dash/reports/,dash.phase/dash.build.step/dash.adviceevents through the existing OTel logger, and summary values in thepost-deployhook environment.dash doctorruns the static Dockerfile rules.dash reportprints the last saved report or a trend over the last N.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.rb—Dash::Timings#phase(name, depth:)records wall time per phase, mutex-guarded, entries in start order;#linesrenders the table. Explicitdepth:because boot host phases run in SSHKit threads.lib/dash/cli/base.rb—print_runtime(printsFinished all inand the table from the outermost level only;setupnestsdeploy),timed(name, depth:),modify(lock:),acquire_lock/acquire_server_lock(currently untimed),ensure_run_directory(runson(DASH.hosts)for every lock acquire),run_hook(hook, **extra_details)(extra details becomeDASH_<KEY>/KAMAL_<KEY>env viaDash::Tags#env,lib/dash/tags.rb:31).lib/dash/cli/main.rb—deploy,redeploy,setup,rollback: thetimed(...)call sites andprint_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-hostDASH.timings.phase("#{role} #{host}", depth: 1)andtiming_healthy(setsentry.detail = "healthy after Ns"). Note the SSH round trips per host:container_id_for_versionis captured twice (once inold_version_renamed_if_clashing, again instart_new_version), eachauditis its own SSH exec, plusensure_env_directory,upload!,run,deploy/health polling,stop.lib/dash/commander.rb—DASHsingleton,resetcreates@timings, memoised@commands,modifyinstrumentsmodify.kamalviaActiveSupport::Notifications,configure_output_withinstallsDash::Output::Formatter+ broadcast logger whenoutput:is configured.lib/dash/sshkit_with_ext.rb— every SSHKit monkey patch lives here:CommandEnvMerge(kwargs likeinteraction_handler:pass straight through toSSHKit::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 thingbin/dashdoes, so a timestamp at the top oflib/dash.rbmeasures gem load + Zeitwerk + config for the Startup row.Build pipeline:
lib/dash/cli/build.rb—pushrunsexecute *push, env: DASH.builder.push_envinsiderun_locallyat:debugverbosity (build output is streamed through SSHKit's local backend viaOpen3.popen3; the command already ends in2>&1).deliver=push+pull.devbuilds too.pulldoes registry login + per-host pull/validate.lib/dash/commands/builder/base.rb—pushbuilds thedocker buildx build …argv;build_dockerfilealready resolves the Dockerfile path (config.builder.dockerfile, raisesBuilderErrorwhen 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.rb—dockerfile,context,cache_from/cache_to(themode=max|minstring lives incache.options),secrets,args,arches,remote.lib/sshkit/command.rb:240call_interaction_handler— any object responding toon_data(command, stream_name, data, channel)passed asinteraction_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.rb—Result(check, target, status, detail),CHECK_TITLES,STATUS_COLORS;ConfigChecksis the SSH-free, config-only check set (readiness today). The Dockerfile static check joins it.Dash::Cli::Main#doctorprints viaprint_doctor_report.lib/dash/output/otel_logger.rb,lib/dash/otel_shipper.rb—on_finish(payload, runtime)fires when the outermostmodifycompletes (afterprint_runtime, so timings are final);OtelShipper#event(name, **attributes)with typed values. Existing attribute keys arekamal.*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.rb—Dash::ProjectDirectory.join("secrets"); reports go under the same project directory.lib/dash/commands/hook.rb+lib/dash/tags.rb— hook env is dual-emittedDASH_*/KAMAL_*fromdetails; new keys need no plumbing.Configuration + docs:
lib/dash/configuration.rb— top-level keys (output,boot,deploy_timeout, …) andDash::Configuration::Validatorwiring;lib/dash/configuration/output.rbis the smallest example of a validated sub-config withto_h.lib/dash/configuration/docs/*.yml— commented-YAML docs that bothdash docsand the docs site render; a new YAML must be registered indocs/app/models/doc.rbordocs/spec/config_docs_spec.rbfails.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.rb—SSHKit::Backend::Printerswaps in for SSH; assertions are against printed command strings.test/cli/main_test.rbasserts the deploy output sequence.test/cli/build_test.rb(9buildx buildassertions) andtest/commands/builder_test.rb(17) assert the exact buildx argv — adding--progress=plaintouches 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::Timingsspine into aDash::Report(timings + build steps + advice), fed by three zero-cost sources, printed always, exported through the loggers that already exist.sshkit_with_ext.rb: everycreate_command_and_executeand everyconnect_sshattributes 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.--progress=plainto the buildx argv (Commands layer) and pass aninteraction_handler:(aDash::Build::ProgressParser) to the existingexecuteinCli::Build#push. Nodocker buildx history, no--metadata-file, no second process.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.hadolintis folded in only when it is onPATHand not disabled.Everything above is additive to output; the deploy's command sequence is byte-identical except for the one
--progress=plainflag.Alternatives considered
docker buildx history inspect --format jsonafter 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 sameDash::Build::Reportshape.--progress=rawjson— cleaner to parse than plain text, but buildx ≥ 0.13 only and the plain format is what operators already see in-voutput. Rejected for compatibility; the parser is a ~120-line line-oriented state machine either way.--profileflag or a separatedash analyzecommand — rejected in interview: the operator should never have to remember a flag to learn why a deploy was slow.--profileis not introduced at all; the verbose detail lives in the JSON report..claude/rules/performance.md. The overhead-fix PR reports SSH round-trip counts and the table's own before/after instead.Settled in interview:
--profileflag. Terminal output is the primary consumer; exports are additional, not alternatives.OtelLogger, a JSON report file per run, andpost-deployhook env vars. All three.Design decisions the executor must not reopen:
dash.prefix. Existingkamal.*keys are untouched (out of scope)..dash/reports/(viaDash::ProjectDirectory.join("reports")) with a generated.dash/reports/.gitignorecontaining*and!.gitignore, so a committed.dash/never picks them up.StandardError, prints one yellow line (Deploy report unavailable: <class>: <message>, backtrace withVERBOSE=1) and the deploy continues with whatever was collected.--progress=plain. A test enforces this (see gates).MINIMUM_VERSIONchange;../kamal-proxyis not touched.Implementation steps
Five PRs, in order. Each is independently mergeable and leaves
mainreleasable. Branch each off freshmain(feat/report-<slice>), PR intomain.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).lib/dash/timings.rbEntrygainsparent,commands(Integer),command_seconds(Float),connect_seconds(Float),local(Boolean, true forrun_locallycommands). Keepname, seconds, detail, depth.phase(name, depth:)setsentry.parent = Thread.current[:dash_timing_entry], then sets the thread-local toentryfor the block's duration and restores it inensure.record(name, seconds, depth: 0, detail: nil)— appends a pre-measured entry (for Startup).attribute_command(seconds, local:)andattribute_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 printedcommands/command_secondsis the sum over its subtree (computed at render time, not at record time, so host rows keep their own numbers). Format:%s%-36s %6.1fsthen, when the entry or its subtree issued commands,%3d ssh %5.1fs(orlocalin place ofsshfor local commands), then the existing(detail). Updatetest/timings_test.rbregexes 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).lib/dash/sshkit_with_ext.rbmodule TimedCommandsprepended toSSHKit::Backend::Abstract: wrapscreate_command_and_execute(args, options)with a monotonic clock and callsDASH.timings.attribute_command(elapsed, local: self.is_a?(SSHKit::Backend::Local)). Guard withdefined?(DASH)so SSHKit used outside dash (tests that build backends directly) is unaffected.connect_sshinLimitConcurrentStartsInstance(or a sibling module prepended after it) to callDASH.timings.attribute_connect(elapsed). The pool only callsconnect_sshon a cache miss, so this naturally measures real connects only.CompleteAll#executeandSSHKitDslRoles#on_roles, captureparent = DASH.timings.currentbeforeThread.newand setThread.current[:dash_timing_entry] = parentas the first statement inside each thread. (Dash::Cli::App::Boot#runthen opens its own host phase as a child ofBoot.)lib/dash.rb— first line after themodule Dasherror class:Dash::PROCESS_STARTED_AT = Process.clock_gettime(Process::CLOCK_MONOTONIC)placed before therequire "active_support"line so gem load is inside the measurement.lib/dash/cli/base.rbprint_runtime(outermost level only): beforeyield,DASH.timings.record("Startup (load, config)", now - Dash::PROCESS_STARTED_AT).acquire_lockandacquire_server_lock: wrap the body intimed("Acquire deploy lock")/timed("Acquire server lock")(depth 0). Theirensure_run_directorySSH sweep is now visible as commands on that row.lib/dash/cli/main.rb—deployandredeploy:timed("Validate config and secrets") { DASH.config.validate_secrets!(...) }.setup: same around the bootstrap it already times. Keep every existingsayline unchanged (main_test asserts them).test/timings_test.rb(above);test/cli/main_test.rb— assert the table now containsStartup,Validate config and secrets,Acquire deploy lockrows and thatBootshows ansshcolumn;test/sshkit_with_ext_test.rb(new, or extend an existing sshkit test if one exists undertest/) — aPrinter-backedonattributes one command to the current entry, arun_locallycommand is marked local, and a thread spawned throughSSHKit::Runner::Parallelinherits the parent entry.test/cli/main_test.rb): capture the full printed command list of adeployonmainbefore 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), newDash::Build::*(utility, no SSH).lib/dash/commands/builder/base.rb#push— insert"--progress=plain"immediately after"--output=type=#{export_action}". Update the 26 argv assertions intest/commands/builder_test.rbandtest/cli/build_test.rb.pack.rbis untouched.lib/dash/build/progress_parser.rb—Dash::Build::ProgressParser, an SSHKit interaction handler:on_data(_command, _stream, data, _channel)appends to a line buffer and feeds complete lines toparse_line;finishflushes the tail. Recognised lines (buildx plain format;Nis the vertex number):#N [internal] load build context→ step kind:context#N transferring context: 340.12MB 3.2s done/... 1.23kB done→context_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 afterk/m]with whitespace collapsed; unnamed stages appear asstage-0,stage-1#N [internal] load metadata for <image>→:metadata#N [<stage> k/m] FROM …and#N resolve <image>→:from#N CACHED→cached = true, seconds 0#N DONE 12.3s→seconds#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(andexporting cachevariants) →:cache_export#N ...continuation lines with a timestamp prefix (#N 12.34 …) are the step's own output → ignoredMultiple
DONElines for the same#N(buildx re-reports on multi-platform builds) take the last value. Steps are keyed byNin aConcurrent::Hash(concurrent-ruby is already a dependency) because SSHKit may callon_datafrom its reader thread.lib/dash/build/report.rb—Dash::Build::Report(built by the parser'sresult):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.lib/dash/cli/build.rb#push— after computingpush,parser = Dash::Build::ProgressParser.new(skip whenDASH.builder.pack?);execute *push, env: DASH.builder.push_env, interaction_handler: parser; inensure,parser.finishandDASH.report.build = parser.result(also on failure: a partial report witherrorsis what an operator wants when the build breaks).devgets the same treatment.lib/dash/commander.rb—attr_reader :report,@report = Dash::Report.new(timings: @timings)inreset.Dash::Report(lib/dash/report.rb) holdstimings,build,advice(PR 3),started_at, andlines: the timings table followed by build sub-rows and, later, the advice block.Cli::Base#print_runtimeprintsDASH.report.linesinstead ofDASH.timings.lines.Dash::Report, depth 1 under the build phase entry found by nameBuild and push app image— pass the entry explicitly rather than searching by name:Cli::Maindoestimed("Build and push app image") { |entry| DASH.report.build_entry = entry; … }):build context 340.1MB 3.2s[build 5/9] RUN bundle install 84.1scached steps 9 of 14export + push 41.4s (cache export 29.4s)— omit rows whose value is zerodash build push/dash build dev(noprint_runtime): print the same sub-rows after the build, under aBuildheader, so CI pipelines that split build and deploy still see them.test/fixtures/build/progress_plain_success.log,progress_plain_cached.log,progress_plain_failed.log,progress_plain_multiplatform.log— captured from realdocker buildx build --progress=plain 2>&1runs of a generic multi-stage Rails Dockerfile (the executor generates these locally with Docker; scrub any registry host toregistry.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:configLayer: new
Dash::Dockerfile::*(utility, no SSH),Dash::Configuration::Report(Configuration),Dash::Cli::Doctor::ConfigChecksandDash::Cli::Main(CLI), docs.lib/dash/dockerfile/parser.rb—Dash::Dockerfile::Parser.parse(text)→Dash::Dockerfile::Filewithstagesandinstructions. Handles: comments and blank lines,# syntax=and# escape=directives,\continuations, heredocs (<<EOF…EOF,<<-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),ARGbefore the firstFROM, JSON-array and shell forms ofRUN/CMD/ENTRYPOINT. EachInstructionhasname,args(raw string),flags(Hash),line(1-based, of the first physical line),stage. EachStagehasname(explicitASname orstage-<i>),index,base(image or another stage's name),instructions,shipped?(true for the last stage and every stage it transitivelyFROMs — copied-from stages are not shipped).lib/dash/dockerfile/analyzer.rb—Dash::Dockerfile::Analyzer.new(file:, dockerignore:, context_dir:, build: nil, builder_config:).findings→Dash::Dockerfile::Finding(rule, severity (:warn | :info), location, message, suggestion).locationisDockerfile:14,.dockerignore,build context, orbuilder.cache. Rules are small classes underlib/dash/dockerfile/rules/each withid,run(ctx) → [Finding]; the analyzer runs them in order and appliesignore. Helper on the context:dependency_install?(instruction)matchingbundle 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/ADDwhose source is.,./,*, or/(after the flags).ignore:refers to them). Static unless marked measured; measured rules run only when abuildreport 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-installRUN. 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.dockerignoreincontext_dir; or one that does not cover.git, and for each ofnode_modules,tmp,log,storage,coverage,.env*that exists incontext_dir, not covered. Measured: ifcontext_bytes> 50 MB, severity warn and the size is named. Pattern coverage is checked withFile.fnmatchagainst the ignore lines (no negation handling beyond skipping!lines).context-size(measured, warn):context_bytes> 50 MB regardless of.dockerignorestate — names the MB and seconds.no-cache-mount(info): a dependency-installRUNwithout--mount=type=cache. Suggestion names the conventional target per tool (bundler/usr/local/bundle/cacheorBUNDLE_PATH, npm/root/.npm, bun/root/.bun/install/cache, pip/root/.cache/pip, apt/var/cache/apt).apt-hygiene(info): aRUNwithapt-get installlacking--no-install-recommends, or lackingrm -rf /var/lib/apt/listsin the sameRUN, in a shipped stage only.latest-base(warn):FROMwith no tag or:latest(ARG-interpolated tags and digests pass).single-stage-build-deps(warn): exactly one stage and it installsbuild-essential,gcc,g++,make, or*-devpackages.root-user(info): the shipped final stage has noUSER.inline-env-blob(info): aRUNwith more than 20 inlineKEY=valueassignments 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|bashin aRUN. Suggestion: pin a version and verify a checksum.secret-in-build-arg(warn):ARG/ENVwhose name matches/(PASSWORD|SECRET|TOKEN|_KEY)\b/i(exceptSECRET_KEY_BASE_DUMMY). Suggestion:--mount=type=secretwithbuilder.secretsindeploy.yml.cache-busting-arg(info): anARGnamed likeGIT_SHA|COMMIT|BUILD_DATE|BUILD_TIME|VERSION(excludingRUBY_VERSION/NODE_VERSION/*_VERSIONthat name a toolchain) referenced ($NAME/${NAME}) in an instruction before the last dependency-installRUNof its stage.cache-export-cost(measured, info):cache_export_seconds> 20% oftotal_step_secondsandbuilder_config.cache_tocontainsmode=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 nocopy-before-installfinding explains it — lists the step so the operator can check what invalidated it.hadolint(severity mapped from hadolint'serror→warn,warning/info/style→info): see step 5.trend-*rules land in PR 4.lib/dash/configuration/report.rb—Dash::Configuration::Reportfrom the new top-levelreport:key, all optional:Validationmixin (test/configuration/report_test.rb). Wireconfig.reportinlib/dash/configuration.rb. Addlib/dash/configuration/docs/report.ymland registerpage "Report", group: "Configuration", slug: "report", view: "Config::Report"indocs/app/models/doc.rb(the drift spec requires it).lib/dash/dockerfile/hadolint.rb— whenconfig.report.hadolintisautoandhadolintresolves onPATH(Dash::Utilshelper usingENV["PATH"]+File.executable?, no shell), runhadolint --format json --no-fail <dockerfile>withOpen3.capture2(a local process, not SSHKit — keeps the printed command sequence unchanged), parse JSON, map to findings withlocation: "Dockerfile:<line>"and message"<code>: <message>". Any error → one info findinghadolint: could not run (<message>).hadolintrule ids inignore(DL3008) are honoured.lib/dash/report.rb—advicelist;Dash::Report#analyze!(build: @build)runs the analyzer forDASH.config.builder.dockerfileinDASH.config.builder.context(only when the context is a local directory — skip for a git-clone build directory? No:Cloneprepares a local checkout; analyze the file atDASH.config.builder.build_directory).linesrenders:warnin yellow,infouncoloured, viasay-compatible ANSI (the table already uses plainputs; keep colours out of the JSON). Nothing prints when there are no findings andadvice: true; the block is skipped entirely whenadvice: false.Cli::Main#deploy/redeploy/setuprunDASH.report.analyze!right after the build/pull phase (so advice appears even if boot later fails), inside the never-raise guard.Cli::Build#pushand#devrun it too for standalone builds.rollbackskips it (no build, no Dockerfile relevance).lib/dash/cli/doctor/config_checks.rb— adddockerfile_results: run the analyzer statically (no build report, hadolint per config);:dockerfilecheck withCHECK_TITLES[:dockerfile] = "Dockerfile"; oneResultper finding (warn→:warn,info→:okwith the message so operators still see it), or one:ok "no findings"row. Missing Dockerfile →:failwith the path (the same conditionBuilder::Base#build_dockerfileraises on). Doctor's never-crash contract applies: rescueStandardErrorper check.test/dockerfile/parser_test.rb(each syntax feature above, with a fixture directorytest/fixtures/dockerfiles/holdingrails_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 stubbedBuild::Report;ignorehonoured;shipped?stage logic);test/dockerfile/hadolint_test.rb(stubbedOpen3, 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 withadvice: false; the cost-guard sequence is unchanged);docs/specgreen after registering the page.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 envLayer:
Dash::Report(utility),Dash::Output::OtelLogger(output),Dash::Cli::Main+ newDash::Cli::Report(CLI).lib/dash/report/writer.rb— after the outermostprint_runtimecompletes (success or failure), whenconfig.report.history > 0: writeDash::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 newesthistoryfiles 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": "…" } ] }statusisfailedwith"error": { "class": …, "message": … }when the deploy raised. The path is printed asReport written to .dash/reports/….jsonafter the advice block.lib/dash/report/history.rb—Dash::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 lastn(n = min(5, available)) →build 84.1s vs median 41.0s over the last 5 deploys.trend-boot(info): same forBoot.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.lib/dash/cli/report.rb—Dash::Cli::Report < Dash::Cli::Base, registered inMainassubcommand "report":dash reportprints the latest saved report (table + build rows + advice, re-rendered from JSON viaDash::Report.from_h);dash report --last Nprints a trend table (one row per report: started_at, version, total, build, boot, advice count);dash report pathprints the reports directory. Honour-d destination. Read-only: nomodify, no SSH.lib/dash/output/otel_logger.rb#on_finish— after the existing complete/failed event, for every timing entry emitdash.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 stepdash.build.step(dash.build.stage,dash.build.ordinal,dash.build.instruction,dash.build.seconds,dash.build.cached), onedash.buildsummary event (context bytes, cached/total, export/push seconds), and per findingdash.advice(dash.advice.rule,dash.advice.severity,dash.advice.location,dash.advice.message). All carry the existingdeployment.idattrs.OtelShipper#eventalready handles typed values; booleans need aboolValuebranch intyped_value(add it).lib/dash/cli/main.rb—run_hook "post-deploy", secrets: true, runtime: …gainsbuild_runtime,boot_runtime,advice_count,advice_warnings,report_path(each rounded/stringified; omit keys whose source phase did not run).Dash::Tags#envdual-emits them asDASH_BUILD_RUNTIME/KAMAL_BUILD_RUNTIMEetc. with no further change. Document the new variables on the existing Hooks docs page.test/report/writer_test.rb(writes under a tmp project dir,.gitignorecreated once, pruning tohistory,history: 0writes 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.rbor existing otel tests (events emitted with the right names;boolValue);test/cli/main_test.rb(post-deploy hook env contains the new keys — check theDASH_BUILD_RUNTIMEline in the printed hook env, mirroring howDASH_RUNTIMEis 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):
Cli::App::Boot—app.container_id_for_version(version)is captured inold_version_renamed_if_clashingand again instart_new_versionafterapp.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 onedocker ps --formatcall viaDash::Commands::Appreturning both, parsed locally.audit(...)is a standalone SSHecho >> audit.logbefore the action it describes. Where the action is itself anexecute(not a capture), fold the audit into the same round trip withcombine(Commands layer), preserving the "audit before action" order within the shell string. Never fold an audit into acapture.Cli::Base#ensure_run_directoryruns anon(DASH.hosts)sweep for every lock acquire (deploy lock, then server lock in the same process). Memoise per process (DASH.run_directory_ensured), reset inCommander#reset.Cli::Build#pull_on_hosts—clean+pull+validate_imageare three round trips per host;cleanandpullcan be onecombined command (cleanalready ignores failure).Detect stale containersandPrunerows 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 minorafter PR 4 at the latest (PRs 1–2 can ship as4.1.0on their own). No manualversion.rbedits; noMINIMUM_VERSIONchange.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 offensescd docs && bundle exec rspec— green (PR 3 registers the new config page; PR 4 touches the Hooks page)test/cli/main_test.rbpasses with an unchanged command sequence in PRs 1, 3 and 4; changed by exactly the--progress=plaintoken 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 realdeployprints the advice block without affecting the deploy), and PR 5 (real deploy behaviour changed). Needs Docker andghcr.io/zoolutions/dash-proxy:$MINIMUM_VERSIONpublished (it is;MINIMUM_VERSIONdoes not move).dash build push -vagainst 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
main; no manuallib/dash/version.rbbumps; noMINIMUM_VERSIONchange; nothing in../kamal-proxy.kamal-proxycontainer/network/volumes,KAMAL_*env vars, image title label) and no renaming of existingkamal.*OTel attribute keys.--profileflag, nodash analyzecommand; the surface is the always-on table plusdash reportanddash doctor.docker buildx history/--metadata-file/rawjsonintegration; no changes to how build output is displayed at-v.ROADMAP.mdand lives in dash-proxy.Execution
Hand this issue to a fresh implementation session on the
sonnettier, one PR slice at a time, in order. Each slice's steps name the exact files; the Context section replaces re-discovery.