Step 01 of 06
Learn the concept
Terminal text is where monitors go to become archaeology. Dashboards, alert rules and databases need fields they can query, not prose they can scrape. The moment Beacon leaves one check, it needs a result object.
The ideas this is made of
Fields preserve meaning after the terminal is gone
dns 18ms tcp 42ms ok is readable and brittle. A parser must know positions, units and missing-value rules. JSON from a struct carries names with values: dnsMs, tcpMs, statusCode. Databases can index them. Dashboards can graph them. Alert rules can compare them. The unit is in the field name, where an exhausted operator can still find it.
A struct is a contract with zero values
A Go struct declares the facts a result may contain. The compiler catches misspelled fields and tests can compare whole results. Field types also say what absence means. StatusCode int can be omitted when no HTTP response existed; *time.Time can be nil when no certificate was seen. Types are part of the story, not paperwork.
Classification belongs at the failure site
Resolver code knows a DNS error is DNS. TLS code knows a handshake error is TLS. After formatting, that knowledge becomes a sentence such as no such host, whose wording varies by OS and Go version. Set FailureStage when the error is caught. Keep the original error text as detail, not as the category engine.
Wrapped errors carry both machine truth and human detail
A sentinel such as ErrDNS is stable program truth. fmt.Errorf("lookup %s: %w", host, ErrDNS) adds context while preserving the category. errors.Is finds the sentinel through wrappers; errors.As extracts concrete types like *net.DNSError. Strings are for people. Wrapped values are for code that must stay correct.
JSON likes instants more than durations
Go encodes time.Time as RFC 3339, which sorts and travels well when stored in UTC. time.Duration encodes as raw nanoseconds, which is precise and hostile. Beacon exposes dnsMs, tcpMs, tlsMs and totalMs as integer milliseconds. The numbers are less surprising, and the field names carry the unit.
package main
import (
"encoding/json"
"errors"
"fmt"
"time"
)
type FailureStage string
const (
StageNone FailureStage = ""
StageDNS FailureStage = "dns"
)
var ErrDNS = errors.New("dns failure")
type Result struct {
URL string `json:"url"`
StartedAt time.Time `json:"startedAt"`
DNSMs int64 `json:"dnsMs"`
FailureStage FailureStage `json:"failureStage,omitempty"`
Error string `json:"error,omitempty"`
}
func main() {
result := Result{
URL: "https://example.invalid",
StartedAt: time.Now().UTC(),
}
err := fmt.Errorf("lookup example.invalid: %w", ErrDNS)
if errors.Is(err, ErrDNS) {
result.FailureStage = StageDNS
result.Error = err.Error()
}
result.DNSMs = 12
line, _ := json.Marshal(result)
fmt.Println(string(line))
}The sentinel is stable enough for code; the wrapped message is useful to a person. The JSON line carries both, with a UTC timestamp created at the boundary where the result begins.
Inspecting errors safely
| Tool | Use it for | Example |
|---|---|---|
errors.Is | Stable category |
|
errors.As | Concrete detail |
|
err.Error() | Human message | logs and JSON detail |
strings.Contains | Avoid for category | breaks on wording |
What these are called on the job
Structured result — A typed record with one field per fact.
Zero value — The default value for a Go type, such as
0, an empty string ornil.Sentinel error — A named error value used as a stable category.
Error wrapping — Adding context while preserving an error with
%w.RFC 3339 — A timestamp format such as
2026-10-05T16:26:18Z.
