main.go
// Command telemetry is a live request explorer over a rolling window of 100,000
// spans — the example for data at a scale that breaks naive UI code.
//
// A background goroutine serves a synthetic fleet at ~1,400 requests a second
// into a fixed-size ring; the dashboard snapshots that window several times a
// second, re-filters and re-sorts all 100,000 rows, and redraws stat tiles, three
// live charts, and a virtualized table from the same single pass. Filter by
// service, route, host, or trace prefix; sort on any column; watch the Age
// column tick every frame. Nothing is precomputed and nothing is sampled.
//
// Three things it is built to show:
//
// - Virtualization. widget.LazyList mounts only the rows on screen, so the
// table costs the same at a hundred rows and a hundred thousand — and a
// column recomputed from the clock on every frame costs nothing.
//
// - Concurrency. The producer is an ordinary goroutine. The only coordination
// is a mutex held for the length of one copy, so the UI never waits on it.
//
// - Honest numbers. The dashboard shows how long its own rebuild took, so the
// claim is on screen rather than in this comment.
//
// go run ./examples/telemetry
package main
import (
"flag"
"log"
"os"
"path/filepath"
"time"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/gomono"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/theme"
)
func main() {
otlp := flag.String("otlp", "", "load an OpenTelemetry OTLP/JSON trace export instead of the synthetic fleet")
flag.Parse()
store := NewStore(9)
if *otlp != "" {
if err := load(store, *otlp); err != nil {
log.Fatal(err)
}
} else {
// Open on a full window rather than filling one over the minute-plus it
// would take in real time: the charts have a minute of history at once.
store.Fill(Window, time.Duration(Window)*time.Second/Rate)
stop := make(chan struct{})
defer close(stop)
go store.Produce(stop)
}
if err := app.Run(App{Store: store}, app.Config{
Title: "Fleet",
AppID: "com.gophics.telemetry",
Size: geom.Size{W: 1360, H: 860},
Font: goregular.TTF,
FontFamilies: map[string][]byte{
theme.FontBold: gobold.TTF,
"mono": gomono.TTF,
},
}); err != nil {
log.Fatal(err)
}
}
// load reads a capture off disk. Anything an OTLP/JSON exporter writes works;
// see otlp.go for what is read out of it.
func load(store *Store, path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
v := newVocab()
spans, err := DecodeOTLP(f, v)
if err != nil {
return err
}
useVocab(v)
store.Replace(spans, filepath.Base(path))
log.Printf("loaded %d spans from %s", len(spans), path)
return nil
}
dict.go
package main
import "sync"
// dict interns names so a Span can refer to a service, route, or host by a
// two-byte index instead of carrying a string. That is what keeps a span at 36
// bytes and, more importantly, what lets the text filter be resolved against a
// few dozen names once per query instead of against a hundred thousand rows.
//
// Dictionaries are built during setup — the synthetic fleet registers its whole
// vocabulary up front, and an OTLP load interns as it decodes — and are only
// read afterwards. The producer goroutine never interns, so the UI can read
// names without a lock. Add returns an error rather than growing past the index
// width so that invariant can't be broken quietly by a pathological input.
type dict struct {
mu sync.Mutex
names []string
idx map[string]int
}
const maxDictEntries = 1 << 16
// intern returns name's index, adding it if new. Names past the index width
// collapse onto the last entry rather than corrupting the mapping; a capture
// with 65,536 distinct routes is a broken capture, not a use case.
func (d *dict) intern(name string) uint16 {
d.mu.Lock()
defer d.mu.Unlock()
if d.idx == nil {
d.idx = make(map[string]int)
}
if i, ok := d.idx[name]; ok {
return uint16(i)
}
if len(d.names) >= maxDictEntries {
return uint16(len(d.names) - 1)
}
d.idx[name] = len(d.names)
d.names = append(d.names, name)
return uint16(len(d.names) - 1)
}
// Name returns the i-th name, or "?" if the index is out of range.
func (d *dict) Name(i uint16) string {
if int(i) >= len(d.names) {
return "?"
}
return d.names[i]
}
// Names is the whole vocabulary, in insertion order.
func (d *dict) Names() []string { return d.names }
// Len is how many distinct names have been interned.
func (d *dict) Len() int { return len(d.names) }
// vocab is the set of dictionaries one dataset is described by. A span's
// indices are only meaningful against the vocabulary it was created with, so
// loading a capture builds a fresh one and swaps it in wholesale — decoding into
// the live dictionaries instead would leave the previous dataset's services
// listed in the filter and its routes matchable, and a decode that failed
// halfway would leave a vocabulary that describes neither.
type vocab struct{ svc, route, host *dict }
func newVocab() *vocab { return &vocab{svc: &dict{}, route: &dict{}, host: &dict{}} }
// The vocabulary the UI and the query engine read. It is replaced only from the
// UI goroutine, and only while the producer is stopped (see Store.Replace).
var (
svcDict *dict
routeDict *dict
hostDict *dict
)
// useVocab installs v as the active vocabulary.
func useVocab(v *vocab) { svcDict, routeDict, hostDict = v.svc, v.route, v.host }
func init() { useVocab(newVocab()) }
otlp.go
package main
import (
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
)
// OTLP/JSON — the OpenTelemetry Protocol's JSON encoding — is what this loads.
// It is what an `otlphttp` exporter posts with Content-Type: application/json,
// what the Collector's file exporter writes, and what `telemetrygen` emits, so
// a real capture from a real service can be dropped straight into this demo.
//
// The shape is three levels deep: resourceSpans (one per producing process,
// carrying the resource attributes that identify it), each holding scopeSpans
// (one per instrumentation library), each holding the spans themselves.
//
// Two wrinkles the spec imposes and this decoder handles:
//
// - 64-bit integers are encoded as JSON *strings*, because JSON numbers can't
// carry them losslessly. Timestamps and int attributes therefore arrive
// quoted, and sometimes unquoted from encoders that ignore that rule, so
// every number here accepts both.
// - The HTTP semantic conventions were renamed in v1.21 (http.method →
// http.request.method, http.status_code → http.response.status_code, and so
// on). Captures in the wild use either, so both spellings are read.
//
// Files may hold one JSON object or a stream of them (the file exporter writes
// one per line); the decoder loops until EOF, so both work.
// sampleOTLP is a small, readable capture in the real wire format — 83 spans
// over five services, one of which is having a bad afternoon. It ships with the
// example so the OTLP path can be demonstrated with no file and no collector,
// including in the browser.
//
//go:embed otlp-sample.json
var sampleOTLP string
// DecodeOTLP reads OTLP/JSON traces and converts them to Spans, interning names
// into v as it goes. Timestamps are returned in milliseconds on the file's own
// clock; Store.Replace rebases them.
func DecodeOTLP(r io.Reader, v *vocab) ([]Span, error) {
dec := json.NewDecoder(r)
var out []Span
for {
var req otlpRequest
if err := dec.Decode(&req); err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, fmt.Errorf("otlp: %w", err)
}
for _, rs := range req.ResourceSpans {
out = append(out, rs.spans(v)...)
}
}
if len(out) == 0 {
return nil, errors.New("otlp: no spans found (is this an OTLP/JSON trace export?)")
}
return out, nil
}
type otlpRequest struct {
ResourceSpans []resourceSpans `json:"resourceSpans"`
}
type resourceSpans struct {
Resource struct {
Attributes []attr `json:"attributes"`
} `json:"resource"`
ScopeSpans []struct {
Spans []otlpSpan `json:"spans"`
} `json:"scopeSpans"`
}
// spans flattens one resource's spans, applying the resource-level identity
// (service name, host) to each.
func (rs resourceSpans) spans(v *vocab) []Span {
res := attrs(rs.Resource.Attributes)
service := res.first("service.name")
if service == "" {
service = "unknown"
}
resHost := res.first("host.name", "service.instance.id", "k8s.pod.name")
svc := v.svc.intern(service)
var out []Span
for _, ss := range rs.ScopeSpans {
for _, sp := range ss.Spans {
out = append(out, sp.decode(v, svc, resHost))
}
}
return out
}
type otlpSpan struct {
TraceID string `json:"traceId"`
Name string `json:"name"`
StartNanos json.Number `json:"startTimeUnixNano"`
EndNanos json.Number `json:"endTimeUnixNano"`
Attributes []attr `json:"attributes"`
Status struct {
Code json.Number `json:"code"`
} `json:"status"`
}
func (sp otlpSpan) decode(v *vocab, svc uint16, resHost string) Span {
a := attrs(sp.Attributes)
// Route: the low-cardinality template if the instrumentation recorded one,
// else the raw path, else the span name — which for HTTP servers is
// conventionally "GET /route" already.
route := a.first("http.route", "url.path", "http.target")
if method := a.first("http.request.method", "http.method"); method != "" && route != "" {
route = method + " " + route
}
if route == "" {
route = sp.Name
}
host := a.first("server.address", "net.host.name", "host.name")
if host == "" {
host = resHost
}
if host == "" {
host = "—"
}
start, end := u64(sp.StartNanos), u64(sp.EndNanos)
dur := int32(0)
if end > start {
dur = int32((end - start) / 1000) // nanoseconds → microseconds
}
out := Span{
At: int32(start / 1e6), // nanoseconds → milliseconds
Dur: dur,
Bytes: -1,
Svc: svc,
Route: v.route.intern(route),
Host: v.host.intern(host),
Code: status(a, sp.Status.Code),
}
if n := a.firstInt("http.response.body.size", "http.response_content_length", "http.response.size"); n >= 0 {
out.Bytes = int32(n)
}
// A short or malformed trace ID decodes to whatever prefix is valid; the
// rest stays zero rather than dropping the span.
if raw, err := hex.DecodeString(sp.TraceID); err == nil {
copy(out.Trace[:], raw)
}
return out
}
// status prefers the HTTP status code, and falls back to the span's own OTLP
// status — 2 is STATUS_CODE_ERROR — so non-HTTP spans still colour correctly.
func status(a attrs, spanStatus json.Number) uint16 {
if n := a.firstInt("http.response.status_code", "http.status_code"); n > 0 {
return uint16(n)
}
if spanStatus.String() == "2" || spanStatus.String() == "STATUS_CODE_ERROR" {
return 500
}
return 200
}
// --- Attributes --------------------------------------------------------------
// attr is one key/value pair. OTLP wraps every value in a type tag, so a string
// arrives as {"stringValue":"GET"} and an int as {"intValue":"200"} — quoted,
// per the 64-bit rule.
type attr struct {
Key string `json:"key"`
Value struct {
StringValue *string `json:"stringValue"`
IntValue *json.Number `json:"intValue"`
DoubleValue *json.Number `json:"doubleValue"`
BoolValue *bool `json:"boolValue"`
} `json:"value"`
}
func (a attr) str() string {
switch {
case a.Value.StringValue != nil:
return *a.Value.StringValue
case a.Value.IntValue != nil:
return a.Value.IntValue.String()
case a.Value.DoubleValue != nil:
return a.Value.DoubleValue.String()
case a.Value.BoolValue != nil:
return strconv.FormatBool(*a.Value.BoolValue)
}
return ""
}
type attrs []attr
// first returns the value of the first key present, trying each in turn — which
// is how the old and new semantic-convention spellings are both accepted.
func (as attrs) first(keys ...string) string {
for _, k := range keys {
for _, a := range as {
if a.Key == k {
if v := strings.TrimSpace(a.str()); v != "" {
return v
}
}
}
}
return ""
}
// firstInt is first, parsed; -1 means absent or unparseable.
func (as attrs) firstInt(keys ...string) int64 {
if v := as.first(keys...); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
return n
}
}
return -1
}
// u64 reads a JSON number that may have arrived quoted or bare.
func u64(n json.Number) uint64 {
v, err := strconv.ParseUint(strings.Trim(n.String(), `"`), 10, 64)
if err != nil {
return 0
}
return v
}
query.go
package main
import (
"bytes"
"math/bits"
"slices"
"strings"
"time"
)
// This file is the query engine: one pass over the snapshot that filters,
// aggregates, and (when the sort isn't chronological) sorts. It runs a few times
// a second on the UI goroutine, between frames, over the whole 100,000-span
// window — so everything here is written to stay well inside a frame budget.
// Sort columns. They are the table's column indices, so a header tap maps
// straight through.
const (
colTime = iota
colAge
colService
colRoute
colHost
colTrace
colStatus
colLatency
colBytes
numCols
)
// Status filter classes.
const (
statusAny = iota
statusOK
statusClient
statusServer
)
// Query is everything the filter bar and the table header contribute.
type Query struct {
Text string
Status int
Svc int // -1 for every service
SortCol int
Desc bool
}
// Buckets for the latency histogram the UI draws, in microseconds. The scale is
// logarithmic because request latency is: a linear histogram of these spans is a
// single bar at the left and eleven empty ones.
var histEdges = [...]int32{1e3, 2e3, 5e3, 1e4, 2e4, 5e4, 1e5, 2e5, 5e5, 1e6, 2e6}
var histLabels = [...]string{"<1ms", "2", "5", "10", "20", "50", "100", "200", "500", "1s", "2s", "2s+"}
// ThroughputSecs is how many seconds of history the throughput chart shows.
const ThroughputSecs = 60
// Result is one query's output: the row order to display, plus every number the
// dashboard shows. They come from the same pass, because a second pass over
// 100,000 rows to compute the charts would cost as much as the first.
type Result struct {
Rows []Span // the snapshot the view indexes into
View []int32 // matching rows, in display order
Now int32
Matching int
Client int // 4xx
Server int // 5xx
P50 int32
P95 int32
P99 int32
Hist [len(histLabels)]int32
PerSec [ThroughputSecs]int32
SvcP95 []int32 // per service, indexed by dictionary id
SvcCount []int32
Elapsed time.Duration
SortedRow bool // whether this rebuild had to sort (see Run)
}
// Run filters, aggregates, and orders the snapshot.
func Run(rows []Span, now int32, q Query) Result {
start := time.Now()
res := Result{Rows: rows, Now: now}
// The text filter is resolved against the dictionaries once, up front, into
// a bitset per dictionary. After this the 100,000-row scan is integer
// lookups — it never touches a string, which is what keeps a filter over the
// whole window off the frame budget.
f := compile(q.Text)
nsvc := svcDict.Len()
res.SvcP95 = make([]int32, nsvc)
res.SvcCount = make([]int32, nsvc)
view := make([]int32, 0, len(rows))
var fine [200]int32
svcFine := make([][200]int32, nsvc)
for i := range rows {
sp := rows[i]
if q.Svc >= 0 && int(sp.Svc) != q.Svc {
continue
}
switch q.Status {
case statusOK:
if !sp.OK() {
continue
}
case statusClient:
if sp.Code < 400 || sp.Code >= 500 {
continue
}
case statusServer:
if !sp.Failed() {
continue
}
}
if f.on && !f.match(sp) {
continue
}
view = append(view, int32(i))
b := latBucket(sp.Dur)
fine[b]++
svcFine[sp.Svc][b]++
res.SvcCount[sp.Svc]++
switch {
case sp.Failed():
res.Server++
case sp.Code >= 400:
res.Client++
}
if age := now - sp.At; age >= 0 && age < ThroughputSecs*1000 {
res.PerSec[ThroughputSecs-1-age/1000]++
}
}
res.View = view
res.Matching = len(view)
res.P50 = percentile(&fine, res.Matching, 0.50)
res.P95 = percentile(&fine, res.Matching, 0.95)
res.P99 = percentile(&fine, res.Matching, 0.99)
for i := range svcFine {
res.SvcP95[i] = percentile(&svcFine[i], int(res.SvcCount[i]), 0.95)
}
for b, n := range fine {
if n > 0 {
res.Hist[histBucket(bucketUs(b))] += n
}
}
res.SortedRow = sortView(view, rows, q)
if q.SortCol == colTime || q.SortCol == colAge {
// Chronological order is the order the ring already holds, so the
// default view — newest first — needs no sort at all, only a reverse.
// It is worth the special case: it is the order the table opens in and
// the one a live tail sits in, so the common path stays free.
if (q.SortCol == colTime) == q.Desc {
slices.Reverse(view)
}
}
res.Elapsed = time.Since(start)
return res
}
// sortView orders the view for any non-chronological column, and reports
// whether it actually had to sort.
func sortView(view []int32, rows []Span, q Query) bool {
if q.SortCol == colTime || q.SortCol == colAge {
return false
}
cmp := func(a, b int32) int {
x, y := rows[a], rows[b]
var d int
switch q.SortCol {
case colService:
d = strings.Compare(svcDict.Name(x.Svc), svcDict.Name(y.Svc))
case colRoute:
d = strings.Compare(routeDict.Name(x.Route), routeDict.Name(y.Route))
case colHost:
d = strings.Compare(hostDict.Name(x.Host), hostDict.Name(y.Host))
case colTrace:
d = bytes.Compare(x.Trace[:], y.Trace[:])
case colStatus:
d = int(x.Code) - int(y.Code)
case colLatency:
d = int(x.Dur - y.Dur)
case colBytes:
d = int(x.Bytes - y.Bytes)
}
if d == 0 { // ties break by time, so the order is total and stable
d = int(x.At - y.At)
}
if q.Desc {
return -d
}
return d
}
slices.SortFunc(view, cmp)
return true
}
// filter is a compiled search: one bitset per dictionary plus an optional trace
// prefix. Matching a hex prefix is done on the raw bytes — the query's nibbles
// against the ID's leading ones — so no span is ever formatted to be searched.
type filter struct {
on bool
svc []bool
route []bool
host []bool
tracePfx [16]byte
traceNibs int // how many leading hex digits of the query to honour
}
// match tests one span. It is the innermost thing in the scan, so it is nothing
// but slice indexing and, at most, a 16-byte compare.
func (f *filter) match(sp Span) bool {
if f.svc[sp.Svc] || f.route[sp.Route] || f.host[sp.Host] {
return true
}
if f.traceNibs == 0 {
return false
}
whole := f.traceNibs / 2
if !bytes.Equal(sp.Trace[:whole], f.tracePfx[:whole]) {
return false
}
if f.traceNibs%2 == 1 { // an odd query length ends on a half byte
return sp.Trace[whole]>>4 == f.tracePfx[whole]>>4
}
return true
}
// compile resolves the search text against the dictionaries once, up front.
// After this the 100,000-row scan is integer lookups — it never touches a
// string, which is what keeps a filter over the whole window off the frame
// budget however fast someone types.
func compile(text string) filter {
f := filter{
svc: make([]bool, svcDict.Len()),
route: make([]bool, routeDict.Len()),
host: make([]bool, hostDict.Len()),
}
q := strings.ToLower(strings.TrimSpace(text))
if q == "" {
return f
}
f.on = true
for i, n := range svcDict.Names() {
f.svc[i] = strings.Contains(strings.ToLower(n), q)
}
for i, n := range routeDict.Names() {
f.route[i] = strings.Contains(strings.ToLower(n), q)
}
for i, n := range hostDict.Names() {
f.host[i] = strings.Contains(strings.ToLower(n), q)
}
if len(q) <= 32 {
if nibs, ok := parseNibbles(q, f.tracePfx[:]); ok {
f.traceNibs = nibs
}
}
return f
}
// parseNibbles writes q's hex digits into dst, high nibble first, and reports
// how many it wrote. It fails on any non-hex character, which is how a search
// for "search" is treated as a name and one for "4afb" as a trace prefix.
func parseNibbles(q string, dst []byte) (int, bool) {
for i := 0; i < len(q); i++ {
var v byte
switch c := q[i]; {
case c >= '0' && c <= '9':
v = c - '0'
case c >= 'a' && c <= 'f':
v = c - 'a' + 10
default:
return 0, false
}
if i%2 == 0 {
dst[i/2] = v << 4
} else {
dst[i/2] |= v
}
}
return len(q), true
}
// latBucket maps microseconds to one of ~200 log-spaced buckets with integer
// operations only: the bucket's octave is the position of the leading set bit,
// and the three bits under it split that octave into eight — about 9%
// resolution, which is finer than a percentile off a live sample deserves and
// costs a shift and a mask instead of a call to math.Log.
func latBucket(us int32) int {
u := uint32(us)
if u < 16 {
return int(u)
}
e := bits.Len32(u) // 5..32
return 16 + (e-5)*8 + int((u>>(e-4))&7)
}
// bucketUs is latBucket's inverse: the middle of the range a bucket covers.
func bucketUs(b int) int32 {
if b < 16 {
return int32(b)
}
b -= 16
shift := b/8 + 1
sub := uint32(b % 8)
return int32(((8+sub)<<shift + (9+sub)<<shift) / 2)
}
// percentile reads a quantile straight off the histogram — O(buckets), not
// O(n log n), and no copy of the matching durations to sort.
func percentile(h *[200]int32, n int, p float64) int32 {
if n == 0 {
return 0
}
want := int32(float64(n) * p)
var cum int32
for b, c := range h {
if cum += c; cum > want {
return bucketUs(b)
}
}
return bucketUs(len(h) - 1)
}
// histBucket places a duration in one of the twelve display buckets.
func histBucket(us int32) int {
for i, e := range histEdges {
if us < e {
return i
}
}
return len(histEdges)
}
store.go
package main
import (
"math"
"math/rand"
"slices"
"sync"
"time"
)
// This file is the data side of the demo: a fixed-size window of request spans
// that a background goroutine keeps filling while the UI reads it. It is the
// example's argument for Go's concurrency story — the producer is an ordinary
// goroutine on an ordinary ticker, the UI never blocks on it, and the only
// coordination is one mutex held for the length of a copy.
//
// Spans come from one of two places, through the same door: the synthetic fleet
// below, or a real OpenTelemetry capture (see otlp.go).
const (
// Window is how many spans the store keeps. Older ones fall out the back,
// so memory is bounded no matter how long the demo runs: at 36 bytes a span
// the whole window is about 3.6 MB, small enough to snapshot wholesale
// several times a second.
Window = 100_000
// Rate is how many spans a second the synthetic fleet "serves".
Rate = 1400
)
// Span is one served request. The service, route, and host are dictionary
// indices rather than strings (see dict.go), so a filter scan over the whole
// window never touches a string — the difference between a 100,000-row filter
// that is imperceptible and one that is not.
//
// Trace is the full 16-byte OpenTelemetry trace ID, kept whole rather than
// truncated to something table-sized: it is the one field you copy out of a
// viewer and paste into another tool, so a shortened one would be useless.
type Span struct {
At int32 // ms since the store's epoch
Dur int32 // microseconds
Bytes int32 // response size; -1 when the capture didn't say
Trace [16]byte
Svc uint16
Route uint16
Host uint16
Code uint16 // HTTP status
}
// OK reports whether the request succeeded (2xx/3xx).
func (s Span) OK() bool { return s.Code < 400 }
// Failed reports whether the server itself failed (5xx) — the class that means
// the fleet is broken rather than the caller.
func (s Span) Failed() bool { return s.Code >= 500 }
// TraceHex renders the trace ID the way every tracing UI does.
func (s Span) TraceHex() string {
const hex = "0123456789abcdef"
out := make([]byte, 32)
for i, b := range s.Trace {
out[i*2], out[i*2+1] = hex[b>>4], hex[b&15]
}
return string(out)
}
// The synthetic fleet's vocabulary. It is interned up front so the producer
// goroutine only ever emits indices that already exist.
var (
genServices = []string{"checkout", "catalog", "search", "identity", "payments", "inventory", "shipping", "reviews"}
genRoutes = []string{
"GET /v1/items", "GET /v1/items/{id}", "POST /v1/cart", "POST /v1/checkout",
"GET /v1/search", "POST /v1/auth/token", "GET /v1/me", "POST /v1/payments",
"GET /v1/stock/{sku}", "PUT /v1/stock/{sku}", "GET /v1/shipments/{id}",
"POST /v1/reviews", "GET /v1/reviews", "DELETE /v1/cart/{id}",
}
genRegions = []string{"iad", "ord", "sfo", "fra", "sin"}
)
// baseLatency is each generated route's healthy median, in microseconds.
// Checkout, search, and payments are the slow ones, which is what gives the
// latency histogram a shape instead of a single spike.
var baseLatency = [...]float64{
1800, 2400, 5200, 42000, 68000, 9500, 2100, 51000,
3300, 7400, 4100, 6800, 2900, 3600,
}
// Store is the rolling window. It is a ring: writes overwrite the oldest span,
// so the buffer is allocated once at startup and never grows.
type Store struct {
mu sync.Mutex
buf []Span
n uint64 // total spans ever written
epoch time.Time
// Source names where the data came from, for the header line.
source string
// paused stops the synthetic producer. Loading a capture sets it: a window
// that mixed a fixed capture with a live generator would be neither, and
// the generator's span indices refer to a vocabulary the load replaced.
paused bool
rng *rand.Rand
svcIDs []uint16
routeIDs []uint16
hostIDs []uint16
degraded int // the service currently having a bad time
degrade float64 // 0..1, how bad
}
func NewStore(seed int64) *Store {
s := &Store{
buf: make([]Span, Window),
epoch: time.Now(),
source: "synthetic fleet",
rng: rand.New(rand.NewSource(seed)),
degraded: 3,
// Open mid-incident: a dashboard whose charts are all flat says nothing
// about whether it would show you anything if they weren't.
degrade: 0.5,
}
v := newVocab()
s.registerVocabulary(v)
useVocab(v)
return s
}
// registerVocabulary interns every name the generator can emit, so that
// producing a span is pure arithmetic and the dictionaries are immutable while
// the producer runs.
func (s *Store) registerVocabulary(v *vocab) {
for _, n := range genServices {
s.svcIDs = append(s.svcIDs, v.svc.intern(n))
}
for _, n := range genRoutes {
s.routeIDs = append(s.routeIDs, v.route.intern(n))
}
for _, r := range genRegions {
for i := range 8 {
s.hostIDs = append(s.hostIDs, v.host.intern(r+"-"+string(rune('a'+i))+string(rune('0'+i%4))))
}
}
}
// Source describes where the spans came from.
func (s *Store) Source() string { return s.source }
// Len is how many spans the window currently holds.
func (s *Store) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.len()
}
func (s *Store) len() int {
if s.n < Window {
return int(s.n)
}
return Window
}
// Snapshot copies the window, oldest first, into dst (reusing its capacity) and
// returns it along with the store's clock reading at the moment of the copy.
//
// Copying looks wasteful next to reading the ring in place under a read lock,
// and it is the point: the UI gets a slice nobody else will touch, so it can
// filter, sort, and re-read it across many frames while the producer keeps
// writing. The lock is held for one memmove of a few megabytes — tens of
// microseconds — instead of for the milliseconds a sort would take.
func (s *Store) Snapshot(dst []Span) ([]Span, int32) {
s.mu.Lock()
defer s.mu.Unlock()
n := s.len()
if cap(dst) < n {
dst = make([]Span, n)
}
dst = dst[:n]
if s.n < Window {
copy(dst, s.buf[:n])
} else {
// The ring's oldest entry is wherever the next write will land.
head := int(s.n % Window)
copy(dst, s.buf[head:])
copy(dst[Window-head:], s.buf[:head])
}
return dst, s.now()
}
func (s *Store) now() int32 { return int32(time.Since(s.epoch).Milliseconds()) }
// Now is the store's clock, in the same units as Span.At. The UI reads it every
// frame for the live Age column — which is the cheap half of "live": ages
// advance on every frame without the view being rebuilt at all.
func (s *Store) Now() int32 { return s.now() }
// Total is how many spans have ever been written, including those the ring has
// since evicted. Sampling it over time gives the observed ingest rate.
func (s *Store) Total() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.n
}
// Wall converts a span's timestamp to a wall clock time.
func (s *Store) Wall(at int32) time.Time { return s.epoch.Add(time.Duration(at) * time.Millisecond) }
// Replace swaps the window's contents for a decoded capture, keeping the newest
// Window spans and rebasing the clock so the newest span reads as "now" — a
// capture from last Tuesday should still show a sensible Age column.
//
// Spans are sorted by time first. An OTLP file is grouped by service, not
// ordered by clock, and the ring being chronological is not a cosmetic detail:
// it is the invariant the query engine's default view relies on to skip sorting
// entirely (see Run). Loading an unsorted capture without this shows the last
// service in the file as though it were the most recent traffic.
func (s *Store) Replace(spans []Span, source string) {
s.mu.Lock()
defer s.mu.Unlock()
slices.SortFunc(spans, func(a, b Span) int { return int(a.At - b.At) })
if len(spans) > Window {
spans = spans[len(spans)-Window:]
}
newest := int32(0)
if len(spans) > 0 {
newest = spans[len(spans)-1].At
}
s.n = 0
s.epoch = time.Now()
for _, sp := range spans {
sp.At -= newest // newest lands at 0, everything else is negative
s.write(sp)
}
s.source = source
s.paused = true
}
// Fill writes n spans immediately, spread backwards over the given duration —
// the cold start, so the demo opens on a full window and populated charts
// rather than filling in over a minute and a half.
//
// Traffic is warped rather than spread evenly: a slow swell plus a faster
// ripple, so the throughput chart opens with a shape instead of the dead flat
// line a uniform fill produces. The incident drifts through the backfill too,
// which is what puts one service's bar above the others from the first frame.
func (s *Store) Fill(n int, over time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
span := float64(over.Milliseconds())
for i := range n {
if i%180 == 0 {
s.drift()
}
u := float64(i) / float64(n) // 0 = oldest, 1 = now
// Warping u compresses spans into some stretches of the timeline and
// thins them out in others, which is what a varying arrival rate looks
// like after the fact. The amplitudes are kept small enough that dw/du
// never approaches zero: a warp that briefly ran backwards would pile
// thousands of spans onto one millisecond and spike the chart.
w := u + 0.022*math.Sin(u*7.1) + 0.010*math.Sin(u*19.3+1.2)
s.write(s.gen(int32(-span * (1 - w))))
}
}
// Produce runs the fleet until stop is closed, writing a batch every tick. It is
// meant to be started with `go`.
func (s *Store) Produce(stop <-chan struct{}) {
const tick = 25 * time.Millisecond
t := time.NewTicker(tick)
defer t.Stop()
batch := int(Rate * tick.Seconds())
for {
select {
case <-stop:
return
case <-t.C:
s.mu.Lock()
if s.paused {
s.mu.Unlock()
continue
}
s.drift()
now := s.now()
for range batch {
s.write(s.gen(now))
}
s.mu.Unlock()
}
}
}
// write appends one span to the ring. The caller holds the lock.
func (s *Store) write(sp Span) {
s.buf[s.n%Window] = sp
s.n++
}
// drift moves the incident around: the degraded service's severity wanders, and
// when it recovers another service is picked. Without it every chart settles
// into the same shape within seconds and the demo has nothing to show.
func (s *Store) drift() {
s.degrade += (s.rng.Float64() - 0.47) * 0.02
switch {
case s.degrade > 1:
s.degrade = 1
case s.degrade < 0:
s.degrade = 0
s.degraded = s.rng.Intn(len(genServices))
}
}
// gen synthesizes one plausible span at time `at`. Latency is lognormal around
// the route's median — the distribution real request latency actually follows,
// and the reason the histogram needs log-spaced buckets to read at all.
func (s *Store) gen(at int32) Span {
route := s.rng.Intn(len(genRoutes))
svc := route % len(genServices)
if s.rng.Intn(4) == 0 { // routes aren't perfectly partitioned across services
svc = s.rng.Intn(len(genServices))
}
sick := 0.0
if svc == s.degraded {
sick = s.degrade
}
// A lognormal draw: exp(normal) has the long right tail latency has.
dur := baseLatency[route] * math.Exp(s.rng.NormFloat64()*0.62) * (1 + sick*6)
if s.rng.Float64() < 0.004+sick*0.03 { // the tail: a retry, a cold cache
dur *= 4 + s.rng.Float64()*8
}
code := uint16(200)
switch r := s.rng.Float64(); {
case r < 0.004+sick*0.14:
code = [...]uint16{500, 502, 503}[s.rng.Intn(3)]
case r < 0.02+sick*0.05:
code = [...]uint16{400, 401, 404, 429}[s.rng.Intn(4)]
case r < 0.08:
code = [...]uint16{201, 204, 304}[s.rng.Intn(3)]
}
sp := Span{
At: at,
Dur: int32(dur),
Bytes: int32(180 + s.rng.ExpFloat64()*4200),
Svc: s.svcIDs[svc],
Route: s.routeIDs[route],
Host: s.hostIDs[s.rng.Intn(len(s.hostIDs))],
Code: code,
}
// A real trace ID is 16 random bytes; filling it from the same seeded source
// keeps the whole generator reproducible.
s.rng.Read(sp.Trace[:])
return sp
}
ui.go
package main
import (
"bytes"
"fmt"
"io"
"sort"
"strings"
"time"
"github.com/doug/gophics/chart"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/paint"
"github.com/doug/gophics/shell"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// rebuildEvery bounds how often the whole window is re-filtered while data is
// arriving. Every rebuild is a full pass over 100,000 spans, so this is the knob
// that trades freshness against the frame budget: at four a second the scan
// costs a small fraction of one core, and the table still reads as a live tail.
// Filter and sort changes bypass it — those must feel instant.
const rebuildEvery = 250 * time.Millisecond
type App struct{ Store *Store }
func (App) CreateState() widget.State { return &dash{} }
type dash struct {
widget.StateBase[App]
ctx widget.Ctx
store *Store
snap []Span // reused across rebuilds so a snapshot allocates nothing
res Result
q Query
live bool
dirty bool // the query changed; rebuild on the next tick regardless
since time.Duration
selected int
loadErr string
// Test hooks. lastCols records the column set the last build chose, and
// cellCount (when non-nil) counts cells built — which is how the
// virtualization claim is tested rather than asserted.
lastCols []int
cellCount *int
// Ingest rate, sampled from the store's running total.
lastTotal uint64
lastAt time.Time
ingest float64
}
// stateHook, if set, receives the state on mount — for tests to drive input.
var stateHook func(*dash)
func (s *dash) Init(ctx widget.Ctx) {
s.ctx = ctx
s.store = s.W().Store
s.q = Query{Svc: -1, SortCol: colTime, Desc: true}
s.live = true
s.selected = -1
s.lastAt = time.Now()
s.lastTotal = s.store.Total()
s.rebuild()
ctx.AddTicker(s)
if stateHook != nil {
stateHook(s)
}
}
// Tick decides whether this frame needs a new view. It always asks for a
// rebuild, not just a repaint: the Age column is derived from the clock rather
// than from the data, and it is produced in cell() during Build, so a repaint
// alone redraws the same strings.
//
// That distinction was a visible bug. Invalidate asks for a frame; only
// SetState marks the tree for rebuilding. With a repaint alone the ages sat
// still until something else dirtied a row — and hovering does dirty exactly
// one row, so the row under the pointer would advance its age while every row
// around it stayed where it was.
//
// Rebuilding every frame is what the design already assumes: cell() runs only
// for the rows the viewport shows, twenty-odd of them, however many rows the
// table holds.
func (s *dash) Tick(dt float64) bool {
s.since += time.Duration(dt * float64(time.Second))
if s.dirty || (s.live && s.since >= rebuildEvery) {
s.rebuild()
}
if now := time.Now(); now.Sub(s.lastAt) >= time.Second {
total := s.store.Total()
s.ingest = float64(total-s.lastTotal) / now.Sub(s.lastAt).Seconds()
s.lastTotal, s.lastAt = total, now
}
s.SetState(nil) // rebuild: Age is computed in cell(), not at paint time
return true
}
func (s *dash) rebuild() {
snap, now := s.store.Snapshot(s.snap)
s.snap = snap
s.res = Run(snap, now, s.q)
s.since, s.dirty = 0, false
}
// setQuery applies a filter or sort change and forces an immediate rebuild.
func (s *dash) setQuery(f func(*Query)) {
s.SetState(func() {
f(&s.q)
s.dirty = true
s.selected = -1
})
}
// --- Build -------------------------------------------------------------------
func (s *dash) Build(ctx widget.Ctx) widget.Widget {
th := theme.Auto(ctx)
return widget.Provide[theme.Theme]{Value: th, Child: widget.Fill{Color: th.Bg,
Child: widget.Padding{All: 18, Child: widget.LayoutBuilder{
Build: func(cs layout.Constraints) widget.Widget {
narrow := cs.BoundedW() && cs.Max.W < 900
return widget.Flex{
Axis: layout.Vertical,
CrossAlign: layout.CrossStretch,
Children: []widget.Widget{
s.header(th),
widget.Sized{H: 14},
s.tiles(th, narrow),
widget.Sized{H: 14},
widget.Sized{H: 196, Child: s.charts(th, narrow)},
widget.Sized{H: 14},
s.filters(th, narrow),
widget.Sized{H: 12},
widget.Expand(theme.Card{Pad: 10, Child: s.table(th, narrow)}),
s.detail(th),
},
}
},
}},
}}
}
func (s *dash) header(th theme.Theme) widget.Widget {
sub := fmt.Sprintf("%s spans · %s", commas(int64(len(s.res.Rows))), s.store.Source())
if s.ingest > 0 {
sub += fmt.Sprintf(" · %s/s arriving", commas(int64(s.ingest+0.5)))
}
if s.loadErr != "" {
sub = s.loadErr
}
return widget.Row(
widget.Expand(widget.Flex{
Axis: layout.Vertical,
CrossAlign: layout.CrossStart,
Children: []widget.Widget{
widget.Text{Value: "Fleet", Font: theme.FontBold, Size: th.Type.Title, Color: th.Text},
widget.Sized{H: 2},
widget.Text{Value: sub, Size: th.Type.Caption, Color: th.Muted},
},
}),
s.loadButtons(),
widget.Text{Value: "Live", Size: th.Type.Label, Color: th.Muted},
widget.Sized{W: 8},
theme.Switch{On: s.live, Label: "Live tail",
OnChange: func(v bool) { s.SetState(func() { s.live = v; s.dirty = v }) }},
)
}
// tiles is the headline row. Every number on it comes out of the same pass that
// built the table's row order, so the dashboard and the grid can never disagree.
func (s *dash) tiles(th theme.Theme, narrow bool) widget.Widget {
r := s.res
errRate := 0.0
if r.Matching > 0 {
errRate = float64(r.Server) / float64(r.Matching) * 100
}
errTone := th.Success
if errRate > 1 {
errTone = th.Warning
}
if errRate > 4 {
errTone = th.Danger
}
cells := []widget.Widget{
tile(th, "Matching", commas(int64(r.Matching)), th.Text),
tile(th, "p50", fmtDur(r.P50), th.Text),
tile(th, "p95", fmtDur(r.P95), th.Text),
tile(th, "p99", fmtDur(r.P99), th.Text),
tile(th, "5xx", fmt.Sprintf("%.2f%%", errRate), errTone),
tile(th, "Rebuild", fmt.Sprintf("%.1f ms", float64(r.Elapsed.Microseconds())/1000), th.Muted),
}
if narrow { // four across reads better than six squeezed
cells = cells[:4]
}
kids := make([]widget.Widget, 0, len(cells)*2)
for i, c := range cells {
if i > 0 {
kids = append(kids, widget.Sized{W: 10})
}
kids = append(kids, widget.Expand(c))
}
row := widget.Row(kids...)
row.CrossAlign = layout.CrossStretch
return row
}
func tile(th theme.Theme, label, value string, tone paint.Color) widget.Widget {
return theme.Card{Pad: 10, Child: widget.Flex{
Axis: layout.Vertical,
CrossAlign: layout.CrossStart,
Children: []widget.Widget{
widget.Text{Value: label, Size: th.Type.Caption, Color: th.Muted},
widget.Sized{H: 3},
widget.Text{Value: value, Font: "mono", Size: th.Type.Heading, Color: tone},
},
}}
}
func (s *dash) charts(th theme.Theme, narrow bool) widget.Widget {
if narrow {
return chartCard(th, "Throughput", s.throughput(th))
}
order, capped := s.svcOrder()
row := widget.Row(
widget.Flexible{Flex: 3, Child: chartCard(th, "Throughput · spans/s", s.throughput(th))},
widget.Sized{W: 12},
widget.Flexible{Flex: 3, Child: chartCard(th, "Latency distribution", s.latency(th))},
widget.Sized{W: 12},
widget.Flexible{Flex: 3, Child: chartCard(th, svcTitle(capped), s.byService(th, order))},
)
row.CrossAlign = layout.CrossStretch
return row
}
func chartCard(th theme.Theme, title string, c widget.Widget) widget.Widget {
return theme.Card{Pad: 12, Child: widget.Flex{
Axis: layout.Vertical,
CrossAlign: layout.CrossStretch,
Children: []widget.Widget{
widget.Text{Value: title, Size: th.Type.Label, Color: th.Muted},
widget.Sized{H: 8},
widget.Expand(c),
},
}}
}
// throughput is the last minute of arrivals, one point a second. It is drawn
// from the filtered set, so narrowing to one service narrows the chart with it.
func (s *dash) throughput(th theme.Theme) widget.Widget {
data := make([]chart.Datum, 0, ThroughputSecs)
// The newest second is still filling, so it always reads low; drop it.
for i := range ThroughputSecs - 1 {
data = append(data, chart.Datum{X: float64(i - (ThroughputSecs - 1)), Y: float64(s.res.PerSec[i])})
}
col := th.ChartAt(0)
return chart.Chart{
Marks: []chart.Mark{
chart.AreaMark{Data: data, Color: col, Alpha: 0.18},
chart.LineMark{Data: data, Color: col, Width: 2},
},
XAxis: chart.Axis{Ticks: 4, Format: func(v float64) string { return fmt.Sprintf("%.0fs", v) }},
YAxis: chart.Axis{Ticks: 3},
LabelColor: th.Text, AxisColor: th.Muted, GridColor: th.Border,
}
}
// latency is the log-bucketed histogram. Bars are tinted by how slow the bucket
// is, so the shape of the tail is legible without reading the axis.
func (s *dash) latency(th theme.Theme) widget.Widget {
pairs := make([]chart.Pair, 0, len(histLabels))
for i, l := range histLabels {
pairs = append(pairs, chart.Pair{Label: l, Value: float64(s.res.Hist[i])})
}
data := chart.Pairs(pairs)
for i := range data {
switch {
case i >= len(histLabels)-2:
data[i].Color = th.Danger
case i >= len(histLabels)-4:
data[i].Color = th.Warning
default:
data[i].Color = th.ChartAt(1)
}
}
return chart.Chart{
Marks: []chart.Mark{chart.BarMark{Data: data}},
XAxis: chart.Axis{Ticks: len(histLabels)},
YAxis: chart.Axis{Ticks: 3},
LabelColor: th.Text, AxisColor: th.Muted, GridColor: th.Border,
}
}
// svcOrder picks which services the p95 chart shows: the busiest, in dictionary
// order so the bars don't reshuffle between rebuilds. It reports the total when
// it had to cap, so the title can admit it.
func (s *dash) svcOrder() (order []int, capped int) {
order = make([]int, len(s.res.SvcCount))
for i := range order {
order[i] = i
}
sort.SliceStable(order, func(a, b int) bool { return s.res.SvcCount[order[a]] > s.res.SvcCount[order[b]] })
// Services with nothing in the current filter would draw as empty slots.
for len(order) > 0 && s.res.SvcCount[order[len(order)-1]] == 0 {
order = order[:len(order)-1]
}
if len(order) > maxSvcBars {
capped, order = len(order), order[:maxSvcBars]
}
sort.Ints(order)
return order, capped
}
func svcTitle(capped int) string {
if capped == 0 {
return "p95 by service"
}
return fmt.Sprintf("p95 by service · top %d of %d", maxSvcBars, capped)
}
// byService is where a fleet-wide incident becomes obvious: one service's p95
// climbs away from the others and its bar turns.
func (s *dash) byService(th theme.Theme, order []int) widget.Widget {
pairs := make([]chart.Pair, 0, len(order))
for _, i := range order {
pairs = append(pairs, chart.Pair{Label: shortName(svcDict.Name(uint16(i))), Value: float64(s.res.SvcP95[i]) / 1000})
}
data := chart.Pairs(pairs)
for i := range data {
switch {
case data[i].Y > 400:
data[i].Color = th.Danger
case data[i].Y > 150:
data[i].Color = th.Warning
default:
data[i].Color = th.ChartAt(2)
}
}
return chart.Chart{
Marks: []chart.Mark{chart.BarMark{Data: data}},
XAxis: chart.Axis{Ticks: len(data)},
YAxis: chart.Axis{Ticks: 3, Format: func(v float64) string { return fmt.Sprintf("%.0fms", v) }},
LabelColor: th.Text, AxisColor: th.Muted, GridColor: th.Border,
}
}
var statusNames = []string{"All", "2xx", "4xx", "5xx"}
func (s *dash) filters(th theme.Theme, narrow bool) widget.Widget {
svcOpts := append([]string{"All services"}, svcDict.Names()...)
search := theme.Field{
Value: s.q.Text,
Placeholder: "Filter by service, route, host, or trace prefix…",
OnChange: func(v string) { s.setQuery(func(q *Query) { q.Text = v }) },
}
status := theme.Segmented{Options: statusNames, Selected: s.q.Status,
OnChange: func(i int) { s.setQuery(func(q *Query) { q.Status = i }) }}
svc := theme.Dropdown{Options: svcOpts, Selected: s.q.Svc + 1,
OnChange: func(i int) { s.setQuery(func(q *Query) { q.Svc = i - 1 }) }}
if narrow {
return widget.Flex{
Axis: layout.Vertical,
CrossAlign: layout.CrossStretch,
Children: []widget.Widget{
search,
widget.Sized{H: 8},
status,
},
}
}
row := widget.Row(
widget.Expand(search),
widget.Sized{W: 10},
widget.Sized{W: 230, Child: status},
widget.Sized{W: 10},
widget.Sized{W: 170, Child: svc},
)
row.CrossAlign = layout.CrossCenter
return row
}
// fullCols is the desktop column set. narrowCols keeps only what survives a
// phone-width table; both index the same colX constants, so a header tap sorts
// the same way in either layout.
var (
fullCols = []int{colTime, colAge, colService, colRoute, colHost, colTrace, colStatus, colLatency, colBytes}
narrowCols = []int{colTime, colService, colStatus, colLatency}
)
// Fixed widths are sized to their widest real content plus the table's column
// gap — a monospaced "11:23:54.728" is about 94 points at the label size, and a
// column narrower than that lets the timestamp run into the next one.
var colSpec = map[int]theme.Col{
colTime: {Title: "Time", Width: 112},
colAge: {Title: "Age", Width: 62, Align: theme.AlignEnd},
colService: {Title: "Service", Flex: 1},
colRoute: {Title: "Route", Flex: 3},
colHost: {Title: "Host", Width: 96},
colTrace: {Title: "Trace", Width: 84},
colStatus: {Title: "Status", Width: 66, Align: theme.AlignCenter},
colLatency: {Title: "Latency", Width: 86, Align: theme.AlignEnd},
colBytes: {Title: "Bytes", Width: 82, Align: theme.AlignEnd},
}
func (s *dash) table(th theme.Theme, narrow bool) widget.Widget {
set := fullCols
if narrow {
set = narrowCols
}
s.lastCols = set
cols := make([]theme.Col, len(set))
for i, c := range set {
cols[i] = colSpec[c]
}
// Sorting is reported against the logical column, not the visible one, so
// the indicator lands on the right header after a layout change.
sortAt := -1
for i, c := range set {
if c == s.q.SortCol {
sortAt = i
}
}
return theme.Table{
Columns: cols,
Count: len(s.res.View),
RowHeight: 28,
Selectable: true,
Selected: s.selected,
OnTapRow: func(i int) { s.SetState(func() { s.selected = i }) },
Sortable: true,
SortCol: sortAt,
SortDesc: s.q.Desc,
OnSort: func(i int, desc bool) {
s.setQuery(func(q *Query) { q.SortCol, q.Desc = set[i], desc })
},
Cell: func(row, col int) widget.Widget { return s.cell(th, row, set[col]) },
}
}
// cell builds one visible cell. It is called only for rows the viewport shows —
// twenty-odd of them — which is what makes a column like Age, recomputed from
// the clock on every frame, cost nothing across a hundred thousand rows.
func (s *dash) cell(th theme.Theme, row, col int) widget.Widget {
if row < 0 || row >= len(s.res.View) {
return nil
}
if s.cellCount != nil {
*s.cellCount++
}
sp := s.res.Rows[s.res.View[row]]
mono := func(txt string, c paint.Color) widget.Widget {
return widget.Text{Value: txt, Font: "mono", Size: th.Type.Label, Color: c}
}
switch col {
case colTime:
return mono(s.store.Wall(sp.At).Format("15:04:05.000"), th.Muted)
case colAge:
return mono(fmtAge(s.store.Now()-sp.At), th.Muted)
case colService:
return widget.Text{Value: svcDict.Name(sp.Svc), Size: th.Type.Label, Color: th.Text, Ellipsis: true, MaxLines: 1}
case colRoute:
return widget.Text{Value: routeDict.Name(sp.Route), Size: th.Type.Label, Color: th.Text, Ellipsis: true, MaxLines: 1}
case colHost:
return mono(hostDict.Name(sp.Host), th.Muted)
case colTrace:
// The column shows the leading digits every tracing UI shows; the full
// 32 are in the detail strip, where they can be read and copied.
return mono(sp.TraceHex()[:8], th.Muted)
case colStatus:
return mono(fmt.Sprint(sp.Code), statusColor(th, sp.Code))
case colLatency:
return mono(fmtDur(sp.Dur), latencyColor(th, sp.Dur))
default:
if sp.Bytes < 0 { // the capture didn't record a response size
return mono("—", th.Muted)
}
return mono(commas(int64(sp.Bytes)), th.Muted)
}
}
// detail is the strip under the table describing the selected row in full —
// the columns the grid had to truncate, plus the trace ID to search on.
func (s *dash) detail(th theme.Theme) widget.Widget {
if s.selected < 0 || s.selected >= len(s.res.View) {
return widget.Sized{H: 0}
}
sp := s.res.Rows[s.res.View[s.selected]]
line := fmt.Sprintf("%s %s %s on %s → %d in %s, %s",
sp.TraceHex(), svcDict.Name(sp.Svc), routeDict.Name(sp.Route), hostDict.Name(sp.Host),
sp.Code, fmtDur(sp.Dur), fmtBytes(sp.Bytes))
return widget.Padding{Insets: geom.Insets{Top: 10},
Child: widget.Align{X: 0, Y: 0.5,
Child: widget.Text{Value: line, Font: "mono", Size: th.Type.Caption,
Color: th.Text, Ellipsis: true, MaxLines: 1}}}
}
func statusColor(th theme.Theme, code uint16) paint.Color {
switch {
case code >= 500:
return th.Danger
case code >= 400:
return th.Warning
default:
return th.Success
}
}
func latencyColor(th theme.Theme, us int32) paint.Color {
switch {
case us > 1_000_000:
return th.Danger
case us > 200_000:
return th.Warning
default:
return th.Text
}
}
// --- Formatting --------------------------------------------------------------
func fmtDur(us int32) string {
switch {
case us <= 0:
return "—"
case us < 1000:
return fmt.Sprintf("%d µs", us)
case us < 10_000:
return fmt.Sprintf("%.2f ms", float64(us)/1000)
case us < 1_000_000:
return fmt.Sprintf("%.1f ms", float64(us)/1000)
default:
return fmt.Sprintf("%.2f s", float64(us)/1e6)
}
}
func fmtAge(ms int32) string {
switch {
case ms < 0:
return "0.0s"
case ms < 60_000:
return fmt.Sprintf("%.1fs", float64(ms)/1000)
default:
return fmt.Sprintf("%dm", ms/60_000)
}
}
func fmtBytes(b int32) string {
if b < 0 {
return "size unrecorded"
}
if b < 1024 {
return fmt.Sprintf("%d B", b)
}
return fmt.Sprintf("%.1f kB", float64(b)/1024)
}
// commas groups an integer for reading: 100000 → "100,000".
func commas(n int64) string {
s := fmt.Sprint(n)
neg := ""
if s[0] == '-' {
neg, s = "-", s[1:]
}
out := make([]byte, 0, len(s)+len(s)/3)
for i := 0; i < len(s); i++ {
if i > 0 && (len(s)-i)%3 == 0 {
out = append(out, ',')
}
out = append(out, s[i])
}
return neg + string(out)
}
// maxSvcBars is how many services the p95 chart can label legibly at the width
// it gets. A capture with more is truncated to the busiest — and says so, rather
// than quietly implying the rest are fine.
const maxSvcBars = 8
// shortName trims a service name to what fits under one bar.
func shortName(s string) string {
if len(s) <= 7 {
return s
}
return s[:6] + "…"
}
// loadButtons offers the two ways into real data: the bundled sample capture,
// and a file off the user's disk where the platform gives us a picker. On a
// platform without one ctx.FilePicker() is nil and the button simply isn't
// there — the capability layer's whole contract in two lines.
func (s *dash) loadButtons() widget.Widget {
kids := []widget.Widget{
theme.Button{Label: "Sample OTLP", OnTap: s.loadSample},
}
if s.ctx.FilePicker() != nil {
kids = append(kids, widget.Sized{W: 8},
theme.Button{Label: "Open OTLP…", OnTap: s.openOTLP})
}
kids = append(kids, widget.Sized{W: 14})
return widget.Row(kids...)
}
func (s *dash) loadSample() { s.load(strings.NewReader(sampleOTLP), "otlp-sample.json") }
func (s *dash) openOTLP() {
s.ctx.FilePicker().Open(shell.OpenOptions{Accept: []string{".json", "application/json"}},
func(files []shell.PickedFile, err error) {
if err != nil || len(files) == 0 {
return
}
s.load(bytes.NewReader(files[0].Data), files[0].Name)
})
}
// load decodes a capture into a fresh vocabulary and, only if that succeeds,
// installs both. A failed decode leaves the current dataset exactly as it was —
// which is why the decoder interns into a vocabulary it is handed rather than
// into the live one.
func (s *dash) load(r io.Reader, name string) {
v := newVocab()
spans, err := DecodeOTLP(r, v)
s.SetState(func() {
if err != nil {
s.loadErr = err.Error()
return
}
s.loadErr = ""
s.live = false
useVocab(v)
s.store.Replace(spans, name) // also stops the synthetic producer
s.q = Query{Svc: -1, SortCol: colTime, Desc: true}
s.selected = -1
s.dirty = true
})
}