Project challenges / verified progress
Beacon: build an SSL and uptime monitor in Go

The engineering notebook

One structured result

How does a monitor record a check so another system can store, query and trust it?

Loading statusStage 8 of 10

  • Workspace not ready
  • Agent not ready
Focus25:00
A small focus ritual

0 focus sessions completed. Every fourth session offers a longer break. Start each phase when you are ready.

Study time never unlocks verified lesson progress.

Loading...

Loading verified progress...

Loading GitHub account...
Phase 4 — From script to prober

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.

ONE RESULT MANY FACTSResultone JSON eventMeasurementsdnsMs tcpMs tlsMsnumbersVerdictstatus and expiryfactsFailurestage plus errorclassifiedTimeUTC startedAtsortable
The result is the boundary between the prober and every later system. If the boundary is typed and classified now, storage and alerting do not have to reverse-engineer English later.
Step 01

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.

Classify first, then encode the result
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

ToolUse it forExample

errors.Is

Stable category

ErrDNS

errors.As

Concrete detail

*net.DNSError

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 or nil.

  • 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.