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
- func Client(timeout time.Duration) *http.Client
- func Dump(ctx context.Context, label string, value any)
- func DumpDie(ctx context.Context, label string, value any)
- func EditorLink(editor, file string, line int) string
- func IsDumpDie(v any) bool
- func Log(ctx context.Context) *slog.Logger
- func NewLogger(env string, level slog.Level) *slog.Logger
- func RootLogger(l *slog.Logger) func(http.Handler) http.Handler
- func Transport(next http.RoundTripper) http.RoundTripper
- func WithCollector(ctx context.Context, c *Collector) context.Context
- func WithCollectorSlot(ctx context.Context) context.Context
- func WithLogger(ctx context.Context, l *slog.Logger) context.Context
- type Collector
- func (c *Collector) Dumps() []DumpRecord
- func (c *Collector) Events() []EventRecord
- func (c *Collector) External() []ExternalRecord
- func (c *Collector) Queries() []QueryRecord
- func (c *Collector) QueryCount() int
- func (c *Collector) QueryTime() time.Duration
- func (c *Collector) RecordEvent(name string, payload any)
- func (c *Collector) RecordExternal(method, url string, status int, d time.Duration)
- func (c *Collector) RecordQuery(sql string, args []any, d time.Duration, rows int, err error)
- func (c *Collector) RecordRender(name string, d time.Duration)
- func (c *Collector) Renders() []RenderRecord
- func (c *Collector) SlowQueries(limit time.Duration) []QueryRecord
- func (c *Collector) SuspectedNPlusOne(threshold int) map[string]int
- func (c *Collector) Timeline(total time.Duration) Timeline
- type Console
- type DumpRecord
- type EventRecord
- type ExternalRecord
- type Frame
- type QueryRecord
- type Recorded
- type Recorder
- type RenderRecord
- type Timeline
Constants ¶
const ConsolePath = "/_arandu/debug"
ConsolePath is where the console is mounted.
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.
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
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 ¶
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 ¶
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 ¶ added in v0.4.0
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 ¶
IsDumpDie identifies the sentinel so the Recover middleware renders the dump page instead of treating the panic as a real 500.
func Log ¶
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 ¶
NewLogger returns the root logger: readable text in development, JSON everywhere else, so it reaches the aggregator without fragile parsing.
func RootLogger ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
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
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) RecordEvent ¶
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 ¶
RecordExternal stores one outbound HTTP call.
func (*Collector) RecordQuery ¶
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
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 ¶
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.
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
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 ¶
EventRecord is one application event emitted during the request.
type ExternalRecord ¶
ExternalRecord is one outbound HTTP call.
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
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
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
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.
type RenderRecord ¶ added in v0.4.0
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.