observability

package
v0.17.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 9, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package observability is the reason this framework exists.

slog covers structured logging and OpenTelemetry covers production tracing. What is missing between the two is the development layer: the moment a request broke and you want the stack, the queries with their timing, what you dumped and what the framework thinks is wrong, on one screen.

The Collector is that layer, and it is core rather than a plugin, so the error page knows the queries, the dumps and the routes without any extra install.

Index

Constants

View Source
const ConsolePath = "/_arandu/debug"

ConsolePath is where the console is mounted.

View Source
const DefaultRecorderSize = 200

DefaultRecorderSize is how many requests the console remembers.

Two hundred is enough to cover the reload-look-reload loop of a debugging session and small enough that nobody has to think about the memory. Each entry holds the queries, dumps and events of one request, so a page that issues a hundred queries is the one that costs.

View Source
const TracingHeader = "X-Arandu-Trace"

TracingHeader carries the secret that turns tracing on outside development, and that the console requires to answer there.

A constant rather than a string in three places: the middleware reads it, the kernel gates on it, and `aru trace` sends it. Three literals is three chances to change one and not the others.

Variables

This section is empty.

Functions

func Client added in v0.6.1

func Client(timeout time.Duration) *http.Client

Client returns an http.Client that records what it calls.

The timeout is required rather than optional: http.Client has none by default, and a call with no deadline is how one slow dependency turns into every request of the process hanging.

func Dump

func Dump(ctx context.Context, label string, value any)

Dump records a value for the debug page. It is the print statement you reach for while chasing something, with the difference that matters: it does not write to stdout and does not corrupt the HTML of the response. The value is recorded in the Collector and shown on the debug page.

In production, where the Collector is nil, it is a no-op.

func DumpDie

func DumpDie(ctx context.Context, label string, value any)

DumpDie records the value and aborts the request with the dump page. It panics with a sentinel the Recover middleware recognizes.

Like Dump, it is a no-op in production -- so a forgotten call cannot take a production request down.

func EditorLink(editor, file string, line int) string

EditorLink builds the link that opens a file straight in the IDE, at the line.

It lives here rather than in errorpage because two things need it now -- the error page and the console -- and a second copy is a second place to add the next editor. The editor name comes from config.Config.Editor.

The scheme has to reach the template as template.URL: html/template rewrites an unknown scheme to #ZgotmplZ, which turns every link on the page into a dead one and gives no hint why.

func IsDumpDie

func IsDumpDie(v any) bool

IsDumpDie identifies the sentinel so the Recover middleware renders the dump page instead of treating the panic as a real 500.

func Log

func Log(ctx context.Context) *slog.Logger

Log returns the request logger. It never returns nil.

This is the only way to log inside a handler or a service. There is no exported global logger, on purpose: a log line without request_id and tenant is noise, and the only way to guarantee both is to force the context through.

func NewLogger

func NewLogger(env string, level slog.Level) *slog.Logger

NewLogger returns the root logger: readable text in development, JSON everywhere else, so it reaches the aggregator without fragile parsing.

func RootLogger

func RootLogger(l *slog.Logger) func(http.Handler) http.Handler

RootLogger installs the application logger at the very top of the pipeline.

Without it, Log(ctx) inside a request falls back to slog.Default(), which ignores the configured handler: production would emit its request lines in the default text format instead of the JSON the aggregator expects, and the level filter from the configuration would not apply either. The Kernel installs this as the outermost middleware, so even a panic in Recover logs correctly.

func Transport added in v0.6.1

func Transport(next http.RoundTripper) http.RoundTripper

Transport records every outbound call on the request's Collector.

Without it, "external" on the timeline is always zero and the console shows nothing about the API the handler waited on -- which is the wrong answer for the request whose 800ms were spent in somebody else's service.

Wrap the transport of the client the application uses:

client := &http.Client{
    Timeout:   10 * time.Second,
    Transport: observability.Transport(nil),
}

It costs nothing in production for the same reason everything else here does: with no Collector in the context, RecordExternal returns on a nil receiver.

func WithCollector

func WithCollector(ctx context.Context, c *Collector) context.Context

WithCollector installs the collector in the context, and fills the slot when one was reserved upstream, so a middleware outside this one can still reach it.

func WithCollectorSlot

func WithCollectorSlot(ctx context.Context) context.Context

WithCollectorSlot reserves a place in the context for a Collector that a middleware further in will create. Recover installs it in development; outside development it is not installed at all, so production pays nothing for it.

func WithLogger

func WithLogger(ctx context.Context, l *slog.Logger) context.Context

WithLogger stores the request-scoped logger in the context.

Types

type Collector

type Collector struct {
	Start     time.Time
	RequestID string
	// contains filtered or unexported fields
}

Collector accumulates everything that happened inside ONE request: queries, dumps, events and outbound HTTP calls.

Cost: the Collector is only installed in the context in development or when the request carries an authorized tracing header. In production, without the header, FromContext returns nil and every Record method is a no-op on a nil receiver -- zero cost, not "low cost".

func FromContext

func FromContext(ctx context.Context) *Collector

FromContext returns the request collector, or nil in production. Every method below is safe on a nil receiver, so callers never need to check.

func NewCollector

func NewCollector(requestID string) *Collector

NewCollector returns a collector for a request id.

func (*Collector) Dumps

func (c *Collector) Dumps() []DumpRecord

Dumps returns a copy of the recorded dumps.

func (*Collector) Events

func (c *Collector) Events() []EventRecord

Events returns a copy of the recorded application events.

func (*Collector) External

func (c *Collector) External() []ExternalRecord

External returns a copy of the recorded outbound HTTP calls.

func (*Collector) Queries

func (c *Collector) Queries() []QueryRecord

Queries returns a copy of the recorded database calls.

A copy, and under the lock: the caller is usually the console rendering a request that has already finished, but a handler that started a goroutine and did not wait for it is still writing. Handing out the slice would hand out a race.

func (*Collector) QueryCount added in v0.10.0

func (c *Collector) QueryCount() int

QueryCount is how many database calls the request made.

It exists so the common case -- a log line saying how many -- does not copy the whole slice to call len on it.

func (*Collector) QueryTime

func (c *Collector) QueryTime() time.Duration

QueryTime is the total time spent in the database during the request.

func (*Collector) RecordEvent

func (c *Collector) RecordEvent(name string, payload any)

RecordEvent stores one application event.

Guard the call when the payload is a struct value:

if col := observability.FromContext(ctx); col != nil {
    col.RecordEvent("invoice.paid", invoice)
}

This method is a no-op on a nil receiver, but converting a struct value to `any` allocates at the CALL SITE, before the receiver is ever looked at. So the unguarded form costs one heap allocation per event in production, where nothing will ever read it. A payload that is already a pointer, a map or a string boxes for free and needs no guard.

func (*Collector) RecordExternal

func (c *Collector) RecordExternal(method, url string, status int, d time.Duration)

RecordExternal stores one outbound HTTP call.

func (*Collector) RecordQuery

func (c *Collector) RecordQuery(sql string, args []any, d time.Duration, rows int, err error)

RecordQuery stores one database call. The skip value walks past this method and the data.DB wrapper, so Caller points at the repository, not at the framework.

func (*Collector) RecordRender added in v0.4.0

func (c *Collector) RecordRender(name string, d time.Duration)

RecordRender stores one template render.

The view runtime calls it around every render; anything producing HTML can call it too. The name is what shows on the timeline, so it should be the template, not the function.

func (*Collector) Renders added in v0.4.0

func (c *Collector) Renders() []RenderRecord

Renders returns a copy of the recorded template renders.

func (*Collector) SlowQueries

func (c *Collector) SlowQueries(limit time.Duration) []QueryRecord

SlowQueries returns the queries at or above the limit. It feeds the "slow query" badge on the debug page.

func (*Collector) SuspectedNPlusOne

func (c *Collector) SuspectedNPlusOne(threshold int) map[string]int

SuspectedNPlusOne counts identical statements repeated within the request and returns those at or above the threshold. It is the diagnosis that saves the most time on generated CRUD.

func (*Collector) Timeline added in v0.4.0

func (c *Collector) Timeline(total time.Duration) Timeline

Timeline breaks the request duration down by where it was spent.

type Console added in v0.4.0

type Console struct {
	// contains filtered or unexported fields
}

Console serves the request inspector at /_arandu/debug.

This is the Telescope equivalent, and it is core rather than a package you install. The reason is the thesis of the product: a framework whose selling point is "the debugger names the probable cause" cannot ship the debugger as an optional dependency that half the projects never add.

It renders with html/template and no assets, like the error page and for the same reason: it has to work when the rest is broken, including when the view build failed.

func NewConsole added in v0.4.0

func NewConsole(r *Recorder, editor string) *Console

NewConsole returns the console over a recorder.

func (*Console) Handler added in v0.4.0

func (c *Console) Handler(w http.ResponseWriter, r *http.Request)

Handler serves the list and the detail.

One handler for both, because the router matches the prefix and the id under it, and splitting them would mean two routes to mount and one more thing to forget.

type DumpRecord

type DumpRecord struct {
	Label  string
	Value  any
	Caller Frame
	At     time.Duration // offset since the start of the request
}

DumpRecord is one Dump call, with its origin and its offset into the request.

type EventRecord

type EventRecord struct {
	Name    string
	Payload any
	At      time.Duration
}

EventRecord is one application event emitted during the request.

type ExternalRecord

type ExternalRecord struct {
	Method   string
	URL      string
	Status   int
	Duration time.Duration
}

ExternalRecord is one outbound HTTP call.

type Frame

type Frame struct {
	File string
	Line int
	Func string
}

Frame is a source location.

type QueryRecord

type QueryRecord struct {
	SQL      string
	Args     []any
	Duration time.Duration
	Rows     int
	Caller   Frame
	Err      error
}

QueryRecord is one database call, with the file and line that issued it -- which is the field that actually saves time when hunting an N+1.

type Recorded added in v0.4.0

type Recorded struct {
	RequestID string
	Method    string
	Path      string
	Status    int
	// Duration is the wall clock of the whole request, which is what the
	// timeline is a breakdown of.
	Duration time.Duration
	// At is when the request started, so the list can be read in order.
	At time.Time
	// Collector holds everything recorded during the request. It is the same
	// pointer the request used: the request is over, so nothing writes to it
	// any more.
	Collector *Collector
}

Recorded is one finished request, kept for the console.

func (Recorded) StatusClass added in v0.4.0

func (e Recorded) StatusClass() string

StatusClass groups a status code for the console, so the list can be scanned without reading every number.

type Recorder added in v0.4.0

type Recorder struct {
	// contains filtered or unexported fields
}

Recorder is the ring buffer behind /_arandu/debug.

A ring rather than a growing slice, because the alternative is a debug console that turns a long-running dev server into an out-of-memory kill -- and it would happen at the end of a long session, which is exactly when losing the process costs the most.

Every method is safe on a nil receiver. In production there is no recorder, and the middleware should not have to check.

func NewRecorder added in v0.4.0

func NewRecorder(size int) *Recorder

NewRecorder returns a ring buffer of the given size. A size of zero or less uses DefaultRecorderSize.

func (*Recorder) Find added in v0.4.0

func (r *Recorder) Find(requestID string) (Recorded, bool)

Find returns one request by id.

This is what `aru trace` resolves: you have a request id from a log line or from the X-Request-ID header, and you want everything that happened inside it.

func (*Recorder) Len added in v0.4.0

func (r *Recorder) Len() int

Len is how many requests are stored.

func (*Recorder) Recent added in v0.4.0

func (r *Recorder) Recent(limit int) []Recorded

Recent returns the stored requests, newest first. A limit of zero or less returns all of them.

func (*Recorder) Record added in v0.4.0

func (r *Recorder) Record(e Recorded)

Record stores a finished request, discarding the oldest when full.

type RenderRecord added in v0.4.0

type RenderRecord struct {
	Name     string
	Duration time.Duration
	At       time.Duration
}

RenderRecord is one template render.

It is what separates "the page is slow because of the database" from "the page is slow because of the view", which are two different afternoons.

type Timeline added in v0.4.0

type Timeline struct {
	Total    time.Duration
	SQL      time.Duration
	Render   time.Duration
	External time.Duration
	// Other is what is left: application code, serialization, the framework
	// itself. A large Other with few queries is a CPU problem, and that is a
	// different investigation.
	Other time.Duration
}

Timeline is where a request spent its time.

The question it answers is the first one worth asking about a slow page: was it the database, the rendering, something it called, or the code itself. Without the breakdown, "this page takes 800ms" leads to guessing.

func (Timeline) Percent added in v0.4.0

func (t Timeline) Percent(d time.Duration) int

Percent returns d as a percentage of the total, rounded down. Zero total gives zero rather than a division by zero on a request that finished in under a microsecond.

Directories

Path Synopsis
Package errorpage renders the development error page.
Package errorpage renders the development error page.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL