Compare commits

..

5 Commits

Author SHA1 Message Date
Piyush Singariya
40956116dc fix: qbtoexpr migrated to v5 2026-02-09 20:24:32 +05:30
Piyush Singariya
b49a95b7b3 Merge branch 'main' into lp-filter-v5 2026-02-09 13:06:54 +05:30
Abhi kumar
e5867cc2ad chore: updated chart theme colors (#10233)
* chore: updated chart theme colors

* fix: fixed failing tests
2026-02-09 12:25:36 +05:30
Piyush Singariya
ae19bb2be2 fix: filter expression 2026-02-05 18:10:45 +05:30
Piyush Singariya
749f52ff9d chore: moving from v3 to v5 2026-02-05 17:48:57 +05:30
20 changed files with 1520 additions and 2367 deletions

View File

@@ -34,159 +34,230 @@ const themeColors = {
cyan: '#00FFFF',
},
chartcolors: {
radicalRed: '#FF1A66',
// Blues (3)
dodgerBlue: '#2F80ED',
mediumOrchid: '#BB6BD9',
seaBuckthorn: '#F2994A',
seaGreen: '#219653',
turquoiseBlue: '#56CCF2',
festivalOrange: '#F2C94C',
silver: '#BDBDBD',
outrageousOrange: '#FF6633',
roseBud: '#FFB399',
canary: '#FFFF99',
deepSkyBlue: '#00B3E6',
goldTips: '#E6B333',
royalBlue: '#3366E6',
avocado: '#999966',
mintGreen: '#99FF99',
chestnut: '#B34D4D',
lima: '#80B300',
olive: '#809900',
beautyBush: '#E6B3B3',
danube: '#6680B3',
oliveDrab: '#66991A',
lavenderRose: '#FF99E6',
electricLime: '#CCFF1A',
robin: '#3F5ECC',
harleyOrange: '#E6331A',
turquoise: '#33FFCC',
gladeGreen: '#66994D',
hemlock: '#66664D',
vidaLoca: '#4D8000',
rust: '#B33300',
red: '#FF0000', // Adding more colors, we need to get better colors from design team
blue: '#0000FF',
green: '#00FF00',
yellow: '#FFFF00',
purple: '#800080',
cyan: '#00FFFF',
magenta: '#FF00FF',
orange: '#FFA500',
pink: '#FFC0CB',
brown: '#A52A2A',
teal: '#008080',
lime: '#00FF00',
maroon: '#800000',
navy: '#000080',
aquamarine: '#7FFFD4',
darkSeaGreen: '#8FBC8F',
gray: '#808080',
skyBlue: '#87CEEB',
indigo: '#4B0082',
slateGray: '#708090',
chocolate: '#D2691E',
tomato: '#FF6347',
steelBlue: '#4682B4',
peru: '#CD853F',
darkOliveGreen: '#556B2F',
indianRed: '#CD5C5C',
mediumSlateBlue: '#7B68EE',
rosyBrown: '#BC8F8F',
darkSlateGray: '#2F4F4F',
mediumAquamarine: '#66CDAA',
lavender: '#E6E6FA',
thistle: '#D8BFD8',
salmon: '#FA8072',
darkSalmon: '#E9967A',
paleVioletRed: '#DB7093',
mediumPurple: '#9370DB',
darkOrchid: '#9932CC',
lawnGreen: '#7CFC00',
// Teals / Cyans (3)
turquoise: '#00CEC9',
lagoon: '#1ABC9C',
cyanBright: '#22A6F2',
// Greens (3)
emeraldGreen: '#27AE60',
mediumSeaGreen: '#3CB371',
lightCoral: '#F08080',
gold: '#FFD700',
sandyBrown: '#F4A460',
darkKhaki: '#BDB76B',
cornflowerBlue: '#6495ED',
mediumVioletRed: '#C71585',
paleGreen: '#98FB98',
limeGreen: '#A3E635',
// Yellows / Golds (3)
festivalYellow: '#F2C94C',
sunflower: '#FFD93D',
warmAmber: '#FFCA28',
// Oranges (3)
festivalOrange: '#F2994A',
coralOrange: '#E17055',
pumpkin: '#FF7F50',
// Reds (3)
radicalRed: '#FF1A66',
crimsonRed: '#EB5757',
fireRed: '#E10600',
// Pinks (3)
hotPink: '#E84393',
rosePink: '#FD79A8',
blush: '#FF7EB6',
// Purples / Violets (3)
mediumPurple: '#BB6BD9',
royalPurple: '#9B51E0',
orchid: '#DA77F2',
// Accent / Neon / Unique Colors (3)
neonViolet: '#C77DFF',
electricPurple: '#6C5CE7',
arcticBlue: '#48DBFB',
// Extended palette — systematic variations to reach 100 colors
blue1: '#1F63E0',
blue2: '#3A7AED',
blue3: '#5A9DF5',
cyan1: '#00B0AA',
cyan2: '#33D6C2',
cyan3: '#66E9DA',
green1: '#1E8449',
green2: '#2ECC71',
green3: '#58D68D',
yellow1: '#F1C40F',
yellow2: '#F7DC6F',
yellow3: '#F9E79F',
orange1: '#D35400',
orange2: '#E67E22',
orange3: '#F5B041',
red1: '#C0392B',
red2: '#E74C3C',
red3: '#EC7063',
pink1: '#D81B60',
pink2: '#E91E63',
pink3: '#F06292',
purple1: '#8E44AD',
purple2: '#9B59B6',
purple3: '#BB8FCE',
teal1: '#009688',
teal2: '#1ABC9C',
teal3: '#48C9B0',
lime1: '#A3E635',
lime2: '#B9F18D',
lime3: '#D4FFB0',
gold1: '#F39C12',
gold2: '#F1C40F',
gold3: '#F7DC6F',
coral1: '#E67E22',
coral2: '#F39C12',
coral3: '#F5B041',
crimson1: '#C0392B',
crimson2: '#E74C3C',
crimson3: '#EC7063',
violet1: '#8E44AD',
violet2: '#9B59B6',
violet3: '#BB8FCE',
aqua1: '#00BFFF',
aqua2: '#1E90FF',
aqua3: '#63B8FF',
forest1: '#27AE60',
forest2: '#2ECC71',
forest3: '#58D68D',
blush1: '#FF6F91',
blush2: '#FF85A2',
blush3: '#FFA0B3',
lavender1: '#9B59B6',
lavender2: '#AF7AC5',
lavender3: '#C39BD3',
tomato1: '#E74C3C',
tomato2: '#EC7063',
tomato3: '#F1948A',
salmon1: '#FF6B6B',
salmon2: '#FF8787',
salmon3: '#FFA1A1',
mustard1: '#F1C40F',
mustard2: '#F7DC6F',
mustard3: '#F9E79F',
teal4: '#1ABC9C',
teal5: '#48C9B0',
teal6: '#76D7C4',
magenta1: '#D6336C',
magenta2: '#E84393',
magenta3: '#F06292',
violet4: '#7D3C98',
violet5: '#8E44AD',
violet6: '#9B59B6',
green4: '#229954',
green5: '#27AE60',
green6: '#52BE80',
blue4: '#2874A6',
blue5: '#2E86C1',
blue6: '#3498DB',
red4: '#C0392B',
red5: '#E74C3C',
red6: '#EC7063',
orange4: '#D35400',
orange5: '#E67E22',
orange6: '#EB984E',
pink4: '#C2185B',
pink5: '#D81B60',
pink6: '#E91E63',
gold4: '#B7950B',
gold5: '#F1C40F',
gold6: '#F4D03F',
},
lightModeColor: {
radicalRed: '#FF1A66',
dodgerBlueDark: '#0C6EED',
steelgrey: '#2f4b7c',
steelpurple: '#665191',
steelindigo: '#a05195',
steelpink: '#d45087',
steelcoral: '#f95d6a',
steelorange: '#ff7c43',
steelgold: '#ffa600',
steelrust: '#de425b',
steelgreen: '#41967e',
mediumOrchidDark: '#C326FD',
seaBuckthornDark: '#E66E05',
seaGreen: '#219653',
turquoiseBlueDark: '#0099CC',
silverDark: '#757575',
outrageousOrangeDark: '#F9521A',
roseBudDark: '#EB6437',
deepSkyBlueDark: '#0595BD',
royalBlue: '#3366E6',
avocadoDark: '#8E8E29',
mintGreenDark: '#00C700',
chestnut: '#B34D4D',
limaDark: '#6E9900',
olive: '#809900',
beautyBushDark: '#E25555',
danube: '#6680B3',
oliveDrab: '#66991A',
lavenderRoseDark: '#F024BD',
electricLimeDark: '#84A800',
robin: '#3F5ECC',
harleyOrange: '#E6331A',
gladeGreen: '#66994D',
hemlock: '#66664D',
vidaLoca: '#4D8000',
rust: '#B33300',
red: '#FF0000', // Adding more colors, we need to get better colors from design team
blue: '#0000FF',
green: '#00FF00',
purple: '#800080',
magentaDark: '#EB00EB',
pinkDark: '#FF3D5E',
brown: '#A52A2A',
teal: '#008080',
limeDark: '#07A207',
maroon: '#800000',
navy: '#000080',
gray: '#808080',
skyBlueDark: '#0CA7E4',
indigo: '#4B0082',
slateGray: '#708090',
chocolate: '#D2691E',
tomato: '#FF6347',
steelBlue: '#4682B4',
peruDark: '#D16E0A',
darkOliveGreen: '#556B2F',
indianRed: '#CD5C5C',
mediumSlateBlue: '#7B68EE',
rosyBrownDark: '#CB4848',
darkSlateGray: '#2F4F4F',
fuchsia: '#FF0AFF',
salmonDark: '#FF432E',
darkSalmonDark: '#D26541',
paleVioletRedDark: '#E83089',
mediumPurple: '#9370DB',
darkOrchid: '#9932CC',
mediumSeaGreenDark: '#109E50',
lightCoralDark: '#F85959',
gold: '#FFD700',
sandyBrownDark: '#D97117',
darkKhakiDark: '#99900A',
cornflowerBlueDark: '#3371E6',
mediumVioletRed: '#C71585',
paleGreenDark: '#0D910D',
radicalRed: '#D81B60',
dodgerBlueDark: '#1E5BD9',
steelgrey: '#344B6B',
steelpurple: '#5E548E',
steelindigo: '#8E4A7C',
steelpink: '#B63A6F',
steelcoral: '#E14B5A',
steelorange: '#E76F2F',
steelgold: '#E09B00',
steelrust: '#C93A50',
steelgreen: '#2F7D69',
mediumOrchidDark: '#8E24AA',
seaBuckthornDark: '#C75A00',
seaGreen: '#1E7F5A',
turquoiseBlueDark: '#007EA7',
silverDark: '#5F5F5F',
outrageousOrangeDark: '#E64A19',
roseBudDark: '#D84315',
deepSkyBlueDark: '#0277BD',
royalBlue: '#2A4FDB',
avocadoDark: '#6B6B1E',
mintGreenDark: '#2E9E55',
chestnut: '#8B3A3A',
limaDark: '#5C7F00',
olive: '#6E7F00',
beautyBushDark: '#C93C3C',
danube: '#4F6FB3',
oliveDrab: '#4F7F1A',
lavenderRoseDark: '#B0178F',
electricLimeDark: '#6B8F00',
robin: '#2F4FCC',
harleyOrange: '#CC2E12',
gladeGreen: '#4F7F46',
hemlock: '#5C5C45',
vidaLoca: '#3D6B00',
rust: '#993300',
red: '#C62828',
blue: '#1A237E',
green: '#1B7F3A',
purple: '#6A1B9A',
magentaDark: '#B000B5',
pinkDark: '#C2185B',
brown: '#7A3A1E',
teal: '#006D6F',
limeDark: '#4C8C2B',
maroon: '#6D1B1B',
navy: '#0D1B5E',
gray: '#616161',
skyBlueDark: '#0288D1',
indigo: '#303F9F',
slateGray: '#556B7C',
chocolate: '#9C4A1A',
tomato: '#E53935',
steelBlue: '#3A6EA5',
peruDark: '#B35E00',
darkOliveGreen: '#445B1F',
indianRed: '#B04040',
mediumSlateBlue: '#5C6BC0',
rosyBrownDark: '#A94444',
darkSlateGray: '#2E4A4A',
fuchsia: '#C511C5',
salmonDark: '#E64A3C',
darkSalmonDark: '#C85A3A',
paleVioletRedDark: '#C2186A',
mediumPurple: '#7E57C2',
darkOrchid: '#7B1FA2',
mediumSeaGreenDark: '#2E8B57',
lightCoralDark: '#E57373',
gold: '#D4AF37',
sandyBrownDark: '#C76A15',
darkKhakiDark: '#8A7F00',
cornflowerBlueDark: '#355FCC',
mediumVioletRed: '#AD1457',
paleGreenDark: '#2E7D32',
},
errorColor: '#d32f2f',
royalGrey: '#888888',

View File

@@ -14,7 +14,7 @@ describe('Get Series Data', () => {
expect(seriesData.length).toBe(5);
expect(seriesData[1].label).toBe('firstLegend');
expect(seriesData[1].show).toBe(true);
expect(seriesData[1].fill).toBe('#C71585');
expect(seriesData[1].fill).toBe('#FF6F91');
expect(seriesData[1].width).toBe(2);
});

View File

@@ -7,9 +7,9 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
. "github.com/smartystreets/goconvey/convey"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
@@ -96,10 +96,10 @@ var buildProcessorTestData = []struct {
func TestBuildLogParsingProcessors(t *testing.T) {
for _, test := range buildProcessorTestData {
Convey(test.Name, t, func() {
t.Run(test.Name, func(t *testing.T) {
err := updateProcessorConfigsInCollectorConf(test.agentConf, test.pipelineProcessor)
So(err, ShouldBeNil)
So(test.agentConf, ShouldResemble, test.outputConf)
assert.NoError(t, err)
assert.Equal(t, test.outputConf, test.agentConf)
})
}
@@ -202,11 +202,11 @@ var BuildLogsPipelineTestData = []struct {
func TestBuildLogsPipeline(t *testing.T) {
for _, test := range BuildLogsPipelineTestData {
Convey(test.Name, t, func() {
t.Run(test.Name, func(t *testing.T) {
v, err := buildCollectorPipelineProcessorsList(test.currentPipeline, test.logsPipeline)
So(err, ShouldBeNil)
assert.NoError(t, err)
fmt.Println(test.Name, "\n", test.currentPipeline, "\n", v, "\n", test.expectedPipeline)
So(v, ShouldResemble, test.expectedPipeline)
assert.Equal(t, test.expectedPipeline, v)
})
}
}
@@ -239,19 +239,8 @@ func TestPipelineAliasCollisionsDontResultInDuplicateCollectorProcessors(t *test
Alias: alias,
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -310,19 +299,8 @@ func TestPipelineRouterWorksEvenIfFirstOpIsDisabled(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -383,19 +361,8 @@ func TestPipeCharInAliasDoesntBreakCollectorConfig(t *testing.T) {
Alias: "test|pipeline",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -11,13 +11,13 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
@@ -140,15 +140,8 @@ func (ic *LogParsingPipelineController) getDefaultPipelines() ([]pipelinetypes.G
Alias: "NormalizeBodyDefault",
Enabled: true,
},
Filter: &v3.FilterSet{
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "body",
},
Operator: v3.FilterOperatorExists,
},
},
Filter: &qbtypes.Filter{
Expression: "body EXISTS",
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -9,14 +9,14 @@ import (
signozstanzahelper "github.com/SigNoz/signoz-otel-collector/processor/signozlogspipelineprocessor/stanza/operator/helper"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -203,10 +203,10 @@ var prepareProcessorTestData = []struct {
func TestPreparePipelineProcessor(t *testing.T) {
for _, test := range prepareProcessorTestData {
Convey(test.Name, t, func() {
t.Run(test.Name, func(t *testing.T) {
res, err := getOperators(test.Operators)
So(err, ShouldBeNil)
So(res, ShouldResemble, test.Output)
assert.NoError(t, err)
assert.Equal(t, test.Output, res)
})
}
}
@@ -214,19 +214,8 @@ func TestPreparePipelineProcessor(t *testing.T) {
func TestNoCollectorErrorsFromProcessorsForMismatchedLogs(t *testing.T) {
require := require.New(t)
testPipelineFilter := &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
testPipelineFilter := &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
}
makeTestPipeline := func(config []pipelinetypes.PipelineOperator) pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
@@ -470,19 +459,8 @@ func TestResourceFiltersWork(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "service",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
},
Operator: "=",
Value: "nginx",
},
},
Filter: &qbtypes.Filter{
Expression: "resource.service = 'nginx'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -524,11 +502,11 @@ func TestResourceFiltersWork(t *testing.T) {
func TestPipelineFilterWithStringOpsShouldNotSpamWarningsIfAttributeIsMissing(t *testing.T) {
require := require.New(t)
for _, operator := range []v3.FilterOperator{
v3.FilterOperatorContains,
v3.FilterOperatorNotContains,
v3.FilterOperatorRegex,
v3.FilterOperatorNotRegex,
for _, operator := range []string{
"CONTAINS",
"NOT CONTAINS",
"REGEXP",
"NOT REGEXP",
} {
testPipeline := pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
@@ -540,19 +518,8 @@ func TestPipelineFilterWithStringOpsShouldNotSpamWarningsIfAttributeIsMissing(t
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "service",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeResource,
},
Operator: operator,
Value: "nginx",
},
},
Filter: &qbtypes.Filter{
Expression: fmt.Sprintf("resource.service %s 'nginx'", operator),
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -601,19 +568,8 @@ func TestAttributePathsContainingDollarDoNotBreakCollector(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "$test",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "test",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.$test = 'test'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -664,19 +620,8 @@ func TestMembershipOpInProcessorFieldExpressions(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "http.method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.http.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -773,18 +718,8 @@ func TestContainsFilterIsCaseInsensitive(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{
Key: "body",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeUnspecified,
IsColumn: true,
},
Operator: "contains",
Value: "log",
}},
Filter: &qbtypes.Filter{
Expression: "body CONTAINS 'log'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -806,18 +741,8 @@ func TestContainsFilterIsCaseInsensitive(t *testing.T) {
Alias: "pipeline2",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{{
Key: v3.AttributeKey{
Key: "body",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeUnspecified,
IsColumn: true,
},
Operator: "ncontains",
Value: "ecom",
}},
Filter: &qbtypes.Filter{
Expression: "body NCONTAINS 'ecom'",
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -7,8 +7,8 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/google/uuid"
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry"
"github.com/stretchr/testify/require"
@@ -25,19 +25,8 @@ func TestPipelinePreview(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -58,19 +47,8 @@ func TestPipelinePreview(t *testing.T) {
Alias: "pipeline2",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{
@@ -159,19 +137,8 @@ func TestGrokParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{
{

View File

@@ -10,8 +10,8 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
"github.com/stretchr/testify/require"
)
@@ -30,19 +30,8 @@ func TestRegexProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -97,19 +86,8 @@ func TestGrokProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -164,19 +142,8 @@ func TestJSONProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -230,20 +197,9 @@ func TestTraceParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
}
@@ -339,19 +295,8 @@ func TestAddProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -404,19 +349,8 @@ func TestRemoveProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -469,19 +403,8 @@ func TestCopyProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -535,19 +458,8 @@ func TestMoveProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},

View File

@@ -7,8 +7,8 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/require"
)
@@ -23,19 +23,8 @@ func TestSeverityParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},
@@ -141,19 +130,8 @@ func TestSeverityParsingProcessor(t *testing.T) {
func TestNoCollectorErrorsFromSeverityParserForMismatchedLogs(t *testing.T) {
require := require.New(t)
testPipelineFilter := &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
testPipelineFilter := &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
}
makeTestPipeline := func(config []pipelinetypes.PipelineOperator) pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{

View File

@@ -8,8 +8,8 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/pipelinetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/require"
)
@@ -24,19 +24,8 @@ func TestTimestampParsingProcessor(t *testing.T) {
Alias: "pipeline1",
Enabled: true,
},
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
Filter: &qbtypes.Filter{
Expression: "attribute.method = 'GET'",
},
Config: []pipelinetypes.PipelineOperator{},
},

View File

@@ -2,11 +2,16 @@ package queryBuilderToExpr
import (
"fmt"
"maps"
"reflect"
"regexp"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
expr "github.com/antonmedv/expr"
"go.uber.org/zap"
)
@@ -15,119 +20,220 @@ var (
CodeExprCompilationFailed = errors.MustNewCode("expr_compilation_failed")
)
var logOperatorsToExpr = map[v3.FilterOperator]string{
v3.FilterOperatorEqual: "==",
v3.FilterOperatorNotEqual: "!=",
v3.FilterOperatorLessThan: "<",
v3.FilterOperatorLessThanOrEq: "<=",
v3.FilterOperatorGreaterThan: ">",
v3.FilterOperatorGreaterThanOrEq: ">=",
v3.FilterOperatorContains: "contains",
v3.FilterOperatorNotContains: "not contains",
v3.FilterOperatorRegex: "matches",
v3.FilterOperatorNotRegex: "not matches",
v3.FilterOperatorIn: "in",
v3.FilterOperatorNotIn: "not in",
v3.FilterOperatorExists: "in",
v3.FilterOperatorNotExists: "not in",
// we dont support like and nlike as of now.
var logOperatorsToExpr = map[qbtypes.FilterOperator]string{
qbtypes.FilterOperatorEqual: "==",
qbtypes.FilterOperatorNotEqual: "!=",
qbtypes.FilterOperatorLessThan: "<",
qbtypes.FilterOperatorLessThanOrEq: "<=",
qbtypes.FilterOperatorGreaterThan: ">",
qbtypes.FilterOperatorGreaterThanOrEq: ">=",
qbtypes.FilterOperatorContains: "contains",
qbtypes.FilterOperatorNotContains: "not contains",
qbtypes.FilterOperatorRegexp: "matches",
qbtypes.FilterOperatorNotRegexp: "not matches",
qbtypes.FilterOperatorIn: "in",
qbtypes.FilterOperatorNotIn: "not in",
qbtypes.FilterOperatorExists: "in",
qbtypes.FilterOperatorNotExists: "not in",
// nlike and like are not supported yet
}
func getName(v v3.AttributeKey) string {
if v.Type == v3.AttributeKeyTypeTag {
return fmt.Sprintf(`attributes["%s"]`, v.Key)
} else if v.Type == v3.AttributeKeyTypeResource {
return fmt.Sprintf(`resource["%s"]`, v.Key)
func getName(key *telemetrytypes.TelemetryFieldKey) string {
if key == nil {
return ""
}
return v.Key
}
func getTypeName(v v3.AttributeKeyType) string {
if v == v3.AttributeKeyTypeTag {
return "attributes"
} else if v == v3.AttributeKeyTypeResource {
return "resource"
switch key.FieldContext {
case telemetrytypes.FieldContextAttribute:
return fmt.Sprintf(`attributes["%s"]`, key.Name)
case telemetrytypes.FieldContextResource:
return fmt.Sprintf(`resource["%s"]`, key.Name)
case telemetrytypes.FieldContextBody:
return fmt.Sprintf("%s.%s", key.FieldContext.StringValue(), key.Name)
default:
return key.Name
}
return ""
}
func Parse(filters *v3.FilterSet) (string, error) {
var res []string
for _, v := range filters.Items {
if _, ok := logOperatorsToExpr[v.Operator]; !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "operator not supported: %s", v.Operator)
}
func parseCondition(c qbtypes.FilterCondition) (string, error) {
if len(c.Keys) == 0 {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "condition has no keys")
}
key := c.Keys[0]
if _, ok := logOperatorsToExpr[c.Op]; !ok {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "operator not supported: %d", c.Op)
}
name := getName(v.Key)
value := exprFormattedValue(c.Value)
var filter string
var filter string
switch c.Op {
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
// EXISTS/NOT EXISTS checks are special:
// - For body fields, we check membership in fromJSON(body) using the JSON field name.
// - For attribute/resource fields, we check membership in the appropriate map
// ("attributes" or "resource") using the logical field name.
// - For intrinsic / toplevel fields (no explicit context), we fall back to
// equality against nil (see default case below).
switch key.FieldContext {
case telemetrytypes.FieldContextBody:
// if body is a string and is a valid JSON, then check if the key exists in the JSON
quoted := exprFormattedValue(key.Name)
jsonMembership := fmt.Sprintf(
`((type(body) == "string" && isJSON(body)) && %s %s %s)`,
quoted, logOperatorsToExpr[c.Op], "fromJSON(body)",
)
switch v.Operator {
// uncomment following lines when new version of expr is used
// case v3.FilterOperatorIn, v3.FilterOperatorNotIn:
// filter = fmt.Sprintf("%s %s list%s", name, logOperatorsToExpr[v.Operator], exprFormattedValue(v.Value))
case v3.FilterOperatorExists, v3.FilterOperatorNotExists:
// accustom log filters like `body.log.message EXISTS` into EXPR language
// where User is attempting to check for keys present in JSON log body
if strings.HasPrefix(v.Key.Key, "body.") {
// if body is a string and is a valid JSON, then check if the key exists in the JSON
filter = fmt.Sprintf(`((type(body) == "string" && isJSON(body)) && %s %s %s)`, exprFormattedValue(strings.TrimPrefix(v.Key.Key, "body.")), logOperatorsToExpr[v.Operator], "fromJSON(body)")
// if body is a map, then check if the key exists in the map
operator := v3.FilterOperatorNotEqual
if v.Operator == v3.FilterOperatorNotExists {
operator = v3.FilterOperatorEqual
}
nilCheckFilter := fmt.Sprintf("%s %s nil", v.Key.Key, logOperatorsToExpr[operator])
// join the two filters with OR
filter = fmt.Sprintf(`(%s or (type(body) == "map" && (%s)))`, filter, nilCheckFilter)
} else if typ := getTypeName(v.Key.Type); typ != "" {
filter = fmt.Sprintf("%s %s %s", exprFormattedValue(v.Key.Key), logOperatorsToExpr[v.Operator], typ)
} else {
// if type of key is not available; is considered as TOP LEVEL key in OTEL Log Data model hence
// switch Exist and Not Exists operators with NOT EQUAL and EQUAL respectively
operator := v3.FilterOperatorNotEqual
if v.Operator == v3.FilterOperatorNotExists {
operator = v3.FilterOperatorEqual
}
filter = fmt.Sprintf("%s %s nil", v.Key.Key, logOperatorsToExpr[operator])
// if body is a map, then check if the key exists in the map
operator := qbtypes.FilterOperatorNotEqual
if c.Op == qbtypes.FilterOperatorNotExists {
operator = qbtypes.FilterOperatorEqual
}
nilCheckFilter := fmt.Sprintf("%s.%s %s nil", key.FieldContext.StringValue(), key.Name, logOperatorsToExpr[operator])
// join the two filters with OR
filter = fmt.Sprintf(`(%s or (type(body) == "map" && (%s)))`, jsonMembership, nilCheckFilter)
case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource:
// Example: "http.method" in attributes
target := "resource"
if key.FieldContext == telemetrytypes.FieldContextAttribute {
target = "attributes"
}
filter = fmt.Sprintf("%q %s %s", key.Name, logOperatorsToExpr[c.Op], target)
default:
filter = fmt.Sprintf("%s %s %s", name, logOperatorsToExpr[v.Operator], exprFormattedValue(v.Value))
if v.Operator == v3.FilterOperatorContains || v.Operator == v3.FilterOperatorNotContains {
// `contains` and `ncontains` should be case insensitive to match how they work when querying logs.
filter = fmt.Sprintf(
"lower(%s) %s lower(%s)",
name, logOperatorsToExpr[v.Operator], exprFormattedValue(v.Value),
)
}
// Avoid running operators on nil values
if v.Operator != v3.FilterOperatorEqual && v.Operator != v3.FilterOperatorNotEqual {
filter = fmt.Sprintf("%s != nil && %s", name, filter)
// if type of key is not available; is considered as TOP LEVEL key in OTEL Log Data model hence
// switch Exist and Not Exists operators with NOT EQUAL and EQUAL respectively
operator := qbtypes.FilterOperatorNotEqual
if c.Op == qbtypes.FilterOperatorNotExists {
operator = qbtypes.FilterOperatorEqual
}
filter = fmt.Sprintf("%s %s nil", key.Name, logOperatorsToExpr[operator])
}
default:
filter = fmt.Sprintf("%s %s %s", getName(key), logOperatorsToExpr[c.Op], value)
if c.Op == qbtypes.FilterOperatorContains || c.Op == qbtypes.FilterOperatorNotContains {
// `contains` and `ncontains` should be case insensitive to match how they work when querying logs.
filter = fmt.Sprintf(
"lower(%s) %s lower(%s)",
getName(key), logOperatorsToExpr[c.Op], value,
)
}
// check if the filter is a correct expression language
_, err := expr.Compile(filter)
if err != nil {
return "", err
// Avoid running operators on nil values
if c.Op != qbtypes.FilterOperatorEqual && c.Op != qbtypes.FilterOperatorNotEqual {
filter = fmt.Sprintf("%s != nil && %s", getName(key), filter)
}
res = append(res, filter)
}
// check the final filter
q := strings.Join(res, " "+strings.ToLower(filters.Operator)+" ")
_, err := expr.Compile(q)
_, err := expr.Compile(filter)
if err != nil {
return "", errors.WrapInternalf(err, CodeExprCompilationFailed, "failed to compile expression: %s", q)
return "", err
}
return filter, nil
}
// Parse converts the QB filter Expression (query builder expression string) into
// the Expr expression string used by the collector. It parses the QB expression
// into a FilterExprNode tree, then serializes that tree to the Expr dialect.
func Parse(filter *qbtypes.Filter) (string, error) {
if filter == nil || strings.TrimSpace(filter.Expression) == "" {
return "", nil
}
node, err := querybuilder.ExtractFilterExprTree(filter.Expression)
if err != nil {
return "", err
}
if node == nil {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter expression; node found nil")
}
return q, nil
for _, condition := range node.Flatten() {
for _, key := range condition.Keys {
_, found := telemetrylogs.IntrinsicFields[key.Name]
if key.FieldContext == telemetrytypes.FieldContextUnspecified && !found {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "field %q in filter expression must include a context prefix (attribute., resource., body.) OR can be one of the following fields: %v", key.Name, maps.Keys(telemetrylogs.IntrinsicFields))
}
}
if condition.Op == qbtypes.FilterOperatorRegexp || condition.Op == qbtypes.FilterOperatorNotRegexp {
switch condition.Value.(type) {
case string:
if _, err := regexp.Compile(condition.Value.(string)); err != nil {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value for regex operator must be a valid regex")
}
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "value for regex operator must be a string or a slice of strings")
}
}
}
return nodeToExpr(node)
}
func nodeToExpr(node *qbtypes.FilterExprNode) (string, error) {
if node == nil {
return "", nil
}
switch node.Op {
case qbtypes.LogicalOpLeaf:
var parts []string
for _, c := range node.Conditions {
s, err := parseCondition(c)
if err != nil {
return "", err
}
parts = append(parts, s)
}
if len(parts) == 0 {
return "", nil
}
// For a simple leaf, just join conditions with AND without wrapping
// the whole clause in parentheses
return strings.Join(parts, " and "), nil
case qbtypes.LogicalOpAnd:
var parts []string
for _, child := range node.Children {
if child == nil {
continue
}
s, err := nodeToExpr(child)
if err != nil {
return "", err
}
// When mixing AND/OR, we need parentheses around any OR child to
// preserve the intended precedence: (a and (b or c)).
if child.Op == qbtypes.LogicalOpOr {
s = fmt.Sprintf("(%s)", s)
}
parts = append(parts, s)
}
if len(parts) == 0 {
return "", nil
}
return strings.Join(parts, " and "), nil
case qbtypes.LogicalOpOr:
var parts []string
for _, child := range node.Children {
if child == nil {
continue
}
s, err := nodeToExpr(child)
if err != nil {
return "", err
}
// When mixing AND/OR, we need parentheses around any AND child to
// preserve the intended precedence: ((a and b) or c).
if child.Op == qbtypes.LogicalOpAnd {
s = fmt.Sprintf("(%s)", s)
}
parts = append(parts, s)
}
if len(parts) == 0 {
return "", nil
}
return strings.Join(parts, " or "), nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported logical op: %s", node.Op)
}
}
func exprFormattedValue(v interface{}) string {
@@ -135,13 +241,12 @@ func exprFormattedValue(v interface{}) string {
case uint8, uint16, uint32, uint64, int, int8, int16, int32, int64:
return fmt.Sprintf("%d", x)
case float32, float64:
return fmt.Sprintf("%f", x)
return fmt.Sprintf("%v", x)
case string:
return fmt.Sprintf("\"%s\"", quoteEscapedString(x))
case bool:
return fmt.Sprintf("%v", x)
case []interface{}:
case []any:
if len(x) == 0 {
return ""
}

View File

@@ -4,7 +4,7 @@ import (
"testing"
signozstanzahelper "github.com/SigNoz/signoz-otel-collector/processor/signozlogspipelineprocessor/stanza/operator/helper"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/expr-lang/expr/vm"
"github.com/open-telemetry/opentelemetry-collector-contrib/pkg/stanza/entry"
"github.com/stretchr/testify/assert"
@@ -13,183 +13,177 @@ import (
func TestParseExpression(t *testing.T) {
var testCases = []struct {
Name string
Query *v3.FilterSet
Query *qbtypes.Filter
Expr string
ExpectError bool
}{
{
Name: "equal",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "checkbody", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key = 'checkbody'",
},
Expr: `attributes["key"] == "checkbody"`,
},
{
Name: "not equal",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "checkbody", Operator: "!="},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key != 'checkbody'",
},
Expr: `attributes["key"] != "checkbody"`,
},
{
Name: "less than",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key < 10",
},
Expr: `attributes["key"] != nil && attributes["key"] < 10`,
},
{
Name: "greater than",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: ">"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key > 10",
},
Expr: `attributes["key"] != nil && attributes["key"] > 10`,
},
{
Name: "less than equal",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<="},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key <= 10",
},
Expr: `attributes["key"] != nil && attributes["key"] <= 10`,
},
{
Name: "greater than equal",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: ">="},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key >= 10",
},
Expr: `attributes["key"] != nil && attributes["key"] >= 10`,
},
// case sensitive
{
Name: "body contains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
}},
Query: &qbtypes.Filter{
Expression: "body contains 'checkbody'",
},
Expr: `body != nil && lower(body) contains lower("checkbody")`,
},
{
Name: "body.log.message exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.log.message", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "body.log.message exists",
},
Expr: `(((type(body) == "string" && isJSON(body)) && "log.message" in fromJSON(body)) or (type(body) == "map" && (body.log.message != nil)))`,
},
{
Name: "body.log.message not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.log.message", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "body.log.message not exists",
},
Expr: `(((type(body) == "string" && isJSON(body)) && "log.message" not in fromJSON(body)) or (type(body) == "map" && (body.log.message == nil)))`,
},
{
Name: "body ncontains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "ncontains"},
}},
Name: "body NOT CONTAINS",
Query: &qbtypes.Filter{
Expression: "body NOT CONTAINS 'checkbody'",
},
Expr: `body != nil && lower(body) not contains lower("checkbody")`,
},
{
Name: "body regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex$", Operator: "regex"},
}},
Name: "body REGEXP",
Query: &qbtypes.Filter{
Expression: "body REGEXP '[0-1]+regex$'",
},
Expr: `body != nil && body matches "[0-1]+regex$"`,
},
{
Name: "body not regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex$", Operator: "nregex"},
}},
Name: "body NOT REGEXP",
Query: &qbtypes.Filter{
Expression: "body NOT REGEXP '[0-1]+regex$'",
},
Expr: `body != nil && body not matches "[0-1]+regex$"`,
},
{
Name: "regex with escape characters",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: `^Executing \[\S+@\S+:[0-9]+\] \S+".*`, Operator: "regex"},
}},
Query: &qbtypes.Filter{
Expression: "body REGEXP '^Executing \\[\\S+@\\S+:[0-9]+\\] \\S+\".*'",
},
Expr: `body != nil && body matches "^Executing \\[\\S+@\\S+:[0-9]+\\] \\S+\".*"`,
},
{
Name: "invalid regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-9]++", Operator: "nregex"},
}},
Expr: `body != nil && lower(body) not matches "[0-9]++"`,
Query: &qbtypes.Filter{
Expression: "body not REGEXP '[0-9]++'",
},
ExpectError: true,
},
{
Name: "in",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{1, 2, 3, 4}, Operator: "in"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key in [1,2,3,4]",
},
Expr: `attributes["key"] != nil && attributes["key"] in [1,2,3,4]`,
},
{
Name: "not in",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{"1", "2"}, Operator: "nin"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key not in ['1','2']",
},
Expr: `attributes["key"] != nil && attributes["key"] not in ['1','2']`,
},
{
Name: "exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key exists",
},
Expr: `"key" in attributes`,
},
{
Name: "not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key not exists",
},
Expr: `"key" not in attributes`,
},
{
Name: "trace_id not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "trace_id not exists",
},
Expr: `trace_id == nil`,
},
{
Name: "trace_id exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "trace_id exists",
},
Expr: `trace_id != nil`,
},
{
Name: "span_id not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "span_id not exists",
},
Expr: `span_id == nil`,
},
{
Name: "span_id exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "span_id exists",
},
Expr: `span_id != nil`,
},
{
Name: "Multi filter",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex$", Operator: "nregex"},
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.key <= 10 and body not regexp '[0-1]+regex$' and attribute.key not exists",
},
Expr: `attributes["key"] != nil && attributes["key"] <= 10 and body != nil && body not matches "[0-1]+regex$" and "key" not in attributes`,
},
{
Name: "incorrect multi filter",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: 10, Operator: "<="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-9]++", Operator: "nregex"},
{Key: v3.AttributeKey{Key: "key", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Expr: `attributes["key"] != nil && attributes["key"] <= 10 and body not matches "[0-9]++" and "key" not in attributes`,
Query: &qbtypes.Filter{
Expression: "attribute.key <= 10 and body not regexp '[0-9]++' and attribute.key not exists",
},
ExpectError: true,
},
}
@@ -267,248 +261,234 @@ func TestExpressionVSEntry(t *testing.T) {
var testCases = []struct {
Name string
Query *v3.FilterSet
Query *qbtypes.Filter
ExpectedMatches []int
}{
{
Name: "resource equal (env)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "resource.env = 'prod'",
},
ExpectedMatches: []int{0, 1, 4, 5, 9, 11, 13, 14, 17, 18},
},
{
Name: "resource not equal (env)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "!="},
}},
Query: &qbtypes.Filter{
Expression: "resource.env != 'prod'",
},
ExpectedMatches: []int{2, 3, 6, 7, 8, 10, 12, 15, 16, 19, 20},
},
{
Name: "attribute less than (numeric)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "count", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 8, Operator: "<"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.count < 8",
},
ExpectedMatches: []int{4},
},
{
Name: "attribute greater than (numeric)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "count", DataType: v3.AttributeKeyDataTypeInt64, Type: v3.AttributeKeyTypeTag}, Value: 8, Operator: ">"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.count > 8",
},
ExpectedMatches: []int{5},
},
{
Name: "body contains (case insensitive)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
}},
Query: &qbtypes.Filter{
Expression: "body contains 'checkbody'",
},
ExpectedMatches: []int{2, 9, 10, 16},
},
{
Name: "body ncontains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "ncontains"},
}},
Name: "body NOT CONTAINS",
Query: &qbtypes.Filter{
Expression: "body NOT CONTAINS 'checkbody'",
},
ExpectedMatches: []int{0, 1, 3, 4, 5, 6, 7, 8, 11, 12, 13, 14, 15, 17},
},
{
Name: "body.msg (case insensitive)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.msg", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: false}, Value: "checkbody", Operator: "contains"},
}},
Query: &qbtypes.Filter{
Expression: "body.msg contains 'checkbody'",
},
ExpectedMatches: []int{2, 9, 10, 18},
},
{
Name: "body regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex", Operator: "regex"},
}},
Name: "body REGEXP",
Query: &qbtypes.Filter{
Expression: "body REGEXP '[0-1]+regex'",
},
ExpectedMatches: []int{4},
},
{
Name: "body not regex",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex", Operator: "nregex"},
}},
Name: "body NOT REGEXP",
Query: &qbtypes.Filter{
Expression: "body NOT REGEXP '[0-1]+regex'",
},
ExpectedMatches: []int{0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17},
},
// body.log.message exists/nexists: expr checks "log.message" in fromJSON(body); nested key
// semantics depend on signoz stanza helper. Omitted here to avoid coupling to env shape.
{
Name: "body top-level key exists (body.msg)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.msg", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "body.msg exists",
},
ExpectedMatches: []int{0, 1, 2, 3, 4, 5, 9, 10, 11, 12, 18},
},
{
Name: "body top-level key not exists (body.missing)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body.missing", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "body.missing not exists",
},
ExpectedMatches: []int{0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 18, 20},
},
{
Name: "attribute exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.service exists",
},
ExpectedMatches: []int{6, 7, 8, 15},
},
{
Name: "attribute not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.service not exists",
},
ExpectedMatches: []int{0, 1, 2, 3, 4, 5, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19, 20},
},
{
Name: "trace_id exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "trace_id exists",
},
ExpectedMatches: []int{1, 2, 5, 7, 12, 15, 19},
},
{
Name: "trace_id not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "trace_id not exists",
},
ExpectedMatches: []int{0, 3, 4, 6, 8, 9, 10, 11, 13, 14, 16, 17, 18, 20},
},
{
Name: "span_id exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "span_id exists",
},
ExpectedMatches: []int{1, 3, 5, 12, 17, 20},
},
{
Name: "span_id not exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "span_id not exists",
},
ExpectedMatches: []int{0, 2, 4, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16, 18, 19},
},
{
Name: "in (attribute in list)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{"info", "error"}, Operator: "in"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.level in ['info', 'error']",
},
ExpectedMatches: []int{0, 1, 2, 14, 16, 20},
},
{
Name: "not in (attribute not in list)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: []any{"error", "warn"}, Operator: "nin"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.level not in ['error', 'warn']",
},
ExpectedMatches: []int{0, 2, 3, 16, 20},
},
{
Name: "multi filter AND",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "staging", Operator: "="},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "info", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "resource.env = 'staging' and attribute.level = 'info'",
},
ExpectedMatches: []int{2, 16},
},
{
Name: "multi filter AND (two attributes)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "auth", Operator: "="},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Operator: "nexists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.service = 'auth' and attribute.level not exists",
},
ExpectedMatches: []int{6, 7},
},
// Multi-filter variations: body + attribute, three conditions, trace/span + attribute
{
Name: "multi filter AND body contains + attribute",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "Connection", Operator: "contains"},
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "body contains 'Connection' and resource.env = 'prod'",
},
ExpectedMatches: []int{14},
},
{
Name: "multi filter AND body contains + service",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "login", Operator: "contains"},
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "auth", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "body contains 'login' and attribute.service = 'auth'",
},
ExpectedMatches: []int{6, 15},
},
{
Name: "multi filter AND env + level (prod error)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "error", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "resource.env = 'prod' and attribute.level = 'error'",
},
ExpectedMatches: []int{1, 14},
},
{
Name: "multi filter AND three conditions (staging + checkbody + info)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "staging", Operator: "="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "info", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "resource.env = 'staging' and body contains 'checkbody' and attribute.level = 'info'",
},
ExpectedMatches: []int{2, 16},
},
{
Name: "multi filter AND trace_id exists + body contains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "checkbody", Operator: "contains"},
}},
Query: &qbtypes.Filter{
Expression: "trace_id exists and body contains 'checkbody'",
},
ExpectedMatches: []int{2},
},
{
Name: "multi filter AND span_id nexists + service auth",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
{Key: v3.AttributeKey{Key: "service", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "auth", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "span_id not exists and attribute.service = 'auth'",
},
ExpectedMatches: []int{6, 7, 15},
},
{
Name: "multi filter AND body regex + attribute",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "[0-1]+regex", Operator: "regex"},
{Key: v3.AttributeKey{Key: "code", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "200", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "body regexp '[0-1]+regex' and attribute.code = '200'",
},
ExpectedMatches: []int{4},
},
{
Name: "multi filter AND no trace_id + no span_id + env prod",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
{Key: v3.AttributeKey{Key: "span_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "nexists"},
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "prod", Operator: "="},
}},
Query: &qbtypes.Filter{
Expression: "trace_id not exists and span_id not exists and resource.env = 'prod'",
},
ExpectedMatches: []int{0, 4, 9, 11, 13, 14, 18},
},
{
Name: "multi filter AND level warn + body contains",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "level", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "warn", Operator: "="},
{Key: v3.AttributeKey{Key: "body", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "disk", Operator: "contains"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.level = 'warn' and body contains 'disk'",
},
ExpectedMatches: []int{17},
},
{
Name: "no matches (attribute value not present)",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "env", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeResource}, Value: "never", Operator: "="},
}},
Name: "no matches (resource value not present)",
Query: &qbtypes.Filter{
Expression: "resource.env = 'never'",
},
ExpectedMatches: []int{},
},
{
Name: "attribute equal and trace_id exists",
Query: &v3.FilterSet{Operator: "AND", Items: []v3.FilterItem{
{Key: v3.AttributeKey{Key: "code", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeTag}, Value: "404", Operator: "="},
{Key: v3.AttributeKey{Key: "trace_id", DataType: v3.AttributeKeyDataTypeString, Type: v3.AttributeKeyTypeUnspecified, IsColumn: true}, Value: "", Operator: "exists"},
}},
Query: &qbtypes.Filter{
Expression: "attribute.code = '404' and trace_id exists",
},
ExpectedMatches: []int{5},
},
}

View File

@@ -0,0 +1,392 @@
package querybuilder
import (
"fmt"
"strconv"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
grammar "github.com/SigNoz/signoz/pkg/parser/grammar"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/antlr4-go/antlr/v4"
)
// ExtractFilterExprTree parses a v5 filter expression and returns a logical
// tree that preserves full boolean structure (AND/OR, parentheses, NOT) as
// well as the individual conditions (keys, operator, values).
//
// This can be reused by callers that need deeper introspection into the logic
// of the filter expression without constructing any SQL or query-engine
// specific structures.
func ExtractFilterExprTree(expr string) (*qbtypes.FilterExprNode, error) {
if strings.TrimSpace(expr) == "" {
return nil, nil
}
// Setup the ANTLR parsing pipeline (same grammar as PrepareWhereClause).
input := antlr.NewInputStream(expr)
lexer := grammar.NewFilterQueryLexer(input)
lexerErrorListener := NewErrorListener()
lexer.RemoveErrorListeners()
lexer.AddErrorListener(lexerErrorListener)
tokens := antlr.NewCommonTokenStream(lexer, 0)
parserErrorListener := NewErrorListener()
parser := grammar.NewFilterQueryParser(tokens)
parser.RemoveErrorListeners()
parser.AddErrorListener(parserErrorListener)
tree := parser.Query()
// Handle syntax errors
if len(parserErrorListener.SyntaxErrors) > 0 {
combinedErrors := errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"Found %d syntax errors while parsing the search expression.",
len(parserErrorListener.SyntaxErrors),
)
additionals := make([]string, 0, len(parserErrorListener.SyntaxErrors))
for _, err := range parserErrorListener.SyntaxErrors {
if err.Error() != "" {
additionals = append(additionals, err.Error())
}
}
return nil, combinedErrors.WithAdditional(additionals...)
}
visitor := &filterTreeVisitor{}
rootAny := visitor.Visit(tree)
if len(visitor.errors) > 0 {
combinedErrors := errors.Newf(
errors.TypeInvalidInput,
errors.CodeInvalidInput,
"Found %d errors while parsing the search expression.",
len(visitor.errors),
)
return nil, combinedErrors.WithAdditional(visitor.errors...)
}
root, _ := rootAny.(*qbtypes.FilterExprNode)
return root, nil
}
// filterTreeVisitor builds a FilterExprNode tree from the parse tree.
type filterTreeVisitor struct {
errors []string
}
// Visit dispatches based on node type.
func (v *filterTreeVisitor) Visit(tree antlr.ParseTree) any {
if tree == nil {
return nil
}
switch t := tree.(type) {
case *grammar.QueryContext:
return v.VisitQuery(t)
case *grammar.ExpressionContext:
return v.VisitExpression(t)
case *grammar.OrExpressionContext:
return v.VisitOrExpression(t)
case *grammar.AndExpressionContext:
return v.VisitAndExpression(t)
case *grammar.UnaryExpressionContext:
return v.VisitUnaryExpression(t)
case *grammar.PrimaryContext:
return v.VisitPrimary(t)
case *grammar.ComparisonContext:
return v.VisitComparison(t)
default:
return nil
}
}
func (v *filterTreeVisitor) VisitQuery(ctx *grammar.QueryContext) any {
return v.Visit(ctx.Expression())
}
func (v *filterTreeVisitor) VisitExpression(ctx *grammar.ExpressionContext) any {
return v.Visit(ctx.OrExpression())
}
func (v *filterTreeVisitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
andExprs := ctx.AllAndExpression()
children := make([]*qbtypes.FilterExprNode, 0, len(andExprs))
for _, andExpr := range andExprs {
if node, ok := v.Visit(andExpr).(*qbtypes.FilterExprNode); ok && node != nil {
children = append(children, node)
}
}
if len(children) == 0 {
return nil
}
if len(children) == 1 {
return children[0]
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpOr,
Children: children,
}
}
func (v *filterTreeVisitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
unaryExprs := ctx.AllUnaryExpression()
children := make([]*qbtypes.FilterExprNode, 0, len(unaryExprs))
for _, unary := range unaryExprs {
if node, ok := v.Visit(unary).(*qbtypes.FilterExprNode); ok && node != nil {
children = append(children, node)
}
}
if len(children) == 0 {
return nil
}
if len(children) == 1 {
return children[0]
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: children,
}
}
func (v *filterTreeVisitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
node, _ := v.Visit(ctx.Primary()).(*qbtypes.FilterExprNode)
if node == nil {
return nil
}
if ctx.NOT() != nil {
node.Negated = !node.Negated
}
return node
}
func (v *filterTreeVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
switch {
case ctx.OrExpression() != nil:
return v.Visit(ctx.OrExpression())
case ctx.Comparison() != nil:
return v.Visit(ctx.Comparison())
default:
// We intentionally ignore FullText/FunctionCall here for the tree
// representation. They can be added later if needed.
return nil
}
}
// VisitComparison builds a leaf node with a single ParsedFilterCondition.
func (v *filterTreeVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
keys := v.buildKeys(ctx.Key())
if len(keys) == 0 {
return nil
}
// Handle EXISTS specially
if ctx.EXISTS() != nil {
op := qbtypes.FilterOperatorExists
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotExists
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Keys: keys,
Op: op,
Value: nil,
}},
}
}
// Handle IN / NOT IN
if ctx.InClause() != nil || ctx.NotInClause() != nil {
values := v.buildValuesFromInClause(ctx.InClause(), ctx.NotInClause())
op := qbtypes.FilterOperatorIn
if ctx.NotInClause() != nil {
op = qbtypes.FilterOperatorNotIn
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Keys: keys,
Op: op,
Value: values,
}},
}
}
// Handle BETWEEN / NOT BETWEEN
if ctx.BETWEEN() != nil {
valuesCtx := ctx.AllValue()
if len(valuesCtx) != 2 {
v.errors = append(v.errors, "BETWEEN operator requires exactly two values")
return nil
}
value1 := v.buildValue(valuesCtx[0])
value2 := v.buildValue(valuesCtx[1])
op := qbtypes.FilterOperatorBetween
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotBetween
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Keys: keys,
Op: op,
Value: []any{value1, value2},
}},
}
}
// All remaining operators have exactly one value.
valuesCtx := ctx.AllValue()
if len(valuesCtx) == 0 {
v.errors = append(v.errors, "comparison operator requires a value")
return nil
}
value := v.buildValue(valuesCtx[0])
var op qbtypes.FilterOperator
switch {
case ctx.EQUALS() != nil:
op = qbtypes.FilterOperatorEqual
case ctx.NOT_EQUALS() != nil || ctx.NEQ() != nil:
op = qbtypes.FilterOperatorNotEqual
case ctx.LT() != nil:
op = qbtypes.FilterOperatorLessThan
case ctx.LE() != nil:
op = qbtypes.FilterOperatorLessThanOrEq
case ctx.GT() != nil:
op = qbtypes.FilterOperatorGreaterThan
case ctx.GE() != nil:
op = qbtypes.FilterOperatorGreaterThanOrEq
case ctx.LIKE() != nil:
op = qbtypes.FilterOperatorLike
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotLike
}
case ctx.ILIKE() != nil:
op = qbtypes.FilterOperatorILike
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotILike
}
case ctx.REGEXP() != nil:
op = qbtypes.FilterOperatorRegexp
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotRegexp
}
case ctx.CONTAINS() != nil:
op = qbtypes.FilterOperatorContains
if ctx.NOT() != nil {
op = qbtypes.FilterOperatorNotContains
}
default:
v.errors = append(v.errors, fmt.Sprintf("unsupported comparison operator in expression: %s", ctx.GetText()))
return nil
}
return &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{{
Keys: keys,
Op: op,
Value: value,
}},
}
}
// buildKeys turns a key context into a slice of TelemetryFieldKey.
func (v *filterTreeVisitor) buildKeys(ctx grammar.IKeyContext) []*telemetrytypes.TelemetryFieldKey {
if ctx == nil {
return nil
}
text := ctx.GetText()
key := telemetrytypes.GetFieldKeyFromKeyText(text)
return []*telemetrytypes.TelemetryFieldKey{&key}
}
// buildValuesFromInClause handles the IN/NOT IN value side.
func (v *filterTreeVisitor) buildValuesFromInClause(in grammar.IInClauseContext, notIn grammar.INotInClauseContext) []any {
var ctxVal any
if in != nil {
ctxVal = v.visitInClause(in)
} else if notIn != nil {
ctxVal = v.visitNotInClause(notIn)
}
switch ret := ctxVal.(type) {
case []any:
return ret
case any:
if ret != nil {
return []any{ret}
}
}
return nil
}
func (v *filterTreeVisitor) visitInClause(ctx grammar.IInClauseContext) any {
if ctx.ValueList() != nil {
return v.visitValueList(ctx.ValueList())
}
return v.buildValue(ctx.Value())
}
func (v *filterTreeVisitor) visitNotInClause(ctx grammar.INotInClauseContext) any {
if ctx.ValueList() != nil {
return v.visitValueList(ctx.ValueList())
}
return v.buildValue(ctx.Value())
}
func (v *filterTreeVisitor) visitValueList(ctx grammar.IValueListContext) any {
values := ctx.AllValue()
parts := make([]any, 0, len(values))
for _, val := range values {
parts = append(parts, v.buildValue(val))
}
return parts
}
// buildValue converts literal values into Go types (string, float64, bool).
func (v *filterTreeVisitor) buildValue(ctx grammar.IValueContext) any {
switch {
case ctx == nil:
return nil
case ctx.QUOTED_TEXT() != nil:
txt := ctx.QUOTED_TEXT().GetText()
return trimQuotes(txt)
case ctx.NUMBER() != nil:
number, err := strconv.ParseFloat(ctx.NUMBER().GetText(), 64)
if err != nil {
v.errors = append(v.errors, fmt.Sprintf("failed to parse number %s", ctx.NUMBER().GetText()))
return nil
}
return number
case ctx.BOOL() != nil:
boolText := strings.ToLower(ctx.BOOL().GetText())
return boolText == "true"
case ctx.KEY() != nil:
return ctx.KEY().GetText()
default:
return nil
}
}

View File

@@ -0,0 +1,351 @@
package querybuilder
import (
"reflect"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// helper to build a simple key with unspecified context/datatype.
func fk(_ *testing.T, name string) *telemetrytypes.TelemetryFieldKey {
key := telemetrytypes.GetFieldKeyFromKeyText(name)
return &key
}
func TestExtractFilterExprTree_NestedConditions(t *testing.T) {
tests := []struct {
name string
expr string
want *qbtypes.FilterExprNode
wantErr bool
}{
{
name: "empty expression returns nil",
expr: " ",
want: nil,
},
{
name: "invalid expression returns error",
expr: "attributes.key =",
wantErr: true,
},
{
name: "single simple equality leaf",
expr: "attributes.host.name = 'frontend'",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.host.name")},
Op: qbtypes.FilterOperatorEqual,
Value: "frontend",
},
},
},
},
{
name: "AND with BETWEEN numeric range and NOT boolean comparison",
expr: "attributes.status_code BETWEEN 500 AND 599 AND NOT attributes.is_error = true",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.status_code")},
Op: qbtypes.FilterOperatorBetween,
Value: []any{
float64(500),
float64(599),
},
},
},
},
{
Negated: true,
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.is_error")},
Op: qbtypes.FilterOperatorEqual,
Value: true,
},
},
},
},
},
},
{
name: "OR with IN list and NOT IN list using different syntaxes",
expr: "attributes.service.name IN ('api','worker') OR resource.region NOT IN [\"us-east-1\",\"us-west-2\"]",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.service.name")},
Op: qbtypes.FilterOperatorIn,
Value: []any{
"api",
"worker",
},
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "resource.region")},
Op: qbtypes.FilterOperatorNotIn,
Value: []any{
"us-east-1",
"us-west-2",
},
},
},
},
},
},
},
{
name: "AND chain of string pattern operators with NOT variants",
expr: "attributes.message LIKE 'error%' AND attributes.message NOT ILIKE '%debug%' AND attributes.message REGEXP 'err[0-9]+' AND attributes.message NOT CONTAINS 'trace'",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.message")},
Op: qbtypes.FilterOperatorLike,
Value: "error%",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.message")},
Op: qbtypes.FilterOperatorNotILike,
Value: "%debug%",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.message")},
Op: qbtypes.FilterOperatorRegexp,
Value: "err[0-9]+",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.message")},
Op: qbtypes.FilterOperatorNotContains,
Value: "trace",
},
},
},
},
},
},
{
name: "EXISTS and NOT EXISTS in OR expression",
expr: "attributes.host EXISTS OR attributes.cluster NOT EXISTS",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.host")},
Op: qbtypes.FilterOperatorExists,
Value: nil,
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.cluster")},
Op: qbtypes.FilterOperatorNotExists,
Value: nil,
},
},
},
},
},
},
{
name: "KEY used as value in equality",
expr: "attributes.left = other_key",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.left")},
Op: qbtypes.FilterOperatorEqual,
Value: "other_key",
},
},
},
},
{
name: "nested OR inside AND with NOT on inner group",
// NOT applies to the whole parenthesized OR group.
expr: "attributes.env = 'prod' AND NOT (attributes.team = 'core' OR attributes.team = 'platform')",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.env")},
Op: qbtypes.FilterOperatorEqual,
Value: "prod",
},
},
},
{
Negated: true,
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.team")},
Op: qbtypes.FilterOperatorEqual,
Value: "core",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.team")},
Op: qbtypes.FilterOperatorEqual,
Value: "platform",
},
},
},
},
},
},
},
},
{
name: "multiple nesting levels mixing AND/OR",
expr: "(attributes.status = 'critical' OR attributes.status = 'warning') AND (resource.region = 'us-east-1' OR (resource.region = 'us-west-2' AND attributes.tier = 'backend'))",
want: &qbtypes.FilterExprNode{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.status")},
Op: qbtypes.FilterOperatorEqual,
Value: "critical",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.status")},
Op: qbtypes.FilterOperatorEqual,
Value: "warning",
},
},
},
},
},
{
Op: qbtypes.LogicalOpOr,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "resource.region")},
Op: qbtypes.FilterOperatorEqual,
Value: "us-east-1",
},
},
},
{
Op: qbtypes.LogicalOpAnd,
Children: []*qbtypes.FilterExprNode{
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "resource.region")},
Op: qbtypes.FilterOperatorEqual,
Value: "us-west-2",
},
},
},
{
Op: qbtypes.LogicalOpLeaf,
Conditions: []qbtypes.FilterCondition{
{
Keys: []*telemetrytypes.TelemetryFieldKey{fk(t, "attributes.tier")},
Op: qbtypes.FilterOperatorEqual,
Value: "backend",
},
},
},
},
},
},
},
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
got, err := ExtractFilterExprTree(tt.expr)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("unexpected tree for expr %q\n got: %#v\n want: %#v", tt.expr, got, tt.want)
}
})
}
}

View File

@@ -151,8 +151,7 @@ func (c *AccountConfig) Value() (driver.Value, error) {
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "couldn't serialize cloud account config to JSON")
}
// Return as string instead of []byte to ensure PostgreSQL stores as text, not bytea
return string(serialized), nil
return serialized, nil
}
type AgentReport struct {
@@ -187,8 +186,7 @@ func (r *AgentReport) Value() (driver.Value, error) {
err, errors.CodeInternal, "couldn't serialize agent report to JSON",
)
}
// Return as string instead of []byte to ensure PostgreSQL stores as text, not bytea
return string(serialized), nil
return serialized, nil
}
type CloudIntegrationService struct {
@@ -242,6 +240,5 @@ func (c *CloudServiceConfig) Value() (driver.Value, error) {
err, errors.CodeInternal, "couldn't serialize cloud service config to JSON",
)
}
// Return as string instead of []byte to ensure PostgreSQL stores as text, not bytea
return string(serialized), nil
return serialized, nil
}

View File

@@ -7,9 +7,9 @@ import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/queryBuilderToExpr"
"github.com/SigNoz/signoz/pkg/types"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/uptrace/bun"
)
@@ -57,7 +57,7 @@ type StoreablePipeline struct {
type GettablePipeline struct {
StoreablePipeline
Filter *v3.FilterSet `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
Config []PipelineOperator `json:"config"`
}
@@ -72,7 +72,7 @@ func (i *GettablePipeline) ParseRawConfig() error {
}
func (i *GettablePipeline) ParseFilter() error {
f := v3.FilterSet{}
f := qbtypes.Filter{}
err := json.Unmarshal([]byte(i.FilterString), &f)
if err != nil {
return errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse filter")
@@ -200,7 +200,7 @@ type PostablePipeline struct {
Alias string `json:"alias"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Filter *v3.FilterSet `json:"filter"`
Filter *qbtypes.Filter `json:"filter"`
Config []PipelineOperator `json:"config"`
}
@@ -218,6 +218,14 @@ func (p *PostablePipeline) IsValid() error {
}
// check the filter
if p.Filter == nil || strings.TrimSpace(p.Filter.Expression) == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "filter.expression is required")
}
// Validate that every field in the expression has an explicit context
// (attribute., resource., body., etc) so later pipeline processing does not
// need to guess. We intentionally do not validate that the field actually
// exists only that the context is specified.
_, err := queryBuilderToExpr.Parse(p.Filter)
if err != nil {
return err

View File

@@ -3,24 +3,13 @@ package pipelinetypes
import (
"testing"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
. "github.com/smartystreets/goconvey/convey"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/stretchr/testify/assert"
)
func TestIsValidPostablePipeline(t *testing.T) {
validPipelineFilterSet := &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeTag,
},
Operator: "=",
Value: "GET",
},
},
validPipelineFilter := &qbtypes.Filter{
Expression: `attributes.method = "GET"`,
}
var correctQueriesTest = []struct {
@@ -34,7 +23,7 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilterSet,
Filter: validPipelineFilter,
Config: []PipelineOperator{},
},
IsValid: false,
@@ -46,7 +35,7 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilterSet,
Filter: validPipelineFilter,
Config: []PipelineOperator{},
},
IsValid: false,
@@ -58,7 +47,7 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilterSet,
Filter: validPipelineFilter,
Config: []PipelineOperator{},
},
IsValid: true,
@@ -70,19 +59,21 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: &v3.FilterSet{
Operator: "AND",
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "method",
DataType: v3.AttributeKeyDataTypeString,
Type: v3.AttributeKeyTypeUnspecified,
},
Operator: "regex",
Value: "[0-9A-Z*",
},
},
Filter: &qbtypes.Filter{
Expression: "",
},
},
IsValid: false,
},
{
Name: "Filter without context prefix on field",
Pipeline: PostablePipeline{
OrderID: 1,
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: &qbtypes.Filter{
Expression: `method = "GET"`,
},
},
IsValid: false,
@@ -94,19 +85,19 @@ func TestIsValidPostablePipeline(t *testing.T) {
Name: "pipeline 1",
Alias: "pipeline1",
Enabled: true,
Filter: validPipelineFilterSet,
Filter: validPipelineFilter,
},
IsValid: true,
},
}
for _, test := range correctQueriesTest {
Convey(test.Name, t, func() {
t.Run(test.Name, func(t *testing.T) {
err := test.Pipeline.IsValid()
if test.IsValid {
So(err, ShouldBeNil)
assert.NoError(t, err)
} else {
So(err, ShouldBeError)
assert.Error(t, err)
}
})
}
@@ -365,12 +356,12 @@ var operatorTest = []struct {
func TestValidOperator(t *testing.T) {
for _, test := range operatorTest {
Convey(test.Name, t, func() {
t.Run(test.Name, func(t *testing.T) {
err := isValidOperator(test.Operator)
if test.IsValid {
So(err, ShouldBeNil)
assert.NoError(t, err)
} else {
So(err, ShouldBeError)
assert.Error(t, err)
}
})
}

View File

@@ -0,0 +1,60 @@
package querybuildertypesv5
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
// LogicalOp represents how child expressions are combined.
type LogicalOp string
const (
// LogicalOpLeaf represents a leaf node containing one or more simple conditions.
LogicalOpLeaf LogicalOp = "LEAF"
// LogicalOpAnd represents an AND combination of children.
LogicalOpAnd LogicalOp = "AND"
// LogicalOpOr represents an OR combination of children.
LogicalOpOr LogicalOp = "OR"
)
// FilterExprNode is a reusable logical representation of a filter expression.
//
// - Leaf nodes (Op == LogicalOpLeaf) contain one or more ParsedFilterCondition.
// - Non-leaf nodes (Op == LogicalOpAnd/LogicalOpOr) contain Children.
// - Negated indicates a leading NOT applied to this subtree.
type FilterExprNode struct {
Op LogicalOp
Negated bool
Conditions []FilterCondition
Children []*FilterExprNode
}
func (f *FilterExprNode) Flatten() []FilterCondition {
var conditions []FilterCondition
var walk func(node *FilterExprNode)
walk = func(node *FilterExprNode) {
if node == nil {
return
}
if node.Op == LogicalOpLeaf {
conditions = append(conditions, node.Conditions...)
}
for _, child := range node.Children {
walk(child)
}
}
walk(f)
return conditions
}
// FilterCondition represents a single comparison or existence check
// extracted from a filter expression.
//
// - Keys: one or more logical field keys on the left-hand side (see where_clause_visitor.VisitKey
// for why one expression key can resolve to multiple TelemetryFieldKeys).
// - Op: filter operator (e.g. =, !=, in, exists, between).
// - Value: right-hand side literal (any type: single value, slice for IN/NOT IN, nil for EXISTS, etc.).
type FilterCondition struct {
Keys []*telemetrytypes.TelemetryFieldKey
Op FilterOperator
Value any
}

View File

@@ -1,200 +0,0 @@
from http import HTTPStatus
from typing import Callable
import requests
from sqlalchemy import text
from wiremock.client import (
HttpMethods,
Mapping,
MappingRequest,
MappingResponse,
WireMockMatchers,
)
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def cleanup_cloud_accounts(postgres: types.TestContainerSQL) -> None:
try:
with postgres.conn.connect() as conn:
conn.execute(text("TRUNCATE TABLE cloud_integration CASCADE"))
conn.commit()
logger.info("Cleaned up cloud_integration table")
except Exception: # pylint: disable=broad-except
logger.info("Cleanup skipped or table does not exist")
def test_generate_connection_url(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test to generate connection URL for AWS CloudFormation stack deployment."""
# Clean up any corrupted data from previous test runs
cleanup_cloud_accounts(postgres)
# Get authentication token for admin user
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
cloud_provider = "aws"
# Mock the deployment info query (for license validation)
make_http_mocks(
signoz.zeus,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v2/deployments/me",
headers={
"X-Signoz-Cloud-Api-Key": {
WireMockMatchers.EQUAL_TO: "secret-key"
}
},
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "test-deployment",
"cluster": {"region": {"dns": "test.signoz.cloud"}},
},
},
),
persistent=False,
)
],
)
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts/generate-connection-url"
# Prepare request payload
request_payload = {
"account_config": {"regions": ["us-east-1", "us-west-2"]},
"agent_config": {
"region": "us-east-1",
"ingestion_url": "https://ingest.test.signoz.cloud",
"ingestion_key": "test-ingestion-key-123456",
"signoz_api_url": "https://test-deployment.test.signoz.cloud",
"signoz_api_key": "test-api-key-789",
"version": "v0.0.8",
},
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=request_payload,
timeout=10,
)
# Assert successful response
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}: {response.text}"
# Parse response JSON
response_data = response.json()
# Assert response structure contains expected data
assert "data" in response_data, "Response should contain 'data' field"
# Assert required fields in the response data
expected_fields = ["account_id", "connection_url"]
for field in expected_fields:
assert (
field in response_data["data"]
), f"Response data should contain '{field}' field"
data = response_data["data"]
# Assert account_id is a valid UUID format
assert (
len(data["account_id"]) > 0
), "account_id should be a non-empty string (UUID)"
# Assert connection_url contains expected CloudFormation parameters
connection_url = data["connection_url"]
# Verify it's an AWS CloudFormation URL
assert (
"console.aws.amazon.com/cloudformation" in connection_url
), "connection_url should be an AWS CloudFormation URL"
# Verify region is included
assert (
"region=us-east-1" in connection_url
), "connection_url should contain the specified region"
# Verify required parameters are in the URL
required_params = [
"param_SigNozIntegrationAgentVersion=v0.0.8",
"param_SigNozApiUrl=https%3A%2F%2Ftest-deployment.test.signoz.cloud",
"param_SigNozApiKey=test-api-key-789",
"param_SigNozAccountId=", # Will be a UUID
"param_IngestionUrl=https%3A%2F%2Fingest.test.signoz.cloud",
"param_IngestionKey=test-ingestion-key-123456",
"stackName=signoz-integration",
"templateURL=https%3A%2F%2Fsignoz-integrations.s3.us-east-1.amazonaws.com%2Faws-quickcreate-template-v0.0.8.json",
]
for param in required_params:
assert (
param in connection_url
), f"connection_url should contain parameter: {param}"
def test_generate_connection_url_unsupported_provider(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
) -> None:
"""Test that unsupported cloud providers return an error."""
# Get authentication token for admin user
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
# Try with GCP (unsupported)
cloud_provider = "gcp"
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts/generate-connection-url"
request_payload = {
"account_config": {"regions": ["us-central1"]},
"agent_config": {
"region": "us-central1",
"ingestion_url": "https://ingest.test.signoz.cloud",
"ingestion_key": "test-ingestion-key-123456",
"signoz_api_url": "https://test-deployment.test.signoz.cloud",
"signoz_api_key": "test-api-key-789",
},
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=request_payload,
timeout=10,
)
# Should return Bad Request for unsupported provider
assert (
response.status_code == HTTPStatus.BAD_REQUEST
), f"Expected 400 for unsupported provider, got {response.status_code}"
response_data = response.json()
assert "error" in response_data, "Response should contain 'error' field"
assert (
"unsupported cloud provider" in response_data["error"].lower()
), "Error message should indicate unsupported provider"

View File

@@ -1,574 +0,0 @@
from http import HTTPStatus
from typing import Callable
import uuid
import pytest
import requests
from sqlalchemy import text
from wiremock.client import (
HttpMethods,
Mapping,
MappingRequest,
MappingResponse,
WireMockMatchers,
)
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def cleanup_cloud_accounts(postgres: types.TestContainerSQL) -> None:
try:
with postgres.conn.connect() as conn:
# Try to delete all records instead of truncate in case table exists
conn.execute(text("DELETE FROM cloud_integration"))
conn.commit()
logger.info("Cleaned up cloud_integration table")
except Exception: # pylint: disable=broad-except
# Table might not exist, which is fine
logger.info("Cleanup skipped or partial")
def generate_unique_cloud_account_id() -> str:
"""Generate a unique cloud account ID for testing."""
# Use last 12 digits of UUID to simulate AWS account ID format
return str(uuid.uuid4().int)[:12]
def simulate_agent_checkin(
signoz: types.SigNoz,
admin_token: str,
cloud_provider: str,
account_id: str,
cloud_account_id: str,
) -> dict:
"""Simulate an agent check-in to mark the account as connected.
Returns:
dict with the response from check-in
"""
endpoint = (
f"/api/v1/cloud-integrations/{cloud_provider}/agent-check-in"
)
checkin_payload = {
"account_id": account_id,
"cloud_account_id": cloud_account_id,
"data": {},
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=checkin_payload,
timeout=10,
)
if response.status_code != HTTPStatus.OK:
logger.error("Agent check-in failed: %s, response: %s", response.status_code, response.text)
assert (
response.status_code == HTTPStatus.OK
), f"Agent check-in failed: {response.status_code}"
response_data = response.json()
return response_data.get("data", response_data)
def create_test_account(
signoz: types.SigNoz,
admin_token: str,
cloud_provider: str = "aws",
) -> dict:
"""Create a test account via generate-connection-url.
Returns the data as-is from the API response. Caller is responsible for
doing agent check-in if needed to mark the account as connected.
Returns:
dict with account_id and connection_url from the API
"""
endpoint = (
f"/api/v1/cloud-integrations/{cloud_provider}/accounts/generate-connection-url"
)
request_payload = {
"account_config": {"regions": ["us-east-1"]},
"agent_config": {
"region": "us-east-1",
"ingestion_url": "https://ingest.test.signoz.cloud",
"ingestion_key": "test-ingestion-key-123456",
"signoz_api_url": "https://test-deployment.test.signoz.cloud",
"signoz_api_key": "test-api-key-789",
"version": "v0.0.8",
},
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=request_payload,
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Failed to create test account: {response.status_code}"
response_data = response.json()
# API returns data wrapped in {'status': 'success', 'data': {...}}
data = response_data.get("data", response_data)
return data
def test_list_connected_accounts_empty(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test listing connected accounts when there are none."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
cloud_provider = "aws"
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts"
response = requests.get(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
response_data = response.json()
# API returns data wrapped in {'status': 'success', 'data': {...}}
data = response_data.get("data", response_data)
assert "accounts" in data, "Response should contain 'accounts' field"
assert isinstance(data["accounts"], list), "Accounts should be a list"
def test_list_connected_accounts_with_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test listing connected accounts after creating one."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
# Mock the deployment info query
make_http_mocks(
signoz.zeus,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v2/deployments/me",
headers={
"X-Signoz-Cloud-Api-Key": {
WireMockMatchers.EQUAL_TO: "secret-key"
}
},
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "test-deployment",
"cluster": {"region": {"dns": "test.signoz.cloud"}},
},
},
),
persistent=False,
)
],
)
# Create a test account
cloud_provider = "aws"
account_data = create_test_account(signoz, admin_token, cloud_provider)
account_id = account_data["account_id"]
# Simulate agent check-in to mark as connected
cloud_account_id = generate_unique_cloud_account_id()
simulate_agent_checkin(signoz, admin_token, cloud_provider, account_id, cloud_account_id)
# List accounts
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts"
response = requests.get(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
response_data = response.json()
data = response_data.get("data", response_data)
assert "accounts" in data, "Response should contain 'accounts' field"
assert isinstance(data["accounts"], list), "Accounts should be a list"
# Find our account in the list (there may be leftover accounts from previous test runs)
account = next((a for a in data["accounts"] if a["id"] == account_id), None)
assert account is not None, f"Account {account_id} should be found in list"
assert account["id"] == account_id, "Account ID should match"
assert "config" in account, "Account should have config field"
assert "status" in account, "Account should have status field"
def test_get_account_status(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test getting the status of a specific account."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
# Mock the deployment info query
make_http_mocks(
signoz.zeus,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v2/deployments/me",
headers={
"X-Signoz-Cloud-Api-Key": {
WireMockMatchers.EQUAL_TO: "secret-key"
}
},
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "test-deployment",
"cluster": {"region": {"dns": "test.signoz.cloud"}},
},
},
),
persistent=False,
)
],
)
# Create a test account (no check-in needed for status check)
cloud_provider = "aws"
account_data = create_test_account(signoz, admin_token, cloud_provider)
account_id = account_data["account_id"]
# Get account status
endpoint = (
f"/api/v1/cloud-integrations/{cloud_provider}/accounts/{account_id}/status"
)
response = requests.get(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
response_data = response.json()
data = response_data.get("data", response_data)
assert "id" in data, "Response should contain 'id' field"
assert data["id"] == account_id, "Account ID should match"
assert "status" in data, "Response should contain 'status' field"
assert "integration" in data["status"], "Status should contain 'integration' field"
def test_get_account_status_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test getting status for a non-existent account."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
cloud_provider = "aws"
fake_account_id = "00000000-0000-0000-0000-000000000000"
endpoint = (
f"/api/v1/cloud-integrations/{cloud_provider}/accounts/{fake_account_id}/status"
)
response = requests.get(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Expected 404, got {response.status_code}"
def test_update_account_config(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test updating account configuration."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
# Mock the deployment info query
make_http_mocks(
signoz.zeus,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v2/deployments/me",
headers={
"X-Signoz-Cloud-Api-Key": {
WireMockMatchers.EQUAL_TO: "secret-key"
}
},
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "test-deployment",
"cluster": {"region": {"dns": "test.signoz.cloud"}},
},
},
),
persistent=False,
)
],
)
# Create a test account
cloud_provider = "aws"
account_data = create_test_account(signoz, admin_token, cloud_provider)
account_id = account_data["account_id"]
# Simulate agent check-in to mark as connected
cloud_account_id = generate_unique_cloud_account_id()
simulate_agent_checkin(signoz, admin_token, cloud_provider, account_id, cloud_account_id)
# Update account configuration
endpoint = (
f"/api/v1/cloud-integrations/{cloud_provider}/accounts/{account_id}/config"
)
updated_config = {
"config": {"regions": ["us-east-1", "us-west-2", "eu-west-1"]}
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=updated_config,
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
response_data = response.json()
data = response_data.get("data", response_data)
assert "id" in data, "Response should contain 'id' field"
assert data["id"] == account_id, "Account ID should match"
# Verify the update by listing accounts
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts"
list_response = requests.get(
signoz.self.host_configs["8080"].get(list_endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
list_response_data = list_response.json()
list_data = list_response_data.get("data", list_response_data)
account = next((a for a in list_data["accounts"] if a["id"] == account_id), None)
assert account is not None, "Account should be found in list"
assert "config" in account, "Account should have config"
assert "regions" in account["config"], "Config should have regions"
assert len(account["config"]["regions"]) == 3, "Should have 3 regions"
assert set(account["config"]["regions"]) == {
"us-east-1",
"us-west-2",
"eu-west-1",
}, "Regions should match updated config"
def test_disconnect_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test disconnecting an account."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
# Mock the deployment info query
make_http_mocks(
signoz.zeus,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v2/deployments/me",
headers={
"X-Signoz-Cloud-Api-Key": {
WireMockMatchers.EQUAL_TO: "secret-key"
}
},
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "test-deployment",
"cluster": {"region": {"dns": "test.signoz.cloud"}},
},
},
),
persistent=False,
)
],
)
# Create a test account
cloud_provider = "aws"
account_data = create_test_account(signoz, admin_token, cloud_provider)
account_id = account_data["account_id"]
# Simulate agent check-in to mark as connected
cloud_account_id = generate_unique_cloud_account_id()
simulate_agent_checkin(signoz, admin_token, cloud_provider, account_id, cloud_account_id)
# Disconnect the account
endpoint = (
f"/api/v1/cloud-integrations/{cloud_provider}/accounts/{account_id}/disconnect"
)
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
# Verify our specific account is no longer in the connected list
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts"
list_response = requests.get(
signoz.self.host_configs["8080"].get(list_endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
list_response_data = list_response.json()
list_data = list_response_data.get("data", list_response_data)
# Check that our specific account is not in the list
disconnected_account = next(
(a for a in list_data["accounts"] if a["id"] == account_id), None
)
assert disconnected_account is None, f"Account {account_id} should be removed from connected accounts"
def test_disconnect_account_not_found(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test disconnecting a non-existent account."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
cloud_provider = "aws"
fake_account_id = "00000000-0000-0000-0000-000000000000"
endpoint = (
f"/api/v1/cloud-integrations/{cloud_provider}/accounts/{fake_account_id}/disconnect"
)
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Expected 404, got {response.status_code}"
def test_list_accounts_unsupported_provider(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test listing accounts for an unsupported cloud provider."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
cloud_provider = "gcp" # Unsupported provider
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts"
response = requests.get(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.BAD_REQUEST
), f"Expected 400, got {response.status_code}"

View File

@@ -1,759 +0,0 @@
from http import HTTPStatus
from typing import Callable
import uuid
import requests
from sqlalchemy import text
from wiremock.client import (
HttpMethods,
Mapping,
MappingRequest,
MappingResponse,
WireMockMatchers,
)
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def cleanup_cloud_accounts(postgres: types.TestContainerSQL) -> None:
"""Clean up cloud_integration table to avoid corrupted data issues."""
try:
with postgres.conn.connect() as conn:
# Try to delete all records instead of truncate in case table exists
conn.execute(text("DELETE FROM cloud_integration"))
conn.commit()
logger.info("Cleaned up cloud_integration table")
except Exception: # pylint: disable=broad-except
# Table might not exist, which is fine
logger.info("Cleanup skipped or partial")
def generate_unique_cloud_account_id() -> str:
"""Generate a unique cloud account ID for testing."""
# Use last 12 digits of UUID to simulate AWS account ID format
return str(uuid.uuid4().int)[:12]
def simulate_agent_checkin(
signoz: types.SigNoz,
admin_token: str,
cloud_provider: str,
account_id: str,
cloud_account_id: str,
) -> dict:
"""Simulate an agent check-in to mark the account as connected.
Returns:
dict with the response from check-in
"""
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/agent-check-in"
checkin_payload = {
"account_id": account_id,
"cloud_account_id": cloud_account_id,
"data": {},
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=checkin_payload,
timeout=10,
)
if response.status_code != HTTPStatus.OK:
logger.error(
"Agent check-in failed: %s, response: %s", response.status_code, response.text
)
assert (
response.status_code == HTTPStatus.OK
), f"Agent check-in failed: {response.status_code}"
response_data = response.json()
return response_data.get("data", response_data)
def create_test_account(
signoz: types.SigNoz,
admin_token: str,
cloud_provider: str = "aws",
) -> dict:
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts/generate-connection-url"
request_payload = {
"account_config": {"regions": ["us-east-1"]},
"agent_config": {
"region": "us-east-1",
"ingestion_url": "https://ingest.test.signoz.cloud",
"ingestion_key": "test-ingestion-key-123456",
"signoz_api_url": "https://test-deployment.test.signoz.cloud",
"signoz_api_key": "test-api-key-789",
"version": "v0.0.8",
},
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=request_payload,
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Failed to create test account: {response.status_code}"
response_data = response.json()
# API returns data wrapped in {'status': 'success', 'data': {...}}
data = response_data.get("data", response_data)
return data
def test_list_services_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test listing available services without specifying an account."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
cloud_provider = "aws"
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
response = requests.get(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
response_data = response.json()
data = response_data.get("data", response_data)
assert "services" in data, "Response should contain 'services' field"
assert isinstance(data["services"], list), "Services should be a list"
assert len(data["services"]) > 0, "Should have at least one service available"
# Verify service structure
service = data["services"][0]
assert "id" in service, "Service should have 'id' field"
assert "title" in service, "Service should have 'title' field"
assert "icon" in service, "Service should have 'icon' field"
def test_list_services_with_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test listing services for a specific connected account."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
# Mock the deployment info query
make_http_mocks(
signoz.zeus,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v2/deployments/me",
headers={
"X-Signoz-Cloud-Api-Key": {
WireMockMatchers.EQUAL_TO: "secret-key"
}
},
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "test-deployment",
"cluster": {"region": {"dns": "test.signoz.cloud"}},
},
},
),
persistent=False,
)
],
)
# Create a test account and do check-in
cloud_provider = "aws"
account_data = create_test_account(signoz, admin_token, cloud_provider)
account_id = account_data["account_id"]
cloud_account_id = generate_unique_cloud_account_id()
simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
# List services for the account
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services?cloud_account_id={cloud_account_id}"
response = requests.get(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
response_data = response.json()
data = response_data.get("data", response_data)
assert "services" in data, "Response should contain 'services' field"
assert isinstance(data["services"], list), "Services should be a list"
assert len(data["services"]) > 0, "Should have at least one service available"
# Services should include config field (may be null if not configured)
service = data["services"][0]
assert "id" in service, "Service should have 'id' field"
assert "title" in service, "Service should have 'title' field"
assert "icon" in service, "Service should have 'icon' field"
def test_get_service_details_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test getting service details without specifying an account."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
cloud_provider = "aws"
# First get the list of services to get a valid service ID
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
list_response = requests.get(
signoz.self.host_configs["8080"].get(list_endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
list_data = list_response.json().get("data", list_response.json())
assert len(list_data["services"]) > 0, "Should have at least one service"
service_id = list_data["services"][0]["id"]
# Get service details
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services/{service_id}"
response = requests.get(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
response_data = response.json()
data = response_data.get("data", response_data)
# Verify service details structure
assert "id" in data, "Service details should have 'id' field"
assert data["id"] == service_id, "Service ID should match requested ID"
assert "title" in data, "Service details should have 'name' field"
assert "overview" in data, "Service details should have 'overview' field"
# assert assets to had list of dashboards
assert "assets" in data, "Service details should have 'assets' field"
assert isinstance(data["assets"], dict), "Assets should be a dictionary"
def test_get_service_details_with_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test getting service details for a specific connected account."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
# Mock the deployment info query
make_http_mocks(
signoz.zeus,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v2/deployments/me",
headers={
"X-Signoz-Cloud-Api-Key": {
WireMockMatchers.EQUAL_TO: "secret-key"
}
},
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "test-deployment",
"cluster": {"region": {"dns": "test.signoz.cloud"}},
},
},
),
persistent=False,
)
],
)
# Create a test account and do check-in
cloud_provider = "aws"
account_data = create_test_account(signoz, admin_token, cloud_provider)
account_id = account_data["account_id"]
cloud_account_id = generate_unique_cloud_account_id()
simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
# Get list of services first
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
list_response = requests.get(
signoz.self.host_configs["8080"].get(list_endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
list_data = list_response.json().get("data", list_response.json())
assert len(list_data["services"]) > 0, "Should have at least one service"
service_id = list_data["services"][0]["id"]
# Get service details with account
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services/{service_id}?cloud_account_id={cloud_account_id}"
response = requests.get(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
response_data = response.json()
data = response_data.get("data", response_data)
# Verify service details structure
assert "id" in data, "Service details should have 'id' field"
assert data["id"] == service_id, "Service ID should match requested ID"
assert "title" in data, "Service details should have 'title' field"
assert "overview" in data, "Service details should have 'overview' field"
assert "assets" in data, "Service details should have 'assets' field"
assert "config" in data, "Service details should have 'config' field"
assert "status" in data, "Config should have 'status' field"
def test_get_service_details_invalid_service(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test getting details for a non-existent service."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
cloud_provider = "aws"
fake_service_id = "non-existent-service"
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services/{fake_service_id}"
response = requests.get(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Expected 404, got {response.status_code}"
def test_list_services_unsupported_provider(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test listing services for an unsupported cloud provider."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
cloud_provider = "gcp" # Unsupported provider
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
response = requests.get(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
assert (
response.status_code == HTTPStatus.BAD_REQUEST
), f"Expected 400, got {response.status_code}"
def test_update_service_config(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test updating service configuration for a connected account."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
# Mock the deployment info query
make_http_mocks(
signoz.zeus,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v2/deployments/me",
headers={
"X-Signoz-Cloud-Api-Key": {
WireMockMatchers.EQUAL_TO: "secret-key"
}
},
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "test-deployment",
"cluster": {"region": {"dns": "test.signoz.cloud"}},
},
},
),
persistent=False,
)
],
)
# Create a test account and do check-in
cloud_provider = "aws"
account_data = create_test_account(signoz, admin_token, cloud_provider)
account_id = account_data["account_id"]
cloud_account_id = generate_unique_cloud_account_id()
simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
# Get list of services to pick a valid service ID
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
list_response = requests.get(
signoz.self.host_configs["8080"].get(list_endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
list_data = list_response.json().get("data", list_response.json())
assert len(list_data["services"]) > 0, "Should have at least one service"
service_id = list_data["services"][0]["id"]
# Update service configuration
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services/{service_id}/config"
config_payload = {
"cloud_account_id": cloud_account_id,
"config": {
"metrics": {"enabled": True},
"logs": {"enabled": True},
},
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=config_payload,
timeout=10,
)
assert (
response.status_code == HTTPStatus.OK
), f"Expected 200, got {response.status_code}"
response_data = response.json()
data = response_data.get("data", response_data)
# Verify response structure
assert "id" in data, "Response should contain 'id' field"
assert data["id"] == service_id, "Service ID should match"
assert "config" in data, "Response should contain 'config' field"
assert "metrics" in data["config"], "Config should contain 'metrics' field"
assert "logs" in data["config"], "Config should contain 'logs' field"
def test_update_service_config_without_account(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test updating service config without a connected account should fail."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
cloud_provider = "aws"
# Get a valid service ID
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
list_response = requests.get(
signoz.self.host_configs["8080"].get(list_endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
list_data = list_response.json().get("data", list_response.json())
service_id = list_data["services"][0]["id"]
# Try to update config with non-existent account
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services/{service_id}/config"
fake_cloud_account_id = generate_unique_cloud_account_id()
config_payload = {
"cloud_account_id": fake_cloud_account_id,
"config": {
"metrics": {"enabled": True},
},
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=config_payload,
timeout=10,
)
assert (
response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
), f"Expected 500 for non-existent account, got {response.status_code}"
def test_update_service_config_invalid_service(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test updating config for a non-existent service should fail."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
# Mock the deployment info query
make_http_mocks(
signoz.zeus,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v2/deployments/me",
headers={
"X-Signoz-Cloud-Api-Key": {
WireMockMatchers.EQUAL_TO: "secret-key"
}
},
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "test-deployment",
"cluster": {"region": {"dns": "test.signoz.cloud"}},
},
},
),
persistent=False,
)
],
)
# Create a test account and do check-in
cloud_provider = "aws"
account_data = create_test_account(signoz, admin_token, cloud_provider)
account_id = account_data["account_id"]
cloud_account_id = generate_unique_cloud_account_id()
simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
# Try to update config for invalid service
fake_service_id = "non-existent-service"
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services/{fake_service_id}/config"
config_payload = {
"cloud_account_id": cloud_account_id,
"config": {
"metrics": {"enabled": True},
},
}
response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=config_payload,
timeout=10,
)
assert (
response.status_code == HTTPStatus.NOT_FOUND
), f"Expected 404 for invalid service, got {response.status_code}"
def test_update_service_config_disable_service(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list], None],
get_token: Callable[[str, str], str],
postgres: types.TestContainerSQL,
) -> None:
"""Test disabling a service by updating config with enabled=false."""
cleanup_cloud_accounts(postgres)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
add_license(signoz, make_http_mocks, get_token)
# Mock the deployment info query
make_http_mocks(
signoz.zeus,
[
Mapping(
request=MappingRequest(
method=HttpMethods.GET,
url="/v2/deployments/me",
headers={
"X-Signoz-Cloud-Api-Key": {
WireMockMatchers.EQUAL_TO: "secret-key"
}
},
),
response=MappingResponse(
status=200,
json_body={
"status": "success",
"data": {
"name": "test-deployment",
"cluster": {"region": {"dns": "test.signoz.cloud"}},
},
},
),
persistent=False,
)
],
)
# Create a test account and do check-in
cloud_provider = "aws"
account_data = create_test_account(signoz, admin_token, cloud_provider)
account_id = account_data["account_id"]
cloud_account_id = generate_unique_cloud_account_id()
simulate_agent_checkin(
signoz, admin_token, cloud_provider, account_id, cloud_account_id
)
# Get a valid service
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
list_response = requests.get(
signoz.self.host_configs["8080"].get(list_endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=10,
)
list_data = list_response.json().get("data", list_response.json())
service_id = list_data["services"][0]["id"]
# First enable the service
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services/{service_id}/config"
enable_payload = {
"cloud_account_id": cloud_account_id,
"config": {
"metrics": {"enabled": True},
},
}
enable_response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=enable_payload,
timeout=10,
)
assert enable_response.status_code == HTTPStatus.OK, "Failed to enable service"
# Now disable the service
disable_payload = {
"cloud_account_id": cloud_account_id,
"config": {
"metrics": {"enabled": False},
"logs": {"enabled": False},
},
}
disable_response = requests.post(
signoz.self.host_configs["8080"].get(endpoint),
headers={"Authorization": f"Bearer {admin_token}"},
json=disable_payload,
timeout=10,
)
assert (
disable_response.status_code == HTTPStatus.OK
), f"Expected 200, got {disable_response.status_code}"
response_data = disable_response.json()
data = response_data.get("data", response_data)
# Verify service is disabled
assert data["config"]["metrics"]["enabled"] is False, "Metrics should be disabled"
assert data["config"]["logs"]["enabled"] is False, "Logs should be disabled"