Help us shape the future of Go! Take the 2026 Go Developer Survey

observe

package module
v0.6.7 Latest Latest
Warning

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

Go to latest
Published: Oct 3, 2026 License: MIT Imports: 30 Imported by: 0

Documentation

Overview

Package observe is where OpenTelemetry lives, so that nothing else in rig has to depend on it.

Every generated application imports rig/runtime, which has two direct dependencies and is meant to keep them. otel is a large decision — a dependency tree, an exporter, a collector somebody runs — and plenty of projects will trace some other way or not at all. So it is here, in a module of its own, and a project that does not set `tracing:` in its rig.yaml gets no span in its generated source, no import of this package, and no otel in its go.mod. Optional, in rig, means absent rather than switched off.

The three prices

Logging is always on and has no configuration: the generated server writes the cause of every 500 whether or not anybody thought about it. Spans are the second price and are what `tracing: enabled` buys. Exporting them is the third, and it is a runtime decision rather than a generated one — see Config.

Nothing configured is not nothing

Setup with no endpoint and no file still installs a real provider, sampling nothing. Spans are not recorded and nothing is exported, and the trace and span identifiers are still real — which is the point, because the generated server's RequestID is one of them. A laptop with no collector still writes log lines carrying a trace id that will mean something when the same binary runs where there is one.

One span per function

Everything here is arranged so that a span is opened at the top of a function and ended by a defer, and never ended anywhere else. Trace and Call take the work as a callback for exactly that reason: the stage becomes a function, so its span is that function's span, and no early return can skip the end. The generated code follows the same rule.

Index

Constants

View Source
const (
	// EndpointEnv is the OTLP collector, in the variable OpenTelemetry's own
	// tooling already reads.
	EndpointEnv = "OTEL_EXPORTER_OTLP_ENDPOINT"
	// FileEnv is the span file, which is rig's and so is named for rig.
	FileEnv = "RIG_TRACE_FILE"
)

The environment variables Config falls back to. Both are read once, in Setup, so a deployment configures where its spans go without a code change and without a flag rig would have to define.

View Source
const (
	// DefaultMonitorPath is where the page is mounted. Under a prefix that
	// starts with an underscore so that it cannot collide with a table: rig
	// reserves the rig_ prefix in the database and nothing generates a route
	// beginning /_rig.
	DefaultMonitorPath = "/_rig/monitor"
	// DefaultMaxTraces is how many requests the page lists.
	DefaultMaxTraces = 200
	// DefaultMaxLogs is how many log lines the page reads. Larger than
	// [DefaultMaxTraces] because one request writes several lines, and the
	// request line alone means there is at least one per request listed.
	DefaultMaxLogs = 500
	// MinPasswordLength is the shortest password the page will start with. It
	// is the one guard against a brute force, because there is no lockout and
	// no rate limit here — see the package documentation.
	MinPasswordLength = 12
	// DefaultCheckTimeout bounds one round of [PageConfig.Checks]. A probe that
	// hangs is a probe that has already answered, and the page would rather show
	// a dependency as failing than show nothing at all while it waits.
	DefaultCheckTimeout = 2 * time.Second
)

The page's defaults, applied by Provider.Page to whatever it is given.

View Source
const AddrEnv = "RIG_MONITOR_ADDR"

AddrEnv is where the monitoring page's listen address comes from when the deployment would rather say it than take what rig.yaml resolved.

It overrides PageConfig.Addr rather than filling it in, which is the opposite of how PasswordEnv works and is deliberate. The password has no sensible value in a checked-in file, so rig.yaml is the fallback and the environment is the ordinary answer. A listen address does have one — the project decided it once, and `rig validate` refuses a project that did not — so the environment is the exception, for the deployment that has to move the port off whatever the project picked.

View Source
const DefaultFileMaxBytes int64 = 8 << 20

DefaultFileMaxBytes is where a span file rotates. Eight mebibytes is a few hundred thousand spans, which is more than anybody reads and less than anybody notices — and the ceiling is twice this, because one rotated file is kept.

View Source
const LogFileEnv = "RIG_LOG_FILE"

LogFileEnv is where the log file's path comes from unless the application names one itself. It is rig's, and so is named for rig, the same as FileEnv.

View Source
const PasswordEnv = "RIG_MONITOR_PASSWORD"

PasswordEnv is where the monitoring page's password comes from unless the project named another variable.

It is an environment variable and not a key in rig.yaml for the reason the collector endpoint is not one either: it is a property of the deployment. A password is also a secret, and rig.yaml is checked in — a project may write one there anyway, and rig says so once rather than refusing.

View Source
const ScopeName = "github.com/simonjanss/rig/observe"

ScopeName is the instrumentation scope every span rig opens is attributed to. A collector groups by it, which is how "this span came from the framework" stays distinguishable from a span the application opened itself.

Variables

This section is empty.

Functions

func Call

func Call(ctx context.Context, name string, f func(context.Context) error) error

Call is Trace on rig's own tracer, shaped to fit rigclient.Config.Trace.

That field is a function rather than an interface so that rigclient — which every generated client imports — depends on no tracing library at all. This is the value to put in it.

func Fail

func Fail(ctx context.Context, status int, err error)

Fail records why a request is being refused, on whatever span the context is in.

It does not end anything: the span belongs to the handler and is ended by the handler's defer. This is what the generated server calls from the one funnel every error response passes through.

Only a 500 makes the span itself a failure. A 404, a 422 or a refused permission is a server that worked, and a trace where every not-found is red is a trace nobody reads — the same argument that puts those lines at debug rather than at error.

A cancelled caller is not a failure either, whatever status it arrives with. The generated server does not call this at all in that case, but this is exported and reachable on its own, and [record] has always taken that arm on the repository-layer path — one fact should not get two answers depending on which road it came in by.

func Pool

func Pool(cfg *pgxpool.Config) error

Pool puts a span around every statement. It has the shape serve.Config.Pool takes, so wiring the database up is one line:

serve.Main(serve.Config{Pool: observe.Pool, …})

This is where the SQL comes from rather than the generated repositories, because a tracer on the connection sees every statement — the ones a hook runs, the ones rig's own foundation runs, the ones a service issues directly — and a generator only ever sees what it wrote.

It replaces any tracer already on the connection configuration, because pgx has room for exactly one. A project that has its own wraps this rather than setting both.

func Tee

func Tee(hs ...slog.Handler) slog.Handler

Tee is a log/slog.Handler that writes every record to each of hs.

It is how the log file is added to a logger that already writes somewhere:

slog.New(observe.Tee(slog.NewJSONHandler(os.Stderr, nil), logs.Handler()))

Enabled is true when any of them is, and each is asked again before it is handed the record — so a file keeping debug lines does not turn stderr into debug output. That asymmetry is the reason this exists rather than a log/slog.Logger per destination.

func Trace

func Trace(ctx context.Context, tracer trace.Tracer, name string, f func(context.Context) error) error

Trace runs f inside a span of its own.

The callback is the whole point. A span opened this way is a function's span: it is ended by a defer inside this call, so no early return, no added branch and no panic can leave it open, and the call site never holds a span it could end twice. It is also where an error becomes a failed span, once, rather than at every place an error is returned.

A nil tracer is Tracer, so a repository that was constructed without one still runs.

func TraceID

func TraceID(r *http.Request) string

TraceID is the trace this request belongs to, or "" when it belongs to none.

It is what the generated server's RequestID returns when a project has tracing on and set nothing else, which makes the identifier in an error body, the request_id on every log line, and the trace in a collector the same string. That is the whole correlation story, and it needs no field anywhere and no otel in rig/runtime.

Empty when there is no span — which is what an untraced request looks like, and what makes the fallback safe to apply unconditionally.

func Tracer

func Tracer() trace.Tracer

Tracer is rig's tracer, from whatever provider is installed. It is what a generated store is handed and what everything here opens spans through.

Types

type APITracer

type APITracer struct{}

APITracer is Server, TraceID and Fail gathered into the three methods a generated server's tracer field takes.

It satisfies that field without either package importing the other, which is the point of it: the interface is declared in github.com/simonjanss/rig/runtime/apibase and every one of its methods speaks in standard-library types, so a value of this type simply fits. A field typed as this package's own would put OpenTelemetry in the go.mod of every project that never set `tracing:`.

The zero value is ready, and there is nothing to configure on it: where the spans go is Setup's business, and this is only how a request reaches them.

api.Server{Tracer: observe.APITracer{}}

A generated server assigns it for you when `tracing:` is on. This is the spelling for a server built by hand.

func (APITracer) Fail

func (APITracer) Fail(ctx context.Context, status int, err error)

Fail records why a request is being refused, on whatever span the context is in. It ends nothing.

func (APITracer) Server

func (APITracer) Server(r *http.Request, route string, status func() int) (*http.Request, func())

Server opens the span for one request and returns the function that ends it.

The span itself is Server's. What changes here is only the shape: a func() rather than a *Span, so that the interface this satisfies can be written without naming a type from this module.

func (APITracer) TraceID

func (APITracer) TraceID(r *http.Request) string

TraceID names the trace this request belongs to, or empty when it is in none.

type Check added in v0.2.0

type Check struct {
	Name  string
	Probe func(ctx context.Context) error
}

Check is one dependency the monitoring page asks about.

Probe returns nil when the dependency is there. Anything else is shown as failing, with the error's text as the detail — so write the error for somebody reading a status pill at three in the morning, not for a stack trace.

type CheckResult added in v0.2.0

type CheckResult struct {
	Name string `json:"name"`
	OK   bool   `json:"ok"`
	// Detail is the error's text when OK is false, and empty otherwise.
	Detail string `json:"detail,omitempty"`
	// Millis is how long the probe took, which is most of what distinguishes a
	// dependency that is there from one that is barely there.
	Millis int64 `json:"ms"`
}

CheckResult is one dependency's answer, as the page renders it.

type Config

type Config struct {
	// ServiceName is what this application is called in a collector. The
	// generated Tracing() passes the API's name from rig.yaml, so nothing has
	// to be typed twice.
	ServiceName string

	// ServiceVersion is the build, if it knows. Empty leaves the attribute off
	// rather than sending "unknown", which sorts and groups as a version.
	ServiceVersion string

	// Endpoint is an OTLP/HTTP collector, for example
	// "http://localhost:4318". Empty falls back to $OTEL_EXPORTER_OTLP_ENDPOINT
	// and, failing that, to exporting nothing at all.
	//
	// Nothing is the ordinary case rather than the broken one. An exporter
	// pointed at a host that was never there retries, and the retry comes due
	// during shutdown — so the failure mode of getting this wrong is a slow
	// exit in exactly the environments nobody is watching.
	Endpoint string

	// File is a path to write finished spans to, one JSON object per line.
	// Empty falls back to $RIG_TRACE_FILE and then to writing none.
	//
	// It is the small deployment's answer to a collector it is not going to
	// run: the file survives a restart, which is when you most want to know
	// what the last few requests were doing, and it is the store rig's own
	// monitoring page reads.
	File string

	// FileMaxBytes is where File rotates, keeping one previous generation.
	// Zero means [DefaultFileMaxBytes]. An append-only file with no ceiling is
	// the thing that fills a disk at three in the morning.
	FileMaxBytes int64

	// SampleRatio is the fraction of traces recorded, between 0 and 1. Zero
	// means one — every trace — because zero is what a field nobody set holds,
	// and a project that wants no traces leaves Endpoint and File empty
	// instead.
	//
	// A trace started elsewhere is honoured rather than re-decided: the sampler
	// is parent-based, so a request that arrives already sampled is recorded
	// whatever this says.
	SampleRatio float64
}

Config says who this service is and where its spans go.

Where they go is deliberately not in rig.yaml. The same binary runs on a laptop, in CI and in production, and only the last of those has a collector; a project that had to regenerate to stop exporting would be a project that exports from its test suite.

type LogConfig

type LogConfig struct {
	// File is the path to append log lines to, one JSON object per line. Empty
	// falls back to $RIG_LOG_FILE and then to writing none, which is the
	// ordinary case rather than the broken one.
	File string

	// FileMaxBytes is where File rotates, keeping one previous generation. Zero
	// means [DefaultFileMaxBytes]. An append-only file with no ceiling is the
	// thing that fills a disk at three in the morning.
	FileMaxBytes int64

	// Level is what the *file* keeps, and nil means [log/slog.LevelDebug].
	//
	// Its own level, and not the application logger's, is the point. rig's
	// request line and its refusal line are at debug — logging a 404 at error
	// is how a log becomes a thing nobody reads — so a deployment running at
	// info writes them nowhere and nobody ever sees them. The file keeps them
	// and stderr does not, which is the whole reason the monitoring page has a
	// request to list at all.
	//
	// A [log/slog.Leveler] rather than a Level because Level's zero value is
	// LevelInfo, and a field whose zero meant debug would be a lie.
	Level slog.Leveler
}

LogConfig says where the log file goes and what it keeps.

Where it goes is deliberately not in rig.yaml, for Config's reason: the same binary runs on a laptop, in CI and in production, and a project that had to regenerate to stop writing a file would be a project that writes one from its test suite.

type LogRecord

type LogRecord struct {
	Time  time.Time `json:"time"`
	Level string    `json:"level"`
	Msg   string    `json:"msg"`

	// TraceID and SpanID are the trace this line was written inside, taken from
	// the context the log call was given. Empty for a line written outside a
	// request, or by a process that is not tracing.
	//
	// This is what makes a log line and a request the same view on the
	// monitoring page, and it is why every log call in rig passes the context:
	// slog hands the context to the handler, and a bare Info would arrive here
	// with nothing to find.
	TraceID string `json:"trace_id,omitempty"`
	SpanID  string `json:"span_id,omitempty"`

	// Attrs is everything else the line carried, nested rather than flattened
	// into the object above: a line is free to have an attribute called "level"
	// or "msg", and a flat record would let it overwrite one. A slog group is a
	// map here, so the generated server's slog.Any("request", rc) arrives as
	// attrs.request.route and attrs.request.request_id.
	Attrs map[string]any `json:"attrs,omitempty"`
}

LogRecord is one line of the log file: one log/slog record, as JSON.

The format is rig's own for SpanRecord's reason — a person is reading this, with grep or with the monitoring page — so the four fields anybody scans for are at the front of the line and every identifier is a hex string that can be pasted into a search.

It is exported because reading the file is a thing to do: decode a line into this and every field is named.

func ReadLogs

func ReadLogs(path string, max int) ([]LogRecord, error)

ReadLogs reads the last max lines of a log file, newest first.

Newest first because that is the order anybody looking at a log wants — the opposite of ReadSpans, which is oldest first because its records are grouped into traces before anybody sees them.

It is Logs.Read without a sink, for a script over a file this process is not the one writing. The rotated generation beside it counts, a line that does not parse is skipped, and a missing file is no records and no error: the same three properties ReadSpans has, and for the same reasons.

Nothing here is specific to the file rig writes beyond the field names, so a log a project's own log/slog.JSONHandler produced reads as well — time, level and msg are slog's own keys, and anything else lands in LogRecord.Attrs.

func (*LogRecord) UnmarshalJSON

func (rec *LogRecord) UnmarshalJSON(data []byte) error

UnmarshalJSON reads one line, whether rig wrote it or some other handler did.

The five named fields are taken by name and *every other top-level key falls into LogRecord.Attrs*, which is the whole of what makes this reader work over a file a project's own log/slog.JSONHandler produced: that handler writes its attributes at the top level, where the plain struct decoding would drop them on the floor.

Marshalling is the ordinary struct encoding, so a line rig writes round-trips and one it did not is normalised on the way in. A level that is not a string is left empty rather than failing the line: one field rig does not recognise should not lose the message beside it.

type Logs

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

Logs is the log file this process writes, and the reader over it.

It is one object rather than a path passed to two places, because the page and the writer agreeing on a file is not something a main should have to arrange twice — the same argument Provider.Page makes about the span file. Hand it to PageConfig.Logs.

func OpenLogs

func OpenLogs(cfg LogConfig) (*Logs, error)

OpenLogs opens the log file, creating the directory it lives in.

Having no file is not an error: it is the ordinary case on a laptop and in CI, and it returns a sink whose handler is never enabled, so teeing it into a logger costs a branch per line and nothing else. Logs.Unarmed says which, in a line a main can log.

logs, err := observe.OpenLogs(observe.LogConfig{})
if err != nil {
    return err
}
logger := slog.New(observe.Tee(base, logs.Handler()))

The file is opened here rather than at the first line, so a path nothing can write is a startup error naming the path rather than a page that is quietly empty.

func (*Logs) Close

func (l *Logs) Close() error

Close closes the file. It is nil-safe and safe to call twice.

Writes are unbuffered, so there is nothing to flush and nothing is lost by never calling this: it exists for a test that wants the file settled, and so that a long-running process can let one go. A main does not need a shutdown step for it, and should not have one — the last lines a server writes are written during its shutdown.

func (*Logs) File

func (l *Logs) File() string

File is the path being written, or empty for a sink that is unarmed. The monitoring page shows it, so that "no lines here" and "no file here" are distinguishable from the page itself.

func (*Logs) Handler

func (l *Logs) Handler() slog.Handler

Handler is this sink as a log/slog.Handler. Tee it beside the one the application already writes to — see Tee.

An unarmed sink returns a handler that is never enabled, so the logger it is teed into behaves exactly as it did without one.

func (*Logs) Read

func (l *Logs) Read(max int) ([]LogRecord, error)

Read is the last max lines of this sink's file, newest first. It is what the monitoring page calls, and it is ReadLogs over the path this sink is writing.

func (*Logs) Unarmed

func (l *Logs) Unarmed() string

Unarmed is why this sink will write nothing, or empty when it will.

A reason rather than a boolean, because the answer is worth logging: a log file that was configured and is absent at run time should say which end is missing.

type Page

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

Page is rig's monitoring page: the last requests this server served, and the spans underneath each one.

It is a reader over the span file and holds nothing. A restart loses no history, because the history is the file; a second replica shows its own, because a file is per process.

func (*Page) Addr

func (pg *Page) Addr() string

Addr is where this page listens, or empty when it is unarmed.

Empty and nil from Page.Handler travel together: a caller that passes both on gets no listener, which is the whole of what an unarmed page does.

func (*Page) BasePath

func (pg *Page) BasePath() string

BasePath is where the page is mounted on its own listener, resolved.

It is here for the caller that has to build a link to the page — the page is on an origin of its own now, so a relative href no longer reaches it — and it answers even when the page is unarmed, because it is a fact about the configuration rather than about whether anything is serving. Page.Addr is the half that goes empty.

func (*Page) Handler

func (pg *Page) Handler() http.Handler

Handler is the page, ready to be served, and nil when it is unarmed.

Nil rather than a handler that refuses, for the reason Page.Mount registers nothing: a port answering 401 tells anybody scanning that there is a page here, and a port that is not open tells them nothing. Passing it to github.com/simonjanss/rig/runtime/serve.Config Monitor is what makes the difference — nil there means no second listener is opened at all.

It is a mux of its own rather than the API's, which is what keeps the page off the network the API is on. See PageConfig.Addr.

func (*Page) Mount

func (pg *Page) Mount(mux *http.ServeMux)

Mount registers the page's routes on a mux, and registers nothing when it is unarmed.

Nothing rather than a handler that refuses: a route that answers 401 tells anybody scanning that there is a page here, and a route that does not exist tells them nothing. It is the same argument that leaves the registration endpoint unmounted rather than answering 403.

Page.Handler is the ordinary way in, and it is this call onto a mux of the page's own. This one is exported for the caller who has a mux already and a reason — a test, or a reverse proxy arrangement where the page shares a listener with something that is not this application's API. Sharing it with that API is what PageConfig.Addr exists to stop.

The path without its trailing slash redirects to the one with it, rather than serving the same page twice. That is what lets the HTML name its stylesheet and its script by a relative path: from /_rig/monitor those would resolve against /_rig/, and the page's assets are the one thing here that cannot be written without knowing what monitoring.base_path was set to. Behind the guard, so an address that may not see the page does not learn it exists from a redirect either.

The page is not traced and not logged, and that is not arranged here: rig opens its spans and writes its request lines inside each generated handler, so anything that is not one is already invisible to both. Looking at the page does not appear on the page — which was true when it shared the API's mux and is now true twice over, since it does not.

func (*Page) Unarmed

func (pg *Page) Unarmed() string

Unarmed is why this page will serve nothing, or empty when it will serve.

A reason rather than a boolean, because the answer is worth logging: a page that is configured in rig.yaml and absent at run time should say which of the two ends is missing.

func (*Page) Watch added in v0.2.0

func (pg *Page) Watch(name string, probe func(ctx context.Context) error)

Watch adds a dependency to the page after it has been built.

It exists because of the order rig starts things in: the page is built by NewProcess, before there is a pool to ping or a sync proxy to ask, and the wiring that has those runs later with the page already in hand.

page.Watch("database", app.Pool.Ping)

Safe on a nil or unarmed page, which is the same courtesy Page.Mount gives: a caller registering a probe should not have to first find out whether the environment armed the page.

A nil probe registers nothing. There is no way to remove one.

type PageConfig

type PageConfig struct {
	// ServiceName is what the page calls this application, and it comes from
	// project.name in rig.yaml.
	ServiceName string

	// Addr is where the page listens, as host:port — "127.0.0.1:9090", or
	// ":9090" for every interface. $RIG_MONITOR_ADDR overrides it; see
	// [AddrEnv].
	//
	// The page gets a listener of its own rather than a route on the API's mux,
	// and this is the whole reason: [PageConfig.Allow] is matched against the
	// connection's own address, so behind a load balancer it matches everything
	// or nothing, and the only boundary left that a client cannot talk its way
	// around is which interface the socket is bound to. Loopback is a boundary
	// the kernel keeps.
	//
	// Empty is not defaulted. Nothing here knows what port is free on the
	// machine this runs on, and a page reachable from an interface nobody chose
	// is the failure this field exists to prevent — so an empty one is a page
	// that does not listen, the same as a missing password, and [Page.Unarmed]
	// says which.
	//
	// Serving it is [Page.Handler] and the application's own server; this
	// package opens no listeners.
	Addr string

	// BasePath is where the page is mounted on that listener. Empty means
	// [DefaultMonitorPath]. It must be absolute and must not end in a slash.
	//
	// Nothing else is on the listener to collide with. It is kept because it is
	// the URL projects already have, and because a reverse proxy in front of
	// [PageConfig.Addr] needs a prefix to key on.
	BasePath string

	// MaxTraces is how many requests the page lists, newest first. Zero means
	// [DefaultMaxTraces].
	MaxTraces int

	// MaxLogs is how many log lines the page reads, newest first. Zero means
	// [DefaultMaxLogs].
	MaxLogs int

	// Logs is the log file this process is writing, and nil is a page with no
	// log half — which is what a project that never wired one gets, and it says
	// so rather than showing an empty list.
	//
	// It is the sink itself and not a path, for the reason the span file is not
	// a field here either: the page and the writer agreeing on a file should not
	// be something a main arranges twice. Open it with [OpenLogs], tee
	// [Logs.Handler] into the logger the application already has, and set the
	// same object here.
	//
	// It is not in rig.yaml and cannot be, because it is an object rather than a
	// value. A generated Monitoring() therefore leaves it empty — but a
	// generated NewProcess() fills it, because that constructor is where the
	// sink is opened. A main that builds its own page is the caller that still
	// sets this by hand.
	Logs *Logs

	// Password is a literal from rig.yaml, for a project that wants the page
	// working without an environment to set. Empty — the ordinary case — falls
	// back to PasswordEnv.
	Password string

	// PasswordEnv names the variable the password is read from. Empty means
	// [PasswordEnv].
	PasswordEnv string

	// Allow is the addresses that may reach the page, as CIDR ranges or single
	// addresses — "10.0.0.0/8", "127.0.0.1", "::1". Empty, the default, allows
	// any address and leaves the password as the only check.
	//
	// It narrows; it does not replace. An address that is not on the list is
	// answered 404 before the password is looked at, so a scan learns nothing
	// and a leaked password is still not enough on its own.
	//
	// It is matched against the connection's own address and never against a
	// forwarded header, for the reason auth.trusted_proxies exists: an address
	// read from a header a client controls is an address a client chooses, and
	// an allowlist keyed on one is an allowlist anybody walks around.
	//
	// The cost of that choice is worth knowing before you rely on this: behind
	// a load balancer every request arrives from the balancer, so the list
	// matches everything or nothing and is no boundary at all. There,
	// [PageConfig.Addr] is the boundary — bind the page somewhere the balancer
	// is not — and this list is what narrows the addresses on that network.
	Allow []string

	// Checks are the dependencies the page shows the state of, beside the
	// requests and the logs.
	//
	// The page holds the probe rather than a state somebody pushed to it, which
	// is what makes it answer the question actually being asked — "is the
	// database there now" — rather than "was it there when something last
	// happened to touch it". A dependency that comes back is visible on the next
	// poll without any traffic having to discover it first.
	//
	// It is not in rig.yaml and cannot be: a probe is a function. A generated
	// Monitoring() therefore leaves this empty, and the wiring that has the pool
	// and the sync proxy calls [Page.Watch].
	Checks []Check

	// CheckTimeout bounds one round of Checks, all of them together. Zero means
	// [DefaultCheckTimeout].
	CheckTimeout time.Duration
}

PageConfig is what the monitoring page needs that the generated Monitoring() can know.

Where the spans are is deliberately not in it: Provider.Page fills that in from what Setup resolved, so the page reads the file this process writes rather than a second path somebody has to keep in step.

type Provider

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

Provider is what Setup installed, kept only so that it can be shut down.

Hand Provider.Shutdown to serve.App.CloseWithin with a limit of its own. The flush at exit is the one part of tracing that can hold a process open, and a limit there is the difference between a slow deploy and a stuck one.

func Setup

func Setup(ctx context.Context, cfg Config) (*Provider, error)

Setup installs a tracer provider and the W3C propagator, and returns what to shut down.

It is safe to call before anything else is constructed: a tracer taken from the global provider before this runs — which is what Tracer returns — starts delegating to the real one as soon as this installs it. So the order of the lines in a main function is not something anybody has to get right.

func (*Provider) Page

func (p *Provider) Page(cfg PageConfig) (*Page, error)

Page builds the monitoring page over the span file this provider is writing.

It returns an error only for a configuration that is wrong — a base path that is not one, a password too short to be worth having. Having no password, or no address to listen on, is not wrong: it is how a project that generated the page decides not to serve it in this environment, and Page.Handler is then nil and Page.Addr empty. Page.Unarmed says which, in one line a main can log.

page, err := tracing.Page(api.Monitoring())
if err != nil {
    return nil, err
}
if why := page.Unarmed(); why != "" {
    app.Logger.Info("monitoring page not listening", "reason", why)
}

Then hand both halves to whatever runs the servers:

serve.Config{Monitor: page.Handler(), MonitorAddr: page.Addr()}

func (*Provider) Shutdown

func (p *Provider) Shutdown(ctx context.Context) error

Shutdown flushes what has not been exported and stops the exporters.

Nil-safe, so a main that failed to set tracing up can defer this without a branch first.

type Span

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

Span is one request's span, and the status it has not answered with yet.

It exists so that a generated handler is two lines — start it, defer the end — and so that the status still reaches the span without a second thing to remember at the bottom of a function that has a dozen ways out.

func Server

func Server(r *http.Request, route string, status func() int) (*http.Request, *Span)

Server starts the span for one request, under whatever trace the caller arrived carrying.

The route is the matched pattern, not the path: "GET /api/v1/todos/{id}" rather than one span name per identifier anybody ever fetched. That is why the caller is the router a generated server registers its routes through rather than a middleware in front of the mux — the pattern is in hand where a route is registered, and net/http fills it in on a request only as it dispatches, so a wrapper outside has one that has matched nothing.

status is how the span learns what was answered, and it is a function rather than the response writer so that this package does not depend on rig/runtime. The generated server passes the Status method of the recorder that router wraps every response in. Nil is allowed and means the attribute is left off.

The returned request carries the span. Everything after this — the claims, the hooks, the service, the SQL — is inside it because it takes that context.

func (*Span) End

func (s *Span) End()

End records what was answered and closes the span.

Deferred, always, and the only place a request's span ends. A handler that ended one on the way out of each branch would eventually grow a branch that forgot.

A handler that wrote no header answered 200: that is net/http's rule, and a span claiming a status of zero would be reporting the recorder's zero value as the client's answer.

type SpanRecord

type SpanRecord struct {
	// Time is when the span ended, which is when the line was written and so
	// the order the file is in.
	Time time.Time `json:"time"`

	TraceID string `json:"trace_id"`
	SpanID  string `json:"span_id"`
	// ParentID is empty for the root of a trace — the request span, usually.
	ParentID string `json:"parent_id,omitempty"`

	Name string `json:"name"`
	Kind string `json:"kind"`

	Start time.Time `json:"start"`
	// DurationMS is milliseconds, as a float, because the interesting spans
	// here are shorter than one and an integer would round most of them to
	// zero.
	DurationMS float64 `json:"duration_ms"`

	// Status is "error" or absent. A span that succeeded says nothing, which is
	// what makes `grep '"status":"error"'` the first thing to try.
	Status string `json:"status,omitempty"`
	// Error is the status description — for rig's spans, the error that caused
	// the failure.
	Error string `json:"error,omitempty"`

	Service    string         `json:"service,omitempty"`
	Attributes map[string]any `json:"attributes,omitempty"`
}

SpanRecord is one line of the span file: a finished span, as JSON.

The format is rig's own rather than OTLP's JSON encoding, and the reason is what it is for. A collector is not reading this — a person is, with grep, or rig's own monitoring page. So it is flat, every identifier is a hex string that can be pasted into a search, and the fields somebody actually scans for are at the front of the line.

It is exported because reading the file is a thing to do: decode a line into this and every field is named.

func ReadSpans

func ReadSpans(path string, max int) ([]SpanRecord, error)

ReadSpans reads the last max records of a span file, oldest first, counting the rotated generation beside it — see [tailLines] for what bounds that.

A line that does not parse is skipped. The last line of a file a process was killed while writing is a partial JSON object, and a page that refused to render because of it would fail in exactly the case somebody opened it for.

A missing file returns no records and no error.

type TraceRecord

type TraceRecord struct {
	// ID is the trace id every span in it carries — the same string the
	// generated server puts in an error body as requestId, so an identifier
	// from somebody's screenshot is a search on this field.
	ID string `json:"id"`

	// Root is the span with no parent, usually the request. It is nil for a
	// trace whose beginning has been rotated away, or one still in flight when
	// the file was read; the rest of the trace is still worth showing, which is
	// why this is a pointer and not an assumption.
	Root *SpanRecord `json:"root,omitempty"`

	// Spans is every record in the trace, in start order. Nesting is not a
	// field: each record carries [SpanRecord.ParentID] and rebuilding the tree
	// from that is the reader's business, which for rig is the page.
	Spans []SpanRecord `json:"spans"`

	// Start is the earliest start in the trace and End the latest end, so the
	// duration covers the whole of it even when the root is missing.
	Start time.Time `json:"start"`
	End   time.Time `json:"end"`
	// DurationMS is End minus Start, in milliseconds. Not the root's own
	// duration: a trace missing its root would otherwise be zero.
	DurationMS float64 `json:"duration_ms"`

	// Status is "error" when any span in the trace failed, and absent
	// otherwise — the same convention [SpanRecord.Status] uses, so the page
	// filters both with one test.
	Status string `json:"status,omitempty"`
	// Name is the root's name, which for a request is its route. Empty when
	// there is no root.
	Name string `json:"name,omitempty"`
	// Service is who wrote the trace, from the first span that says.
	Service string `json:"service,omitempty"`
}

Trace is one request: every span that shared a trace id, and what the whole of it amounts to.

It is what the monitoring page lists and what a script reading the span file most likely wants, which is why the grouping is here rather than in the page.

func GroupTraces

func GroupTraces(recs []SpanRecord) []TraceRecord

GroupTraces groups records by trace id, newest first.

Newest first because that is the order anybody looking at a monitoring page wants: what just happened is at the top. Within a trace the spans are in start order, which is the order they nest in.

func ReadTraces

func ReadTraces(path string, maxTraces int) ([]TraceRecord, error)

ReadTraces reads the newest traces from a span file, newest first.

This is ReadSpans and GroupTraces together, and it is what the monitoring page calls. A missing file is not an error: a server that has not written a span yet, or one running with no RIG_TRACE_FILE, is the ordinary case and answers as no traces rather than as a failure.

Jump to

Keyboard shortcuts

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