apibase

package
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package apibase is the half of a generated API layer that is the same in every project.

A generated `api` package used to carry all of this: the request envelope, the request context, the front half of every handler, the error funnel, the log lines and the decoders. None of it is derived from anybody's schema — what varies is one field on a struct and three lines behind a configuration block — so it was the same eight hundred lines copied into every repository that ever ran `rig generate`.

It is here so that it can be imported rather than copied, and so that rig's own modules can mount routes on the same terms: a shipped handler in rig/notify needs Prepare, Fail and RequestContext exactly as much as a generated one does, and there was nowhere for it to get them.

Nothing here knows about tracing, files, notifications or authentication. Each of those reaches this package as a field on Server whose type is declared here and satisfied elsewhere — the trick Authenticator has always used — which is what keeps a project that asked for none of them from naming any of them in its go.mod.

Index

Examples

Constants

View Source
const DefaultRequestIDHeader = "X-Request-Id"

DefaultRequestIDHeader is where a caller may name its own request, so that its logs and this API's can be lined up afterwards.

It is read on every route, including the authentication ones. What is done with it is Server.RequestID's documentation.

View Source
const DefaultRevisionHeader = "API-Revision"

DefaultRevisionHeader carries the API revision in both directions: what the caller was built against on the way in, what this server was generated from on the way out. It is what `api.revision_header` defaults to.

View Source
const MaxBodyBytes = 1 << 20

MaxBodyBytes bounds a request body. Without a limit, one client can exhaust the server's memory by streaming forever.

Variables

This section is empty.

Functions

func Caller

func Caller(claims tenancy.Claims) *tenancy.Claims

Caller is the claims a read hook is handed, or nil when the request carried none.

Only a public endpoint can reach a hook with none: everything else is refused before a handler runs, and every write is refused again by the repository. That is why the write hooks take a value and these take a pointer.

func CallerRequestID

func CallerRequestID(r *http.Request, header string) string

CallerRequestID is the identifier the caller asked this request to be known by, or empty when it did not ask or asked for something not worth repeating.

Refusing rather than truncating or escaping, because a header this API does not understand is not one it should half-quote into a log line. What a Caller gets for sending nonsense is the identifier it would have got for sending nothing.

func DecodeBody

func DecodeBody(r *http.Request, into any) error

DecodeBody reads a JSON request body.

Unknown fields are rejected. A client that misspells a field name is asking for something it will not get, and telling it so beats silently ignoring half the request.

func DecodeReader

func DecodeReader(r io.Reader, into any) error

DecodeReader is the decode itself, separated from the request so that a body arriving as one part of a form goes through exactly the same one.

That sharing is the point rather than a tidiness: a multipart create and a JSON create have to refuse the same keys and produce the same field errors, and two decoders would eventually differ about one of them.

func Fail

func Fail(s Server, w http.ResponseWriter, r *http.Request, rc RequestContext, err error)

Fail writes an error response.

OnError is the answer when it is set, and a generated Register sets it to that project's own error mapper — which is where `naming.json_case` is honoured, and the reason this package cannot write that body itself. What is left here is the answer rig's own routes give: the same envelope, camelCase, carrying the request identifier so a failure from a shipped route lands in the log beside every other route's.

func IdempotencyPruner

func IdempotencyPruner(retention time.Duration) serve.Task

IdempotencyPruner deletes the records of writes past their retention, and so decides how long after a write its Idempotency-Key still replays.

Zero takes idempotency.DefaultRetention, a day: long enough to cover any retry, short enough that a key reused a week later is a new request rather than a write that silently does nothing.

A task rather than a goroutine, for the reason FileSweeper is one — a cron job is one thing running, and a goroutine in every replica is as many as there are replicas, all racing to delete the same rows. Register it in serve.Config.Tasks and run `<binary> prune-idempotency`:

Tasks: map[string]serve.Task{"prune-idempotency": api.IdempotencyPruner(0)},

Nothing schedules it for you. Without it rig_idempotency keeps every record ever written, and the retention above is a sentence rather than a behaviour.

func LevelFor added in v0.6.0

func LevelFor(code rigerr.Code) slog.Level

LevelFor is the level rig writes a failure at, given the code it carries.

Exported so that a project writing its own OnError can match rig rather than guess at it, and so that Server.LogLevel has something to fall back to and to defer to for the codes it does not care about.

The split is by whether anybody can act on the line, not by status class:

  • A refusal the server meant — 401, 403, 404, 409, 422, 426, 429 — is debug. The server did its job. These are the ones rig produces structurally, one per expired session and one per page load before sign-in, and a warning stream made of them is one people learn to skim.
  • A caller that was built wrong — 400, 413, 415 — is a warning. Nobody is at a keyboard causing these: they are a client that shipped broken, they are rare, and each one is somebody's deploy.
  • 503 is a warning. Something the server waited on did not answer, which is latency or capacity rather than a broken path.
  • 500 is an error, and anything this function has never heard of is levelled by its status, so a project's own Coder lands where the answer it produces says it should.

func LogFailure

func LogFailure(s Server, r *http.Request, rc RequestContext, err error)

LogFailure records why this request is about to fail.

It is the only place that ever sees the whole error. DefaultErrorMapper rewrites an internal message to "something went wrong" before it reaches the client — deliberately, because the original is exactly the kind of thing that leaks a table name or a connection string — so without this line the cause of a 500 exists nowhere. The request identifier goes out in the body and comes out here, and that pair is the whole mechanism for answering "what happened to my request".

The level is chosen from the error, because these are not all the same event. LevelFor is the table and the argument for it. The message splits with it, so the four cases are greppable without anybody having to filter on a level.

func LogHandler added in v0.3.0

func LogHandler(h slog.Handler) slog.Handler

LogHandler returns h with the request on every record written inside one.

It is the whole of how a service's own lines come to carry the identifier in the error body, the route that matched and the method, without a call site saying so:

logger := apibase.RequestLogger(app.Logger)
...
s.logger.InfoContext(ctx, "importing", slog.Int("rows", len(items)))
// {"msg":"importing","rows":12,"request":{"request_id":"…","route":"…"}}

The request comes off the context, which is the only reason the context is worth having here — and the reason RequestContextFrom and sloglint's `context: all` are two halves of one mechanism. A call that drops the context drops the request with it.

A record written outside a request gains nothing. A migration, a subcommand and a background job all find no request context, and a line that says it belongs to one would be worse than a line that says nothing.

A record that already carries a "request" attribute is left alone, so a service that puts the group on the line itself — which is what rig's own two lines do, and what its documentation used to ask for — gets one group rather than two.

Wrapping twice is wrapping once: the generated Mount wraps the logger the application was handed, and an application that wrapped it first is not punished for it.

One thing worth knowing: an attribute a handler adds at Handle time lands inside whatever group is open, so a logger derived with slog.Logger.WithGroup("db") writes the request at db.request. That is ordinary slog semantics for a decorating handler rather than something this can undo.

Example

ExampleLogHandler is what a service's own line looks like once the logger it was handed has been through here. Nothing in the call site names the request.

package main

import (
	"context"
	"log/slog"
	"net/http"
	"os"

	"github.com/simonjanss/rig/runtime/apibase"
)

func main() {
	// The time is dropped so this example has one output; a real handler keeps it.
	h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	})
	logger := slog.New(apibase.LogHandler(h))

	// The server puts this on the context before any service is called.
	ctx := apibase.NewContext(context.Background(), apibase.RequestContext{
		RequestID: "01930f3c-6f7a-7b8c-9d0e-1f2a3b4c5d6e",
		Method:    http.MethodPost,
		Route:     "POST /api/v1/todos",
	})

	logger.InfoContext(ctx, "importing", slog.Int("rows", 12))

}
Output:
{"level":"INFO","msg":"importing","rows":12,"request":{"request_id":"01930f3c-6f7a-7b8c-9d0e-1f2a3b4c5d6e","method":"POST","route":"POST /api/v1/todos"}}

func LogRequest

func LogRequest(s Server, r *http.Request, rec *reqlog.Writer, rc RequestContext)

LogRequest writes the request line, once every handler has finished.

Deferred from the handler rather than wrapped around the mux, because the route is only known inside: net/http sets the matched pattern on the request the mux dispatches, and a middleware in front of it has an earlier request that has matched nothing. A line labelled by path instead would be one line per identifier that ever appeared in a URL.

func NewContext

func NewContext(ctx context.Context, rc RequestContext) context.Context

NewContext returns a context carrying the request's metadata.

The server calls it on every request, before the Server.Context hook runs, so anything that hook adds can already see it. It is exported for the same reason NewRequest is: a test that calls a service method directly still has to put one there, or the hooks underneath will find nothing.

func ReadScope

func ReadScope(s tenancy.Scope) []readopt.Option

ReadScope turns a requested scope into the read options that produce it.

Only "all" does anything. Every other value — including a zero value from a caller that never set the field — leaves the narrow default in place, so the failure mode of forgetting this is too few rows rather than too many.

func RequestLogger added in v0.3.0

func RequestLogger(l *slog.Logger) *slog.Logger

RequestLogger is l with LogHandler over its handler, and the default logger when l is nil.

Nil is a caller that configured no logging rather than one that asked for silence — the same reading Server.Logger gives it, and for the same reason.

This is what the generated Mount applies to serve.App.Logger, so that everything the application builds out of it — its services, its repositories, the authentication configuration — is request-aware without being told.

func Tracing added in v0.6.0

func Tracing(r httpx.Router, t Tracer) httpx.Router

Tracing returns a router that opens a span for every route registered through it, named by the pattern it was registered under.

A generated handler used to open its own span, which meant the trace stopped where the generator stopped: the authentication routes, the inbox, presence, the live-sync shapes and the OpenAPI document are mounted by packages a generator never sees, and none of them had one. Registering all of them through this is what makes the answer the same for every route, and for whatever is mounted next.

A nil Tracer returns r itself. That is the ordinary case — a project that never set `tracing:` — and it costs nothing, not a wrapper and not a branch per request.

This rather than a handler around the mux, which is the shape that first suggests itself: a span is named by the route and not by the path, and the only way to learn the route from outside is to match the request a second time. Here the pattern is already in hand, because registering it is what this is. It also leaves the mux alone — a caller that mounts its own catch-all on what Register returns is not competing with a wrapper for it.

func WriteJSON

func WriteJSON(w http.ResponseWriter, status int, body any)

WriteJSON writes one JSON response: the content type, the status, and the body when there is one.

A nil body writes the status and stops, which is what a 204 is. The encoder runs after WriteHeader, so an encoding failure cannot change the status already sent — it truncates the body instead, and the request line that follows is where that shows up.

func WriteResult

func WriteResult(w http.ResponseWriter, res idempotency.Result)

WriteResult answers with what a write produced, or with what it produced the first time somebody sent it.

The bytes are written rather than re-encoded, because a replay that round-tripped through a decoder would come back with its keys in whatever order that decoder chose — and a client that hashes or signs what it received would see two different answers to one request.

Types

type Authenticator

type Authenticator interface {
	// Claims identifies the caller behind a request.
	Claims(*http.Request) (tenancy.Claims, error)
	// Mount registers the authentication routes — signing in, refreshing, keys
	// — through the same router the resource routes went through, so they land
	// on one mux and are traced on the same terms.
	Mount(httpx.Router)
}

Authenticator identifies a caller and serves its own routes.

It is declared here rather than imported, and that is the whole trick: github.com/simonjanss/rig/auth.Auth satisfies it without either side knowing the other exists. A field typed as the auth package's own would make every project depend on it — argon2, OAuth and all — including the ones with no authentication at all.

Anything else with these two methods works just as well: a wrapper that adds a header check, a stub in a test, an entirely different implementation.

type Request

type Request[Path, Query, Body any] struct {
	// Claims describe the caller. They are always present: a handler does
	// not run without them.
	Claims tenancy.Claims

	Path  Path
	Query Query
	Body  Body
	// contains filtered or unexported fields
}

Request is everything a handler received.

Each part is a separate type parameter, and an operation that has no path parameters uses struct{} for that slot. That is deliberate: the signature says what an endpoint takes, so reaching for something it does not have is a compile error rather than a nil check.

func NewRequest

func NewRequest[Path, Query, Body any](claims tenancy.Claims, path Path, query Query, body Body, rc RequestContext) Request[Path, Query, Body]

NewRequest builds a request. The server calls it; it is exported so a test can call a service method directly without going through HTTP.

func (Request[Path, Query, Body]) BuiltBefore

func (r Request[Path, Query, Body]) BuiltBefore(rev apirev.Revision) bool

BuiltBefore reports whether the client that sent this request was built before rev.

It is RequestContext.BuiltBefore with the request already in hand, which is where a compatibility shim usually is:

var notesAdded = apirev.MustParse("2026-04-30")

if r.BuiltBefore(notesAdded) {
	r.Body.Title = "Unknown"
}

A shim belongs here rather than in a hook. This is the one place that has the request as it arrived, and on a create the generated validation runs after the service method and before every hook — so a hook that filled in a missing NOT NULL column would only ever see requests that already passed the check it was written for.

func (Request[Path, Query, Body]) Context

func (r Request[Path, Query, Body]) Context() RequestContext

Context returns what is known about the request itself, as opposed to what it carries.

type RequestContext

type RequestContext struct {
	// RequestID correlates this request with the server's logs. Always set on a
	// request the server served — see [RequestContextOf] for where it comes from
	// — and empty only on a context somebody built by hand.
	RequestID string
	Method    string
	Path      string
	// Route is the pattern that matched, so a metric can be labelled by
	// endpoint rather than by every distinct identifier.
	Route      string
	RemoteAddr string
	UserAgent  string
	// ClientRevision is the raw header, kept for a log line that wants to
	// count what callers are sending. Empty when the caller said nothing, and
	// unparseable prose when it said something that is not a revision — a
	// hand-rolled client and a curl will not say, and that is a normal thing
	// for a caller to be. Compare with [RequestContext.BuiltBefore] rather
	// than with this.
	ClientRevision string

	// ServerRevision is what this server was generated from, so that
	// [RequestContext.Stale] has both sides of the comparison in hand.
	//
	// A field rather than a package-level value, because this package serves
	// every project rather than one. [RequestContextOf] fills it from
	// [Server.Revision] on every request; a context built by hand in a test has
	// it unset, and an unset one is unknown — so Stale answers false rather than
	// inventing a distance.
	ServerRevision apirev.Revision
}

RequestContext is the request's own metadata.

func Prepare

Prepare runs the shared front half of every handler: the hooks, the claims, and the request metadata.

func PreparePublic

PreparePublic is Prepare for an endpoint the configuration marked public.

The claims lookup still runs, and a caller who presents a credential is still identified by it — an application that resolves a tenant from the host rather than from a token gets one either way. What changes is that a caller who presents nothing is served instead of refused.

func RequestContextFrom

func RequestContextFrom(ctx context.Context) (RequestContext, bool)

RequestContextFrom returns the request metadata on a context.

This is how a validator or a hook reaches what only the service method is handed — the revision the caller was built against, the request identifier, the route that matched.

ok is false rather than an error, and the zero value is usable: work that did not come from a request at all — a migration, a background job — has no metadata to find, and that is not a failure.

func RequestContextOf

func RequestContextOf(s Server, r *http.Request) RequestContext

RequestContextOf is what one request looks like to an error body and to a log line.

A function rather than a literal per call site, because a route that builds a blank one still answers correctly and still logs a failure — it just logs one that names no method, no path and no request identifier, which is the one thing the line exists for.

What comes back always has a RequestContext.RequestID. The three ways it gets one are in order below; the last of them invents one, so an untraced project answering a caller that named nothing still has a string to correlate on.

func Resolve

Resolve is the body of both, differing only in whether a caller who cannot be identified is refused.

func (RequestContext) BuiltBefore

func (rc RequestContext) BuiltBefore(rev apirev.Revision) bool

BuiltBefore reports whether the caller was built before rev, which is the question a compatibility shim is written in terms of.

False for a caller that sent no revision. That is a decision rather than a fallback: revisions describe what rig's own generated clients were built against, so a caller rig cannot place is served the current behavior. An application that would rather treat an unknown caller as ancient has RequestContext.ClientRevision and can say so itself.

func (RequestContext) Client

func (rc RequestContext) Client() apirev.Revision

Client is what the caller was built against.

Unknown — the zero apirev.Revision — when the caller said nothing and equally when what it said is not a revision. The two are the same answer on purpose: both mean this caller cannot be placed on the timeline.

func (RequestContext) LogValue

func (rc RequestContext) LogValue() slog.Value

LogValue is how a request appears in a log line.

A group, so one attribute carries the lot:

logger.ErrorContext(ctx, "that failed", slog.Any("request", rc))

Only the fields that have something in them. An empty attribute on every line is the same width in a terminal and a key in a structured backend, and it says a field was collected when it was not — a context built by hand for work that came from no request at all, a migration or a background job, logs as a smaller group rather than as a row of empty strings. One that came from RequestContextOf always has a request_id.

func (RequestContext) Stale

func (rc RequestContext) Stale() (time.Duration, bool)

Stale reports how far behind this server's revision the caller is.

ok is false when the caller did not say, said something unparseable, or is not behind at all — including the case of a caller newer than this server, which is somebody halfway through a deploy rather than somebody to warn about.

type Server

type Server struct {
	// Auth wires the whole authentication foundation in one field.
	//
	// Setting it does two things that otherwise have to be remembered separately:
	// GetClaims comes from it, and its routes are mounted on the mux Register
	// returns. Wiring the claims from one thing and mounting another is a mistake
	// this makes unavailable.
	//
	// Nil for a project with no authentication, which then has to supply GetClaims
	// itself.
	Auth Authenticator

	// GetClaims establishes who is calling. A handler does not run without it, so
	// a route cannot accidentally be left unauthenticated.
	//
	// Auth supplies it. Set this instead for a project that authenticates its own
	// way, or has no auth routes to mount.
	GetClaims func(*http.Request) (tenancy.Claims, error)

	// RequestID labels a request for the logs and for the error body it may end
	// in.
	//
	// Nil is the ordinary case and does not mean nothing: it means
	// [CallerRequestID] — the caller's own [Server.RequestIDHeader], if it sent one worth
	// trusting — then this request's trace, and a fresh identifier when there is
	// neither. Set this only to answer the question differently; the default is
	// already the answer every route in this package gives, including the
	// authentication ones.
	//
	// A hook replaces only the first of those three. One that returns "" for some
	// requests leaves them to the trace and then to a fresh identifier, exactly
	// as a caller that sent no header is left — so "every request has an
	// identifier" is true rather than usually true, and a traced project whose
	// hook declines a request still labels it with that request's trace. A hook
	// is how the answer is chosen, not how it is opted out of.
	RequestID func(*http.Request) string

	// Logger is where this server says what it did and why a request failed. Nil
	// uses [log/slog.Default].
	//
	// Nil means the default logger and not silence, which is the whole point: the
	// one line worth having is the cause of a 500, and a server that drops it
	// because nobody configured logging is a server whose error bodies say
	// "something went wrong" and mean it literally. Set this to route the lines
	// somewhere, not to turn them on.
	//
	// What comes out: an error line per failed request carrying the whole error,
	// and, at [log/slog.LevelDebug], a line per request with its status and size.
	//
	// There is still no logger on the context and no way to reach this one from a
	// service: a service is constructed by the application and handed a logger
	// there. What it is handed is already request-aware, though. The generated
	// Mount wraps [github.com/simonjanss/rig/runtime/serve.App.Logger] in
	// [LogHandler] before the application's own wiring sees it, so every line a
	// service writes inside a request carries the same request group these two
	// lines do, without the call site saying so.
	//
	// This field is not wrapped, because it is a value the application set.
	// [LogFailure] and [LogRequest] put the request on their lines themselves,
	// so they read the same whoever configured the logger. [RequestContextFrom]
	// is still how anything reaches the fields rather than the group.
	Logger *slog.Logger

	// MinRevision refuses a caller built against an API revision older than this
	// one. The zero value, the default, refuses nothing.
	//
	//	MinRevision: apirev.MustParse("2026-04-30"),
	//
	// This is the end of the story [Revision] starts: you removed a field, you
	// waited, the logs said nobody old was left, and now you close the door. Until
	// somebody decides to, the revision is telemetry and nothing else.
	//
	// Only a caller that sends a revision older than this is refused. One that
	// sends none is served — an unknown client is not the same as an old one,
	// and turning every curl and every hand-written integration into a 426 is not
	// a door closing, it is an outage.
	MinRevision apirev.Revision

	// OnError turns a service error into a response. Nil uses DefaultErrorMapper,
	// which is the right behavior for almost everyone.
	OnError func(w http.ResponseWriter, r *http.Request, rc RequestContext, err error)

	// LogLevel chooses the level a failure is written at. Nil is [LevelFor],
	// which is rig's own table and what almost every project wants.
	//
	// It is the only field here that changes what the log says rather than what
	// the client gets, and it is deliberately narrow. It cannot turn a line off:
	// the lowest level it can return is still a level, and the one line that says
	// why a 500 happened is the reason this package writes anything at all. What
	// it is for is a project that knows something rig cannot — that a particular
	// route should never 404, so a 404 there is worth a warning, or that its
	// public API takes enough hand-written requests that a 400 is noise.
	//
	// It is asked about the code, not the error, so it cannot see the two rows of
	// the table that have no code. A request whose caller went away is debug and a
	// timeout is [rigerr.CodeUnavailable] before this is consulted.
	LogLevel func(code rigerr.Code) slog.Level

	// PreHooks run before anything else, in order. A hook that writes a response
	// stops the request.
	PreHooks []func(w http.ResponseWriter, r *http.Request) bool

	// Context lets a hook attach values a service will read.
	Context func(ctx context.Context, r *http.Request) context.Context

	// DB is what a write carrying an Idempotency-Key is recorded in, so that a
	// client which had to send the same write twice gets one row and the same
	// answer both times. Pass the pool: `DB: app.Pool`.
	//
	// Required, and Register panics without it. A nil one would mean the header
	// was quietly ignored, and a client that thinks its retry is safe when it is
	// not is worse off than one that knows it is not — this is the one failure
	// mode worth a startup panic rather than a runtime surprise.
	//
	// It costs nothing until a caller sends the header: a write without one takes
	// the path it always took, with no transaction and no extra round trip. See
	// [github.com/simonjanss/rig/runtime/idempotency].
	DB dbx.Beginner

	// Revision is what this server's API surface was generated from, announced
	// on the way out of every response and compared against MinRevision on the
	// way in. The generated Register fills it in; the zero value is a project
	// that has never generated with a revision recorded, and it announces
	// nothing rather than announcing a wrong answer.
	Revision apirev.Revision

	// RevisionHeader is the header the revision travels in, both ways. Empty
	// means [DefaultRevisionHeader], which is what `api.revision_header` leaves
	// it at.
	RevisionHeader string

	// RequestIDHeader is the header a caller may name its own request in. Empty
	// means [DefaultRequestIDHeader], which is what `request_id_header` leaves it
	// at.
	RequestIDHeader string

	// Tracer is where this API's spans come from. Nil is a project that does not
	// trace, and then none of its methods is called.
	//
	// [Tracing] is what opens the request span from it, and a generated Register
	// registers every route through that — so setting this by hand on a server
	// built by hand traces nothing on its own. What it does reach either way is
	// the request identifier, which falls back to the trace, and [LogFailure],
	// which reddens the span a refusal happened in.
	Tracer Tracer

	// Throttle is the rate limiter, or nil for a project with no `throttle:`
	// block. A nil Gate allows, so this costs one nil check rather than a
	// limiter nobody configured.
	Throttle *throttle.Gate

	// TrustedProxies are the networks whose X-Forwarded-For this server
	// believes, for the one case Throttle has to key on an address: an
	// unidentified caller. Empty trusts nothing, which is the safe default — an
	// address read from a header the client controls is an address the client
	// chooses, and a limit keyed on one of those is decorative.
	TrustedProxies []netip.Prefix
}

Server is the behavior every handler shares.

type Tracer

type Tracer interface {
	// Server opens the span for one request and returns the request carrying it,
	// along with the function that ends it.
	//
	// The route is the matched pattern rather than the path, so a trace has one
	// span name per endpoint instead of one per identifier anybody ever fetched.
	// status is how the span learns what was answered.
	Server(r *http.Request, route string, status func() int) (*http.Request, func())

	// TraceID names the trace this request belongs to, or empty when there is
	// none. It is what a request that sent no identifier of its own is labelled
	// with, so that the one in an error body and the trace in a collector are one
	// string.
	TraceID(r *http.Request) string

	// Fail records why a request is being refused, on whatever span the context
	// is in. It ends nothing: the span belongs to the handler.
	//
	// Not called at all when the caller went away. Nothing failed, so there is
	// nothing to put on the span — see [LogFailure], which is the only thing that
	// calls this.
	Fail(ctx context.Context, status int, err error)
}

Tracer is where this API's spans come from.

It is declared here rather than imported, and that is the whole trick: github.com/simonjanss/rig/observe.APITracer satisfies it without either side knowing the other exists. A field typed as the observe package's own would put OpenTelemetry in the go.mod of every project that never set `tracing:`.

Every method takes and returns nothing but standard-library types, and that is not incidental: two interfaces are interchangeable only when their method signatures are identical, so a named type from either side would make these two different interfaces that merely look alike.

Jump to

Keyboard shortcuts

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