goreach

Find unreached code paths in running Go services.
goreach sits on top of Go's native coverage instrumentation (go build -cover / GOCOVERDIR) and turns raw coverage data into actionable JSON reports highlighting exactly which functions and code blocks your production traffic never touches.
Key Features
- Production coverage analysis -- Collect coverage from long-running services without stopping them
- Flush SDK -- Lightweight, zero-dependency library to emit coverage data on a schedule, via HTTP, signal, or manual trigger
- Multi-build merge -- Automatically reconcile coverage across different binary versions
- Web UI -- Interactive browser-based viewer with inline source preview
- Cloud-agnostic storage -- Local disk, S3, GCS, Azure, or any custom backend via a simple interface
Install
go install github.com/yag13s/goreach/cmd/goreach@latest
Quick Start
# Build with coverage
go build -cover -covermode=set -o myserver ./cmd/myserver
# Run with GOCOVERDIR
mkdir -p /tmp/coverage
GOCOVERDIR=/tmp/coverage ./myserver
# Stop the process, then analyze
goreach analyze -coverdir /tmp/coverage -pretty
View the report in browser
goreach view -src . report.json
Try without building — pre-generated sample reports
The repository includes sample reports so you can explore the viewer and merge
workflow immediately:
# View a single-build report
goreach view testdata/sample-reports/v1.json
# Merge two build versions and view
goreach merge -pretty -o testdata/sample-reports/merged.json \
testdata/sample-reports/v1.json \
testdata/sample-reports/v2.json
goreach view -src . testdata/sample-reports/merged.json
To generate fresh coverage data from the sample server:
bash testdata/sampleserver/run.sh
CLI Commands
| Command |
Description |
goreach analyze |
Analyze coverage data and output unreached code as JSON |
goreach merge |
Merge multiple reports, taking max coverage per function |
goreach view |
Launch interactive Web UI with optional source preview |
goreach summary |
Print a text coverage summary |
goreach version |
Show version info |
analyze flags
| Flag |
Description |
Default |
-profile <file> |
Text coverage profile path |
-- |
-coverdir <dir> |
GOCOVERDIR path (exclusive with -profile) |
-- |
-r |
Recursively search coverdir |
false |
-pkg <prefixes> |
Package filter (comma-separated) |
all |
-threshold <float> |
Show functions with coverage <= X% |
100 |
-min-statements <n> |
Show functions with >= N unreached statements |
0 |
-o <file> |
Output file |
stdout |
-pretty |
Pretty-print JSON |
false |
merge flags
| Flag |
Description |
Default |
-o <file> |
Output file |
stdout |
-pretty |
Pretty-print JSON |
false |
Uses the newest report as the structural base. Takes the maximum coverage_percent per function across all inputs. Deleted functions (only in older reports) are excluded.
When an older build wins on coverage but lacks unreached block detail (e.g. covdata func origin), the latest build's blocks are preserved in latest_unreached_blocks. The viewer shows a toggle to switch between merged and latest-build block views.
view flags
| Flag |
Description |
Default |
-src <dir> |
Source root for inline code preview |
-- (disabled) |
-port <n> |
HTTP port |
0 (random) |
-no-open |
Don't auto-open browser |
false |
Multi-Build Workflow
When deploying new versions of your service, each build produces different coverage metadata.
goreach handles this by analyzing each build version separately, then merging the results.
Per-build analyze → merge
Analyze each build version in its own directory, then merge. Merging takes the maximum
coverage_percent per function across all inputs, so no coverage is lost when you redeploy.
# Directory structure after collecting coverage from multiple deploys:
# coverage-data/
# ├── abc1234/ ← build version (git short hash)
# │ └── pod-name/
# │ ├── covmeta.*
# │ └── covcounters.*
# └── def5678/
# └── pod-name/
# ├── covmeta.*
# └── covcounters.*
# 1. Analyze each build version separately
for dir in coverage-data/*/; do
version=$(basename "$dir")
goreach analyze -coverdir "$dir" -r -pretty -o "reports/$version.json"
done
# 2. Merge all per-version reports
goreach merge -pretty -o merged-report.json reports/*.json
# 3. View in browser (with source preview)
goreach view -src . merged-report.json
Makefile example:
analyze-coverage:
@mkdir -p coverage-reports
@for dir in coverage-data/*/; do \
version=$$(basename "$$dir"); \
goreach analyze -coverdir "$$dir" -r -pretty \
-o "coverage-reports/$$version.json"; \
done
merge-coverage:
goreach merge -pretty -o coverage-report.json coverage-reports/*.json
coverage: download-coverage analyze-coverage merge-coverage
JSON output vs Web UI
| Use case |
Command |
Features |
| CI / scripts |
analyze -o report.json |
Filter with jq, diffable, store in Git |
| Human review |
view report.json -src . |
Inline source preview, package tree, block detail |
| Text overview |
summary -coverdir ... |
Terminal-friendly function list with coverage % |
JSON report structure (shortened):
{
"version": 1,
"generated_at": "2025-02-28T...",
"mode": "atomic",
"total": {
"total_statements": 120,
"covered_statements": 95,
"coverage_percent": 79.16
},
"packages": [{
"import_path": "myapp/handler",
"files": [{
"file_name": "myapp/handler/handler.go",
"functions": [{
"name": "HandleRequest",
"line": 28,
"total_statements": 12,
"covered_statements": 9,
"coverage_percent": 75.0,
"unreached_blocks": [
{"start_line": 42, "end_line": 45, "num_statements": 2}
]
}]
}]
}]
}
Flush SDK
A zero-dependency library for collecting coverage from running processes. Embed it in your service to flush coverage data without waiting for process exit.
import "github.com/yag13s/goreach/flush"
flush.Enable(flush.Config{
Storage: flush.LocalStorage{Dir: "/var/coverage"},
ServiceName: "myserver",
BuildVersion: version,
Interval: 5 * time.Minute,
Clear: true,
})
defer flush.Stop()
Note: When using the flush SDK, build with -covermode=atomic. The set mode is not supported for runtime counter reads.
Safe to call on binaries built without -cover -- all flush operations become no-ops.
Flush Triggers
| Trigger |
Use Case |
How |
| Periodic |
Long-running servers |
Config{Interval: 5 * time.Minute} |
| Manual |
Lambda, request-scoped |
flush.Emit() |
| HTTP |
CronJob, external trigger |
flushhttp.Handler() |
| Signal |
Batch jobs, non-HTTP processes |
flush.HandleSignal(syscall.SIGUSR1) |
| Shutdown |
All processes |
defer flush.Stop() |
Storage Interface
type Storage interface {
Store(ctx context.Context, files []string, meta Metadata) error
}
Built-in: LocalStorage, WriterStorage, objstore.Storage (S3/GCS/Azure).
S3 example
import "github.com/yag13s/goreach/flush/objstore"
storage := &objstore.Storage{
Upload: func(ctx context.Context, key string, body io.Reader) error {
_, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &bucket, Key: &key, Body: body,
})
return err
},
}
Default key format: <prefix>/<service>/<version>/<pod>/<filename>
HTTP Endpoints (opt-in)
import "github.com/yag13s/goreach/flush/flushhttp"
mux.Handle("/internal/coverage/", flushhttp.Handler())
| Method |
Path |
Action |
GET |
/internal/coverage |
Return current coverage data |
POST |
/internal/coverage/flush |
Flush to storage |
POST |
/internal/coverage/clear |
Reset counters |
Architecture
flowchart TB
subgraph App["Instrumented Application"]
BIN["go build -cover binary"]
SDK["flush SDK"]
BIN --- SDK
end
subgraph Store["Storage"]
LOCAL["Local Disk"]
REMOTE["S3 / GCS / Azure"]
end
subgraph CLI["goreach CLI"]
ANALYZE["analyze"]
MERGE["merge"]
VIEW["view"]
end
App -- "covmeta + covcounters" --> Store
Store --> CLI
Deployment Examples
Kubernetes + S3
flush.Enable(flush.Config{
Storage: &objstore.Storage{
Upload: func(ctx context.Context, key string, body io.Reader) error {
_, err := s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &bucket, Key: &key, Body: body,
})
return err
},
},
ServiceName: "myserver",
BuildVersion: version,
Interval: 10 * time.Minute,
})
defer flush.Stop()
# Download and analyze
aws s3 sync s3://bucket/goreach/myserver/ /tmp/coverage/
goreach analyze -coverdir /tmp/coverage -r -pretty
AWS Lambda
Lambda environments freeze between invocations, so timer-based flush does not work.
Call flush.Emit() at the end of each request instead.
// init — runs once per cold start
func init() {
flush.Enable(flush.Config{
Storage: storage, // e.g. objstore.Storage with S3
ServiceName: "my-lambda",
BuildVersion: os.Getenv("BUILD_VERSION"),
Clear: true,
})
}
// handler — flush per invocation
func handler(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) {
resp, err := process(ctx, req)
flush.Emit() // must be explicit, timers are frozen
return resp, err
}
Build script — a shell wrapper creates GOCOVERDIR before the Go binary starts
and embeds BUILD_VERSION so coverage data is organized by deploy:
VERSION=$(git rev-parse --short HEAD)
GOOS=linux GOARCH=arm64 go build -cover -covermode=atomic -o bootstrap.bin .
printf '#!/bin/sh\nmkdir -p /tmp/coverage-data\nexport BUILD_VERSION=%s\nexec /var/task/bootstrap.bin "$@"\n' "$VERSION" > bootstrap
chmod +x bootstrap
Collect and analyze:
# Download from S3
aws s3 sync s3://my-bucket/goreach/my-lambda/ coverage-data/
# Analyze per build version, then merge
for dir in coverage-data/*/; do
version=$(basename "$dir")
goreach analyze -coverdir "$dir" -r -pretty -o "reports/$version.json"
done
goreach merge -pretty -o report.json reports/*.json
# View
goreach view -src . report.json
CronJob-triggered flush
apiVersion: batch/v1
kind: CronJob
spec:
schedule: "0 */6 * * *"
jobTemplate:
spec:
containers:
- name: coverage-trigger
command: ["curl", "-X", "POST", "http://myserver:8080/internal/coverage/flush"]
Overhead
Real-world benchmark on a production AWS Lambda (ARM64, 256 MB, ap-northeast-1) serving a Go API with DynamoDB reads/writes. 50 requests × 14 endpoints (6 read, 8 write lifecycle).
Three configurations compared:
| Configuration |
p50 (avg across endpoints) |
Δ from baseline |
| Baseline — normal build |
58.4 ms |
— |
Instrumentation only — -cover -covermode=atomic, no flush |
62.7 ms |
+4.3 ms (+7%) |
Full — instrumentation + flush.Emit() to S3 per request |
123.0 ms |
+64.6 ms (+111%) |
The -cover instrumentation itself adds ~4 ms per request and ~30 KB to the binary (+0.1%) — negligible for most services.
The remaining ~60 ms in the "full" configuration is the S3 PutObject in flush.Emit(). This cost is not inherent to goreach — it depends entirely on your flush strategy:
- Lambda (
flush.Emit() per request): ~60 ms/req overhead from S3 write
- Long-running server (
Interval: 5 * time.Minute): amortized to near-zero per request
- HTTP/signal trigger: zero overhead on normal requests
Per-endpoint breakdown
14 REST endpoints (CRUD operations backed by DynamoDB) measured individually:
Endpoint Op | Baseline | Instr. | Full | Instr. Δ
──────────────────────────────────────────────────────────────────────
GET /items read | 49.1 ms | 49.8 ms | 105.1 ms | +0.7 ms
GET /items/:id read | 51.6 ms | 54.3 ms | 113.4 ms | +2.8 ms
GET /nested/a read | 55.0 ms | 58.3 ms | 117.8 ms | +3.3 ms
GET /nested/b read | 65.6 ms | 69.9 ms | 127.5 ms | +4.3 ms
GET /nested/c read | 55.2 ms | 61.8 ms | 115.8 ms | +6.6 ms
GET /global read | 56.3 ms | 62.8 ms | 117.3 ms | +6.5 ms
POST /items create | 54.0 ms | 60.3 ms | 120.0 ms | +6.2 ms
PUT /items/:id update | 56.7 ms | 64.1 ms | 120.1 ms | +7.5 ms
DELETE /items/:id delete | 53.4 ms | 57.5 ms | 119.6 ms | +4.1 ms
POST /comments create | 61.0 ms | 64.7 ms | 130.8 ms | +3.6 ms
DELETE /comments delete | 60.4 ms | 67.8 ms | 133.3 ms | +7.4 ms
POST /routes create | 69.3 ms | 69.3 ms | 140.9 ms | +0.0 ms
PUT /routes update | 69.8 ms | 71.3 ms | 131.2 ms | +1.5 ms
DELETE /routes delete | 59.9 ms | 65.4 ms | 129.5 ms | +5.5 ms
All values are p50 (median) over 50 iterations. Measured with curl from the same region.
Requirements
- Go 1.26+
go tool covdata (included with Go)
License
MIT