Skip to content
6 changes: 4 additions & 2 deletions docs/app/views/docs/pages/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ def lead = "The full dash CLI surface. Every command also answers --help."
[ "dash registry <setup|remove|login|logout>", "Manage the local registry or remote registry sessions" ],
[ "dash lock <status|acquire|release>", "Manage the deploy lock; --server targets the shared server lock instead" ],
[ "dash prune <all|images|containers>", "Prune old application images and stopped containers" ],
[ "dash secrets <fetch|extract|print>", "Helpers for extracting secrets from a vault" ]
[ "dash secrets <fetch|extract|print>", "Helpers for extracting secrets from a vault" ],
[ "dash report [--last N]", "Print the last saved deploy report, or a trend over the last N" ],
[ "dash report path", "Print the directory saved reports are written to" ]
].freeze

def content
Expand Down Expand Up @@ -140,7 +142,7 @@ def build_commands
end

def other_commands
DocsUI::Section("server, registry, lock, prune, secrets") do
DocsUI::Section("server, registry, lock, prune, secrets, report") do
command_table OTHERS
end
end
Expand Down
167 changes: 167 additions & 0 deletions docs/app/views/docs/pages/deploy_report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ def content
build_rows
advice_block
rules
trends
saved_reports
reading_reports
otel
hooks
silencing
end

Expand Down Expand Up @@ -149,6 +154,167 @@ def rules
end
end

def trends
DocsUI::Section("Trends") do
md <<~'MD'
Every deploy saves a JSON report, and the next one compares itself with
the ones before it. Once a destination has three retained reports of the
same command that succeeded, four more rules can fire — all
informational, all comparing against the median of the last five:

| Rule | What it means |
|---|---|
| `trend-build` | The build took more than 1.5× its usual time. |
| `trend-boot` | The boot took more than 1.5× its usual time. |
| `trend-total` | The whole deploy took more than 1.5× its usual time. |
| `trend-overhead` | `Startup`, secrets validation and the locks together took over ten seconds, or more than 1.5× their usual. The message names whichever row dominated — a secrets adapter shelling out is the usual answer. |

They read `.dash/reports` and nothing else: no network, no server, no
clock beyond the one the deploy already used. A history that cannot be
read — a report half-written by a deploy that was killed, a file dropped
in by hand — is skipped without a word.
MD
end
end

def saved_reports
DocsUI::Section("Saved reports") do
md <<~'MD'
After the advice, dash writes the whole report as JSON and prints where
it went:

```
Report written to .dash/reports/2026-09-10T12-00-00Z-production-deploy.json
```

Files are named for the UTC time the run started, the destination, and
the command. dash keeps the newest `report: history:` of them per
destination (20 by default) and deletes the rest; `history: 0` writes
none. The directory gets its own `.gitignore` the first time it is used,
so a project that commits `.dash/` does not start committing a report on
every deploy.

A deploy that failed is written too, with `"status": "failed"`, the error
that ended it, and every phase that had finished — which is usually the
run you most want to read afterwards.
MD
DocsUI::Code(<<~JSON, lexer: :json)
{
"schema": 1,
"dash_version": "4.0.8",
"command": "deploy",
"service": "app",
"destination": "production",
"version": "abc1234",
"started_at": "2026-09-10T12:00:00Z",
"runtime": 196.2,
"status": "succeeded",
"phases": [
{ "name": "Startup (load, config)", "depth": 0, "seconds": 0.9, "detail": null,
"commands": 0, "command_seconds": 0.0, "connect_seconds": 0.0, "local": true },
{ "name": "Build and push app image", "depth": 0, "seconds": 140.0, "detail": null,
"commands": 0, "command_seconds": 0.0, "connect_seconds": 0.0, "local": true },
{ "name": "Boot", "depth": 0, "seconds": 55.2, "detail": null,
"commands": 12, "command_seconds": 41.0, "connect_seconds": 1.2, "local": false }
],
"build_phase": 1,
"build": {
"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": [
{ "number": 7, "kind": "instruction", "label": "[build 5/9] RUN bundle install",
"stage": "build", "ordinal": 5, "steps_in_stage": 9,
"instruction": "RUN bundle install", "seconds": 84.1, "cached": false }
]
},
"advice": [
{ "rule": "copy-before-install", "severity": "warn", "location": "Dockerfile:14",
"message": "…", "suggestion": "…" }
]
}
JSON
md <<~'MD'
`schema` is the promise: a reader that does not recognise the number
should skip the file rather than guess. `build_phase` is the index into
`phases` that the build rows belong under. The command counts on a phase
are subtree totals — a parent and its children must not be summed.
MD
end
end

def reading_reports
DocsUI::Section("dash report") do
md <<~'MD'
`dash report` prints the last saved report for the current destination,
rendered exactly as the deploy printed it — same table, same build rows
under the same phase, same advice. It is entirely local: no lock, no SSH,
nothing that can change a server, so it is safe to run while a deploy is
in flight.
MD
DocsUI::Code(<<~TEXT, lexer: :text)
dash report # the latest report for this destination
dash report -d production # …for another destination
dash report --last 5 # one row per report, oldest first
dash report path # where the reports are written
TEXT
DocsUI::Code(<<~TEXT, lexer: :text)
Last 3 reports for app to production
started version total build boot advice
2026-09-08T09-12-44Z abc1234 118.9s 61.0s 49.1s 2 (1 warn)
2026-09-09T17-40-02Z bcd2345 121.4s 63.2s 49.8s 2 (1 warn)
2026-09-10T12-00-00Z cde3456 196.2s 140.0s 55.2s 3 (1 warn)
TEXT
end
end

def otel
DocsUI::Section("OpenTelemetry") do
md <<~'MD'
With an [OTel logger](/docs/output) configured, the same numbers ship as
events at the end of the run, alongside the `kamal.complete` /
`kamal.failed` events that were already there:

| Event | One per | Attributes |
|---|---|---|
| `dash.phase` | table row | `dash.phase.name`, `.depth`, `.seconds`, `.detail`, `.commands`, `.command_seconds`, `.connect_seconds` |
| `dash.build` | build | `dash.build.context_bytes`, `.context_seconds`, `.cached_steps`, `.total_steps`, `.export_seconds`, `.cache_export_seconds`, `.push_seconds` |
| `dash.build.step` | Dockerfile step | `dash.build.stage`, `.ordinal`, `.instruction`, `.seconds`, `.cached` |
| `dash.advice` | finding | `dash.advice.rule`, `.severity`, `.location`, `.message` |

Each carries the same `deployment.id` as the rest of the run, so a
backend can group them. Nothing is uploaded anywhere except the endpoint
you configured, and a shipping failure never fails a deploy that
succeeded.
MD
end
end

def hooks
DocsUI::Section("Hooks") do
md <<~'MD'
The `post-deploy` hook gets the summary as environment variables —
`DASH_BUILD_RUNTIME`, `DASH_BOOT_RUNTIME`, `DASH_ADVICE_COUNT`,
`DASH_ADVICE_WARNINGS` and `DASH_REPORT_PATH`, each with its `KAMAL_*`
twin. A phase that did not run contributes no variable at all rather than
a zero that reads as "instant". See [Hooks](/docs/hooks).

Two things about the ordering are worth knowing:

- Under `dash setup`, the hook fires from the `deploy` it wraps, before
the outer report is finalised. `DASH_REPORT_PATH` and the trend findings
are therefore absent from that one hook run — the report itself is
written as usual, a moment later.
- `status` in the saved report covers whatever ran inside the frame that
finalised it. A `post-deploy` hook fires after a standalone deploy has
written its report, so a hook failure fails the command but the report
says `succeeded` — the deploy did. Under `dash setup` the hook fires
inside the outer frame, so there the same failure marks the deferred
report `failed`.
MD
end
end

def silencing
DocsUI::Section("Turning it down") do
md <<~'MD'
Expand All @@ -158,6 +324,7 @@ def silencing
report:
advice: true # print the Advice block at all
hadolint: auto # also run hadolint when it is on PATH; false to never (anything else is an error)
history: 20 # JSON reports to keep per destination; 0 writes none
ignore:
- root-user # any rule id above, or a hadolint code like DL3008
YAML
Expand Down
15 changes: 15 additions & 0 deletions docs/app/views/docs/pages/hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,21 @@ def environment
| `DASH_LOCK` | `true` when the command holds the deploy lock |
| `DASH_RUNTIME` | seconds elapsed, on `post-deploy` |

`post-deploy` also gets what the [deploy report](/docs/deploy-report)
measured, so a hook can page on a build that doubled without parsing
anything:

| Variable | Value |
|---|---|
| `DASH_BUILD_RUNTIME` | seconds the build phase took — absent when nothing was built |
| `DASH_BOOT_RUNTIME` | seconds the boot phase took — absent when nothing was booted |
| `DASH_ADVICE_COUNT` | how many pieces of advice the report printed |
| `DASH_ADVICE_WARNINGS` | how many of those were warnings rather than notes |
| `DASH_REPORT_PATH` | the JSON report this deploy saved, when `report: history:` is not `0` |

Under `dash setup` the hook fires from the `deploy` it wraps, before the
outer report is finalised, so `DASH_REPORT_PATH` is absent from that run.

Hooks marked **Secrets** in the table also receive every entry of
`.dash/secrets` as environment variables, so a `pre-deploy` hook can
talk to the same services the deploy does.
Expand Down
9 changes: 9 additions & 0 deletions lib/dash/build/report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@
class Dash::Build::Report
attr_reader :steps, :push_seconds

# Rebuilds a report from what #to_h exported. Only the steps and the push are restored:
# every other number in the export is derived from them, so recomputing keeps a
# hand-edited file from claiming a total its own steps do not add up to.
def self.from_h(hash)
hash = hash.transform_keys(&:to_sym)

new steps: Array(hash[:steps]).map { |step| Dash::Build::Step.from_h(step) }, push_seconds: hash[:push_seconds].to_f
end

def initialize(steps: [], push_seconds: 0.0)
@steps = steps
@push_seconds = push_seconds
Expand Down
18 changes: 17 additions & 1 deletion lib/dash/build/step.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ class Dash::Build::Step
attr_reader :number
attr_accessor :kind, :name, :platform, :stage, :ordinal, :steps_in_stage, :instruction, :seconds, :cached, :error, :bytes

def self.from_h(step)
step = step.transform_keys(&:to_sym)

new(step[:number], kind: step[:kind]&.to_sym || :other, name: step[:label]).tap do |rebuilt|
rebuilt.platform, rebuilt.stage = step[:platform], step[:stage]
rebuilt.ordinal, rebuilt.steps_in_stage = step[:ordinal], step[:steps_in_stage]
rebuilt.instruction, rebuilt.seconds = step[:instruction], step[:seconds]
rebuilt.cached, rebuilt.bytes, rebuilt.error = !!step[:cached], step[:bytes], step[:error]
end
end

def initialize(number, kind: :other, name: nil)
@number = number
@kind = kind
Expand All @@ -26,8 +37,13 @@ def dockerfile_step?
!ordinal.nil?
end

# `label` is here for whatever reads the JSON — it is the string buildx printed and the
# one a human matches against their Dockerfile — and the parts it is built from are here
# so #from_h can rebuild it rather than trusting a field a hand-edited file may disagree
# with.
def to_h
{ number: number, kind: kind, label: label, platform: platform, stage: stage, ordinal: ordinal,
instruction: instruction, seconds: seconds, cached: cached, bytes: bytes, error: error }
steps_in_stage: steps_in_stage, instruction: instruction, seconds: seconds, cached: cached,
bytes: bytes, error: error }
end
end
80 changes: 78 additions & 2 deletions lib/dash/cli/base.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "thor"
require "time"
require "dash/sshkit_with_ext"

module Dash::Cli
Expand Down Expand Up @@ -148,11 +149,83 @@ def print_runtime
record_startup_timing if @print_runtime_depth == 1
yield
Time.now - started_at
rescue StandardError => e
# Kept so the saved report says how the run ended. `setup` nests, and the inner
# deploy sets it first — the outermost writer reads the same error either way.
@report_error = e
raise
ensure
@print_runtime_depth -= 1
runtime = Time.now - started_at
puts " Finished all in #{sprintf("%.1f seconds", runtime)}"
puts DASH.report.lines if @print_runtime_depth.zero? && DASH.timings.any?
finish_report(started_at, runtime) if @print_runtime_depth.zero? && DASH.timings.any?
end

# Trends, then the table, then the saved JSON. Each half is guarded on its own so a
# report that cannot be written still prints, and a table that cannot be compared is
# still a table — none of this is allowed to be why a deploy ends badly.
def finish_report(started_at, runtime)
run = nil

guarded_report do
run = report_run(started_at, runtime)
DASH.report.advice += report_trends(run)
end

puts DASH.report.lines

guarded_report { write_report(run) } if run
end

# What this invocation was, for the saved report and for the trend rules that
# compare it with the invocations before it.
def report_run(started_at, runtime)
{
command: [ command, subcommand ].compact.join(" "),
service: DASH.config.service, destination: DASH.config.destination, version: DASH.config.version,
started_at: started_at.getutc.iso8601, runtime: runtime.round(1),
status: @report_error ? "failed" : "succeeded",
error: @report_error && { class: @report_error.class.name, message: @report_error.message }
}.compact
end

def report_trends(run)
return [] unless DASH.config.report.advice?

history = Dash::Report::History.new(reports_directory, destination: DASH.config.destination)

Dash::Report::Trends.new(run.merge(phases: DASH.timings.to_h),
history: history.recent(DASH.config.report.history), ignore: DASH.config.report.ignore).findings
end

def write_report(run)
@report_path = Dash::Report::Writer.new(DASH.report,
run: run, keep: DASH.config.report.history, directory: reports_directory).write

puts " Report written to #{@report_path}" if @report_path
end

def reports_directory
Dash::ProjectDirectory.join("reports")
end

# Summary numbers for the post-deploy hook, so a hook can page on a build that
# doubled or a warning that appeared without re-deriving any of it. Phases that did
# not run contribute nothing rather than a zero that reads as "instant".
def report_hook_details
guarded_report({}) do
{
build_runtime: phase_runtime(Dash::Report::Trends::BUILD_PHASE),
boot_runtime: phase_runtime(Dash::Report::Trends::BOOT_PHASE),
advice_count: DASH.report.advice.size.to_s,
advice_warnings: DASH.report.advice.count(&:warn?).to_s,
report_path: @report_path
}.compact
end
end

def phase_runtime(name)
DASH.timings.seconds_for(name)&.round(1)&.to_s
end

# Everything that happened before the first phase could be timed: requiring the gem,
Expand All @@ -170,11 +243,14 @@ def analyze_report
guarded_report { DASH.report.analyze!(DASH.config) }
end

def guarded_report
# `fallback` is what the caller gets when the report could not be produced, for the
# callers that need a value rather than a side effect.
def guarded_report(fallback = nil)
yield
rescue StandardError => e
say "Deploy report unavailable: #{e.class}: #{e.message}", :yellow
say e.backtrace.join("\n"), :yellow if ENV["VERBOSE"]
fallback
end

def timed(name, depth: 0, &block)
Expand Down
Loading