Files
mats 49f9bb3458
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 1m3s
materialized view and a bit better ui
2026-09-19 12:43:17 +02:00

252 lines
6.2 KiB
Go

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
}