Skip to content
Trilha
Chapters

Reference

Observability

Config.Observability, health endpoints, metrics registry, environment variables and the contract of each response.

Config.Observability#

FieldDefaultWhat it does
Health string/_trilha/healthbase path of the probes; trilha.Off removes them
Metrics string"" (off)scrape path; empty registers no endpoint and does not instrument requests
Token stringTRILHA_OBS_TOKENauthorizes details and metrics; at least 32 bytes, compared in constant time
Trusted []string—CIDRs (or IPs) that do not need the token
Details stringautomatictrilha.Off never reveals details, not even to a token holder; empty = open in dev, authorized in prod
Timeout time.Duration2 sdeadline of each check; trilha.NoTimeout disables it
CacheFor time.Duration1 svalidity of the readiness result; trilha.NoTimeout disables the cache

Variables read by ConfigFromEnv: TRILHA_OBS_TOKEN, TRILHA_METRICS, TRILHA_OBS_TRUSTED (comma-separated list).

Endpoints#

Method and pathResponseStatus
GET /_trilha/health/liveapplication/health+jsonalways 200
GET /_trilha/health/readysame, runs the checks200 or 503 + Retry-After: 5
GET /_trilha/healthsame as ready200 or 503
GET <Metrics>text/plain; version=0.0.4200, or 401 without authorization

All of them carry Cache-Control: no-store, X-Robots-Tag: noindex and X-Content-Type-Options: nosniff. Any other method returns 405 with Allow: GET, HEAD.

The probes run outside the middleware chain: no CSRF, no layout, no rate limit (a liveness probe that got a 429 would kill a healthy process) and logged at Debug level, so they do not drown the audit log.

Readiness checks#

func (a *App) Check(name string, fn func(context.Context) error)
func (a *App) HealthReport(ctx context.Context) HealthReport
type HealthReport struct {
	Status        string        // trilha.StatusPass | trilha.StatusFail
	Checks        []CheckResult
	UptimeSeconds float64
}

type CheckResult struct {
	Name       string
	Status     string
	DurationMS float64
	Error      string
}

HealthReport always returns everything: it is for your code (an internal status page, a startup gate). The endpoint decides what to reveal. The two values are the constants trilha.StatusPass ("pass") and trilha.StatusFail ("fail"), so a status page compares against the framework's name instead of a string it typed.

Metrics registry#

func (a *App) Metrics() *Metrics

func (m *Metrics) Counter(name, help string, labels ...string) *Counter
func (m *Metrics) Gauge(name, help string, labels ...string) *Gauge
func (m *Metrics) Histogram(name, help string, buckets []float64, labels ...string) *Histogram

MaxSeries (a thousand by default) caps the label combinations per metric; the overflow falls into one series with every label set to other and a single warning in the log.

TypeMethods
*CounterInc(), Add(v), With(values...)
*GaugeSet(v), Add(v), Inc(), Dec(), With(values...)
*HistogramObserve(v), With(values...)

An invalid name (outside [a-zA-Z_:][a-zA-Z0-9_:]*) or the wrong number of label values causes a panic: it is a programming error, shows up on the first run and does not corrupt the output. Calling Counter twice with the same name returns the same series.

Histogram with nil buckets uses the defaults, in seconds: 0.001 0.005 0.01 0.025 0.05 0.1 0.25 0.5 1 2.5 5 10.

Framework metrics#

MetricTypeLabels
trilha_requests_totalcountermethod, route, status
trilha_request_duration_secondshistogrammethod, route
trilha_requests_in_flightgauge—
trilha_security_events_totalcounterkind (csrf, auth, body, rate, panic)
trilha_panics_totalcounter—
go_goroutines, go_memstats_alloc_bytes, go_memstats_sys_bytesgauges—
go_gc_cycles_totalcounter—
trilha_uptime_secondsgauge—
trilha_build_infogauge (always 1)version, go_version

route is the registered pattern (/blog/{slug}). Static files, 404 and anything outside the router come in as other.

Correlation#

func (c *Ctx) RequestID() string  // the client's X-Request-ID, or generated
func (c *Ctx) TraceID() string    // W3C traceparent; "" when absent or malformed
func (c *Ctx) Log() *slog.Logger  // logger with request_id and trace_id

A malformed traceparent is silently dropped: a value chosen by a third party does not enter the log as if it were a legitimate trace.

The request hook#

Config.OnRequest wraps every request that reached a route, with the request id and the trace id already set and before the first middleware. It is the seam an optional module hooks into — tracing, profiling, a per-request budget — and it carries no dependency of its own: the signature speaks only of *Ctx and of an int.

type RequestHook func(c *Ctx, next func() (status int))

cfg.OnRequest = func(c *trilha.Ctx, next func() int) {
	ctx, span := tracer.Start(c.Context(), c.Pattern())
	defer span.End()
	c.SetContext(ctx) // travels to the handler, to Upstream and to ai
	span.SetAttributes(attribute.Int("http.response.status_code", next()))
}

The rules are short: call next exactly once; it runs the whole rest of the request — middlewares, CSRF, the handler, the error page — and returns the status that was written, so a panic in the handler comes back as 500 instead of unwinding through the hook. Replacing the context with c.SetContext before next is how a value reaches the handler and everything the framework sends out afterwards. A hook that forgets to call next does not swallow the request: the answer is served anyway. The hook runs in the request goroutine and must not panic — the recover that turns a handler panic into a 500 is inside next, not around it.

The exporter that uses this seam is the optional module github.com/emersonjoe/trilha/otel (otel.Install, otel.Options, otel.Transport); see Observability. The core never imports it.

What the audit checks#

trilha audit adds these items: token too short (critical), metrics configured without a token or a trusted network (critical), 0.0.0.0/0 in Trusted (warning) and no a.Check( anywhere in the project (warning).

The audit trail#

Every internal application ends up needing "who did what": who deleted the document, who changed the permission, who exported the list. The framework already holds half of it — the request id, the client IP behind TrustedProxies, the session, the route pattern. What is missing is the sentence, and a place for it.

func DELETE(c *trilha.Ctx) error {
	if err := docs.Delete(c, c.Param("id")); err != nil {
		return err
	}
	c.Audit("document.deleted", c.Param("id"))
	return c.Redirect("/documents")
}

c.Audit("permission.changed", role, trilha.Fields{"module": "docs", "from": "view", "to": "edit"})

The record that comes out, with nothing else written by the application:

{"at":"2026-09-08T15:04:05Z","action":"document.deleted","target":"42",
 "actor":{"subject":"u_17","email":"ana@org.br","via":"session"},
 "ip":"10.0.0.7","request_id":"…","route":"/documents/{id}"}

That is the whole point of it being one line: the actor, the address, the request id and the route are already known, and writing them by hand is what every application does and what every application forgets in half its handlers.

Where it goes#

Config.Audit is a trilha.AuditSink: one method, Write(AuditRecord) error, because the decision an application actually makes is which table, not which shape. An error coming back is logged and the request carries on — the document was deleted either way, and refusing to answer would lose the trail and confuse the person.

cfg.Audit = trilha.AuditFunc(func(r trilha.AuditRecord) error {
	_, err := db.Exec(`INSERT INTO audit_log (at, action, target, actor, ip) VALUES (?,?,?,?,?)`,
		r.At, r.Action, r.Target, r.Actor.Subject, r.IP)
	return err
})

Leave it nil and the record goes to the app's logger with kind=audit, which is enough to grep and enough to ship a first version with.

A sink that fails does not take the response with it. The error is logged and the request carries on. That is deliberate: the document was deleted either way, and refusing to answer now would lose the trail and confuse the person who did it — two failures instead of one.

Who is acting#

auth sets it: any route behind Require, RequireRole or RequirePolicy attributes the trail without the application writing a line, with via: "session".

An application that authenticates its own way calls c.SetActor once in its middleware and everything below is attributed:

c.SetActor(trilha.Actor{Subject: key.ID, Name: key.Label, Via: "api_key"})

Nobody recognised is recorded as anonymous, and it is recorded — an audit trail that silently drops the anonymous action has a hole exactly where somebody would look. trilha audit warns when c.Audit is called in a project where no route requires a session.

The screen#

ui.AuditTable is the screen every application with c.Audit ends up writing by hand: who did what, to what, when and from where.

regs, total := auditoria.Buscar(q)
return ui.AuditTable(c, regs, ui.AuditOpts{
	Params:  q.ListParams,
	Total:   total,
	Actions: auditoria.Acoes(),
	Action:  q.Acao,
	Export:  "/auditoria/csv",
}), nil

trilha add audit writes the package wired up, this screen and the test.

app/…/page.go
records, total := auditoria.Search(q)
return ui.AuditTable(c, records, ui.AuditOpts{
	Params:  q.ListParams,
	Total:   total,
	Actions: auditoria.Actions(),
	Export:  "/audit/csv",
}), nil
result
Export CSV
Audit trail
WhenWhoActionTargetIPDetail
Ana Souzaupdatedinvoice/2024-093203.0.113.9
svc-billing api_keyrevokedapi-key/ak_92f
2 results

It is a ui.DataTable underneath, and that is the point: the filter form, the ordering links, the pagination and the fragment swap are the ones every other listing already has. A trail that behaved differently from the rest of the app would be a second thing to learn.

Reading the trail is the application's job. Config.Audit is a write interface with one method and stays that way — the framework has no database, and the query behind this screen (a period, an actor, a table this app chose) is not something it could write. The example's is thirty lines over a slice.

Fields is a detail and not a column: each action carries its own keys, so a column per key is a table that grows a column every time somebody audits something new.

Export points at a route answering with c.CSV, and the button carries the query that is on screen — an export that ignores the filter in front of somebody is an export of the wrong thing, and they only find out in the spreadsheet.

Route is the pattern#

/documents/{id}, not /documents/42. The concrete id is already in Target; the pattern is what lets a query group a thousand deletions into one row.