Compare commits

...

6 Commits

Author SHA1 Message Date
Nikhil Soni
b065064d89 refactor(tracedetail): drop unused orgID from waterfall module
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:48:02 +05:30
Nikhil Soni
21880d50cb refactor(tracedetail): always read thread JSON attributes, drop unused store orgID
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:25:58 +05:30
Nikhil Soni
e9513112c2 refactor(tracedetail): inline attribute columns, gate thread JSON read on flag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:02:16 +05:30
Nikhil Soni
cb06b13053 refactor(tracedetail): keep orgID on waterfall store reads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:49:36 +05:30
Nikhil Soni
439e328f5f refactor(tracedetail): read JSON attributes unconditionally, drop orgID
The use_trace_attributes_json flag only gated the read; per-row
JSON-else-maps fallback makes it unnecessary, and orgID existed only to
evaluate it. The thread keeps plain map columns since its WHERE filters
on them and the blanking aliases would shadow them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:44:37 +05:30
Nikhil Soni
b265a1d723 feat(tracedetail): read waterfall/flamegraph attributes from JSON column
Attributes() now prefers the flattened JSON document per span and falls
back to the legacy maps, so waterfall, flamegraph and thread share one
bag. With the flag on, maps are blanked in SQL for rows whose JSON is
non-empty so dual-written spans ship attributes once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:32:47 +05:30
13 changed files with 260 additions and 132 deletions

View File

@@ -37,12 +37,6 @@ func (h *handler) GetTraceSummary(rw http.ResponseWriter, r *http.Request) {
}
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
req := new(spantypes.PostableWaterfall)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
@@ -54,7 +48,7 @@ func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
return
}
result, err := h.module.GetWaterfallV4(r.Context(), valuer.MustNewUUID(claims.OrgID), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans)
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req.SelectedSpanID, req.UncollapsedSpans)
if err != nil {
render.Error(rw, err)
return

View File

@@ -64,7 +64,7 @@ func (m *module) GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
// minimal fields for all spans to build the tree, then full fields for the
// visible window only. Aggregations are not returned.
func (m *module) GetWaterfallV4(ctx context.Context, orgID valuer.UUID, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error) {
bounds, err := m.store.GetTraceBounds(ctx, traceID)
if err != nil {
return nil, err
@@ -78,9 +78,9 @@ func (m *module) GetWaterfallV4(ctx context.Context, orgID valuer.UUID, traceID
var trace *spantypes.GettableWaterfallTrace
if windowed {
trace, err = m.getWindowedWaterfall(ctx, orgID, traceID, selectedSpanID, uncollapsedSpans, bounds.Start, bounds.End)
trace, err = m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, bounds.Start, bounds.End)
} else {
trace, err = m.getFullWaterfall(ctx, orgID, traceID, bounds)
trace, err = m.getFullWaterfall(ctx, traceID, bounds)
}
if err != nil {
return nil, err
@@ -91,8 +91,8 @@ func (m *module) GetWaterfallV4(ctx context.Context, orgID valuer.UUID, traceID
return trace, nil
}
func (m *module) getFullWaterfall(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds) (*spantypes.GettableWaterfallTrace, error) {
spanItems, err := m.store.GetTraceSpans(ctx, orgID, traceID, bounds)
func (m *module) getFullWaterfall(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) (*spantypes.GettableWaterfallTrace, error) {
spanItems, err := m.store.GetTraceSpans(ctx, traceID, bounds)
if err != nil {
return nil, err
}
@@ -167,7 +167,7 @@ func (m *module) GetFlamegraph(ctx context.Context, traceID string, selectedSpan
}
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
func (m *module) getWindowedWaterfall(ctx context.Context, orgID valuer.UUID, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpanID string, uncollapsedSpans []string, start, end time.Time) (*spantypes.GettableWaterfallTrace, error) {
// Step 1: minimal fetch → build full tree → select visible window
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, start, end)
if err != nil {
@@ -195,7 +195,7 @@ func (m *module) getWindowedWaterfall(ctx context.Context, orgID valuer.UUID, tr
for i, s := range selectedSpans {
spanIDs[i] = s.SpanID
}
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, orgID, traceID, start, end, spanIDs)
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, traceID, start, end, spanIDs)
if err != nil {
return nil, err
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -25,11 +24,14 @@ import (
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
// storableSpanColumns are the columns a waterfall span scans; the JSON attributes column is added per flag.
// storableSpanColumns are the columns a waterfall span scans. A row's legacy maps are blanked when its
// JSON holds the attributes.
var storableSpanColumns = []string{
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "has_error", "kind",
colServiceName, "name",
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
colServiceName, "name", "attributes", "resources_string",
"if(notEmpty(attributes), map(), attributes_string) AS attributes_string",
"if(notEmpty(attributes), map(), attributes_number) AS attributes_number",
"if(notEmpty(attributes), map(), attributes_bool) AS attributes_bool",
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
"flags", "is_remote", "trace_state", "status_code",
"db_name", "db_operation", "http_method", "http_url", "http_host",
@@ -207,8 +209,8 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
return columns, nil
}
func (s *traceStore) GetTraceSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds) ([]spantypes.StorableSpan, error) {
sb := s.selectStorableSpans(ctx, orgID)
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, bounds *spantypes.TraceBounds) ([]spantypes.StorableSpan, error) {
sb := selectStorableSpans()
sb.Where(
sb.E("trace_id", traceID),
sb.GE("ts_bucket_start", bounds.Start.Unix()-1800),
@@ -249,11 +251,11 @@ func (s *traceStore) GetMinimalSpans(ctx context.Context, traceID string, start,
return spans, nil
}
func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, orgID valuer.UUID, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]spantypes.StorableSpan, error) {
if len(spanIDs) == 0 {
return []spantypes.StorableSpan{}, nil
}
sb := s.selectStorableSpans(ctx, orgID)
sb := selectStorableSpans()
ids := make([]any, len(spanIDs))
for i, id := range spanIDs {
ids[i] = id
@@ -275,14 +277,10 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, orgID valuer.UUID,
return spans, nil
}
// selectStorableSpans reads the JSON attributes column alongside the legacy maps when the
// use_trace_attributes_json flag is on, as the query builder does.
func (s *traceStore) selectStorableSpans(ctx context.Context, orgID valuer.UUID) *sqlbuilder.SelectBuilder {
// selectStorableSpans reads the JSON attributes column alongside the legacy maps.
func selectStorableSpans() *sqlbuilder.SelectBuilder {
sb := sqlbuilder.NewSelectBuilder()
sb.Select(storableSpanColumns...)
if s.flagger.BooleanOrEmpty(ctx, flagger.FeatureUseTraceAttributesJSON, featuretypes.NewFlaggerEvaluationContext(orgID)) {
sb.SelectMore("attributes")
}
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
return sb
}
@@ -293,12 +291,9 @@ func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, trac
sb.Select(
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "parent_span_id", "has_error", "name", "kind_string",
"status_code_string", "status_message", "resources_string",
"attributes_string", "attributes_number", "attributes_bool",
"attributes", "attributes_string", "attributes_number", "attributes_bool",
"events", "links as references",
)
if q.TraceAttrsJSONOn {
sb.SelectMore("attributes")
}
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
keys, err := s.genAIFieldKeys(ctx, orgID, aiobservabilitytypes.GenAISpanGateKeys)
if err != nil {
@@ -382,9 +377,10 @@ func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, sta
"any(has_error) AS has_error",
"any(name) AS name",
"any(events) AS events",
"any(attributes_string) AS attributes_string",
"any(attributes_number) AS attributes_number",
"any(attributes_bool) AS attributes_bool",
"any(attributes) AS attributes",
"if(notEmpty(attributes), map(), any(attributes_string)) AS attributes_string", // `attributes` below resolves to the aggregated alias
"if(notEmpty(attributes), map(), any(attributes_number)) AS attributes_number",
"if(notEmpty(attributes), map(), any(attributes_bool)) AS attributes_bool",
"any(resources_string) AS resources_string",
)
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))

View File

@@ -153,25 +153,39 @@ func TestGetSpanCountByField(t *testing.T) {
}
}
func TestGetFlamegraphSpans(t *testing.T) {
baseSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY span_id ORDER BY timestamp ASC, name ASC"
withSpanIDsSQL := "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes_string) AS attributes_string, any(attributes_number) AS attributes_number, any(attributes_bool) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND span_id IN (?, ?) GROUP BY span_id ORDER BY timestamp ASC, name ASC"
func TestGetTraceSpans(t *testing.T) {
expectedSQL := "SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, has_error, kind, resource_string_service$$name, name, attributes, resources_string, if(notEmpty(attributes), map(), attributes_string) AS attributes_string, if(notEmpty(attributes), map(), attributes_number) AS attributes_number, if(notEmpty(attributes), map(), attributes_bool) AS attributes_bool, events, status_message, status_code_string, kind_string, parent_span_id, flags, is_remote, trace_state, status_code, db_name, db_operation, http_method, http_url, http_host, external_http_method, external_http_url, response_status_code, links as references FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY timestamp ASC, name ASC"
tests := []struct {
s := newTestStore(sqlmock.QueryMatcherEqual)
s.Mock().ExpectSelect(expectedSQL).
WillReturnRows(cmock.NewRows(nil, nil))
_, _ = s.Store().GetTraceSpans(context.Background(), testTraceID, testBounds)
assert.NoError(t, s.Mock().ExpectationsWereMet())
}
func TestGetFlamegraphSpans(t *testing.T) {
testCases := []struct {
name string
spanIDs []string
sql string
}{
{name: "NoSpanIDs_GeneratesBaseSQL", spanIDs: nil, sql: baseSQL},
{name: "WithSpanIDs_GeneratesInClauseSQL", spanIDs: []string{"span-1", "span-2"}, sql: withSpanIDsSQL},
{
name: "NoSpanIDs_GeneratesBaseSQL",
sql: "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes) AS attributes, if(notEmpty(attributes), map(), any(attributes_string)) AS attributes_string, if(notEmpty(attributes), map(), any(attributes_number)) AS attributes_number, if(notEmpty(attributes), map(), any(attributes_bool)) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY span_id ORDER BY timestamp ASC, name ASC",
},
{
name: "WithSpanIDs_GeneratesInClauseSQL",
spanIDs: []string{"span-1", "span-2"},
sql: "SELECT span_id, any(parent_span_id) AS parent_span_id, any(timestamp) AS timestamp, any(duration_nano) AS duration_nano, any(has_error) AS has_error, any(name) AS name, any(events) AS events, any(attributes) AS attributes, if(notEmpty(attributes), map(), any(attributes_string)) AS attributes_string, if(notEmpty(attributes), map(), any(attributes_number)) AS attributes_number, if(notEmpty(attributes), map(), any(attributes_bool)) AS attributes_bool, any(resources_string) AS resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND span_id IN (?, ?) GROUP BY span_id ORDER BY timestamp ASC, name ASC",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := newTestStore(sqlmock.QueryMatcherRegexp)
s.Mock().ExpectSelect(regexp.QuoteMeta(tc.sql)).
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
s := newTestStore(sqlmock.QueryMatcherEqual)
s.Mock().ExpectSelect(testCase.sql).
WillReturnRows(cmock.NewRows(nil, nil))
_, _ = s.Store().GetFlamegraphSpans(context.Background(), testTraceID, testStart, testEnd, tc.spanIDs)
_, _ = s.Store().GetFlamegraphSpans(context.Background(), testTraceID, testStart, testEnd, testCase.spanIDs)
assert.NoError(t, s.Mock().ExpectationsWereMet())
})
}
@@ -204,7 +218,7 @@ func TestGetSpanDurationByField(t *testing.T) {
}
func TestGetThreadSpans(t *testing.T) {
selectSQL := "SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, parent_span_id, has_error, name, kind_string, status_code_string, status_message, resources_string, attributes_string, attributes_number, attributes_bool, events, links as references"
selectSQL := "SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, parent_span_id, has_error, name, kind_string, status_code_string, status_message, resources_string, attributes, attributes_string, attributes_number, attributes_bool, events, links as references"
fromSQL := " FROM signoz_traces.distributed_signoz_index_v3 WHERE trace_id = ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND "
orderSQL := " ORDER BY timestamp ASC, span_id ASC LIMIT ?"
jsonInsideTrace := testStart.Add(500 * time.Second)
@@ -213,20 +227,17 @@ func TestGetThreadSpans(t *testing.T) {
name string
jsonOn bool
jsonRelease *time.Time
selectSQL string
whereSQL string
}{
{
name: "FlagOff_ReadsAndFiltersLegacyMaps",
name: "FlagOff_FiltersLegacyMaps",
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL,
whereSQL: "(mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))",
},
{
name: "FlagOn_ReleasedDuringTrace_ReadsJSONFiltersJSONThenMaps",
name: "FlagOn_ReleasedDuringTrace_FiltersJSONThenMaps",
jsonOn: true,
jsonRelease: &jsonInsideTrace,
selectSQL: selectSQL + ", attributes",
whereSQL: "(multiIf(attributes.`gen_ai.request.model` IS NOT NULL, attributes.`gen_ai.request.model`::String, mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.tool.name` IS NOT NULL, attributes.`gen_ai.tool.name`::String, mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL) IS NOT NULL OR multiIf(attributes.`gen_ai.agent.name` IS NOT NULL, attributes.`gen_ai.agent.name`::String, mapContains(attributes_string, 'gen_ai.agent.name'), attributes_string['gen_ai.agent.name'], NULL) IS NOT NULL)",
},
}
@@ -235,7 +246,7 @@ func TestGetThreadSpans(t *testing.T) {
t.Run(testCase.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{flagger.FeatureUseTraceAttributesJSON.String(): testCase.jsonOn})
s := newTestStoreWithMetadata(sqlmock.QueryMatcherRegexp, genAIMetadataStore(testCase.jsonRelease), fl)
s.Mock().ExpectSelect(regexp.QuoteMeta(testCase.selectSQL + fromSQL + testCase.whereSQL + orderSQL)).
s.Mock().ExpectSelect(regexp.QuoteMeta(selectSQL + fromSQL + testCase.whereSQL + orderSQL)).
WillReturnRows(cmock.NewRows(nil, nil))
_, _ = s.Store().GetThreadSpans(context.Background(), valuer.GenerateUUID(), testTraceID, testBounds, spantypes.ThreadPage{Limit: 3})
assert.NoError(t, s.Mock().ExpectationsWereMet())

View File

@@ -21,7 +21,7 @@ type Handler interface {
// Module defines the business logic for trace detail operations.
type Module interface {
GetTraceSummary(ctx context.Context, orgID valuer.UUID, traceID string) (*spantypes.TraceStats, error)
GetWaterfallV4(ctx context.Context, orgID valuer.UUID, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string) (*spantypes.GettableWaterfallTrace, error)
GetTraceAggregations(ctx context.Context, traceID string, req *spantypes.PostableTraceAggregations) (*spantypes.GettableTraceAggregations, error)
GetFlamegraph(ctx context.Context, traceID string, selectedSpanID string, selectFields []telemetrytypes.TelemetryFieldKey) (*spantypes.GettableFlamegraphTrace, error)
GetThread(ctx context.Context, orgID valuer.UUID, traceID string, query *spantypes.ThreadQuery) (*spantypes.GettableTraceThread, error)

View File

@@ -70,6 +70,7 @@ func NewFlamegraphSpanFromStorable(s *StorableSpan, level int64, selectFields []
if len(selectFields) == 0 {
return span
}
var attributes map[string]any
for _, field := range selectFields {
switch field.FieldContext {
case telemetrytypes.FieldContextResource:
@@ -77,7 +78,10 @@ func NewFlamegraphSpanFromStorable(s *StorableSpan, level int64, selectFields []
span.Resource[field.Name] = v
}
case telemetrytypes.FieldContextAttribute:
if v := s.AttributeValue(field.Name); v != nil {
if attributes == nil {
attributes = s.Attributes()
}
if v, ok := attributes[field.Name]; ok {
span.Attributes[field.Name] = v
}
}

View File

@@ -0,0 +1,59 @@
package spantypes
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func TestNewFlamegraphSpanFromStorable_SelectFields(t *testing.T) {
testCases := []struct {
name string
span StorableSpan
selectFields []telemetrytypes.TelemetryFieldKey
wantAttrs map[string]any
wantResource map[string]string
}{
{
name: "JSONColumn_ResolvesDottedPaths",
span: StorableSpan{AttributesJSON: jsonAttrs(map[string]any{"http.route": "/orders", "http.retry.count": int64(2)})},
selectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "http.route", FieldContext: telemetrytypes.FieldContextAttribute},
{Name: "http.retry.count", FieldContext: telemetrytypes.FieldContextAttribute},
{Name: "http", FieldContext: telemetrytypes.FieldContextAttribute},
},
wantAttrs: map[string]any{"http.route": "/orders", "http.retry.count": float64(2)},
wantResource: map[string]string{},
},
{
name: "LegacyMaps_Resolve",
span: StorableSpan{AttributesString: map[string]string{"http.route": "/cart"}, AttributesBool: map[string]bool{"cache.hit": true}},
selectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "http.route", FieldContext: telemetrytypes.FieldContextAttribute},
{Name: "cache.hit", FieldContext: telemetrytypes.FieldContextAttribute},
{Name: "missing", FieldContext: telemetrytypes.FieldContextAttribute},
},
wantAttrs: map[string]any{"http.route": "/cart", "cache.hit": true},
wantResource: map[string]string{},
},
{
name: "ResourceFields_EmptyValueSkipped",
span: StorableSpan{ResourcesString: map[string]string{"service.name": "checkout", "deployment.environment": ""}},
selectFields: []telemetrytypes.TelemetryFieldKey{
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource},
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource},
},
wantAttrs: map[string]any{},
wantResource: map[string]string{"service.name": "checkout"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
span := NewFlamegraphSpanFromStorable(&testCase.span, 0, testCase.selectFields)
assert.Equal(t, testCase.wantAttrs, span.Attributes)
assert.Equal(t, testCase.wantResource, span.Resource)
})
}
}

View File

@@ -33,9 +33,9 @@ type SpanMapperStore interface {
type TraceStore interface {
GetTraceBounds(ctx context.Context, traceID string) (*TraceBounds, error)
GetTraceStats(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds) (*TraceStats, error)
GetTraceSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds) ([]StorableSpan, error)
GetTraceSpans(ctx context.Context, traceID string, bounds *TraceBounds) ([]StorableSpan, error)
GetMinimalSpans(ctx context.Context, traceID string, start, end time.Time) ([]MinimalSpan, error)
GetTraceSpansByIDs(ctx context.Context, orgID valuer.UUID, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
GetTraceSpansByIDs(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
GetFlamegraphSpans(ctx context.Context, traceID string, start, end time.Time, spanIDs []string) ([]StorableSpan, error)
GetThreadSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *TraceBounds, page ThreadPage) ([]StorableSpan, error)
GetThreadCursor(ctx context.Context, traceID string, bounds *TraceBounds, spanID string) (*ThreadCursor, error)

View File

@@ -177,7 +177,7 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
resources := make(map[string]string, len(storable.ResourcesString))
maps.Copy(resources, storable.ResourcesString)
timeUnixNano := uint64(storable.StartTime.UnixNano())
attributes := threadAttributes(storable)
attributes := storable.Attributes()
// Spans from before the JSON rollout have an empty document and format to nothing.
formatted := aiobservabilitytypes.FormatMessages(storable.AttributesJSON.Nest())
return &ThreadSpan{
@@ -201,13 +201,3 @@ func newThreadSpan(traceID string, storable *StorableSpan) *ThreadSpan {
timeUnixNano: timeUnixNano,
}
}
// threadAttributes flattens the JSON column into dotted keys, as the querier does for list
// responses, and falls back to the legacy maps for spans written before the JSON rollout.
func threadAttributes(storable *StorableSpan) map[string]any {
attributes := storable.AttributesJSON.Flatten()
if len(attributes) == 0 {
return storable.Attributes()
}
return attributes
}

View File

@@ -51,57 +51,6 @@ func TestNewThreadQuery_Cursor(t *testing.T) {
}
}
func TestThreadAttributes(t *testing.T) {
testCases := []struct {
name string
span StorableSpan
wantAttrs map[string]any
}{
{
name: "JSONColumnPresent_LegacyMapsIgnored",
span: StorableSpan{
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "json"}),
AttributesString: map[string]string{"gen_ai.request.model": "map", "http.method": "GET"},
},
wantAttrs: map[string]any{"gen_ai.request.model": "json"},
},
{
name: "NoJSONColumn_FallsBackToLegacyMaps",
span: StorableSpan{
AttributesString: map[string]string{"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`},
AttributesNumber: map[string]float64{"gen_ai.usage.input_tokens": 12},
AttributesBool: map[string]bool{"gen_ai.stream": true},
},
wantAttrs: map[string]any{
"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`,
"gen_ai.usage.input_tokens": float64(12),
"gen_ai.stream": true,
},
},
{
// ClickHouse materializes a row's absent paths as null from the part's path schema.
name: "JSONColumn_DropsNullPaths",
span: StorableSpan{
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": chcol.Variant{}}),
},
wantAttrs: map[string]any{"gen_ai.request.model": "gpt-4o"},
},
{
name: "JSONColumn_OnlyNullPaths_FallsBackToLegacyMaps",
span: StorableSpan{
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.output.messages": chcol.Variant{}}),
AttributesString: map[string]string{"gen_ai.input.messages": "legacy"},
},
wantAttrs: map[string]any{"gen_ai.input.messages": "legacy"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.wantAttrs, threadAttributes(&testCase.span))
})
}
}
func TestNewThreadSpan_FormatsJSONMessages(t *testing.T) {
storable := StorableSpan{
SpanID: "f1fa1bc863e94dd0",

View File

@@ -273,20 +273,12 @@ func (ws *WaterfallSpan) getPathToSelectedSpanID(selectedSpanID string) ([]strin
return nil, false
}
func (item *StorableSpan) AttributeValue(name string) any {
if v, ok := item.AttributesString[name]; ok {
return v
}
if v, ok := item.AttributesNumber[name]; ok {
return v
}
if v, ok := item.AttributesBool[name]; ok {
return v
}
return nil
}
// Attributes flattens the JSON column into dotted keys, as the querier does for list responses, and
// falls back to the legacy maps for spans written before the JSON rollout.
func (item *StorableSpan) Attributes() map[string]any {
if attributes := item.AttributesJSON.Flatten(); len(attributes) > 0 {
return attributes
}
attributes := make(map[string]any, len(item.AttributesString)+len(item.AttributesNumber)+len(item.AttributesBool))
for k, v := range item.AttributesString {
attributes[k] = v

View File

@@ -0,0 +1,66 @@
package spantypes
import (
"testing"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
"github.com/stretchr/testify/assert"
)
func TestStorableSpanAttributes(t *testing.T) {
testCases := []struct {
name string
span StorableSpan
wantAttrs map[string]any
}{
{
name: "JSONColumnPresent_LegacyMapsIgnored",
span: StorableSpan{
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "json"}),
AttributesString: map[string]string{"gen_ai.request.model": "map", "http.method": "GET"},
},
wantAttrs: map[string]any{"gen_ai.request.model": "json"},
},
{
name: "NoJSONColumn_FallsBackToLegacyMaps",
span: StorableSpan{
AttributesString: map[string]string{"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`},
AttributesNumber: map[string]float64{"gen_ai.usage.input_tokens": 12},
AttributesBool: map[string]bool{"gen_ai.stream": true},
},
wantAttrs: map[string]any{
"gen_ai.output.messages": `[{"role":"assistant","content":"hello"}]`,
"gen_ai.usage.input_tokens": float64(12),
"gen_ai.stream": true,
},
},
{
name: "JSONColumn_ScalarAndObjectKey_StaysTwoPaths",
span: StorableSpan{
AttributesJSON: jsonAttrs(map[string]any{"db.function": "node_refresh", "db.function.arg_count": int64(2)}),
},
wantAttrs: map[string]any{"db.function": "node_refresh", "db.function.arg_count": float64(2)},
},
{
// ClickHouse materializes a row's absent paths as null from the part's path schema.
name: "JSONColumn_DropsNullPaths",
span: StorableSpan{
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.request.model": "gpt-4o", "gen_ai.output.messages": chcol.Variant{}}),
},
wantAttrs: map[string]any{"gen_ai.request.model": "gpt-4o"},
},
{
name: "JSONColumn_OnlyNullPaths_FallsBackToLegacyMaps",
span: StorableSpan{
AttributesJSON: jsonAttrs(map[string]any{"gen_ai.output.messages": chcol.Variant{}}),
AttributesString: map[string]string{"gen_ai.input.messages": "legacy"},
},
wantAttrs: map[string]any{"gen_ai.input.messages": "legacy"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.wantAttrs, testCase.span.Attributes())
})
}
}

View File

@@ -0,0 +1,67 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.traces import ATTRIBUTE_JSON_ROLLOUT_TIME, TraceIdGenerator, Traces, TracesKind
def test_waterfall_and_flamegraph_read_each_attribute_home(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
seed_attribute_evolution: Callable[[str, datetime], None],
) -> None:
seed_attribute_evolution("traces", ATTRIBUTE_JSON_ROLLOUT_TIME)
now = datetime.now(tz=UTC).replace(microsecond=0)
trace_id = TraceIdGenerator.trace_id()
legacy_id, json_id, dual_id = (TraceIdGenerator.span_id() for _ in range(3))
resources = {"service.name": "tracedetail-attribute-homes"}
insert_traces(
[
Traces(timestamp=now - timedelta(seconds=10), duration=timedelta(seconds=9), trace_id=trace_id, span_id=legacy_id, name="GET /legacy", kind=TracesKind.SPAN_KIND_SERVER, resources=resources, attributes={"http.route": "/legacy", "http.retry.count": 1, "cache.hit": True}, attribute_write_mode="legacy_only"),
Traces(timestamp=now - timedelta(seconds=8), trace_id=trace_id, span_id=json_id, parent_span_id=legacy_id, name="db call", resources=resources, attributes={"http.route": "/json", "db.function": "node_refresh", "db.function.arg_count": 2}, attribute_write_mode="json_only"),
Traces(timestamp=now - timedelta(seconds=6), trace_id=trace_id, span_id=dual_id, parent_span_id=legacy_id, name="GET /dual", resources=resources, attributes={"http.route": "/dual"}),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v4/traces/{trace_id}/waterfall"),
json={"selectedSpanId": "", "uncollapsedSpans": []},
headers=headers,
timeout=10,
)
assert response.status_code == HTTPStatus.OK, response.text
waterfall = {span["span_id"]: span["attributes"] for span in response.json()["data"]["spans"]}
assert waterfall[legacy_id] == {"http.route": "/legacy", "http.retry.count": 1, "cache.hit": True}
# a key stored at both a scalar and an object path stays two flat keys
assert waterfall[json_id] == {"http.route": "/json", "db.function": "node_refresh", "db.function.arg_count": 2}
assert waterfall[dual_id] == {"http.route": "/dual"}
response = requests.post(
signoz.self.host_configs["8080"].get(f"/api/v3/traces/{trace_id}/flamegraph"),
json={
"selectedSpanId": "",
"selectFields": [
{"name": "http.route", "fieldContext": "attribute"},
{"name": "db.function.arg_count", "fieldContext": "attribute"},
{"name": "service.name", "fieldContext": "resource"},
],
},
headers=headers,
timeout=10,
)
assert response.status_code == HTTPStatus.OK, response.text
flamegraph = {span["spanId"]: span for level in response.json()["data"]["spans"] for span in level}
assert flamegraph[legacy_id]["attributes"] == {"http.route": "/legacy"}
assert flamegraph[json_id]["attributes"] == {"http.route": "/json", "db.function.arg_count": 2}
assert flamegraph[dual_id]["attributes"] == {"http.route": "/dual"}
assert all(span["resource"] == resources for span in flamegraph.values())