gospan

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 21, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

README

gospan

Embedded span tracing for Go. Zero-dependency core; spans to your slog logger or a queryable SQLite file.

Go Reference CI License

Quickstart · Overhead · Guarantees · Configuration · FAQ

gospan shows you where a Go program spends its time. Wrap the slow-looking parts in named spans; they nest through the context.Context you already pass around. Send them to your slog logger to watch live, or to a SQLite file you query with plain SQL when the run ends. It's in-process tracing — no collector, no agent, no CGO — cheap enough (two allocations per span) to leave in.

Pre-1.0. The file format (SPEC §3–§5) is a frozen, cross-version contract; the Go API may still shift before 1.0. Implemented, tested, and validated in Alexandria, a real import pipeline. Testers and contributions welcome — help me find where it falls short. Common questions ("isn't this just OpenTelemetry?", overload behavior, why SQLite) are in the FAQ.

Why

Logs tell you what happened, not where the time went. Metrics need a server. go tool trace shows the Go scheduler, not your code. OpenTelemetry is built for tracing across services — overkill when one program is slow and you just want to know why. gospan fills that gap: name the operations you care about and read their timing right where you already look.

Good for anything with a start and an end — request latency (which nested query ate the time), pipeline backpressure (the wait on a full worker pool is the span), batch-job stages, or a one-line wrapper around an ffmpeg/git exec.

Quickstart

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

Start returns a span and a context carrying it; anything started from that context nests beneath. End stamps the duration and hands the span to a bounded buffer that a single background goroutine drains to the destination — your code never touches the sink.

Trace to a SQLite file
go get github.com/akmadian/gospan/sqlite

The SQLite sink (pure Go, no CGO) writes one auto-named file per run. When the run ends it's plain SQLite — the built-in spans_named view resolves the name join and duration for you:

sink, err := sqlite.New("./traces")
if err != nil { /* same rule: errors only at construction */ }
tracer, err := gospan.New(sink)
-- worst durations by span name
SELECT name, COUNT(*), MAX(duration_ns) AS worst_ns
FROM spans_named
WHERE end_ns IS NOT NULL GROUP BY name ORDER BY worst_ns DESC;

Ready-made analysis queries — per-name percentiles, slowest spans, failure triage, effective parallelism — ship in sqlite/scripts/:

sqlite3 ./traces/gospan-*.sqlite < sqlite/scripts/by-name.sql
Both at once

Send every span to the file and the log flow with MultiSink — or make the file the sink and the logger just the complaints channel (WithLogger) to keep spans out of your logs:

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

Summary() reports on your code; Stats() on the tracer itself — what tracing costs, 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)

Span names must stay low-cardinality — a small, stable set. Put varying data (request IDs, paths) in attributes, not the name: Summary() keeps a fixed-size histogram per distinct name, so names carrying IDs would grow memory. gospan caps the tracked set and reports any excess in Stats().SummaryDropped.

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.

Overhead

Measured on Apple M1, Go 1.26, medians of 5 runs. Allocation counts are deterministic (ns/op is not) and enforced as ceilings by the test suite, 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 (off) 4.3 ns 0 0 B

Attributes cost one slice regardless of count. For context, the OpenTelemetry SDK (v1.44) on the same machine in its cheapest configuration (batch processor, discard exporter) costs 615 ns / 3 allocs / 944 B for a bare span and 893 ns / 8 allocs / 1.4 KB with two attributes — so a gospan span with attrs runs ~2.3× faster at a sixth of the memory, about the cost of 2.5 structured log lines.

These are hot-path microbenchmarks (single producer, discard sink, warm cache) — a floor, not a promise. Under real concurrent load expect low single-digit microseconds per span end to end; Alexandria's pipeline sees ~2µs. The write side (the SQLite commit) runs on gospan's goroutine, never yours: it sustains ~286k spans/sec coalesced, ~82k with four attrs each. Reproduce: go test -bench . -benchmem ./....

Guarantees

gospan is built to leave in. All four of these are tested, not just claimed:

  • Can't crash your program. Every public boundary recovers panics — including hostile ones (panicking sinks, loggers, and error types are in the test suite). gospan's own bugs never become yours.
  • Won't block you. A full buffer drops the event and increments Stats().Dropped — your code never stalls beyond one channel send. Prefer to slow down rather than lose spans? WithBlockingPolicy(). Either way, loss is measured, never silent.
  • No errors after construction. Disk failures, sick sinks — all degrade to drop-and-count, visible in Stats(). The only errors you handle are at New, the one moment a human can fix a bad path. A graceful Close loses nothing; a hard kill loses at most one flush interval (default 1s).
  • nil is off. Every method on a nil *Tracer/*Span is a ~4ns, zero-alloc no-op that returns your context unchanged — so you gate construction on an env var and leave every call site in place.

And zero-dependency core: the tracer and slog output are pure stdlib — nothing in your go.mod. The SQLite file is one optional module (gospan/sqlite) using a pure-Go driver, so no CGO and no C compiler; its ~10 transitive modules land in your binary only if you import it.

What it is — and isn't

Is: in-process span monitoring for anything with a start and an end, over a small three-method destination seam (the slog.Handler pattern) — slog and SQLite in-tree, anything heavier in its own module so no one's build pays for a destination they don't use.

Isn't: distributed tracing (no cross-process propagation — that's OpenTelemetry's job), a log aggregator, a metrics server, or a durable job queue. The trace file is observational exhaust; deleting it loses diagnostics, never state.

Configuration

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
)

The SQLite sink also takes sqlite.WithName(name, overwrite) for a stable file path instead of the per-run auto-name.

FAQ

Isn't this just OpenTelemetry? OTel does distributed tracing across services, with a collector and an SDK dependency tree. gospan is the opposite on purpose: one process, zero core dependencies, a plain SQLite file at the end. Need cross-service traces? Use OTel. Need to know where one program's time goes? go get and two calls. (An OTel adapter is deferred, not rejected — it would feed OTel spans into gospan's file + viewer.)

Can I combine traces from multiple runs or hosts? Yes, unofficially — the files are plain SQLite, so ATTACH them and query the union. Two rules keep it honest:

  • Namespace by file_id. Span and trace IDs are per-file counters (every file starts at 1, so they collide across files); each file's random file_id (in its meta table) is the real key — group and join on (file_id, id), never id alone.
  • Clocks aren't aligned across machines. Durations (end_ns - start_ns) are exact within a file, but absolute cross-host ordering is only as good as the hosts' NTP sync (see SPEC §4).
ATTACH 'run2.sqlite' AS r2;
SELECT (SELECT file_id FROM meta) AS file_id, * FROM spans_named
UNION ALL SELECT (SELECT file_id FROM r2.meta), * FROM r2.spans_named;

You get pooled, per-file-coherent analysis — per-host trees, aggregate durations, cross-run comparisons — not one causal trace spanning hosts. It's post-hoc multi-file analysis, not distributed tracing (there's no propagation).

Why a separate attrs table? Doesn't querying attributes need a join? Attribute keys are yours to choose and unbounded, so they can't be fixed columns. The one alternative — a JSON blob on the span row — corrupts data: JSON's only number is float64, so int64 attributes past 2⁵³ (byte counts, nanosecond values) silently round off, and a late SetAttrs becomes read-modify-write on the blob. A key/value table stores each value at its exact type and keeps writes a single upsert. Most queries (durations, percentiles, failures) never touch attributes; the ones that do get the spans_named view or a one-line join.

Where's the UI? The file is plain SQLite — the scripts answer the common questions today, and a dedicated drag-and-drop viewer is the next milestone (its own repo). You're never locked out; it's just SQL.

Is it production-ready? It can't crash your program (see Guarantees) and Alexandria runs it against a real workload. But it's pre-1.0 and built for dev, debugging, and shipping as a support artifact — not enterprise distributed production.

Docs

License

Licensed under the Apache License, Version 2.0. Copyright 2026 Ari Madian.

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

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.

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

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