Documentation
¶
Overview ¶
Package gospan is embedded, span-based tracing for Go programs.
Instrument work with named spans (Start/End, or one-line Track); span context rides context.Context; events flow through a bounded buffer to a single writer goroutine and on to a pluggable destination (Sink) — the SQLite trace file (the gospan/sqlite module) or your existing slog flow. After construction, nothing gospan does returns an error, panics into the caller, or blocks the hot path beyond a channel send. Every method on a nil *Tracer or nil *Span is a no-op: tracing disabled is a tracer never constructed.
The conceptual model lives in DESIGN.md; the API, semantics, and file-format contract in SPEC.md.
Example ¶
The quickstart: pick a destination, construct once, set as default, instrument with one-liners everywhere else.
package main
import (
"context"
"log/slog"
"github.com/akmadian/gospan"
)
func main() {
tracer, err := gospan.New(gospan.SlogSink(slog.Default()))
if err != nil {
// Construction is the only moment gospan can fail.
panic(err)
}
defer tracer.Close(context.Background())
gospan.SetDefault(tracer)
ctx, span := gospan.Start(context.Background(), "process-asset",
slog.String("asset", "a.raw"))
defer span.End()
resizeImage(ctx)
}
func resizeImage(ctx context.Context) {
defer gospan.Track(ctx, "resize-image")()
decodeAndScalePixels()
}
func decodeAndScalePixels() {}
Output:
Index ¶
- func SetDefault(tracer *Tracer)
- func Track(ctx context.Context, name string, attrs ...slog.Attr) func()
- type Batch
- type Event
- type EventKind
- type Option
- type Sink
- type Span
- type SpanStatus
- type SpanSummary
- type Stats
- type Tracer
- func (tracer *Tracer) Close(ctx context.Context) error
- func (tracer *Tracer) Start(parent context.Context, name string, attrs ...slog.Attr) (ctx context.Context, span *Span)
- func (tracer *Tracer) Stats() Stats
- func (tracer *Tracer) Summary() map[string]SpanSummary
- func (tracer *Tracer) Track(ctx context.Context, name string, attrs ...slog.Attr) func()
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func SetDefault ¶
func SetDefault(tracer *Tracer)
SetDefault makes tracer the one used by the package-level Start and Track — the slog.SetDefault pattern.
Types ¶
type Batch ¶
type Batch struct {
Events []Event
}
Batch is the unit of delivery to a Sink: one or more events in the order they occurred. It is a struct argument so fields can be added without breaking implementers.
type Event ¶
type Event struct {
Kind EventKind
SpanID int64 // tracer-minted, monotonic, unique within the run
TraceID int64 // the root span's SpanID
ParentID int64 // 0 = trace root
Name string // set on EventStart and EventEnd
StartNS int64 // unix nanoseconds; set on EventStart and EventEnd
EndNS int64 // unix nanoseconds; set on EventEnd
Status SpanStatus // set on EventEnd
Error string // set on EventEnd when Fail recorded an error
Attrs []slog.Attr // set on EventStart and EventAttrs
}
Event is one span lifecycle occurrence, delivered to sinks in the order it happened. Which fields are meaningful depends on Kind; see the EventKind constants. Fields may be added compatibly in future versions.
type EventKind ¶
type EventKind int
EventKind discriminates the three span lifecycle events a Sink receives.
const ( // EventStart records that a span began. SpanID, TraceID, ParentID, // Name, StartNS, and any attributes passed to Start are set. EventStart EventKind = iota // EventAttrs carries attributes recorded mid-flight via SetAttrs. // SpanID and TraceID identify the span; Attrs holds only the new // values. Last write per key wins. EventAttrs // EventEnd records that a span finished. EndNS, Status, and Error are // set. Name and StartNS repeat the start event's values so a sink can // write a complete span even when the start event was dropped. EventEnd )
type Option ¶
type Option func(*config)
Option configures a Tracer at construction.
func WithBlockingPolicy ¶
func WithBlockingPolicy() Option
WithBlockingPolicy makes producers block when the event buffer is full instead of dropping. Close unblocks any blocked producer.
func WithBufferSize ¶
WithBufferSize sets the bounded event buffer's capacity (default 8192). When the buffer is full, events are dropped and counted unless WithBlockingPolicy is set.
func WithFlushInterval ¶
WithFlushInterval sets the cadence at which the sink's Flush is ticked (default 1s) — the commit/fsync moment, and the most a hard kill loses.
func WithLogger ¶
WithLogger gives the tracer a place to complain (rate-limited, Warn) about degraded operation: dropped batches, sink errors, recovered internal panics. nil means silent — degradation is still visible in Stats.
func WithOverheadSampling ¶
WithOverheadSampling makes every Nth span time its own tracer cost for Stats.OverheadPerSpan (default 128). every = 1 measures every span — the ultimate accuracy in span-cost measurement, at the price of two extra clock reads per span: the instrument taxing the pipeline it measures. Raise it to cheapen tracing further on hot workloads.
type Sink ¶
Sink is the destination seam — the slog.Handler pattern. The tracer's writer goroutine is the only caller: methods are never invoked concurrently, so implementations need no locking.
WriteBatch delivers events as the tracer's queue drains — under light load a batch of one is a stream. Flush ticks on the tracer's flush interval and is the commit/fsync moment; a sink that writes immediately may no-op it. Close is called exactly once — from the writer goroutine, as Tracer.Close's final act after the drain and last Flush — so the sink's entire post-construction lifecycle stays on one goroutine; nothing is delivered afterward.
Sinks must not retain the Batch or anything reachable from it (including Attrs slices) after the call returns: the writer reuses buffers, and under MultiSink the same batch visits every sink. Copy what you keep.
Returned errors never propagate to the traced program; the tracer counts them (Stats.WriteErrors) and optionally logs them (WithLogger). A sink that defers durability to Flush must report commit failures from Flush — it is the only way a lost commit becomes visible in Stats, since the tracer treats accepted batches as the sink's responsibility.
func MultiSink ¶
MultiSink returns a Sink that delivers every call to each of sinks, sequentially and in order — the io.MultiWriter pattern, so composition is itself a Sink and the tracer never grows fan-out machinery (D22). Errors are joined: one failing sink never starves the rest. Nil entries are dropped. Because the same Batch visits every sink, the no-retention rule is what makes fan-out safe.
Delivery is sequential in the writer goroutine by design — no per-sink goroutines. A slow member slows the whole fan-out, and that pressure surfaces as rising Stats QueueDepth rather than hiding in a buffer.
Example ¶
Live log lines AND the post-run trace file: fan-out is itself a Sink, so composition costs the tracer nothing.
package main
import (
"context"
"log/slog"
"github.com/akmadian/gospan"
)
func main() {
fileSink := gospan.SlogSink(slog.Default()) // stand-in for sqlite.New("./traces")
tracer, _ := gospan.New(gospan.MultiSink(
gospan.SlogSink(slog.Default()),
fileSink,
))
defer tracer.Close(context.Background())
gospan.SetDefault(tracer)
}
Output:
func SlogSink ¶
SlogSink returns a Sink that emits one log record per finished span into logger — spans join your existing log flow, no file involved, and any logging library with a slog.Handler bridge can receive them. The record's message is the span name; its level tracks the status (Info, Warn for canceled, Error for failed); span_id, trace_id, duration, status, and the span's merged attributes ride along as record attrs.
A nil logger falls back to slog.Default. Spans still open when the tracer closes emit nothing — the incomplete-span story belongs to the file sinks, not the log flow.
type Span ¶
type Span struct {
// contains filtered or unexported fields
}
Span is a named unit of work with a start, an end, a parent, and typed attributes. Spans are created by Tracer.Start; a nil *Span is valid and every method on it is a no-op. End, Fail, and SetAttrs are safe to call from any goroutine, not just Start's.
func FromContext ¶
FromContext returns the span carried by ctx, or nil if there is none. A nil result is safe to use like any other span.
Example ¶
Channel pipelines: ctx follows the call stack, but items cross goroutines through channels — so the span context travels on the item, and FromContext closes the root at the final stage.
package main
import (
"context"
"log/slog"
"github.com/akmadian/gospan"
)
func main() {
tracer, _ := gospan.New(gospan.SlogSink(slog.Default()))
defer tracer.Close(context.Background())
type pipelineItem struct {
ctx context.Context // carries this item's root span
path string
}
// Intake mints the item's root trace.
item := pipelineItem{path: "/a.raw"}
item.ctx, _ = tracer.Start(context.Background(), "asset",
slog.String("path", item.path))
// Each stage nests under the item's root, wherever it runs.
_, hashSpan := tracer.Start(item.ctx, "hash")
hashSpan.End()
// The final stage closes the root.
gospan.FromContext(item.ctx).End()
}
Output:
func (*Span) End ¶
func (span *Span) End()
End finishes the span. The first End wins: every later mutation (SetAttrs, Fail, a second End) is a no-op. End never blocks beyond a channel send, and is safe in defer even through a panic — the span gets an end time.
func (*Span) Fail ¶
Fail records why the span didn't succeed: SpanStatusCanceled when err is context.Canceled or context.DeadlineExceeded (via errors.Is), SpanStatusError otherwise. Fail(nil) is a no-op; the last Fail before End wins; after End it is a no-op. Fail does not end the span — End still must be called.
Example ¶
Fail records why a span didn't succeed; End still ends it. Cancellation is classified automatically from the error value.
package main
import (
"context"
"errors"
"log/slog"
"github.com/akmadian/gospan"
)
func main() {
tracer, _ := gospan.New(gospan.SlogSink(slog.Default()))
defer tracer.Close(context.Background())
processAsset := func(ctx context.Context) error {
ctx, span := tracer.Start(ctx, "process-asset")
defer span.End()
if err := extractFrames(ctx); err != nil {
span.Fail(err) // status=error, or canceled if errors.Is says so
return err
}
return nil
}
_ = processAsset(context.Background())
}
func extractFrames(context.Context) error { return errors.New("boom") }
Output:
type SpanStatus ¶
type SpanStatus int
SpanStatus classifies how a span ended — it never says whether it ended. That is carried by the end timestamp alone (in trace files, end_ns IS NULL means running or incomplete), so a successful finish is SpanStatusOK plus an end time, never a distinct status. The numeric values are frozen: they are written into trace files (spans.status) and never change meaning; future statuses append (SPEC §5).
const ( SpanStatusOK SpanStatus = 0 // ended without a recorded failure SpanStatusError SpanStatus = 1 // Fail recorded a non-cancellation error SpanStatusCanceled SpanStatus = 2 // Fail recorded context.Canceled or context.DeadlineExceeded )
func (SpanStatus) String ¶
func (status SpanStatus) String() string
String returns "ok", "error", or "canceled"; unknown values format as "status(N)".
type SpanSummary ¶
type SpanSummary struct {
Count uint64
Errors uint64
Canceled uint64
Min time.Duration
Max time.Duration
Mean time.Duration
P50 time.Duration
P90 time.Duration
P99 time.Duration
}
SpanSummary aggregates every finished span sharing one name — "how is your code doing", where Stats answers "how is the tracer doing". Count, Errors, Canceled, Min, Max, and Mean are exact; the percentiles are approximate, read from a log-bucketed histogram with relative error bounded at ~12.5% (four linear sub-buckets per power of two, values reported at bucket midpoints, then clamped into [Min, Max]). Exact answers live in the trace file — one SQL query away — so the in-process copy stays small and lock-free on the hot path.
type Stats ¶
type Stats struct {
// Cumulative since New.
//
// Written means accepted by the sink, not durable: a sink that
// buffers in WriteBatch and commits in Flush (the SQLite sink) can
// still lose accepted events to a failed commit — that failure is a
// WriteErrors increment, because durability is the sink's own story
// and the tracer never guesses at it. The trace file itself is the
// ground truth of what survived.
Started uint64 // spans begun
Completed uint64 // spans whose End ran
Written uint64 // events accepted by the sink
Dropped uint64 // events lost to a full buffer
// SummaryDropped counts completed spans not folded into Summary()
// because its distinct-name cap was reached. Not data loss — the sink
// still receives those spans and the trace file holds them exactly, only
// the in-memory per-name rollup omits them. A nonzero value means span
// names carry high-cardinality data that belongs in attributes.
SummaryDropped uint64
// This instant.
SpansInFlight int // started, not yet ended
TracesInFlight int // root spans still open
QueueDepth int // events buffered, not yet taken by the writer
// WriteErrors counts failed sink calls — a WriteBatch or Flush that
// errored or panicked. Flush failures matter as much as batch
// failures: for a buffering sink the flush IS the commit, so this is
// where lost durability becomes visible. Degraded operation, counted
// never propagated.
WriteErrors uint64
// OverheadPerSpan is the rolling average tracer-added cost of one
// span's Start+End pair — what tracing costs you on this hardware,
// right now. Measured on a 1-in-128 sample by default; tune with
// WithOverheadSampling.
OverheadPerSpan time.Duration
}
Stats is the tracer's self-health snapshot: what tracing has cost and what it has dropped — "how is the tracer doing", where Summary answers "how is your code doing". All values come from atomics; calling this on a ticker is fine.
type Tracer ¶
type Tracer struct {
// contains filtered or unexported fields
}
Tracer collects spans and delivers them to a Sink from a single writer goroutine. Construct one with New; a nil *Tracer is a valid, permanently inert tracer (every method is a no-op).
func Default ¶
func Default() *Tracer
Default returns the tracer set by SetDefault, or nil if none was set.
func New ¶
New constructs a Tracer that delivers spans to sink and starts its writer goroutine. Construction is the only place gospan surfaces errors; after New succeeds, nothing gospan does returns an error or panics into the caller.
func (*Tracer) Close ¶
Close drains the buffer, flushes and closes the sink, and makes the Tracer permanently inert — subsequent calls on it are no-ops, and Close itself is idempotent. ctx bounds the wait: on expiry Close returns ctx.Err() immediately while the writer finishes draining and closes the sink in the background (the sink is still closed exactly once).
func (*Tracer) Start ¶
func (tracer *Tracer) Start(parent context.Context, name string, attrs ...slog.Attr) (ctx context.Context, span *Span)
Start begins a span named name. The parent is read off ctx; with no span in ctx the new span roots a fresh trace. The returned context carries the new span. On a nil Tracer it returns (ctx, nil), both safe to use.
Example (SemaphoreWait) ¶
A semaphore wait is just a child span: the duration IS the wait time, the numbers are attributes.
package main
import (
"context"
"log/slog"
"github.com/akmadian/gospan"
)
func main() {
tracer, _ := gospan.New(gospan.SlogSink(slog.Default()))
defer tracer.Close(context.Background())
ctx := context.Background()
_, waitSpan := tracer.Start(ctx, "acquire-budget",
slog.Int64("tokens_requested", 4), slog.Int64("budget_total", 16))
acquireTokens(ctx, 4)
waitSpan.End()
}
func acquireTokens(context.Context, int64) {}
Output:
func (*Tracer) Summary ¶
func (tracer *Tracer) Summary() map[string]SpanSummary
Summary returns the per-name aggregates for every span name seen so far. It is safe from any goroutine and cheap enough for a ticker; on a nil Tracer it returns nil.
Span names must stay low-cardinality: a small, stable set (~dozens), with any varying data (request IDs, paths) carried in attributes rather than the name. Each distinct name costs a small fixed-size histogram, so Summary tracks only a bounded number of them and counts any completed spans past that cap in Stats.SummaryDropped — an accidental high-cardinality name degrades visibly instead of growing memory without bound.
Example ¶
Summary answers "how is my code doing" live, without waiting for the file; Stats answers "how is the tracer doing".
package main
import (
"context"
"log/slog"
"github.com/akmadian/gospan"
)
func main() {
tracer, _ := gospan.New(gospan.SlogSink(slog.Default()))
defer tracer.Close(context.Background())
_, span := tracer.Start(context.Background(), "extract-frames")
span.End()
extract := tracer.Summary()["extract-frames"]
slog.Info("extract", "p90", extract.P90, "count", extract.Count, "errors", extract.Errors)
stats := tracer.Stats()
slog.Info("tracer health", "dropped", stats.Dropped, "overhead", stats.OverheadPerSpan)
}
Output:
func (*Tracer) Track ¶
Track starts a leaf span and returns its closer:
defer tracer.Track(ctx, "ffmpeg-extract")()
The span starts when Track is evaluated and ends when the closer runs. Track cannot return a context, so nothing can nest under it — leaf-only by construction. On a nil Tracer the returned closer is still callable.
