mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-12 21:20:57 +01:00
Compare commits
5 Commits
feat/input
...
ns/trace-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99cdb69510 | ||
|
|
a2d0a39e76 | ||
|
|
07ff0f8aa4 | ||
|
|
28e574dc55 | ||
|
|
073ba42495 |
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
72
pkg/types/spantypes/flamegraph_span_test.go
Normal file
72
pkg/types/spantypes/flamegraph_span_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
72
pkg/types/spantypes/waterfall_span_test.go
Normal file
72
pkg/types/spantypes/waterfall_span_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
22
pkg/types/telemetrystoretypes/json_flatten.go
Normal file
22
pkg/types/telemetrystoretypes/json_flatten.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user