mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-18 16:00:32 +01:00
Compare commits
3 Commits
e2e/dashbo
...
issue_4361
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c71b16ff18 | ||
|
|
3c86f82e8f | ||
|
|
96b6c6caba |
2
go.mod
2
go.mod
@@ -23,6 +23,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/go-redis/redismock/v9 v9.2.0
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/gojek/heimdall/v7 v7.0.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
@@ -131,7 +132,6 @@ require (
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
||||
github.com/huandu/go-clone v1.7.3 // indirect
|
||||
|
||||
@@ -2,12 +2,17 @@ package implspanmapper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
var _ spanmapper.Module = (*module)(nil)
|
||||
|
||||
type module struct {
|
||||
store spantypes.SpanMapperStore
|
||||
}
|
||||
@@ -34,11 +39,22 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
|
||||
return err
|
||||
}
|
||||
group.Update(name, condition, enabled, updatedBy)
|
||||
return module.store.UpdateGroup(ctx, group)
|
||||
|
||||
err = module.store.UpdateGroup(ctx, group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
return module.store.DeleteGroup(ctx, orgID, id)
|
||||
err := module.store.DeleteGroup(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) ListMappers(ctx context.Context, orgID, groupID valuer.UUID) ([]*spantypes.SpanMapper, error) {
|
||||
@@ -54,7 +70,12 @@ func (module *module) CreateMapper(ctx context.Context, orgID, groupID valuer.UU
|
||||
if _, err := module.store.GetGroup(ctx, orgID, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
return module.store.CreateMapper(ctx, mapper)
|
||||
err := module.store.CreateMapper(ctx, mapper)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error {
|
||||
@@ -66,9 +87,72 @@ func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id value
|
||||
return err
|
||||
}
|
||||
mapper.Update(fieldContext, config, enabled, updatedBy)
|
||||
return module.store.UpdateMapper(ctx, mapper)
|
||||
err = module.store.UpdateMapper(ctx, mapper)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error {
|
||||
return module.store.DeleteMapper(ctx, orgID, groupID, id)
|
||||
err := module.store.DeleteMapper(ctx, orgID, groupID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentConf.NotifyConfigUpdate(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
|
||||
return spantypes.SpanAttrMappingFeatureType
|
||||
}
|
||||
|
||||
func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []byte, configVersion *opamptypes.AgentConfigVersion) ([]byte, string, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
enabled, err := module.listEnabledGroupsWithMappers(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
updatedConf, err := spantypes.GenerateCollectorConfigWithSpanMapperProcessor(currentConfYaml, enabled)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
serialized, err := json.Marshal(enabled)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return updatedConf, string(serialized), nil
|
||||
}
|
||||
|
||||
// listEnabledGroupsWithMappers returns groups with their mappers.
|
||||
func (module *module) listEnabledGroupsWithMappers(ctx context.Context, orgID valuer.UUID) ([]*spantypes.SpanMapperGroupWithMappers, error) {
|
||||
enabled := true
|
||||
groups, err := module.store.ListGroups(ctx, orgID, &spantypes.ListSpanMapperGroupsQuery{Enabled: &enabled})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]*spantypes.SpanMapperGroupWithMappers, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
mappers, err := module.store.ListMappers(ctx, orgID, g.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(mappers) == 0 {
|
||||
continue
|
||||
}
|
||||
enabledMappers := make([]*spantypes.SpanMapper, 0, len(mappers))
|
||||
for _, m := range mappers {
|
||||
if m.Enabled {
|
||||
enabledMappers = append(enabledMappers, m)
|
||||
}
|
||||
}
|
||||
out = append(out, &spantypes.SpanMapperGroupWithMappers{Group: g, Mappers: enabledMappers})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Module defines the business logic for span attribute mapping groups and mappers.
|
||||
type Module interface {
|
||||
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
|
||||
agentConf.AgentFeature
|
||||
|
||||
// Group operations
|
||||
ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error)
|
||||
GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes.SpanMapperGroup, error)
|
||||
|
||||
@@ -13,7 +13,9 @@ var (
|
||||
ErrCodeMappingGroupAlreadyExists = errors.MustNewCode("span_attribute_mapping_group_already_exists")
|
||||
)
|
||||
|
||||
// A group runs when any of the listed attribute/resource key patterns match.
|
||||
// SpanMapperGroupCondition gates whether a group's rules run for a given span.
|
||||
// A group runs when any attribute or resource key on the span CONTAINS one of
|
||||
// the listed substrings (plain substring match — no glob syntax).
|
||||
type SpanMapperGroupCondition struct {
|
||||
Attributes []string `json:"attributes" required:"true" nullable:"true"`
|
||||
Resource []string `json:"resource" required:"true" nullable:"true"`
|
||||
|
||||
144
pkg/types/spantypes/spanmapperprocessor.go
Normal file
144
pkg/types/spantypes/spanmapperprocessor.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
const (
|
||||
SpanAttrMappingFeatureType agentConf.AgentFeatureType = "span_attr_mapping"
|
||||
|
||||
ProcessorName = "signozspanmapper"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeInvalidCollectorConfig = errors.MustNewCode("invalid_collector_config")
|
||||
ErrCodeBuildMappingProcessorConfig = errors.MustNewCode("build_mapping_processor_config")
|
||||
)
|
||||
|
||||
type SpanMapperGroupWithMappers struct {
|
||||
Group *SpanMapperGroup `json:"group"`
|
||||
Mappers []*SpanMapper `json:"mappers"`
|
||||
}
|
||||
|
||||
// spanMapperProcessorConfig is the collector config for signozspanmapper.
|
||||
type spanMapperProcessorConfig struct {
|
||||
Groups []spanMapperProcessorGroup `yaml:"groups" json:"groups"`
|
||||
}
|
||||
|
||||
type spanMapperProcessorGroup struct {
|
||||
ID string `yaml:"id" json:"id"`
|
||||
ExistsAny spanMapperProcessorExistsAny `yaml:"exists_any" json:"exists_any"`
|
||||
Attributes []spanMapperProcessorAttribute `yaml:"attributes" json:"attributes"`
|
||||
}
|
||||
|
||||
type spanMapperProcessorExistsAny struct {
|
||||
Attributes []string `yaml:"attributes,omitempty" json:"attributes,omitempty"`
|
||||
Resource []string `yaml:"resource,omitempty" json:"resource,omitempty"`
|
||||
}
|
||||
|
||||
type spanMapperProcessorAttribute struct {
|
||||
Target string `yaml:"target" json:"target"`
|
||||
Context string `yaml:"context,omitempty" json:"context,omitempty"`
|
||||
Action string `yaml:"action,omitempty" json:"action,omitempty"`
|
||||
Sources []string `yaml:"sources" json:"sources"`
|
||||
}
|
||||
|
||||
func GenerateCollectorConfigWithSpanMapperProcessor(currentConfYaml []byte, groups []*SpanMapperGroupWithMappers) ([]byte, error) {
|
||||
var collectorConf map[string]any
|
||||
if err := yaml.Unmarshal(currentConfYaml, &collectorConf); err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeInvalidCollectorConfig, "failed to unmarshal collector config")
|
||||
}
|
||||
// rare but don't do anything in this case, also means it's just comments.
|
||||
if collectorConf == nil {
|
||||
collectorConf = map[string]any{}
|
||||
}
|
||||
|
||||
processors := map[string]any{}
|
||||
if existing, ok := collectorConf["processors"]; ok && existing != nil {
|
||||
p, ok := existing.(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCollectorConfig, "collector config 'processors' must be a mapping, got %T", existing)
|
||||
}
|
||||
processors = p
|
||||
}
|
||||
|
||||
procConfig := buildProcessorConfig(groups)
|
||||
configBytes, err := yaml.Marshal(procConfig)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "failed to marshal span mapper processor config")
|
||||
}
|
||||
var configMap any
|
||||
if err := yaml.Unmarshal(configBytes, &configMap); err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "failed to re-unmarshal span mapper processor config")
|
||||
}
|
||||
|
||||
processors[ProcessorName] = configMap
|
||||
collectorConf["processors"] = processors
|
||||
|
||||
out, err := yaml.Marshal(collectorConf)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "failed to marshal collector config")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildProcessorConfig(groups []*SpanMapperGroupWithMappers) *spanMapperProcessorConfig {
|
||||
out := make([]spanMapperProcessorGroup, 0, len(groups))
|
||||
|
||||
for _, gm := range groups {
|
||||
rules := make([]spanMapperProcessorAttribute, 0, len(gm.Mappers))
|
||||
for _, m := range gm.Mappers {
|
||||
rules = append(rules, buildAttributeRule(m))
|
||||
}
|
||||
|
||||
out = append(out, spanMapperProcessorGroup{
|
||||
ID: gm.Group.Name,
|
||||
ExistsAny: spanMapperProcessorExistsAny{
|
||||
Attributes: gm.Group.Condition.Attributes,
|
||||
Resource: gm.Group.Condition.Resource,
|
||||
},
|
||||
Attributes: rules,
|
||||
})
|
||||
}
|
||||
|
||||
return &spanMapperProcessorConfig{Groups: out}
|
||||
}
|
||||
|
||||
// buildAttributeRule maps a single SpanMapper to a collector attribute rule.
|
||||
// Sources are sorted by Priority DESC (highest-priority first), and read-from-
|
||||
// resource sources are encoded via the "resource." prefix.
|
||||
func buildAttributeRule(m *SpanMapper) spanMapperProcessorAttribute {
|
||||
sources := make([]SpanMapperSource, len(m.Config.Sources))
|
||||
copy(sources, m.Config.Sources)
|
||||
sort.SliceStable(sources, func(i, j int) bool { return sources[i].Priority > sources[j].Priority })
|
||||
|
||||
keys := make([]string, 0, len(sources))
|
||||
for _, s := range sources {
|
||||
if s.Context == FieldContextResource {
|
||||
keys = append(keys, FieldContextResource.StringValue()+"."+s.Key)
|
||||
} else {
|
||||
keys = append(keys, s.Key)
|
||||
}
|
||||
}
|
||||
|
||||
action := SpanMapperOperationCopy
|
||||
if len(sources) > 0 && sources[0].Operation == SpanMapperOperationMove {
|
||||
action = SpanMapperOperationMove
|
||||
}
|
||||
|
||||
ctx := FieldContextSpanAttribute
|
||||
if m.FieldContext == FieldContextResource {
|
||||
ctx = FieldContextResource
|
||||
}
|
||||
|
||||
return spanMapperProcessorAttribute{
|
||||
Target: m.Name,
|
||||
Context: ctx.StringValue(),
|
||||
Action: action.StringValue(),
|
||||
Sources: keys,
|
||||
}
|
||||
}
|
||||
193
pkg/types/spantypes/spanmapperprocessor_test.go
Normal file
193
pkg/types/spantypes/spanmapperprocessor_test.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package spantypes
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestGenerateCollectorConfigWithSpanMapperProcessor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
baseline := loadFixture(t, "collector_baseline.yaml")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
groups []*SpanMapperGroupWithMappers
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no_groups",
|
||||
want: "collector_no_groups.yaml",
|
||||
},
|
||||
{
|
||||
name: "with_groups",
|
||||
groups: []*SpanMapperGroupWithMappers{
|
||||
{
|
||||
Group: newGroup("llm", []string{"model"}, []string{"service.name"}),
|
||||
Mappers: []*SpanMapper{
|
||||
newMapper("gen_ai.request.model", FieldContextResource,
|
||||
attrSrc("gen_ai.llm.model", SpanMapperOperationCopy, 3),
|
||||
attrSrc("llm.model", SpanMapperOperationCopy, 2),
|
||||
resSrc("service.name", SpanMapperOperationCopy, 1),
|
||||
),
|
||||
newMapper("gen_ai.request.tokens", FieldContextSpanAttribute,
|
||||
attrSrc("gen_ai.request_tokens", SpanMapperOperationCopy, 2),
|
||||
attrSrc("llm.tokens", SpanMapperOperationCopy, 1),
|
||||
),
|
||||
newMapper("gen_ai.request.input", FieldContextSpanAttribute,
|
||||
attrSrc("gen_ai.input", SpanMapperOperationMove, 2),
|
||||
attrSrc("llm.input", SpanMapperOperationMove, 1),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
Group: newGroup("agent", []string{"agent."}, nil),
|
||||
Mappers: []*SpanMapper{
|
||||
newMapper("gen_ai.agent.name", FieldContextSpanAttribute,
|
||||
attrSrc("agent.name", SpanMapperOperationCopy, 2),
|
||||
attrSrc("llm.agent.name", SpanMapperOperationCopy, 1),
|
||||
),
|
||||
newMapper("gen_ai.agent.id", FieldContextSpanAttribute,
|
||||
attrSrc("gen_ai.agent.id", SpanMapperOperationCopy, 2),
|
||||
attrSrc("llm.agent.id", SpanMapperOperationCopy, 1),
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
Group: newGroup("tool", []string{"agent."}, nil),
|
||||
Mappers: []*SpanMapper{
|
||||
newMapper("gen_ai.tool.name", FieldContextSpanAttribute,
|
||||
attrSrc("ai.tool.name", SpanMapperOperationCopy, 2),
|
||||
attrSrc("llm.tool.name", SpanMapperOperationCopy, 1),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
want: "collector_with_groups.yaml",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := GenerateCollectorConfigWithSpanMapperProcessor(baseline, tc.groups)
|
||||
require.NoError(t, err)
|
||||
assertYAMLEqual(t, loadFixture(t, tc.want), got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCollectorConfigWithSpanMapperProcessor_Errors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in []byte
|
||||
}{
|
||||
{"processors_not_a_map", []byte("processors: not-a-map\n")},
|
||||
{"malformed_yaml", []byte(": :")},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := GenerateCollectorConfigWithSpanMapperProcessor(tc.in, nil)
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "want TypeInvalidInput, got %v", err)
|
||||
assert.True(t, errors.Asc(err, ErrCodeInvalidCollectorConfig), "want ErrCodeInvalidCollectorConfig, got %v", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAttributeRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mapper *SpanMapper
|
||||
want spanMapperProcessorAttribute
|
||||
}{
|
||||
{
|
||||
name: "priority_sort_and_resource_prefix",
|
||||
mapper: newMapper("gen_ai.request.model", FieldContextResource,
|
||||
attrSrc("llm.model", SpanMapperOperationCopy, 20),
|
||||
resSrc("service.name", SpanMapperOperationCopy, 10),
|
||||
attrSrc("gen_ai.llm.model", SpanMapperOperationCopy, 30),
|
||||
),
|
||||
want: spanMapperProcessorAttribute{
|
||||
Target: "gen_ai.request.model",
|
||||
Context: FieldContextResource.StringValue(),
|
||||
Action: SpanMapperOperationCopy.StringValue(),
|
||||
Sources: []string{"gen_ai.llm.model", "llm.model", "resource.service.name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move_action_follows_highest_priority_source",
|
||||
mapper: newMapper("gen_ai.request.input", FieldContextSpanAttribute,
|
||||
attrSrc("gen_ai.input", SpanMapperOperationMove, 20),
|
||||
attrSrc("llm.input", SpanMapperOperationMove, 10),
|
||||
),
|
||||
want: spanMapperProcessorAttribute{
|
||||
Target: "gen_ai.request.input",
|
||||
Context: FieldContextSpanAttribute.StringValue(),
|
||||
Action: SpanMapperOperationMove.StringValue(),
|
||||
Sources: []string{"gen_ai.input", "llm.input"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tc.want, buildAttributeRule(tc.mapper))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func loadFixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
// assertYAMLEqual compares two YAML documents structurally so key order and
|
||||
// slice formatting do not matter.
|
||||
func assertYAMLEqual(t *testing.T, want, got []byte) {
|
||||
t.Helper()
|
||||
var w, g any
|
||||
require.NoError(t, yaml.Unmarshal(want, &w))
|
||||
require.NoError(t, yaml.Unmarshal(got, &g))
|
||||
assert.Equal(t, w, g)
|
||||
}
|
||||
|
||||
func newGroup(name string, attrs, res []string) *SpanMapperGroup {
|
||||
return &SpanMapperGroup{
|
||||
Name: name,
|
||||
Condition: SpanMapperGroupCondition{Attributes: attrs, Resource: res},
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func newMapper(name string, target FieldContext, sources ...SpanMapperSource) *SpanMapper {
|
||||
return &SpanMapper{
|
||||
Name: name,
|
||||
FieldContext: target,
|
||||
Config: SpanMapperConfig{Sources: sources},
|
||||
Enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func attrSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
|
||||
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority}
|
||||
}
|
||||
|
||||
func resSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
|
||||
return SpanMapperSource{Key: key, Context: FieldContextResource, Operation: op, Priority: priority}
|
||||
}
|
||||
17
pkg/types/spantypes/testdata/collector_baseline.yaml
vendored
Normal file
17
pkg/types/spantypes/testdata/collector_baseline.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
processors:
|
||||
signozspanmapper:
|
||||
groups: []
|
||||
batch: {}
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmapper, batch]
|
||||
exporters: [otlp]
|
||||
17
pkg/types/spantypes/testdata/collector_no_groups.yaml
vendored
Normal file
17
pkg/types/spantypes/testdata/collector_no_groups.yaml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
processors:
|
||||
signozspanmapper:
|
||||
groups: []
|
||||
batch: {}
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmapper, batch]
|
||||
exporters: [otlp]
|
||||
71
pkg/types/spantypes/testdata/collector_with_groups.yaml
vendored
Normal file
71
pkg/types/spantypes/testdata/collector_with_groups.yaml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
processors:
|
||||
signozspanmapper:
|
||||
groups:
|
||||
- id: llm
|
||||
exists_any:
|
||||
attributes:
|
||||
- model
|
||||
resource:
|
||||
- service.name
|
||||
attributes:
|
||||
- target: gen_ai.request.model
|
||||
context: resource
|
||||
action: copy
|
||||
sources:
|
||||
- gen_ai.llm.model
|
||||
- llm.model
|
||||
- resource.service.name
|
||||
- target: gen_ai.request.tokens
|
||||
context: attribute
|
||||
action: copy
|
||||
sources:
|
||||
- gen_ai.request_tokens
|
||||
- llm.tokens
|
||||
- target: gen_ai.request.input
|
||||
context: attribute
|
||||
action: move
|
||||
sources:
|
||||
- gen_ai.input
|
||||
- llm.input
|
||||
- id: agent
|
||||
exists_any:
|
||||
attributes:
|
||||
- agent.
|
||||
attributes:
|
||||
- target: gen_ai.agent.name
|
||||
context: attribute
|
||||
action: copy
|
||||
sources:
|
||||
- agent.name
|
||||
- llm.agent.name
|
||||
- target: gen_ai.agent.id
|
||||
context: attribute
|
||||
action: copy
|
||||
sources:
|
||||
- gen_ai.agent.id
|
||||
- llm.agent.id
|
||||
- id: tool
|
||||
exists_any:
|
||||
attributes:
|
||||
- agent.
|
||||
attributes:
|
||||
- target: gen_ai.tool.name
|
||||
context: attribute
|
||||
action: copy
|
||||
sources:
|
||||
- ai.tool.name
|
||||
- llm.tool.name
|
||||
batch: {}
|
||||
exporters:
|
||||
otlp:
|
||||
endpoint: localhost:4317
|
||||
service:
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmapper, batch]
|
||||
exporters: [otlp]
|
||||
@@ -3,10 +3,6 @@ import path from 'path';
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
|
||||
import apmMetricsTemplate from '../testdata/apm-metrics.json';
|
||||
import queriesData from '../testdata/queries.json';
|
||||
|
||||
export type SignalType = 'metrics' | 'logs' | 'traces';
|
||||
export type QueriesData = typeof queriesData;
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -181,145 +177,3 @@ export async function openDashboardActionMenu(
|
||||
await icon.click();
|
||||
return page.getByRole('tooltip');
|
||||
}
|
||||
|
||||
// ─── Dashboard detail page helpers ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Click the Configure button (`data-testid="show-drawer"`) on a dashboard
|
||||
* detail page and wait for the settings drawer (`.settings-container-root`) to
|
||||
* be visible. Works from both the empty-state view and the populated toolbar —
|
||||
* both render the same testid.
|
||||
*
|
||||
* Returns the drawer locator so callers can scope further assertions to it.
|
||||
*/
|
||||
export async function openDashboardSettingsDrawer(page: Page): Promise<Locator> {
|
||||
await page.getByTestId('show-drawer').first().click();
|
||||
const drawer = page.locator('.settings-container-root');
|
||||
await drawer.waitFor({ state: 'visible' });
|
||||
return drawer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click `data-testid="save-dashboard-config"` and wait for the resulting
|
||||
* `PUT /api/v1/dashboards/<id>` response. The Save button is only rendered
|
||||
* when there is at least one unsaved change — callers must ensure the drawer
|
||||
* has been dirtied before calling this.
|
||||
*/
|
||||
export async function saveDashboardSettings(page: Page): Promise<void> {
|
||||
const patchResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('save-dashboard-config').click();
|
||||
await patchResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a dashboard via the toolbar options popover:
|
||||
* opens the popover (`data-testid="options"`), clicks "Rename", fills the
|
||||
* input, clicks "Rename Dashboard", and waits for the PUT response.
|
||||
*
|
||||
* Pre-condition: the caller must be on the dashboard detail page.
|
||||
*/
|
||||
export async function renameDashboardViaToolbar(
|
||||
page: Page,
|
||||
newTitle: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId('options').click();
|
||||
await page.getByRole('button', { name: 'Rename' }).click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await modal.waitFor({ state: 'visible' });
|
||||
|
||||
const input = modal.getByTestId('dashboard-name');
|
||||
await input.clear();
|
||||
await input.fill(newTitle);
|
||||
|
||||
const patchResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByRole('button', { name: 'Rename Dashboard' }).click();
|
||||
await patchResponse;
|
||||
|
||||
await modal.waitFor({ state: 'hidden' });
|
||||
}
|
||||
|
||||
// ─── Add panel flow ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* From the dashboard detail page (must already be loaded), drive the full
|
||||
* "Add Panel" flow for the given signal type:
|
||||
* 1. Click the empty-state `add-panel` CTA to open the New Panel modal.
|
||||
* 2. Pick the Time Series panel type.
|
||||
* 3. Fill the panel name in the right pane (drives the post-save assertion).
|
||||
* 4. For metrics: type the metric name from `queries.json` into the metric
|
||||
* AutoComplete and select it from the dropdown. For logs/traces: switch
|
||||
* the data-source selector to LOGS / TRACES; default Query Builder state
|
||||
* is sufficient (queries.json query strings are empty by design).
|
||||
* 5. Click Save Changes, confirm the modal, and wait for the
|
||||
* PUT /api/v1/dashboards/<id> response.
|
||||
*
|
||||
* Throws if the PUT response is not 2xx. After return, the page is back on
|
||||
* the dashboard detail page; the caller asserts the panel rendered.
|
||||
*/
|
||||
export async function configureAndSavePanel(
|
||||
page: Page,
|
||||
signal: SignalType,
|
||||
panelTitle: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId('add-panel').click();
|
||||
|
||||
const newPanelModal = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: 'New Panel' });
|
||||
await newPanelModal.waitFor({ state: 'visible' });
|
||||
await newPanelModal.getByTestId('panel-type-graph').click();
|
||||
|
||||
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
|
||||
await page.getByTestId('panel-name-input').fill(panelTitle);
|
||||
|
||||
if (signal === 'metrics') {
|
||||
const metricName = queriesData.metrics.metricName;
|
||||
// The testid is on the Ant Select wrapper <div>; the editable input
|
||||
// lives inside it. Target the descendant input for fill().
|
||||
const metricInput = page.getByTestId('metric-name-selector-0').locator('input');
|
||||
await metricInput.click();
|
||||
await metricInput.fill(metricName);
|
||||
// AutoComplete debounces and fetches; wait for the option then click.
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', { hasText: metricName })
|
||||
.first()
|
||||
.click();
|
||||
} else {
|
||||
// logs / traces — switch the data source. Default query is sufficient.
|
||||
await page.getByTestId('query-data-source-selector-0').click();
|
||||
await page
|
||||
.locator('.ant-select-item-option-content', {
|
||||
hasText: signal.toUpperCase(),
|
||||
})
|
||||
.click();
|
||||
}
|
||||
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
|
||||
// Confirmation modal (title varies: "Save Widget" vs "Unsaved Changes" —
|
||||
// don't assert title, just click OK on the topmost dialog).
|
||||
const confirmModal = page.getByRole('dialog').last();
|
||||
await confirmModal.waitFor({ state: 'visible' });
|
||||
await confirmModal.getByRole('button', { name: /^OK$/i }).click();
|
||||
|
||||
const res = await putResponse;
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Save navigates back to /dashboard/<id> (no /new suffix).
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
|
||||
}
|
||||
|
||||
12
tests/e2e/testdata/queries.json
vendored
12
tests/e2e/testdata/queries.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"logs": {
|
||||
"query": ""
|
||||
},
|
||||
"metrics": {
|
||||
"metricName": "signoz_calls_total",
|
||||
"query": ""
|
||||
},
|
||||
"traces": {
|
||||
"query": ""
|
||||
}
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
import path from 'path';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import {
|
||||
APM_METRICS_TITLE,
|
||||
authToken,
|
||||
configureAndSavePanel,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
gotoDashboardsList,
|
||||
openDashboardSettingsDrawer,
|
||||
renameDashboardViaToolbar,
|
||||
saveDashboardSettings,
|
||||
SEARCH_PLACEHOLDER,
|
||||
} from '../../helpers/dashboards';
|
||||
|
||||
// All tests mutate dashboard state (create / rename / delete). Run serially to
|
||||
// prevent cross-test interference on the list and detail pages.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// ─── Suite-level seed registry ────────────────────────────────────────────────
|
||||
//
|
||||
// Every dashboard created by any test is registered here; one afterAll tears
|
||||
// them all down. Tests that don't create anything (TC-10, TC-11, TC-13) need
|
||||
// no cleanup entry.
|
||||
const seedIds = new Set<string>();
|
||||
const BASE_FIXTURE_TITLE = 'create-flow-base-fixture';
|
||||
|
||||
const APM_METRICS_TESTDATA_PATH = path.resolve(
|
||||
__dirname,
|
||||
'../../testdata/apm-metrics.json',
|
||||
);
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// Seed one base dashboard so the list is non-empty and the
|
||||
// `new-dashboard-cta` header button is rendered for all tests that
|
||||
// drive the "New dashboard" dropdown from the list page.
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
seedIds.add(await createDashboardViaApi(page, BASE_FIXTURE_TITLE));
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of [...seedIds]) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard Create Flow', () => {
|
||||
// ─── 1. Create Dashboard (blank) ─────────────────────────────────────────
|
||||
|
||||
test('TC-01 blank create lands on onboarding state with correct default title', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
|
||||
const postResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('create-dashboard-menu-cta').click();
|
||||
const res = await postResponse;
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
expect(res.status()).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status()).toBeLessThan(300);
|
||||
const body = (await res.json()) as {
|
||||
data: { data: { title: string }; id: string };
|
||||
};
|
||||
expect(body.data.data.title).toBe('Sample Title');
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
// DashboardDescription always renders dashboard-title even on blank dashboards.
|
||||
await expect(page.getByTestId('dashboard-title')).toBeVisible();
|
||||
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
|
||||
await expect(page.getByText('Configure your new dashboard')).toBeVisible();
|
||||
await expect(page.getByTestId('show-drawer').first()).toBeVisible();
|
||||
await expect(page.getByTestId('add-panel')).toBeVisible();
|
||||
|
||||
// Register the UI-created dashboard for cleanup.
|
||||
const id = body.data.id;
|
||||
expect(id, 'POST response must include a dashboard id').toBeTruthy();
|
||||
seedIds.add(id);
|
||||
});
|
||||
|
||||
test('TC-02 configure drawer opens with Overview tab and pre-fills existing title', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc02');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const drawer = await openDashboardSettingsDrawer(page);
|
||||
|
||||
// Overview tab is the default active tab.
|
||||
await expect(drawer.getByRole('button', { name: 'Overview' })).toBeVisible();
|
||||
|
||||
const nameInput = drawer.getByTestId('dashboard-name');
|
||||
await expect(nameInput).toHaveValue('create-flow-tc02');
|
||||
|
||||
const descInput = drawer.getByTestId('dashboard-desc');
|
||||
await expect(descInput).toBeVisible();
|
||||
await expect(descInput).toHaveValue('');
|
||||
|
||||
await expect(
|
||||
drawer.getByPlaceholder('Start typing your tag name'),
|
||||
).toBeVisible();
|
||||
|
||||
// Ant Drawer does not close on Escape — use the X close button in the header.
|
||||
await drawer.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(drawer).not.toHaveClass(/ant-drawer-open/);
|
||||
});
|
||||
|
||||
test('TC-03 rename title, add description and tags, save persists to list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc03-original');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const drawer = await openDashboardSettingsDrawer(page);
|
||||
|
||||
const nameInput = drawer.getByTestId('dashboard-name');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill('create-flow-tc03-renamed');
|
||||
await expect(drawer.getByText(/1 unsaved change/)).toBeVisible();
|
||||
|
||||
await drawer.getByTestId('dashboard-desc').fill('A test description');
|
||||
await expect(drawer.getByText(/2 unsaved changes/)).toBeVisible();
|
||||
|
||||
const tagInput = drawer.getByPlaceholder('Start typing your tag name');
|
||||
await tagInput.click();
|
||||
await tagInput.fill('e2e-tag');
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(drawer.getByText(/3 unsaved changes/)).toBeVisible();
|
||||
|
||||
// Click save and wait for the unsaved-changes footer to disappear — the
|
||||
// footer only clears after the PUT success callback re-syncs local state.
|
||||
await page.getByTestId('save-dashboard-config').click();
|
||||
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
|
||||
|
||||
await drawer.getByRole('button', { name: 'Close' }).click();
|
||||
|
||||
// Renamed dashboard appears in the list.
|
||||
await gotoDashboardsList(page);
|
||||
const searchInput = page.getByPlaceholder(SEARCH_PLACEHOLDER);
|
||||
await searchInput.fill('create-flow-tc03-renamed');
|
||||
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
|
||||
|
||||
// Tag search also surfaces the renamed dashboard.
|
||||
await searchInput.fill('e2e-tag');
|
||||
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 discard reverts unsaved changes without API call', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc04');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const drawer = await openDashboardSettingsDrawer(page);
|
||||
|
||||
const nameInput = drawer.getByTestId('dashboard-name');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill('create-flow-tc04-discarded');
|
||||
await drawer.getByTestId('dashboard-desc').fill('discarded desc');
|
||||
await expect(drawer.getByText(/unsaved change/)).toBeVisible();
|
||||
|
||||
// Intercept any PUT to detect an unwanted save.
|
||||
let patchFired = false;
|
||||
await page.route(/\/api\/v1\/dashboards\//, (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
patchFired = true;
|
||||
}
|
||||
route.continue();
|
||||
});
|
||||
|
||||
await drawer.getByRole('button', { name: 'Discard' }).click();
|
||||
|
||||
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
|
||||
await expect(nameInput).toHaveValue('create-flow-tc04');
|
||||
await expect(drawer.getByTestId('dashboard-desc')).toHaveValue('');
|
||||
expect(patchFired).toBe(false);
|
||||
});
|
||||
|
||||
test('TC-05 rename via toolbar options popover persists to the toolbar title', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc05');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// DashboardDescription toolbar always renders — even on blank dashboards.
|
||||
await expect(page.getByTestId('options')).toBeVisible();
|
||||
|
||||
await renameDashboardViaToolbar(page, 'create-flow-tc05-renamed');
|
||||
|
||||
await expect(page.getByTestId('dashboard-title')).toHaveText(
|
||||
'create-flow-tc05-renamed',
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 2. Variables ─────────────────────────────────────────────────────────
|
||||
|
||||
test('TC-06 add a Custom variable, verify it appears in the variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc06');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const drawer = await openDashboardSettingsDrawer(page);
|
||||
await drawer.getByRole('button', { name: 'Variables' }).click();
|
||||
|
||||
await drawer.getByTestId('add-new-variable').click();
|
||||
await expect(drawer.getByRole('button', { name: 'All variables' })).toBeVisible();
|
||||
|
||||
await drawer
|
||||
.getByPlaceholder('Unique name of the variable')
|
||||
.fill('env');
|
||||
|
||||
await drawer.getByRole('button', { name: 'Custom' }).click();
|
||||
|
||||
// After selecting "Custom" type, the Options collapse panel contains a
|
||||
// textarea with placeholder "Enter options separated by commas."
|
||||
const customInput = drawer.getByPlaceholder(
|
||||
'Enter options separated by commas.',
|
||||
);
|
||||
await customInput.fill('prod,staging,dev');
|
||||
|
||||
const patchResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await drawer.getByRole('button', { name: 'Save Variable' }).click();
|
||||
const res = await patchResponse;
|
||||
expect(res.status()).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status()).toBeLessThan(300);
|
||||
|
||||
// After saving, the variable form disappears and the table row is visible.
|
||||
await expect(drawer.getByRole('button', { name: 'All variables' })).not.toBeVisible();
|
||||
await expect(drawer.getByText('env')).toBeVisible();
|
||||
|
||||
// Close the drawer via its X button and check the variables bar.
|
||||
await drawer.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(page.locator('.dashboard-variables')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-07 duplicate variable name is rejected inline', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Seed a dashboard that already has a variable named 'env'.
|
||||
const id = await seed(page, 'create-flow-tc07');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// Use the UI to add the first variable so the state is real.
|
||||
const drawer = await openDashboardSettingsDrawer(page);
|
||||
await drawer.getByRole('button', { name: 'Variables' }).click();
|
||||
await drawer.getByTestId('add-new-variable').click();
|
||||
await drawer.getByPlaceholder('Unique name of the variable').fill('env');
|
||||
await drawer.getByRole('button', { name: 'Custom' }).click();
|
||||
await drawer
|
||||
.getByPlaceholder('Enter options separated by commas.')
|
||||
.fill('prod');
|
||||
const firstSave = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await drawer.getByRole('button', { name: 'Save Variable' }).click();
|
||||
await firstSave;
|
||||
|
||||
// Now try to add a second variable with the same name.
|
||||
await drawer.getByTestId('add-new-variable').click();
|
||||
const nameInput = drawer.getByPlaceholder('Unique name of the variable');
|
||||
await nameInput.fill('env');
|
||||
|
||||
await expect(
|
||||
drawer.getByText('Variable name already exists'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
drawer.getByRole('button', { name: 'Save Variable' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
// ─── 3. Import JSON ───────────────────────────────────────────────────────
|
||||
//
|
||||
// TC-08 and TC-12 are merged: TC-08 covers the POST contract and navigation;
|
||||
// the merged test also navigates back to the list and verifies metadata
|
||||
// surfacing (the TC-12 concern). This avoids two identical import flows.
|
||||
|
||||
test('TC-08 import via file upload creates dashboard, navigates to detail, and surfaces metadata in list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
const postResponse = page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
|
||||
);
|
||||
await dialog.locator('input[type="file"]').setInputFiles(APM_METRICS_TESTDATA_PATH);
|
||||
await dialog.getByRole('button', { name: 'Import and Next' }).click();
|
||||
const res = await postResponse;
|
||||
|
||||
expect(res.status()).toBeGreaterThanOrEqual(200);
|
||||
expect(res.status()).toBeLessThan(300);
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
// Register for cleanup.
|
||||
const urlMatch = page.url().match(/\/dashboard\/([0-9a-f-]+)/);
|
||||
expect(urlMatch, 'URL must contain dashboard ID').not.toBeNull();
|
||||
seedIds.add(urlMatch![1]);
|
||||
|
||||
await expect(page.getByTestId('dashboard-title')).toHaveText(APM_METRICS_TITLE);
|
||||
|
||||
// Navigate back and confirm the imported dashboard surfaces in the list
|
||||
// with at least one tag chip (TC-12 coverage).
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(APM_METRICS_TITLE);
|
||||
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
|
||||
// The apm-metrics fixture has tags ['apm', 'latency', 'error rate', 'throughput'].
|
||||
await expect(page.getByText('apm').first()).toBeVisible();
|
||||
});
|
||||
|
||||
// TC-09 (Monaco paste path) is intentionally dropped — the file-upload
|
||||
// path (TC-08) exercises the same populate-editor-then-import code path.
|
||||
// Keyboard-typing large JSON into Monaco is unreliable in headless CI.
|
||||
|
||||
test('TC-10 invalid JSON via file upload shows "Invalid JSON" error', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// No dashboard is created by this test — no cleanup entry needed.
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: 'bad.json',
|
||||
mimeType: 'application/json',
|
||||
buffer: Buffer.from('not valid json {'),
|
||||
});
|
||||
|
||||
await expect(dialog.getByText('Invalid JSON')).toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Clicking "Import and Next" with invalid content should surface an error
|
||||
// and keep the dialog open.
|
||||
await dialog.getByRole('button', { name: 'Import and Next' }).click();
|
||||
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(dialog).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-11 import with empty editor clicking Import and Next shows error', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// No dashboard is created — no cleanup entry needed.
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
await page.getByTestId('import-json-menu-cta').click();
|
||||
|
||||
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Import and Next' }).click();
|
||||
|
||||
await expect(dialog.getByText('Error loading JSON file')).toBeVisible();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
});
|
||||
|
||||
// ─── 4. View Templates ────────────────────────────────────────────────────
|
||||
|
||||
test('TC-13 View templates menu item is an external link targeting a new tab', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// No dashboard is created — no cleanup entry needed.
|
||||
// The assertion guards against the link being silently changed to an
|
||||
// in-app modal or a different URL (the DashboardTemplatesModal exists in
|
||||
// source but is never triggered from this menu item).
|
||||
await gotoDashboardsList(page);
|
||||
await page.getByTestId('new-dashboard-cta').click();
|
||||
|
||||
const link = page.getByTestId('view-templates-menu-cta');
|
||||
await expect(link).toBeVisible();
|
||||
|
||||
await expect(link).toHaveAttribute(
|
||||
'href',
|
||||
/signoz\.io\/docs\/dashboards\/dashboard-templates/,
|
||||
);
|
||||
await expect(link).toHaveAttribute('target', '_blank');
|
||||
await expect(link).toHaveAttribute('rel', /noopener/);
|
||||
});
|
||||
|
||||
// ─── 5. Post-Create Dashboard Detail — Panel Addition ────────────────────
|
||||
|
||||
test('TC-14 New Panel modal opens and selecting Time Series navigates to widget editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc14');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
|
||||
|
||||
await page.getByTestId('add-panel').click();
|
||||
// PANEL_TYPES enum: TIME_SERIES='graph', VALUE='value', TABLE='table'
|
||||
// — the testid is panel-type-<enum-value>, not panel-type-<enum-name>.
|
||||
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
|
||||
await expect(modal.getByTestId('panel-type-value')).toBeVisible();
|
||||
await expect(modal.getByTestId('panel-type-table')).toBeVisible();
|
||||
|
||||
await modal.getByTestId('panel-type-graph').click();
|
||||
await expect(page).toHaveURL(/graphType=graph/);
|
||||
});
|
||||
|
||||
test('TC-15 New Panel button from toolbar header opens the same panel type modal', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc15');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// The toolbar "New Panel" button (add-panel-header) is present even on
|
||||
// a blank dashboard, alongside the empty-state "add-panel" button.
|
||||
await expect(page.getByTestId('add-panel-header')).toBeVisible();
|
||||
await page.getByTestId('add-panel-header').click();
|
||||
|
||||
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
|
||||
|
||||
// Click the modal X button to close (Escape also works but may conflict
|
||||
// with the Enterprise modal in the background; explicit click is more reliable).
|
||||
await modal.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── 6. Cancellation and Navigation Away ─────────────────────────────────
|
||||
|
||||
test('TC-16 browser Back from dashboard detail returns to list with URL preserved', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc16');
|
||||
|
||||
await page.goto(`/dashboard?search=create-flow-tc16`);
|
||||
await page
|
||||
.getByRole('heading', { name: 'Dashboards', level: 1 })
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
await page.getByAltText('dashboard-image').first().click();
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(/search=create-flow-tc16/);
|
||||
await expect(
|
||||
page.getByPlaceholder(SEARCH_PLACEHOLDER),
|
||||
).toHaveValue('create-flow-tc16');
|
||||
});
|
||||
|
||||
test('TC-17 navigating away with the settings drawer open does not crash', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'create-flow-tc17');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openDashboardSettingsDrawer(page);
|
||||
|
||||
// Navigate away without closing the drawer.
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
// No error overlay should be present.
|
||||
await expect(
|
||||
page.getByRole('alert').filter({ hasText: /error/i }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── 7. Add Panel — end-to-end per signal ────────────────────────────────
|
||||
//
|
||||
// TC-14/TC-15 verify the New Panel modal opens and routes to the widget
|
||||
// editor. The TCs below go further: configure a query for each signal
|
||||
// using values from testdata/queries.json, save the panel, return to the
|
||||
// dashboard, and verify the panel card renders.
|
||||
|
||||
test('TC-18 add metrics Time Series panel using signoz_calls_total from queries.json', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'add-panel-metrics');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByTestId('add-panel')).toBeVisible();
|
||||
|
||||
await configureAndSavePanel(page, 'metrics', 'metrics-timeseries');
|
||||
|
||||
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-19 add logs Time Series panel with default query from queries.json', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'add-panel-logs');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByTestId('add-panel')).toBeVisible();
|
||||
|
||||
await configureAndSavePanel(page, 'logs', 'logs-timeseries');
|
||||
|
||||
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-20 add traces Time Series panel with default query from queries.json', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'add-panel-traces');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByTestId('add-panel')).toBeVisible();
|
||||
|
||||
await configureAndSavePanel(page, 'traces', 'traces-timeseries');
|
||||
|
||||
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user