gospan

package module
v0.0.1 Latest Latest
Warning

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

Go to latest
Published: Jul 14, 2026 License: GPL-3.0 Imports: 9 Imported by: 0

README

gospan

Embedded, span-based tracing for Go. Wrap the operations you care about in named spans — nested, timed, attributed — and gospan delivers them to a destination you choose: your existing structured logger, or a local SQLite file you can query with SQL. It runs entirely inside your process. No collector, no agent, no CGO, nothing to deploy.

Currently pre 1.0, implemented and tested but still validating in another personal project. Testers and contributions welcome.

Why

Logs are great, but they're not structured, and not easily queryable or aggregable without larger more dedicated log browsers. When you want per-operation timing with structure — this call, inside this request, took this long, and here's its p99 across the whole run, or this step of this pipeline job took this long, with p99s across all pipeline runs, ALL in a small, efficient, self contained package — the existing tools each miss:

  • OpenTelemetry solves distributed tracing: multi-service, network hops, collectors. In-process, it's a heavy dependency tree pointed at infrastructure you don't want to run.
  • go tool trace operates at the runtime-scheduler level (goroutines, GC, syscalls) — unreadable for business-logic diagnosis, by design.
  • Metrics (Prometheus and friends) usually require a server you have to run and provide an small, but non negligent, API surface for you to maintain.

gospan is the missing middle, for servers, CLIs, batch jobs, and worker pipelines alike: spans with names you chose, kept inside your process, readable where you already look.

How it works

Start returns a span and a context.Context carrying it; anything started from that context nests beneath it. End stamps the duration and hands the span to a bounded buffer; a single background goroutine drains the buffer to the destination. If the buffer is full, the event is dropped and counted — never blocking your code — unless you opt into blocking instead.

Quickstart & Examples

go get github.com/akmadian/gospan

Core is stdlib-only. Hand it your logger — every maintained Go logging library is or bridges to a slog.Handler — and spans arrive as structured log lines with durations:

func main() {
    tracer, err := gospan.New(gospan.SlogSink(slog.Default()))
    if err != nil { /* construction is the only moment gospan can fail */ }
    defer tracer.Close(context.Background())
    gospan.SetDefault(tracer)
    // ...
}

func handleReport(ctx context.Context, req Request) error {
    ctx, span := gospan.Start(ctx, "build-report", slog.String("user", req.User))
    defer span.End()

    data, err := loadRows(ctx, req) // loadRows' own spans nest via ctx
    if err != nil {
        span.Fail(err) // status = error, or canceled when errors.Is says so
        return err
    }
    return render(ctx, data)
}

func render(ctx context.Context, data Rows) error {
    defer gospan.Track(ctx, "render-pdf")() // one-line leaf span
    // ...
}
Or trace to a file
go get github.com/akmadian/gospan/sqlite

The SQLite sink (pure Go, no CGO) writes one auto-named file per run:

sink, err := sqlite.New("./traces")
if err != nil { /* same rule: errors only at construction */ }
tracer, err := gospan.New(sink)

When the run ends, the file is plain SQLite:

-- worst durations by span name
SELECT n.name, COUNT(*), MAX(end_ns - start_ns) AS worst_ns
FROM spans s JOIN names n ON n.id = s.name_id
WHERE end_ns IS NOT NULL GROUP BY n.name ORDER BY worst_ns DESC;
Or both

To keep spans out of your logs but still hear about tracing's own problems, make the file the sink and the logger the complaints channel:

sink, err := sqlite.New("./traces")
if err != nil { /* ... */ }
tracer, err := gospan.New(sink, gospan.WithLogger(slog.Default()))

Or send every span to both, with MultiSink:

tracer, err := gospan.New(gospan.MultiSink(
    gospan.SlogSink(slog.Default()),
    sink,
))
Live statistics output

Summary() reports on your code; Stats() on the tracer itself — what tracing costs you, what it dropped:

report := tracer.Summary()["build-report"]
slog.Info("reports", "p90", report.P90, "count", report.Count, "errors", report.Errors)

health := tracer.Stats()
slog.Info("tracing itself", "dropped", health.Dropped, "cost", health.OverheadPerSpan)
Patterns

The quickstart is the call-tree case: ctx flows down the stack, spans nest for free. Two more shapes cover most programs.

Pipeline items cross goroutines through channels, so the span rides the item, not the call stack:

type pipelineItem struct {
    ctx context.Context // carries this item's root span
    // ...
}
// intake:      item.ctx, _ = gospan.Start(ctx, "job", slog.String("path", path))
// each stage:  _, span := gospan.Start(item.ctx, "hash"); defer span.End()
// final stage: gospan.FromContext(item.ctx).End()

Waits — the span is the wait, the numbers are attributes:

_, wait := gospan.Start(ctx, "acquire-budget", slog.Int64("tokens", n))
err := sem.Acquire(ctx, n)
wait.End() // the duration IS the wait time

More recipes — subprocess leaves, fan-in batches — in docs/DESIGN.md §6.

What it is — and is not

Is:

  • In-process span monitoring: no external processes to babysit.
  • For anything with a start and an end: request handling, subprocess calls, DB queries, semaphore waits, queue time.
  • A small destination seam (the slog.Handler pattern): slog and SQLite in-tree; anything heavier lives in its own module, so no one's build pays for a destination they don't use.

Is not:

  • Distributed tracing — no cross-process propagation; that's OpenTelemetry's job.
  • A log aggregator, a metrics server, or a durable job queue.

Guarantees

  • Nothing gospan does can crash your program: panics are recovered at every public boundary, and the test suite proves it with hostile fixtures (panicking sinks, panicking loggers, error types whose methods panic).
  • nil is off: every method on a nil *Tracer/*Span is a ~4ns no-op.
  • After construction, no errors: disk failures degrade to drop-and-count, visible in Stats(), never an error you must handle.
  • Graceful Close loses nothing; a hard kill loses at most one flush interval (default 1s).

Overhead

Measured on Apple M1, Go 1.26, medians of 5 runs. The allocation counts are deterministic (ns/op is not) and a function of gospan's architecture — the test suite enforces them as ceilings, so they hold on every machine:

hot-path op time allocs bytes
Start + End 361 ns 2 160 B
Start + End, 2 attrs 393 ns 3 240 B
Track leaf 369 ns 2 160 B
SetAttrs 121 ns 1 48 B
buffer full (dropping) 187 ns 2 160 B
nil tracer 4.3 ns 0 0 B

Attrs cost one slice regardless of count. For context, the same shapes on the same machine against the OpenTelemetry SDK (v1.44) in its cheapest configuration — batch processor, discard exporter:

OTel op time allocs bytes
span start + end 615 ns 3 944 B
span + 2 attrs 893 ns 8 1.4 KB

A gospan span with attrs runs ~2.3× faster than OTel's floor at a sixth of the memory, and costs about 2.5 structured log lines (a 2-attr slog line to a discard handler: 144 ns). The write side runs on gospan's goroutine, never yours: the SQLite sink sustains ~286k spans/sec when a span starts and ends within one flush interval, ~82k spans/sec with four attrs per span.

Reproduce: go test -bench . -benchmem ./... in either module.

Sinks

type Sink interface {
    WriteBatch(b Batch) error // ≥1 events, in order — always from one goroutine
    Flush() error             // the commit moment, ticked every flush interval
    Close() error
}

In-tree: gospan.SlogSink (spans into your existing log flow), sqlite.New (a nested module carrying modernc.org/sqlite, so core stays zero-dependency), and gospan.MultiSink (fan-out). Third-party destinations belong in third-party modules — the seam is three methods wide.

Tuning

Defaults are drop-in-and-forget; every knob exists because some workload disagrees:

tracer, err := gospan.New(sink,
    gospan.WithBufferSize(8192),           // event buffer; full = drop and count
    gospan.WithFlushInterval(time.Second), // durability heartbeat: a hard kill loses ≤ this
    gospan.WithBlockingPolicy(),           // block producers instead of dropping
    gospan.WithLogger(logger),             // where gospan complains (rate-limited); default silent
    gospan.WithOverheadSampling(128),      // every Nth span times its own cost; 1 = all
)

Docs

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() {}

Index

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.

func Track

func Track(ctx context.Context, name string, attrs ...slog.Attr) func()

Track starts a leaf span on the default tracer. See Tracer.Track.

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

func WithBufferSize(n int) Option

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

func WithFlushInterval(duration time.Duration) Option

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

func WithLogger(l *slog.Logger) Option

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

func WithOverheadSampling(everyNSpans int) Option

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

type Sink interface {
	WriteBatch(b Batch) error
	Flush() error
	Close() error
}

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

func MultiSink(sinks ...Sink) Sink

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)
}

func SlogSink

func SlogSink(logger *slog.Logger) Sink

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

func FromContext(ctx context.Context) *Span

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()
}

func Start

func Start(ctx context.Context, name string, attrs ...slog.Attr) (context.Context, *Span)

Start begins a span on the default tracer. See Tracer.Start.

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

func (span *Span) Fail(err error)

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") }

func (*Span) SetAttrs

func (span *Span) SetAttrs(attrs ...slog.Attr)

SetAttrs records facts learned mid-flight. Last write per key wins. After End it is a no-op.

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). 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

	// 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

func New(sink Sink, options ...Option) (*Tracer, error)

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

func (tracer *Tracer) Close(ctx context.Context) error

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) {}

func (*Tracer) Stats

func (tracer *Tracer) Stats() Stats

Stats returns the current snapshot. On a nil Tracer it is all zeros.

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.

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)
}

func (*Tracer) Track

func (tracer *Tracer) Track(ctx context.Context, name string, attrs ...slog.Attr) func()

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.

Directories

Path Synopsis
sqlite module

Jump to

Keyboard shortcuts

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