Compare commits

...

5 Commits

Author SHA1 Message Date
Nikhil Soni
99cdb69510 test(spantypes): make attribute bag tests table-driven
Convert the StorableSpan.Attributes and flamegraph selectFields tests to the testCases/testCase table format, and drop the long GetTraceSpans comment.

Assisted-by: Claude Opus 4.8
2026-09-29 12:30:21 +05:30
Nikhil Soni
a2d0a39e76 refactor(tracedetail): read native JSON via flattened serialization, drop CAST
Set output_format_native_use_flattened_dynamic_and_json_serialization=1 (with write_json_as_string=0) per-query so clickhouse-go decodes the JSON(max_dynamic_paths=0) column natively, dropping the CAST workaround and the nativeJSONReadCtx wrapper. Inline the test SQL and trim comments.

Assisted-by: Claude Opus 4.8
2026-09-29 12:03:39 +05:30
Nikhil Soni
07ff0f8aa4 fix(tracedetail): read span attributes as native JSON to keep scalar-and-parent keys
The string form (output_format_native_write_json_as_string) collapses the duplicate JSON key a path stored as both a scalar and an object produces. Read the column as native chcol.JSON via a per-query setting override, CAST it to normalise the wire type past clickhouse-go #1854, and build the bag from ValuesByPath so both survive.

Assisted-by: Claude Opus 4.8
2026-09-28 21:41:50 +05:30
Nikhil Soni
28e574dc55 fix(tracedetail): prefer JSON attribute home over legacy maps
Blank the maps when the JSON column holds the row instead of suppressing JSON: CAST('{}','JSON') re-parses the document and fails with a duplicate-path error on keys stored as both a scalar and an object.

Assisted-by: Claude Opus 4.8
2026-09-28 17:05:35 +05:30
Nikhil Soni
073ba42495 feat(tracedetail): read waterfall/flamegraph span attributes from the JSON column
Span rows hold their attributes either in the legacy maps or in the
`attributes` JSON column (dual-written rows in both), so the trace span
reads (GetTraceSpans, GetTraceSpansByIDs, GetFlamegraphSpans) suppress
the JSON document per row when any legacy map carries the row's
attributes: if(notEmpty(attributes_string) OR ..., CAST('{}', 'JSON'),
attributes). The result set carries one attribute home per span instead
of both — full waterfall/flamegraph load up to 10k/100k spans in
memory, and reading both homes during dual-write would double that.
Probing the maps (not the JSON column) is what tells dual-written rows
apart from json-only ones.

StorableSpan gains AttributesJSON; the merged bag keeps its flat
dotted-key shape (Attributes() flattens the JSON document first and
lays the maps over it, maps winning on collision — same precedence as
the querier list view). The flamegraph selectFields path builds that
bag at most once per span and resolves dotted names through it, which
subsumes AttributeValue(). FlattenJSONPaths moves to
telemetrystoretypes to be shared with the querier.
2026-09-28 14:37:36 +05:30
10 changed files with 284 additions and 78 deletions

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"
sqlbuilder "github.com/huandu/go-sqlbuilder"
@@ -11,12 +12,21 @@ import (
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const colServiceName = `resource_string_service$$$$name` // $ gets escaped so $$$$ converts to $$.
// JSON is the preferred attribute home; each legacy map is blanked when the JSON column holds the row.
var spanAttributeHomeSelection = strings.Join([]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",
"attributes",
}, ", ")
func buildFieldExpr(fieldKey telemetrytypes.TelemetryFieldKey) (string, error) {
switch fieldKey.FieldContext {
case telemetrytypes.FieldContextResource:
@@ -66,12 +76,12 @@ func (s *traceStore) GetTraceSummary(ctx context.Context, traceID string) (*span
}
func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary) ([]spantypes.StorableSpan, error) {
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder
// DISTINCT ON (span_id) is ClickHouse-specific syntax not supported by sqlbuilder.
query := fmt.Sprintf(`
SELECT DISTINCT ON (span_id)
timestamp, duration_nano, span_id, has_error, kind,
resource_string_service$$name, name,
attributes_string, attributes_number, attributes_bool, resources_string,
%s, resources_string,
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,
@@ -79,11 +89,11 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
FROM %s.%s
WHERE trace_id=? AND ts_bucket_start>=? AND ts_bucket_start<=?
ORDER BY timestamp ASC, name ASC`,
spantypes.TraceDB, spantypes.TraceTable,
spanAttributeHomeSelection, spantypes.TraceDB, spantypes.TraceTable,
)
var spanItems []spantypes.StorableSpan
err := s.telemetryStore.ClickhouseDB().Select(
ctx, &spanItems, query,
ctxtypes.SetClickhouseReadJSONNative(ctx), &spanItems, query,
traceID,
summary.Start.Unix()-1800,
summary.End.Unix(),
@@ -127,7 +137,7 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
"DISTINCT ON (span_id) timestamp",
"duration_nano", "span_id", "has_error", "kind",
colServiceName, "name",
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
spanAttributeHomeSelection, "resources_string",
"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",
@@ -149,7 +159,7 @@ func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, sta
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var spans []spantypes.StorableSpan
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
if err := s.telemetryStore.ClickhouseDB().Select(ctxtypes.SetClickhouseReadJSONNative(ctx), &spans, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace spans by IDs")
}
return spans, nil
@@ -165,9 +175,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(if(notEmpty(attributes), map(), attributes_string)) AS attributes_string",
"any(if(notEmpty(attributes), map(), attributes_number)) AS attributes_number",
"any(if(notEmpty(attributes), map(), attributes_bool)) AS attributes_bool",
"any(attributes) AS attributes",
"any(resources_string) AS resources_string",
)
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
@@ -190,7 +201,7 @@ func (s *traceStore) GetFlamegraphSpans(ctx context.Context, traceID string, sta
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
var spans []spantypes.StorableSpan
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
if err := s.telemetryStore.ClickhouseDB().Select(ctxtypes.SetClickhouseReadJSONNative(ctx), &spans, query, args...); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying flamegraph spans")
}
return spans, nil

View File

@@ -91,17 +91,46 @@ 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) {
s := newTestStore(sqlmock.QueryMatcherRegexp)
s.Mock().ExpectSelect(`(?s)SELECT\s+DISTINCT ON \(span_id\).*?` +
`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, ` +
`attributes, resources_string.*?FROM signoz_traces\.distributed_signoz_index_v3`).
WillReturnRows(cmock.NewRows(nil, nil))
_, _ = s.Store().GetTraceSpans(context.Background(), testTraceID, testSummary)
assert.NoError(t, s.Mock().ExpectationsWereMet())
}
func TestGetTraceSpansByIDs(t *testing.T) {
s := newTestStore(sqlmock.QueryMatcherRegexp)
s.Mock().ExpectSelect(`SELECT DISTINCT ON \(span_id\) timestamp.*?` +
`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, ` +
`attributes, resources_string.*?FROM signoz_traces\.distributed_signoz_index_v3 WHERE trace_id = \? AND span_id IN \(\?, \?\)`).
WillReturnRows(cmock.NewRows(nil, nil))
_, _ = s.Store().GetTraceSpansByIDs(context.Background(), testTraceID, testStart, testEnd, []string{"span-1", "span-2"})
assert.NoError(t, s.Mock().ExpectationsWereMet())
}
func TestGetFlamegraphSpans(t *testing.T) {
tests := []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",
spanIDs: nil,
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(if(notEmpty(attributes), map(), attributes_string)) AS attributes_string, any(if(notEmpty(attributes), map(), attributes_number)) AS attributes_number, any(if(notEmpty(attributes), map(), attributes_bool)) AS attributes_bool, any(attributes) AS attributes, 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(if(notEmpty(attributes), map(), attributes_string)) AS attributes_string, any(if(notEmpty(attributes), map(), attributes_number)) AS attributes_number, any(if(notEmpty(attributes), map(), attributes_bool)) AS attributes_bool, any(attributes) AS attributes, 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 {

View File

@@ -566,23 +566,7 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
}, nil
}
// flattenJSONPaths flattens a decoded JSON document into dotted keys, overwriting existing keys in out.
func flattenJSONPaths(prefix string, m map[string]any, out map[string]any) {
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
switch child := v.(type) {
case map[string]any:
flattenJSONPaths(key, child, out)
case telemetrystoretypes.JSONValue:
flattenJSONPaths(key, child, out)
default:
out[key] = v
}
}
}
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
// unified "attributes" and "resource" keys, and parses the stringified `events`
@@ -598,7 +582,7 @@ func mergeSpanAttributeColumns(data map[string]any) {
resStr, hasRes := data["resources_string"]
if hasStr || hasNum || hasBool || attrJSON != nil || hasRes {
attributes := make(map[string]any)
flattenJSONPaths("", attrJSON, attributes)
telemetrystoretypes.FlattenJSONPaths("", attrJSON, attributes)
if m, ok := attrStr.(map[string]string); ok {
for k, v := range m {
attributes[k] = v

View File

@@ -30,6 +30,11 @@ func (h *provider) BeforeQuery(ctx context.Context, _ *telemetrystore.QueryEvent
settings["log_comment"] = ctxtypes.CommentFromContext(ctx).String()
if ctx.Value(ctxtypes.ClickhouseContextReadJSONNativeKey) != nil {
settings["output_format_native_write_json_as_string"] = 0
settings["output_format_native_use_flattened_dynamic_and_json_serialization"] = 1
}
if ctx.Value("enforce_max_result_rows") != nil {
settings["max_result_rows"] = h.settings.MaxResultRows
}

View File

@@ -5,10 +5,16 @@ import "context"
type ctxKey string
const (
ClickhouseContextMaxThreadsKey ctxKey = "clickhouse_max_threads"
ClickhouseContextMaxThreadsKey ctxKey = "clickhouse_max_threads"
ClickhouseContextReadJSONNativeKey ctxKey = "clickhouse_read_json_native"
)
// SetClickhouseMaxThreads stores the max threads value in context.
func SetClickhouseMaxThreads(ctx context.Context, maxThreads int) context.Context {
return context.WithValue(ctx, ClickhouseContextMaxThreadsKey, maxThreads)
}
// SetClickhouseReadJSONNative marks the query to read JSON columns as native documents instead of collapsed strings.
func SetClickhouseReadJSONNative(ctx context.Context) context.Context {
return context.WithValue(ctx, ClickhouseContextReadJSONNativeKey, true)
}

View File

@@ -70,6 +70,9 @@ func NewFlamegraphSpanFromStorable(s *StorableSpan, level int64, selectFields []
if len(selectFields) == 0 {
return span
}
// The attributes bag (legacy maps + flattened JSON document) is built at most once per
// span, on the first attribute-context field — not per field.
var attributes map[string]any
for _, field := range selectFields {
switch field.FieldContext {
case telemetrytypes.FieldContextResource:
@@ -77,7 +80,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 && v != nil {
span.Attributes[field.Name] = v
}
}

View File

@@ -0,0 +1,72 @@
package spantypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
)
func attributeField(name string) telemetrytypes.TelemetryFieldKey {
return telemetrytypes.TelemetryFieldKey{Name: name, FieldContext: telemetrytypes.FieldContextAttribute}
}
func resourceField(name string) telemetrytypes.TelemetryFieldKey {
return telemetrytypes.TelemetryFieldKey{Name: name, FieldContext: telemetrytypes.FieldContextResource}
}
// selectFields resolve from the span's merged attribute bag (JSON or legacy maps) and from
// resources_string; leaf-only, empties and missing keys contribute nothing.
func TestNewFlamegraphSpanFromStorableSelectFields(t *testing.T) {
testCases := []struct {
name string
span *StorableSpan
selectFields []telemetrytypes.TelemetryFieldKey
wantAttrs map[string]any
wantResource map[string]string
}{
{
name: "AttributeFields_ResolveFromFlattenedJSONPaths",
span: &StorableSpan{SpanID: "s1", AttributesJSON: makeAttributesJSON(map[string]any{"http.route": "/a", "http.retry.count": float64(2)})},
selectFields: []telemetrytypes.TelemetryFieldKey{attributeField("http.route"), attributeField("http.retry.count"), attributeField("http"), attributeField("missing")},
wantAttrs: map[string]any{"http.route": "/a", "http.retry.count": float64(2)},
wantResource: map[string]string{},
},
{
name: "AttributeFields_ResolveFromLegacyMaps",
span: &StorableSpan{SpanID: "s1", AttributesString: map[string]string{"http.route": "/a"}, AttributesNumber: map[string]float64{"http.retry.count": 2}},
selectFields: []telemetrytypes.TelemetryFieldKey{attributeField("http.route"), attributeField("http.retry.count"), attributeField("missing")},
wantAttrs: map[string]any{"http.route": "/a", "http.retry.count": float64(2)},
wantResource: map[string]string{},
},
{
name: "JSONNullValues_AreSkipped",
span: &StorableSpan{SpanID: "s1", AttributesJSON: makeAttributesJSON(map[string]any{"k": nil})},
selectFields: []telemetrytypes.TelemetryFieldKey{attributeField("k")},
wantAttrs: map[string]any{},
wantResource: map[string]string{},
},
{
name: "ResourceFields_ComeFromResourcesStringEmptiesSkipped",
span: &StorableSpan{SpanID: "s1", ResourcesString: map[string]string{"service.name": "api", "deployment.environment": ""}},
selectFields: []telemetrytypes.TelemetryFieldKey{resourceField("service.name"), resourceField("deployment.environment"), resourceField("missing")},
wantAttrs: map[string]any{},
wantResource: map[string]string{"service.name": "api"},
},
{
name: "NoSelectFields_EmptyBags",
span: &StorableSpan{SpanID: "s1", AttributesString: map[string]string{"http.route": "/a"}},
selectFields: nil,
wantAttrs: map[string]any{},
wantResource: map[string]string{},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
flat := NewFlamegraphSpanFromStorable(testCase.span, 0, testCase.selectFields)
assert.Equal(t, testCase.wantAttrs, flat.Attributes)
assert.Equal(t, testCase.wantResource, flat.Resource)
})
}
}

View File

@@ -7,6 +7,7 @@ import (
"sort"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -93,35 +94,37 @@ type WaterfallSpan struct {
// StorableSpan is the ClickHouse scan struct for the v3 waterfall query.
type StorableSpan struct {
StartTime time.Time `ch:"timestamp"`
DurationNano uint64 `ch:"duration_nano"`
SpanID string `ch:"span_id"`
HasError bool `ch:"has_error"`
Kind int8 `ch:"kind"`
ServiceName string `ch:"resource_string_service$$name"`
Name string `ch:"name"`
AttributesString map[string]string `ch:"attributes_string"`
AttributesNumber map[string]float64 `ch:"attributes_number"`
AttributesBool map[string]bool `ch:"attributes_bool"`
ResourcesString map[string]string `ch:"resources_string"`
Events []string `ch:"events"`
StatusMessage string `ch:"status_message"`
StatusCodeString string `ch:"status_code_string"`
SpanKind string `ch:"kind_string"`
ParentSpanID string `ch:"parent_span_id"`
Flags uint32 `ch:"flags"`
IsRemote string `ch:"is_remote"`
TraceState string `ch:"trace_state"`
StatusCode int16 `ch:"status_code"`
DBName string `ch:"db_name"`
DBOperation string `ch:"db_operation"`
HTTPMethod string `ch:"http_method"`
HTTPURL string `ch:"http_url"`
HTTPHost string `ch:"http_host"`
ExternalHTTPMethod string `ch:"external_http_method"`
ExternalHTTPURL string `ch:"external_http_url"`
ResponseStatusCode string `ch:"response_status_code"`
References string `ch:"references"`
StartTime time.Time `ch:"timestamp"`
DurationNano uint64 `ch:"duration_nano"`
SpanID string `ch:"span_id"`
HasError bool `ch:"has_error"`
Kind int8 `ch:"kind"`
ServiceName string `ch:"resource_string_service$$name"`
Name string `ch:"name"`
AttributesString map[string]string `ch:"attributes_string"`
AttributesNumber map[string]float64 `ch:"attributes_number"`
AttributesBool map[string]bool `ch:"attributes_bool"`
// AttributesJSON is the post-rollout home; the reads suppress the empty home, so a span holds its attributes here or in the maps, never both.
AttributesJSON chcol.JSON `ch:"attributes"`
ResourcesString map[string]string `ch:"resources_string"`
Events []string `ch:"events"`
StatusMessage string `ch:"status_message"`
StatusCodeString string `ch:"status_code_string"`
SpanKind string `ch:"kind_string"`
ParentSpanID string `ch:"parent_span_id"`
Flags uint32 `ch:"flags"`
IsRemote string `ch:"is_remote"`
TraceState string `ch:"trace_state"`
StatusCode int16 `ch:"status_code"`
DBName string `ch:"db_name"`
DBOperation string `ch:"db_operation"`
HTTPMethod string `ch:"http_method"`
HTTPURL string `ch:"http_url"`
HTTPHost string `ch:"http_host"`
ExternalHTTPMethod string `ch:"external_http_method"`
ExternalHTTPURL string `ch:"external_http_url"`
ResponseStatusCode string `ch:"response_status_code"`
References string `ch:"references"`
}
// MinimalSpan with only the fields needed to build the parent-child tree.
@@ -264,21 +267,17 @@ 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
}
func (item *StorableSpan) Attributes() map[string]any {
attributes := make(map[string]any, len(item.AttributesString)+len(item.AttributesNumber)+len(item.AttributesBool))
jsonPaths := item.AttributesJSON.ValuesByPath()
attributes := make(map[string]any, len(jsonPaths)+len(item.AttributesString)+len(item.AttributesNumber)+len(item.AttributesBool))
// JSON paths first (a scalar-and-object key stays two distinct paths), then legacy maps overlaid as the fallback.
for path, value := range jsonPaths {
if dynamic, ok := value.(chcol.Variant); ok {
attributes[path] = dynamic.Any()
} else {
attributes[path] = value
}
}
for k, v := range item.AttributesString {
attributes[k] = v
}

View File

@@ -0,0 +1,72 @@
package spantypes
import (
"testing"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
"github.com/stretchr/testify/assert"
)
func makeAttributesJSON(paths map[string]any) chcol.JSON {
j := chcol.NewJSON()
for path, value := range paths {
j.SetValueAtPath(path, value)
}
return *j
}
// A span carries its attributes in the legacy maps or in AttributesJSON (never both); the merged
// bag is always the flat dotted-key shape, with maps winning over a same-named JSON path.
func TestStorableSpanAttributes(t *testing.T) {
testCases := []struct {
name string
span *StorableSpan
want map[string]any
}{
{
name: "MapOnly_ReadsFromLegacyMaps",
span: &StorableSpan{
AttributesString: map[string]string{"http.route": "/a"},
AttributesNumber: map[string]float64{"http.retry.count": 2},
AttributesBool: map[string]bool{"http.cache.hit": true},
},
want: map[string]any{"http.route": "/a", "http.retry.count": float64(2), "http.cache.hit": true},
},
{
name: "JSONOnly_ReadsFlattenedPaths",
span: &StorableSpan{
AttributesJSON: makeAttributesJSON(map[string]any{"http.route": "/b", "http.retry.count": float64(2), "cache.hit": true}),
},
want: map[string]any{"http.route": "/b", "http.retry.count": float64(2), "cache.hit": true},
},
{
name: "ScalarAndObjectKey_StaysDistinctPaths",
span: &StorableSpan{
AttributesJSON: makeAttributesJSON(map[string]any{"db.function": "node_refresh", "db.function.arg_count": float64(2)}),
},
want: map[string]any{"db.function": "node_refresh", "db.function.arg_count": float64(2)},
},
{
name: "MapEntry_WinsOverSameNamedJSONPath",
span: &StorableSpan{
AttributesString: map[string]string{"http.route": "/a"},
AttributesJSON: makeAttributesJSON(map[string]any{"http.route": "/stale"}),
},
want: map[string]any{"http.route": "/a"},
},
{
name: "EmptyJSON_ContributesNothing",
span: &StorableSpan{
AttributesString: map[string]string{"http.route": "/a"},
AttributesJSON: makeAttributesJSON(nil),
},
want: map[string]any{"http.route": "/a"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, testCase.span.Attributes())
})
}
}

View File

@@ -0,0 +1,22 @@
package telemetrystoretypes
// FlattenJSONPaths flattens a decoded JSON document into dotted keys (a nested {"http":{"route":x}}
// becomes "http.route": x), matching the legacy map representation so the attributes bag has the
// same flat shape whether it was read from the maps or the JSON column. Existing keys are
// overwritten, so a JSON path wins over a same-named map entry.
func FlattenJSONPaths(prefix string, m map[string]any, out map[string]any) {
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
switch child := v.(type) {
case map[string]any:
FlattenJSONPaths(key, child, out)
case JSONValue:
FlattenJSONPaths(key, child, out)
default:
out[key] = v
}
}
}