| title | Renderer plugin contract | |||
|---|---|---|---|---|
| description | The output.Renderer interface — synchronous formatting of Result and Event into bytes. | |||
| tags |
|
A renderer takes typed values — *output.Result for one-
shot commands, *output.Event for streaming commands —
and writes bytes to an io.Writer. Exactly one
renderer is active per CLI invocation, picked by
--output / env / TTY auto-detection at startup.
This is the synchronous, command-scoped output tier. Fan-out to external systems (Slack, syslog, PagerDuty) goes through Sinks, which run alongside the active renderer and consume the same event stream concurrently.
!!! note "Reference implementations"
- internal/plugin/renderer/text/text.go — default
human-readable renderer; opt-in TextWriter
interface lets payloads format themselves.
- internal/plugin/renderer/ndjson/ndjson.go — the
streaming renderer (one JSON document per line, no
indentation, per-event flush).
- internal/plugin/renderer/json/json.go — pretty-
printed JSON for one-shot commands.
Read all three before writing your own; together they
show the spectrum of single-event vs streaming
behaviour.
// internal/output/renderer.go
package output
type Renderer interface {
Name() string
RenderResult(w io.Writer, r *Result) error
RenderEvent(w io.Writer, e *Event) error
SupportsTTY() bool
Close() error
} New() (or constructor with options) ─ at startup, after CLI flags parsed
│
▼
RenderResult OR RenderEvent (many) ─ the dispatcher serializes calls
│
▼
Close() ─ once at end of CLI invocation
The dispatcher serializes every call through a mutex, so implementations may assume single-threaded access and don't need their own locking. This is intentional — renderers are often stateful (column-aligned text tables, JSON Encoder reuse, ASCII-progress-bar repaint state) and forcing them to be goroutine-safe would multiply complexity for no gain.
Lowercase canonical name ("text", "json", "ndjson",
"junit", "yaml", …). Stable across versions; matched
case-sensitively against --output / PG_HS_OUTPUT.
Writes a one-shot Result. Called exactly once per
non-streaming command (status, version, inspect,
verify, kms inspect). Implementations should write
a trailing newline so terminal sessions are tidy.
Result shape (internal/output/event.go):
type Result struct {
Schema string `json:"schema"` // "pg_hardstorage.v1"
Command string `json:"command"`
GeneratedAt time.Time `json:"generated_at"`
Result any `json:"result,omitempty"` // success body
Error *Error `json:"error,omitempty"` // failure body
}Either Result or Error is set, never both. The text
renderer's pattern:
func (r *Renderer) RenderResult(w io.Writer, res *output.Result) error {
if res == nil { return nil }
if res.IsError() {
return r.renderError(w, res.Error)
}
return r.renderBody(w, res.Result)
}Writes one streaming event. Called many times per
streaming command (backup, restore, verify --stream,
wal stream, log tails). Each invocation should write
exactly one logical record:
- Line-oriented renderers: one
\n-terminated line. - Paragraph-oriented (text): one paragraph + blank line.
- Document-oriented (junit, yaml): well, it's complicated for those — see the existing impls.
Critical: do NOT internally wrap the writer in a
bufio.Writer that delays output. Streaming consumers
(pg_hardstorage backup --output ndjson | jq) rely on
per-event flush so progress is visible in real time.
The json.Encoder flushes on each Encode; that's the
pattern the ndjson renderer uses.
Event shape:
type Event struct {
Schema string `json:"schema"`
Severity Severity `json:"severity"`
SeverityName string `json:"severity_name"`
Component string `json:"component,omitempty"`
Op string `json:"op,omitempty"`
Subject Subject `json:"subject,omitzero"`
Body any `json:"body,omitempty"`
Suggestion *Suggestion `json:"suggestion,omitempty"`
Trace TraceContext `json:"trace,omitzero"`
GeneratedAt time.Time `json:"generated_at"`
}Reports whether this renderer is appropriate for an
interactive terminal. Used by TTY auto-detection:
--output text is forced to --output ndjson when stdout
is not a TTY (and vice-versa) unless the operator
overrides.
| Renderer | SupportsTTY |
|---|---|
text |
true |
markdown |
true |
json (pretty) |
true (single-shot) |
ndjson |
false |
csv, yaml, junit, tap, html, pdf |
false |
Releases renderer-side resources. Called once at the end of the CLI invocation. Most renderers have nothing to release; this hook exists for future renderers that hold file handles (the html / pdf renderers may emit a trailing footer).
The Renderer contract supports both modes through the two methods. In practice each renderer leans toward one:
| Renderer | Streaming behaviour | Single-event behaviour |
|---|---|---|
text |
Paragraph per event, blank-line separated | Pretty multi-line block |
json |
One pretty doc per event (verbose) | One pretty doc total |
ndjson |
One compact line per event (the default for streaming) | One long line |
junit |
Aggregates events into a <testsuite> tree, emits at Close |
One <testcase> |
tap |
Per-event ok / not ok line |
Single 1..1 plan + line |
csv |
Header + row per event | Header + single row |
pdf, html |
Buffered through Close; emits one document |
One document |
The CLI's TTY auto-detection picks text for human
operators and ndjson for pipes. Operators can override
either via --output <name> or PG_HS_OUTPUT=<name>.
The text renderer supports an opt-in interface that lets payload types format themselves:
type TextWriter interface {
WriteText(w io.Writer) error
}If Result.Result (or Event.Body) implements
TextWriter, the text renderer calls WriteText and
trusts the output. Otherwise it falls back to indented
JSON. Implementations should NOT include a trailing
newline — the renderer adds one.
This pattern keeps domain-specific formatting (status tables, version blocks, doctor reports) close to the domain code rather than ballooning the renderer with per-command rendering logic.
Renderers do NOT self-register against a default registry
the way sinks and storage plugins do. The dispatcher's
constructor takes one explicit Renderer:
func NewDispatcher(renderer Renderer, out, err io.Writer) *Dispatcher…and the CLI's startup wiring chooses which one to
construct based on --output / TTY auto-detection. See
cmd/pg_hardstorage/main.go for the resolver function.
The reasoning: exactly one renderer is active per invocation, so a registry / lookup-by-name pattern would be ceremony for no benefit. If you ship a Tier-1 renderer, add a case to the resolver alongside the existing ones.
Tier-2 renderers register against the (forthcoming)
output.DefaultRendererRegistry and are looked up by
Name(); the proto for that path is at
proto/plugin/v1/plugin.proto service RendererPlugin.
Renderers may assume single-threaded access. The dispatcher's mutex serializes every call. Goroutine- safety is NOT a requirement; in fact the text renderer's ASCII-progress-bar state machine assumes the opposite.
Sinks (which run concurrently) handle the goroutine-safety story for fan-out separately — Sink contract.
- One logical record per
RenderEventcall. No internal buffering across events. - Per-event flush for streaming renderers. The
json.Encoder.Encodepattern (one Write per record) is canonical. SupportsTTYdoesn't lie. False positives leak ANSI escape codes into log files; false negatives give the operator a degraded experience.Closeis idempotent. The dispatcher MAY callCloseafter a fatal error AND in the normal exit path.
- Output event schema:
reference/output-event-schema.md(auto-generated). - The dispatcher:
internal/output/dispatcher.go. - The Severity model:
internal/output/severity.go— RFC 5424. - Sinks (the asynchronous tier): Sink contract.