mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-16 13:30:36 +01:00
Compare commits
36 Commits
settings-e
...
issue_4203
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a086ba27c | ||
|
|
59ca03330f | ||
|
|
c076d48b9c | ||
|
|
7f7e6e3659 | ||
|
|
b210e5f532 | ||
|
|
8c719696bf | ||
|
|
7ae7c6eb4b | ||
|
|
c4efc0d2da | ||
|
|
13dec174bf | ||
|
|
9ee57c0950 | ||
|
|
33df48c822 | ||
|
|
af117374c8 | ||
|
|
ba4cef67ac | ||
|
|
f0c33a6734 | ||
|
|
e897f4866a | ||
|
|
282b6fdef1 | ||
|
|
9b64bb2fc0 | ||
|
|
b818ff5fc4 | ||
|
|
e7d729ab5d | ||
|
|
ed812ad1c8 | ||
|
|
3b82c2ce43 | ||
|
|
214980ddad | ||
|
|
a7b69a2678 | ||
|
|
73c82f50a9 | ||
|
|
2593c5eb91 | ||
|
|
b6b2d36baa | ||
|
|
a444a039f9 | ||
|
|
bfb050ec17 | ||
|
|
ff3e87f70c | ||
|
|
9ac02ebe00 | ||
|
|
fbdd0bebbc | ||
|
|
b2245b48fe | ||
|
|
87e654fc73 | ||
|
|
0ee31ce440 | ||
|
|
63e681b87b | ||
|
|
28375c8c1e |
@@ -363,6 +363,15 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: This should move to readAsRaw function in consume.go but for now we are keeping it here since it's only relevant for traces
|
||||
if q.spec.Signal == telemetrytypes.SignalTraces {
|
||||
if raw, ok := payload.(*qbtypes.RawData); ok {
|
||||
for _, rr := range raw.Rows {
|
||||
mergeSpanAttributeColumns(rr.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &qbtypes.Result{
|
||||
Type: q.kind,
|
||||
Value: payload,
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
@@ -452,6 +453,53 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
|
||||
// unified "attributes" and "resource" keys, and parses the stringified `events`
|
||||
// and `links` columns into structured slices. Raw DB columns are removed.
|
||||
func mergeSpanAttributeColumns(data map[string]any) {
|
||||
attrStr, hasStr := data["attributes_string"]
|
||||
attrNum, hasNum := data["attributes_number"]
|
||||
attrBool, hasBool := data["attributes_bool"]
|
||||
// todo(nitya): move to resource json
|
||||
resStr, hasRes := data["resources_string"]
|
||||
if hasStr || hasNum || hasBool || hasRes {
|
||||
attributes := make(map[string]any)
|
||||
if m, ok := attrStr.(map[string]string); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
}
|
||||
}
|
||||
if m, ok := attrNum.(map[string]float64); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
}
|
||||
}
|
||||
if m, ok := attrBool.(map[string]bool); ok {
|
||||
for k, v := range m {
|
||||
attributes[k] = v
|
||||
}
|
||||
}
|
||||
delete(data, "attributes_string")
|
||||
delete(data, "attributes_number")
|
||||
delete(data, "attributes_bool")
|
||||
data["attributes"] = attributes
|
||||
|
||||
resource := map[string]string{}
|
||||
if m, ok := resStr.(map[string]string); ok {
|
||||
resource = m
|
||||
}
|
||||
data["resource"] = resource
|
||||
delete(data, "resources_string")
|
||||
}
|
||||
|
||||
if raw, ok := data["events"]; ok {
|
||||
data["events"] = spantypes.ParseEvents(raw)
|
||||
}
|
||||
if raw, ok := data["links"]; ok {
|
||||
data["links"] = spantypes.ParseLinks(raw)
|
||||
}
|
||||
}
|
||||
|
||||
// numericAsFloat converts numeric types to float64 efficiently.
|
||||
func numericAsFloat(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
|
||||
92
pkg/querier/consume_test.go
Normal file
92
pkg/querier/consume_test.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
)
|
||||
|
||||
func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"attributes_string": map[string]string{"http.method": "GET"},
|
||||
"attributes_number": map[string]float64{"http.status_code": 200},
|
||||
"attributes_bool": map[string]bool{"is_root": true},
|
||||
"resources_string": map[string]string{"service.name": "api"},
|
||||
"events": []string{
|
||||
`{"name":"request_received","timeUnixNano":1778489782759245000,"attributeMap":{"http.method":"GET","http.route":"/api/chat"}}`,
|
||||
`{"name":"cache_lookup","timeUnixNano":1778489782811697000,"attributeMap":{"cache.hit":"true","cache.key":"user:123:prompt"}}`,
|
||||
},
|
||||
"links": `[{"traceId":"abc","spanId":"123","refType":"CHILD_OF"},{"traceId":"def","spanId":"456","refType":"FOLLOWS_FROM"}]`,
|
||||
}
|
||||
|
||||
mergeSpanAttributeColumns(data)
|
||||
|
||||
attrs, ok := data["attributes"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected attributes to be map[string]any, got %T", data["attributes"])
|
||||
}
|
||||
if attrs["http.method"] != "GET" || attrs["http.status_code"] != float64(200) || attrs["is_root"] != true {
|
||||
t.Fatalf("attributes not merged correctly: %#v", attrs)
|
||||
}
|
||||
|
||||
res, ok := data["resource"].(map[string]string)
|
||||
if !ok || res["service.name"] != "api" {
|
||||
t.Fatalf("resource not set correctly: %#v", data["resource"])
|
||||
}
|
||||
|
||||
for _, removed := range []string{"attributes_string", "attributes_number", "attributes_bool", "resources_string"} {
|
||||
if _, present := data[removed]; present {
|
||||
t.Fatalf("expected %s to be removed", removed)
|
||||
}
|
||||
}
|
||||
|
||||
events, ok := data["events"].([]spantypes.EventV2)
|
||||
if !ok {
|
||||
t.Fatalf("expected events to be []spantypes.EventV2, got %T", data["events"])
|
||||
}
|
||||
wantEvents := []spantypes.EventV2{
|
||||
{
|
||||
Name: "request_received",
|
||||
TimeUnixNano: 1778489782759245000,
|
||||
Attributes: map[string]any{"http.method": "GET", "http.route": "/api/chat"},
|
||||
IsError: false,
|
||||
},
|
||||
{
|
||||
Name: "cache_lookup",
|
||||
TimeUnixNano: 1778489782811697000,
|
||||
Attributes: map[string]any{"cache.hit": "true", "cache.key": "user:123:prompt"},
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(events, wantEvents) {
|
||||
t.Fatalf("events parsed incorrectly:\n got: %#v\nwant: %#v", events, wantEvents)
|
||||
}
|
||||
|
||||
links, ok := data["links"].([]spantypes.Link)
|
||||
if !ok {
|
||||
t.Fatalf("expected links to be []spantypes.Link, got %T", data["links"])
|
||||
}
|
||||
wantLinks := []spantypes.Link{
|
||||
{TraceID: "abc", SpanID: "123"},
|
||||
{TraceID: "def", SpanID: "456"},
|
||||
}
|
||||
if !reflect.DeepEqual(links, wantLinks) {
|
||||
t.Fatalf("links parsed incorrectly:\n got: %#v\nwant: %#v", links, wantLinks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"events": []string{},
|
||||
"links": "[]",
|
||||
}
|
||||
|
||||
mergeSpanAttributeColumns(data)
|
||||
|
||||
if events, ok := data["events"].([]spantypes.EventV2); !ok || len(events) != 0 {
|
||||
t.Fatalf("expected empty []spantypes.EventV2, got %#v", data["events"])
|
||||
}
|
||||
if links, ok := data["links"].([]spantypes.Link); !ok || len(links) != 0 {
|
||||
t.Fatalf("expected empty []spantypes.Link, got %#v", data["links"])
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,13 @@ func (q *traceOperatorQuery) executeWithContext(ctx context.Context, query strin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// TODO: This should move to readAsRaw function in consume.go but for now we can keep it here since it's only relevant for traces
|
||||
if raw, ok := payload.(*qbtypes.RawData); ok {
|
||||
for _, rr := range raw.Rows {
|
||||
mergeSpanAttributeColumns(rr.Data)
|
||||
}
|
||||
}
|
||||
|
||||
return &qbtypes.Result{
|
||||
Type: q.kind,
|
||||
Value: payload,
|
||||
|
||||
@@ -4,6 +4,48 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
// Internal Columns.
|
||||
SpanTimestampBucketStartColumn = "ts_bucket_start"
|
||||
SpanResourceFingerPrintColumn = "resource_fingerprint"
|
||||
|
||||
// Intrinsic Columns.
|
||||
SpanTimestampColumn = "timestamp"
|
||||
SpanTraceIDColumn = "trace_id"
|
||||
SpanSpanIDColumn = "span_id"
|
||||
SpanTraceStateColumn = "trace_state"
|
||||
SpanParentSpanIDColumn = "parent_span_id"
|
||||
SpanFlagsColumn = "flags"
|
||||
SpanNameColumn = "name"
|
||||
SpanKindColumn = "kind"
|
||||
SpanKindStringColumn = "kind_string"
|
||||
SpanDurationNanoColumn = "duration_nano"
|
||||
SpanStatusCodeColumn = "status_code"
|
||||
SpanStatusMessageColumn = "status_message"
|
||||
SpanStatusCodeStringColumn = "status_code_string"
|
||||
SpanEventsColumn = "events"
|
||||
SpanLinksColumn = "links"
|
||||
|
||||
// Calculated Columns.
|
||||
SpanResponseStatusCodeColumn = "response_status_code"
|
||||
SpanExternalHTTPURLColumn = "external_http_url"
|
||||
SpanHTTPURLColumn = "http_url"
|
||||
SpanExternalHTTPMethodColumn = "external_http_method"
|
||||
SpanHTTPMethodColumn = "http_method"
|
||||
SpanHTTPHostColumn = "http_host"
|
||||
SpanDBNameColumn = "db_name"
|
||||
SpanDBOperationColumn = "db_operation"
|
||||
SpanHasErrorColumn = "has_error"
|
||||
SpanIsRemoteColumn = "is_remote"
|
||||
|
||||
// Contextual Columns.
|
||||
SpanAttributesStringColumn = "attributes_string"
|
||||
SpanAttributesNumberColumn = "attributes_number"
|
||||
SpanAttributesBoolColumn = "attributes_bool"
|
||||
SpanResourcesStringColumn = "resources_string"
|
||||
)
|
||||
|
||||
var (
|
||||
IntrinsicFields = map[string]telemetrytypes.TelemetryFieldKey{
|
||||
"trace_id": {
|
||||
@@ -336,6 +378,51 @@ var (
|
||||
SpanSearchScopeRoot = "isroot"
|
||||
SpanSearchScopeEntryPoint = "isentrypoint"
|
||||
|
||||
// IntrinsicSpanFields lists the intrinsic span columns, in the order they
|
||||
// should appear when a raw query expands its SelectFields.
|
||||
IntrinsicSpanFields = []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: SpanTimestampColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanTraceIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanTraceStateColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanParentSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanFlagsColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanNameColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanKindColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanKindStringColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanDurationNanoColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanStatusCodeColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanStatusMessageColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanStatusCodeStringColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanEventsColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanLinksColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
}
|
||||
|
||||
// CalculatedSpanFields lists the calculated/derived span columns, in the
|
||||
// order they should appear when a raw query expands its SelectFields.
|
||||
CalculatedSpanFields = []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: SpanResponseStatusCodeColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanExternalHTTPURLColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanHTTPURLColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanExternalHTTPMethodColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanHTTPMethodColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanHTTPHostColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanDBNameColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanDBOperationColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanHasErrorColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanIsRemoteColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
}
|
||||
|
||||
// ContextualSpanColumns lists the typed attribute and resource columns
|
||||
// selected raw (rather than via ColumnExpressionFor) so that consume.go
|
||||
// can merge them into unified "attributes" and "resource" maps.
|
||||
ContextualSpanColumns = []string{
|
||||
SpanAttributesStringColumn,
|
||||
SpanAttributesNumberColumn,
|
||||
SpanAttributesBoolColumn,
|
||||
SpanResourcesStringColumn,
|
||||
}
|
||||
|
||||
DefaultFields = map[string]telemetrytypes.TelemetryFieldKey{
|
||||
"timestamp": {
|
||||
Name: "timestamp",
|
||||
|
||||
@@ -82,6 +82,17 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`==true, `resource_string_deployment$$environment`, NULL)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
// Query like `attribute.attribute_string:string` should resolve to `attributes_string['attribute_string']`.
|
||||
name: "Attribute key whose name collides with contextual map column resolves as a map lookup",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: SpanAttributesStringColumn,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
expectedResult: "attributes_string['attributes_string']",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Non-existent column",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -16,7 +15,6 @@ import (
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -86,40 +84,13 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
start = querybuilder.ToNanoSecs(start)
|
||||
end = querybuilder.ToNanoSecs(end)
|
||||
|
||||
/*
|
||||
Adding a tech debt note here:
|
||||
This piece of code is a hot fix and should be removed once we close issue: engineering-pod/issues/3622
|
||||
*/
|
||||
/*
|
||||
-------------------------------- Start of tech debt ----------------------------
|
||||
*/
|
||||
isSelectFieldsEmpty := false
|
||||
if requestType == qbtypes.RequestTypeRaw {
|
||||
|
||||
selectedFields := query.SelectFields
|
||||
|
||||
if len(selectedFields) == 0 {
|
||||
sortedKeys := maps.Keys(DefaultFields)
|
||||
slices.Sort(sortedKeys)
|
||||
for _, key := range sortedKeys {
|
||||
selectedFields = append(selectedFields, DefaultFields[key])
|
||||
}
|
||||
query.SelectFields = selectedFields
|
||||
}
|
||||
|
||||
selectFieldKeys := []string{}
|
||||
for _, field := range selectedFields {
|
||||
selectFieldKeys = append(selectFieldKeys, field.Name)
|
||||
}
|
||||
|
||||
for _, x := range []string{"timestamp", "span_id", "trace_id"} {
|
||||
if !slices.Contains(selectFieldKeys, x) {
|
||||
query.SelectFields = append(query.SelectFields, DefaultFields[x])
|
||||
}
|
||||
}
|
||||
isSelectFieldsEmpty = len(query.SelectFields) == 0
|
||||
// we are expanding here to ensure that all the conflicts are taken care in adjustKeys
|
||||
// i.e if there is a conflict we strip away context of the key in adjustKeys
|
||||
query = b.expandRawSelectFields(query)
|
||||
}
|
||||
/*
|
||||
-------------------------------- End of tech debt ----------------------------
|
||||
*/
|
||||
|
||||
// We modify SelectFields above (injecting default fields), and those default
|
||||
// fields can carry keys that need evolutions, so fetch keys after that.
|
||||
@@ -139,7 +110,7 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildListQuery(ctx, q, query, start, end, keys, variables)
|
||||
return b.buildListQuery(ctx, q, query, start, end, keys, variables, isSelectFieldsEmpty)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildTimeSeriesQuery(ctx, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
@@ -305,6 +276,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
isSelectFieldsEmpty bool,
|
||||
) (*qbtypes.Statement, error) {
|
||||
|
||||
var (
|
||||
@@ -321,7 +293,6 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
|
||||
// TODO: should we deprecate `SelectFields` and return everything from a span like we do for logs?
|
||||
for _, field := range query.SelectFields {
|
||||
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &field, keys)
|
||||
if err != nil {
|
||||
@@ -330,6 +301,12 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
sb.SelectMore(colExpr)
|
||||
}
|
||||
|
||||
if isSelectFieldsEmpty {
|
||||
for _, col := range ContextualSpanColumns {
|
||||
sb.SelectMore(col)
|
||||
}
|
||||
}
|
||||
|
||||
// From table
|
||||
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
|
||||
|
||||
@@ -851,3 +828,30 @@ func (b *traceQueryStatementBuilder) maybeAttachResourceFilter(
|
||||
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
|
||||
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, true, nil
|
||||
}
|
||||
|
||||
// expandRawSelectFields populates SelectFields for raw (list view) queries.
|
||||
// It must be called before adjustKeys so that normalization runs over the full set.
|
||||
func (b *traceQueryStatementBuilder) expandRawSelectFields(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation] {
|
||||
if len(query.SelectFields) == 0 {
|
||||
selectFields := make([]telemetrytypes.TelemetryFieldKey, 0, len(IntrinsicSpanFields)+len(CalculatedSpanFields))
|
||||
selectFields = append(selectFields, IntrinsicSpanFields...)
|
||||
selectFields = append(selectFields, CalculatedSpanFields...)
|
||||
query.SelectFields = selectFields
|
||||
return query
|
||||
}
|
||||
|
||||
selectFields := []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: SpanTimestampColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanTraceIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
{Name: SpanSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
|
||||
}
|
||||
for _, field := range query.SelectFields {
|
||||
// TODO(tvats): If a user specifies attribute.timestamp in the select fields, this loop will basically ignore it, as we already added a field by default. This can be fixed once we close https://github.com/SigNoz/engineering-pod/issues/3693
|
||||
if field.Name == SpanTimestampColumn || field.Name == SpanTraceIDColumn || field.Name == SpanSpanIDColumn {
|
||||
continue
|
||||
}
|
||||
selectFields = append(selectFields, field)
|
||||
}
|
||||
query.SelectFields = selectFields
|
||||
return query
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT name AS `name`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, duration_nano AS `duration_nano`, `attribute_number_cart$$items_count` AS `cart.items_count`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, duration_nano AS `duration_nano`, `attribute_number_cart$$items_count` AS `cart.items_count` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -491,7 +491,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY attributes_string['user.id'] AS `user.id` desc LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY attributes_string['user.id'] AS `user.id` desc LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -535,7 +535,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, response_status_code AS `responseStatusCode`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, response_status_code AS `responseStatusCode` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -579,7 +579,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, multiIf(toString(`attribute_string_mixed$$materialization$$key`) != '', toString(`attribute_string_mixed$$materialization$$key`), toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)) != '', toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)), NULL) AS `mixed.materialization.key`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, multiIf(toString(`attribute_string_mixed$$materialization$$key`) != '', toString(`attribute_string_mixed$$materialization$$key`), toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)) != '', toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)), NULL) AS `mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -624,7 +624,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, `attribute_string_mixed$$materialization$$key` AS `mixed.materialization.key`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, `attribute_string_mixed$$materialization$$key` AS `mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -746,7 +746,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -788,7 +788,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
}},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY timestamp AS `timestamp` asc LIMIT ?",
|
||||
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY timestamp AS `timestamp` asc LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
|
||||
@@ -424,6 +424,20 @@ func (b *traceOperatorCTEBuilder) buildNotCTE(leftCTE, rightCTE string) (string,
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFromCTE string, requestType qbtypes.RequestType) (*qbtypes.Statement, error) {
|
||||
// Mirror statement_builder.go::Build: for raw queries, empty selectFields
|
||||
// expands to the full intrinsic + calculated set, and the list query also
|
||||
// pulls in the contextual columns so the consume layer can merge them
|
||||
// into unified attributes/resource (and parse events/links).
|
||||
isSelectFieldsEmpty := false
|
||||
if requestType == qbtypes.RequestTypeRaw {
|
||||
isSelectFieldsEmpty = len(b.operator.SelectFields) == 0
|
||||
if isSelectFieldsEmpty {
|
||||
b.operator.SelectFields = make([]telemetrytypes.TelemetryFieldKey, 0, len(IntrinsicSpanFields)+len(CalculatedSpanFields))
|
||||
b.operator.SelectFields = append(b.operator.SelectFields, IntrinsicSpanFields...)
|
||||
b.operator.SelectFields = append(b.operator.SelectFields, CalculatedSpanFields...)
|
||||
}
|
||||
}
|
||||
|
||||
keySelectors := b.getKeySelectors()
|
||||
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
|
||||
if err != nil {
|
||||
@@ -433,7 +447,7 @@ func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFro
|
||||
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildListQuery(ctx, selectFromCTE, keys)
|
||||
return b.buildListQuery(ctx, selectFromCTE, keys, isSelectFieldsEmpty)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildTimeSeriesQuery(ctx, selectFromCTE, keys)
|
||||
case qbtypes.RequestTypeTrace:
|
||||
@@ -445,10 +459,11 @@ func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFro
|
||||
}
|
||||
}
|
||||
|
||||
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey, isSelectFieldsEmpty bool) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
// Select core fields
|
||||
// Select core fields. These are always present so the trace operator
|
||||
// response shape is stable regardless of user-supplied selectFields.
|
||||
sb.Select(
|
||||
"timestamp",
|
||||
"trace_id",
|
||||
@@ -482,6 +497,12 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
selectedFields[field.Name] = true
|
||||
}
|
||||
|
||||
if isSelectFieldsEmpty {
|
||||
for _, col := range ContextualSpanColumns {
|
||||
sb.SelectMore(col)
|
||||
}
|
||||
}
|
||||
|
||||
sb.From(selectFromCTE)
|
||||
|
||||
// Add order by support using ColumnExpressionFor
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_INDIR_DESC_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_INDIR_DESC_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 5},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -160,7 +160,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_AND_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_AND_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 15},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -197,7 +197,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_OR_B AS (SELECT * FROM A UNION DISTINCT SELECT * FROM B) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_OR_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_OR_B AS (SELECT * FROM A UNION DISTINCT SELECT * FROM B) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_OR_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 20},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -234,7 +234,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_not_B AS (SELECT l.* FROM A AS l WHERE l.trace_id GLOBAL NOT IN (SELECT DISTINCT trace_id FROM B)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_not_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_not_B AS (SELECT l.* FROM A AS l WHERE l.trace_id GLOBAL NOT IN (SELECT DISTINCT trace_id FROM B)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_not_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -399,7 +399,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_D AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), D AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_D) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), C_DIR_DESC_D AS (SELECT p.* FROM C AS p INNER JOIN D AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), A_DIR_DESC_B_AND_C_DIR_DESC_D AS (SELECT l.* FROM A_DIR_DESC_B AS l INNER JOIN C_DIR_DESC_D AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_DIR_DESC_B_AND_C_DIR_DESC_D ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_D AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), D AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_D) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), C_DIR_DESC_D AS (SELECT p.* FROM C AS p INNER JOIN D AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), A_DIR_DESC_B_AND_C_DIR_DESC_D AS (SELECT l.* FROM A_DIR_DESC_B AS l INNER JOIN C_DIR_DESC_D AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_DIR_DESC_B_AND_C_DIR_DESC_D ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "auth", "%service.name%", "%service.name\":\"auth%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 5},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -433,7 +433,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __return_from_B AS (SELECT * FROM B WHERE trace_id IN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __return_from_B AS (SELECT * FROM B WHERE trace_id IN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM __return_from_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -475,7 +475,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B_AND_C AS (SELECT l.* FROM A_INDIR_DESC_B AS l INNER JOIN C AS r ON l.trace_id = r.trace_id), __return_from_C AS (SELECT * FROM C WHERE trace_id IN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B_AND_C)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_C ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B_AND_C AS (SELECT l.* FROM A_INDIR_DESC_B AS l INNER JOIN C AS r ON l.trace_id = r.trace_id), __return_from_C AS (SELECT * FROM C WHERE trace_id IN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B_AND_C)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM __return_from_C ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "auth", "%service.name%", "%service.name\":\"auth%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
|
||||
@@ -236,6 +236,7 @@ type RawStream struct {
|
||||
Error chan error
|
||||
}
|
||||
|
||||
|
||||
func roundToNonZeroDecimals(val float64, n int) float64 {
|
||||
if val == 0 || math.IsNaN(val) || math.IsInf(val, 0) {
|
||||
return val
|
||||
|
||||
54
pkg/types/spantypes/event.go
Normal file
54
pkg/types/spantypes/event.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package spantypes
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// The Event struct has the data exactly store in the db, while EventV2 is more of what we want to send to client.
|
||||
type EventV2 struct {
|
||||
Name string `json:"name"`
|
||||
TimeUnixNano uint64 `json:"timeUnixNano"`
|
||||
Attributes map[string]any `json:"attributes,omitempty"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// Link is the response shape for a span link.
|
||||
// The refType field is intentionally not decoded; it's a Jaeger-era
|
||||
// concept that OTel doesn't model, so we drop it on the way out.
|
||||
type Link struct {
|
||||
TraceID string `json:"traceId,omitempty"`
|
||||
SpanID string `json:"spanId,omitempty"`
|
||||
}
|
||||
|
||||
// ParseEvents column (Array(String) of JSON-encoded events) into a slice of Event values.
|
||||
// Malformed entries are skipped.
|
||||
func ParseEvents(raw any) []EventV2 {
|
||||
strs, ok := raw.([]string)
|
||||
if !ok {
|
||||
return []EventV2{}
|
||||
}
|
||||
events := make([]EventV2, 0, len(strs))
|
||||
for _, s := range strs {
|
||||
var e Event
|
||||
if err := json.Unmarshal([]byte(s), &e); err != nil {
|
||||
continue
|
||||
}
|
||||
events = append(events, EventV2{
|
||||
Name: e.Name,
|
||||
TimeUnixNano: e.TimeUnixNano,
|
||||
Attributes: e.AttributeMap,
|
||||
IsError: false,
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func ParseLinks(raw any) []Link {
|
||||
s, ok := raw.(string)
|
||||
if !ok || s == "" {
|
||||
return []Link{}
|
||||
}
|
||||
var links []Link
|
||||
if err := json.Unmarshal([]byte(s), &links); err != nil {
|
||||
return []Link{}
|
||||
}
|
||||
return links
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func (p *PostableWaterfall) Validate() error {
|
||||
}
|
||||
|
||||
// Event represents a span event.
|
||||
// todo():depricate this and use EventV2 everywhere instead while sending to client.
|
||||
type Event struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
TimeUnixNano uint64 `json:"timeUnixNano,omitempty"`
|
||||
|
||||
@@ -6,13 +6,6 @@ import {
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
|
||||
import {
|
||||
detectPersona,
|
||||
detectSettingsEnv,
|
||||
type Persona,
|
||||
type SettingsEnv,
|
||||
} from '../helpers/persona';
|
||||
|
||||
export type User = { email: string; password: string };
|
||||
|
||||
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
|
||||
@@ -27,11 +20,6 @@ export const ADMIN: User = {
|
||||
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
const storageByUser = new Map<string, Promise<StorageState>>();
|
||||
|
||||
// Per-worker persona/env caches by user email. Detection is constant for a
|
||||
// given backend + user, so it runs once per worker.
|
||||
const personaByUser = new Map<string, Promise<Persona>>();
|
||||
const envByUser = new Map<string, Promise<SettingsEnv>>();
|
||||
|
||||
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
|
||||
const cached = storageByUser.get(user.email);
|
||||
if (cached) {
|
||||
@@ -84,10 +72,6 @@ export const test = base.extend<{
|
||||
* storageState is held in memory and reused for all later requests.
|
||||
*/
|
||||
authedPage: Page;
|
||||
|
||||
persona: Persona;
|
||||
|
||||
env: SettingsEnv;
|
||||
}>({
|
||||
user: [ADMIN, { option: true }],
|
||||
|
||||
@@ -109,24 +93,6 @@ export const test = base.extend<{
|
||||
await use(page);
|
||||
await ctx.close();
|
||||
},
|
||||
|
||||
persona: async ({ authedPage, user }, use) => {
|
||||
let task = personaByUser.get(user.email);
|
||||
if (!task) {
|
||||
task = detectPersona(authedPage);
|
||||
personaByUser.set(user.email, task);
|
||||
}
|
||||
await use(await task);
|
||||
},
|
||||
|
||||
env: async ({ authedPage, user }, use) => {
|
||||
let task = envByUser.get(user.email);
|
||||
if (!task) {
|
||||
task = detectSettingsEnv(authedPage);
|
||||
envByUser.set(user.email, task);
|
||||
}
|
||||
await use(await task);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from './dashboards';
|
||||
|
||||
export type Tier =
|
||||
| 'cloud'
|
||||
| 'enterprise'
|
||||
| 'community'
|
||||
| 'community-enterprise';
|
||||
export type Role = 'ADMIN' | 'EDITOR' | 'VIEWER' | 'ANONYMOUS';
|
||||
|
||||
export interface Persona {
|
||||
tier: Tier;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export interface SettingsEnv {
|
||||
isGatewayEnabled: boolean;
|
||||
}
|
||||
|
||||
interface AuthzCheckItem {
|
||||
authorized?: boolean;
|
||||
object?: { selector?: string };
|
||||
}
|
||||
|
||||
interface FeatureFlag {
|
||||
name?: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const LICENSE_URL = '/api/v3/licenses/active';
|
||||
const AUTHZ_CHECK_URL = '/api/v1/authz/check';
|
||||
const FEATURES_URL = '/api/v1/features';
|
||||
|
||||
// Mirrors IsAdmin/Editor/Viewer in frontend/src/hooks/useAuthZ/legacy.ts:
|
||||
// relation 'assignee' on resource kind/type 'role', selector = preset role id.
|
||||
const ROLE_PROBES: { role: Exclude<Role, 'ANONYMOUS'>; selector: string }[] = [
|
||||
{ role: 'ADMIN', selector: 'signoz-admin' },
|
||||
{ role: 'EDITOR', selector: 'signoz-editor' },
|
||||
{ role: 'VIEWER', selector: 'signoz-viewer' },
|
||||
];
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function parseOverride(): Persona | null {
|
||||
const raw = process.env.SIGNOZ_E2E_PERSONA;
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parts = raw.toLowerCase().split('-');
|
||||
const roleRaw = parts.pop();
|
||||
const tier = parts.join('-') as Tier;
|
||||
const role = roleRaw?.toUpperCase() as Role;
|
||||
return { tier, role };
|
||||
}
|
||||
|
||||
async function detectTier(page: Page, token: string): Promise<Tier> {
|
||||
const res = await page.request.get(LICENSE_URL, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
if (res.status() === 404) {
|
||||
return 'community-enterprise';
|
||||
}
|
||||
if (res.status() === 501) {
|
||||
return 'community';
|
||||
}
|
||||
const body = await res.json();
|
||||
const platform = body?.data?.platform;
|
||||
if (platform === 'CLOUD') {
|
||||
return 'cloud';
|
||||
}
|
||||
return 'enterprise';
|
||||
}
|
||||
|
||||
async function detectRole(page: Page, token: string): Promise<Role> {
|
||||
const payload = ROLE_PROBES.map((p) => ({
|
||||
relation: 'assignee',
|
||||
object: {
|
||||
resource: { kind: 'role', type: 'role' },
|
||||
selector: p.selector,
|
||||
},
|
||||
}));
|
||||
const res = await page.request.post(AUTHZ_CHECK_URL, {
|
||||
headers: authHeaders(token),
|
||||
data: payload,
|
||||
});
|
||||
const body = await res.json();
|
||||
const items: AuthzCheckItem[] = body?.data ?? [];
|
||||
const granted = new Set(
|
||||
items.filter((i) => i?.authorized).map((i) => i?.object?.selector),
|
||||
);
|
||||
for (const p of ROLE_PROBES) {
|
||||
if (granted.has(p.selector)) {
|
||||
return p.role;
|
||||
}
|
||||
}
|
||||
return 'ANONYMOUS';
|
||||
}
|
||||
|
||||
export async function detectPersona(page: Page): Promise<Persona> {
|
||||
const override = parseOverride();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
const token = await authToken(page);
|
||||
const [tier, role] = await Promise.all([
|
||||
detectTier(page, token),
|
||||
detectRole(page, token),
|
||||
]);
|
||||
return { tier, role };
|
||||
}
|
||||
|
||||
export async function detectSettingsEnv(page: Page): Promise<SettingsEnv> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get(FEATURES_URL, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
const body = await res.json();
|
||||
const flags: FeatureFlag[] = body?.data ?? [];
|
||||
const gateway = flags.find((f) => f?.name === 'gateway');
|
||||
return { isGatewayEnabled: !!gateway?.active };
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect } from '../fixtures/auth';
|
||||
|
||||
// Verbatim from frontend/src/constants/routes.ts
|
||||
export const SETTINGS_ROUTES = {
|
||||
WORKSPACE: '/settings',
|
||||
MY_SETTINGS: '/settings/my-settings',
|
||||
ORG_SETTINGS: '/settings/org-settings',
|
||||
ALL_CHANNELS: '/settings/channels',
|
||||
INGESTION: '/settings/ingestion-settings',
|
||||
BILLING: '/settings/billing',
|
||||
ROLES: '/settings/roles',
|
||||
MEMBERS: '/settings/members',
|
||||
SERVICE_ACCOUNTS: '/settings/service-accounts',
|
||||
SHORTCUTS: '/settings/shortcuts',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
INTEGRATIONS: '/integrations',
|
||||
} as const;
|
||||
|
||||
export type SettingsRoute =
|
||||
(typeof SETTINGS_ROUTES)[keyof typeof SETTINGS_ROUTES];
|
||||
|
||||
// Sidenav item data-testid == itemKey in menuItems.tsx settingsNavSections.
|
||||
export const NAV_TESTID: Record<string, string> = {
|
||||
[SETTINGS_ROUTES.WORKSPACE]: 'workspace',
|
||||
[SETTINGS_ROUTES.MY_SETTINGS]: 'account',
|
||||
[SETTINGS_ROUTES.ALL_CHANNELS]: 'notification-channels',
|
||||
[SETTINGS_ROUTES.BILLING]: 'billing',
|
||||
[SETTINGS_ROUTES.INTEGRATIONS]: 'integrations',
|
||||
[SETTINGS_ROUTES.MCP_SERVER]: 'mcp-server',
|
||||
[SETTINGS_ROUTES.ROLES]: 'roles',
|
||||
[SETTINGS_ROUTES.MEMBERS]: 'members',
|
||||
[SETTINGS_ROUTES.SERVICE_ACCOUNTS]: 'service-accounts',
|
||||
[SETTINGS_ROUTES.INGESTION]: 'ingestion',
|
||||
[SETTINGS_ROUTES.ORG_SETTINGS]: 'sso',
|
||||
[SETTINGS_ROUTES.SHORTCUTS]: 'keyboard-shortcuts',
|
||||
};
|
||||
|
||||
export async function gotoSettings(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.WORKSPACE);
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openSettingsTab(
|
||||
page: Page,
|
||||
route: SettingsRoute,
|
||||
): Promise<void> {
|
||||
const testid = NAV_TESTID[route];
|
||||
await page.getByTestId('settings-page-sidenav').getByTestId(testid).click();
|
||||
await expect(page).toHaveURL(new RegExp(route.replace(/\//g, '\\/')));
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { Persona, SettingsEnv, Tier } from './persona';
|
||||
import { SETTINGS_ROUTES, NAV_TESTID } from './settings';
|
||||
|
||||
// Mirrors the isEnabled effect in frontend/src/pages/Settings/Settings.tsx.
|
||||
// Returns the set of sidenav item testids (itemKeys) that should be visible.
|
||||
export function visibleNavItems(
|
||||
persona: Persona,
|
||||
_env: SettingsEnv,
|
||||
): Set<string> {
|
||||
const { tier, role } = persona;
|
||||
const isAdmin = role === 'ADMIN';
|
||||
const isEditor = role === 'EDITOR';
|
||||
const isViewer = role === 'VIEWER';
|
||||
|
||||
// Defaults that start enabled in menuItems.tsx settingsNavSections.
|
||||
const s = new Set<string>([
|
||||
'workspace',
|
||||
'account',
|
||||
'notification-channels',
|
||||
'keyboard-shortcuts',
|
||||
]);
|
||||
|
||||
const enableForAllUsers = (): void => {
|
||||
s.add('roles');
|
||||
s.add('service-accounts');
|
||||
};
|
||||
|
||||
if (tier === 'cloud') {
|
||||
enableForAllUsers();
|
||||
if (isAdmin) {
|
||||
[
|
||||
'billing',
|
||||
'integrations',
|
||||
'ingestion',
|
||||
'sso',
|
||||
'members',
|
||||
'mcp-server',
|
||||
].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isEditor) {
|
||||
['ingestion', 'integrations', 'mcp-server'].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isViewer) {
|
||||
s.add('mcp-server');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
if (tier === 'enterprise') {
|
||||
enableForAllUsers();
|
||||
if (isAdmin) {
|
||||
[
|
||||
'billing',
|
||||
'integrations',
|
||||
'sso',
|
||||
'members',
|
||||
'ingestion',
|
||||
'mcp-server',
|
||||
].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isEditor) {
|
||||
['integrations', 'ingestion', 'mcp-server'].forEach((k) => s.add(k));
|
||||
}
|
||||
if (isViewer) {
|
||||
s.add('mcp-server');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// community / community-enterprise (!cloud && !enterprise)
|
||||
enableForAllUsers();
|
||||
if (isAdmin) {
|
||||
s.add('sso');
|
||||
s.add('members');
|
||||
}
|
||||
// billing & integrations explicitly disabled for non-cloud users.
|
||||
s.delete('billing');
|
||||
s.delete('integrations');
|
||||
return s;
|
||||
}
|
||||
|
||||
// Mirrors getRoutes() in frontend/src/pages/Settings/utils.ts.
|
||||
// Returns the set of /settings route paths that are mounted (navigable).
|
||||
export function registeredRoutes(
|
||||
persona: Persona,
|
||||
env: SettingsEnv,
|
||||
): Set<string> {
|
||||
const { tier, role } = persona;
|
||||
const isAdmin = role === 'ADMIN';
|
||||
const isEditor = role === 'EDITOR';
|
||||
const isCloud = tier === 'cloud';
|
||||
const isEnterprise = tier === 'enterprise';
|
||||
|
||||
const r = new Set<string>([
|
||||
SETTINGS_ROUTES.WORKSPACE, // generalSettings — always
|
||||
SETTINGS_ROUTES.ALL_CHANNELS, // always
|
||||
SETTINGS_ROUTES.SERVICE_ACCOUNTS, // always
|
||||
SETTINGS_ROUTES.ROLES, // always
|
||||
SETTINGS_ROUTES.MY_SETTINGS, // always
|
||||
SETTINGS_ROUTES.SHORTCUTS, // always
|
||||
SETTINGS_ROUTES.MCP_SERVER, // always
|
||||
]);
|
||||
|
||||
// organizationSettings — gated by current_org_settings; mirrored as admin-only.
|
||||
if (isAdmin) {
|
||||
r.add(SETTINGS_ROUTES.ORG_SETTINGS);
|
||||
}
|
||||
// multiIngestionSettings if gateway && (admin||editor); cloud read-only if cloud && !gateway.
|
||||
if (
|
||||
(env.isGatewayEnabled && (isAdmin || isEditor)) ||
|
||||
(isCloud && !env.isGatewayEnabled)
|
||||
) {
|
||||
r.add(SETTINGS_ROUTES.INGESTION);
|
||||
}
|
||||
// membersSettings if admin.
|
||||
if (isAdmin) {
|
||||
r.add(SETTINGS_ROUTES.MEMBERS);
|
||||
}
|
||||
// billing if (cloud||enterprise) && admin.
|
||||
if ((isCloud || isEnterprise) && isAdmin) {
|
||||
r.add(SETTINGS_ROUTES.BILLING);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// Skip reason when a route's nav item is hidden for the persona; null when
|
||||
// visible. Centralised so every skip reads identically and is greppable.
|
||||
export function personaSkipReason(
|
||||
persona: Persona,
|
||||
env: SettingsEnv,
|
||||
route: string,
|
||||
): string | null {
|
||||
const visible = visibleNavItems(persona, env);
|
||||
const testid = NAV_TESTID[route];
|
||||
if (testid && visible.has(testid)) {
|
||||
return null;
|
||||
}
|
||||
return `PERSONA_SKIP: ${route} hidden for ${persona.tier}×${persona.role}`;
|
||||
}
|
||||
|
||||
// Second skip axis: a route is visible but renders tier-specific CONTENT (e.g.
|
||||
// /settings shows a cloud support card vs self-hosted retention controls).
|
||||
// Gates a test to the tiers whose content it asserts. Shares the PERSONA_SKIP:
|
||||
// prefix.
|
||||
export function tierSkipReason(
|
||||
persona: Persona,
|
||||
allowedTiers: Tier[],
|
||||
label: string,
|
||||
): string | null {
|
||||
if (allowedTiers.includes(persona.tier)) {
|
||||
return null;
|
||||
}
|
||||
return `PERSONA_SKIP: ${label} not applicable for tier ${persona.tier} (needs ${allowedTiers.join(
|
||||
'|',
|
||||
)})`;
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
personaSkipReason,
|
||||
tierSkipReason,
|
||||
} from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Workspace (/settings) has two views: cloud (retention inputs disabled, no Save,
|
||||
// GeneralSettingsCloud support card) and self-hosted (interactive inputs, per-row Save).
|
||||
// Retention inputs in compact mode have no data-testid — role/text/CSS fallback.
|
||||
|
||||
async function gotoWorkspace(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.WORKSPACE);
|
||||
// Retention data is fetched server-side; allow margin for the API response.
|
||||
await expect(page.locator('.retention-controls-container')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
function retentionRow(page: Page, signal: string) {
|
||||
return page.locator('.retention-row').filter({ hasText: signal });
|
||||
}
|
||||
|
||||
function retentionInput(page: Page, signal: string) {
|
||||
return retentionRow(page, signal).locator('input[type="number"]').first();
|
||||
}
|
||||
|
||||
function saveButton(page: Page, signal: string) {
|
||||
return retentionRow(page, signal).getByRole('button', { name: /^save$/i });
|
||||
}
|
||||
|
||||
// Tier sets for the two Workspace content variants.
|
||||
const CLOUD_TIERS = ['cloud'] as const;
|
||||
const SELF_HOSTED_TIERS = [
|
||||
'enterprise',
|
||||
'community',
|
||||
'community-enterprise',
|
||||
] as const;
|
||||
|
||||
test.describe('Settings — Workspace / General page', () => {
|
||||
test('TC-01 page renders retention controls and license-key row', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoWorkspace(page);
|
||||
|
||||
// Scoped to avoid strict-mode conflict with the sidenav item.
|
||||
await expect(page.locator('.general-settings-title')).toContainText(
|
||||
'Workspace',
|
||||
);
|
||||
await expect(page.locator('.general-settings-subtitle')).toContainText(
|
||||
'Manage your workspace settings.',
|
||||
);
|
||||
|
||||
await expect(page.getByText('Retention Controls')).toBeVisible();
|
||||
|
||||
await expect(retentionRow(page, 'Metrics')).toBeVisible();
|
||||
await expect(retentionRow(page, 'Traces')).toBeVisible();
|
||||
await expect(retentionRow(page, 'Logs')).toBeVisible();
|
||||
|
||||
await expect(retentionInput(page, 'Metrics')).toBeVisible();
|
||||
await expect(retentionInput(page, 'Traces')).toBeVisible();
|
||||
await expect(retentionInput(page, 'Logs')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('license-key-row-copy-btn')).toBeVisible();
|
||||
});
|
||||
|
||||
// RISK MODE: read-only — only asserts disabled state, nothing is mutated.
|
||||
test('TC-02 cloud view — retention inputs are disabled and support card is visible', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(persona, [...CLOUD_TIERS], 'cloud retention view'),
|
||||
tierSkipReason(persona, [...CLOUD_TIERS], 'cloud retention view') ??
|
||||
undefined,
|
||||
);
|
||||
|
||||
await gotoWorkspace(page);
|
||||
|
||||
await expect(retentionInput(page, 'Metrics')).toBeDisabled();
|
||||
await expect(retentionInput(page, 'Traces')).toBeDisabled();
|
||||
await expect(retentionInput(page, 'Logs')).toBeDisabled();
|
||||
|
||||
await expect(saveButton(page, 'Metrics')).toHaveCount(0);
|
||||
await expect(saveButton(page, 'Traces')).toHaveCount(0);
|
||||
await expect(saveButton(page, 'Logs')).toHaveCount(0);
|
||||
|
||||
await expect(
|
||||
page.getByText(/please.*email us.*or connect.*via chat support/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// RISK MODE: never clicks Save — only asserts enable-on-change / disable-on-clear; no PUT/POST.
|
||||
test('TC-03 self-hosted view — retention input enables/disables Save — no save triggered', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(
|
||||
persona,
|
||||
[...SELF_HOSTED_TIERS],
|
||||
'self-hosted retention controls',
|
||||
),
|
||||
tierSkipReason(
|
||||
persona,
|
||||
[...SELF_HOSTED_TIERS],
|
||||
'self-hosted retention controls',
|
||||
) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoWorkspace(page);
|
||||
|
||||
const metricsInput = retentionInput(page, 'Metrics');
|
||||
const metricsSaveBtn = saveButton(page, 'Metrics');
|
||||
|
||||
const originalValue = await metricsInput.inputValue();
|
||||
|
||||
try {
|
||||
await metricsInput.fill('9999');
|
||||
await expect(metricsSaveBtn).toBeEnabled();
|
||||
|
||||
await metricsInput.fill('');
|
||||
await expect(metricsSaveBtn).toBeDisabled();
|
||||
await expect(
|
||||
page.getByText(/retention period for .+ is not set yet/i),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
// Restore so unsaved UI state does not leak to other workers sharing this stack.
|
||||
await metricsInput.fill(originalValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
personaSkipReason,
|
||||
tierSkipReason,
|
||||
} from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Ingestion page, two variants gated by env.isGatewayEnabled / tier:
|
||||
// MultiIngestionSettings (gateway ON) vs read-only IngestionSettings (cloud, gateway OFF).
|
||||
// RISK MODE — READ-ONLY: never create/edit/delete keys or rate limits; create
|
||||
// button and copy affordances asserted for presence only, never clicked.
|
||||
// Each TC guards its variant via test.skip so bodies stay branch-free
|
||||
// (playwright/no-conditional-in-test).
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
async function gotoIngestion(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.INGESTION);
|
||||
// Ingestion keys/settings are fetched server-side; allow margin for the API response.
|
||||
await expect(
|
||||
page
|
||||
.locator('.ingestion-key-container, .ingestion-settings-container')
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
test.describe('Settings — Ingestion page', () => {
|
||||
test('TC-01 MultiIngestionSettings — page chrome, search, table, and create affordance render', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(
|
||||
persona,
|
||||
['cloud', 'enterprise'],
|
||||
'MultiIngestionSettings (gateway)',
|
||||
) || !env.isGatewayEnabled,
|
||||
!env.isGatewayEnabled
|
||||
? 'PERSONA_SKIP: gateway feature flag is OFF — MultiIngestionSettings does not render'
|
||||
: (tierSkipReason(
|
||||
persona,
|
||||
['cloud', 'enterprise'],
|
||||
'MultiIngestionSettings (gateway)',
|
||||
) ?? undefined),
|
||||
);
|
||||
|
||||
await gotoIngestion(page);
|
||||
|
||||
const container = page.locator('.ingestion-key-container');
|
||||
await expect(container).toBeVisible();
|
||||
|
||||
// Exact name match avoids the subtitle partial match.
|
||||
await expect(
|
||||
container.getByRole('heading', { name: 'Ingestion Keys' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
container.getByText(/Create and manage ingestion keys/i),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.getByPlaceholder('Search for ingestion key...'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.getByRole('button', { name: /new ingestion key/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(container.locator('.ingestion-keys-table')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.locator('.ingestion-key-url-label', { hasText: 'Ingestion URL' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 IngestionSettings (read-only) — table rows for URL, key, and region render', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION) ?? undefined,
|
||||
);
|
||||
// This view only renders on cloud when gateway is disabled
|
||||
test.skip(
|
||||
env.isGatewayEnabled,
|
||||
'PERSONA_SKIP: gateway is ON — MultiIngestionSettings renders instead of read-only table',
|
||||
);
|
||||
test.skip(
|
||||
!!tierSkipReason(persona, ['cloud'], 'IngestionSettings read-only table'),
|
||||
tierSkipReason(persona, ['cloud'], 'IngestionSettings read-only table') ??
|
||||
undefined,
|
||||
);
|
||||
|
||||
await gotoIngestion(page);
|
||||
|
||||
const container = page.locator('.ingestion-settings-container');
|
||||
await expect(container).toBeVisible();
|
||||
|
||||
await expect(
|
||||
container.getByText(/start sending your telemetry data/i),
|
||||
).toBeVisible();
|
||||
|
||||
const table = container.locator('.ant-table');
|
||||
await expect(table).toBeVisible();
|
||||
await expect(table.getByText('Ingestion URL')).toBeVisible();
|
||||
await expect(table.getByText('Ingestion Key')).toBeVisible();
|
||||
await expect(table.getByText('Ingestion Region')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// MCP Server settings, two variants gated by mcp_url in /api/v1/global/config:
|
||||
// full page (mcp_url present, cloud) vs NotCloudFallback (absent, community/self-hosted).
|
||||
// RISK MODE — READ-ONLY: never create a service account; copy/create/install
|
||||
// buttons asserted for presence only, never clicked.
|
||||
// mcpEndpointPresent is probed in beforeAll (real backend state) so TC-01/TC-02
|
||||
// skip via test.skip rather than branching in bodies (playwright/no-conditional-in-test).
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let mcpEndpointPresent = false;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get('/api/v1/global/config', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok()) {
|
||||
const body = await res.json();
|
||||
const mcpUrl: unknown = body?.data?.mcp_url;
|
||||
mcpEndpointPresent = typeof mcpUrl === 'string' && mcpUrl.length > 0;
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoMcpServer(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.MCP_SERVER);
|
||||
// Spinner gone => either full page or fallback has rendered.
|
||||
await expect(page.locator('.ant-spin-spinning')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe('Settings — MCP Server page', () => {
|
||||
// Locators below use CSS classes / role-text; only mcp-settings has a data-testid.
|
||||
test('TC-01 full page renders: header, client tabs, auth card, use-cases card', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER) ?? undefined,
|
||||
);
|
||||
// Full-page content requires mcp_url to be configured. If not present the
|
||||
// NotCloudFallback renders instead — TC-02 covers that path.
|
||||
test.skip(
|
||||
!mcpEndpointPresent,
|
||||
'PERSONA_SKIP: mcp_url not configured on this stack — NotCloudFallback renders; see TC-02',
|
||||
);
|
||||
|
||||
await gotoMcpServer(page);
|
||||
|
||||
await expect(page.getByTestId('mcp-settings')).toBeVisible();
|
||||
|
||||
await expect(page.locator('.mcp-settings__header-title')).toContainText(
|
||||
'SigNoz MCP Server',
|
||||
);
|
||||
await expect(page.locator('.mcp-settings__header-subtitle')).toContainText(
|
||||
'Model Context Protocol',
|
||||
);
|
||||
|
||||
await expect(page.locator('.mcp-settings__card')).toBeVisible();
|
||||
await expect(page.locator('.mcp-settings__card-title')).toContainText(
|
||||
'Configure your client',
|
||||
);
|
||||
|
||||
const tabsRoot = page.locator('.mcp-client-tabs-root');
|
||||
await expect(tabsRoot).toBeVisible();
|
||||
await expect(tabsRoot.getByRole('tab', { name: /cursor/i })).toBeVisible();
|
||||
await expect(
|
||||
tabsRoot.getByRole('tab', { name: /claude code/i }),
|
||||
).toBeVisible();
|
||||
await expect(tabsRoot.getByRole('tab', { name: /vs code/i })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator('.mcp-client-tabs__snippet-pre').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /copy cursor config/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const authCard = page.locator('.mcp-auth-card');
|
||||
await expect(authCard).toBeVisible();
|
||||
await expect(authCard.locator('.mcp-auth-card__title')).toContainText(
|
||||
'Authenticate from your client',
|
||||
);
|
||||
|
||||
await expect(
|
||||
authCard.locator('.mcp-auth-card__field-label').first(),
|
||||
).toContainText('SigNoz Instance URL');
|
||||
await expect(
|
||||
authCard.getByRole('button', { name: /copy signoz instance url/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
authCard.locator('.mcp-auth-card__field-label').nth(1),
|
||||
).toContainText('API Key');
|
||||
await expect(
|
||||
authCard.getByRole('button', { name: /create service account/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const useCasesCard = page.locator('.mcp-use-cases-card');
|
||||
await expect(useCasesCard).toBeVisible();
|
||||
await expect(
|
||||
useCasesCard.locator('.mcp-use-cases-card__title'),
|
||||
).toContainText('What you can do with it');
|
||||
await expect(useCasesCard.locator('.mcp-use-cases-card__list')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
useCasesCard.getByRole('button', { name: /see more use cases/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// Skipped when the beforeAll probe found mcp_url — full page renders instead.
|
||||
test('TC-02 NotCloudFallback renders when MCP endpoint is not configured', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
mcpEndpointPresent,
|
||||
'PERSONA_SKIP: mcp_url is configured on this stack — NotCloudFallback does not render',
|
||||
);
|
||||
|
||||
await gotoMcpServer(page);
|
||||
|
||||
await expect(page.locator('.not-cloud-fallback')).toBeVisible();
|
||||
await expect(page.locator('.not-cloud-fallback__title')).toContainText(
|
||||
'MCP Server is available on SigNoz',
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /view mcp server docs/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('mcp-settings')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,205 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// RISK MODE: read-only plus one non-submitting invite-modal check — no member is
|
||||
// created/edited/deleted/role-changed. The fresh bootstrap stack has exactly one
|
||||
// member (seeded admin, active), so filter/search coverage is limited to that row.
|
||||
// No data-testid exists in MembersSettings/Table/InviteModal — role/placeholder/text/CSS fallback.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const ADMIN_EMAIL = process.env.SIGNOZ_E2E_USERNAME ?? 'admin@integration.test';
|
||||
const SEARCH_PLACEHOLDER = 'Search by name or email...';
|
||||
|
||||
async function gotoMembers(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.MEMBERS);
|
||||
// Members list is fetched server-side; allow margin for the API response.
|
||||
await expect(page.locator('.members-table-wrapper')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Settings — Members page', () => {
|
||||
test('TC-01 list renders with columns and the bootstrap admin user row', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Members', level: 1 }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Overview of people added to this workspace.'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.locator('.members-filter-trigger')).toBeVisible();
|
||||
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /invite member/i }),
|
||||
).toBeVisible();
|
||||
|
||||
const table = page.locator('.members-table');
|
||||
await expect(
|
||||
table.getByRole('columnheader', { name: 'Name / Email' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
table.getByRole('columnheader', { name: 'Status' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
table.getByRole('columnheader', { name: 'Joined On' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
|
||||
const adminRow = page
|
||||
.locator('tr')
|
||||
.filter({ has: page.locator('.member-email', { hasText: ADMIN_EMAIL }) });
|
||||
await expect(adminRow.getByText('ACTIVE')).toBeVisible();
|
||||
});
|
||||
|
||||
// On the single-member stack, Pending/Deleted both yield the empty state.
|
||||
test('TC-02 filter dropdown — cycles All / Pending / Deleted and updates the list', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.members-filter-trigger').click();
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await menu.getByText(/pending invites/i).click();
|
||||
|
||||
await expect(page.locator('.members-empty-state')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.locator('.members-filter-trigger').click();
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await page
|
||||
.getByRole('menu')
|
||||
.getByText(/^deleted/i)
|
||||
.click();
|
||||
|
||||
await expect(page.locator('.members-empty-state')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.locator('.members-filter-trigger').click();
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
await page
|
||||
.getByRole('menu')
|
||||
.getByText(/all members/i)
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.members-empty-state')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 search filters by email match and shows empty state on no match', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
const searchInput = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
|
||||
await searchInput.fill(ADMIN_EMAIL);
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.members-empty-state')).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('xyznonexistentuser999@nowhere.invalid');
|
||||
await expect(page.locator('.members-empty-state')).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('.members-empty-state__text')
|
||||
.getByText('xyznonexistentuser999@nowhere.invalid'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('');
|
||||
await expect(
|
||||
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('.members-empty-state')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// RISK MODE: submit is never clicked; no invite is sent.
|
||||
test('TC-04 invite modal — renders correctly, submit disabled on untouched rows, Cancel dismisses', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMembers(page);
|
||||
|
||||
await page.getByRole('button', { name: /invite member/i }).click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await expect(
|
||||
modal.getByRole('heading', { name: 'Invite Team Members' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Header cells scoped to class selectors to avoid matching input placeholders.
|
||||
await expect(modal.locator('.email-header')).toBeVisible();
|
||||
await expect(modal.locator('.role-header')).toBeVisible();
|
||||
|
||||
// Modal starts with 3 empty rows.
|
||||
const emailInputs = modal.locator('input[type="email"]');
|
||||
await expect(emailInputs.first()).toBeVisible();
|
||||
await expect(emailInputs).toHaveCount(3);
|
||||
|
||||
await expect(
|
||||
modal.getByRole('button', { name: /add another/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// Submit is disabled while all rows are untouched.
|
||||
const submitBtn = modal.getByRole('button', { name: 'Invite Team Members' });
|
||||
await expect(submitBtn).toBeVisible();
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await modal.getByRole('button', { name: /cancel/i }).click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// Runtime branching lives in these helpers, not test() bodies — playwright/no-conditional-in-test.
|
||||
|
||||
async function gotoMySettings(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.MY_SETTINGS);
|
||||
await expect(page.getByTestId('theme-selector')).toBeVisible();
|
||||
}
|
||||
|
||||
async function readThemeState(
|
||||
page: Page,
|
||||
): Promise<{ theme: string; autoSwitch: string }> {
|
||||
// globalThis cast: the evaluate callback runs in the browser, but the e2e
|
||||
// tsconfig uses the ES2020 lib (no DOM), so `localStorage` isn't typed here.
|
||||
return page.evaluate(() => ({
|
||||
theme: (globalThis as any).localStorage.getItem('THEME') ?? 'dark',
|
||||
autoSwitch:
|
||||
(globalThis as any).localStorage.getItem('THEME_AUTO_SWITCH') ?? 'false',
|
||||
}));
|
||||
}
|
||||
|
||||
async function restoreTheme(
|
||||
page: Page,
|
||||
theme: string,
|
||||
autoSwitch: string,
|
||||
): Promise<void> {
|
||||
await page.evaluate(
|
||||
([t, a]) => {
|
||||
(globalThis as any).localStorage.setItem('THEME', t);
|
||||
(globalThis as any).localStorage.setItem('THEME_AUTO_SWITCH', a);
|
||||
},
|
||||
[theme, autoSwitch],
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreSideNavPinned(
|
||||
page: Page,
|
||||
originalChecked: string,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: originalChecked === 'true' },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
function flipAriaChecked(current: string): string {
|
||||
if (current === 'true') {
|
||||
return 'false';
|
||||
}
|
||||
return 'true';
|
||||
}
|
||||
|
||||
test.describe('My Settings — Account page', () => {
|
||||
test('TC-01 page renders with all expected controls', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /update name/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /reset password/i }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('theme-selector')).toBeVisible();
|
||||
await expect(page.getByTestId('timezone-adaptation-switch')).toBeVisible();
|
||||
await expect(page.getByTestId('side-nav-pinned-switch')).toBeVisible();
|
||||
|
||||
// License copy button renders because bootstrap issues an enterprise license on cloud.
|
||||
await expect(page.getByTestId('license-key-copy-btn')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 theme toggle cycles dark → light → auto and applies', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const originalTheme = await readThemeState(page);
|
||||
|
||||
try {
|
||||
// Radix ToggleGroup renders items as role="radio" within a radiogroup.
|
||||
const selector = page.getByTestId('theme-selector');
|
||||
const darkRadio = selector.getByRole('radio', { name: /dark/i });
|
||||
const lightRadio = selector.getByRole('radio', { name: /light/i });
|
||||
const systemRadio = selector.getByRole('radio', { name: /system/i });
|
||||
|
||||
await lightRadio.click();
|
||||
await expect(lightRadio).toBeChecked();
|
||||
|
||||
await systemRadio.click();
|
||||
await expect(systemRadio).toBeChecked();
|
||||
|
||||
await darkRadio.click();
|
||||
await expect(darkRadio).toBeChecked();
|
||||
} finally {
|
||||
await restoreTheme(page, originalTheme.theme, originalTheme.autoSwitch);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-03 sidebar pin toggle flips checked state', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const switchEl = page.getByTestId('side-nav-pinned-switch');
|
||||
const originalChecked =
|
||||
(await switchEl.getAttribute('aria-checked')) ?? 'false';
|
||||
const expectedAfterToggle = flipAriaChecked(originalChecked);
|
||||
|
||||
try {
|
||||
await switchEl.click();
|
||||
// Pin state persists server-side; allow margin for the update under
|
||||
// parallel-worker CPU contention (default 5s expect timeout flakes).
|
||||
await expect(switchEl).toHaveAttribute('aria-checked', expectedAfterToggle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
await restoreSideNavPinned(page, originalChecked);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 timezone adaptation toggle flips checked state', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const switchEl = page.getByTestId('timezone-adaptation-switch');
|
||||
const originalChecked =
|
||||
(await switchEl.getAttribute('aria-checked')) ?? 'true';
|
||||
const expectedAfterToggle = flipAriaChecked(originalChecked);
|
||||
|
||||
try {
|
||||
await switchEl.click();
|
||||
await expect(switchEl).toHaveAttribute('aria-checked', expectedAfterToggle, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
} finally {
|
||||
// isAdaptationEnabled is not persisted — toggle back to restore session state.
|
||||
await switchEl.click();
|
||||
}
|
||||
});
|
||||
|
||||
// note: PUT /api/v2/users/me returns root_user_operation_unsupported for the
|
||||
// bootstrap admin user. Only the modal open/input/submit-button UI is tested
|
||||
// here; the "name reflects in card after save" assertion cannot be verified
|
||||
// against this stack.
|
||||
test('TC-05 update name modal — opens, pre-fills, submit button active', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
const currentName = await page.locator('.user-name').first().innerText();
|
||||
|
||||
await page.getByRole('button', { name: /update name/i }).click();
|
||||
|
||||
const nameInput = page.getByPlaceholder('e.g. John Doe');
|
||||
await expect(nameInput).toBeVisible();
|
||||
|
||||
await expect(nameInput).toHaveValue(currentName);
|
||||
|
||||
const submitBtn = page.getByTestId('update-name-btn');
|
||||
await expect(submitBtn).toBeVisible();
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
|
||||
// Close via × button — Ant Modal's Escape handler can race with input focus in headless mode.
|
||||
await page
|
||||
.locator('.update-name-modal')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click();
|
||||
await expect(nameInput).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 reset-password modal — validation only, never submits', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoMySettings(page);
|
||||
|
||||
// The button that OPENS the modal has no testid; reset-password-btn is the SUBMIT button inside.
|
||||
await page
|
||||
.getByRole('button', { name: /reset password/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const currentPasswordInput = page.getByTestId('current-password-textbox');
|
||||
const newPasswordInput = page.getByTestId('new-password-textbox');
|
||||
const submitBtn = page.getByTestId('reset-password-btn');
|
||||
|
||||
await expect(currentPasswordInput).toBeVisible();
|
||||
await expect(newPasswordInput).toBeVisible();
|
||||
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await currentPasswordInput.fill('somepassword');
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Same value → passwords match → validation error + disabled
|
||||
await newPasswordInput.fill('somepassword');
|
||||
await expect(page.getByText(/new password must be different/i)).toBeVisible();
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
// Stop at enabled — clicking would rotate the admin password and break every other worker.
|
||||
await newPasswordInput.fill('differentpassword!1');
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
|
||||
await page
|
||||
.locator('.reset-password-modal')
|
||||
.getByRole('button', { name: 'Close' })
|
||||
.click();
|
||||
await expect(currentPasswordInput).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// OrganizationSettings (/settings/org-settings): DisplayName form + AuthDomain section.
|
||||
// Invite coverage lives in members.spec.ts — the #invite-team-members hash is ignored here.
|
||||
//
|
||||
// note: PUT /api/v2/orgs returns root_user_operation_unsupported for the bootstrap
|
||||
// admin user. TC-02 only asserts the field is editable and the Submit button enables;
|
||||
// it does NOT submit the form. The original org name is never mutated.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
async function gotoOrgSettings(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.ORG_SETTINGS);
|
||||
await expect(page.getByLabel('Display name')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Organization Settings — SSO & Org page', () => {
|
||||
test('TC-01 page renders display-name field and authenticated-domains section', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoOrgSettings(page);
|
||||
|
||||
await expect(page.getByLabel('Display name')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Authenticated Domains' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Add Domain' })).toBeVisible();
|
||||
});
|
||||
|
||||
// note: root_user_operation_unsupported on save (see header) — never clicks Submit; value restored in finally.
|
||||
test('TC-02 org display name — field is editable and Submit enables on change', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoOrgSettings(page);
|
||||
|
||||
const nameInput = page.getByLabel('Display name');
|
||||
const submitBtn = page.getByRole('button', { name: 'Submit' });
|
||||
|
||||
const originalValue = await nameInput.inputValue();
|
||||
|
||||
try {
|
||||
// Submit is disabled when the value equals the current saved name.
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await nameInput.fill('org-sso-spec-temp');
|
||||
await expect(nameInput).toHaveValue('org-sso-spec-temp');
|
||||
await expect(submitBtn).toBeEnabled();
|
||||
|
||||
await nameInput.fill('');
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
} finally {
|
||||
// Restored value equals the saved one, so Submit stays disabled — no API call.
|
||||
await nameInput.fill(originalValue);
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
// RISK MODE: never enable SSO/SAML or click Save — that changes auth for the whole stack.
|
||||
test('TC-03 SSO config — Add Domain opens provider-selector modal, close dismisses it', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoOrgSettings(page);
|
||||
|
||||
await page.getByRole('button', { name: 'Add Domain' }).click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await expect(
|
||||
modal.getByText('Configure Authentication Method'),
|
||||
).toBeVisible();
|
||||
await expect(modal.getByText('Google Apps Authentication')).toBeVisible();
|
||||
|
||||
// SAML/OIDC visibility depends on the SSO flag — only assert Google Auth, always enabled.
|
||||
|
||||
await modal.getByRole('button', { name: /close/i }).click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Roles page. RISK MODE — READ-ONLY: never create/edit/delete a role; TC-03
|
||||
// only views a managed role's detail page and navigates back.
|
||||
// rolesEnabled probes /api/v1/features for USE_FINE_GRAINED_AUTHZ — real backend
|
||||
// state, not a guess; row navigation is only wired up when it is on, so TC-03 skips otherwise.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let rolesEnabled = false;
|
||||
|
||||
async function gotoRolesList(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.ROLES);
|
||||
await expect(page.getByTestId('roles-settings')).toBeVisible();
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get('/api/v1/features', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const body = await res.json();
|
||||
const flags: { name?: string; active?: boolean }[] = body?.data ?? [];
|
||||
const flag = flags.find((f) => f?.name === 'use_fine_grained_authz');
|
||||
rolesEnabled = !!flag?.active;
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Settings — Roles page', () => {
|
||||
test('TC-01 list renders with container, header, search, and managed-role rows', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoRolesList(page);
|
||||
|
||||
await expect(page.locator('.roles-settings-header-title')).toContainText(
|
||||
'Roles',
|
||||
);
|
||||
await expect(
|
||||
page.locator('.roles-settings-header-description'),
|
||||
).toContainText('Create and manage custom roles for your team.');
|
||||
|
||||
await expect(page.locator('input[type="search"]')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('input[placeholder="Search for roles..."]'),
|
||||
).toBeVisible();
|
||||
|
||||
const table = page.locator('.roles-listing-table');
|
||||
await expect(table).toBeVisible();
|
||||
await expect(table.locator('.roles-table-header-cell--name')).toContainText(
|
||||
'Name',
|
||||
);
|
||||
await expect(
|
||||
table.locator('.roles-table-header-cell--description'),
|
||||
).toContainText('Description');
|
||||
await expect(
|
||||
table.locator('.roles-table-header-cell--updated-at'),
|
||||
).toContainText('Updated At');
|
||||
await expect(
|
||||
table.locator('.roles-table-header-cell--created-at'),
|
||||
).toContainText('Created At');
|
||||
|
||||
await expect(
|
||||
table.locator('.roles-table-section-header', { hasText: 'Managed roles' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(table.locator('.roles-table-row').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 search filters roles by match and shows empty state on no match', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoRolesList(page);
|
||||
|
||||
const searchInput = page.locator('input[placeholder="Search for roles..."]');
|
||||
const table = page.locator('.roles-listing-table');
|
||||
|
||||
await searchInput.fill('Admin');
|
||||
await expect(
|
||||
table.locator('.roles-table-cell--name', { hasText: /admin/i }).first(),
|
||||
).toBeVisible();
|
||||
await expect(table.locator('.roles-table-empty')).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('xyznonexistentrole999');
|
||||
await expect(table.locator('.roles-table-empty')).toBeVisible();
|
||||
await expect(table.locator('.roles-table-empty')).toContainText(
|
||||
'No roles match your search.',
|
||||
);
|
||||
await expect(table.locator('.roles-table-row')).toHaveCount(0);
|
||||
|
||||
await searchInput.fill('');
|
||||
await expect(table.locator('.roles-table-row').first()).toBeVisible();
|
||||
await expect(table.locator('.roles-table-empty')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// Read-only: views a managed role, asserts no edit/delete, navigates back.
|
||||
// Skipped when USE_FINE_GRAINED_AUTHZ is off — rows have no click handler.
|
||||
test('TC-03 role detail page — clicking a managed role navigates to its detail view', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
|
||||
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!rolesEnabled,
|
||||
'PERSONA_SKIP: USE_FINE_GRAINED_AUTHZ feature flag is off — role rows are not clickable',
|
||||
);
|
||||
|
||||
await gotoRolesList(page);
|
||||
|
||||
const table = page.locator('.roles-listing-table');
|
||||
|
||||
const firstRow = table.locator('.roles-table-row').first();
|
||||
await firstRow.scrollIntoViewIfNeeded();
|
||||
await firstRow.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/settings\/roles\/[^/]+/);
|
||||
|
||||
const detailPage = page.locator('.role-details-page');
|
||||
await expect(detailPage).toBeVisible();
|
||||
await expect(detailPage.locator('.role-details-title')).toBeVisible();
|
||||
await expect(detailPage.locator('.role-details-title')).toContainText(
|
||||
'Role —',
|
||||
);
|
||||
|
||||
await expect(
|
||||
detailPage.getByText(
|
||||
'This is a managed role. Permissions and settings are view-only and cannot be modified.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
detailPage.getByRole('button', { name: 'Edit Role Details' }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await expect(
|
||||
detailPage.locator('.role-details-section-label', {
|
||||
hasText: 'Permissions',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto(SETTINGS_ROUTES.ROLES);
|
||||
await expect(page.getByTestId('roles-settings')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { authToken } from '../../helpers/dashboards';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Service Accounts page. RISK MODE — READ-ONLY: never create/edit/delete an
|
||||
// account or generate a token; the create modal is never opened.
|
||||
// listAccessible probes the real authz/check backend state in beforeAll (when
|
||||
// use_fine_grained_authz is on the admin may lack serviceaccount:list, rendering
|
||||
// PermissionDeniedFullPage); the functional TCs skip when it is false.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
let listAccessible = false;
|
||||
|
||||
async function gotoServiceAccounts(page: Page): Promise<void> {
|
||||
await page.goto(SETTINGS_ROUTES.SERVICE_ACCOUNTS);
|
||||
await expect(page.locator('.sa-settings__title')).toBeVisible();
|
||||
}
|
||||
|
||||
function buildSkipReason(
|
||||
persona: Parameters<typeof personaSkipReason>[0],
|
||||
env: Parameters<typeof personaSkipReason>[1],
|
||||
): string | null {
|
||||
return personaSkipReason(persona, env, SETTINGS_ROUTES.SERVICE_ACCOUNTS);
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get('/api/v1/features', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const body = await res.json();
|
||||
const flags: { name?: string; active?: boolean }[] = body?.data ?? [];
|
||||
const fgAuthz = flags.find((f) => f?.name === 'use_fine_grained_authz');
|
||||
|
||||
if (!fgAuthz?.active) {
|
||||
// Without fine-grained authz the SA list is always accessible.
|
||||
listAccessible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe the authz check endpoint for serviceaccount:list (wildcard).
|
||||
const authzRes = await page.request.post('/api/v1/authz/check', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: [
|
||||
{
|
||||
relation: 'list',
|
||||
object: {
|
||||
resource: { kind: 'serviceaccount', type: 'serviceaccount' },
|
||||
selector: '*',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const authzBody = await authzRes.json();
|
||||
const items: { authorized?: boolean }[] = authzBody?.data ?? [];
|
||||
listAccessible = items.some((i) => i?.authorized);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Settings — Service Accounts page', () => {
|
||||
test('TC-01 page chrome and empty-state render', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!buildSkipReason(persona, env),
|
||||
buildSkipReason(persona, env) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!listAccessible,
|
||||
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
|
||||
);
|
||||
|
||||
await gotoServiceAccounts(page);
|
||||
|
||||
await expect(page.locator('.sa-settings__title')).toContainText(
|
||||
'Service Accounts',
|
||||
);
|
||||
await expect(page.locator('.sa-settings__subtitle')).toContainText(
|
||||
'Overview of service accounts added to this workspace.',
|
||||
);
|
||||
await expect(
|
||||
page.locator('.sa-settings__subtitle a[href*="signoz.io/docs"]'),
|
||||
).toBeVisible();
|
||||
|
||||
const controls = page.locator('.sa-settings__controls');
|
||||
await expect(controls).toBeVisible();
|
||||
await expect(
|
||||
controls.getByRole('button', { name: /All accounts/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
controls.locator('input[placeholder="Search by name or email..."]'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
controls.getByRole('button', { name: /New Service Account/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.locator('.sa-table-wrapper')).toBeVisible();
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
await expect(page.locator('.sa-empty-state__text')).toContainText(
|
||||
'No service accounts.',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-02 filter dropdown writes URL param and shows empty-state per mode', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!buildSkipReason(persona, env),
|
||||
buildSkipReason(persona, env) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!listAccessible,
|
||||
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
|
||||
);
|
||||
|
||||
await gotoServiceAccounts(page);
|
||||
|
||||
const filterTrigger = page.getByRole('button', { name: /All accounts/i });
|
||||
|
||||
await filterTrigger.click();
|
||||
await page.getByText(/^Active ⎯/).click();
|
||||
await expect(page).toHaveURL(/[?&]filter=active/);
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Active ⎯/i }).click();
|
||||
await page.getByText(/^Deleted ⎯/).click();
|
||||
await expect(page).toHaveURL(/[?&]filter=deleted/);
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Deleted ⎯/i }).click();
|
||||
await page.getByText(/^All accounts ⎯/).click();
|
||||
await expect(page).not.toHaveURL(/[?&]filter=active/);
|
||||
await expect(page).not.toHaveURL(/[?&]filter=deleted/);
|
||||
await expect(page.locator('.sa-empty-state')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 search updates URL and empty-state; create button enabled', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!buildSkipReason(persona, env),
|
||||
buildSkipReason(persona, env) ?? undefined,
|
||||
);
|
||||
test.skip(
|
||||
!listAccessible,
|
||||
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
|
||||
);
|
||||
|
||||
await gotoServiceAccounts(page);
|
||||
|
||||
const searchInput = page.locator(
|
||||
'input[placeholder="Search by name or email..."]',
|
||||
);
|
||||
|
||||
await searchInput.fill('xyznonexistent999');
|
||||
await expect(page).toHaveURL(/[?&]search=xyznonexistent999/);
|
||||
await expect(page.locator('.sa-empty-state__text')).toContainText(
|
||||
'No results for',
|
||||
);
|
||||
await expect(page.locator('.sa-empty-state__text strong')).toContainText(
|
||||
'xyznonexistent999',
|
||||
);
|
||||
|
||||
await searchInput.fill('');
|
||||
await expect(page).not.toHaveURL(/[?&]search=xyznonexistent999/);
|
||||
await expect(page.locator('.sa-empty-state__text')).toContainText(
|
||||
'No service accounts.',
|
||||
);
|
||||
|
||||
const createBtn = page.getByRole('button', { name: /New Service Account/i });
|
||||
await expect(createBtn).toBeVisible();
|
||||
await expect(createBtn).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { Persona, SettingsEnv } from '../../helpers/persona';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
registeredRoutes,
|
||||
visibleNavItems,
|
||||
} from '../../helpers/settingsAccess';
|
||||
import {
|
||||
NAV_TESTID,
|
||||
SETTINGS_ROUTES,
|
||||
gotoSettings,
|
||||
} from '../../helpers/settings';
|
||||
|
||||
// Branching lives in module-level helpers, not test bodies — the repo's
|
||||
// playwright/no-conditional-in-test rule forbids `if` inside `test()`.
|
||||
|
||||
function partitionNavTestids(
|
||||
persona: Persona,
|
||||
env: SettingsEnv,
|
||||
): { visible: string[]; hidden: string[] } {
|
||||
const all = Object.values(NAV_TESTID);
|
||||
const expected = visibleNavItems(persona, env);
|
||||
return {
|
||||
visible: all.filter((testid) => expected.has(testid)),
|
||||
hidden: all.filter((testid) => !expected.has(testid)),
|
||||
};
|
||||
}
|
||||
|
||||
// Visible nav items whose /settings route is not registered (mounted).
|
||||
// INTEGRATIONS is excluded — it is a top-level page, not a RouteTab route.
|
||||
function navRouteMismatches(persona: Persona, env: SettingsEnv): string[] {
|
||||
const visible = visibleNavItems(persona, env);
|
||||
const registered = registeredRoutes(persona, env);
|
||||
const routeByTestid = Object.fromEntries(
|
||||
Object.entries(NAV_TESTID).map(([route, testid]) => [testid, route]),
|
||||
);
|
||||
return [...visible]
|
||||
.map((testid) => routeByTestid[testid])
|
||||
.filter((route) => !!route && route !== SETTINGS_ROUTES.INTEGRATIONS)
|
||||
.filter((route) => !registered.has(route))
|
||||
.map((route) => `${route} is nav-visible but route not registered`);
|
||||
}
|
||||
|
||||
test.describe('Settings — shell, gating matrix & integrity', () => {
|
||||
test('TC-01 settings shell chrome renders with no JS pageerror', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await gotoSettings(page);
|
||||
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
|
||||
expect(errors, errors.map((e) => e.message).join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-02 sidenav shows exactly the matrix-predicted items', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
await gotoSettings(page);
|
||||
const sidenav = page.getByTestId('settings-page-sidenav');
|
||||
const { visible, hidden } = partitionNavTestids(persona, env);
|
||||
|
||||
for (const testid of visible) {
|
||||
await expect(
|
||||
sidenav.getByTestId(testid),
|
||||
`${testid} should be visible`,
|
||||
).toBeVisible();
|
||||
}
|
||||
for (const testid of hidden) {
|
||||
await expect(
|
||||
sidenav.getByTestId(testid),
|
||||
`${testid} should be hidden`,
|
||||
).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-03 every registered route deep-links with no JS pageerror', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
const routes = [...registeredRoutes(persona, env)];
|
||||
for (const route of routes) {
|
||||
const errors: Error[] = [];
|
||||
const onError = (err: Error): void => {
|
||||
errors.push(err);
|
||||
};
|
||||
page.on('pageerror', onError);
|
||||
await page.goto(route);
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
page.off('pageerror', onError);
|
||||
expect(
|
||||
errors,
|
||||
`pageerror on ${route}: ${errors.map((e) => e.message).join('\n')}`,
|
||||
).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 every visible nav item resolves to a registered route', async ({
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
const mismatches = navRouteMismatches(persona, env);
|
||||
expect(mismatches, mismatches.join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-05 clicking a nav item navigates and marks active', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!visibleNavItems(persona, env).has('account'),
|
||||
'PERSONA_SKIP: account nav hidden',
|
||||
);
|
||||
await gotoSettings(page);
|
||||
const sidenav = page.getByTestId('settings-page-sidenav');
|
||||
await sidenav.getByTestId('account').click();
|
||||
await expect(page).toHaveURL(/\/settings\/my-settings/);
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { personaSkipReason } from '../../helpers/settingsAccess';
|
||||
import { SETTINGS_ROUTES } from '../../helpers/settings';
|
||||
|
||||
// Keyboard Shortcuts — static read-only page (RISK MODE: nothing mutated).
|
||||
// No testids here, so locators are CSS classes (.keyboard-shortcuts,
|
||||
// .shortcut-section-heading) and role/text.
|
||||
|
||||
const ROUTE = SETTINGS_ROUTES.SHORTCUTS;
|
||||
|
||||
async function gotoShortcuts(page: Page): Promise<void> {
|
||||
await page.goto(ROUTE);
|
||||
await expect(page.locator('.keyboard-shortcuts')).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Settings — Keyboard Shortcuts page', () => {
|
||||
test('TC-01 shortcuts page renders all four grouped sections with entries', async ({
|
||||
authedPage: page,
|
||||
persona,
|
||||
env,
|
||||
}) => {
|
||||
test.skip(
|
||||
!!personaSkipReason(persona, env, ROUTE),
|
||||
personaSkipReason(persona, env, ROUTE) ?? undefined,
|
||||
);
|
||||
|
||||
await gotoShortcuts(page);
|
||||
|
||||
await expect(page.getByTestId('settings-page-title')).toBeVisible();
|
||||
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('settings-page-sidenav').getByTestId('keyboard-shortcuts'),
|
||||
).toBeVisible();
|
||||
|
||||
const sections = page.locator('.shortcut-section-heading');
|
||||
await expect(sections).toHaveCount(4);
|
||||
await expect(sections.nth(0)).toHaveText('Global Shortcuts');
|
||||
await expect(sections.nth(1)).toHaveText('Logs Explorer Shortcuts');
|
||||
await expect(sections.nth(2)).toHaveText('Query Builder Shortcuts');
|
||||
await expect(sections.nth(3)).toHaveText('Dashboard Shortcuts');
|
||||
|
||||
await expect(page.locator('.shortcut-section-table')).toHaveCount(4);
|
||||
|
||||
const firstTable = page.locator('.shortcut-section-table').first();
|
||||
await expect(
|
||||
firstTable.getByRole('columnheader', { name: 'Keyboard Shortcut' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
firstTable.getByRole('columnheader', { name: 'Description' }),
|
||||
).toBeVisible();
|
||||
|
||||
// "shift+d" chosen as it is stable across OS variants (no cmd/ctrl).
|
||||
const globalTable = page.locator('.shortcut-section-table').nth(0);
|
||||
await expect(
|
||||
globalTable.getByRole('cell', { name: 'shift+d' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
globalTable.getByRole('cell', { name: 'Navigate to Dashboards List' }),
|
||||
).toBeVisible();
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const table = page.locator('.shortcut-section-table').nth(i);
|
||||
await expect(table.locator('tbody tr').first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
77
tests/fixtures/traces.py
vendored
77
tests/fixtures/traces.py
vendored
@@ -16,6 +16,42 @@ from fixtures import types
|
||||
from fixtures.fingerprint import LogsOrTracesFingerprint
|
||||
from fixtures.time import parse_duration, parse_timestamp
|
||||
|
||||
# All keys returned by the trace list endpoint when selectFields is empty:
|
||||
# every intrinsic and calculated column, plus the merged `attributes` and
|
||||
# `resource` maps that wrap the contextual columns in the response layer.
|
||||
ALL_SELECT_FIELDS = [
|
||||
# all intrinsic columns
|
||||
"timestamp",
|
||||
"trace_id",
|
||||
"span_id",
|
||||
"trace_state",
|
||||
"parent_span_id",
|
||||
"flags",
|
||||
"name",
|
||||
"kind",
|
||||
"kind_string",
|
||||
"duration_nano",
|
||||
"status_code",
|
||||
"status_message",
|
||||
"status_code_string",
|
||||
"events",
|
||||
"links",
|
||||
# all calculated columns
|
||||
"response_status_code",
|
||||
"external_http_url",
|
||||
"http_url",
|
||||
"external_http_method",
|
||||
"http_method",
|
||||
"http_host",
|
||||
"db_name",
|
||||
"db_operation",
|
||||
"has_error",
|
||||
"is_remote",
|
||||
# all contextual columns (merged in response layer)
|
||||
"attributes",
|
||||
"resource",
|
||||
]
|
||||
|
||||
|
||||
class TracesKind(Enum):
|
||||
SPAN_KIND_UNSPECIFIED = 0
|
||||
@@ -236,9 +272,10 @@ class Traces(ABC):
|
||||
attributes_number: dict[str, np.float64]
|
||||
attributes_bool: dict[str, bool]
|
||||
resources_string: dict[str, str]
|
||||
# Accepting parsed events and links, but will be stored as list[str], str in db
|
||||
events: list[dict[str, Any]]
|
||||
links: list[dict[str, Any]]
|
||||
resource_json: dict[str, str]
|
||||
events: list[str]
|
||||
links: str
|
||||
response_status_code: str
|
||||
external_http_url: str
|
||||
http_url: str
|
||||
@@ -428,10 +465,17 @@ class Traces(ABC):
|
||||
)
|
||||
)
|
||||
|
||||
# Process events and derive error events
|
||||
# Process events and derive error events. self.events holds the parsed
|
||||
# response shape; np_arr() encodes back to the DB format on insert.
|
||||
self.events = []
|
||||
for event in events:
|
||||
self.events.append(json.dumps([event.name, event.time_unix_nano, event.attribute_map]))
|
||||
self.events.append(
|
||||
{
|
||||
"name": event.name,
|
||||
"timeUnixNano": int(event.time_unix_nano),
|
||||
"attributes": dict(event.attribute_map),
|
||||
}
|
||||
)
|
||||
|
||||
# Create error events for exception events (following Go exporter logic)
|
||||
if event.name == "exception":
|
||||
@@ -453,7 +497,26 @@ class Traces(ABC):
|
||||
),
|
||||
)
|
||||
|
||||
self.links = json.dumps([link.__dict__() for link in links_copy], separators=(",", ":"))
|
||||
# self.links holds the parsed response shape (trace_id/span_id only;
|
||||
# ref_type is dropped to match the API). np_arr() re-encodes for DB insert.
|
||||
self.links = [{"traceId": link.trace_id, "spanId": link.span_id} for link in links_copy]
|
||||
self._links_db = json.dumps(
|
||||
[link.__dict__() for link in links_copy],
|
||||
separators=(",", ":"),
|
||||
)
|
||||
# DB shape per event: {"name", "timeUnixNano", "attributeMap"}. Must match
|
||||
# what the consume-layer parser in pkg/types/spantypes expects.
|
||||
self._events_db = [
|
||||
json.dumps(
|
||||
{
|
||||
"name": event.name,
|
||||
"timeUnixNano": int(event.time_unix_nano),
|
||||
"attributeMap": dict(event.attribute_map),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
for event in events
|
||||
]
|
||||
|
||||
# Initialize resource
|
||||
self.resource = []
|
||||
@@ -568,8 +631,8 @@ class Traces(ABC):
|
||||
self.attributes_number,
|
||||
self.attributes_bool,
|
||||
self.resources_string,
|
||||
self.events,
|
||||
self.links,
|
||||
self._events_db,
|
||||
self._links_db,
|
||||
self.response_status_code,
|
||||
self.external_http_url,
|
||||
self.http_url,
|
||||
|
||||
@@ -17,7 +17,16 @@ from fixtures.querier import (
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
from fixtures.traces import (
|
||||
ALL_SELECT_FIELDS,
|
||||
TraceIdGenerator,
|
||||
Traces,
|
||||
TracesEvent,
|
||||
TracesKind,
|
||||
TracesLink,
|
||||
TracesRefType,
|
||||
TracesStatusCode,
|
||||
)
|
||||
|
||||
|
||||
def test_traces_list(
|
||||
@@ -473,7 +482,9 @@ def test_traces_list(
|
||||
@pytest.mark.parametrize(
|
||||
"payload,status_code,results",
|
||||
[
|
||||
# Case 1: order by timestamp field which there in attributes as well
|
||||
# Case 1: order by timestamp; empty selectFields returns the full
|
||||
# response shape (all intrinsic + calculated columns plus the merged
|
||||
# `attributes` and `resource` maps). x[3] (topic-service) is latest.
|
||||
pytest.param(
|
||||
{
|
||||
"type": "builder_query",
|
||||
@@ -487,19 +498,42 @@ def test_traces_list(
|
||||
},
|
||||
HTTPStatus.OK,
|
||||
lambda x: [
|
||||
x[3].duration_nano,
|
||||
{
|
||||
**x[3].attribute_string,
|
||||
**x[3].attributes_number,
|
||||
**x[3].attributes_bool,
|
||||
}, # attributes
|
||||
x[3].db_name,
|
||||
x[3].db_operation,
|
||||
int(x[3].duration_nano),
|
||||
x[3].events,
|
||||
x[3].external_http_method,
|
||||
x[3].external_http_url,
|
||||
int(x[3].flags),
|
||||
x[3].has_error,
|
||||
x[3].http_host,
|
||||
x[3].http_method,
|
||||
x[3].http_url,
|
||||
x[3].is_remote,
|
||||
int(x[3].kind),
|
||||
x[3].kind_string,
|
||||
x[3].links,
|
||||
x[3].name,
|
||||
x[3].parent_span_id,
|
||||
x[3].resources_string,
|
||||
x[3].response_status_code,
|
||||
x[3].service_name,
|
||||
x[3].span_id,
|
||||
int(x[3].status_code),
|
||||
x[3].status_code_string,
|
||||
x[3].status_message,
|
||||
format_timestamp(x[3].timestamp),
|
||||
x[3].trace_id,
|
||||
x[3].trace_state,
|
||||
], # type: Callable[[List[Traces]], List[Any]]
|
||||
),
|
||||
# Case 2: order by attribute timestamp field which is there in attributes as well
|
||||
# This should break but it doesn't because attribute.timestamp gets adjusted to timestamp
|
||||
# because of default trace.timestamp gets added by default and bug in field mapper picks
|
||||
# instrinsic field
|
||||
# Case 2: order by attribute.timestamp. The key resolves to the
|
||||
# intrinsic span.timestamp column, so the latest span (x[3]) is
|
||||
# returned with the same full response shape as Case 1.
|
||||
pytest.param(
|
||||
{
|
||||
"type": "builder_query",
|
||||
@@ -513,13 +547,37 @@ def test_traces_list(
|
||||
},
|
||||
HTTPStatus.OK,
|
||||
lambda x: [
|
||||
x[3].duration_nano,
|
||||
{
|
||||
**x[3].attribute_string,
|
||||
**x[3].attributes_number,
|
||||
**x[3].attributes_bool,
|
||||
}, # attributes
|
||||
x[3].db_name,
|
||||
x[3].db_operation,
|
||||
int(x[3].duration_nano),
|
||||
x[3].events,
|
||||
x[3].external_http_method,
|
||||
x[3].external_http_url,
|
||||
int(x[3].flags),
|
||||
x[3].has_error,
|
||||
x[3].http_host,
|
||||
x[3].http_method,
|
||||
x[3].http_url,
|
||||
x[3].is_remote,
|
||||
int(x[3].kind),
|
||||
x[3].kind_string,
|
||||
x[3].links,
|
||||
x[3].name,
|
||||
x[3].parent_span_id,
|
||||
x[3].resources_string,
|
||||
x[3].response_status_code,
|
||||
x[3].service_name,
|
||||
x[3].span_id,
|
||||
int(x[3].status_code),
|
||||
x[3].status_code_string,
|
||||
x[3].status_message,
|
||||
format_timestamp(x[3].timestamp),
|
||||
x[3].trace_id,
|
||||
x[3].trace_state,
|
||||
], # type: Callable[[List[Traces]], List[Any]]
|
||||
),
|
||||
# Case 3: select timestamp with empty order by
|
||||
@@ -542,7 +600,7 @@ def test_traces_list(
|
||||
], # type: Callable[[List[Traces]], List[Any]]
|
||||
),
|
||||
# Case 4: select attribute.timestamp with empty order by
|
||||
# This doesn't return any data because of where_clause using aliased timestamp
|
||||
# This returns the one span which has attribute.timestamp
|
||||
pytest.param(
|
||||
{
|
||||
"type": "builder_query",
|
||||
@@ -556,7 +614,11 @@ def test_traces_list(
|
||||
},
|
||||
},
|
||||
HTTPStatus.OK,
|
||||
lambda x: [], # type: Callable[[List[Traces]], List[Any]]
|
||||
lambda x: [
|
||||
x[0].span_id,
|
||||
format_timestamp(x[0].timestamp),
|
||||
x[0].trace_id,
|
||||
], # type: Callable[[List[Traces]], List[Any]]
|
||||
),
|
||||
# Case 5: select timestamp with timestamp order by
|
||||
pytest.param(
|
||||
@@ -693,6 +755,159 @@ def test_traces_list_with_corrupt_data(
|
||||
assert data[key] == value
|
||||
|
||||
|
||||
def _verify_events_links_full(rows: list[dict], traces: list[Traces]) -> None:
|
||||
"""Empty-selectFields case: events/links arrive parsed into structured objects.
|
||||
Every row's events/links should match the fixture's stored parsed shape
|
||||
(the fixture's `.events`/`.links` mirror the API response shape directly).
|
||||
"""
|
||||
for row, trace in zip(rows, traces, strict=True):
|
||||
assert row["data"]["events"] == trace.events
|
||||
assert row["data"]["links"] == trace.links
|
||||
# Jaeger-era `refType` is dropped at the consume layer.
|
||||
for link in row["data"]["links"]:
|
||||
assert "refType" not in link
|
||||
|
||||
|
||||
def _verify_events_links_skip(rows: list[dict], traces: list[Traces]) -> None:
|
||||
"""Projected-selectFields case: nothing to verify beyond the key set."""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"select_fields,status_code,expected_keys,verify_values",
|
||||
[
|
||||
pytest.param(
|
||||
[],
|
||||
HTTPStatus.OK,
|
||||
ALL_SELECT_FIELDS,
|
||||
_verify_events_links_full,
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
{"name": "service.name"},
|
||||
],
|
||||
HTTPStatus.OK,
|
||||
["timestamp", "trace_id", "span_id", "service.name"],
|
||||
_verify_events_links_skip,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_traces_list_with_select_fields(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
select_fields: list[dict],
|
||||
status_code: HTTPStatus,
|
||||
expected_keys: list[str],
|
||||
verify_values: Callable[[list[dict], list[Traces]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Insert a root span with no events/links and a child span carrying two
|
||||
events and one user-supplied link.
|
||||
|
||||
Tests:
|
||||
1. Empty select fields should return all the fields, and the `events` /
|
||||
`links` columns should arrive parsed into structured objects (events
|
||||
carry `attributes`, links carry only `traceId`/`spanId` — refType is
|
||||
dropped at the consume layer).
|
||||
2. Non-empty select field should return the select field along with
|
||||
timestamp, trace_id and span_id.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
parent_trace_id = TraceIdGenerator.trace_id()
|
||||
parent_span_id = TraceIdGenerator.span_id()
|
||||
child_span_id = TraceIdGenerator.span_id()
|
||||
linked_trace_id = TraceIdGenerator.trace_id()
|
||||
linked_span_id = TraceIdGenerator.span_id()
|
||||
|
||||
event_one = TracesEvent(
|
||||
name="request_received",
|
||||
timestamp=now - timedelta(seconds=3, microseconds=500_000),
|
||||
attribute_map={"http.method": "GET", "http.route": "/api/chat"},
|
||||
)
|
||||
event_two = TracesEvent(
|
||||
name="cache_lookup",
|
||||
timestamp=now - timedelta(seconds=3, microseconds=400_000),
|
||||
attribute_map={"cache.hit": "true", "cache.key": "user:123:prompt"},
|
||||
)
|
||||
user_link = TracesLink(
|
||||
trace_id=linked_trace_id,
|
||||
span_id=linked_span_id,
|
||||
ref_type=TracesRefType.REF_TYPE_FOLLOWS_FROM,
|
||||
)
|
||||
|
||||
traces = [
|
||||
# Root span: no events, no links. Verifies the empty-case parsed shape.
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=3),
|
||||
trace_id=parent_trace_id,
|
||||
span_id=parent_span_id,
|
||||
parent_span_id="",
|
||||
name="root span",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "events-links-service"},
|
||||
attributes={"http.request.method": "GET"},
|
||||
),
|
||||
# Child span: two events + one user-supplied link. The fixture
|
||||
# auto-inserts a CHILD_OF link for the parent, so the parsed response
|
||||
# contains two links total — the auto-inserted one first.
|
||||
Traces(
|
||||
timestamp=now - timedelta(seconds=3),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=parent_trace_id,
|
||||
span_id=child_span_id,
|
||||
parent_span_id=parent_span_id,
|
||||
name="child span",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "events-links-service"},
|
||||
attributes={"http.request.method": "GET"},
|
||||
events=[event_one, event_two],
|
||||
links=[user_link],
|
||||
),
|
||||
]
|
||||
|
||||
insert_traces(traces)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
payload = {
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": "resource.service.name = 'events-links-service'"},
|
||||
"selectFields": select_fields,
|
||||
"order": [{"key": {"name": "timestamp"}, "direction": "asc"}],
|
||||
"limit": 10,
|
||||
},
|
||||
}
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[payload],
|
||||
)
|
||||
assert response.status_code == status_code
|
||||
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
return
|
||||
|
||||
rows = response.json()["data"]["data"]["results"][0]["rows"]
|
||||
assert len(rows) == 2
|
||||
for row in rows:
|
||||
assert set(row["data"].keys()) == set(expected_keys)
|
||||
|
||||
verify_values(rows, traces)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"order_by,aggregation_alias,expected_status",
|
||||
[
|
||||
|
||||
@@ -36,12 +36,22 @@ from fixtures.querier import (
|
||||
assert_grouped_scalar,
|
||||
assert_raw_row_subset,
|
||||
assert_scalar_value,
|
||||
find_named_result,
|
||||
format_timestamp,
|
||||
generate_traces_with_corrupt_metadata,
|
||||
get_rows,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
|
||||
from fixtures.traces import (
|
||||
ALL_SELECT_FIELDS,
|
||||
TraceIdGenerator,
|
||||
Traces,
|
||||
TracesEvent,
|
||||
TracesKind,
|
||||
TracesLink,
|
||||
TracesRefType,
|
||||
TracesStatusCode,
|
||||
)
|
||||
|
||||
|
||||
def _names(response: requests.Response) -> set:
|
||||
@@ -613,3 +623,178 @@ def test_trace_operator_with_adjusted_keys(
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert_result(response, traces)
|
||||
|
||||
|
||||
# Hardcoded core columns the trace_operator buildListQuery always projects,
|
||||
# in addition to any user-supplied selectFields.
|
||||
TRACE_OPERATOR_CORE_FIELDS = [
|
||||
"timestamp",
|
||||
"trace_id",
|
||||
"span_id",
|
||||
"name",
|
||||
"duration_nano",
|
||||
"parent_span_id",
|
||||
]
|
||||
|
||||
|
||||
def _verify_full_expansion(rows: list[dict], parent_trace: Traces) -> None:
|
||||
"""Empty-selectFields case: every column from the builder_query parity set
|
||||
arrives, and events/links are parsed into structured form (refType is
|
||||
dropped at the consume layer).
|
||||
"""
|
||||
assert len(rows) == 1
|
||||
parent_row = rows[0]["data"]
|
||||
assert set(parent_row.keys()) == set(ALL_SELECT_FIELDS)
|
||||
assert parent_row["events"] == parent_trace.events
|
||||
assert parent_row["links"] == parent_trace.links
|
||||
for link in parent_row["links"]:
|
||||
assert "refType" not in link
|
||||
|
||||
|
||||
def _verify_explicit_projection(rows: list[dict], parent_trace: Traces) -> None: # pylint: disable=unused-argument
|
||||
"""Explicit-selectFields case: only the 6 hardcoded core fields plus the
|
||||
user-supplied resource.service.name come back. Contextual columns
|
||||
(events/links/attributes/resource) and the rest of the intrinsics never
|
||||
appear because the consume-layer merge isn't triggered.
|
||||
"""
|
||||
assert len(rows) == 1
|
||||
parent_row = rows[0]["data"]
|
||||
assert set(parent_row.keys()) == set(TRACE_OPERATOR_CORE_FIELDS + ["service.name"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"select_fields,verify_values",
|
||||
[
|
||||
pytest.param([], _verify_full_expansion, id="empty-select-fields"),
|
||||
pytest.param(
|
||||
[{"name": "service.name", "fieldContext": "resource"}],
|
||||
_verify_explicit_projection,
|
||||
id="explicit-service-name",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_trace_operator_select_fields(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
select_fields: list[dict[str, Any]],
|
||||
verify_values: Callable[[list[dict], Traces], None],
|
||||
) -> None:
|
||||
"""
|
||||
Setup:
|
||||
Insert a parent (operation.type = 'parent') with one event and one
|
||||
user-supplied link, plus a child span (operation.type = 'child').
|
||||
|
||||
Tests:
|
||||
1. With selectFields=[], the `A => B` trace_operator returns every column
|
||||
in ALL_SELECT_FIELDS, mirroring the builder_query path. Events arrive
|
||||
as {name, timeUnixNano, attributes} and links as {traceId, spanId}
|
||||
with refType dropped at the consume layer.
|
||||
2. With an explicit selectFields=[{"name": "service.name"}], only the 6
|
||||
hardcoded core columns plus service.name come back — no auto-expansion
|
||||
to the full set.
|
||||
|
||||
See:
|
||||
- pkg/telemetrytraces/trace_operator_cte_builder.go::buildFinalQuery for
|
||||
the expansion gate.
|
||||
- pkg/telemetrytraces/trace_operator_cte_builder.go::buildListQuery for
|
||||
the per-row SELECT.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
|
||||
trace_id = TraceIdGenerator.trace_id()
|
||||
parent_span_id = TraceIdGenerator.span_id()
|
||||
child_span_id = TraceIdGenerator.span_id()
|
||||
|
||||
parent_event = TracesEvent(
|
||||
name="request_received",
|
||||
timestamp=now - timedelta(seconds=4, microseconds=500_000),
|
||||
attribute_map={"http.method": "GET"},
|
||||
)
|
||||
linked_trace_id = TraceIdGenerator.trace_id()
|
||||
linked_span_id = TraceIdGenerator.span_id()
|
||||
user_link = TracesLink(
|
||||
trace_id=linked_trace_id,
|
||||
span_id=linked_span_id,
|
||||
ref_type=TracesRefType.REF_TYPE_FOLLOWS_FROM,
|
||||
)
|
||||
|
||||
parent_trace = Traces(
|
||||
timestamp=now - timedelta(seconds=5),
|
||||
duration=timedelta(seconds=4),
|
||||
trace_id=trace_id,
|
||||
span_id=parent_span_id,
|
||||
parent_span_id="",
|
||||
name="parent-operation",
|
||||
kind=TracesKind.SPAN_KIND_SERVER,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "trace-operator-query"},
|
||||
attributes={"operation.type": "parent"},
|
||||
events=[parent_event],
|
||||
links=[user_link],
|
||||
)
|
||||
child_trace = Traces(
|
||||
timestamp=now - timedelta(seconds=4),
|
||||
duration=timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
span_id=child_span_id,
|
||||
parent_span_id=parent_span_id,
|
||||
name="child-operation",
|
||||
kind=TracesKind.SPAN_KIND_INTERNAL,
|
||||
status_code=TracesStatusCode.STATUS_CODE_OK,
|
||||
resources={"service.name": "trace-operator-query"},
|
||||
attributes={"operation.type": "child"},
|
||||
)
|
||||
insert_traces([parent_trace, child_trace])
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
operator_spec: dict[str, Any] = {
|
||||
"name": "C",
|
||||
"expression": "A => B",
|
||||
"limit": 10,
|
||||
"order": [{"key": {"name": "timestamp"}, "direction": "asc"}],
|
||||
}
|
||||
if select_fields:
|
||||
operator_spec["selectFields"] = select_fields
|
||||
|
||||
queries = [
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": "operation.type = 'parent'"},
|
||||
"limit": 100,
|
||||
"disabled": True,
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "B",
|
||||
"signal": "traces",
|
||||
"filter": {"expression": "operation.type = 'child'"},
|
||||
"limit": 100,
|
||||
"disabled": True,
|
||||
},
|
||||
},
|
||||
{"type": "builder_trace_operator", "spec": operator_spec},
|
||||
]
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=queries,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
results = response.json()["data"]["data"]["results"]
|
||||
trace_operator_result = find_named_result(results, "C")
|
||||
rows = trace_operator_result["rows"]
|
||||
verify_values(rows, parent_trace)
|
||||
|
||||
Reference in New Issue
Block a user