mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 19:40:41 +00:00
Compare commits
6 Commits
main
...
ns/trace-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b065064d89 | ||
|
|
21880d50cb | ||
|
|
e9513112c2 | ||
|
|
cb06b13053 | ||
|
|
439e328f5f | ||
|
|
b265a1d723 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
59
pkg/types/spantypes/flamegraph_span_test.go
Normal file
59
pkg/types/spantypes/flamegraph_span_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
66
pkg/types/spantypes/waterfall_span_test.go
Normal file
66
pkg/types/spantypes/waterfall_span_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
67
tests/integration/tests/tracedetail/04_attribute_homes.py
Normal file
67
tests/integration/tests/tracedetail/04_attribute_homes.py
Normal 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())
|
||||
Reference in New Issue
Block a user