{"id":51861792,"url":"https://github.com/akmadian/gospan","last_synced_at":"2026-07-24T08:30:32.733Z","repository":{"id":372440533,"uuid":"1299638464","full_name":"akmadian/gospan","owner":"akmadian","description":"In-process span tracing for Go — find where your program spends its time, as structured logs or queryable SQLite. No collector, no agent, no CGO.","archived":false,"fork":false,"pushed_at":"2026-07-21T05:37:25.000Z","size":424,"stargazers_count":0,"open_issues_count":5,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-07-21T07:16:20.488Z","etag":null,"topics":["golang","observability","performance","spans","sqlite","timing","tracing"],"latest_commit_sha":null,"homepage":"","language":"Go","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/akmadian.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-07-13T18:57:07.000Z","updated_at":"2026-07-21T05:34:49.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/akmadian/gospan","commit_stats":null,"previous_names":["akmadian/gospan"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/akmadian/gospan","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/akmadian%2Fgospan","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/akmadian%2Fgospan/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/akmadian%2Fgospan/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/akmadian%2Fgospan/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/akmadian","download_url":"https://codeload.github.com/akmadian/gospan/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/akmadian%2Fgospan/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35835401,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-07-20T02:08:10.276Z","status":"online","status_checked_at":"2026-07-24T02:00:07.870Z","response_time":62,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["golang","observability","performance","spans","sqlite","timing","tracing"],"created_at":"2026-07-24T08:30:32.035Z","updated_at":"2026-07-24T08:30:32.721Z","avatar_url":"https://github.com/akmadian.png","language":"Go","funding_links":[],"categories":[],"sub_categories":[],"readme":"# gospan\n\n*Embedded span tracing for Go. Zero-dependency core; spans to your slog logger or a queryable SQLite file.*\n\n[![Go Reference](https://pkg.go.dev/badge/github.com/akmadian/gospan.svg)](https://pkg.go.dev/github.com/akmadian/gospan)\n[![CI](https://github.com/akmadian/gospan/actions/workflows/ci.yml/badge.svg)](https://github.com/akmadian/gospan/actions/workflows/ci.yml)\n[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)\n\n[Quickstart](#quickstart) · [Overhead](#overhead) · [Guarantees](#guarantees) · [Configuration](#configuration) · [FAQ](#faq)\n\ngospan 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.\n\n![gospan-demo: one run, logged live and queried as SQL](docs/demo.jpg)\n\n\u003e **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](#faq).\n\n## Why\n\nLogs 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.\n\nGood 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.\n\n\u003ca name=\"quickstart\"\u003e\u003c/a\u003e\n\n## Quickstart\n\n```sh\ngo get github.com/akmadian/gospan\n```\n\nCore 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:\n\n```go\nfunc main() {\n    tracer, err := gospan.New(gospan.SlogSink(slog.Default()))\n    if err != nil { /* construction is the only moment gospan can fail */ }\n    defer tracer.Close(context.Background())\n    gospan.SetDefault(tracer)\n    // ...\n}\n\nfunc handleReport(ctx context.Context, req Request) error {\n    ctx, span := gospan.Start(ctx, \"build-report\", slog.String(\"user\", req.User))\n    defer span.End()\n\n    data, err := loadRows(ctx, req) // loadRows' own spans nest via ctx\n    if err != nil {\n        span.Fail(err) // status = error, or canceled when errors.Is says so\n        return err\n    }\n    return render(ctx, data)\n}\n\nfunc render(ctx context.Context, data Rows) error {\n    defer gospan.Track(ctx, \"render-pdf\")() // one-line leaf span\n    // ...\n}\n```\n\n`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.\n\n### Trace to a SQLite file\n\n```sh\ngo get github.com/akmadian/gospan/sqlite\n```\n\nThe 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:\n\n```go\nsink, err := sqlite.New(\"./traces\")\nif err != nil { /* same rule: errors only at construction */ }\ntracer, err := gospan.New(sink)\n```\n\n```sql\n-- worst durations by span name\nSELECT name, COUNT(*), MAX(duration_ns) AS worst_ns\nFROM spans_named\nWHERE end_ns IS NOT NULL GROUP BY name ORDER BY worst_ns DESC;\n```\n\nReady-made analysis queries — per-name percentiles, slowest spans, failure triage, effective parallelism — ship in [sqlite/scripts/](sqlite/scripts/):\n\n```sh\nsqlite3 ./traces/gospan-*.sqlite \u003c sqlite/scripts/by-name.sql\n```\n\n### Both at once\n\nSend 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:\n\n```go\ntracer, err := gospan.New(gospan.MultiSink(\n    gospan.SlogSink(slog.Default()),\n    sink,\n))\n```\n\n### Live statistics\n\n`Summary()` reports on your code; `Stats()` on the tracer itself — what tracing costs, what it dropped:\n\n```go\nreport := tracer.Summary()[\"build-report\"]\nslog.Info(\"reports\", \"p90\", report.P90, \"count\", report.Count, \"errors\", report.Errors)\n\nhealth := tracer.Stats()\nslog.Info(\"tracing itself\", \"dropped\", health.Dropped, \"cost\", health.OverheadPerSpan)\n```\n\n\u003e 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`.\n\n## Patterns\n\nThe quickstart is the call-tree case: ctx flows down the stack, spans nest for free. Two more shapes cover most programs.\n\n**Pipeline items** cross goroutines through channels, so the span rides the item, not the call stack:\n\n```go\ntype pipelineItem struct {\n    ctx context.Context // carries this item's root span\n    // ...\n}\n// intake:      item.ctx, _ = gospan.Start(ctx, \"job\", slog.String(\"path\", path))\n// each stage:  _, span := gospan.Start(item.ctx, \"hash\"); defer span.End()\n// final stage: gospan.FromContext(item.ctx).End()\n```\n\n**Waits** — the span *is* the wait, the numbers are attributes:\n\n```go\n_, wait := gospan.Start(ctx, \"acquire-budget\", slog.Int64(\"tokens\", n))\nerr := sem.Acquire(ctx, n)\nwait.End() // the duration IS the wait time\n```\n\nMore recipes — subprocess leaves, fan-in batches — in [docs/DESIGN.md](docs/DESIGN.md) §6.\n\n\u003ca name=\"overhead\"\u003e\u003c/a\u003e\n\n## Overhead\n\nMeasured 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:\n\n| hot-path op              | time    | allocs | bytes |\n|--------------------------|---------|--------|-------|\n| `Start` + `End`          | 361 ns  | 2      | 160 B |\n| `Start` + `End`, 2 attrs | 393 ns  | 3      | 240 B |\n| `Track` leaf             | 369 ns  | 2      | 160 B |\n| `SetAttrs`               | 121 ns  | 1      | 48 B  |\n| buffer full (dropping)   | 187 ns  | 2      | 160 B |\n| nil tracer (off)         | 4.3 ns  | 0      | 0 B   |\n\nAttributes 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.\n\nThese 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 ./...`.\n\n\u003ca name=\"guarantees\"\u003e\u003c/a\u003e\n\n## Guarantees\n\ngospan is built to leave in. All four of these are tested, not just claimed:\n\n- **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.\n- **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.\n- **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).\n- **`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.\n\nAnd **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.\n\n## What it is — and isn't\n\n**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.\n\n**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.\n\n\u003ca name=\"configuration\"\u003e\u003c/a\u003e\n\n## Configuration\n\nDefaults are drop-in-and-forget; every knob exists because some workload disagrees:\n\n```go\ntracer, err := gospan.New(\n    sink,\n    gospan.WithBufferSize(8192),           // event buffer; full = drop and count\n    gospan.WithFlushInterval(time.Second), // durability heartbeat: a hard kill loses ≤ this\n    gospan.WithBlockingPolicy(),           // block producers instead of dropping\n    gospan.WithLogger(logger),             // where gospan complains (rate-limited); default silent\n    gospan.WithOverheadSampling(128),      // every Nth span times its own cost; 1 = all\n)\n```\n\nThe SQLite sink also takes `sqlite.WithName(name, overwrite)` for a stable file path instead of the per-run auto-name.\n\n\u003ca name=\"faq\"\u003e\u003c/a\u003e\n\n## FAQ\n\n**Isn't this just OpenTelemetry?**\nOTel 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](docs/DEFERRED.md), not rejected — it would feed OTel spans into gospan's file + viewer.)\n\n**Can I combine traces from multiple runs or hosts?**\nYes, unofficially — the files are plain SQLite, so `ATTACH` them and query the union. Two rules keep it honest:\n\n- **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.\n- **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](docs/SPEC.md)).\n\n```sql\nATTACH 'run2.sqlite' AS r2;\nSELECT (SELECT file_id FROM meta) AS file_id, * FROM spans_named\nUNION ALL SELECT (SELECT file_id FROM r2.meta), * FROM r2.spans_named;\n```\n\nYou 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).\n\n**Why a separate attrs table? Doesn't querying attributes need a join?**\nAttribute 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.\n\n**Where's the UI?**\nThe file is plain SQLite — the [scripts](sqlite/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.\n\n**Is it production-ready?**\nIt can't crash your program (see [Guarantees](#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.\n\n## Docs\n\n- [docs/DESIGN.md](docs/DESIGN.md) — the conceptual model and architecture\n- [docs/SPEC.md](docs/SPEC.md) — API surface, schema, file-format contract\n- [docs/DECISIONS.md](docs/DECISIONS.md) — why it is the way it is (append-only)\n- [docs/DEFERRED.md](docs/DEFERRED.md) — what's consciously not in v1, and what would trigger it\n\n## License\n\nLicensed under the [Apache License, Version 2.0](LICENSE). Copyright 2026 Ari Madian.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fakmadian%2Fgospan","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fakmadian%2Fgospan","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fakmadian%2Fgospan/lists"}