materialized view and a bit better ui
This commit is contained in:
1 parent
26b677154b
commit
49f9bb3458
30 files changed
+2121
-43
No files matched your search
@@ -101,7 +101,13 @@ gRPC services for traces, metrics, and logs using standard OTLP protobuf definit
|
|||||||
filters as `search`, plus `timeout` (seconds, default 30). Rate-limited
|
filters as `search`, plus `timeout` (seconds, default 30). Rate-limited
|
||||||
per client IP.
|
per client IP.
|
||||||
- `GET /api/traces/{traceId}` - Retrieve all stored entries (spans and
|
- `GET /api/traces/{traceId}` - Retrieve all stored entries (spans and
|
||||||
correlated logs) referencing a given trace id.
|
correlated logs) referencing a given trace id. With ClickHouse this is an
|
||||||
|
indexed lookup (`entries.trace_ids`) and each payload is trimmed to that
|
||||||
|
trace's own spans/log records.
|
||||||
|
- `GET /api/traces/summaries?ids=a,b,c` - Per-trace rollups (name, start/end,
|
||||||
|
duration, span count, error count, services; max 100 ids) without reading
|
||||||
|
span payloads. Maintained on insert by the `trace_summaries` view; empty
|
||||||
|
with the file store.
|
||||||
|
|
||||||
All endpoints above search the full persistent store, not just the
|
All endpoints above search the full persistent store, not just the
|
||||||
in-memory ring buffer.
|
in-memory ring buffer.
|
||||||
@@ -133,6 +139,12 @@ Errors seen in traces (spans with an error status) and logs (severity
|
|||||||
Supports `limit`.
|
Supports `limit`.
|
||||||
- `GET /api/issues/{id}` - Fetch a single issue, including up to 5 recent
|
- `GET /api/issues/{id}` - Fetch a single issue, including up to 5 recent
|
||||||
sample occurrences (timestamp, trace/span id, message).
|
sample occurrences (timestamp, trace/span id, message).
|
||||||
|
- `GET /api/issues/{id}/traces` - Every trace the issue occurred in, most
|
||||||
|
recently hit first, with each trace's summary and its occurrences, in one
|
||||||
|
request (`limit`, `before` = `nextBefore` from the previous page).
|
||||||
|
ClickHouse keeps *all* occurrences (`issue_occurrences`, 90-day TTL);
|
||||||
|
without it the response is built from the 5 in-memory samples and
|
||||||
|
`complete` is `false`.
|
||||||
- `GET /api/patterns` - List deterministic groups of related exact issues.
|
- `GET /api/patterns` - List deterministic groups of related exact issues.
|
||||||
Pattern grouping is explainable and does not merge issue IDs.
|
Pattern grouping is explainable and does not merge issue IDs.
|
||||||
- `GET /api/issues/{id}/similar` - Return bounded, deterministic related-issue
|
- `GET /api/issues/{id}/similar` - Return bounded, deterministic related-issue
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ func main() {
|
|||||||
uiMux.Handle("/api/history/logs", oidcAuth.RequireAuth(coll.HandleHistoryByType("logs")))
|
uiMux.Handle("/api/history/logs", oidcAuth.RequireAuth(coll.HandleHistoryByType("logs")))
|
||||||
uiMux.Handle("/api/history/search", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleSearch)))
|
uiMux.Handle("/api/history/search", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleSearch)))
|
||||||
uiMux.Handle("/api/history/wait", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleWait)))
|
uiMux.Handle("/api/history/wait", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleWait)))
|
||||||
|
uiMux.Handle("GET /api/traces/summaries", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTraceSummaries)))
|
||||||
|
uiMux.Handle("GET /api/issues/{id}/traces", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueTraces)))
|
||||||
uiMux.Handle("GET /api/traces/{traceId}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTraceByID)))
|
uiMux.Handle("GET /api/traces/{traceId}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTraceByID)))
|
||||||
uiMux.Handle("/api/issues", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssues)))
|
uiMux.Handle("/api/issues", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssues)))
|
||||||
uiMux.Handle("POST /api/ml/test-spike", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleMLDetectorTest)))
|
uiMux.Handle("POST /api/ml/test-spike", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleMLDetectorTest)))
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ type Collector struct {
|
|||||||
sourceMaps *sourcemaps.Resolver
|
sourceMaps *sourcemaps.Resolver
|
||||||
kubeWatcher *kube.Watcher
|
kubeWatcher *kube.Watcher
|
||||||
prometheus *prometheus.Client
|
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.
|
// New creates a new Collector instance.
|
||||||
@@ -72,6 +79,7 @@ func New(broadcaster websocket.Broadcaster, historySize int, store storage.Store
|
|||||||
}
|
}
|
||||||
|
|
||||||
if store != nil {
|
if store != nil {
|
||||||
|
c.initOptionalStores(store)
|
||||||
// Bounded worker pool prevents concurrent ClickHouse INSERTs from
|
// Bounded worker pool prevents concurrent ClickHouse INSERTs from
|
||||||
// multiplying query and merge memory under bursty ingest traffic.
|
// multiplying query and merge memory under bursty ingest traffic.
|
||||||
numWorkers := 4
|
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 (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) {
|
func resolveIssue(issue *issues.Issue, resolver *sourcemaps.Resolver) {
|
||||||
for i := range issue.Samples {
|
for i := range issue.Samples {
|
||||||
issue.Samples[i].ResolvedStacktrace = resolver.ResolveStack(issue.Samples[i].Release, issue.Samples[i].Stacktrace)
|
issue.Samples[i].ResolvedStacktrace = resolver.ResolveStack(issue.Samples[i].Release, issue.Samples[i].Stacktrace)
|
||||||
|
|||||||
@@ -360,7 +360,14 @@ func (c *Collector) HandleTraceByID(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
log.Printf("failed to fetch trace %s: %v", traceID, err)
|
log.Printf("failed to fetch trace %s: %v", traceID, err)
|
||||||
http.Error(w, "failed to fetch trace", http.StatusInternalServerError)
|
http.Error(w, "failed to fetch trace", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,6 +117,19 @@ type Tracker struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
issues map[string]*Issue
|
issues map[string]*Issue
|
||||||
feedback map[string]string
|
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
|
// ListOptions filters and paginates the issue list. Issues remain ordered by
|
||||||
@@ -153,6 +166,13 @@ func NewTracker() *Tracker {
|
|||||||
// fingerprint.
|
// fingerprint.
|
||||||
func (t *Tracker) Record(kind, service, title string, sample Sample) {
|
func (t *Tracker) Record(kind, service, title string, sample Sample) {
|
||||||
normalizeSample(&sample, title)
|
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))
|
id := Fingerprint(kind, service, NormalizeTitle(title))
|
||||||
bucket := HourBucket(sample.Timestamp)
|
bucket := HourBucket(sample.Timestamp)
|
||||||
|
|
||||||
@@ -196,6 +216,7 @@ func (t *Tracker) Record(kind, service, title string, sample Sample) {
|
|||||||
if len(issue.Samples) > maxSamples {
|
if len(issue.Samples) > maxSamples {
|
||||||
issue.Samples = issue.Samples[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.
|
// Similar returns bounded, deterministic near-match suggestions for an issue.
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ func (s *ClickHouseStore) initSchema(ctx context.Context) error {
|
|||||||
// empty until this succeeds.
|
// empty until this succeeds.
|
||||||
fmt.Printf("clickhouse: failed to create rollup view (stats endpoints stay empty): %v\n", err)
|
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
|
// 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 {
|
func (s *ClickHouseStore) Append(ctx context.Context, entry websocket.HistoryEntry) error {
|
||||||
service, isErr, release, traceID := extractHead(entry.Data)
|
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()
|
ts := time.Unix(0, entry.Timestamp).UTC()
|
||||||
err := s.conn.AsyncInsert(ctx, fmt.Sprintf(
|
err := s.conn.AsyncInsert(ctx, fmt.Sprintf(
|
||||||
`INSERT INTO %s.entries
|
`INSERT INTO %s.entries
|
||||||
(timestamp, type, service, is_error, release, trace_id, data)
|
(timestamp, type, service, is_error, release, trace_id, trace_ids, data)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`, s.db),
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, s.db),
|
||||||
false,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("clickhouse append: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,17 @@ func TestClickHouseStoreIntegration(t *testing.T) {
|
|||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
mk := func(minsAgo int, typ, service string, isErr bool, release string) websocket.HistoryEntry {
|
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{
|
return websocket.HistoryEntry{
|
||||||
Type: typ,
|
Type: typ,
|
||||||
Timestamp: now.Add(-time.Duration(minsAgo) * time.Minute).UnixNano(),
|
Timestamp: now.Add(-time.Duration(minsAgo) * time.Minute).UnixNano(),
|
||||||
@@ -58,7 +68,7 @@ func TestClickHouseStoreIntegration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Flush async insert buffer before reading.
|
// 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)
|
t.Fatalf("flush: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"git.tornberg.me/go-otel/internal/websocket"
|
"git.tornberg.me/go-otel/internal/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,3 +39,73 @@ type StatsQuerier interface {
|
|||||||
// Services lists distinct services seen in the rollup.
|
// Services lists distinct services seen in the rollup.
|
||||||
Services(ctx context.Context) ([]string, error)
|
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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { MasterDetail } from "./MasterDetail"
|
||||||
|
import { BackButton } from "./common/BackButton"
|
||||||
|
import { SectionLabel } from "./common/DetailField"
|
||||||
|
import { Stat, StatHeader } from "./common/Stat"
|
||||||
|
import { TraceDetail } from "./trace/TraceDetail"
|
||||||
|
import { TraceOverviewHeader, TraceOverviewRow } from "./trace/TraceOverviewRow"
|
||||||
|
import { OccurrenceDetail } from "./issues/OccurrenceDetail"
|
||||||
|
import { useIssueTraces } from "../hooks/useIssueTraces"
|
||||||
|
import { useProcessedTraces } from "../hooks/useProcessedTraces"
|
||||||
|
import { formatDuration } from "../lib/timeUtils"
|
||||||
|
import type { Issue, IssueSample, TraceSummary } from "../lib/types"
|
||||||
|
|
||||||
|
/** One selectable row: a trace with its occurrences, or a lone traceless occurrence. */
|
||||||
|
interface Row {
|
||||||
|
key: string
|
||||||
|
traceId?: string
|
||||||
|
samples: IssueSample[]
|
||||||
|
ts: number
|
||||||
|
hits: number
|
||||||
|
summary?: TraceSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focused view of one issue: an overview of every trace it occurred in
|
||||||
|
* (server-side summaries, paged), with the selected trace's waterfall and its
|
||||||
|
* occurrences below. `initialTraceId` preselects the trace the user came from.
|
||||||
|
*/
|
||||||
|
export const IssueOccurrences = ({
|
||||||
|
issue,
|
||||||
|
initialTraceId,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
issue: Issue
|
||||||
|
initialTraceId?: string
|
||||||
|
onBack: () => void
|
||||||
|
}) => {
|
||||||
|
const { traces, untraced, total, complete, hasMore, loading, error, loadMore } = useIssueTraces(issue.id)
|
||||||
|
|
||||||
|
const rows: Row[] = useMemo(() => {
|
||||||
|
const traced = traces.map((t) => ({
|
||||||
|
key: t.traceId,
|
||||||
|
traceId: t.traceId,
|
||||||
|
samples: t.samples,
|
||||||
|
ts: t.firstSeen,
|
||||||
|
hits: t.hits,
|
||||||
|
summary: t.summary,
|
||||||
|
}))
|
||||||
|
const loose = untraced.map((s, i) => ({ key: `s-${s.timestamp}-${i}`, samples: [s], ts: s.timestamp, hits: 1 }))
|
||||||
|
return [...traced, ...loose].sort((a, b) => b.ts - a.ts)
|
||||||
|
}, [traces, untraced])
|
||||||
|
|
||||||
|
const [picked, setPicked] = useState<string | null>(initialTraceId ?? null)
|
||||||
|
const selected = rows.find((r) => r.key === picked) ?? rows[0]
|
||||||
|
const selectedTraceId = selected?.traceId
|
||||||
|
|
||||||
|
// Spans are fetched only for the selected trace.
|
||||||
|
const { byId, loading: spansLoading, failed } = useProcessedTraces(selectedTraceId ? [selectedTraceId] : [])
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const sums = traces.flatMap((t) => (t.summary ? [t.summary] : []))
|
||||||
|
return {
|
||||||
|
maxDuration: Math.max(1, ...sums.map((s) => s.duration)),
|
||||||
|
slowest: Math.max(0, ...sums.map((s) => s.duration)),
|
||||||
|
errored: sums.filter((s) => s.errorCount > 0).length,
|
||||||
|
services: new Set(sums.flatMap((s) => s.services)).size,
|
||||||
|
known: sums.length > 0,
|
||||||
|
}
|
||||||
|
}, [traces])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<StatHeader
|
||||||
|
back={<BackButton label="All issues" onClick={onBack} />}
|
||||||
|
eyebrow={`service=${issue.service}`}
|
||||||
|
title={issue.title}
|
||||||
|
stats={
|
||||||
|
<>
|
||||||
|
<Stat label="Traces" value={`${traces.length}${hasMore ? "+" : ""}`} />
|
||||||
|
<Stat label="Occurrences" value={total || issue.count} />
|
||||||
|
<Stat label="Slowest" value={stats.known ? formatDuration(stats.slowest) : "–"} />
|
||||||
|
<Stat label="With errors" value={stats.known ? stats.errored : "–"} tone={stats.errored ? "critical" : undefined} />
|
||||||
|
<Stat label="Services" value={stats.known ? stats.services : "–"} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-[12px] text-ds-critical">Failed to load traces: {error}</p>}
|
||||||
|
{!complete && (
|
||||||
|
<p className="text-[12px] text-ds-text-3">
|
||||||
|
Showing only the {issue.samples.length} most recent occurrences; full history needs ClickHouse storage.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{loading && rows.length === 0 && <p className="text-[13px] text-ds-text-3">Loading traces…</p>}
|
||||||
|
|
||||||
|
<MasterDetail
|
||||||
|
layout="stacked"
|
||||||
|
items={rows}
|
||||||
|
getKey={(r) => r.key}
|
||||||
|
selectedKey={selected?.key ?? null}
|
||||||
|
onSelect={setPicked}
|
||||||
|
label="Traces containing this issue"
|
||||||
|
listHeader={<TraceOverviewHeader />}
|
||||||
|
renderItem={(r) => (
|
||||||
|
<TraceOverviewRow
|
||||||
|
summary={r.summary}
|
||||||
|
traceId={r.traceId}
|
||||||
|
timestampNs={r.ts}
|
||||||
|
fallbackTitle={r.samples[0]?.message ?? "occurrence"}
|
||||||
|
maxDuration={stats.maxDuration}
|
||||||
|
hits={r.hits}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderDetail={(r) => (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{r.traceId && (
|
||||||
|
<TraceDetail
|
||||||
|
traceId={r.traceId}
|
||||||
|
trace={byId.get(r.traceId)}
|
||||||
|
loading={spansLoading && !failed.has(r.traceId)}
|
||||||
|
spanId={r.samples[0]?.spanId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SectionLabel>
|
||||||
|
Occurrences in this {r.traceId ? "trace" : "record"} ({r.hits}
|
||||||
|
{r.samples.length < r.hits ? `, latest ${r.samples.length} shown` : ""})
|
||||||
|
</SectionLabel>
|
||||||
|
{r.samples.map((s, i) => (
|
||||||
|
<OccurrenceDetail key={`${s.timestamp}-${i}`} sample={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loading}
|
||||||
|
className="self-center rounded-[9px] border border-ds-line-strong px-4 py-2 text-[12.5px] font-medium text-ds-text-2 hover:bg-ds-surface-2 hover:text-ds-text disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Loading…" : "Load older traces"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IssueOccurrences
|
||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
strongestCorrelate,
|
strongestCorrelate,
|
||||||
type SeverityTone,
|
type SeverityTone,
|
||||||
} from "../lib/issue-utils"
|
} from "../lib/issue-utils"
|
||||||
|
import { IssueOccurrences } from "./IssueOccurrences"
|
||||||
import { NeedleTestModal } from "./NeedleTestModal"
|
import { NeedleTestModal } from "./NeedleTestModal"
|
||||||
import { FilterBar, type FilterChip } from "./FilterBar"
|
import { FilterBar, type FilterChip } from "./FilterBar"
|
||||||
import type { Issue, IssueSample } from "../lib/types"
|
import type { Issue, IssueSample } from "../lib/types"
|
||||||
@@ -277,12 +278,13 @@ const IssueCard = ({
|
|||||||
issue,
|
issue,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
statusBusy,
|
statusBusy,
|
||||||
|
onFocus,
|
||||||
}: {
|
}: {
|
||||||
issue: Issue
|
issue: Issue
|
||||||
onStatusChange: (issue: Issue, status: string) => void
|
onStatusChange: (issue: Issue, status: string) => void
|
||||||
statusBusy: boolean
|
statusBusy: boolean
|
||||||
|
onFocus: (issue: Issue, traceId?: string) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [needleResult, setNeedleResult] = useState<NeedleResult | null>(null)
|
const [needleResult, setNeedleResult] = useState<NeedleResult | null>(null)
|
||||||
const [needleLoading, setNeedleLoading] = useState(false)
|
const [needleLoading, setNeedleLoading] = useState(false)
|
||||||
@@ -311,11 +313,7 @@ const IssueCard = ({
|
|||||||
const contextSample = issue.samples.find((s) => s.httpMethod || s.httpRoute || s.httpStatus)
|
const contextSample = issue.samples.find((s) => s.httpMethod || s.httpRoute || s.httpStatus)
|
||||||
const correlate = strongestCorrelate(issue)
|
const correlate = strongestCorrelate(issue)
|
||||||
|
|
||||||
const viewTrace = (traceId: string, spanId?: string) => {
|
const viewTrace = (traceId: string) => onFocus(issue, traceId)
|
||||||
setActiveTraceId(traceId)
|
|
||||||
if (spanId) setActiveSpanId(spanId)
|
|
||||||
setActiveTab("tab1")
|
|
||||||
}
|
|
||||||
|
|
||||||
const metaParts = [`service=${issue.service}`]
|
const metaParts = [`service=${issue.service}`]
|
||||||
if (contextSample?.httpMethod && contextSample?.httpRoute) {
|
if (contextSample?.httpMethod && contextSample?.httpRoute) {
|
||||||
@@ -360,7 +358,7 @@ const IssueCard = ({
|
|||||||
{latestSample?.traceId && (
|
{latestSample?.traceId && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => viewTrace(latestSample.traceId!, latestSample.spanId)}
|
onClick={() => viewTrace(latestSample.traceId!)}
|
||||||
className="inline-flex h-9 items-center rounded-[9px] bg-ds-accent px-3.5 text-[13px] font-semibold text-ds-on-accent"
|
className="inline-flex h-9 items-center rounded-[9px] bg-ds-accent px-3.5 text-[13px] font-semibold text-ds-on-accent"
|
||||||
>
|
>
|
||||||
View trace
|
View trace
|
||||||
@@ -534,7 +532,7 @@ const IssueCard = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
viewTrace(sample.traceId!, sample.spanId)
|
viewTrace(sample.traceId!)
|
||||||
}}
|
}}
|
||||||
className="flex shrink-0 items-center gap-1.5 rounded-full border border-ds-line-strong px-2.5 py-0.5 text-[10.5px] font-bold text-ds-text-2 hover:text-ds-text"
|
className="flex shrink-0 items-center gap-1.5 rounded-full border border-ds-line-strong px-2.5 py-0.5 text-[10.5px] font-bold text-ds-text-2 hover:text-ds-text"
|
||||||
>
|
>
|
||||||
@@ -655,6 +653,8 @@ export const IssuesVisualizer = ({
|
|||||||
}: IssuesVisualizerProps) => {
|
}: IssuesVisualizerProps) => {
|
||||||
const { selectedServices, toggleService, resetFilters, searchQuery, setSearchQuery, uniqueServices } =
|
const { selectedServices, toggleService, resetFilters, searchQuery, setSearchQuery, uniqueServices } =
|
||||||
useObservabilityData()
|
useObservabilityData()
|
||||||
|
const [focus, setFocus] = useState<{ issueId: string; traceId?: string } | null>(null)
|
||||||
|
const focusIssue = (issue: Issue, traceId?: string) => setFocus({ issueId: issue.id, traceId })
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
|
||||||
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
const [kindFilter, setKindFilter] = useState<KindFilter>("all")
|
||||||
const [needleModalOpen, setNeedleModalOpen] = useState(false)
|
const [needleModalOpen, setNeedleModalOpen] = useState(false)
|
||||||
@@ -813,6 +813,18 @@ export const IssuesVisualizer = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const focusedIssue = focus ? issues.find((i) => i.id === focus.issueId) : undefined
|
||||||
|
if (focus && focusedIssue) {
|
||||||
|
return (
|
||||||
|
<IssueOccurrences
|
||||||
|
key={focus.issueId}
|
||||||
|
issue={focusedIssue}
|
||||||
|
initialTraceId={focus.traceId}
|
||||||
|
onBack={() => setFocus(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -993,7 +1005,7 @@ export const IssuesVisualizer = ({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{primaryIssues.map((issue) => (
|
{primaryIssues.map((issue) => (
|
||||||
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} />
|
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} onFocus={focusIssue} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1015,7 +1027,7 @@ export const IssuesVisualizer = ({
|
|||||||
{showSecondary && (
|
{showSecondary && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{secondaryIssues.map((issue) => (
|
{secondaryIssues.map((issue) => (
|
||||||
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} />
|
<IssueCard key={issue.id} issue={issue} onStatusChange={onStatusChange} statusBusy={statusBusy} onFocus={focusIssue} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from "react"
|
||||||
|
|
||||||
|
interface MasterDetailProps<T> {
|
||||||
|
items: T[]
|
||||||
|
getKey: (item: T) => string
|
||||||
|
selectedKey: string | null
|
||||||
|
onSelect: (key: string) => void
|
||||||
|
renderItem: (item: T, selected: boolean) => ReactNode
|
||||||
|
renderDetail: (item: T) => ReactNode
|
||||||
|
/** Accessible name for the list, e.g. "Occurrences". */
|
||||||
|
label: string
|
||||||
|
/** Optional heading rendered above the list. */
|
||||||
|
listHeader?: ReactNode
|
||||||
|
emptyDetail?: ReactNode
|
||||||
|
/**
|
||||||
|
* "split": list left, detail right (lists of cards). "stacked": full-width
|
||||||
|
* list on top (tables/overviews), detail below.
|
||||||
|
*/
|
||||||
|
layout?: "split" | "stacked"
|
||||||
|
/** Split layout: Tailwind width class for the master column. */
|
||||||
|
listWidth?: string
|
||||||
|
/** Tailwind max-height class for the list (and, in split, the detail). */
|
||||||
|
maxHeight?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable master-detail layout: a selectable list on the left, the selected
|
||||||
|
* item's detail on the right (stacked on narrow screens). Arrow keys move the
|
||||||
|
* selection. Callers own selection state so it can be driven from elsewhere
|
||||||
|
* (e.g. a deep link or "focus this trace").
|
||||||
|
*/
|
||||||
|
export function MasterDetail<T>({
|
||||||
|
items,
|
||||||
|
getKey,
|
||||||
|
selectedKey,
|
||||||
|
onSelect,
|
||||||
|
renderItem,
|
||||||
|
renderDetail,
|
||||||
|
label,
|
||||||
|
listHeader,
|
||||||
|
emptyDetail,
|
||||||
|
layout = "split",
|
||||||
|
listWidth = "lg:w-[380px]",
|
||||||
|
maxHeight,
|
||||||
|
}: MasterDetailProps<T>) {
|
||||||
|
const stacked = layout === "stacked"
|
||||||
|
const maxH = maxHeight ?? (stacked ? "max-h-[300px]" : "lg:max-h-[calc(100vh-260px)]")
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const selected = items.find((item) => getKey(item) === selectedKey) ?? null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listRef.current
|
||||||
|
?.querySelector<HTMLElement>('[aria-selected="true"]')
|
||||||
|
?.scrollIntoView({ block: "nearest" })
|
||||||
|
}, [selectedKey])
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
|
||||||
|
e.preventDefault()
|
||||||
|
const idx = items.findIndex((item) => getKey(item) === selectedKey)
|
||||||
|
const next = items[Math.min(items.length - 1, Math.max(0, idx + (e.key === "ArrowDown" ? 1 : -1)))]
|
||||||
|
if (next) onSelect(getKey(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-4 ${stacked ? "" : "lg:flex-row lg:items-start"}`}>
|
||||||
|
<div className={`flex w-full shrink-0 flex-col gap-2 ${stacked ? "" : listWidth}`}>
|
||||||
|
{listHeader}
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
role="listbox"
|
||||||
|
aria-label={label}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
className={`flex max-h-[360px] flex-col gap-1.5 overflow-auto rounded-[14px] border border-ds-line bg-ds-surface p-1.5 focus:outline-none focus-visible:border-ds-accent ${maxH}`}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const key = getKey(item)
|
||||||
|
const isSelected = key === selectedKey
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onClick={() => onSelect(key)}
|
||||||
|
className={`cursor-pointer rounded-[9px] border px-3 py-2 ${
|
||||||
|
isSelected
|
||||||
|
? "border-ds-accent/40 bg-ds-surface-2"
|
||||||
|
: "border-transparent hover:bg-ds-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{renderItem(item, isSelected)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section
|
||||||
|
aria-live="polite"
|
||||||
|
className={`min-w-0 flex-1 ${stacked ? "" : `overflow-auto rounded-[14px] border border-ds-line bg-ds-surface p-5 ${maxH}`}`}
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
renderDetail(selected)
|
||||||
|
) : (
|
||||||
|
<p className="py-10 text-center text-[13px] text-ds-text-3">
|
||||||
|
{emptyDetail ?? "Select an item to see its details."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MasterDetail
|
||||||
@@ -821,7 +821,7 @@ const VirtualizedSpanWaterfall = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Main Trace Box with Header, Visual Time Distribution Bar, View Toggle, and Detail Drawer
|
// Main Trace Box with Header, Visual Time Distribution Bar, View Toggle, and Detail Drawer
|
||||||
const SingleTraceView = ({
|
export const SingleTraceView = ({
|
||||||
trace,
|
trace,
|
||||||
}: {
|
}: {
|
||||||
trace: ProcessedTrace;
|
trace: ProcessedTrace;
|
||||||
@@ -1542,29 +1542,11 @@ const TraceDirectoryTable = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TraceVisualizationProps {
|
/** Group raw OTLP trace payloads into per-trace ProcessedTraces, most recent first. */
|
||||||
traces: TraceData[] | undefined;
|
export const processTraces = (
|
||||||
}
|
safeTraces: TraceData[],
|
||||||
|
logCountBySpanId: Record<string, number>,
|
||||||
export const TraceVisualization = ({ traces }: TraceVisualizationProps) => {
|
): ProcessedTrace[] => {
|
||||||
const safeTraces = traces ?? [];
|
|
||||||
const {
|
|
||||||
logCountBySpanId,
|
|
||||||
activeTraceId,
|
|
||||||
setActiveTraceId,
|
|
||||||
selectedTimeRange,
|
|
||||||
setSelectedTimeRange,
|
|
||||||
} = useObservabilityData();
|
|
||||||
|
|
||||||
const [viewMode, setViewMode] = useState<"waterfall" | "directory">("waterfall");
|
|
||||||
const [traceSearchQuery, setTraceSearchQuery] = useState("");
|
|
||||||
const [traceFilter, setTraceFilter] = useState<"all" | "errors" | "slow">("all");
|
|
||||||
const [traceServiceFilter, setTraceServiceFilter] = useState<string>("all");
|
|
||||||
const [traceSortBy, setTraceSortBy] = useState<
|
|
||||||
"recent" | "duration-desc" | "duration-asc" | "errors" | "spans"
|
|
||||||
>("recent");
|
|
||||||
|
|
||||||
const orderedTraces: ProcessedTrace[] = useMemo(() => {
|
|
||||||
const spanAccumulator: ProcessedSpan[] = safeTraces.flatMap((trace) =>
|
const spanAccumulator: ProcessedSpan[] = safeTraces.flatMap((trace) =>
|
||||||
trace.resourceSpans?.flatMap((resourceSpan) =>
|
trace.resourceSpans?.flatMap((resourceSpan) =>
|
||||||
resourceSpan.scopeSpans?.flatMap((scopeSpan) =>
|
resourceSpan.scopeSpans?.flatMap((scopeSpan) =>
|
||||||
@@ -1650,7 +1632,34 @@ export const TraceVisualization = ({ traces }: TraceVisualizationProps) => {
|
|||||||
// Sort traces by most recent first
|
// Sort traces by most recent first
|
||||||
traceList.sort((a, b) => b.startTime - a.startTime);
|
traceList.sort((a, b) => b.startTime - a.startTime);
|
||||||
return traceList;
|
return traceList;
|
||||||
}, [safeTraces, logCountBySpanId]);
|
};
|
||||||
|
|
||||||
|
export interface TraceVisualizationProps {
|
||||||
|
traces: TraceData[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TraceVisualization = ({ traces }: TraceVisualizationProps) => {
|
||||||
|
const safeTraces = traces ?? [];
|
||||||
|
const {
|
||||||
|
logCountBySpanId,
|
||||||
|
activeTraceId,
|
||||||
|
setActiveTraceId,
|
||||||
|
selectedTimeRange,
|
||||||
|
setSelectedTimeRange,
|
||||||
|
} = useObservabilityData();
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<"waterfall" | "directory">("waterfall");
|
||||||
|
const [traceSearchQuery, setTraceSearchQuery] = useState("");
|
||||||
|
const [traceFilter, setTraceFilter] = useState<"all" | "errors" | "slow">("all");
|
||||||
|
const [traceServiceFilter, setTraceServiceFilter] = useState<string>("all");
|
||||||
|
const [traceSortBy, setTraceSortBy] = useState<
|
||||||
|
"recent" | "duration-desc" | "duration-asc" | "errors" | "spans"
|
||||||
|
>("recent");
|
||||||
|
|
||||||
|
const orderedTraces: ProcessedTrace[] = useMemo(
|
||||||
|
() => processTraces(safeTraces, logCountBySpanId),
|
||||||
|
[safeTraces, logCountBySpanId],
|
||||||
|
);
|
||||||
|
|
||||||
const allServices = useMemo(() => {
|
const allServices = useMemo(() => {
|
||||||
const s = new Set<string>();
|
const s = new Set<string>();
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
|
||||||
|
export const BackButton = ({ label, onClick }: { label: string; onClick: () => void }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 self-start rounded-[9px] border border-ds-line-strong px-3 text-[12.5px] font-medium text-ds-text-2 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** Label/value pair for a <dl>; renders nothing for empty values. */
|
||||||
|
export const DetailField = ({ label, value }: { label: string; value?: string | number }) =>
|
||||||
|
value === undefined || value === "" ? null : (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<dt className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{label}</dt>
|
||||||
|
<dd className="break-all font-[family-name:var(--font-data)] text-[12px] text-ds-text-2">{value}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SectionLabel = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<p className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{children}</p>
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export const Stat = ({ label, value, tone }: { label: string; value: string | number; tone?: "critical" }) => (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3">{label}</span>
|
||||||
|
<span
|
||||||
|
className={`font-[family-name:var(--font-display)] text-[20px] font-bold ${tone === "critical" ? "text-ds-critical" : "text-ds-text"}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Header strip: optional back button, title block, then stats on the right. */
|
||||||
|
export const StatHeader = ({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
back,
|
||||||
|
stats,
|
||||||
|
}: {
|
||||||
|
eyebrow?: string
|
||||||
|
title: string
|
||||||
|
back?: React.ReactNode
|
||||||
|
stats: React.ReactNode
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-wrap items-end gap-x-7 gap-y-3">
|
||||||
|
{back}
|
||||||
|
<div className="min-w-0 flex-1 basis-[280px]">
|
||||||
|
{eyebrow && <p className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{eyebrow}</p>}
|
||||||
|
<h2 className="break-all font-[family-name:var(--font-display)] text-[22px] font-bold leading-tight text-ds-text">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{stats}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { ExternalLink } from "lucide-react"
|
||||||
|
import { DetailField } from "../common/DetailField"
|
||||||
|
import { formatRelativeTime } from "../../lib/timeUtils"
|
||||||
|
import { parseBreadcrumbs } from "../../lib/issue-utils"
|
||||||
|
import type { IssueSample } from "../../lib/types"
|
||||||
|
|
||||||
|
export const OccurrenceDetail = ({ sample }: { sample: IssueSample }) => {
|
||||||
|
const stack = sample.resolvedStacktrace || sample.stacktrace
|
||||||
|
const crumbs = parseBreadcrumbs(sample.breadcrumbs)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-[10px] border border-ds-line bg-ds-surface-2 p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-ds-text-3">
|
||||||
|
{new Date(sample.timestamp / 1e6).toLocaleString()} · {formatRelativeTime(sample.timestamp / 1e6)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-all text-[13.5px] text-ds-text">{sample.message}</p>
|
||||||
|
</div>
|
||||||
|
<dl className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<DetailField label="Route" value={[sample.httpMethod, sample.httpRoute].filter(Boolean).join(" ")} />
|
||||||
|
<DetailField label="Status" value={sample.httpStatus} />
|
||||||
|
<DetailField label="Release" value={sample.release} />
|
||||||
|
<DetailField label="Environment" value={sample.environment} />
|
||||||
|
<DetailField label="Span" value={sample.spanId} />
|
||||||
|
<DetailField label="Source IP" value={sample.sourceIp} />
|
||||||
|
<DetailField label="User agent" value={sample.userAgent} />
|
||||||
|
</dl>
|
||||||
|
{sample.url && (
|
||||||
|
<a href={sample.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 break-all text-[12px] text-ds-accent">
|
||||||
|
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||||
|
{sample.url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{stack && (
|
||||||
|
<pre className="max-h-56 overflow-auto whitespace-pre-wrap break-all rounded-[10px] border border-ds-line bg-ds-bg p-3 font-[family-name:var(--font-data)] text-[11px] leading-relaxed text-ds-text-2">
|
||||||
|
{stack}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{crumbs.length > 0 && (
|
||||||
|
<div className="max-h-44 space-y-1 overflow-auto rounded-[10px] border border-ds-line bg-ds-bg p-3">
|
||||||
|
{crumbs.map((c, i) => (
|
||||||
|
<div key={`${c.t}-${i}`} className="flex items-baseline gap-2 font-[family-name:var(--font-data)] text-[11px]">
|
||||||
|
<span className="shrink-0 text-ds-text-3">{new Date(c.t).toLocaleTimeString([], { hour12: false })}</span>
|
||||||
|
<span className="shrink-0 rounded bg-ds-fill px-1.5 py-0.5 text-[10px] font-bold uppercase text-ds-text-2">{c.category}</span>
|
||||||
|
<span className="min-w-0 break-all text-ds-text-2">{c.message.slice(0, 200)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Activity } from "lucide-react"
|
||||||
|
import { SingleTraceView, type ProcessedTrace } from "../TraceVisualization"
|
||||||
|
import { useObservabilityData } from "../../hooks/useObservabilityData"
|
||||||
|
import { SectionLabel } from "../common/DetailField"
|
||||||
|
|
||||||
|
/** Waterfall + span inspector for one trace, with an "Open in Traces" jump. Handles the not-yet-loaded state. */
|
||||||
|
export const TraceDetail = ({
|
||||||
|
traceId,
|
||||||
|
trace,
|
||||||
|
loading,
|
||||||
|
spanId,
|
||||||
|
label = "Selected trace",
|
||||||
|
}: {
|
||||||
|
traceId: string
|
||||||
|
trace?: ProcessedTrace
|
||||||
|
loading?: boolean
|
||||||
|
spanId?: string
|
||||||
|
label?: string
|
||||||
|
}) => {
|
||||||
|
const { setActiveTraceId, setActiveSpanId, setActiveTab } = useObservabilityData()
|
||||||
|
const open = () => {
|
||||||
|
setActiveTraceId(traceId)
|
||||||
|
if (spanId) setActiveSpanId(spanId)
|
||||||
|
setActiveTab("tab1")
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<SectionLabel>{label}</SectionLabel>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={open}
|
||||||
|
className="inline-flex h-8 items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-3 text-[12.5px] font-medium text-ds-text-2 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||||
|
>
|
||||||
|
<Activity className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
Open in Traces
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{trace ? (
|
||||||
|
<SingleTraceView key={trace.id} trace={trace} />
|
||||||
|
) : (
|
||||||
|
<p className="rounded-[14px] border border-dashed border-ds-line py-10 text-center text-[13px] text-ds-text-3">
|
||||||
|
{loading ? "Loading trace…" : "Trace spans are no longer available."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
import type { TraceSummary } from "../../lib/types"
|
||||||
|
import { formatDuration } from "../../lib/timeUtils"
|
||||||
|
|
||||||
|
const GRID =
|
||||||
|
"grid grid-cols-[84px_minmax(0,1.6fr)_minmax(140px,1fr)_56px_minmax(0,1fr)_64px_64px] items-center gap-3"
|
||||||
|
|
||||||
|
export const clockTime = (ns: number) =>
|
||||||
|
new Date(ns / 1e6).toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
||||||
|
|
||||||
|
/** Column header matching TraceOverviewRow. Pass as MasterDetail's listHeader. */
|
||||||
|
export const TraceOverviewHeader = ({ hitsLabel = "Hits" }: { hitsLabel?: string }) => (
|
||||||
|
<div className={`${GRID} px-4 text-[10.5px] font-bold uppercase tracking-widest text-ds-text-3`}>
|
||||||
|
<span>Time</span>
|
||||||
|
<span>Trace</span>
|
||||||
|
<span>Duration</span>
|
||||||
|
<span className="text-right">Spans</span>
|
||||||
|
<span>Services</span>
|
||||||
|
<span className="text-right">Errors</span>
|
||||||
|
<span className="text-right">{hitsLabel}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One trace in an overview table, rendered from a server-side TraceSummary
|
||||||
|
* (no spans needed). `summary` may be undefined for traces the server has no
|
||||||
|
* rollup for; `fallbackTitle` and `traceId` keep the row useful then.
|
||||||
|
* `maxDuration` scales the duration bar across the visible set.
|
||||||
|
*/
|
||||||
|
export const TraceOverviewRow = ({
|
||||||
|
summary: trace,
|
||||||
|
traceId,
|
||||||
|
timestampNs,
|
||||||
|
fallbackTitle,
|
||||||
|
maxDuration,
|
||||||
|
hits,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
summary?: TraceSummary
|
||||||
|
traceId?: string
|
||||||
|
timestampNs: number
|
||||||
|
fallbackTitle: string
|
||||||
|
maxDuration: number
|
||||||
|
hits?: number
|
||||||
|
loading?: boolean
|
||||||
|
}) => (
|
||||||
|
<div className={`${GRID} text-[12.5px]`}>
|
||||||
|
<span className="font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-3">{clockTime(timestampNs)}</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate text-ds-text">{trace?.name ?? fallbackTitle}</span>
|
||||||
|
<span className="block font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-3">
|
||||||
|
{traceId ? `trace:${traceId.slice(0, 12)}` : "no trace attached"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{trace ? (
|
||||||
|
<>
|
||||||
|
<span className="h-2 flex-1 overflow-hidden rounded-full bg-ds-fill">
|
||||||
|
<span
|
||||||
|
className={`block h-full rounded-full ${trace.errorCount > 0 ? "bg-ds-critical" : "bg-ds-accent"}`}
|
||||||
|
style={{ width: `${Math.max(2, (trace.duration / maxDuration) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-14 shrink-0 text-right font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">
|
||||||
|
{formatDuration(trace.duration)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : loading ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-ds-text-3" aria-label="Loading trace" />
|
||||||
|
) : (
|
||||||
|
<span className="text-ds-text-3">–</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="text-right font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">{trace?.spanCount ?? "–"}</span>
|
||||||
|
<span className="truncate text-[11.5px] text-ds-text-3">{trace?.services.join(", ") || "–"}</span>
|
||||||
|
<span className={`text-right font-[family-name:var(--font-data)] text-[11.5px] ${trace && trace.errorCount > 0 ? "text-ds-critical" : "text-ds-text-3"}`}>
|
||||||
|
{trace ? trace.errorCount : "–"}
|
||||||
|
</span>
|
||||||
|
<span className="text-right font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">
|
||||||
|
{hits === undefined ? "" : `×${hits}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { authFetch } from "../lib/auth"
|
||||||
|
import type { IssueSample, IssueTrace, IssueTracesResponse } from "../lib/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads every trace an issue occurred in (one request, summaries included) and
|
||||||
|
* pages further back with loadMore(). Spans are NOT included: load the selected
|
||||||
|
* trace's spans separately (useProcessedTraces).
|
||||||
|
*/
|
||||||
|
export function useIssueTraces(issueId: string, pageSize = 50) {
|
||||||
|
const [traces, setTraces] = useState<IssueTrace[]>([])
|
||||||
|
const [untraced, setUntraced] = useState<IssueSample[]>([])
|
||||||
|
const [meta, setMeta] = useState({ total: 0, complete: true, hasMore: false })
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const nextBefore = useRef<number | undefined>(undefined)
|
||||||
|
const gen = useRef(0)
|
||||||
|
|
||||||
|
const fetchPage = useCallback(
|
||||||
|
async (before?: number) => {
|
||||||
|
const my = gen.current
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams({ limit: String(pageSize) })
|
||||||
|
if (before) q.set("before", String(before))
|
||||||
|
const res = await authFetch(`/api/issues/${encodeURIComponent(issueId)}/traces?${q}`)
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = (await res.json()) as IssueTracesResponse
|
||||||
|
if (my !== gen.current) return
|
||||||
|
setTraces((prev) => (before ? [...prev, ...data.traces] : data.traces))
|
||||||
|
if (!before) setUntraced(data.untraced)
|
||||||
|
setMeta({ total: data.totalOccurrences, complete: data.complete, hasMore: data.hasMore })
|
||||||
|
nextBefore.current = data.nextBefore
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
if (my === gen.current) setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
if (my === gen.current) setLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[issueId, pageSize],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
gen.current++
|
||||||
|
nextBefore.current = undefined
|
||||||
|
void fetchPage()
|
||||||
|
}, [fetchPage])
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (nextBefore.current) void fetchPage(nextBefore.current)
|
||||||
|
}, [fetchPage])
|
||||||
|
|
||||||
|
return { traces, untraced, ...meta, loading, error, loadMore }
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useMemo } from "react"
|
||||||
|
import { processTraces, type ProcessedTrace } from "../components/TraceVisualization"
|
||||||
|
import { useObservabilityData } from "./useObservabilityData"
|
||||||
|
import { useTracesById } from "./useTracesById"
|
||||||
|
|
||||||
|
/** Fetch traces by id and return them processed (spans, duration, services, errors), keyed by trace id. */
|
||||||
|
export function useProcessedTraces(ids: string[]) {
|
||||||
|
const { logCountBySpanId } = useObservabilityData()
|
||||||
|
const { traces, loading, failed } = useTracesById(ids)
|
||||||
|
const byId = useMemo(() => {
|
||||||
|
const map = new Map<string, ProcessedTrace>()
|
||||||
|
processTraces(traces, logCountBySpanId).forEach((t) => map.set(t.id, t))
|
||||||
|
return map
|
||||||
|
}, [traces, logCountBySpanId])
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const all = [...byId.values()]
|
||||||
|
return {
|
||||||
|
maxDuration: Math.max(1, ...all.map((t) => t.duration)),
|
||||||
|
slowest: all.reduce((m, t) => Math.max(m, t.duration), 0),
|
||||||
|
errored: all.filter((t) => t.hasError).length,
|
||||||
|
services: new Set(all.flatMap((t) => t.services)).size,
|
||||||
|
loaded: all.length,
|
||||||
|
}
|
||||||
|
}, [byId])
|
||||||
|
|
||||||
|
return { byId, stats, loading, failed }
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react"
|
||||||
|
import { authFetch } from "../lib/auth"
|
||||||
|
import { partitionMessages } from "../lib/ingest"
|
||||||
|
import type { TraceData } from "../lib/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the given traces from /api/traces/:id (once each, cached for the
|
||||||
|
* hook's lifetime). Returns the raw payloads so callers can run them through
|
||||||
|
* processTraces(); `loading` is true while any id is still in flight.
|
||||||
|
*/
|
||||||
|
export function useTracesById(ids: string[]) {
|
||||||
|
const [byId, setById] = useState<Record<string, TraceData[]>>({})
|
||||||
|
const [failed, setFailed] = useState<Set<string>>(new Set())
|
||||||
|
const key = ids.join(",")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
for (const id of key ? key.split(",") : []) {
|
||||||
|
if (byId[id] || failed.has(id)) continue
|
||||||
|
authFetch(`/api/traces/${encodeURIComponent(id)}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setById((prev) => ({ ...prev, [id]: partitionMessages(data ?? []).traces }))
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFailed((prev) => new Set(prev).add(id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
// byId/failed are read only to skip already-settled ids.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [key])
|
||||||
|
|
||||||
|
const traces = useMemo(() => Object.values(byId).flat(), [byId])
|
||||||
|
const loading = ids.some((id) => !byId[id] && !failed.has(id))
|
||||||
|
return { traces, loading, failed }
|
||||||
|
}
|
||||||
@@ -190,3 +190,32 @@ export function sparklineSeries(issue: Issue, hours = 24): SparklineSeries {
|
|||||||
}
|
}
|
||||||
return { points, max, reference: mean, breachIndex }
|
return { points, max, reference: mean, breachIndex }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row per trace (all its samples grouped), plus one per traceless sample; newest first. */
|
||||||
|
export interface SampleGroup {
|
||||||
|
key: string
|
||||||
|
traceId?: string
|
||||||
|
samples: IssueSample[]
|
||||||
|
firstTs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupSamplesByTrace(samples: IssueSample[]): SampleGroup[] {
|
||||||
|
const byTrace = new Map<string, SampleGroup>()
|
||||||
|
const out: SampleGroup[] = []
|
||||||
|
samples.forEach((sample, i) => {
|
||||||
|
if (!sample.traceId) {
|
||||||
|
out.push({ key: `s-${sample.timestamp}-${i}`, samples: [sample], firstTs: sample.timestamp })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const group = byTrace.get(sample.traceId)
|
||||||
|
if (group) {
|
||||||
|
group.samples.push(sample)
|
||||||
|
group.firstTs = Math.min(group.firstTs, sample.timestamp)
|
||||||
|
} else {
|
||||||
|
const created = { key: sample.traceId, traceId: sample.traceId, samples: [sample], firstTs: sample.timestamp }
|
||||||
|
byTrace.set(sample.traceId, created)
|
||||||
|
out.push(created)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return out.sort((a, b) => b.firstTs - a.firstTs)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ProcessedTrace } from "../components/TraceVisualization"
|
||||||
|
import type { TraceSummary } from "./types"
|
||||||
|
|
||||||
|
/** Same shape the server returns, derived from a fully loaded trace (for lists built from live data). */
|
||||||
|
export function summarizeTrace(t: ProcessedTrace): TraceSummary {
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
startTime: t.startTime,
|
||||||
|
endTime: t.endTime,
|
||||||
|
duration: t.duration,
|
||||||
|
spanCount: t.spans.length,
|
||||||
|
errorCount: t.errorCount,
|
||||||
|
services: t.services,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -266,3 +266,36 @@ export interface ErrorStatsResponse {
|
|||||||
export interface ReleaseStatsResponse {
|
export interface ReleaseStatsResponse {
|
||||||
series: Record<string, StatsPoint[]>;
|
series: Record<string, StatsPoint[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-trace rollup for overview tables (server-side, no spans). Times are unix ns. */
|
||||||
|
export interface TraceSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
duration: number;
|
||||||
|
spanCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
services: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One trace an issue occurred in, from GET /api/issues/:id/traces. */
|
||||||
|
export interface IssueTrace {
|
||||||
|
traceId: string;
|
||||||
|
hits: number;
|
||||||
|
firstSeen: number;
|
||||||
|
lastSeen: number;
|
||||||
|
summary?: TraceSummary;
|
||||||
|
samples: IssueSample[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssueTracesResponse {
|
||||||
|
issueId: string;
|
||||||
|
totalOccurrences: number;
|
||||||
|
traces: IssueTrace[];
|
||||||
|
untraced: IssueSample[];
|
||||||
|
hasMore: boolean;
|
||||||
|
nextBefore?: number;
|
||||||
|
/** false when the server only had the bounded in-memory samples. */
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user