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
164 changes: 164 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,164 @@ 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 describes the deploy's own phases. A
`post-deploy` hook that fails will fail the command, but the deploy
before it succeeded, and the report says so.
Comment thread
mhenrixon marked this conversation as resolved.
Outdated
MD
end
end

def silencing
DocsUI::Section("Turning it down") do
md <<~'MD'
Expand All @@ -158,6 +321,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