materialized view and a bit better ui
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m3s

This commit is contained in:
mats committed 2026-09-19 12:43:17 +02:00
1 parent 26b677154b
commit 49f9bb3458
30 files changed
+2184 -106

No files matched your search

+14
View File
@@ -51,6 +51,13 @@ type Collector struct {
sourceMaps *sourcemaps.Resolver
kubeWatcher *kube.Watcher
prometheus *prometheus.Client
// Optional capabilities of the configured store (ClickHouse implements
// all of them); nil when unsupported, and the API falls back to the
// bounded in-memory data.
traceQuerier storage.TraceQuerier
occStore storage.OccurrenceStore
occChan chan storage.Occurrence
}
// New creates a new Collector instance.
@@ -72,6 +79,7 @@ func New(broadcaster websocket.Broadcaster, historySize int, store storage.Store
}
if store != nil {
c.initOptionalStores(store)
// Bounded worker pool prevents concurrent ClickHouse INSERTs from
// multiplying query and merge memory under bursty ingest traffic.
numWorkers := 4
@@ -162,6 +170,12 @@ func (c *Collector) GetIssue(id string) (issues.Issue, bool) {
func (c *Collector) SetSourceMaps(resolver *sourcemaps.Resolver) { c.sourceMaps = resolver }
func resolveSamples(samples []issues.Sample, resolver *sourcemaps.Resolver) {
for i := range samples {
samples[i].ResolvedStacktrace = resolver.ResolveStack(samples[i].Release, samples[i].Stacktrace)
}
}
func resolveIssue(issue *issues.Issue, resolver *sourcemaps.Resolver) {
for i := range issue.Samples {
issue.Samples[i].ResolvedStacktrace = resolver.ResolveStack(issue.Samples[i].Release, issue.Samples[i].Stacktrace)
+8 -1
View File
@@ -360,7 +360,14 @@ func (c *Collector) HandleTraceByID(w http.ResponseWriter, r *http.Request) {
return
}
entries, err := c.SearchHistory(r.Context(), storage.SearchOptions{Query: traceID}, parseLimit(r, 1000))
var entries []websocket.HistoryEntry
var err error
if c.traceQuerier != nil {
// Indexed lookup, payloads trimmed to this trace's spans/logs.
entries, err = c.traceQuerier.TraceEntries(r.Context(), traceID, parseLimit(r, 1000))
} else {
entries, err = c.SearchHistory(r.Context(), storage.SearchOptions{Query: traceID}, parseLimit(r, 1000))
}
if err != nil {
log.Printf("failed to fetch trace %s: %v", traceID, err)
http.Error(w, "failed to fetch trace", http.StatusInternalServerError)
+242
View File
@@ -0,0 +1,242 @@
package collector
import (
"context"
"encoding/json"
"log"
"net/http"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"git.tornberg.me/go-otel/internal/issues"
"git.tornberg.me/go-otel/internal/storage"
)
// occurrenceBuffer bounds queued occurrence writes; beyond it, writes are
// dropped (and counted in the log) rather than slowing ingest.
const occurrenceBuffer = 4096
// initOptionalStores wires the store's optional capabilities: trace lookups
// and persistent issue occurrences.
func (c *Collector) initOptionalStores(store storage.Store) {
if tq, ok := store.(storage.TraceQuerier); ok {
c.traceQuerier = tq
}
occ, ok := store.(storage.OccurrenceStore)
if !ok {
return
}
c.occStore = occ
c.occChan = make(chan storage.Occurrence, occurrenceBuffer)
go func() {
for o := range c.occChan {
if err := occ.AppendOccurrence(context.Background(), o); err != nil {
log.Printf("failed to persist issue occurrence: %v", err)
}
}
}()
var dropped atomic.Int64
c.issues.SetOccurrenceSink(func(issueID, kind, service string, sample issues.Sample) {
raw, err := json.Marshal(sample)
if err != nil {
return
}
select {
case c.occChan <- storage.Occurrence{
IssueID: issueID, Kind: kind, Service: service, Timestamp: sample.Timestamp,
TraceID: sample.TraceID, SpanID: sample.SpanID, Sample: raw,
}:
default:
if n := dropped.Add(1); n%1000 == 1 {
log.Printf("issue occurrence buffer full; dropped %d so far", n)
}
}
})
}
// issueTraceJSON is one trace in the issue overview.
type issueTraceJSON struct {
TraceID string `json:"traceId"`
Hits int `json:"hits"`
FirstSeen int64 `json:"firstSeen"`
LastSeen int64 `json:"lastSeen"`
Summary *storage.TraceSummary `json:"summary,omitempty"`
Samples []issues.Sample `json:"samples"`
}
type issueTracesResponse struct {
IssueID string `json:"issueId"`
TotalOccurrences int `json:"totalOccurrences"`
Traces []issueTraceJSON `json:"traces"`
Untraced []issues.Sample `json:"untraced"`
HasMore bool `json:"hasMore"`
// NextBefore is the LastSeen boundary to pass as ?before= for the next page.
NextBefore int64 `json:"nextBefore,omitempty"`
// Complete is false when only the in-memory samples were available.
Complete bool `json:"complete"`
}
func decodeSamples(raw []json.RawMessage) []issues.Sample {
out := make([]issues.Sample, 0, len(raw))
for _, r := range raw {
var s issues.Sample
if json.Unmarshal(r, &s) == nil {
out = append(out, s)
}
}
return out
}
// HandleIssueTraces returns every trace an issue occurred in with per-trace
// summaries (duration, spans, services, errors) and the occurrences in each,
// in a single request. Spans themselves stay on GET /api/traces/{id}.
func (c *Collector) HandleIssueTraces(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
issue, known := c.GetIssue(id)
var resp issueTracesResponse
resp.IssueID = id
if c.occStore != nil {
opts := storage.IssueTracesOptions{Limit: parseLimit(r, 50)}
if b, err := strconv.ParseInt(r.URL.Query().Get("before"), 10, 64); err == nil {
opts.Before = b
}
page, err := c.occStore.IssueTraces(r.Context(), id, opts)
if err != nil {
log.Printf("failed to load issue traces %s: %v", id, err)
http.Error(w, "failed to load issue traces", http.StatusInternalServerError)
return
}
if page.TotalOccurrences == 0 && !known {
http.Error(w, "issue not found", http.StatusNotFound)
return
}
resp.TotalOccurrences, resp.HasMore, resp.Complete = page.TotalOccurrences, page.HasMore, true
resp.Untraced = decodeSamples(page.Untraced)
for _, t := range page.Traces {
resp.Traces = append(resp.Traces, issueTraceJSON{
TraceID: t.TraceID, Hits: t.Hits, FirstSeen: t.FirstSeen, LastSeen: t.LastSeen,
Summary: t.Summary, Samples: decodeSamples(t.Samples),
})
}
if resp.HasMore && len(resp.Traces) > 0 {
resp.NextBefore = resp.Traces[len(resp.Traces)-1].LastSeen
}
} else {
if !known {
http.Error(w, "issue not found", http.StatusNotFound)
return
}
resp.TotalOccurrences = issue.Count
resp.Traces, resp.Untraced = groupInMemory(issue.Samples)
}
// Source-map resolution applies to stored samples exactly as it does for
// the in-memory ones.
if c.sourceMaps != nil {
for i := range resp.Traces {
resolveSamples(resp.Traces[i].Samples, c.sourceMaps)
}
resolveSamples(resp.Untraced, c.sourceMaps)
}
if resp.Traces == nil {
resp.Traces = []issueTraceJSON{}
}
if resp.Untraced == nil {
resp.Untraced = []issues.Sample{}
}
// Summaries for traces the store didn't summarise (fallback path, or
// traces older than the summary table): fill from the store when possible.
if c.traceQuerier != nil {
var missing []string
for _, t := range resp.Traces {
if t.Summary == nil {
missing = append(missing, t.TraceID)
}
}
if len(missing) > 0 {
if sums, err := c.traceQuerier.TraceSummaries(r.Context(), missing); err == nil {
for i := range resp.Traces {
if s, ok := sums[resp.Traces[i].TraceID]; ok {
resp.Traces[i].Summary = &s
}
}
}
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("failed to encode issue traces: %v", err)
}
}
// groupInMemory builds the same shape from the tracker's bounded samples,
// newest trace first.
func groupInMemory(samples []issues.Sample) ([]issueTraceJSON, []issues.Sample) {
byTrace := map[string]*issueTraceJSON{}
var traces []*issueTraceJSON
var untraced []issues.Sample
for _, s := range samples {
if s.TraceID == "" {
untraced = append(untraced, s)
continue
}
t, ok := byTrace[s.TraceID]
if !ok {
t = &issueTraceJSON{TraceID: s.TraceID, FirstSeen: s.Timestamp, LastSeen: s.Timestamp}
byTrace[s.TraceID] = t
traces = append(traces, t)
}
t.Hits++
t.Samples = append(t.Samples, s)
if s.Timestamp < t.FirstSeen {
t.FirstSeen = s.Timestamp
}
if s.Timestamp > t.LastSeen {
t.LastSeen = s.Timestamp
}
}
sort.Slice(traces, func(i, j int) bool { return traces[i].LastSeen > traces[j].LastSeen })
out := make([]issueTraceJSON, len(traces))
for i, t := range traces {
// Newest first, matching the store-backed path.
sort.SliceStable(t.Samples, func(a, b int) bool { return t.Samples[a].Timestamp > t.Samples[b].Timestamp })
out[i] = *t
}
sort.Slice(untraced, func(i, j int) bool { return untraced[i].Timestamp > untraced[j].Timestamp })
return out, untraced
}
// HandleTraceSummaries returns per-trace summaries for ?ids=a,b,c (max 100).
// Unknown ids are omitted; stores without summaries answer an empty object.
func (c *Collector) HandleTraceSummaries(w http.ResponseWriter, r *http.Request) {
var ids []string
for _, id := range strings.Split(r.URL.Query().Get("ids"), ",") {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
if len(ids) > 100 {
http.Error(w, "at most 100 ids", http.StatusBadRequest)
return
}
out := map[string]storage.TraceSummary{}
if c.traceQuerier != nil && len(ids) > 0 {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
sums, err := c.traceQuerier.TraceSummaries(ctx, ids)
if err != nil {
log.Printf("failed to load trace summaries: %v", err)
http.Error(w, "failed to load trace summaries", http.StatusInternalServerError)
return
}
out = sums
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
+133
View File
@@ -0,0 +1,133 @@
package collector
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"git.tornberg.me/go-otel/internal/issues"
"git.tornberg.me/go-otel/internal/storage"
"git.tornberg.me/go-otel/internal/websocket"
)
// occStore is a mockStore that also implements OccurrenceStore and
// TraceQuerier, standing in for ClickHouse.
type occStore struct {
mockStore
omu sync.Mutex
occs []storage.Occurrence
page storage.IssueTracesPage
}
func (o *occStore) AppendOccurrence(_ context.Context, occ storage.Occurrence) error {
o.omu.Lock()
defer o.omu.Unlock()
o.occs = append(o.occs, occ)
return nil
}
func (o *occStore) IssueTraces(context.Context, string, storage.IssueTracesOptions) (storage.IssueTracesPage, error) {
return o.page, nil
}
func (o *occStore) TraceEntries(context.Context, string, int) ([]websocket.HistoryEntry, error) {
return nil, nil
}
func (o *occStore) TraceSummaries(_ context.Context, ids []string) (map[string]storage.TraceSummary, error) {
out := map[string]storage.TraceSummary{}
for _, id := range ids {
out[id] = storage.TraceSummary{ID: id, Name: "sum-" + id, Duration: 42, SpanCount: 3}
}
return out, nil
}
func getIssueTraces(t *testing.T, c *Collector, id string) (int, issueTracesResponse) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/issues/"+id+"/traces", nil)
req.SetPathValue("id", id)
rec := httptest.NewRecorder()
c.HandleIssueTraces(rec, req)
var resp issueTracesResponse
_ = json.Unmarshal(rec.Body.Bytes(), &resp)
return rec.Code, resp
}
func TestOccurrencesPersistedBeyondInMemorySamples(t *testing.T) {
store := &occStore{}
c := New(nil, 10, store)
for i := 0; i < 12; i++ {
c.issues.Record("span_error", "api", "boom", issues.Sample{Timestamp: int64(1000 + i), TraceID: "t1", Message: "boom"})
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
store.omu.Lock()
n := len(store.occs)
store.omu.Unlock()
if n == 12 {
break
}
time.Sleep(10 * time.Millisecond)
}
store.omu.Lock()
defer store.omu.Unlock()
if len(store.occs) != 12 {
t.Fatalf("persisted %d occurrences, want all 12 (memory keeps only 5)", len(store.occs))
}
if store.occs[0].IssueID == "" || store.occs[0].TraceID != "t1" {
t.Errorf("occurrence = %+v", store.occs[0])
}
}
func TestHandleIssueTracesFromStoreFillsSummaries(t *testing.T) {
sample, _ := json.Marshal(issues.Sample{Timestamp: 5, TraceID: "t9", Message: "x"})
store := &occStore{page: storage.IssueTracesPage{
TotalOccurrences: 7,
HasMore: true,
Traces: []storage.IssueTrace{{TraceID: "t9", Hits: 7, LastSeen: 5, Samples: []json.RawMessage{sample}}},
}}
c := New(nil, 10, store)
c.issues.Record("span_error", "api", "boom", issues.Sample{Timestamp: 1, TraceID: "t9", Message: "x"})
id := c.issues.List(10)[0].ID
code, resp := getIssueTraces(t, c, id)
if code != http.StatusOK || !resp.Complete || resp.TotalOccurrences != 7 || !resp.HasMore || resp.NextBefore != 5 {
t.Fatalf("code=%d resp=%+v", code, resp)
}
if len(resp.Traces) != 1 || resp.Traces[0].Summary == nil || resp.Traces[0].Summary.Name != "sum-t9" {
t.Errorf("summary not filled from TraceQuerier: %+v", resp.Traces)
}
if len(resp.Traces[0].Samples) != 1 || resp.Traces[0].Samples[0].Message != "x" {
t.Errorf("samples = %+v", resp.Traces[0].Samples)
}
}
func TestHandleIssueTracesInMemoryFallback(t *testing.T) {
c := New(nil, 10, nil)
c.issues.Record("span_error", "api", "boom", issues.Sample{Timestamp: 1, TraceID: "a", Message: "m"})
c.issues.Record("span_error", "api", "boom", issues.Sample{Timestamp: 3, TraceID: "b", Message: "m"})
c.issues.Record("span_error", "api", "boom", issues.Sample{Timestamp: 2, TraceID: "a", Message: "m"})
c.issues.Record("span_error", "api", "boom", issues.Sample{Timestamp: 4, Message: "no trace"})
id := c.issues.List(10)[0].ID
code, resp := getIssueTraces(t, c, id)
if code != http.StatusOK || resp.Complete {
t.Fatalf("code=%d complete=%v (fallback must say it is incomplete)", code, resp.Complete)
}
if len(resp.Traces) != 2 || resp.Traces[0].TraceID != "b" || resp.Traces[1].TraceID != "a" || resp.Traces[1].Hits != 2 {
t.Errorf("traces = %+v", resp.Traces)
}
if len(resp.Untraced) != 1 {
t.Errorf("untraced = %d, want 1", len(resp.Untraced))
}
if s := resp.Traces[1].Samples; len(s) != 2 || s[0].Timestamp != 2 || s[1].Timestamp != 1 {
t.Errorf("samples must be newest first: %+v", s)
}
if code, _ := getIssueTraces(t, c, "missing"); code != http.StatusNotFound {
t.Errorf("unknown issue: code=%d, want 404", code)
}
}
+21
View File
@@ -117,6 +117,19 @@ type Tracker struct {
mu sync.Mutex
issues map[string]*Issue
feedback map[string]string
sink OccurrenceSink
}
// OccurrenceSink receives every recorded occurrence (already normalized) so
// it can be persisted beyond the bounded in-memory sample window. It is
// called outside the tracker lock and must not block.
type OccurrenceSink func(issueID, kind, service string, sample Sample)
// SetOccurrenceSink registers the sink; nil disables it.
func (t *Tracker) SetOccurrenceSink(fn OccurrenceSink) {
t.mu.Lock()
t.sink = fn
t.mu.Unlock()
}
// ListOptions filters and paginates the issue list. Issues remain ordered by
@@ -153,6 +166,13 @@ func NewTracker() *Tracker {
// fingerprint.
func (t *Tracker) Record(kind, service, title string, sample Sample) {
normalizeSample(&sample, title)
id, sink := t.record(kind, service, title, sample)
if sink != nil {
sink(id, kind, service, sample)
}
}
func (t *Tracker) record(kind, service, title string, sample Sample) (string, OccurrenceSink) {
id := Fingerprint(kind, service, NormalizeTitle(title))
bucket := HourBucket(sample.Timestamp)
@@ -196,6 +216,7 @@ func (t *Tracker) Record(kind, service, title string, sample Sample) {
if len(issue.Samples) > maxSamples {
issue.Samples = issue.Samples[len(issue.Samples)-maxSamples:]
}
return id, t.sink
}
// Similar returns bounded, deterministic near-match suggestions for an issue.
+28 -4
View File
@@ -198,7 +198,7 @@ func (s *ClickHouseStore) initSchema(ctx context.Context) error {
// empty until this succeeds.
fmt.Printf("clickhouse: failed to create rollup view (stats endpoints stay empty): %v\n", err)
}
return nil
return s.initTraceSchema(ctx)
}
// chPayload extracts rollup columns from the marshaled OTLP JSON. Field
@@ -326,17 +326,41 @@ func extractHead(data []byte) (service string, isErr bool, release, traceID stri
func (s *ClickHouseStore) Append(ctx context.Context, entry websocket.HistoryEntry) error {
service, isErr, release, traceID := extractHead(entry.Data)
// A batch routinely holds several traces, so keep every trace id (the
// scalar trace_id column only records the first) for indexed lookups.
var facts []spanFact
var traceIDs []string
switch entry.Type {
case "traces":
facts = extractSpanFacts(entry.Data)
seen := make(map[string]struct{}, len(facts))
for _, f := range facts {
if _, ok := seen[f.TraceID]; !ok {
seen[f.TraceID] = struct{}{}
traceIDs = append(traceIDs, f.TraceID)
}
}
case "logs":
traceIDs = allTraceIDs(entry.Data)
}
if traceIDs == nil {
traceIDs = []string{}
}
ts := time.Unix(0, entry.Timestamp).UTC()
err := s.conn.AsyncInsert(ctx, fmt.Sprintf(
`INSERT INTO %s.entries
(timestamp, type, service, is_error, release, trace_id, data)
VALUES (?, ?, ?, ?, ?, ?, ?)`, s.db),
(timestamp, type, service, is_error, release, trace_id, trace_ids, data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, s.db),
false,
ts, entry.Type, service, boolToUint8(isErr), release, traceID, string(entry.Data),
ts, entry.Type, service, boolToUint8(isErr), release, traceID, traceIDs, string(entry.Data),
)
if err != nil {
return fmt.Errorf("clickhouse append: %w", err)
}
if err := s.appendSpanFacts(ctx, ts, facts); err != nil {
return fmt.Errorf("clickhouse span facts: %w", err)
}
return nil
}
+12 -2
View File
@@ -38,7 +38,17 @@ func TestClickHouseStoreIntegration(t *testing.T) {
now := time.Now().UTC()
mk := func(minsAgo int, typ, service string, isErr bool, release string) websocket.HistoryEntry {
raw, _ := json.Marshal(map[string]interface{}{"n": minsAgo})
sev := 9
if isErr {
sev = 17
}
str := func(k, v string) map[string]any {
return map[string]any{"key": k, "value": map[string]any{"stringValue": v}}
}
raw, _ := json.Marshal(map[string]any{"resourceLogs": []any{map[string]any{
"resource": map[string]any{"attributes": []any{str("service.name", service), str("service.release", release)}},
"scopeLogs": []any{map[string]any{"logRecords": []any{map[string]any{"severityNumber": sev, "n": minsAgo}}}},
}}})
return websocket.HistoryEntry{
Type: typ,
Timestamp: now.Add(-time.Duration(minsAgo) * time.Minute).UnixNano(),
@@ -58,7 +68,7 @@ func TestClickHouseStoreIntegration(t *testing.T) {
}
}
// Flush async insert buffer before reading.
if err := s.conn.AsyncInsert(ctx, "INSERT INTO FUNCTION null('x UInt8') VALUES (0)", true); err != nil {
if err := s.conn.Exec(ctx, "SYSTEM FLUSH ASYNC INSERT QUEUE"); err != nil {
t.Fatalf("flush: %v", err)
}
+336
View File
@@ -0,0 +1,336 @@
package storage
import (
"context"
"encoding/json"
"fmt"
"sort"
"time"
"git.tornberg.me/go-otel/internal/websocket"
)
// initTraceSchema adds everything the trace and issue overviews need on top of
// the raw entries table:
//
// - entries.trace_ids + bloom indexes: indexed "which payloads contain trace X"
// instead of a substring scan over every payload.
// - span_facts (Null engine) -> trace_summaries (AggregatingMergeTree): one
// row per trace maintained on insert, so overview tables never read spans.
// - issue_occurrences: every occurrence of every issue, beyond the 5 samples
// the tracker keeps in memory.
//
// Failures are logged, not fatal: raw storage keeps working and the API falls
// back to the slower paths.
func (s *ClickHouseStore) initTraceSchema(ctx context.Context) error {
ddl := []string{
fmt.Sprintf(`ALTER TABLE %s.entries ADD COLUMN IF NOT EXISTS trace_ids Array(String) DEFAULT []`, s.db),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.span_facts (
at DateTime('UTC'),
trace_id String,
span_id String,
parent_span_id String,
service LowCardinality(String),
name String,
t0_ns UInt64,
t1_ns UInt64,
is_error UInt8
) ENGINE = Null`, s.db),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.trace_summaries (
trace_id String,
ts SimpleAggregateFunction(min, DateTime('UTC')),
start_ns SimpleAggregateFunction(min, UInt64),
end_ns SimpleAggregateFunction(max, UInt64),
spans SimpleAggregateFunction(sum, UInt64),
errors SimpleAggregateFunction(sum, UInt64),
services SimpleAggregateFunction(groupUniqArrayArray, Array(String)),
root AggregateFunction(argMin, String, Tuple(UInt8, UInt64))
) ENGINE = AggregatingMergeTree
ORDER BY trace_id
TTL ts + INTERVAL 90 DAY`, s.db),
fmt.Sprintf(`CREATE MATERIALIZED VIEW IF NOT EXISTS %s.trace_summaries_mv TO %s.trace_summaries AS
SELECT
trace_id,
min(at) AS ts,
min(t0_ns) AS start_ns,
max(t1_ns) AS end_ns,
count() AS spans,
sum(is_error) AS errors,
groupUniqArray(service) AS services,
argMinState(name, (parent_span_id != '', t0_ns)) AS root
FROM %s.span_facts
GROUP BY trace_id`, s.db, s.db, s.db),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.issue_occurrences (
issue_id String,
timestamp DateTime64(9, 'UTC'),
kind LowCardinality(String),
service LowCardinality(String),
trace_id String,
span_id String,
sample String CODEC(ZSTD(3))
) ENGINE = MergeTree
PARTITION BY toYYYYMM(timestamp)
ORDER BY (issue_id, timestamp)
TTL toDateTime(timestamp) + INTERVAL 90 DAY`, s.db),
}
for _, q := range ddl {
if err := s.conn.Exec(ctx, q); err != nil {
fmt.Printf("clickhouse: trace schema step failed (falling back to slow paths): %v\n", err)
return nil
}
}
for _, col := range []string{"trace_id", "trace_ids"} {
s.ensureBloomIndex(ctx, "entries", "idx_"+col, col)
}
return nil
}
// ensureBloomIndex adds a bloom-filter skip index and, only when it is newly
// added, materializes it over existing parts (a background mutation).
func (s *ClickHouseStore) ensureBloomIndex(ctx context.Context, table, name, expr string) {
var n uint64
row := s.conn.QueryRow(ctx,
`SELECT count() FROM system.data_skipping_indices WHERE database = ? AND table = ? AND name = ?`,
s.db, table, name)
if err := row.Scan(&n); err != nil || n > 0 {
return
}
if err := s.conn.Exec(ctx, fmt.Sprintf(
`ALTER TABLE %s.%s ADD INDEX IF NOT EXISTS %s %s TYPE bloom_filter(0.01) GRANULARITY 1`,
s.db, table, name, expr)); err != nil {
fmt.Printf("clickhouse: add index %s: %v\n", name, err)
return
}
if err := s.conn.Exec(ctx, fmt.Sprintf(`ALTER TABLE %s.%s MATERIALIZE INDEX %s`, s.db, table, name)); err != nil {
fmt.Printf("clickhouse: materialize index %s: %v\n", name, err)
}
}
func (s *ClickHouseStore) appendSpanFacts(ctx context.Context, ts time.Time, facts []spanFact) error {
if len(facts) == 0 {
return nil
}
batch, err := s.conn.PrepareBatch(ctx, fmt.Sprintf(
`INSERT INTO %s.span_facts (at, trace_id, span_id, parent_span_id, service, name, t0_ns, t1_ns, is_error)`, s.db))
if err != nil {
return err
}
for _, f := range facts {
if err := batch.Append(ts, f.TraceID, f.SpanID, f.ParentSpanID, f.Service, f.Name, f.StartNs, f.EndNs, boolToUint8(f.IsError)); err != nil {
_ = batch.Abort()
return err
}
}
return batch.Send()
}
// TraceEntries implements TraceQuerier.
func (s *ClickHouseStore) TraceEntries(ctx context.Context, traceID string, limit int) ([]websocket.HistoryEntry, error) {
if limit <= 0 {
limit = 1000
}
rows, err := s.conn.Query(ctx, fmt.Sprintf(
`SELECT timestamp, type, data FROM %s.entries
WHERE has(trace_ids, ?) OR trace_id = ?
ORDER BY timestamp DESC LIMIT ?`, s.db), traceID, traceID, limit)
if err != nil {
return nil, fmt.Errorf("clickhouse trace entries: %w", err)
}
defer rows.Close()
var out []websocket.HistoryEntry
for rows.Next() {
var (
ts time.Time
typ string
raw string
)
if err := rows.Scan(&ts, &typ, &raw); err != nil {
return nil, err
}
trimmed, ok := TrimToTrace([]byte(raw), traceID)
if !ok {
continue
}
out = append(out, websocket.HistoryEntry{Type: typ, Timestamp: ts.UnixNano(), Data: json.RawMessage(trimmed)})
}
return out, rows.Err()
}
// TraceSummaries implements TraceQuerier.
func (s *ClickHouseStore) TraceSummaries(ctx context.Context, ids []string) (map[string]TraceSummary, error) {
out := make(map[string]TraceSummary, len(ids))
if len(ids) == 0 {
return out, nil
}
rows, err := s.conn.Query(ctx, fmt.Sprintf(
`SELECT trace_id, min(start_ns), max(end_ns), sum(spans), sum(errors),
groupUniqArrayArray(services), argMinMerge(root)
FROM %s.trace_summaries
WHERE trace_id IN ?
GROUP BY trace_id`, s.db), ids)
if err != nil {
return nil, fmt.Errorf("clickhouse trace summaries: %w", err)
}
defer rows.Close()
for rows.Next() {
var (
id, name string
start, end, spans, e uint64
services []string
)
if err := rows.Scan(&id, &start, &end, &spans, &e, &services, &name); err != nil {
return nil, err
}
sort.Strings(services)
if name == "" {
name = "Trace " + id[:min(8, len(id))]
}
out[id] = TraceSummary{
ID: id, Name: name, StartTime: int64(start), EndTime: int64(end), Duration: int64(end - start),
SpanCount: int(spans), ErrorCount: int(e), Services: services,
}
}
return out, rows.Err()
}
// AppendOccurrence implements OccurrenceStore.
func (s *ClickHouseStore) AppendOccurrence(ctx context.Context, occ Occurrence) error {
err := s.conn.AsyncInsert(ctx, fmt.Sprintf(
`INSERT INTO %s.issue_occurrences (issue_id, timestamp, kind, service, trace_id, span_id, sample)
VALUES (?, ?, ?, ?, ?, ?, ?)`, s.db),
false,
occ.IssueID, time.Unix(0, occ.Timestamp).UTC(), occ.Kind, occ.Service, occ.TraceID, occ.SpanID, string(occ.Sample),
)
if err != nil {
return fmt.Errorf("clickhouse append occurrence: %w", err)
}
return nil
}
const maxSamplesPerIssueTrace = 20
const maxUntraced = 20
// IssueTraces implements OccurrenceStore: one aggregate query for the page of
// traces, one for their samples, one batched lookup for their summaries.
func (s *ClickHouseStore) IssueTraces(ctx context.Context, issueID string, opts IssueTracesOptions) (IssueTracesPage, error) {
limit := opts.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
var page IssueTracesPage
var total uint64
if err := s.conn.QueryRow(ctx, fmt.Sprintf(
`SELECT count() FROM %s.issue_occurrences WHERE issue_id = ?`, s.db), issueID).Scan(&total); err != nil {
return page, fmt.Errorf("clickhouse issue totals: %w", err)
}
page.TotalOccurrences = int(total)
having := ""
args := []any{issueID}
if opts.Before > 0 {
having = "HAVING max(timestamp) < ?"
args = append(args, time.Unix(0, opts.Before).UTC())
}
args = append(args, limit+1)
rows, err := s.conn.Query(ctx, fmt.Sprintf(
`SELECT trace_id, count(), min(timestamp), max(timestamp)
FROM %s.issue_occurrences
WHERE issue_id = ? AND trace_id != ''
GROUP BY trace_id %s
ORDER BY max(timestamp) DESC
LIMIT ?`, s.db, having), args...)
if err != nil {
return page, fmt.Errorf("clickhouse issue traces: %w", err)
}
for rows.Next() {
var (
t IssueTrace
hits uint64
from, to time.Time
)
if err := rows.Scan(&t.TraceID, &hits, &from, &to); err != nil {
_ = rows.Close()
return page, err
}
t.Hits, t.FirstSeen, t.LastSeen = int(hits), from.UnixNano(), to.UnixNano()
page.Traces = append(page.Traces, t)
}
_ = rows.Close()
if err := rows.Err(); err != nil {
return page, err
}
if len(page.Traces) > limit {
page.Traces = page.Traces[:limit]
page.HasMore = true
}
if len(page.Traces) > 0 {
ids := make([]string, len(page.Traces))
index := make(map[string]int, len(ids))
for i, t := range page.Traces {
ids[i] = t.TraceID
index[t.TraceID] = i
}
srows, err := s.conn.Query(ctx, fmt.Sprintf(
`SELECT trace_id, sample FROM %s.issue_occurrences
WHERE issue_id = ? AND trace_id IN ?
ORDER BY timestamp DESC
LIMIT ? BY trace_id`, s.db), issueID, ids, maxSamplesPerIssueTrace)
if err != nil {
return page, fmt.Errorf("clickhouse issue trace samples: %w", err)
}
for srows.Next() {
var id, sample string
if err := srows.Scan(&id, &sample); err != nil {
_ = srows.Close()
return page, err
}
i := index[id]
page.Traces[i].Samples = append(page.Traces[i].Samples, json.RawMessage(sample))
}
_ = srows.Close()
if err := srows.Err(); err != nil {
return page, err
}
summaries, err := s.TraceSummaries(ctx, ids)
if err != nil {
return page, err
}
for i := range page.Traces {
if sum, ok := summaries[page.Traces[i].TraceID]; ok {
page.Traces[i].Summary = &sum
}
}
}
if opts.Before == 0 {
urows, err := s.conn.Query(ctx, fmt.Sprintf(
`SELECT sample FROM %s.issue_occurrences
WHERE issue_id = ? AND trace_id = ''
ORDER BY timestamp DESC LIMIT ?`, s.db), issueID, maxUntraced)
if err != nil {
return page, fmt.Errorf("clickhouse issue untraced: %w", err)
}
for urows.Next() {
var sample string
if err := urows.Scan(&sample); err != nil {
_ = urows.Close()
return page, err
}
page.Untraced = append(page.Untraced, json.RawMessage(sample))
}
_ = urows.Close()
if err := urows.Err(); err != nil {
return page, err
}
}
return page, nil
}
+113
View File
@@ -0,0 +1,113 @@
package storage
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"time"
"git.tornberg.me/go-otel/internal/websocket"
)
// TestClickHouseTraceOverviewIntegration exercises the trace/issue SQL against
// a real ClickHouse: indexed trace lookup with trimming, summary rollups
// across multiple batches, and issue occurrence paging. Skipped unless
// OTEL_CLICKHOUSE_TEST_ADDR is set (see TestClickHouseStoreIntegration).
func TestClickHouseTraceOverviewIntegration(t *testing.T) {
addr := os.Getenv("OTEL_CLICKHOUSE_TEST_ADDR")
if addr == "" {
t.Skip("set OTEL_CLICKHOUSE_TEST_ADDR=host:9000 (with a disposable DB) to run")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
db := "otel_trace_test_" + time.Now().UTC().Format("150405")
s, err := NewClickHouseStore(ctx, ClickHouseConfig{Addr: addr, Database: db,
Username: os.Getenv("OTEL_CLICKHOUSE_TEST_USER"), Password: os.Getenv("OTEL_CLICKHOUSE_TEST_PASSWORD")})
if err != nil {
t.Skipf("ClickHouse not reachable at %s: %v", addr, err)
}
defer func() {
_ = s.conn.Exec(ctx, "DROP DATABASE IF EXISTS "+db)
_ = s.Close()
}()
base := time.Now().UTC().Add(-time.Hour).UnixNano()
// Batch 1 holds spans of two traces; trace "aaa" continues in batch 2.
b1 := json.RawMessage(mixedPayload)
b2 := json.RawMessage(`{"resourceSpans":[{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"db"}}]},
"scopeSpans":[{"spans":[{"traceId":"aaa","spanId":"s9","parentSpanId":"s3","name":"pg","startTimeUnixNano":"150","endTimeUnixNano":"900","status":{"code":2}}]}]}]}`)
for i, d := range []json.RawMessage{b1, b2} {
if err := s.Append(ctx, websocket.HistoryEntry{Type: "traces", Timestamp: base + int64(i), Data: d}); err != nil {
t.Fatalf("Append: %v", err)
}
}
// AsyncInsert buffers; wait for the flush like the stats test does.
time.Sleep(2 * time.Second)
_ = s.conn.Exec(ctx, "SYSTEM FLUSH ASYNC INSERT QUEUE")
// Trace "bbb" is NOT the first trace in batch 1, so only trace_ids finds it.
got, err := s.TraceEntries(ctx, "bbb", 100)
if err != nil || len(got) != 1 {
t.Fatalf("TraceEntries(bbb) = %d entries, err %v; want 1", len(got), err)
}
if _, ok := TrimToTrace(got[0].Data, "aaa"); ok {
t.Error("bbb entry must not contain aaa spans after trimming")
}
got, err = s.TraceEntries(ctx, "aaa", 100)
if err != nil || len(got) != 2 {
t.Fatalf("TraceEntries(aaa) = %d entries, err %v; want 2 (both batches)", len(got), err)
}
sums, err := s.TraceSummaries(ctx, []string{"aaa", "bbb", "nope"})
if err != nil {
t.Fatalf("TraceSummaries: %v", err)
}
if _, ok := sums["nope"]; ok {
t.Error("unknown trace must be absent")
}
a := sums["aaa"]
// aaa spans: s1 100-300 (root, "GET /"), s3 120-200 (503 => error), s9 150-900 (error).
if a.SpanCount != 3 || a.ErrorCount != 2 || a.Duration != 800 || a.Name != "GET /" {
t.Errorf("aaa summary = %+v; want 3 spans, 2 errors, 800ns, name 'GET /'", a)
}
if len(a.Services) != 2 || a.Services[0] != "api" || a.Services[1] != "db" {
t.Errorf("aaa services = %v; want [api db]", a.Services)
}
// Issue occurrences: 3 in aaa, 1 in bbb, 1 without a trace.
occ := func(i int, trace string) Occurrence {
raw, _ := json.Marshal(map[string]any{"timestamp": base + int64(i), "traceId": trace, "message": fmt.Sprintf("boom %d", i)})
return Occurrence{IssueID: "issue1", Kind: "span_error", Service: "api", Timestamp: base + int64(i)*int64(time.Second), TraceID: trace, Sample: raw}
}
for i, tr := range []string{"aaa", "aaa", "bbb", "", "aaa"} {
if err := s.AppendOccurrence(ctx, occ(i, tr)); err != nil {
t.Fatalf("AppendOccurrence: %v", err)
}
}
_ = s.conn.Exec(ctx, "SYSTEM FLUSH ASYNC INSERT QUEUE")
time.Sleep(time.Second)
page, err := s.IssueTraces(ctx, "issue1", IssueTracesOptions{Limit: 1})
if err != nil {
t.Fatalf("IssueTraces: %v", err)
}
if page.TotalOccurrences != 5 || len(page.Traces) != 1 || !page.HasMore {
t.Fatalf("page1 total=%d traces=%d more=%v", page.TotalOccurrences, len(page.Traces), page.HasMore)
}
first := page.Traces[0]
if first.TraceID != "aaa" || first.Hits != 3 || len(first.Samples) != 3 || first.Summary == nil || first.Summary.SpanCount != 3 {
t.Errorf("first trace = %+v", first)
}
if len(page.Untraced) != 1 {
t.Errorf("untraced = %d, want 1", len(page.Untraced))
}
next, err := s.IssueTraces(ctx, "issue1", IssueTracesOptions{Limit: 1, Before: first.LastSeen})
if err != nil || len(next.Traces) != 1 || next.Traces[0].TraceID != "bbb" || next.HasMore {
t.Fatalf("page2 = %+v err %v", next, err)
}
if len(next.Untraced) != 0 {
t.Error("untraced only belongs on the first page")
}
}
+71
View File
@@ -2,6 +2,7 @@ package storage
import (
"context"
"encoding/json"
"git.tornberg.me/go-otel/internal/websocket"
)
@@ -38,3 +39,73 @@ type StatsQuerier interface {
// Services lists distinct services seen in the rollup.
Services(ctx context.Context) ([]string, error)
}
// TraceSummary is the per-trace rollup shown in overview tables: everything
// needed to render a row without reading any span payloads. Times are unix
// nanoseconds, matching the rest of the API.
type TraceSummary struct {
ID string `json:"id"`
Name string `json:"name"`
StartTime int64 `json:"startTime"`
EndTime int64 `json:"endTime"`
Duration int64 `json:"duration"`
SpanCount int `json:"spanCount"`
ErrorCount int `json:"errorCount"`
Services []string `json:"services"`
}
// TraceQuerier is implemented by stores that can look a trace up by id
// without scanning payloads, and can summarise many traces cheaply
// (ClickHouseStore).
type TraceQuerier interface {
// TraceEntries returns every stored entry that contains the trace, with
// each payload trimmed to that trace's spans/log records. Newest first.
TraceEntries(ctx context.Context, traceID string, limit int) ([]websocket.HistoryEntry, error)
// TraceSummaries returns summaries keyed by trace id; unknown ids are absent.
TraceSummaries(ctx context.Context, ids []string) (map[string]TraceSummary, error)
}
// Occurrence is one persisted occurrence of an issue. Sample is the issue
// sample as JSON, so its shape can evolve without a schema change.
type Occurrence struct {
IssueID string
Kind string
Service string
Timestamp int64
TraceID string
SpanID string
Sample json.RawMessage
}
// IssueTrace is one trace an issue occurred in.
type IssueTrace struct {
TraceID string `json:"traceId"`
Hits int `json:"hits"`
FirstSeen int64 `json:"firstSeen"`
LastSeen int64 `json:"lastSeen"`
Summary *TraceSummary `json:"summary,omitempty"`
Samples []json.RawMessage `json:"-"`
}
// IssueTracesOptions pages through an issue's traces, most recently hit first.
// Before is a LastSeen boundary (exclusive); zero starts from the newest.
type IssueTracesOptions struct {
Limit int
Before int64
}
// IssueTracesPage is one page of an issue's traces plus totals. Untraced holds
// occurrences that carried no trace id (only on the first page).
type IssueTracesPage struct {
Traces []IssueTrace
Untraced []json.RawMessage
TotalOccurrences int
HasMore bool
}
// OccurrenceStore persists every issue occurrence and answers "which traces
// did this issue happen in" in one query (ClickHouseStore).
type OccurrenceStore interface {
AppendOccurrence(ctx context.Context, occ Occurrence) error
IssueTraces(ctx context.Context, issueID string, opts IssueTracesOptions) (IssueTracesPage, error)
}
+251
View File
@@ -0,0 +1,251 @@
package storage
import (
"encoding/json"
"strconv"
"strings"
)
// spanFact is the slice of a span needed for trace summaries.
type spanFact struct {
TraceID string
SpanID string
ParentSpanID string
Service string
Name string
StartNs uint64
EndNs uint64
IsError bool
}
type otlpValue struct {
StringValue string `json:"stringValue"`
IntValue json.Number `json:"intValue"`
}
type otlpAttr struct {
Key string `json:"key"`
Value otlpValue `json:"value"`
}
type otlpSpan struct {
TraceID string `json:"traceId"`
SpanID string `json:"spanId"`
ParentSpanID string `json:"parentSpanId"`
Name string `json:"name"`
StartTimeUnixNano json.Number `json:"startTimeUnixNano"`
EndTimeUnixNano json.Number `json:"endTimeUnixNano"`
Status struct {
Code int `json:"code"`
} `json:"status"`
Attributes []otlpAttr `json:"attributes"`
}
// httpStatusOf reads http.status_code (old) / http.response.status_code
// (new) as a number, tolerating string- or int-encoded OTLP values.
func httpStatusOf(attrs []otlpAttr) int {
for _, a := range attrs {
if a.Key != "http.status_code" && a.Key != "http.response.status_code" {
continue
}
if n, err := a.Value.IntValue.Int64(); err == nil {
return int(n)
}
if n, err := strconv.Atoi(a.Value.StringValue); err == nil {
return n
}
}
return 0
}
// isErrorSpan mirrors the UI's rule: OTLP error status or HTTP status >= 400.
func isErrorSpan(s otlpSpan) bool {
return s.Status.Code == 2 || httpStatusOf(s.Attributes) >= 400
}
func parseUint(n json.Number) uint64 {
v, err := strconv.ParseUint(n.String(), 10, 64)
if err != nil {
return 0
}
return v
}
// extractSpanFacts flattens an OTLP traces payload into per-span facts.
func extractSpanFacts(data []byte) []spanFact {
var p struct {
ResourceSpans []struct {
Resource struct {
Attributes []otlpAttr `json:"attributes"`
} `json:"resource"`
ScopeSpans []struct {
Spans []otlpSpan `json:"spans"`
} `json:"scopeSpans"`
} `json:"resourceSpans"`
}
if err := json.Unmarshal(data, &p); err != nil {
return nil
}
var out []spanFact
for _, rs := range p.ResourceSpans {
service := "unknown-service"
for _, a := range rs.Resource.Attributes {
if a.Key == "service.name" && a.Value.StringValue != "" {
service = a.Value.StringValue
}
}
for _, ss := range rs.ScopeSpans {
for _, sp := range ss.Spans {
if sp.TraceID == "" {
continue
}
start := parseUint(sp.StartTimeUnixNano)
end := parseUint(sp.EndTimeUnixNano)
if end < start {
end = start
}
out = append(out, spanFact{
TraceID: sp.TraceID, SpanID: sp.SpanID, ParentSpanID: sp.ParentSpanID,
Service: service, Name: sp.Name, StartNs: start, EndNs: end, IsError: isErrorSpan(sp),
})
}
}
}
return out
}
// allTraceIDs lists the distinct trace ids in a payload (a single OTLP batch
// routinely carries spans/logs of many traces, so the scalar trace_id column
// alone cannot answer "which entries contain trace X").
func allTraceIDs(data []byte) []string {
var p struct {
ResourceSpans []struct {
ScopeSpans []struct {
Spans []struct {
TraceID string `json:"traceId"`
} `json:"spans"`
} `json:"scopeSpans"`
} `json:"resourceSpans"`
ResourceLogs []struct {
ScopeLogs []struct {
LogRecords []struct {
TraceID string `json:"traceId"`
} `json:"logRecords"`
} `json:"scopeLogs"`
} `json:"resourceLogs"`
}
if err := json.Unmarshal(data, &p); err != nil {
return nil
}
seen := map[string]struct{}{}
var out []string
add := func(id string) {
if id == "" {
return
}
if _, ok := seen[id]; !ok {
seen[id] = struct{}{}
out = append(out, id)
}
}
for _, rs := range p.ResourceSpans {
for _, ss := range rs.ScopeSpans {
for _, sp := range ss.Spans {
add(sp.TraceID)
}
}
}
for _, rl := range p.ResourceLogs {
for _, sl := range rl.ScopeLogs {
for _, rec := range sl.LogRecords {
add(rec.TraceID)
}
}
}
return out
}
// filterNested walks raw (a JSON array) down path (object keys holding arrays
// at each level), keeping only leaf elements for which keep returns true and
// dropping any parent that ends up empty. Unknown fields are preserved
// verbatim. It returns the pruned array and the number of leaves kept.
func filterNested(raw json.RawMessage, path []string, keep func(json.RawMessage) bool) (json.RawMessage, int, error) {
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return nil, 0, err
}
kept := 0
out := make([]json.RawMessage, 0, len(items))
for _, item := range items {
if len(path) == 0 {
if keep(item) {
out = append(out, item)
kept++
}
continue
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(item, &obj); err != nil {
return nil, 0, err
}
child, ok := obj[path[0]]
if !ok {
continue
}
pruned, n, err := filterNested(child, path[1:], keep)
if err != nil {
return nil, 0, err
}
if n == 0 {
continue
}
prunedJSON := pruned
obj[path[0]] = prunedJSON
rebuilt, err := json.Marshal(obj)
if err != nil {
return nil, 0, err
}
out = append(out, rebuilt)
kept += n
}
b, err := json.Marshal(out)
return b, kept, err
}
// TrimToTrace returns data reduced to the spans and log records of traceID,
// and false when nothing in it belongs to the trace. Payloads that are not
// OTLP traces/logs (metrics) report false.
func TrimToTrace(data []byte, traceID string) ([]byte, bool) {
var top map[string]json.RawMessage
if err := json.Unmarshal(data, &top); err != nil {
return nil, false
}
match := func(item json.RawMessage) bool {
var t struct {
TraceID string `json:"traceId"`
}
return json.Unmarshal(item, &t) == nil && strings.EqualFold(t.TraceID, traceID)
}
total := 0
for key, path := range map[string][]string{
"resourceSpans": {"scopeSpans", "spans"},
"resourceLogs": {"scopeLogs", "logRecords"},
} {
raw, ok := top[key]
if !ok {
continue
}
pruned, n, err := filterNested(raw, path, match)
if err != nil || n == 0 {
delete(top, key)
continue
}
top[key] = pruned
total += n
}
if total == 0 {
return nil, false
}
out, err := json.Marshal(top)
return out, err == nil
}
+114
View File
@@ -0,0 +1,114 @@
package storage
import (
"encoding/json"
"testing"
)
const mixedPayload = `{
"resourceSpans":[
{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"api"}}]},"schemaUrl":"keep-me",
"scopeSpans":[{"scope":{"name":"lib"},"spans":[
{"traceId":"aaa","spanId":"s1","parentSpanId":"","name":"GET /","startTimeUnixNano":"100","endTimeUnixNano":"300","status":{"code":0}},
{"traceId":"bbb","spanId":"s2","parentSpanId":"","name":"other","startTimeUnixNano":"150","endTimeUnixNano":"160","status":{"code":2}},
{"traceId":"aaa","spanId":"s3","parentSpanId":"s1","name":"db","startTimeUnixNano":"120","endTimeUnixNano":"200","status":{"code":0},
"attributes":[{"key":"http.status_code","value":{"intValue":"503"}}]}
]}]},
{"resource":{"attributes":[{"key":"service.name","value":{"stringValue":"only-bbb"}}]},
"scopeSpans":[{"spans":[{"traceId":"bbb","spanId":"s4","name":"x","startTimeUnixNano":"1","endTimeUnixNano":"2"}]}]}
],
"resourceLogs":[{"scopeLogs":[{"logRecords":[{"traceId":"aaa","body":{"stringValue":"hi"}},{"traceId":"ccc"}]}]}]
}`
func TestTrimToTraceKeepsOnlyRequestedTrace(t *testing.T) {
out, ok := TrimToTrace([]byte(mixedPayload), "aaa")
if !ok {
t.Fatal("expected trace aaa to be found")
}
var p struct {
ResourceSpans []struct {
SchemaURL string `json:"schemaUrl"`
ScopeSpans []struct {
Scope struct{ Name string } `json:"scope"`
Spans []struct {
SpanID string `json:"spanId"`
} `json:"spans"`
} `json:"scopeSpans"`
} `json:"resourceSpans"`
ResourceLogs []struct {
ScopeLogs []struct {
LogRecords []struct {
TraceID string `json:"traceId"`
} `json:"logRecords"`
} `json:"scopeLogs"`
} `json:"resourceLogs"`
}
if err := json.Unmarshal(out, &p); err != nil {
t.Fatal(err)
}
if len(p.ResourceSpans) != 1 {
t.Fatalf("resource with only other traces should be dropped, got %d resources", len(p.ResourceSpans))
}
rs := p.ResourceSpans[0]
if rs.SchemaURL != "keep-me" || rs.ScopeSpans[0].Scope.Name != "lib" {
t.Errorf("unknown/sibling fields must be preserved: %+v", rs)
}
got := []string{}
for _, s := range rs.ScopeSpans[0].Spans {
got = append(got, s.SpanID)
}
if len(got) != 2 || got[0] != "s1" || got[1] != "s3" {
t.Errorf("spans = %v, want [s1 s3]", got)
}
if len(p.ResourceLogs) != 1 || len(p.ResourceLogs[0].ScopeLogs[0].LogRecords) != 1 {
t.Errorf("logs not trimmed to aaa: %+v", p.ResourceLogs)
}
}
func TestTrimToTraceMissAndNonTrace(t *testing.T) {
if _, ok := TrimToTrace([]byte(mixedPayload), "zzz"); ok {
t.Error("unknown trace should report false")
}
if _, ok := TrimToTrace([]byte(`{"resourceMetrics":[]}`), "aaa"); ok {
t.Error("metrics payload should report false")
}
if _, ok := TrimToTrace([]byte(`not json`), "aaa"); ok {
t.Error("invalid json should report false")
}
}
func TestExtractSpanFacts(t *testing.T) {
facts := extractSpanFacts([]byte(mixedPayload))
if len(facts) != 4 {
t.Fatalf("got %d facts, want 4", len(facts))
}
by := map[string]spanFact{}
for _, f := range facts {
by[f.SpanID] = f
}
if f := by["s1"]; f.Service != "api" || f.StartNs != 100 || f.EndNs != 300 || f.IsError {
t.Errorf("s1 = %+v", f)
}
if !by["s2"].IsError {
t.Error("status code 2 must be an error")
}
if !by["s3"].IsError {
t.Error("http.status_code 503 (intValue) must be an error")
}
if by["s4"].Service != "only-bbb" {
t.Errorf("s4 service = %q", by["s4"].Service)
}
}
func TestAllTraceIDsDistinctAcrossSpansAndLogs(t *testing.T) {
ids := allTraceIDs([]byte(mixedPayload))
want := map[string]bool{"aaa": true, "bbb": true, "ccc": true}
if len(ids) != len(want) {
t.Fatalf("ids = %v", ids)
}
for _, id := range ids {
if !want[id] {
t.Errorf("unexpected id %q", id)
}
}
}