mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-05 04:30:44 +01:00
Compare commits
34 Commits
SIG-5270
...
feat/query
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
694d9958db | ||
|
|
addee4c0a5 | ||
|
|
f10cf7ac04 | ||
|
|
b336678639 | ||
|
|
c438b3444e | ||
|
|
b624414507 | ||
|
|
bde7963444 | ||
|
|
2df93ff217 | ||
|
|
f496a6ecde | ||
|
|
599e230a72 | ||
|
|
9a0e32ff3b | ||
|
|
5fe2732698 | ||
|
|
4993a44ecc | ||
|
|
ebd575a16b | ||
|
|
666582337e | ||
|
|
23512ab05c | ||
|
|
1423749529 | ||
|
|
4437630127 | ||
|
|
89639b239e | ||
|
|
785ae9f0bd | ||
|
|
8752022cef | ||
|
|
c7e4a9c45d | ||
|
|
bf92c92204 | ||
|
|
bd63633be7 | ||
|
|
1158e1199b | ||
|
|
0a60c49314 | ||
|
|
c25e3beb81 | ||
|
|
c9e0f2b9ca | ||
|
|
6d831849c1 | ||
|
|
83eeb46f99 | ||
|
|
287558dc9d | ||
|
|
83aad793c2 | ||
|
|
3eff689c85 | ||
|
|
f5bcd65e2e |
@@ -42,7 +42,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
schema-migrator-sync:
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
condition: service_healthy
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.99.0
|
||||
image: signoz/signoz:v0.100.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -209,7 +209,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.7
|
||||
image: signoz/signoz-otel-collector:v0.129.8
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -233,7 +233,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.99.0
|
||||
image: signoz/signoz:v0.100.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -150,7 +150,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.7
|
||||
image: signoz/signoz-otel-collector:v0.129.8
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.99.0}
|
||||
image: signoz/signoz:${VERSION:-v0.100.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -239,7 +239,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -250,7 +250,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.99.0}
|
||||
image: signoz/signoz:${VERSION:-v0.100.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -144,7 +144,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -166,7 +166,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -178,7 +178,7 @@ services:
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
153
ee/sqlstore/postgressqlstore/formatter.go
Normal file
153
ee/sqlstore/postgressqlstore/formatter.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package postgressqlstore
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun/schema"
|
||||
)
|
||||
|
||||
type formatter struct {
|
||||
bunf schema.Formatter
|
||||
}
|
||||
|
||||
func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
|
||||
return &formatter{bunf: schema.NewFormatter(dialect)}
|
||||
}
|
||||
|
||||
func (f *formatter) JSONExtractString(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, f.convertJSONPathToPostgres(path)...)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONType(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_typeof("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONIsArray(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, f.JSONType(column, path)...)
|
||||
sql = append(sql, " = "...)
|
||||
sql = schema.Append(f.bunf, sql, "array")
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayElements(column, path, alias string) ([]byte, []byte) {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_array_elements("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
|
||||
sql = append(sql, ") AS "...)
|
||||
sql = f.bunf.AppendIdent(sql, alias)
|
||||
|
||||
return sql, []byte(alias)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayOfStrings(column, path, alias string) ([]byte, []byte) {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_array_elements_text("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
|
||||
sql = append(sql, ") AS "...)
|
||||
sql = f.bunf.AppendIdent(sql, alias)
|
||||
|
||||
return sql, append([]byte(alias), "::text"...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONKeys(column, path, alias string) ([]byte, []byte) {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_each("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
|
||||
sql = append(sql, ") AS "...)
|
||||
sql = f.bunf.AppendIdent(sql, alias)
|
||||
|
||||
return sql, append([]byte(alias), ".key"...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayAgg(expression string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_agg("...)
|
||||
sql = append(sql, expression...)
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayLiteral(values ...string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_build_array("...)
|
||||
for idx, value := range values {
|
||||
if idx > 0 {
|
||||
sql = append(sql, ", "...)
|
||||
}
|
||||
sql = schema.Append(f.bunf, sql, value)
|
||||
}
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) TextToJsonColumn(column string) []byte {
|
||||
var sql []byte
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, "::jsonb"...)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) convertJSONPathToPostgres(jsonPath string) []byte {
|
||||
return f.convertJSONPathToPostgresWithMode(jsonPath, true)
|
||||
}
|
||||
|
||||
func (f *formatter) convertJSONPathToPostgresWithMode(jsonPath string, asText bool) []byte {
|
||||
path := strings.TrimPrefix(strings.TrimPrefix(jsonPath, "$"), ".")
|
||||
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(path, ".")
|
||||
|
||||
var validParts []string
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
validParts = append(validParts, part)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validParts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []byte
|
||||
|
||||
for idx, part := range validParts {
|
||||
if idx == len(validParts)-1 {
|
||||
if asText {
|
||||
result = append(result, "->>"...)
|
||||
} else {
|
||||
result = append(result, "->"...)
|
||||
}
|
||||
result = schema.Append(f.bunf, result, part)
|
||||
return result
|
||||
}
|
||||
|
||||
result = append(result, "->"...)
|
||||
result = schema.Append(f.bunf, result, part)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (f *formatter) LowerExpression(expression string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "lower("...)
|
||||
sql = append(sql, expression...)
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
500
ee/sqlstore/postgressqlstore/formatter_test.go
Normal file
500
ee/sqlstore/postgressqlstore/formatter_test.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package postgressqlstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/uptrace/bun/dialect/pgdialect"
|
||||
)
|
||||
|
||||
func TestJSONExtractString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple path",
|
||||
column: "data",
|
||||
path: "$.field",
|
||||
expected: `"data"->>'field'`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.user.name",
|
||||
expected: `"metadata"->'user'->>'name'`,
|
||||
},
|
||||
{
|
||||
name: "deeply nested path",
|
||||
column: "json_col",
|
||||
path: "$.level1.level2.level3",
|
||||
expected: `"json_col"->'level1'->'level2'->>'level3'`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "$",
|
||||
expected: `"json_col"`,
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
column: "data",
|
||||
path: "",
|
||||
expected: `"data"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONExtractString(tt.column, tt.path))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple path",
|
||||
column: "data",
|
||||
path: "$.field",
|
||||
expected: `jsonb_typeof("data"->'field')`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.user.age",
|
||||
expected: `jsonb_typeof("metadata"->'user'->'age')`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "$",
|
||||
expected: `jsonb_typeof("json_col")`,
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
column: "data",
|
||||
path: "",
|
||||
expected: `jsonb_typeof("data")`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONType(tt.column, tt.path))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONIsArray(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple path",
|
||||
column: "data",
|
||||
path: "$.items",
|
||||
expected: `jsonb_typeof("data"->'items') = 'array'`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.user.tags",
|
||||
expected: `jsonb_typeof("metadata"->'user'->'tags') = 'array'`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "$",
|
||||
expected: `jsonb_typeof("json_col") = 'array'`,
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
column: "data",
|
||||
path: "",
|
||||
expected: `jsonb_typeof("data") = 'array'`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONIsArray(tt.column, tt.path))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayElements(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
alias string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "root path with dollar sign",
|
||||
column: "data",
|
||||
path: "$",
|
||||
alias: "elem",
|
||||
expected: `jsonb_array_elements("data") AS "elem"`,
|
||||
},
|
||||
{
|
||||
name: "root path empty",
|
||||
column: "data",
|
||||
path: "",
|
||||
alias: "elem",
|
||||
expected: `jsonb_array_elements("data") AS "elem"`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.items",
|
||||
alias: "item",
|
||||
expected: `jsonb_array_elements("metadata"->'items') AS "item"`,
|
||||
},
|
||||
{
|
||||
name: "deeply nested path",
|
||||
column: "json_col",
|
||||
path: "$.user.tags",
|
||||
alias: "tag",
|
||||
expected: `jsonb_array_elements("json_col"->'user'->'tags') AS "tag"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got, _ := f.JSONArrayElements(tt.column, tt.path, tt.alias)
|
||||
assert.Equal(t, tt.expected, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayOfStrings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
alias string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "root path with dollar sign",
|
||||
column: "data",
|
||||
path: "$",
|
||||
alias: "str",
|
||||
expected: `jsonb_array_elements_text("data") AS "str"`,
|
||||
},
|
||||
{
|
||||
name: "root path empty",
|
||||
column: "data",
|
||||
path: "",
|
||||
alias: "str",
|
||||
expected: `jsonb_array_elements_text("data") AS "str"`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.strings",
|
||||
alias: "s",
|
||||
expected: `jsonb_array_elements_text("metadata"->'strings') AS "s"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got, _ := f.JSONArrayOfStrings(tt.column, tt.path, tt.alias)
|
||||
assert.Equal(t, tt.expected, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
alias string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "root path with dollar sign",
|
||||
column: "data",
|
||||
path: "$",
|
||||
alias: "k",
|
||||
expected: `jsonb_each("data") AS "k"`,
|
||||
},
|
||||
{
|
||||
name: "root path empty",
|
||||
column: "data",
|
||||
path: "",
|
||||
alias: "k",
|
||||
expected: `jsonb_each("data") AS "k"`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.object",
|
||||
alias: "key",
|
||||
expected: `jsonb_each("metadata"->'object') AS "key"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got, _ := f.JSONKeys(tt.column, tt.path, tt.alias)
|
||||
assert.Equal(t, tt.expected, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayAgg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expression string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple column",
|
||||
expression: "id",
|
||||
expected: "jsonb_agg(id)",
|
||||
},
|
||||
{
|
||||
name: "expression with function",
|
||||
expression: "DISTINCT name",
|
||||
expected: "jsonb_agg(DISTINCT name)",
|
||||
},
|
||||
{
|
||||
name: "complex expression",
|
||||
expression: "data->>'field'",
|
||||
expected: "jsonb_agg(data->>'field')",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONArrayAgg(tt.expression))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayLiteral(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty array",
|
||||
values: []string{},
|
||||
expected: "jsonb_build_array()",
|
||||
},
|
||||
{
|
||||
name: "single value",
|
||||
values: []string{"value1"},
|
||||
expected: "jsonb_build_array('value1')",
|
||||
},
|
||||
{
|
||||
name: "multiple values",
|
||||
values: []string{"value1", "value2", "value3"},
|
||||
expected: "jsonb_build_array('value1', 'value2', 'value3')",
|
||||
},
|
||||
{
|
||||
name: "values with special characters",
|
||||
values: []string{"test", "with space", "with-dash"},
|
||||
expected: "jsonb_build_array('test', 'with space', 'with-dash')",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONArrayLiteral(tt.values...))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertJSONPathToPostgresWithMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jsonPath string
|
||||
asText bool
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple path as text",
|
||||
jsonPath: "$.field",
|
||||
asText: true,
|
||||
expected: "->>'field'",
|
||||
},
|
||||
{
|
||||
name: "simple path as json",
|
||||
jsonPath: "$.field",
|
||||
asText: false,
|
||||
expected: "->'field'",
|
||||
},
|
||||
{
|
||||
name: "nested path as text",
|
||||
jsonPath: "$.user.name",
|
||||
asText: true,
|
||||
expected: "->'user'->>'name'",
|
||||
},
|
||||
{
|
||||
name: "nested path as json",
|
||||
jsonPath: "$.user.name",
|
||||
asText: false,
|
||||
expected: "->'user'->'name'",
|
||||
},
|
||||
{
|
||||
name: "deeply nested as text",
|
||||
jsonPath: "$.a.b.c.d",
|
||||
asText: true,
|
||||
expected: "->'a'->'b'->'c'->>'d'",
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
jsonPath: "$",
|
||||
asText: true,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
jsonPath: "",
|
||||
asText: true,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New()).(*formatter)
|
||||
got := string(f.convertJSONPathToPostgresWithMode(tt.jsonPath, tt.asText))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextToJsonColumn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple column name",
|
||||
column: "data",
|
||||
expected: `"data"::jsonb`,
|
||||
},
|
||||
{
|
||||
name: "column with underscore",
|
||||
column: "user_data",
|
||||
expected: `"user_data"::jsonb`,
|
||||
},
|
||||
{
|
||||
name: "column with special characters",
|
||||
column: "json-col",
|
||||
expected: `"json-col"::jsonb`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.TextToJsonColumn(tt.column))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerExpression(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple column name",
|
||||
expr: "name",
|
||||
expected: "lower(name)",
|
||||
},
|
||||
{
|
||||
name: "quoted column identifier",
|
||||
expr: `"column_name"`,
|
||||
expected: `lower("column_name")`,
|
||||
},
|
||||
{
|
||||
name: "jsonb text extraction",
|
||||
expr: "data->>'field'",
|
||||
expected: "lower(data->>'field')",
|
||||
},
|
||||
{
|
||||
name: "nested jsonb extraction",
|
||||
expr: "metadata->'user'->>'name'",
|
||||
expected: "lower(metadata->'user'->>'name')",
|
||||
},
|
||||
{
|
||||
name: "jsonb_typeof expression",
|
||||
expr: "jsonb_typeof(data->'field')",
|
||||
expected: "lower(jsonb_typeof(data->'field'))",
|
||||
},
|
||||
{
|
||||
name: "string concatenation",
|
||||
expr: "first_name || ' ' || last_name",
|
||||
expected: "lower(first_name || ' ' || last_name)",
|
||||
},
|
||||
{
|
||||
name: "CAST expression",
|
||||
expr: "CAST(value AS TEXT)",
|
||||
expected: "lower(CAST(value AS TEXT))",
|
||||
},
|
||||
{
|
||||
name: "COALESCE expression",
|
||||
expr: "COALESCE(name, 'default')",
|
||||
expected: "lower(COALESCE(name, 'default'))",
|
||||
},
|
||||
{
|
||||
name: "subquery column",
|
||||
expr: "users.email",
|
||||
expected: "lower(users.email)",
|
||||
},
|
||||
{
|
||||
name: "quoted identifier with special chars",
|
||||
expr: `"user-name"`,
|
||||
expected: `lower("user-name")`,
|
||||
},
|
||||
{
|
||||
name: "jsonb to text cast",
|
||||
expr: "data::text",
|
||||
expected: "lower(data::text)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.LowerExpression(tt.expr))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,11 @@ import (
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
sqldb *sql.DB
|
||||
bundb *sqlstore.BunDB
|
||||
dialect *dialect
|
||||
settings factory.ScopedProviderSettings
|
||||
sqldb *sql.DB
|
||||
bundb *sqlstore.BunDB
|
||||
dialect *dialect
|
||||
formatter sqlstore.SQLFormatter
|
||||
}
|
||||
|
||||
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
|
||||
@@ -55,11 +56,14 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
|
||||
sqldb := stdlib.OpenDBFromPool(pool)
|
||||
|
||||
pgDialect := pgdialect.New()
|
||||
bunDB := sqlstore.NewBunDB(settings, sqldb, pgDialect, hooks)
|
||||
return &provider{
|
||||
settings: settings,
|
||||
sqldb: sqldb,
|
||||
bundb: sqlstore.NewBunDB(settings, sqldb, pgdialect.New(), hooks),
|
||||
dialect: new(dialect),
|
||||
settings: settings,
|
||||
sqldb: sqldb,
|
||||
bundb: bunDB,
|
||||
dialect: new(dialect),
|
||||
formatter: newFormatter(bunDB.Dialect()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -75,6 +79,10 @@ func (provider *provider) Dialect() sqlstore.SQLDialect {
|
||||
return provider.dialect
|
||||
}
|
||||
|
||||
func (provider *provider) Formatter() sqlstore.SQLFormatter {
|
||||
return provider.formatter
|
||||
}
|
||||
|
||||
func (provider *provider) BunDBCtx(ctx context.Context) bun.IDB {
|
||||
return provider.bundb.BunDBCtx(ctx)
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ function App(): JSX.Element {
|
||||
chat_settings: {
|
||||
app_id: process.env.PYLON_APP_ID,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
name: user.displayName || user.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,8 +86,9 @@ const interceptorRejected = async (
|
||||
|
||||
if (
|
||||
response.status === 401 &&
|
||||
// if the session rotate call errors out with 401 or the delete sessions call returns 401 then we do not retry!
|
||||
// if the session rotate call or the create session errors out with 401 or the delete sessions call returns 401 then we do not retry!
|
||||
response.config.url !== '/sessions/rotate' &&
|
||||
response.config.url !== '/sessions/email_password' &&
|
||||
!(
|
||||
response.config.url === '/sessions' && response.config.method === 'delete'
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
export const getQueryRangeV5 = async (
|
||||
props: QueryRangePayloadV5,
|
||||
version: string,
|
||||
signal: AbortSignal,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
|
||||
try {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Switch, Table, Tooltip, Typography } from 'antd';
|
||||
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
|
||||
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
|
||||
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
@@ -11,13 +13,12 @@ import {
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
TopErrorsResponseRow,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
|
||||
import { SuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -46,7 +47,7 @@ function TopErrors({
|
||||
true,
|
||||
);
|
||||
|
||||
const queryPayloads = useMemo(
|
||||
const queryPayload = useMemo(
|
||||
() =>
|
||||
getTopErrorsQueryPayload(
|
||||
domainName,
|
||||
@@ -82,37 +83,34 @@ function TopErrors({
|
||||
],
|
||||
);
|
||||
|
||||
const topErrorsDataQueries = useQueries(
|
||||
queryPayloads.map((payload) => ({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
|
||||
payload,
|
||||
DEFAULT_ENTITY_VERSION,
|
||||
showStatusCodeErrors,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(payload, DEFAULT_ENTITY_VERSION),
|
||||
enabled: !!payload,
|
||||
staleTime: 0,
|
||||
cacheTime: 0,
|
||||
})),
|
||||
);
|
||||
|
||||
const topErrorsDataQuery = topErrorsDataQueries[0];
|
||||
const {
|
||||
data: topErrorsData,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = topErrorsDataQuery;
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
|
||||
queryPayload,
|
||||
ENTITY_VERSION_V5,
|
||||
showStatusCodeErrors,
|
||||
],
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: QueryFunctionContext): Promise<SuccessResponseV2<MetricRangePayloadV5>> =>
|
||||
getQueryRangeV5(queryPayload, ENTITY_VERSION_V5, signal),
|
||||
enabled: !!queryPayload,
|
||||
staleTime: 0,
|
||||
cacheTime: 0,
|
||||
});
|
||||
|
||||
const topErrorsColumnsConfig = useMemo(() => getTopErrorsColumnsConfig(), []);
|
||||
|
||||
const formattedTopErrorsData = useMemo(
|
||||
() =>
|
||||
formatTopErrorsDataForTable(
|
||||
topErrorsData?.payload?.data?.result as TopErrorsResponseRow[],
|
||||
topErrorsData?.data?.data?.data?.results[0] as ScalarData,
|
||||
),
|
||||
[topErrorsData],
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
endPointStatusCodeColumns,
|
||||
extractPortAndEndpoint,
|
||||
formatDataForTable,
|
||||
formatTopErrorsDataForTable,
|
||||
getAllEndpointsWidgetData,
|
||||
getCustomFiltersForBarChart,
|
||||
getEndPointDetailsQueryPayload,
|
||||
@@ -23,8 +22,6 @@ import {
|
||||
getStatusCodeBarChartWidgetData,
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
TopErrorsResponseRow,
|
||||
} from '../utils';
|
||||
import { APIMonitoringColumnsMock } from './mock';
|
||||
|
||||
@@ -344,49 +341,6 @@ describe('API Monitoring Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTopErrorsDataForTable', () => {
|
||||
it('should format top errors data correctly', () => {
|
||||
// Arrange
|
||||
const inputData = [
|
||||
{
|
||||
metric: {
|
||||
[SPAN_ATTRIBUTES.URL_PATH]: '/api/test',
|
||||
[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE]: '500',
|
||||
status_message: 'Internal Server Error',
|
||||
},
|
||||
values: [[1000000100, '10']],
|
||||
queryName: 'A',
|
||||
legend: 'Test Legend',
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = formatTopErrorsDataForTable(
|
||||
inputData as TopErrorsResponseRow[],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBe(1);
|
||||
|
||||
// Check first item is formatted correctly
|
||||
expect(result[0].endpointName).toBe('/api/test');
|
||||
expect(result[0].statusCode).toBe('500');
|
||||
expect(result[0].statusMessage).toBe('Internal Server Error');
|
||||
expect(result[0].count).toBe('10');
|
||||
expect(result[0].key).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
// Act
|
||||
const result = formatTopErrorsDataForTable(undefined);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopErrorsColumnsConfig', () => {
|
||||
it('should return column configuration with expected fields', () => {
|
||||
// Act
|
||||
@@ -453,72 +407,6 @@ describe('API Monitoring Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopErrorsQueryPayload', () => {
|
||||
it('should create correct query payload with filters', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const start = 1000000000;
|
||||
const end = 1000010000;
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-filter',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: 'test-key',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'test-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getTopErrorsQueryPayload(
|
||||
domainName,
|
||||
start,
|
||||
end,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify query params
|
||||
expect(result[0].start).toBe(start);
|
||||
expect(result[0].end).toBe(end);
|
||||
|
||||
// Verify correct structure
|
||||
expect(result[0].graphType).toBeDefined();
|
||||
expect(result[0].query).toBeDefined();
|
||||
expect(result[0].query.builder).toBeDefined();
|
||||
expect(result[0].query.builder.queryData).toBeDefined();
|
||||
|
||||
// Verify domain filter is included
|
||||
const queryData = result[0].query.builder.queryData[0];
|
||||
expect(queryData.filters).toBeDefined();
|
||||
|
||||
// Check for domain filter
|
||||
const domainFilter = queryData.filters?.items?.find(
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
(item) =>
|
||||
item.key &&
|
||||
item.key.key === SPAN_ATTRIBUTES.SERVER_NAME &&
|
||||
item.value === domainName,
|
||||
);
|
||||
expect(domainFilter).toBeDefined();
|
||||
|
||||
// Check that custom filters were included
|
||||
const testFilter = queryData.filters?.items?.find(
|
||||
(item) => item.id === 'test-filter',
|
||||
);
|
||||
expect(testFilter).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Add new tests for EndPointDetails utility functions
|
||||
describe('extractPortAndEndpoint', () => {
|
||||
it('should extract port and endpoint from a valid URL', () => {
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
formatTopErrorsDataForTable,
|
||||
getEndPointDetailsQueryPayload,
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { useQueries } from 'react-query';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
|
||||
@@ -35,28 +27,15 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('components/CeleryTask/useNavigateToExplorer', () => ({
|
||||
useNavigateToExplorer: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/ApiMonitoring/utils', () => ({
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY: ['key1', 'key2', 'key3', 'key4', 'key5'],
|
||||
formatTopErrorsDataForTable: jest.fn(),
|
||||
getEndPointDetailsQueryPayload: jest.fn(),
|
||||
getTopErrorsColumnsConfig: jest.fn(),
|
||||
getTopErrorsCoRelationQueryFilters: jest.fn(),
|
||||
getTopErrorsQueryPayload: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('TopErrors', () => {
|
||||
const TABLE_BODY_SELECTOR = '.ant-table-tbody';
|
||||
const V5_QUERY_RANGE_API_PATH = '*/api/v5/query_range';
|
||||
|
||||
const mockProps = {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
domainName: 'test-domain',
|
||||
timeRange: {
|
||||
startTime: 1000000000,
|
||||
@@ -68,75 +47,72 @@ describe('TopErrors', () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Setup basic mocks
|
||||
// Helper function to wait for table data to load
|
||||
const waitForTableDataToLoad = async (
|
||||
container: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
const tableBody = container.querySelector(TABLE_BODY_SELECTOR);
|
||||
expect(tableBody).not.toBeNull();
|
||||
if (tableBody) {
|
||||
expect(
|
||||
within(tableBody as HTMLElement).queryByText('/api/test'),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock getTopErrorsColumnsConfig
|
||||
(getTopErrorsColumnsConfig as jest.Mock).mockReturnValue([
|
||||
{
|
||||
title: 'Endpoint',
|
||||
dataIndex: 'endpointName',
|
||||
key: 'endpointName',
|
||||
},
|
||||
{
|
||||
title: 'Status Code',
|
||||
dataIndex: 'statusCode',
|
||||
key: 'statusCode',
|
||||
},
|
||||
{
|
||||
title: 'Status Message',
|
||||
dataIndex: 'statusMessage',
|
||||
key: 'statusMessage',
|
||||
},
|
||||
{
|
||||
title: 'Count',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
},
|
||||
]);
|
||||
// Mock useNavigateToExplorer
|
||||
(useNavigateToExplorer as jest.Mock).mockReturnValue(jest.fn());
|
||||
|
||||
// Mock useQueries
|
||||
(useQueries as jest.Mock).mockImplementation((queryConfigs) => {
|
||||
// For topErrorsDataQueries
|
||||
if (
|
||||
queryConfigs.length === 1 &&
|
||||
queryConfigs[0].queryKey &&
|
||||
queryConfigs[0].queryKey[0] === REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN
|
||||
) {
|
||||
return [
|
||||
{
|
||||
// Mock V5 API endpoint for top errors
|
||||
server.use(
|
||||
rest.post(V5_QUERY_RANGE_API_PATH, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: {
|
||||
'http.url': '/api/test',
|
||||
status_code: '500',
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
status_message: 'Internal Server Error',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
name: 'http.url',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
values: [[1000000100, '10']],
|
||||
queryName: 'A',
|
||||
legend: 'Test Legend',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'response_status_code',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'status_message',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{ name: 'count()', fieldDataType: 'int64', fieldContext: '' },
|
||||
],
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
data: [['/api/test', '500', 'Internal Server Error', 10]],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// For endPointDropDownDataQueries
|
||||
return [
|
||||
{
|
||||
data: {
|
||||
// Mock V4 API endpoint for dropdown data
|
||||
server.use(
|
||||
rest.post('*/api/v1/query_range', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
@@ -153,62 +129,13 @@ describe('TopErrors', () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
isError: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// Mock formatTopErrorsDataForTable
|
||||
(formatTopErrorsDataForTable as jest.Mock).mockReturnValue([
|
||||
{
|
||||
key: '1',
|
||||
endpointName: '/api/test',
|
||||
statusCode: '500',
|
||||
statusMessage: 'Internal Server Error',
|
||||
count: 10,
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock getTopErrorsQueryPayload
|
||||
(getTopErrorsQueryPayload as jest.Mock).mockReturnValue([
|
||||
{
|
||||
queryName: 'TopErrorsQuery',
|
||||
start: mockProps.timeRange.startTime,
|
||||
end: mockProps.timeRange.endTime,
|
||||
step: 60,
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock getEndPointDetailsQueryPayload
|
||||
(getEndPointDetailsQueryPayload as jest.Mock).mockReturnValue([
|
||||
{},
|
||||
{},
|
||||
{
|
||||
queryName: 'EndpointDropdownQuery',
|
||||
start: mockProps.timeRange.startTime,
|
||||
end: mockProps.timeRange.endTime,
|
||||
step: 60,
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock useNavigateToExplorer
|
||||
(useNavigateToExplorer as jest.Mock).mockReturnValue(jest.fn());
|
||||
|
||||
// Mock getTopErrorsCoRelationQueryFilters
|
||||
(getTopErrorsCoRelationQueryFilters as jest.Mock).mockReturnValue({
|
||||
items: [
|
||||
{ id: 'test1', key: { key: 'domain' }, op: '=', value: 'test-domain' },
|
||||
{ id: 'test2', key: { key: 'endpoint' }, op: '=', value: '/api/test' },
|
||||
{ id: 'test3', key: { key: 'status' }, op: '=', value: '500' },
|
||||
],
|
||||
op: 'AND',
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders component correctly', () => {
|
||||
it('renders component correctly', async () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
const { container } = render(<TopErrors {...mockProps} />);
|
||||
|
||||
@@ -216,10 +143,11 @@ describe('TopErrors', () => {
|
||||
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
|
||||
expect(screen.getByText('Status Message Exists')).toBeInTheDocument();
|
||||
|
||||
// Find the table row and verify content
|
||||
const tableBody = container.querySelector('.ant-table-tbody');
|
||||
expect(tableBody).not.toBeNull();
|
||||
// Wait for data to load
|
||||
await waitForTableDataToLoad(container);
|
||||
|
||||
// Find the table row and verify content
|
||||
const tableBody = container.querySelector(TABLE_BODY_SELECTOR);
|
||||
if (tableBody) {
|
||||
const row = within(tableBody as HTMLElement).getByRole('row');
|
||||
expect(within(row).getByText('/api/test')).toBeInTheDocument();
|
||||
@@ -228,35 +156,40 @@ describe('TopErrors', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders error state when isError is true', () => {
|
||||
// Mock useQueries to return isError: true
|
||||
(useQueries as jest.Mock).mockImplementationOnce(() => [
|
||||
{
|
||||
isError: true,
|
||||
refetch: jest.fn(),
|
||||
},
|
||||
]);
|
||||
it('renders error state when API fails', async () => {
|
||||
// Mock API to return error
|
||||
server.use(
|
||||
rest.post(V5_QUERY_RANGE_API_PATH, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'Internal Server Error' })),
|
||||
),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<TopErrors {...mockProps} />);
|
||||
|
||||
// Error state should be shown with the actual text displayed in the UI
|
||||
expect(
|
||||
screen.getByText('Uh-oh :/ We ran into an error.'),
|
||||
).toBeInTheDocument();
|
||||
// Wait for error state
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Uh-oh :/ We ran into an error.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Please refresh this panel.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Refresh this panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles row click correctly', () => {
|
||||
it('handles row click correctly', async () => {
|
||||
const navigateMock = jest.fn();
|
||||
(useNavigateToExplorer as jest.Mock).mockReturnValue(navigateMock);
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
const { container } = render(<TopErrors {...mockProps} />);
|
||||
|
||||
// Wait for data to load
|
||||
await waitForTableDataToLoad(container);
|
||||
|
||||
// Find and click on the table cell containing the endpoint
|
||||
const tableBody = container.querySelector('.ant-table-tbody');
|
||||
const tableBody = container.querySelector(TABLE_BODY_SELECTOR);
|
||||
expect(tableBody).not.toBeNull();
|
||||
|
||||
if (tableBody) {
|
||||
@@ -267,11 +200,28 @@ describe('TopErrors', () => {
|
||||
|
||||
// Check if navigateToExplorer was called with correct params
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
filters: [
|
||||
{ id: 'test1', key: { key: 'domain' }, op: '=', value: 'test-domain' },
|
||||
{ id: 'test2', key: { key: 'endpoint' }, op: '=', value: '/api/test' },
|
||||
{ id: 'test3', key: { key: 'status' }, op: '=', value: '500' },
|
||||
],
|
||||
filters: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.url' }),
|
||||
op: '=',
|
||||
value: '/api/test',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'has_error' }),
|
||||
op: '=',
|
||||
value: 'true',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'net.peer.name' }),
|
||||
op: '=',
|
||||
value: 'test-domain',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'response_status_code' }),
|
||||
op: '=',
|
||||
value: '500',
|
||||
}),
|
||||
]),
|
||||
dataSource: DataSource.TRACES,
|
||||
startTime: mockProps.timeRange.startTime,
|
||||
endTime: mockProps.timeRange.endTime,
|
||||
@@ -279,24 +229,34 @@ describe('TopErrors', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates endpoint filter when dropdown value changes', () => {
|
||||
it('updates endpoint filter when dropdown value changes', async () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<TopErrors {...mockProps} />);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the dropdown
|
||||
const dropdown = screen.getByRole('combobox');
|
||||
|
||||
// Mock the change
|
||||
fireEvent.change(dropdown, { target: { value: '/api/new-endpoint' } });
|
||||
|
||||
// Check if getTopErrorsQueryPayload was called with updated parameters
|
||||
expect(getTopErrorsQueryPayload).toHaveBeenCalled();
|
||||
// Component should re-render with new filter
|
||||
expect(dropdown).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles status message toggle correctly', () => {
|
||||
it('handles status message toggle correctly', async () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<TopErrors {...mockProps} />);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the toggle switch
|
||||
const toggle = screen.getByRole('switch');
|
||||
expect(toggle).toBeInTheDocument();
|
||||
@@ -307,69 +267,104 @@ describe('TopErrors', () => {
|
||||
// Click the toggle to turn it off
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Check if getTopErrorsQueryPayload was called with showStatusCodeErrors=false
|
||||
expect(getTopErrorsQueryPayload).toHaveBeenCalledWith(
|
||||
mockProps.domainName,
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
expect.any(Object),
|
||||
false,
|
||||
);
|
||||
|
||||
// Title should change
|
||||
expect(screen.getByText('All Errors')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Errors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the toggle to turn it back on
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Check if getTopErrorsQueryPayload was called with showStatusCodeErrors=true
|
||||
expect(getTopErrorsQueryPayload).toHaveBeenCalledWith(
|
||||
mockProps.domainName,
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
expect.any(Object),
|
||||
true,
|
||||
);
|
||||
|
||||
// Title should change back
|
||||
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('includes toggle state in query key for cache busting', () => {
|
||||
it('includes toggle state in query key for cache busting', async () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<TopErrors {...mockProps} />);
|
||||
|
||||
const toggle = screen.getByRole('switch');
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Initial query should include showStatusCodeErrors=true
|
||||
expect(useQueries).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
queryKey: expect.arrayContaining([
|
||||
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
true,
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const toggle = screen.getByRole('switch');
|
||||
|
||||
// Click toggle
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Query should be called with showStatusCodeErrors=false in key
|
||||
expect(useQueries).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
queryKey: expect.arrayContaining([
|
||||
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
false,
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
// Wait for title to change, indicating query was refetched with new key
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Errors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The fact that data refetches when toggle changes proves the query key includes the toggle state
|
||||
expect(toggle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends query_range v5 API call with required filters including has_error', async () => {
|
||||
let capturedRequest: any;
|
||||
|
||||
// Override the v5 API mock to capture the request
|
||||
server.use(
|
||||
rest.post(V5_QUERY_RANGE_API_PATH, async (req, res, ctx) => {
|
||||
capturedRequest = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: {
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
name: 'http.url',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
name: 'response_status_code',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'status_message',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{ name: 'count()', fieldDataType: 'int64', fieldContext: '' },
|
||||
],
|
||||
data: [['/api/test', '500', 'Internal Server Error', 10]],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<TopErrors {...mockProps} />);
|
||||
|
||||
// Wait for the API call to be made
|
||||
await waitFor(() => {
|
||||
expect(capturedRequest).toBeDefined();
|
||||
});
|
||||
|
||||
// Extract the filter expression from the captured request
|
||||
const filterExpression =
|
||||
capturedRequest.compositeQuery.queries[0].spec.filter.expression;
|
||||
|
||||
// Verify all required filters are present
|
||||
expect(filterExpression).toContain(`kind_string = 'Client'`);
|
||||
expect(filterExpression).toContain(`(http.url EXISTS OR url.full EXISTS)`);
|
||||
expect(filterExpression).toContain(
|
||||
`(net.peer.name = 'test-domain' OR server.address = 'test-domain')`,
|
||||
);
|
||||
expect(filterExpression).toContain(`has_error = true`);
|
||||
expect(filterExpression).toContain(`status_message EXISTS`); // toggle is on by default
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, Tag, Tooltip } from 'antd';
|
||||
import { ColumnType } from 'antd/es/table';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
@@ -27,6 +28,11 @@ import {
|
||||
OrderByPayload,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
ColumnDescriptor,
|
||||
QueryRangePayloadV5,
|
||||
ScalarData,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -40,6 +46,9 @@ import {
|
||||
EndPointsResponseRow,
|
||||
} from './types';
|
||||
|
||||
export const isEmptyFilterValue = (value: unknown): boolean =>
|
||||
value === '' || value === null || value === undefined || value === 'n/a';
|
||||
|
||||
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
@@ -816,153 +825,100 @@ export const getEndPointsQueryPayload = (
|
||||
];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function buildFilterExpression(
|
||||
domainName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
showStatusCodeErrors: boolean,
|
||||
): string {
|
||||
const baseFilterParts = [
|
||||
`kind_string = 'Client'`,
|
||||
`(http.url EXISTS OR url.full EXISTS)`,
|
||||
`(net.peer.name = '${domainName}' OR server.address = '${domainName}')`,
|
||||
`has_error = true`,
|
||||
];
|
||||
if (showStatusCodeErrors) {
|
||||
baseFilterParts.push('status_message EXISTS');
|
||||
}
|
||||
const filterExpression = baseFilterParts.join(' AND ');
|
||||
if (!filters) {
|
||||
return filterExpression;
|
||||
}
|
||||
const { filter } = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
filterExpression,
|
||||
);
|
||||
return filter.expression;
|
||||
}
|
||||
|
||||
export const getTopErrorsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
showStatusCodeErrors = true,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: '04da97bd',
|
||||
key: {
|
||||
key: 'kind_string',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'Client',
|
||||
},
|
||||
{
|
||||
id: 'b1af6bdb',
|
||||
key: {
|
||||
key: SPAN_ATTRIBUTES.URL_PATH,
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
op: 'exists',
|
||||
value: '',
|
||||
},
|
||||
...(showStatusCodeErrors
|
||||
? [
|
||||
{
|
||||
id: '75d65388',
|
||||
key: {
|
||||
key: 'status_message',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
op: 'exists',
|
||||
value: '',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: '4872bf91',
|
||||
key: {
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
{
|
||||
id: 'ab4c885d',
|
||||
key: {
|
||||
key: 'has_error',
|
||||
dataType: DataTypes.bool,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: true,
|
||||
},
|
||||
...(filters?.items || []),
|
||||
],
|
||||
},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: 10,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
groupBy: [
|
||||
{
|
||||
key: SPAN_ATTRIBUTES.URL_PATH,
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
key: 'response_status_code',
|
||||
type: '',
|
||||
id: 'response_status_code--string----true',
|
||||
},
|
||||
{
|
||||
key: 'status_message',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
): QueryRangePayloadV5 => {
|
||||
const filterExpression = buildFilterExpression(
|
||||
domainName,
|
||||
filters,
|
||||
showStatusCodeErrors,
|
||||
);
|
||||
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
start,
|
||||
end,
|
||||
step: 240,
|
||||
},
|
||||
];
|
||||
requestType: 'scalar',
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'traces',
|
||||
stepInterval: 60,
|
||||
disabled: false,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: filterExpression },
|
||||
groupBy: [
|
||||
{
|
||||
name: 'http.url',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
name: 'url.full',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
name: 'response_status_code',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'status_message',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
],
|
||||
limit: 10,
|
||||
order: [
|
||||
{
|
||||
key: {
|
||||
name: 'count()',
|
||||
},
|
||||
direction: 'desc',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
formatOptions: { formatTableResultForUI: true, fillGaps: false },
|
||||
variables: {},
|
||||
};
|
||||
};
|
||||
|
||||
export interface EndPointsTableRowData {
|
||||
key: string;
|
||||
@@ -1242,63 +1198,55 @@ export const formatEndPointsDataForTable = (
|
||||
return formattedData;
|
||||
};
|
||||
|
||||
export interface TopErrorsResponseRow {
|
||||
metric: {
|
||||
[SPAN_ATTRIBUTES.URL_PATH]: string;
|
||||
[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE]: string;
|
||||
status_message: string;
|
||||
};
|
||||
values: [number, string][];
|
||||
queryName: string;
|
||||
legend: string;
|
||||
}
|
||||
export type TopErrorsResponseRow = ScalarData;
|
||||
|
||||
export interface TopErrorsTableRowData {
|
||||
key: string;
|
||||
endpointName: string;
|
||||
statusCode: string;
|
||||
statusMessage: string;
|
||||
count: number | string;
|
||||
count: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns '-' if value is empty, otherwise returns value as string
|
||||
*/
|
||||
export function getDisplayValue(value: unknown): string {
|
||||
return isEmptyFilterValue(value) ? '-' : String(value);
|
||||
}
|
||||
|
||||
export const formatTopErrorsDataForTable = (
|
||||
data: TopErrorsResponseRow[] | undefined,
|
||||
scalarResult: TopErrorsResponseRow | undefined,
|
||||
): TopErrorsTableRowData[] => {
|
||||
if (!data) return [];
|
||||
if (!scalarResult?.data) return [];
|
||||
|
||||
return data.map((row) => ({
|
||||
key: v4(),
|
||||
endpointName:
|
||||
row.metric[SPAN_ATTRIBUTES.URL_PATH] === 'n/a' ||
|
||||
row.metric[SPAN_ATTRIBUTES.URL_PATH] === undefined
|
||||
? '-'
|
||||
: row.metric[SPAN_ATTRIBUTES.URL_PATH],
|
||||
statusCode:
|
||||
row.metric[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE] === 'n/a' ||
|
||||
row.metric[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE] === undefined
|
||||
? '-'
|
||||
: row.metric[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE],
|
||||
statusMessage:
|
||||
row.metric.status_message === 'n/a' ||
|
||||
row.metric.status_message === undefined
|
||||
? '-'
|
||||
: row.metric.status_message,
|
||||
count:
|
||||
row.values &&
|
||||
row.values[0] &&
|
||||
row.values[0][1] !== undefined &&
|
||||
row.values[0][1] !== 'n/a'
|
||||
? row.values[0][1]
|
||||
: '-',
|
||||
}));
|
||||
const columns = scalarResult.columns || [];
|
||||
const rows = scalarResult.data || [];
|
||||
|
||||
return rows.map((rowData: unknown[]) => {
|
||||
const rowObj: Record<string, unknown> = {};
|
||||
columns.forEach((col: ColumnDescriptor, index: number) => {
|
||||
rowObj[col.name] = rowData[index];
|
||||
});
|
||||
|
||||
return {
|
||||
key: v4(),
|
||||
endpointName: getDisplayValue(
|
||||
rowObj[SPAN_ATTRIBUTES.URL_PATH] || rowObj['url.full'],
|
||||
),
|
||||
statusCode: getDisplayValue(rowObj[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE]),
|
||||
statusMessage: getDisplayValue(rowObj.status_message),
|
||||
count: getDisplayValue(rowObj.__result_0),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getTopErrorsCoRelationQueryFilters = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
statusCode: string,
|
||||
): IBuilderQuery['filters'] => ({
|
||||
items: [
|
||||
): IBuilderQuery['filters'] => {
|
||||
const items: TagFilterItem[] = [
|
||||
{
|
||||
id: 'ea16470b',
|
||||
key: {
|
||||
@@ -1330,7 +1278,10 @@ export const getTopErrorsCoRelationQueryFilters = (
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
if (statusCode !== '-') {
|
||||
items.push({
|
||||
id: 'f6891e27',
|
||||
key: {
|
||||
key: 'response_status_code',
|
||||
@@ -1340,10 +1291,14 @@ export const getTopErrorsCoRelationQueryFilters = (
|
||||
},
|
||||
op: '=',
|
||||
value: statusCode,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
op: 'AND',
|
||||
};
|
||||
};
|
||||
|
||||
export const getTopErrorsColumnsConfig = (): ColumnType<TopErrorsTableRowData>[] => [
|
||||
{
|
||||
|
||||
@@ -137,8 +137,7 @@ function GeneralSettings({
|
||||
if (logsCurrentTTLValues) {
|
||||
setLogsTotalRetentionPeriod(logsCurrentTTLValues.default_ttl_days * 24);
|
||||
setLogsS3RetentionPeriod(
|
||||
logsCurrentTTLValues.cold_storage_ttl_days &&
|
||||
logsCurrentTTLValues.cold_storage_ttl_days > 0
|
||||
logsCurrentTTLValues.cold_storage_ttl_days
|
||||
? logsCurrentTTLValues.cold_storage_ttl_days * 24
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -94,6 +94,9 @@ const mockDisksWithoutS3: IDiskType[] = [
|
||||
];
|
||||
|
||||
describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
const BUTTON_SELECTOR = 'button[type="button"]';
|
||||
const PRIMARY_BUTTON_CLASS = 'ant-btn-primary';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(setRetentionApiV2 as jest.Mock).mockResolvedValue({
|
||||
@@ -155,10 +158,10 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
await user.type(s3Input, '5');
|
||||
|
||||
// Find the save button in the Logs card
|
||||
const buttons = logsCard?.querySelectorAll('button[type="button"]');
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
// The primary button should be the save button
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes('ant-btn-primary'),
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
@@ -262,9 +265,9 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
await user.type(totalInput, '60');
|
||||
|
||||
// Find the save button
|
||||
const buttons = logsCard?.querySelectorAll('button[type="button"]');
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes('ant-btn-primary'),
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
@@ -329,4 +332,59 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
expect(dropdowns?.[1]).toHaveTextContent('Days');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 4: Save Button State with S3 Disabled', () => {
|
||||
it('should disable save button when cold_storage_ttl_days is -1 and no changes made', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithoutS3}
|
||||
getAvailableDiskPayload={mockDisksWithS3}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
expect(logsCard).toBeInTheDocument();
|
||||
|
||||
// Find the save button
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Verify save button is disabled on initial load (no changes, S3 disabled with -1)
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Find the total retention input
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
|
||||
// Change total retention value to trigger button enable
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '60');
|
||||
|
||||
// Button should now be enabled after change
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Revert to original value (30 days displays as 1 Month)
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '1');
|
||||
|
||||
// Button should be disabled again (back to original state)
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,8 +46,7 @@ export const convertHoursValueToRelevantUnit = (
|
||||
availableUnits?: ITimeUnit[],
|
||||
): ITimeUnitConversion => {
|
||||
const unitsToConsider = availableUnits?.length ? availableUnits : TimeUnits;
|
||||
|
||||
if (value) {
|
||||
if (value >= 0) {
|
||||
for (let idx = unitsToConsider.length - 1; idx >= 0; idx -= 1) {
|
||||
const timeUnit = unitsToConsider[idx];
|
||||
const convertedValue = timeUnit.multiplier * value;
|
||||
@@ -62,7 +61,7 @@ export const convertHoursValueToRelevantUnit = (
|
||||
}
|
||||
|
||||
// Fallback to the first available unit
|
||||
return { value, timeUnitValue: unitsToConsider[0].value };
|
||||
return { value: -1, timeUnitValue: unitsToConsider[0].value };
|
||||
};
|
||||
|
||||
export const convertHoursValueToRelevantUnitString = (
|
||||
|
||||
@@ -17,12 +17,6 @@ export const Card = styled(CardComponent)<CardProps>`
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(171, 189, 255, 0) 0%,
|
||||
rgba(171, 189, 255, 0) 100%
|
||||
),
|
||||
#0b0c0e;
|
||||
|
||||
${({ isDarkMode }): StyledCSS =>
|
||||
!isDarkMode &&
|
||||
|
||||
@@ -88,17 +88,13 @@ function GridTableComponent({
|
||||
const newValue = { ...val };
|
||||
Object.keys(val).forEach((k) => {
|
||||
const unit = getColumnUnit(k, columnUnits);
|
||||
// Apply formatting if:
|
||||
// 1. Column has a unit defined, OR
|
||||
// 2. decimalPrecision is specified (format all values)
|
||||
const shouldFormat = unit || decimalPrecision !== undefined;
|
||||
|
||||
if (shouldFormat) {
|
||||
if (unit) {
|
||||
// the check below takes care of not adding units for rows that have n/a or null values
|
||||
if (val[k] !== 'n/a' && val[k] !== null) {
|
||||
newValue[k] = getYAxisFormattedValue(
|
||||
String(val[k]),
|
||||
unit || 'none',
|
||||
unit,
|
||||
decimalPrecision,
|
||||
);
|
||||
} else if (val[k] === null) {
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
|
||||
.section-icon {
|
||||
display: flex;
|
||||
@@ -461,7 +462,6 @@
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
width: 150px;
|
||||
justify-content: flex-end;
|
||||
|
||||
.ant-btn {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { getNonIntegrationDashboardById } from 'mocks-server/__mockdata__/dashboards';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
@@ -7,6 +8,16 @@ import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import DashboardDescription from '..';
|
||||
|
||||
interface MockSafeNavigateReturn {
|
||||
safeNavigate: jest.MockedFunction<(url: string) => void>;
|
||||
}
|
||||
|
||||
const DASHBOARD_TEST_ID = 'dashboard-title';
|
||||
const DASHBOARD_TITLE_TEXT = 'thor';
|
||||
const DASHBOARD_PATH = '/dashboard/4';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -26,20 +37,24 @@ jest.mock(
|
||||
);
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
useSafeNavigate: (): MockSafeNavigateReturn => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Dashboard landing page actions header tests', () => {
|
||||
beforeEach(() => {
|
||||
mockSafeNavigate.mockClear();
|
||||
});
|
||||
|
||||
it('unlock dashboard should be disabled for integrations created dashboards', async () => {
|
||||
const mockLocation = {
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/dashboard/4`,
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${DASHBOARD_PATH}`,
|
||||
search: '',
|
||||
};
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={['/dashboard/4']}>
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
@@ -54,7 +69,9 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('dashboard-title')).toHaveTextContent('thor'),
|
||||
expect(getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
|
||||
DASHBOARD_TITLE_TEXT,
|
||||
),
|
||||
);
|
||||
|
||||
const dashboardSettingsTrigger = getByTestId('options');
|
||||
@@ -65,9 +82,10 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
|
||||
await waitFor(() => expect(lockUnlockButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it('unlock dashboard should not be disabled for non integration created dashboards', async () => {
|
||||
const mockLocation = {
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/dashboard/4`,
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${DASHBOARD_PATH}`,
|
||||
search: '',
|
||||
};
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
@@ -77,7 +95,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
),
|
||||
);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={['/dashboard/4']}>
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
@@ -92,7 +110,9 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('dashboard-title')).toHaveTextContent('thor'),
|
||||
expect(getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
|
||||
DASHBOARD_TITLE_TEXT,
|
||||
),
|
||||
);
|
||||
|
||||
const dashboardSettingsTrigger = getByTestId('options');
|
||||
@@ -103,4 +123,58 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
|
||||
await waitFor(() => expect(lockUnlockButton).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it('should navigate to dashboard list with correct params and exclude variables', async () => {
|
||||
const dashboardUrlWithVariables = `${DASHBOARD_PATH}?variables=%7B%22var1%22%3A%22value1%22%7D&otherParam=test`;
|
||||
const mockLocation = {
|
||||
pathname: DASHBOARD_PATH,
|
||||
search: '?variables=%7B%22var1%22%3A%22value1%22%7D&otherParam=test',
|
||||
};
|
||||
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
|
||||
const { getByText } = render(
|
||||
<MemoryRouter initialEntries={[dashboardUrlWithVariables]}>
|
||||
<DashboardProvider>
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
enter: (): Promise<void> => Promise.resolve(),
|
||||
exit: (): Promise<void> => Promise.resolve(),
|
||||
node: { current: null },
|
||||
}}
|
||||
/>
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
|
||||
DASHBOARD_TITLE_TEXT,
|
||||
),
|
||||
);
|
||||
|
||||
// Click the dashboard breadcrumb to navigate back to list
|
||||
const dashboardButton = getByText('Dashboard /');
|
||||
fireEvent.click(dashboardButton);
|
||||
|
||||
// Verify navigation was called with correct URL
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard?columnKey=updatedAt&order=descend&page=1&search=',
|
||||
);
|
||||
|
||||
// Ensure the URL contains only essential dashboard list params
|
||||
const calledUrl = mockSafeNavigate.mock.calls[0][0] as string;
|
||||
const urlParams = new URLSearchParams(calledUrl.split('?')[1]);
|
||||
|
||||
// Should have essential dashboard list params
|
||||
expect(urlParams.get('columnKey')).toBe('updatedAt');
|
||||
expect(urlParams.get('order')).toBe('descend');
|
||||
expect(urlParams.get('page')).toBe('1');
|
||||
expect(urlParams.get('search')).toBe('');
|
||||
|
||||
// Should NOT have variables or other dashboard-specific params
|
||||
expect(urlParams.has('variables')).toBeFalsy();
|
||||
expect(urlParams.has('relativeTime')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
@@ -22,7 +21,6 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import {
|
||||
Check,
|
||||
@@ -116,8 +114,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] = useState<boolean>(
|
||||
@@ -291,13 +287,13 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
}
|
||||
|
||||
function goToListPage(): void {
|
||||
urlQuery.set('columnKey', listSortOrder.columnKey as string);
|
||||
urlQuery.set('order', listSortOrder.order as string);
|
||||
urlQuery.set('page', listSortOrder.pagination as string);
|
||||
urlQuery.set('search', listSortOrder.search as string);
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set('columnKey', listSortOrder.columnKey as string);
|
||||
urlParams.set('order', listSortOrder.order as string);
|
||||
urlParams.set('page', listSortOrder.pagination as string);
|
||||
urlParams.set('search', listSortOrder.search as string);
|
||||
|
||||
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlQuery.toString()}`;
|
||||
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlParams.toString()}`;
|
||||
safeNavigate(generatedUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ export const tablePanelWidgetQuery = {
|
||||
stackedBarChart: false,
|
||||
bucketWidth: 0,
|
||||
mergeAllActiveQueries: false,
|
||||
decimalPrecision: 2,
|
||||
};
|
||||
|
||||
export const tablePanelQueryResponse = {
|
||||
|
||||
@@ -90,8 +90,9 @@ export function QueryTable({
|
||||
column: any,
|
||||
tableColumns: any,
|
||||
): void => {
|
||||
e.stopPropagation();
|
||||
if (isQueryTypeBuilder && enableDrillDown) {
|
||||
e.stopPropagation();
|
||||
|
||||
onClick({ x: e.clientX, y: e.clientY }, { record, column, tableColumns });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -68,23 +68,39 @@
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.span-percentile-value {
|
||||
color: var(--text-sakura-400, #f56c87);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
min-width: 48px;
|
||||
padding: 4px 8px;
|
||||
.span-percentile-value-container {
|
||||
.span-percentile-value {
|
||||
color: var(--text-sakura-400, #f56c87);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on;
|
||||
|
||||
border-left: 1px solid var(--bg-slate-400);
|
||||
border-radius: 0 50px 50px 0;
|
||||
|
||||
cursor: pointer;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
min-width: 48px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
|
||||
border-left: 1px solid var(--bg-slate-400);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
word-break: normal;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&.span-percentile-value-container-open {
|
||||
.span-percentile-value {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +621,15 @@
|
||||
.description {
|
||||
.item {
|
||||
.span-name-wrapper {
|
||||
.span-percentile-value-container {
|
||||
&.span-percentile-value-container-open {
|
||||
.span-percentile-value {
|
||||
border: 1px solid var(--bg-vanilla-200);
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-value {
|
||||
color: var(--text-sakura-400, #f56c87);
|
||||
border-left: 1px solid var(--bg-slate-300);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Bookmark,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Link2,
|
||||
Loader2,
|
||||
PanelRight,
|
||||
@@ -387,17 +388,24 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
setShouldUpdateUserPreference(false);
|
||||
}
|
||||
},
|
||||
keepPreviousData: false,
|
||||
cacheTime: 0, // no cache
|
||||
});
|
||||
|
||||
// Prod Req - Wait for 2 seconds before fetching span percentile data on initial load
|
||||
useEffect(() => {
|
||||
setSpanPercentileData(null);
|
||||
setIsSpanPercentilesOpen(false);
|
||||
setInitialWaitCompleted(false);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setInitialWaitCompleted(true);
|
||||
}, 2000); // 2-second delay
|
||||
|
||||
return (): void => clearTimeout(timer); // Cleanup on re-run or unmount
|
||||
return (): void => {
|
||||
// clean the old state around span percentile data
|
||||
clearTimeout(timer); // Cleanup on re-run or unmount
|
||||
};
|
||||
}, [selectedSpan?.spanId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -538,6 +546,11 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
initialWaitCompleted,
|
||||
]);
|
||||
|
||||
const loadingSpanPercentilesData =
|
||||
isLoadingSpanPercentilesData || isFetchingSpanPercentilesData;
|
||||
|
||||
const spanPercentileValue = Math.floor(spanPercentileData?.percentile || 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="header">
|
||||
@@ -566,25 +579,43 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
{isLoadingSpanPercentilesData && (
|
||||
{loadingSpanPercentilesData && (
|
||||
<div className="loading-spinner-container">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingSpanPercentilesData && spanPercentileData && (
|
||||
{!loadingSpanPercentilesData && spanPercentileData && (
|
||||
<Tooltip
|
||||
title={isSpanPercentilesOpen ? '' : spanPercentileTooltipText}
|
||||
placement="bottomRight"
|
||||
overlayClassName="span-percentile-tooltip"
|
||||
arrow={false}
|
||||
>
|
||||
<Typography.Text
|
||||
className="span-percentile-value"
|
||||
onClick={(): void => setIsSpanPercentilesOpen((prev) => !prev)}
|
||||
<div
|
||||
className={`span-percentile-value-container ${
|
||||
isSpanPercentilesOpen
|
||||
? 'span-percentile-value-container-open'
|
||||
: 'span-percentile-value-container-closed'
|
||||
}`}
|
||||
>
|
||||
p{Math.floor(spanPercentileData?.percentile || 0)}
|
||||
</Typography.Text>
|
||||
<Typography.Text
|
||||
className="span-percentile-value"
|
||||
onClick={(): void => setIsSpanPercentilesOpen((prev) => !prev)}
|
||||
disabled={loadingSpanPercentilesData}
|
||||
>
|
||||
<span className="span-percentile-value-text">
|
||||
p{spanPercentileValue}
|
||||
</span>
|
||||
|
||||
{!isSpanPercentilesOpen && (
|
||||
<ChevronDown size={16} className="span-percentile-value-icon" />
|
||||
)}
|
||||
{isSpanPercentilesOpen && (
|
||||
<ChevronUp size={16} className="span-percentile-value-icon" />
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ interface UseVariablesFromUrlReturn {
|
||||
name: string,
|
||||
selectedValue: IDashboardVariable['selectedValue'],
|
||||
) => void;
|
||||
clearUrlVariables: () => void;
|
||||
}
|
||||
|
||||
const useVariablesFromUrl = (): UseVariablesFromUrlReturn => {
|
||||
@@ -68,15 +67,6 @@ const useVariablesFromUrl = (): UseVariablesFromUrlReturn => {
|
||||
[history, urlQuery],
|
||||
);
|
||||
|
||||
const clearUrlVariables = useCallback((): void => {
|
||||
const params = new URLSearchParams(urlQuery.toString());
|
||||
params.delete(QueryParams.variables);
|
||||
|
||||
history.replace({
|
||||
search: params.toString(),
|
||||
});
|
||||
}, [history, urlQuery]);
|
||||
|
||||
const updateUrlVariable = useCallback(
|
||||
(name: string, selectedValue: IDashboardVariable['selectedValue']): void => {
|
||||
const currentVariables = getUrlVariables();
|
||||
@@ -95,7 +85,6 @@ const useVariablesFromUrl = (): UseVariablesFromUrlReturn => {
|
||||
getUrlVariables,
|
||||
setUrlVariables,
|
||||
updateUrlVariable,
|
||||
clearUrlVariables,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -662,21 +662,23 @@ const generateTableColumns = (
|
||||
*
|
||||
* @param columnKey - The column identifier (could be queryName.expression or queryName)
|
||||
* @param columnUnits - The column units mapping
|
||||
* @returns The unit string or undefined if not found
|
||||
* @returns The unit string (none if the unit is set to empty string) or undefined if not found
|
||||
*/
|
||||
export const getColumnUnit = (
|
||||
columnKey: string,
|
||||
columnUnits: Record<string, string>,
|
||||
): string | undefined => {
|
||||
// First try the exact match (new syntax: queryName.expression)
|
||||
if (columnUnits[columnKey]) {
|
||||
return columnUnits[columnKey];
|
||||
if (columnUnits[columnKey] !== undefined) {
|
||||
return columnUnits[columnKey] || 'none';
|
||||
}
|
||||
|
||||
// Fallback to old syntax: extract queryName from queryName.expression
|
||||
if (columnKey.includes('.')) {
|
||||
const queryName = columnKey.split('.')[0];
|
||||
return columnUnits[queryName];
|
||||
if (columnUnits[queryName] !== undefined) {
|
||||
return columnUnits[queryName] || 'none';
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -285,10 +285,11 @@ export const getUPlotChartOptions = ({
|
||||
cursor: {
|
||||
lock: false,
|
||||
focus: {
|
||||
prox: 1e6,
|
||||
prox: 25,
|
||||
bias: 1,
|
||||
},
|
||||
points: {
|
||||
one: true,
|
||||
size: (u, seriesIdx): number => u.series[seriesIdx].points.size * 3,
|
||||
width: (u, seriesIdx, size): number => size / 4,
|
||||
stroke: (u, seriesIdx): string =>
|
||||
@@ -394,14 +395,25 @@ export const getUPlotChartOptions = ({
|
||||
hooks: {
|
||||
draw: [
|
||||
(u): void => {
|
||||
if (isAnomalyRule) {
|
||||
if (isAnomalyRule || !thresholds?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
thresholds?.forEach((threshold) => {
|
||||
const { ctx } = u;
|
||||
const { left: plotLeft, width: plotWidth } = u.bbox;
|
||||
const plotRight = plotLeft + plotWidth;
|
||||
const canvasHeight = ctx.canvas.height;
|
||||
const threshold90Percent = canvasHeight * 0.9;
|
||||
|
||||
// Single save/restore for all thresholds
|
||||
ctx.save();
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([10, 5]);
|
||||
|
||||
for (let i = 0; i < thresholds.length; i++) {
|
||||
const threshold = thresholds[i];
|
||||
if (threshold.thresholdValue !== undefined) {
|
||||
const { ctx } = u;
|
||||
ctx.save();
|
||||
const color = threshold.thresholdColor || 'red';
|
||||
const yPos = u.valToPos(
|
||||
convertValue(
|
||||
threshold.thresholdValue,
|
||||
@@ -411,35 +423,28 @@ export const getUPlotChartOptions = ({
|
||||
'y',
|
||||
true,
|
||||
);
|
||||
ctx.strokeStyle = threshold.thresholdColor || 'red';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([10, 5]);
|
||||
|
||||
// Draw threshold line
|
||||
ctx.strokeStyle = color;
|
||||
ctx.beginPath();
|
||||
const plotLeft = u.bbox.left; // left edge of the plot area
|
||||
const plotRight = plotLeft + u.bbox.width; // right edge of the plot area
|
||||
ctx.moveTo(plotLeft, yPos);
|
||||
ctx.lineTo(plotRight, yPos);
|
||||
ctx.stroke();
|
||||
// Text configuration
|
||||
|
||||
// Draw threshold label if present
|
||||
if (threshold.thresholdLabel) {
|
||||
const text = threshold.thresholdLabel;
|
||||
const textX = plotRight - ctx.measureText(text).width - 20;
|
||||
const canvasHeight = ctx.canvas.height;
|
||||
const textWidth = ctx.measureText(threshold.thresholdLabel).width;
|
||||
const textX = plotRight - textWidth - 20;
|
||||
const yposHeight = canvasHeight - yPos;
|
||||
const isHeightGreaterThan90Percent = canvasHeight * 0.9 < yposHeight;
|
||||
// Adjust textY based on the condition
|
||||
let textY;
|
||||
if (isHeightGreaterThan90Percent) {
|
||||
textY = yPos + 15; // Below the threshold line
|
||||
} else {
|
||||
textY = yPos - 15; // Above the threshold line
|
||||
}
|
||||
ctx.fillStyle = threshold.thresholdColor || 'red';
|
||||
ctx.fillText(text, textX, textY);
|
||||
const textY = yposHeight > threshold90Percent ? yPos + 15 : yPos - 15;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillText(threshold.thresholdLabel, textX, textY);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
},
|
||||
],
|
||||
setSelect: [
|
||||
@@ -555,19 +560,22 @@ export const getUPlotChartOptions = ({
|
||||
// Get the current text content
|
||||
const legendText = seriesLabels[index];
|
||||
|
||||
// Clear the th content and rebuild it
|
||||
thElement.innerHTML = '';
|
||||
// Use DocumentFragment to batch DOM operations
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Add back the marker
|
||||
if (markerClone) {
|
||||
thElement.appendChild(markerClone);
|
||||
fragment.appendChild(markerClone);
|
||||
}
|
||||
|
||||
// Create text wrapper
|
||||
const textSpan = document.createElement('span');
|
||||
textSpan.className = 'legend-text';
|
||||
textSpan.textContent = legendText;
|
||||
thElement.appendChild(textSpan);
|
||||
fragment.appendChild(textSpan);
|
||||
|
||||
// Replace the children in a single operation
|
||||
thElement.replaceChildren(fragment);
|
||||
|
||||
// Setup tooltip functionality - check truncation on hover
|
||||
let tooltipElement: HTMLElement | null = null;
|
||||
|
||||
@@ -16,8 +16,20 @@
|
||||
|
||||
// https://tobyzerner.github.io/placement.js/dist/index.js
|
||||
|
||||
/**
|
||||
* Positions an element (tooltip/popover) relative to a reference element.
|
||||
* Automatically flips to the opposite side if there's insufficient space.
|
||||
*
|
||||
* @param element - The HTMLElement to position
|
||||
* @param reference - Reference element/Range or bounding rect
|
||||
* @param side - Preferred side: 'top', 'bottom', 'left', 'right' (default: 'bottom')
|
||||
* @param align - Alignment: 'start', 'center', 'end' (default: 'center')
|
||||
* @param options - Optional bounds for constraining the element
|
||||
* - bound: Custom boundary rect/element
|
||||
* - followCursor: { x, y } - If provided, tooltip follows cursor with smart positioning
|
||||
*/
|
||||
export const placement = (function () {
|
||||
const e = {
|
||||
const AXIS_PROPS = {
|
||||
size: ['height', 'width'],
|
||||
clientSize: ['clientHeight', 'clientWidth'],
|
||||
offsetSize: ['offsetHeight', 'offsetWidth'],
|
||||
@@ -28,87 +40,241 @@ export const placement = (function () {
|
||||
marginAfter: ['marginBottom', 'marginRight'],
|
||||
scrollOffset: ['pageYOffset', 'pageXOffset'],
|
||||
};
|
||||
function t(e) {
|
||||
return { top: e.top, bottom: e.bottom, left: e.left, right: e.right };
|
||||
}
|
||||
return function (o, r, f, a, i) {
|
||||
void 0 === f && (f = 'bottom'),
|
||||
void 0 === a && (a = 'center'),
|
||||
void 0 === i && (i = {}),
|
||||
(r instanceof Element || r instanceof Range) &&
|
||||
(r = t(r.getBoundingClientRect()));
|
||||
const n = {
|
||||
top: r.bottom,
|
||||
bottom: r.top,
|
||||
left: r.right,
|
||||
right: r.left,
|
||||
...r,
|
||||
|
||||
function extractRect(source) {
|
||||
return {
|
||||
top: source.top,
|
||||
bottom: source.bottom,
|
||||
left: source.left,
|
||||
right: source.right,
|
||||
};
|
||||
const s = {
|
||||
}
|
||||
|
||||
return function (element, reference, side, align, options) {
|
||||
// Default parameters
|
||||
void 0 === side && (side = 'bottom');
|
||||
void 0 === align && (align = 'center');
|
||||
void 0 === options && (options = {});
|
||||
|
||||
// Handle cursor following mode
|
||||
if (options.followCursor) {
|
||||
const cursorX = options.followCursor.x;
|
||||
const cursorY = options.followCursor.y;
|
||||
const offset = options.followCursor.offset || 10; // Default 10px offset from cursor
|
||||
|
||||
element.style.position = 'absolute';
|
||||
element.style.maxWidth = '';
|
||||
element.style.maxHeight = '';
|
||||
|
||||
const elementWidth = element.offsetWidth;
|
||||
const elementHeight = element.offsetHeight;
|
||||
|
||||
// Use viewport bounds for cursor following (not chart bounds)
|
||||
const viewportBounds = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: window.innerHeight,
|
||||
right: window.innerWidth,
|
||||
};
|
||||
|
||||
// Vertical positioning: follow cursor Y with offset, clamped to viewport
|
||||
const topPosition = cursorY + offset;
|
||||
const clampedTop = Math.max(
|
||||
viewportBounds.top,
|
||||
Math.min(topPosition, viewportBounds.bottom - elementHeight),
|
||||
);
|
||||
element.style.top = `${clampedTop}px`;
|
||||
element.style.bottom = 'auto';
|
||||
|
||||
// Horizontal positioning: auto-detect left or right based on available space
|
||||
const spaceOnRight = viewportBounds.right - cursorX;
|
||||
const spaceOnLeft = cursorX - viewportBounds.left;
|
||||
|
||||
if (spaceOnRight >= elementWidth + offset) {
|
||||
// Enough space on the right
|
||||
element.style.left = `${cursorX + offset}px`;
|
||||
element.style.right = 'auto';
|
||||
element.dataset.side = 'right';
|
||||
} else if (spaceOnLeft >= elementWidth + offset) {
|
||||
// Not enough space on right, use left
|
||||
element.style.left = `${cursorX - elementWidth - offset}px`;
|
||||
element.style.right = 'auto';
|
||||
element.dataset.side = 'left';
|
||||
} else if (spaceOnRight > spaceOnLeft) {
|
||||
// Not enough space on either side, pick the side with more space
|
||||
const leftPos = cursorX + offset;
|
||||
const clampedLeft = Math.max(
|
||||
viewportBounds.left,
|
||||
Math.min(leftPos, viewportBounds.right - elementWidth),
|
||||
);
|
||||
element.style.left = `${clampedLeft}px`;
|
||||
element.style.right = 'auto';
|
||||
element.dataset.side = 'right';
|
||||
} else {
|
||||
const leftPos = cursorX - elementWidth - offset;
|
||||
const clampedLeft = Math.max(
|
||||
viewportBounds.left,
|
||||
Math.min(leftPos, viewportBounds.right - elementWidth),
|
||||
);
|
||||
element.style.left = `${clampedLeft}px`;
|
||||
element.style.right = 'auto';
|
||||
element.dataset.side = 'left';
|
||||
}
|
||||
|
||||
element.dataset.align = 'cursor';
|
||||
return; // Exit early, don't run normal positioning logic
|
||||
}
|
||||
|
||||
// Normalize reference to rect object
|
||||
(reference instanceof Element || reference instanceof Range) &&
|
||||
(reference = extractRect(reference.getBoundingClientRect()));
|
||||
|
||||
// Create anchor rect with swapped opposite edges for positioning
|
||||
const anchorRect = {
|
||||
top: reference.bottom,
|
||||
bottom: reference.top,
|
||||
left: reference.right,
|
||||
right: reference.left,
|
||||
...reference,
|
||||
};
|
||||
|
||||
// Viewport bounds (can be overridden via options.bound)
|
||||
const bounds = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: window.innerHeight,
|
||||
right: window.innerWidth,
|
||||
};
|
||||
i.bound &&
|
||||
((i.bound instanceof Element || i.bound instanceof Range) &&
|
||||
(i.bound = t(i.bound.getBoundingClientRect())),
|
||||
Object.assign(s, i.bound));
|
||||
const l = getComputedStyle(o);
|
||||
const m = {};
|
||||
const b = {};
|
||||
for (const g in e)
|
||||
(m[g] = e[g][f === 'top' || f === 'bottom' ? 0 : 1]),
|
||||
(b[g] = e[g][f === 'top' || f === 'bottom' ? 1 : 0]);
|
||||
(o.style.position = 'absolute'),
|
||||
(o.style.maxWidth = ''),
|
||||
(o.style.maxHeight = '');
|
||||
const d = parseInt(l[b.marginBefore]);
|
||||
const c = parseInt(l[b.marginAfter]);
|
||||
const u = d + c;
|
||||
const p = s[b.after] - s[b.before] - u;
|
||||
const h = parseInt(l[b.maxSize]);
|
||||
(!h || p < h) && (o.style[b.maxSize] = `${p}px`);
|
||||
const x = parseInt(l[m.marginBefore]) + parseInt(l[m.marginAfter]);
|
||||
const y = n[m.before] - s[m.before] - x;
|
||||
const z = s[m.after] - n[m.after] - x;
|
||||
((f === m.before && o[m.offsetSize] > y) ||
|
||||
(f === m.after && o[m.offsetSize] > z)) &&
|
||||
(f = y > z ? m.before : m.after);
|
||||
const S = f === m.before ? y : z;
|
||||
const v = parseInt(l[m.maxSize]);
|
||||
(!v || S < v) && (o.style[m.maxSize] = `${S}px`);
|
||||
const w = window[m.scrollOffset];
|
||||
const O = function (e) {
|
||||
return Math.max(s[m.before], Math.min(e, s[m.after] - o[m.offsetSize] - x));
|
||||
|
||||
options.bound &&
|
||||
((options.bound instanceof Element || options.bound instanceof Range) &&
|
||||
(options.bound = extractRect(options.bound.getBoundingClientRect())),
|
||||
Object.assign(bounds, options.bound));
|
||||
|
||||
const styles = getComputedStyle(element);
|
||||
const isVertical = side === 'top' || side === 'bottom';
|
||||
|
||||
// Build axis property maps based on orientation
|
||||
const mainAxis = {}; // Properties for the main positioning axis
|
||||
const crossAxis = {}; // Properties for the perpendicular axis
|
||||
|
||||
for (const prop in AXIS_PROPS) {
|
||||
mainAxis[prop] = AXIS_PROPS[prop][isVertical ? 0 : 1];
|
||||
crossAxis[prop] = AXIS_PROPS[prop][isVertical ? 1 : 0];
|
||||
}
|
||||
|
||||
// Reset element positioning
|
||||
element.style.position = 'absolute';
|
||||
element.style.maxWidth = '';
|
||||
element.style.maxHeight = '';
|
||||
|
||||
// Cross-axis: calculate and apply max size constraint
|
||||
const crossMarginBefore = parseInt(styles[crossAxis.marginBefore]);
|
||||
const crossMarginAfter = parseInt(styles[crossAxis.marginAfter]);
|
||||
const crossMarginTotal = crossMarginBefore + crossMarginAfter;
|
||||
const crossAvailableSpace =
|
||||
bounds[crossAxis.after] - bounds[crossAxis.before] - crossMarginTotal;
|
||||
const crossMaxSize = parseInt(styles[crossAxis.maxSize]);
|
||||
|
||||
(!crossMaxSize || crossAvailableSpace < crossMaxSize) &&
|
||||
(element.style[crossAxis.maxSize] = `${crossAvailableSpace}px`);
|
||||
|
||||
// Main-axis: calculate space on both sides
|
||||
const mainMarginTotal =
|
||||
parseInt(styles[mainAxis.marginBefore]) +
|
||||
parseInt(styles[mainAxis.marginAfter]);
|
||||
const spaceBefore =
|
||||
anchorRect[mainAxis.before] - bounds[mainAxis.before] - mainMarginTotal;
|
||||
const spaceAfter =
|
||||
bounds[mainAxis.after] - anchorRect[mainAxis.after] - mainMarginTotal;
|
||||
|
||||
// Auto-flip to the side with more space if needed
|
||||
((side === mainAxis.before && element[mainAxis.offsetSize] > spaceBefore) ||
|
||||
(side === mainAxis.after && element[mainAxis.offsetSize] > spaceAfter)) &&
|
||||
(side = spaceBefore > spaceAfter ? mainAxis.before : mainAxis.after);
|
||||
|
||||
// Apply main-axis max size constraint
|
||||
const mainAvailableSpace =
|
||||
side === mainAxis.before ? spaceBefore : spaceAfter;
|
||||
const mainMaxSize = parseInt(styles[mainAxis.maxSize]);
|
||||
|
||||
(!mainMaxSize || mainAvailableSpace < mainMaxSize) &&
|
||||
(element.style[mainAxis.maxSize] = `${mainAvailableSpace}px`);
|
||||
|
||||
// Position on main axis
|
||||
const mainScrollOffset = window[mainAxis.scrollOffset];
|
||||
const clampMainPosition = function (pos) {
|
||||
return Math.max(
|
||||
bounds[mainAxis.before],
|
||||
Math.min(
|
||||
pos,
|
||||
bounds[mainAxis.after] - element[mainAxis.offsetSize] - mainMarginTotal,
|
||||
),
|
||||
);
|
||||
};
|
||||
f === m.before
|
||||
? ((o.style[m.before] = `${w + O(n[m.before] - o[m.offsetSize] - x)}px`),
|
||||
(o.style[m.after] = 'auto'))
|
||||
: ((o.style[m.before] = `${w + O(n[m.after])}px`),
|
||||
(o.style[m.after] = 'auto'));
|
||||
const B = window[b.scrollOffset];
|
||||
const I = function (e) {
|
||||
return Math.max(s[b.before], Math.min(e, s[b.after] - o[b.offsetSize] - u));
|
||||
|
||||
side === mainAxis.before
|
||||
? ((element.style[mainAxis.before] = `${
|
||||
mainScrollOffset +
|
||||
clampMainPosition(
|
||||
anchorRect[mainAxis.before] -
|
||||
element[mainAxis.offsetSize] -
|
||||
mainMarginTotal,
|
||||
)
|
||||
}px`),
|
||||
(element.style[mainAxis.after] = 'auto'))
|
||||
: ((element.style[mainAxis.before] = `${
|
||||
mainScrollOffset + clampMainPosition(anchorRect[mainAxis.after])
|
||||
}px`),
|
||||
(element.style[mainAxis.after] = 'auto'));
|
||||
|
||||
// Position on cross axis based on alignment
|
||||
const crossScrollOffset = window[crossAxis.scrollOffset];
|
||||
const clampCrossPosition = function (pos) {
|
||||
return Math.max(
|
||||
bounds[crossAxis.before],
|
||||
Math.min(
|
||||
pos,
|
||||
bounds[crossAxis.after] - element[crossAxis.offsetSize] - crossMarginTotal,
|
||||
),
|
||||
);
|
||||
};
|
||||
switch (a) {
|
||||
|
||||
switch (align) {
|
||||
case 'start':
|
||||
(o.style[b.before] = `${B + I(n[b.before] - d)}px`),
|
||||
(o.style[b.after] = 'auto');
|
||||
(element.style[crossAxis.before] = `${
|
||||
crossScrollOffset +
|
||||
clampCrossPosition(anchorRect[crossAxis.before] - crossMarginBefore)
|
||||
}px`),
|
||||
(element.style[crossAxis.after] = 'auto');
|
||||
break;
|
||||
case 'end':
|
||||
(o.style[b.before] = 'auto'),
|
||||
(o.style[b.after] = `${
|
||||
B + I(document.documentElement[b.clientSize] - n[b.after] - c)
|
||||
(element.style[crossAxis.before] = 'auto'),
|
||||
(element.style[crossAxis.after] = `${
|
||||
crossScrollOffset +
|
||||
clampCrossPosition(
|
||||
document.documentElement[crossAxis.clientSize] -
|
||||
anchorRect[crossAxis.after] -
|
||||
crossMarginAfter,
|
||||
)
|
||||
}px`);
|
||||
break;
|
||||
default:
|
||||
var H = n[b.after] - n[b.before];
|
||||
(o.style[b.before] = `${
|
||||
B + I(n[b.before] + H / 2 - o[b.offsetSize] / 2 - d)
|
||||
// 'center'
|
||||
var crossSize = anchorRect[crossAxis.after] - anchorRect[crossAxis.before];
|
||||
(element.style[crossAxis.before] = `${
|
||||
crossScrollOffset +
|
||||
clampCrossPosition(
|
||||
anchorRect[crossAxis.before] +
|
||||
crossSize / 2 -
|
||||
element[crossAxis.offsetSize] / 2 -
|
||||
crossMarginBefore,
|
||||
)
|
||||
}px`),
|
||||
(o.style[b.after] = 'auto');
|
||||
(element.style[crossAxis.after] = 'auto');
|
||||
}
|
||||
(o.dataset.side = f), (o.dataset.align = a);
|
||||
|
||||
// Store final placement as data attributes
|
||||
(element.dataset.side = side), (element.dataset.align = align);
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -3,7 +3,71 @@ import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
|
||||
function isSeriesValueValid(seriesValue: number | undefined | null): boolean {
|
||||
return (
|
||||
seriesValue !== undefined &&
|
||||
seriesValue !== null &&
|
||||
!Number.isNaN(seriesValue)
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to get the focused/highlighted series at a specific position
|
||||
function resolveSeriesColor(series: uPlot.Series, index: number): string {
|
||||
let color = '#000000';
|
||||
if (typeof series.stroke === 'string') {
|
||||
color = series.stroke;
|
||||
} else if (typeof series.fill === 'string') {
|
||||
color = series.fill;
|
||||
} else {
|
||||
const seriesLabel = series.label || `Series ${index}`;
|
||||
const isDarkMode = !document.body.classList.contains('lightMode');
|
||||
color = generateColor(
|
||||
seriesLabel,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function getPreferredSeriesIndex(
|
||||
u: uPlot,
|
||||
timestampIndex: number,
|
||||
e: MouseEvent,
|
||||
): number {
|
||||
const bbox = u.over.getBoundingClientRect();
|
||||
const top = e.clientY - bbox.top;
|
||||
// Prefer series explicitly marked as focused
|
||||
for (let i = 1; i < u.series.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const isSeriesFocused = u.series[i]?._focus === true;
|
||||
const isSeriesShown = u.series[i].show !== false;
|
||||
const seriesValue = u.data[i]?.[timestampIndex];
|
||||
if (isSeriesFocused && isSeriesShown && isSeriesValueValid(seriesValue)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: choose series with Y closest to mouse position
|
||||
let focusedSeriesIndex = -1;
|
||||
let closestPixelDiff = Infinity;
|
||||
for (let i = 1; i < u.series.length; i++) {
|
||||
const series = u.data[i];
|
||||
const seriesValue = series?.[timestampIndex];
|
||||
|
||||
if (isSeriesValueValid(seriesValue) && u.series[i].show !== false) {
|
||||
const yPx = u.valToPos(seriesValue as number, 'y');
|
||||
const diff = Math.abs(yPx - top);
|
||||
if (diff < closestPixelDiff) {
|
||||
closestPixelDiff = diff;
|
||||
focusedSeriesIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return focusedSeriesIndex;
|
||||
}
|
||||
|
||||
export const getFocusedSeriesAtPosition = (
|
||||
e: MouseEvent,
|
||||
u: uPlot,
|
||||
@@ -17,74 +81,28 @@ export const getFocusedSeriesAtPosition = (
|
||||
} | null => {
|
||||
const bbox = u.over.getBoundingClientRect();
|
||||
const left = e.clientX - bbox.left;
|
||||
const top = e.clientY - bbox.top;
|
||||
|
||||
const timestampIndex = u.posToIdx(left);
|
||||
let focusedSeriesIndex = -1;
|
||||
let closestPixelDiff = Infinity;
|
||||
|
||||
// Check all series (skip index 0 which is the x-axis)
|
||||
for (let i = 1; i < u.data.length; i++) {
|
||||
const series = u.data[i];
|
||||
const seriesValue = series[timestampIndex];
|
||||
|
||||
if (
|
||||
seriesValue !== undefined &&
|
||||
seriesValue !== null &&
|
||||
!Number.isNaN(seriesValue)
|
||||
) {
|
||||
const seriesYPx = u.valToPos(seriesValue, 'y');
|
||||
const pixelDiff = Math.abs(seriesYPx - top);
|
||||
|
||||
if (pixelDiff < closestPixelDiff) {
|
||||
closestPixelDiff = pixelDiff;
|
||||
focusedSeriesIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a focused series, return its data
|
||||
if (focusedSeriesIndex > 0) {
|
||||
const series = u.series[focusedSeriesIndex];
|
||||
const seriesValue = u.data[focusedSeriesIndex][timestampIndex];
|
||||
|
||||
// Ensure we have a valid value
|
||||
if (
|
||||
seriesValue !== undefined &&
|
||||
seriesValue !== null &&
|
||||
!Number.isNaN(seriesValue)
|
||||
) {
|
||||
// Get color - try series stroke first, then generate based on label
|
||||
let color = '#000000';
|
||||
if (typeof series.stroke === 'string') {
|
||||
color = series.stroke;
|
||||
} else if (typeof series.fill === 'string') {
|
||||
color = series.fill;
|
||||
} else {
|
||||
// Generate color based on series label (like the tooltip plugin does)
|
||||
const seriesLabel = series.label || `Series ${focusedSeriesIndex}`;
|
||||
// Detect theme mode by checking body class
|
||||
const isDarkMode = !document.body.classList.contains('lightMode');
|
||||
color = generateColor(
|
||||
seriesLabel,
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
);
|
||||
}
|
||||
const preferredIndex = getPreferredSeriesIndex(u, timestampIndex, e);
|
||||
|
||||
if (preferredIndex > 0) {
|
||||
const series = u.series[preferredIndex];
|
||||
const seriesValue = u.data[preferredIndex][timestampIndex];
|
||||
if (isSeriesValueValid(seriesValue)) {
|
||||
const color = resolveSeriesColor(series, preferredIndex);
|
||||
return {
|
||||
seriesIndex: focusedSeriesIndex,
|
||||
seriesName: series.label || `Series ${focusedSeriesIndex}`,
|
||||
seriesIndex: preferredIndex,
|
||||
seriesName: series.label || `Series ${preferredIndex}`,
|
||||
value: seriesValue as number,
|
||||
color,
|
||||
show: series.show !== false,
|
||||
isFocused: true, // This indicates it's the highlighted/bold one
|
||||
isFocused: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export interface OnClickPluginOpts {
|
||||
onClick: (
|
||||
xValue: number,
|
||||
@@ -137,50 +155,31 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
|
||||
const yValue = u.posToVal(event.offsetY, 'y');
|
||||
|
||||
// Get the focused/highlighted series (the one that would be bold in hover)
|
||||
const focusedSeries = getFocusedSeriesAtPosition(event, u);
|
||||
const focusedSeriesData = getFocusedSeriesAtPosition(event, u);
|
||||
|
||||
let metric = {};
|
||||
const { series } = u;
|
||||
const apiResult = opts.apiResponse?.data?.result || [];
|
||||
const outputMetric = {
|
||||
queryName: '',
|
||||
inFocusOrNot: false,
|
||||
};
|
||||
|
||||
// this is to get the metric value of the focused series
|
||||
if (Array.isArray(series) && series.length > 0) {
|
||||
series.forEach((item, index) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (item?.show && item?._focus) {
|
||||
const { metric: focusedMetric, queryName } = apiResult[index - 1] || [];
|
||||
metric = focusedMetric;
|
||||
outputMetric.queryName = queryName;
|
||||
outputMetric.inFocusOrNot = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!outputMetric.queryName) {
|
||||
// Get the focused series data
|
||||
const focusedSeriesData = getFocusedSeriesAtPosition(event, u);
|
||||
|
||||
// If we found a valid focused series, get its data
|
||||
if (
|
||||
focusedSeriesData &&
|
||||
focusedSeriesData.seriesIndex <= apiResult.length
|
||||
) {
|
||||
const { metric: focusedMetric, queryName } =
|
||||
apiResult[focusedSeriesData.seriesIndex - 1] || [];
|
||||
metric = focusedMetric;
|
||||
outputMetric.queryName = queryName;
|
||||
outputMetric.inFocusOrNot = true;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
if (
|
||||
focusedSeriesData &&
|
||||
focusedSeriesData.seriesIndex <= apiResult.length
|
||||
) {
|
||||
const { metric: focusedMetric, queryName } =
|
||||
apiResult[focusedSeriesData.seriesIndex - 1] || {};
|
||||
metric = focusedMetric;
|
||||
outputMetric.queryName = queryName;
|
||||
outputMetric.inFocusOrNot = true;
|
||||
}
|
||||
|
||||
// Get the actual data point timestamp from the focused series
|
||||
let actualDataTimestamp = xValue; // fallback to click position timestamp
|
||||
if (focusedSeries) {
|
||||
if (focusedSeriesData) {
|
||||
// Get the data index from the focused series
|
||||
const dataIndex = u.posToIdx(event.offsetX);
|
||||
// Get the actual timestamp from the x-axis data (u.data[0])
|
||||
@@ -209,7 +208,7 @@ function onClickPlugin(opts: OnClickPluginOpts): uPlot.Plugin {
|
||||
absoluteMouseX,
|
||||
absoluteMouseY,
|
||||
axesData,
|
||||
focusedSeries,
|
||||
focusedSeriesData,
|
||||
);
|
||||
};
|
||||
u.over.addEventListener('click', handleClick);
|
||||
|
||||
@@ -38,6 +38,31 @@ function getTooltipBaseValue(
|
||||
return data[index][idx];
|
||||
}
|
||||
|
||||
function sortTooltipContentBasedOnValue(
|
||||
tooltipDataObj: Record<string, UplotTooltipDataProps>,
|
||||
): Record<string, UplotTooltipDataProps> {
|
||||
const entries = Object.entries(tooltipDataObj);
|
||||
|
||||
// Separate focused and non-focused entries in a single pass
|
||||
const focusedEntries: [string, UplotTooltipDataProps][] = [];
|
||||
const nonFocusedEntries: [string, UplotTooltipDataProps][] = [];
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
if (entry[1].focus) {
|
||||
focusedEntries.push(entry);
|
||||
} else {
|
||||
nonFocusedEntries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort non-focused entries by value (descending)
|
||||
nonFocusedEntries.sort((a, b) => b[1].value - a[1].value);
|
||||
|
||||
// Combine with focused entries on top
|
||||
return Object.fromEntries(focusedEntries.concat(nonFocusedEntries));
|
||||
}
|
||||
|
||||
const generateTooltipContent = (
|
||||
seriesList: any[],
|
||||
data: any[],
|
||||
@@ -57,23 +82,31 @@ const generateTooltipContent = (
|
||||
): HTMLElement => {
|
||||
const container = document.createElement('div');
|
||||
container.classList.add('tooltip-container');
|
||||
const overlay = document.getElementById('overlay');
|
||||
let tooltipCount = 0;
|
||||
|
||||
let tooltipTitle = '';
|
||||
const formattedData: Record<string, UplotTooltipDataProps> = {};
|
||||
const duplicatedLegendLabels: Record<string, true> = {};
|
||||
|
||||
function sortTooltipContentBasedOnValue(
|
||||
tooltipDataObj: Record<string, UplotTooltipDataProps>,
|
||||
): Record<string, UplotTooltipDataProps> {
|
||||
const entries = Object.entries(tooltipDataObj);
|
||||
entries.sort((a, b) => b[1].value - a[1].value);
|
||||
return Object.fromEntries(entries);
|
||||
// Pre-build a label-to-series map for O(1) lookup instead of O(n) search
|
||||
let seriesColorMap: Map<string, string> | null = null;
|
||||
if (isBillingUsageGraphs && series) {
|
||||
seriesColorMap = new Map();
|
||||
for (let i = 0; i < series.length; i++) {
|
||||
const item = series[i];
|
||||
if (item.label) {
|
||||
const fillColor = get(item, '_fill');
|
||||
if (fillColor) {
|
||||
seriesColorMap.set(item.label, fillColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(series) && series.length > 0) {
|
||||
series.forEach((item, index) => {
|
||||
for (let index = 0; index < series.length; index++) {
|
||||
const item = series[index];
|
||||
|
||||
if (index === 0) {
|
||||
if (isBillingUsageGraphs) {
|
||||
tooltipTitle = dayjs(data[0][idx] * 1000)
|
||||
@@ -114,15 +147,12 @@ const generateTooltipContent = (
|
||||
isDarkMode ? themeColors.chartcolors : themeColors.lightModeColor,
|
||||
);
|
||||
|
||||
// in case of billing graph pick colors from the series options
|
||||
if (isBillingUsageGraphs) {
|
||||
let clr;
|
||||
series.forEach((item) => {
|
||||
if (item.label === label) {
|
||||
clr = get(item, '_fill');
|
||||
}
|
||||
});
|
||||
color = clr ?? color;
|
||||
// O(1) lookup instead of O(n) search for billing graph colors
|
||||
if (isBillingUsageGraphs && seriesColorMap) {
|
||||
const billingColor = seriesColorMap.get(label);
|
||||
if (billingColor) {
|
||||
color = billingColor;
|
||||
}
|
||||
}
|
||||
|
||||
let tooltipItemLabel = label;
|
||||
@@ -130,10 +160,7 @@ const generateTooltipContent = (
|
||||
if (Number.isFinite(value)) {
|
||||
const tooltipValue = getToolTipValue(value, yAxisUnit, decimalPrecision);
|
||||
const dataIngestedFormated = getToolTipValue(dataIngested);
|
||||
if (
|
||||
duplicatedLegendLabels[label] ||
|
||||
Object.prototype.hasOwnProperty.call(formattedData, label)
|
||||
) {
|
||||
if (duplicatedLegendLabels[label] || label in formattedData) {
|
||||
duplicatedLegendLabels[label] = true;
|
||||
const tempDataObj = formattedData[label];
|
||||
|
||||
@@ -170,15 +197,11 @@ const generateTooltipContent = (
|
||||
formattedData[tooltipItemLabel] = dataObj;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show tooltip only if atleast only series has a value at the hovered timestamp
|
||||
// Early return if no valid data points - avoids unnecessary DOM manipulation
|
||||
if (tooltipCount <= 0) {
|
||||
if (overlay && overlay.style.display === 'block') {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -187,48 +210,42 @@ const generateTooltipContent = (
|
||||
UplotTooltipDataProps
|
||||
> = sortTooltipContentBasedOnValue(formattedData);
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('tooltip-content-row');
|
||||
div.textContent = isHistogramGraphs ? '' : tooltipTitle;
|
||||
div.classList.add('tooltip-content-header');
|
||||
container.appendChild(div);
|
||||
const headerDiv = document.createElement('div');
|
||||
headerDiv.classList.add('tooltip-content-row', 'tooltip-content-header');
|
||||
headerDiv.textContent = isHistogramGraphs ? '' : tooltipTitle;
|
||||
container.appendChild(headerDiv);
|
||||
|
||||
const sortedKeys = Object.keys(sortedData);
|
||||
// Use DocumentFragment for better performance when adding multiple elements
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if (Array.isArray(sortedKeys) && sortedKeys.length > 0) {
|
||||
sortedKeys.forEach((key) => {
|
||||
if (sortedData[key]) {
|
||||
const { textContent, color, focus } = sortedData[key];
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('tooltip-content-row');
|
||||
div.classList.add('tooltip-content');
|
||||
const squareBox = document.createElement('div');
|
||||
squareBox.classList.add('pointSquare');
|
||||
const sortedValues = Object.values(sortedData);
|
||||
|
||||
squareBox.style.borderColor = color;
|
||||
for (let i = 0; i < sortedValues.length; i++) {
|
||||
const { textContent, color, focus } = sortedValues[i];
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.classList.add('tooltip-data-point');
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('tooltip-content-row', 'tooltip-content');
|
||||
|
||||
text.textContent = textContent;
|
||||
text.style.color = color;
|
||||
const squareBox = document.createElement('div');
|
||||
squareBox.classList.add('pointSquare');
|
||||
squareBox.style.borderColor = color;
|
||||
|
||||
if (focus) {
|
||||
text.classList.add('focus');
|
||||
} else {
|
||||
text.classList.remove('focus');
|
||||
}
|
||||
const text = document.createElement('div');
|
||||
text.classList.add('tooltip-data-point');
|
||||
text.textContent = textContent;
|
||||
text.style.color = color;
|
||||
|
||||
div.appendChild(squareBox);
|
||||
div.appendChild(text);
|
||||
if (focus) {
|
||||
text.classList.add('focus');
|
||||
}
|
||||
|
||||
container.appendChild(div);
|
||||
}
|
||||
});
|
||||
div.appendChild(squareBox);
|
||||
div.appendChild(text);
|
||||
fragment.appendChild(div);
|
||||
}
|
||||
|
||||
if (overlay && overlay.style.display === 'none') {
|
||||
overlay.style.display = 'block';
|
||||
if (fragment.hasChildNodes()) {
|
||||
container.appendChild(fragment);
|
||||
}
|
||||
|
||||
return container;
|
||||
@@ -266,80 +283,152 @@ const tooltipPlugin = ({
|
||||
ToolTipPluginProps): any => {
|
||||
let over: HTMLElement;
|
||||
let bound: HTMLElement;
|
||||
let bLeft: any;
|
||||
let bTop: any;
|
||||
// Cache bounding box to avoid recalculating on every cursor move
|
||||
let cachedBBox: DOMRect | null = null;
|
||||
let isActive = false;
|
||||
let overlay: HTMLElement | null = null;
|
||||
|
||||
// Pre-compute apiResult once
|
||||
const apiResult = apiResponse?.data?.result || [];
|
||||
|
||||
// Sync bounds and cache the result
|
||||
const syncBounds = (): void => {
|
||||
const bbox = over.getBoundingClientRect();
|
||||
bLeft = bbox.left;
|
||||
bTop = bbox.top;
|
||||
if (over) {
|
||||
cachedBBox = over.getBoundingClientRect();
|
||||
}
|
||||
};
|
||||
|
||||
let overlay = document.getElementById('overlay');
|
||||
// Create overlay once and reuse it
|
||||
const initOverlay = (): void => {
|
||||
if (!overlay) {
|
||||
overlay = document.getElementById('overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'overlay';
|
||||
overlay.style.cssText = 'display: none; position: absolute;';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'overlay';
|
||||
overlay.style.display = 'none';
|
||||
overlay.style.position = 'absolute';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
const showOverlay = (): void => {
|
||||
if (overlay && overlay.style.display === 'none') {
|
||||
overlay.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
const apiResult = apiResponse?.data?.result || [];
|
||||
const hideOverlay = (): void => {
|
||||
if (overlay && overlay.style.display === 'block') {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const plotEnter = (): void => {
|
||||
isActive = true;
|
||||
showOverlay();
|
||||
};
|
||||
|
||||
const plotLeave = (): void => {
|
||||
isActive = false;
|
||||
hideOverlay();
|
||||
};
|
||||
|
||||
// Cleanup function to remove event listeners
|
||||
const cleanup = (): void => {
|
||||
if (over) {
|
||||
over.removeEventListener('mouseenter', plotEnter);
|
||||
over.removeEventListener('mouseleave', plotLeave);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
init: (u: any): void => {
|
||||
over = u?.over;
|
||||
bound = over;
|
||||
over.onmouseenter = (): void => {
|
||||
if (overlay) {
|
||||
overlay.style.display = 'block';
|
||||
}
|
||||
};
|
||||
over.onmouseleave = (): void => {
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize overlay once during init
|
||||
initOverlay();
|
||||
|
||||
// Initial bounds sync
|
||||
syncBounds();
|
||||
|
||||
over.addEventListener('mouseenter', plotEnter);
|
||||
over.addEventListener('mouseleave', plotLeave);
|
||||
},
|
||||
setSize: (): void => {
|
||||
// Re-sync bounds when size changes
|
||||
syncBounds();
|
||||
},
|
||||
// Cache bounding box on syncRect for better performance
|
||||
syncRect: (u: any, rect: DOMRect): void => {
|
||||
cachedBBox = rect;
|
||||
},
|
||||
setCursor: (u: {
|
||||
cursor: { left: any; top: any; idx: any };
|
||||
data: any[];
|
||||
series: uPlot.Options['series'];
|
||||
}): void => {
|
||||
if (overlay) {
|
||||
overlay.textContent = '';
|
||||
const { left, top, idx } = u.cursor;
|
||||
|
||||
if (Number.isInteger(idx)) {
|
||||
const anchor = { left: left + bLeft, top: top + bTop };
|
||||
const content = generateTooltipContent(
|
||||
apiResult,
|
||||
u.data,
|
||||
idx,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
u.series,
|
||||
isBillingUsageGraphs,
|
||||
isHistogramGraphs,
|
||||
isMergedSeries,
|
||||
stackBarChart,
|
||||
timezone,
|
||||
colorMapping,
|
||||
query,
|
||||
);
|
||||
if (customTooltipElement) {
|
||||
content.appendChild(customTooltipElement);
|
||||
}
|
||||
overlay.appendChild(content);
|
||||
placement(overlay, anchor, 'right', 'start', { bound });
|
||||
}
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { left, top, idx } = u.cursor;
|
||||
|
||||
// Early return if not active or no valid index
|
||||
if (!isActive || !Number.isInteger(idx)) {
|
||||
if (isActive) {
|
||||
// Clear tooltip content efficiently using replaceChildren
|
||||
overlay.replaceChildren();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Use cached bounding box if available
|
||||
const bbox = cachedBBox || over.getBoundingClientRect();
|
||||
const anchor = {
|
||||
left: left + bbox.left,
|
||||
top: top + bbox.top,
|
||||
};
|
||||
|
||||
const content = generateTooltipContent(
|
||||
apiResult,
|
||||
u.data,
|
||||
idx,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
u.series,
|
||||
isBillingUsageGraphs,
|
||||
isHistogramGraphs,
|
||||
isMergedSeries,
|
||||
stackBarChart,
|
||||
timezone,
|
||||
colorMapping,
|
||||
query,
|
||||
);
|
||||
|
||||
// Only show tooltip if there's actual content
|
||||
if (content.children.length > 1) {
|
||||
if (customTooltipElement) {
|
||||
content.appendChild(customTooltipElement);
|
||||
}
|
||||
// Clear and set new content in one operation
|
||||
overlay.replaceChildren(content);
|
||||
placement(overlay, anchor, 'right', 'start', {
|
||||
bound,
|
||||
followCursor: { x: anchor.left, y: anchor.top, offset: 4 },
|
||||
});
|
||||
|
||||
showOverlay();
|
||||
} else {
|
||||
hideOverlay();
|
||||
}
|
||||
},
|
||||
destroy: (): void => {
|
||||
// Cleanup on destroy
|
||||
cleanup();
|
||||
hideOverlay();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,6 +16,6 @@ export const topTracesTableColumns = [
|
||||
title: 'STEP TRANSITION DURATION',
|
||||
dataIndex: 'duration_ms',
|
||||
key: 'duration_ms',
|
||||
render: (value: string): string => getYAxisFormattedValue(value, 'ms'),
|
||||
render: (value: string): string => getYAxisFormattedValue(`${value}`, 'ms'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -202,11 +202,7 @@ export function DashboardProvider({
|
||||
updateLocalStorageDashboardVariables,
|
||||
} = useDashboardVariablesFromLocalStorage(dashboardId);
|
||||
|
||||
const {
|
||||
getUrlVariables,
|
||||
updateUrlVariable,
|
||||
clearUrlVariables,
|
||||
} = useVariablesFromUrl();
|
||||
const { getUrlVariables, updateUrlVariable } = useVariablesFromUrl();
|
||||
|
||||
const updatedTimeRef = useRef<Dayjs | null>(null); // Using ref to store the updated time
|
||||
const modalRef = useRef<any>(null);
|
||||
@@ -218,14 +214,6 @@ export function DashboardProvider({
|
||||
|
||||
const [isDashboardFetching, setIsDashboardFetching] = useState<boolean>(false);
|
||||
|
||||
// Clear variable configs when not on dashboard pages
|
||||
useEffect(() => {
|
||||
const isOnDashboardPage = !!isDashboardPage || !!isDashboardWidgetPage;
|
||||
if (!isOnDashboardPage) {
|
||||
clearUrlVariables();
|
||||
}
|
||||
}, [isDashboardPage, isDashboardWidgetPage, clearUrlVariables]);
|
||||
|
||||
const mergeDBWithLocalStorage = (
|
||||
data: Dashboard,
|
||||
localStorageVariables: any,
|
||||
|
||||
@@ -121,7 +121,6 @@ function renderWithDashboardProvider(
|
||||
// Mock URL variables hook
|
||||
const mockGetUrlVariables = jest.fn();
|
||||
const mockUpdateUrlVariable = jest.fn();
|
||||
const mockClearUrlVariables = jest.fn();
|
||||
const mockSetUrlVariables = jest.fn();
|
||||
|
||||
jest.mock('hooks/dashboard/useVariablesFromUrl', () => ({
|
||||
@@ -129,7 +128,6 @@ jest.mock('hooks/dashboard/useVariablesFromUrl', () => ({
|
||||
default: jest.fn(() => ({
|
||||
getUrlVariables: mockGetUrlVariables,
|
||||
updateUrlVariable: mockUpdateUrlVariable,
|
||||
clearUrlVariables: mockClearUrlVariables,
|
||||
setUrlVariables: mockSetUrlVariables,
|
||||
})),
|
||||
}));
|
||||
@@ -524,16 +522,6 @@ describe('Dashboard Provider - URL Variables Integration', () => {
|
||||
expect(parsedVariables.environment.allSelected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear URL variables when not on dashboard page', async () => {
|
||||
mockUseRouteMatch.mockReturnValue(null); // Not on dashboard page
|
||||
|
||||
renderWithDashboardProvider('/other-page', null);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockClearUrlVariables).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variable Value Normalization', () => {
|
||||
|
||||
@@ -401,14 +401,14 @@ body {
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
margin: 0.5rem;
|
||||
background: rgba(0, 0, 0);
|
||||
background: var(--bg-ink-300);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #fff;
|
||||
color: var(--bg-vanilla-100);
|
||||
z-index: 10000;
|
||||
// pointer-events: none;
|
||||
overflow: auto;
|
||||
max-height: 480px !important;
|
||||
max-width: 240px !important;
|
||||
max-width: 300px !important;
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
@@ -571,6 +571,12 @@ body {
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
#overlay {
|
||||
color: var(--bg-ink-500);
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.ant-dropdown-menu {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
2
go.mod
2
go.mod
@@ -4,7 +4,7 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.1
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.11
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.16
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.40.1
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
|
||||
|
||||
2
go.sum
2
go.sum
@@ -68,6 +68,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.11 h1:fZMKAjRmgzW44+hEhF6ywi4VjFZQjJ8QrFBbgBsjmF4=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.11/go.mod h1:W0Z82wJWkJxz2RVun/RMwxue3g7ut47Xxl+SFqdJGus=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.16 h1:gpl+wXclYUKT0p4+gBq22XeRYWwEoZ9f35vogqMvkLQ=
|
||||
github.com/AfterShip/clickhouse-sql-parser v0.4.16/go.mod h1:W0Z82wJWkJxz2RVun/RMwxue3g7ut47Xxl+SFqdJGus=
|
||||
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
|
||||
@@ -99,9 +99,9 @@ func transformToSpanPercentileResponse(queryResult *qbtypes.QueryRangeResponse)
|
||||
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "no spans found matching the specified criteria")
|
||||
}
|
||||
|
||||
description := fmt.Sprintf("faster than %.1f%% of spans", position)
|
||||
description := fmt.Sprintf("slower than %.1f%% of spans", position)
|
||||
if position < 50 {
|
||||
description = fmt.Sprintf("slower than %.1f%% of spans", 100-position)
|
||||
description = fmt.Sprintf("faster than %.1f%% of spans", 100-position)
|
||||
}
|
||||
|
||||
return &spanpercentiletypes.SpanPercentileResponse{
|
||||
|
||||
695
pkg/parser/queryfilterextractor/clickhouse.go
Normal file
695
pkg/parser/queryfilterextractor/clickhouse.go
Normal file
@@ -0,0 +1,695 @@
|
||||
package queryfilterextractor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
clickhouse "github.com/AfterShip/clickhouse-sql-parser/parser"
|
||||
)
|
||||
|
||||
const (
|
||||
// MetricNameColumn is the column name used for filtering metrics
|
||||
MetricNameColumn = "metric_name"
|
||||
)
|
||||
|
||||
// ClickHouseFilterExtractor extracts metric names and grouping keys from ClickHouse SQL queries
|
||||
type ClickHouseFilterExtractor struct{}
|
||||
|
||||
// NewClickHouseFilterExtractor creates a new ClickHouse filter extractor
|
||||
func NewClickHouseFilterExtractor() *ClickHouseFilterExtractor {
|
||||
return &ClickHouseFilterExtractor{}
|
||||
}
|
||||
|
||||
// Extract parses a ClickHouse query and extracts metric names and grouping keys
|
||||
func (e *ClickHouseFilterExtractor) Extract(query string) (*FilterResult, error) {
|
||||
p := clickhouse.NewParser(query)
|
||||
stmts, err := p.ParseStmts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &FilterResult{MetricNames: []string{}, GroupByColumns: []ColumnInfo{}}
|
||||
|
||||
metricNames := make(map[string]bool)
|
||||
|
||||
// Track top-level queries for GROUP BY extraction
|
||||
topLevelQueries := make(map[*clickhouse.SelectQuery]bool)
|
||||
|
||||
// Process all statements
|
||||
for _, stmt := range stmts {
|
||||
selectQuery, ok := stmt.(*clickhouse.SelectQuery)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark as top-level
|
||||
topLevelQueries[selectQuery] = true
|
||||
|
||||
// Walk the AST to extract metrics
|
||||
clickhouse.Walk(selectQuery, func(node clickhouse.Expr) bool {
|
||||
e.fillMetricNamesFromExpr(node, metricNames)
|
||||
return true // Continue traversal
|
||||
})
|
||||
}
|
||||
|
||||
// Extract GROUP BY from the top-level queries by first building a map of CTEs and
|
||||
// then recursively extracting the GROUP BY from the CTEs and subqueries.
|
||||
|
||||
// Build CTE map for all top-level queries
|
||||
cteMap := make(map[string]*clickhouse.SelectQuery)
|
||||
for query := range topLevelQueries {
|
||||
e.buildCTEMap(query, cteMap)
|
||||
}
|
||||
|
||||
// Extract GROUP BY with aliases and origins from the CTEs and subqueries using recursive approach
|
||||
// Use a map to handle duplicates (last ColumnInfo wins across queries)
|
||||
groupByColumnsMap := make(map[string]ColumnInfo) // column name -> ColumnInfo
|
||||
visited := make(map[*clickhouse.SelectQuery]bool)
|
||||
for query := range topLevelQueries {
|
||||
columns, err := e.extractGroupByColumns(query, cteMap, visited)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, col := range columns {
|
||||
// Last column info wins for duplicate columns across multiple queries
|
||||
groupByColumnsMap[col.Name] = col
|
||||
}
|
||||
}
|
||||
|
||||
// Convert sets to slices
|
||||
for metric := range metricNames {
|
||||
result.MetricNames = append(result.MetricNames, metric)
|
||||
}
|
||||
|
||||
// Build GroupByColumns from the map
|
||||
for _, colInfo := range groupByColumnsMap {
|
||||
result.GroupByColumns = append(result.GroupByColumns, colInfo)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Metric Name Extraction
|
||||
// ========================================
|
||||
|
||||
// fillMetricNamesFromExpr extracts metric names from various node types
|
||||
func (e *ClickHouseFilterExtractor) fillMetricNamesFromExpr(node clickhouse.Expr, metricNames map[string]bool) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *clickhouse.BinaryOperation:
|
||||
e.fillMetricFromBinaryOp(n, metricNames)
|
||||
}
|
||||
}
|
||||
|
||||
// fillMetricFromBinaryOp extracts metrics from binary operations
|
||||
func (e *ClickHouseFilterExtractor) fillMetricFromBinaryOp(op *clickhouse.BinaryOperation, metricNames map[string]bool) {
|
||||
// Check if left side is metric_name column
|
||||
leftCol := e.getColumnName(op.LeftExpr)
|
||||
rightCol := e.getColumnName(op.RightExpr)
|
||||
|
||||
// Handle metric_name on left side: metric_name = 'value'
|
||||
if leftCol == MetricNameColumn {
|
||||
e.fillMetricWithBinaryOpConditions(op, op.RightExpr, metricNames)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle metric_name on right side: 'value' = metric_name
|
||||
if rightCol == MetricNameColumn {
|
||||
e.fillMetricWithBinaryOpConditions(op, op.LeftExpr, metricNames)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// fillMetricWithBinaryOpConditions extracts metric names from the value side of a binary operation
|
||||
//
|
||||
// Supported operators:
|
||||
// - "=", "==": Extracts literal string values or values from any() function
|
||||
// - "IN", "GLOBAL IN": Extracts all literal string values from the list
|
||||
//
|
||||
// Unsupported operators (can be added later if needed):
|
||||
// - "!=", "<>", "NOT IN": Negative filters. (e.g., metric_name != 'a')
|
||||
// - "LIKE", "ILIKE": Pattern matching filters
|
||||
// - "NOT LIKE", "NOT ILIKE": Negative pattern matching filters
|
||||
// - "OR", "AND": Boolean operators as the Walk function will automatically traverse both sides
|
||||
// of OR/AND operations and extract metrics from each branch. (e.g., metric_name='a' OR metric_name='b')
|
||||
func (e *ClickHouseFilterExtractor) fillMetricWithBinaryOpConditions(op *clickhouse.BinaryOperation, valueExpr clickhouse.Expr, metricNames map[string]bool) {
|
||||
switch op.Operation {
|
||||
case "=", "==":
|
||||
// metric_name = 'value' or metric_name = any(['a', 'b'])
|
||||
// Skip if value side is a function call (per spec - function-wrapped literals are ignored, CH59)
|
||||
if fn, ok := valueExpr.(*clickhouse.FunctionExpr); ok {
|
||||
// Only handle any() function, skip others like lowercase('cpu')
|
||||
if fn.Name != nil && fn.Name.Name == "any" {
|
||||
e.extractInValues(valueExpr, metricNames)
|
||||
}
|
||||
// Otherwise skip function-wrapped literals per spec
|
||||
} else if val := e.extractStringLiteral(valueExpr); val != "" {
|
||||
metricNames[val] = true
|
||||
}
|
||||
case "IN", "GLOBAL IN":
|
||||
// metric_name IN ('a', 'b', 'c')
|
||||
// GLOBAL IN behaves the same as IN for metric extraction purposes
|
||||
// Skip if value side is a function call (per spec - function-wrapped literals are ignored, CH59)
|
||||
if _, ok := valueExpr.(*clickhouse.FunctionExpr); !ok {
|
||||
e.extractInValues(valueExpr, metricNames)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractStringLiteral extracts a string literal value from an expression
|
||||
func (e *ClickHouseFilterExtractor) extractStringLiteral(expr clickhouse.Expr) string {
|
||||
switch ex := expr.(type) {
|
||||
case *clickhouse.StringLiteral:
|
||||
return ex.Literal
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractInValues extracts values from IN expressions
|
||||
func (e *ClickHouseFilterExtractor) extractInValues(expr clickhouse.Expr, metricNames map[string]bool) {
|
||||
// Find all string literals in the expression
|
||||
strLits := clickhouse.FindAll(expr, func(node clickhouse.Expr) bool {
|
||||
// metric_name passed in `in` condition will be string literal.
|
||||
_, ok := node.(*clickhouse.StringLiteral)
|
||||
return ok
|
||||
})
|
||||
|
||||
for _, strLitNode := range strLits {
|
||||
if strLit, ok := strLitNode.(*clickhouse.StringLiteral); ok {
|
||||
// Unquote the string literal
|
||||
val := e.extractStringLiteral(strLit)
|
||||
if val != "" {
|
||||
metricNames[val] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// GROUP BY Column Extraction
|
||||
// ========================================
|
||||
|
||||
// extractGroupByColumns extracts the GROUP BY columns from a query
|
||||
// It follows the top-down approach where outer GROUP BY overrides inner GROUP BY in subqueries and CTEs.
|
||||
// Returns a slice of ColumnInfo with column names, aliases, and origins
|
||||
func (e *ClickHouseFilterExtractor) extractGroupByColumns(query *clickhouse.SelectQuery, cteMap map[string]*clickhouse.SelectQuery, visited map[*clickhouse.SelectQuery]bool) ([]ColumnInfo, error) {
|
||||
if visited[query] {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Mark this query as visited to prevent cycles
|
||||
visited[query] = true
|
||||
|
||||
// First, check if this query has its own GROUP BY using direct field access
|
||||
hasGroupBy := query.GroupBy != nil
|
||||
|
||||
// If this query has GROUP BY, use it (outer overrides inner)
|
||||
if hasGroupBy {
|
||||
// Extract GROUP BY columns
|
||||
tempGroupBy := make(map[string]bool)
|
||||
e.fillGroupsFromGroupByClause(query.GroupBy, tempGroupBy)
|
||||
|
||||
// Extract SELECT columns and their aliases from the same query level
|
||||
selectAliases := e.extractSelectColumns(query)
|
||||
|
||||
// Build ColumnInfo array by matching GROUP BY with SELECT aliases and origins
|
||||
result := []ColumnInfo{}
|
||||
originVisited := make(map[*clickhouse.SelectQuery]bool)
|
||||
|
||||
for groupByCol := range tempGroupBy {
|
||||
alias := selectAliases[groupByCol] // Will be "" if not in SELECT
|
||||
|
||||
// Extract originExpr by tracing back through queries
|
||||
originExpr := e.extractColumnOrigin(groupByCol, query, cteMap, originVisited)
|
||||
originField, err := extractCHOriginFieldFromQuery(fmt.Sprintf("SELECT %s", originExpr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result = append(result, ColumnInfo{
|
||||
Name: groupByCol,
|
||||
Alias: alias,
|
||||
OriginExpr: originExpr,
|
||||
OriginField: originField,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// If no GROUP BY in this query, follow CTE/subquery references
|
||||
// It might have grouping inside the CTE/subquery
|
||||
sourceQuery := e.extractSourceQuery(query, cteMap)
|
||||
if sourceQuery != nil {
|
||||
return e.extractGroupByColumns(sourceQuery, cteMap, visited)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// fillGroupsFromGroupByClause extracts GROUP BY columns from a specific GroupByClause and fills the map with the column names
|
||||
func (e *ClickHouseFilterExtractor) fillGroupsFromGroupByClause(groupByClause *clickhouse.GroupByClause, groupBy map[string]bool) {
|
||||
|
||||
// Extract GROUP BY expressions properly
|
||||
// Find only the direct child ColumnExprList, not nested ones
|
||||
// We use Find instead of FindAll to get only the first (direct child) ColumnExprList
|
||||
exprListNode, foundList := clickhouse.Find(groupByClause, func(node clickhouse.Expr) bool {
|
||||
_, ok := node.(*clickhouse.ColumnExprList)
|
||||
return ok
|
||||
})
|
||||
|
||||
if !foundList {
|
||||
return
|
||||
}
|
||||
|
||||
// Note: We only extract from the top-level ColumnExprList.Items to avoid extracting nested parts
|
||||
// This prevents extracting 'timestamp' from 'toDate(timestamp)' - we only get 'toDate(timestamp)'
|
||||
if exprList, ok := exprListNode.(*clickhouse.ColumnExprList); ok {
|
||||
// Extract each expression from the list - these are top-level only
|
||||
if exprList.Items != nil {
|
||||
for _, item := range exprList.Items {
|
||||
groupKey := e.extractColumnStrByExpr(item)
|
||||
if groupKey != "" {
|
||||
// Strip table alias if present (e.g., "m.region" -> "region")
|
||||
groupKey = e.stripTableAlias(groupKey)
|
||||
groupBy[groupKey] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// extractColumnStrByExpr extracts the complete string representation of different expression types
|
||||
// Supports:
|
||||
// - Ident: Simple identifier like "region" or "timestamp"
|
||||
// - FunctionExpr: Function call like "toDate(timestamp)"
|
||||
// - ColumnExpr: Column expression like "m.region", "toDate(timestamp)"
|
||||
// - Other expression types: Return the string representation of the expression
|
||||
//
|
||||
// For example:
|
||||
// - "region" -> "region"
|
||||
// - "toDate(timestamp)" -> "toDate(timestamp)"
|
||||
// - "`m.region`" -> "`m.region`"
|
||||
func (e *ClickHouseFilterExtractor) extractColumnStrByExpr(expr clickhouse.Expr) string {
|
||||
if expr == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch ex := expr.(type) {
|
||||
// Ident is a simple identifier like "region" or "timestamp"
|
||||
case *clickhouse.Ident:
|
||||
// Handling for backticks which are native to ClickHouse and used for literal names.
|
||||
// CH Parser removes the backticks from the identifier, so we need to add them back.
|
||||
if ex.QuoteType == clickhouse.BackTicks {
|
||||
return "`" + ex.Name + "`"
|
||||
}
|
||||
return ex.Name
|
||||
// FunctionExpr is a function call like "toDate(timestamp)"
|
||||
case *clickhouse.FunctionExpr:
|
||||
// For function expressions, return the complete function call string
|
||||
return ex.String()
|
||||
// ColumnExpr is a column expression like "m.region", "toDate(timestamp)"
|
||||
case *clickhouse.ColumnExpr:
|
||||
// ColumnExpr wraps another expression - extract the underlying expression
|
||||
if ex.Expr != nil {
|
||||
return e.extractColumnStrByExpr(ex.Expr)
|
||||
}
|
||||
return ex.String()
|
||||
default:
|
||||
// For other expression types, return the string representation
|
||||
return expr.String()
|
||||
}
|
||||
}
|
||||
|
||||
// stripTableAlias removes table alias prefix from a column name (e.g., "m.region" -> "region")
|
||||
// but for literals with backticks, we need preserve the entire string. (e.g., `os.type` -> "os.type")
|
||||
func (e *ClickHouseFilterExtractor) stripTableAlias(name string) string {
|
||||
// Handling for backticks which are native to ClickHouse and used for literal names.
|
||||
if strings.HasPrefix(name, "`") && strings.HasSuffix(name, "`") {
|
||||
return strings.Trim(name, "`")
|
||||
}
|
||||
|
||||
// split the name by dot and return the last part
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) > 1 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// getColumnName extracts column name from an expression
|
||||
func (e *ClickHouseFilterExtractor) getColumnName(expr clickhouse.Expr) string {
|
||||
switch ex := expr.(type) {
|
||||
case *clickhouse.Ident:
|
||||
return ex.Name
|
||||
case *clickhouse.Path:
|
||||
// Handle Path type for qualified column names like "m.metric_name"
|
||||
// Extract the last field which is the column name
|
||||
if len(ex.Fields) > 0 {
|
||||
return ex.Fields[len(ex.Fields)-1].Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractSourceQuery extracts the SelectQuery from FROM expressions
|
||||
// Handles CTE references, subqueries, and table expressions
|
||||
// For example: from the below query We'll try to extract the name of the source query
|
||||
// which in the below case is "aggregated". Once we find it we return the SelectQuery node
|
||||
// from the cteMap, which acts as the source for the GROUP BY extraction.
|
||||
//
|
||||
// WITH aggregated AS (
|
||||
// SELECT region as region_alias, sum(value) AS total
|
||||
// FROM metrics
|
||||
// WHERE metric_name = 'cpu_usage'
|
||||
// GROUP BY region
|
||||
// )
|
||||
// SELECT * FROM aggregated
|
||||
func (e *ClickHouseFilterExtractor) extractSourceQuery(query *clickhouse.SelectQuery, cteMap map[string]*clickhouse.SelectQuery) *clickhouse.SelectQuery {
|
||||
if query.From == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the FROM clause and extract the source
|
||||
fromExprs := clickhouse.FindAll(query.From, func(node clickhouse.Expr) bool {
|
||||
switch node.(type) {
|
||||
case *clickhouse.Ident, *clickhouse.SelectQuery:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
for _, fromExpr := range fromExprs {
|
||||
switch expr := fromExpr.(type) {
|
||||
case *clickhouse.Ident:
|
||||
// CTE reference by simple name
|
||||
if cteQuery, exists := cteMap[expr.Name]; exists {
|
||||
return cteQuery
|
||||
}
|
||||
case *clickhouse.SelectQuery:
|
||||
// Direct subquery
|
||||
return expr
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Column Origin Tracing
|
||||
// ========================================
|
||||
|
||||
// extractColumnOrigin recursively traces a column back to its original expression
|
||||
// Returns the original expression string (e.g., "JSONExtractString(labels, 'service.name')")
|
||||
// or the column name itself if it's a direct column reference
|
||||
func (e *ClickHouseFilterExtractor) extractColumnOrigin(
|
||||
columnName string,
|
||||
query *clickhouse.SelectQuery,
|
||||
cteMap map[string]*clickhouse.SelectQuery,
|
||||
visited map[*clickhouse.SelectQuery]bool,
|
||||
) string {
|
||||
if query == nil {
|
||||
return columnName
|
||||
}
|
||||
|
||||
// Prevent infinite recursion and redundant work
|
||||
// Once a query is visited, we don't need to check it again
|
||||
if visited[query] {
|
||||
return columnName
|
||||
}
|
||||
visited[query] = true
|
||||
// this is to prevent infinite recursion in a single query search
|
||||
// but we don't want this to affect the other queries searches
|
||||
// so we delete it after the search is done for current query
|
||||
defer delete(visited, query)
|
||||
|
||||
// Step 1: Search in CTE and Joins, this will take us to very end of the SubQueries and CTE
|
||||
sourceQuery := e.extractSourceQuery(query, cteMap)
|
||||
if sourceQuery != nil {
|
||||
returningOrigin := e.extractColumnOrigin(columnName, sourceQuery, cteMap, visited)
|
||||
if returningOrigin != columnName {
|
||||
return returningOrigin
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Once we're sure there are no SubQueries and CTE we just find all the selectItem
|
||||
// and then get their column origin values
|
||||
selectItems := clickhouse.FindAll(query, func(node clickhouse.Expr) bool {
|
||||
_, ok := node.(*clickhouse.SelectItem)
|
||||
return ok
|
||||
})
|
||||
|
||||
// extractOriginFromSelectItem extracts the origin from a SelectItem
|
||||
extractOriginFromSelectItem := func(selectItem *clickhouse.SelectItem) *string {
|
||||
// Check if this SelectItem matches our column (by alias or by name)
|
||||
alias := e.extractSelectItemAlias(selectItem)
|
||||
exprStr := e.extractSelectItemName(selectItem)
|
||||
normalizedExpr := e.stripTableAlias(exprStr)
|
||||
|
||||
// Case 1: Column matches an alias in SELECT
|
||||
if alias == columnName {
|
||||
// This is an alias - get the expression it's aliasing
|
||||
if selectItem.Expr != nil {
|
||||
originExpr := e.extractFullExpression(selectItem.Expr)
|
||||
// If the expression is just a column name, trace it back further
|
||||
if normalizedExpr == columnName || e.isSimpleColumnReference(selectItem.Expr) {
|
||||
// It's referencing another column - trace back through source query
|
||||
sourceQuery := e.extractSourceQuery(query, cteMap)
|
||||
if sourceQuery != nil {
|
||||
originExpr := e.extractColumnOrigin(normalizedExpr, sourceQuery, cteMap, visited)
|
||||
return &originExpr
|
||||
}
|
||||
}
|
||||
return &originExpr
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Column matches the expression itself (no alias)
|
||||
if normalizedExpr == columnName {
|
||||
// Check if this is a simple column reference or a complex expression
|
||||
if e.isSimpleColumnReference(selectItem.Expr) {
|
||||
// Simple column - trace back through source query
|
||||
sourceQuery := e.extractSourceQuery(query, cteMap)
|
||||
if sourceQuery != nil {
|
||||
originExpr := e.extractColumnOrigin(columnName, sourceQuery, cteMap, visited)
|
||||
return &originExpr
|
||||
}
|
||||
return &columnName
|
||||
} else {
|
||||
// Complex expression - return it as origin
|
||||
originExpr := e.extractFullExpression(selectItem.Expr)
|
||||
return &originExpr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var finalColumnOrigin string
|
||||
for _, itemNode := range selectItems {
|
||||
if selectItem, ok := itemNode.(*clickhouse.SelectItem); ok {
|
||||
// We call the extractOriginFromSelectItem function for each SelectItem
|
||||
// and if the origin is not nil, we set the finalColumnOrigin to the origin
|
||||
// this has to be done to get to the most nested origin of column where selectItem is present
|
||||
origin := extractOriginFromSelectItem(selectItem)
|
||||
if origin != nil {
|
||||
finalColumnOrigin = *origin
|
||||
}
|
||||
}
|
||||
}
|
||||
if finalColumnOrigin != "" {
|
||||
return finalColumnOrigin
|
||||
}
|
||||
|
||||
return columnName
|
||||
}
|
||||
|
||||
// extractFullExpression extracts the complete string representation of an expression
|
||||
func (e *ClickHouseFilterExtractor) extractFullExpression(expr clickhouse.Expr) string {
|
||||
if expr == nil {
|
||||
return ""
|
||||
}
|
||||
return expr.String()
|
||||
}
|
||||
|
||||
// isSimpleColumnReference checks if an expression is just a simple column reference
|
||||
// (not a function call or complex expression)
|
||||
func (e *ClickHouseFilterExtractor) isSimpleColumnReference(expr clickhouse.Expr) bool {
|
||||
if expr == nil {
|
||||
return false
|
||||
}
|
||||
switch ex := expr.(type) {
|
||||
case *clickhouse.Ident:
|
||||
// backticks are treated as non simple column reference
|
||||
// so that we can return the origin expression with backticks
|
||||
// origin parser will handle the backticks and extract the column name from it
|
||||
if ex.QuoteType == clickhouse.BackTicks {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
case *clickhouse.Path:
|
||||
return true
|
||||
case *clickhouse.ColumnExpr:
|
||||
// Check if it wraps a simple reference
|
||||
if ex.Expr != nil {
|
||||
return e.isSimpleColumnReference(ex.Expr)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// SELECT Column Alias Extraction
|
||||
// ========================================
|
||||
|
||||
// extractSelectColumns extracts column names and their aliases from SELECT clause of a specific query
|
||||
// Returns a map where key is normalized column name and value is the alias
|
||||
// For duplicate columns with different aliases, the last alias wins
|
||||
// This follows the same pattern as extractGroupFromGroupByClause - finding direct children only
|
||||
func (e *ClickHouseFilterExtractor) extractSelectColumns(query *clickhouse.SelectQuery) map[string]string {
|
||||
aliasMap := make(map[string]string)
|
||||
|
||||
if query == nil {
|
||||
return aliasMap
|
||||
}
|
||||
|
||||
// Find SelectItem nodes which represent columns in the SELECT clause
|
||||
// SelectItem has an Expr field (the column/expression) and an Alias field
|
||||
selectItems := clickhouse.FindAll(query, func(node clickhouse.Expr) bool {
|
||||
_, ok := node.(*clickhouse.SelectItem)
|
||||
return ok
|
||||
})
|
||||
|
||||
// Process each SelectItem and extract column name and alias
|
||||
for _, itemNode := range selectItems {
|
||||
if selectItem, ok := itemNode.(*clickhouse.SelectItem); ok {
|
||||
// Extract the column name/expression from SelectItem.Expr
|
||||
columnName := e.extractSelectItemName(selectItem)
|
||||
if columnName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize column name (strip table alias)
|
||||
normalizedName := e.stripTableAlias(columnName)
|
||||
|
||||
// Extract alias from SelectItem.Alias
|
||||
alias := e.extractSelectItemAlias(selectItem)
|
||||
|
||||
// Store in map - last alias wins for duplicates
|
||||
aliasMap[normalizedName] = alias
|
||||
}
|
||||
}
|
||||
|
||||
return aliasMap
|
||||
}
|
||||
|
||||
// extractSelectItemName extracts the column name or expression from a SelectItem
|
||||
func (e *ClickHouseFilterExtractor) extractSelectItemName(selectItem *clickhouse.SelectItem) string {
|
||||
if selectItem == nil || selectItem.Expr == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return e.extractColumnStrByExpr(selectItem.Expr)
|
||||
}
|
||||
|
||||
// extractSelectItemAlias extracts the alias from a SelectItem
|
||||
// Returns empty string if no alias is present
|
||||
func (e *ClickHouseFilterExtractor) extractSelectItemAlias(selectItem *clickhouse.SelectItem) string {
|
||||
if selectItem == nil || selectItem.Alias == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// The Alias field is an *Ident (pointer type)
|
||||
if selectItem.Alias.Name != "" {
|
||||
return selectItem.Alias.Name
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CTE and Subquery Extraction
|
||||
// ========================================
|
||||
|
||||
// buildCTEMap builds a map of CTE names to their SelectQuery nodes by recursively
|
||||
// traversing all queries and their nested expressions
|
||||
func (e *ClickHouseFilterExtractor) buildCTEMap(query *clickhouse.SelectQuery, cteMap map[string]*clickhouse.SelectQuery) {
|
||||
if query == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Access CTEs directly from WithClause if it exists
|
||||
if query.With != nil && query.With.CTEs != nil {
|
||||
for _, cte := range query.With.CTEs {
|
||||
cteName := e.extractCTEName(cte)
|
||||
cteQuery := e.extractCTEQuery(cte)
|
||||
if cteName != "" && cteQuery != nil {
|
||||
cteMap[cteName] = cteQuery
|
||||
// Recursively build CTE map for nested CTEs
|
||||
e.buildCTEMap(cteQuery, cteMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for CTEs in subqueries and other expressions
|
||||
e.buildCTEMapFromExpr(query, cteMap)
|
||||
}
|
||||
|
||||
// extractCTEName extracts the CTE name from a CTEStmt, the Expr field is the name of the CTE
|
||||
func (e *ClickHouseFilterExtractor) extractCTEName(cte *clickhouse.CTEStmt) string {
|
||||
if cte == nil || cte.Expr == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch name := cte.Expr.(type) {
|
||||
case *clickhouse.Ident:
|
||||
return name.Name
|
||||
default:
|
||||
return cte.Expr.String()
|
||||
}
|
||||
}
|
||||
|
||||
// extractCTEQuery extracts the SelectQuery from a CTEStmt, the Alias field is the SelectQuery
|
||||
func (e *ClickHouseFilterExtractor) extractCTEQuery(cte *clickhouse.CTEStmt) *clickhouse.SelectQuery {
|
||||
if cte == nil || cte.Alias == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The Alias field should contain a SelectQuery
|
||||
if selectQuery, ok := cte.Alias.(*clickhouse.SelectQuery); ok {
|
||||
return selectQuery
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCTEMapFromExpr recursively extracts CTEs from various expression types
|
||||
func (e *ClickHouseFilterExtractor) buildCTEMapFromExpr(expr clickhouse.Expr, cteMap map[string]*clickhouse.SelectQuery) {
|
||||
if expr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Walk through all nodes to find SelectQuery nodes that might contain CTEs
|
||||
clickhouse.Walk(expr, func(node clickhouse.Expr) bool {
|
||||
switch n := node.(type) {
|
||||
case *clickhouse.SelectQuery:
|
||||
// Don't process the same query we started with to avoid infinite recursion
|
||||
if n != expr {
|
||||
e.buildCTEMap(n, cteMap)
|
||||
}
|
||||
case *clickhouse.TableExpr:
|
||||
if n.Expr != nil {
|
||||
e.buildCTEMapFromExpr(n.Expr, cteMap)
|
||||
}
|
||||
case *clickhouse.JoinTableExpr:
|
||||
if n.Table != nil {
|
||||
e.buildCTEMapFromExpr(n.Table, cteMap)
|
||||
}
|
||||
}
|
||||
return true // Continue traversal
|
||||
})
|
||||
}
|
||||
316
pkg/parser/queryfilterextractor/clickhouse_originparser.go
Normal file
316
pkg/parser/queryfilterextractor/clickhouse_originparser.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package queryfilterextractor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/AfterShip/clickhouse-sql-parser/parser"
|
||||
)
|
||||
|
||||
// excludedFunctions contains functions that should cause ExtractOriginField to return empty string.
|
||||
// Map key is the function name in lowercase, value is the original function name.
|
||||
var excludedFunctions = map[string]string{
|
||||
// Time functions
|
||||
"now": "now",
|
||||
"today": "today",
|
||||
"yesterday": "yesterday",
|
||||
"todatetime": "toDateTime",
|
||||
"todatetime64": "toDateTime64",
|
||||
"todate": "toDate",
|
||||
"todate32": "toDate32",
|
||||
"tostartofinterval": "toStartOfInterval",
|
||||
"tostartofday": "toStartOfDay",
|
||||
"tostartofweek": "toStartOfWeek",
|
||||
"tostartofmonth": "toStartOfMonth",
|
||||
"tostartofquarter": "toStartOfQuarter",
|
||||
"tostartofyear": "toStartOfYear",
|
||||
"tostartofhour": "toStartOfHour",
|
||||
"tostartofminute": "toStartOfMinute",
|
||||
"tostartofsecond": "toStartOfSecond",
|
||||
"tostartoffiveminutes": "toStartOfFiveMinutes",
|
||||
"tostartoftenminutes": "toStartOfTenMinutes",
|
||||
"tostartoffifteenminutes": "toStartOfFifteenMinutes",
|
||||
"tointervalsecond": "toIntervalSecond",
|
||||
"tointervalminute": "toIntervalMinute",
|
||||
"tointervalhour": "toIntervalHour",
|
||||
"tointervalday": "toIntervalDay",
|
||||
"tointervalweek": "toIntervalWeek",
|
||||
"tointervalmonth": "toIntervalMonth",
|
||||
"tointervalquarter": "toIntervalQuarter",
|
||||
"tointervalyear": "toIntervalYear",
|
||||
"parsedatetime": "parseDateTime",
|
||||
"parsedatetimebesteffort": "parseDateTimeBestEffort",
|
||||
|
||||
// Aggregate functions
|
||||
"count": "count",
|
||||
"sum": "sum",
|
||||
"avg": "avg",
|
||||
"min": "min",
|
||||
"max": "max",
|
||||
"any": "any",
|
||||
"stddevpop": "stddevPop",
|
||||
"stddevsamp": "stddevSamp",
|
||||
"varpop": "varPop",
|
||||
"varsamp": "varSamp",
|
||||
"grouparray": "groupArray",
|
||||
"groupuniqarray": "groupUniqArray",
|
||||
"quantile": "quantile",
|
||||
"quantiles": "quantiles",
|
||||
"quantileexact": "quantileExact",
|
||||
"quantiletiming": "quantileTiming",
|
||||
"median": "median",
|
||||
"uniq": "uniq",
|
||||
"uniqexact": "uniqExact",
|
||||
"uniqcombined": "uniqCombined",
|
||||
"uniqhll12": "uniqHLL12",
|
||||
"topk": "topK",
|
||||
"first": "first",
|
||||
"last": "last",
|
||||
}
|
||||
|
||||
// jsonExtractFunctions contains functions that extract from JSON columns.
|
||||
// Map key is the function name in lowercase, value is the original function name.
|
||||
var jsonExtractFunctions = map[string]string{
|
||||
"jsonextractstring": "JSONExtractString",
|
||||
"jsonextractint": "JSONExtractInt",
|
||||
"jsonextractuint": "JSONExtractUInt",
|
||||
"jsonextractfloat": "JSONExtractFloat",
|
||||
"jsonextractbool": "JSONExtractBool",
|
||||
"jsonextract": "JSONExtract",
|
||||
"jsonextractraw": "JSONExtractRaw",
|
||||
"jsonextractarrayraw": "JSONExtractArrayRaw",
|
||||
"jsonextractkeysandvalues": "JSONExtractKeysAndValues",
|
||||
}
|
||||
|
||||
// isFunctionPresentInStore checks if a function name exists in the function store map
|
||||
func isFunctionPresentInStore(funcName string, funcStore map[string]string) bool {
|
||||
_, exists := funcStore[strings.ToLower(funcName)]
|
||||
return exists
|
||||
}
|
||||
|
||||
// isReservedSelectKeyword checks if a keyword is a reserved keyword for the SELECT statement
|
||||
// We're only including those which can appear in the SELECT statement without being quoted
|
||||
func isReservedSelectKeyword(keyword string) bool {
|
||||
return strings.ToUpper(keyword) == parser.KeywordSelect || strings.ToUpper(keyword) == parser.KeywordFrom
|
||||
}
|
||||
|
||||
// extractCHOriginField extracts the origin field (column name) from a query string
|
||||
// or fields getting extracted in case of JSON extraction functions.
|
||||
func extractCHOriginFieldFromQuery(query string) (string, error) {
|
||||
// Parse the query string
|
||||
p := parser.NewParser(query)
|
||||
stmts, err := p.ParseStmts()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(stmts) == 0 {
|
||||
return "", fmt.Errorf("no statements found in query")
|
||||
}
|
||||
|
||||
// Get the first statement which should be a SELECT
|
||||
selectStmt, ok := stmts[0].(*parser.SelectQuery)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("first statement is not a SELECT query")
|
||||
}
|
||||
|
||||
// If query has multiple select items, return blank string as we don't expect multiple select items
|
||||
if len(selectStmt.SelectItems) > 1 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if len(selectStmt.SelectItems) == 0 {
|
||||
return "", fmt.Errorf("SELECT query has no select items")
|
||||
}
|
||||
|
||||
// Extract origin field from the first (and only) select item's expression
|
||||
return extractOriginFieldFromExpr(selectStmt.SelectItems[0].Expr)
|
||||
}
|
||||
|
||||
// extractOriginFieldFromExpr extracts the origin field (column name) from an expression.
|
||||
// This is the internal helper function that contains the original logic.
|
||||
func extractOriginFieldFromExpr(expr parser.Expr) (string, error) {
|
||||
if expr == nil {
|
||||
return "", fmt.Errorf("expression is nil")
|
||||
}
|
||||
|
||||
// Check if expression contains excluded functions or IF/CASE
|
||||
hasExcludedExpressions := false
|
||||
hasReservedKeyword := false
|
||||
|
||||
parser.Walk(expr, func(node parser.Expr) bool {
|
||||
// exclude reserved keywords because the parser will treat them as valid SQL
|
||||
// example: SELECT FROM table here the "FROM" is a reserved keyword,
|
||||
// but the parser will treat it as valid SQL
|
||||
if ident, ok := node.(*parser.Ident); ok {
|
||||
if ident.QuoteType == parser.Unquoted && isReservedSelectKeyword(ident.Name) {
|
||||
hasReservedKeyword = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
if funcExpr, ok := node.(*parser.FunctionExpr); ok {
|
||||
if isFunctionPresentInStore(funcExpr.Name.Name, excludedFunctions) {
|
||||
hasExcludedExpressions = true
|
||||
return false
|
||||
}
|
||||
// Check for nested JSON extraction functions
|
||||
if isFunctionPresentInStore(funcExpr.Name.Name, jsonExtractFunctions) {
|
||||
// Check if any argument contains another JSON extraction function
|
||||
if funcExpr.Params != nil && funcExpr.Params.Items != nil {
|
||||
for _, arg := range funcExpr.Params.Items.Items {
|
||||
if containsJSONExtractFunction(arg) {
|
||||
hasExcludedExpressions = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, ok := node.(*parser.CaseExpr); ok {
|
||||
hasExcludedExpressions = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// If the expression contains reserved keywords, return error
|
||||
if hasReservedKeyword {
|
||||
return "", fmt.Errorf("reserved keyword found in query")
|
||||
}
|
||||
|
||||
// If the expression contains excluded expressions, return empty string
|
||||
if hasExcludedExpressions {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Extract all column names from the expression
|
||||
columns := extractColumns(expr)
|
||||
|
||||
// If we found exactly one unique column, return it
|
||||
if len(columns) == 1 {
|
||||
return columns[0], nil
|
||||
}
|
||||
|
||||
// Multiple columns or no columns - return empty string
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// containsJSONExtractFunction checks if an expression contains a JSON extraction function
|
||||
func containsJSONExtractFunction(expr parser.Expr) bool {
|
||||
if expr == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
found := false
|
||||
parser.Walk(expr, func(node parser.Expr) bool {
|
||||
if funcExpr, ok := node.(*parser.FunctionExpr); ok {
|
||||
if isFunctionPresentInStore(funcExpr.Name.Name, jsonExtractFunctions) {
|
||||
found = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
// extractColumns recursively extracts all unique column names from an expression.
|
||||
// Note: String literals are also considered as origin fields and will be included in the result.
|
||||
func extractColumns(expr parser.Expr) []string {
|
||||
if expr == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
columnMap := make(map[string]bool)
|
||||
extractColumnsHelper(expr, columnMap)
|
||||
|
||||
// Convert map to slice
|
||||
columns := make([]string, 0, len(columnMap))
|
||||
for col := range columnMap {
|
||||
columns = append(columns, col)
|
||||
}
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
// extractColumnsHelper is a recursive helper that finds all column references.
|
||||
// Note: String literals are also considered as origin fields and will be added to the columnMap.
|
||||
func extractColumnsHelper(expr parser.Expr, columnMap map[string]bool) {
|
||||
if expr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch n := expr.(type) {
|
||||
case *parser.Ident:
|
||||
// Add identifiers as column references
|
||||
columnMap[n.Name] = true
|
||||
|
||||
case *parser.FunctionExpr:
|
||||
// Special handling for JSON extraction functions
|
||||
// In case of nested JSON extraction, we return blank values (handled at top level)
|
||||
if isFunctionPresentInStore(n.Name.Name, jsonExtractFunctions) {
|
||||
// For JSON functions, extract from the second argument (the JSON path/key being extracted)
|
||||
// The first argument is the column name, the second is the exact data being extracted
|
||||
// The extracted data (second argument) is treated as the origin field
|
||||
if n.Params != nil && n.Params.Items != nil && len(n.Params.Items.Items) >= 2 {
|
||||
secondArg := n.Params.Items.Items[1]
|
||||
// If the second argument is a string literal, use its value as the origin field
|
||||
// String literals are considered as origin fields
|
||||
if strLit, ok := secondArg.(*parser.StringLiteral); ok {
|
||||
columnMap[strLit.Literal] = true
|
||||
} else {
|
||||
// Otherwise, try to extract columns from it
|
||||
extractColumnsHelper(secondArg, columnMap)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For regular functions, recursively process all arguments
|
||||
// Don't mark the function name itself as a column
|
||||
if n.Params != nil && n.Params.Items != nil {
|
||||
for _, item := range n.Params.Items.Items {
|
||||
extractColumnsHelper(item, columnMap)
|
||||
}
|
||||
}
|
||||
|
||||
case *parser.BinaryOperation:
|
||||
extractColumnsHelper(n.LeftExpr, columnMap)
|
||||
extractColumnsHelper(n.RightExpr, columnMap)
|
||||
|
||||
case *parser.ColumnExpr:
|
||||
extractColumnsHelper(n.Expr, columnMap)
|
||||
|
||||
case *parser.CastExpr:
|
||||
extractColumnsHelper(n.Expr, columnMap)
|
||||
|
||||
case *parser.ParamExprList:
|
||||
if n.Items != nil {
|
||||
extractColumnsHelper(n.Items, columnMap)
|
||||
}
|
||||
|
||||
case *parser.ColumnExprList:
|
||||
for _, item := range n.Items {
|
||||
extractColumnsHelper(item, columnMap)
|
||||
}
|
||||
|
||||
case *parser.StringLiteral:
|
||||
// String literals are considered as origin fields
|
||||
columnMap[n.Literal] = true
|
||||
return
|
||||
|
||||
// Support for columns like table.column_name
|
||||
case *parser.Path:
|
||||
if len(n.Fields) > 0 {
|
||||
extractColumnsHelper(n.Fields[len(n.Fields)-1], columnMap)
|
||||
}
|
||||
return
|
||||
|
||||
// Add more cases as needed for other expression types
|
||||
|
||||
default:
|
||||
// For unknown types, return empty (don't extract columns)
|
||||
return
|
||||
}
|
||||
}
|
||||
252
pkg/parser/queryfilterextractor/clickhouse_originparser_test.go
Normal file
252
pkg/parser/queryfilterextractor/clickhouse_originparser_test.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package queryfilterextractor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractOriginField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expected string
|
||||
expectError bool
|
||||
}{
|
||||
// JSON extraction functions - should return the second argument (JSON path/key) as origin field
|
||||
{
|
||||
name: "JSONExtractString simple",
|
||||
query: `SELECT JSONExtractString(labels, 'service.name')`,
|
||||
expected: "service.name",
|
||||
},
|
||||
{
|
||||
name: "JSONExtractInt",
|
||||
query: `SELECT JSONExtractInt(labels, 'status.code')`,
|
||||
expected: "status.code",
|
||||
},
|
||||
{
|
||||
name: "JSONExtractFloat",
|
||||
query: `SELECT JSONExtractFloat(labels, 'cpu.usage')`,
|
||||
expected: "cpu.usage",
|
||||
},
|
||||
{
|
||||
name: "JSONExtractBool",
|
||||
query: `SELECT JSONExtractBool(labels, 'feature.enabled')`,
|
||||
expected: "feature.enabled",
|
||||
},
|
||||
{
|
||||
name: "JSONExtractString with function wrapper",
|
||||
query: `SELECT lower(JSONExtractString(labels, 'user.email'))`,
|
||||
expected: "user.email",
|
||||
},
|
||||
{
|
||||
name: "Nested JSON extraction",
|
||||
query: `SELECT JSONExtractInt(JSONExtractRaw(labels, 'meta'), 'status.code')`,
|
||||
expected: "", // Nested JSON extraction should return blank
|
||||
},
|
||||
|
||||
// Nested functions - should return the deepest column
|
||||
{
|
||||
name: "Nested time functions with column",
|
||||
query: `SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60))`,
|
||||
expected: "", // Contains toStartOfInterval and toDateTime which are excluded
|
||||
},
|
||||
{
|
||||
name: "Division with column",
|
||||
query: `SELECT unix_milli / 1000`,
|
||||
expected: "unix_milli",
|
||||
},
|
||||
{
|
||||
name: "Function with single column",
|
||||
query: `SELECT lower(unix_milli)`,
|
||||
expected: "unix_milli",
|
||||
},
|
||||
{
|
||||
name: "CAST with single column",
|
||||
query: `SELECT CAST(unix_milli AS String)`,
|
||||
expected: "unix_milli",
|
||||
},
|
||||
{
|
||||
name: "intDiv with single column",
|
||||
query: `SELECT intDiv(unix_milli, 1000)`,
|
||||
expected: "unix_milli",
|
||||
},
|
||||
|
||||
// Multiple columns - should return blank
|
||||
{
|
||||
name: "Multiple columns in coalesce",
|
||||
query: `SELECT (coalesce(cpu_usage, 0) + coalesce(mem_usage, 0)) / 2`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Multiple columns in arithmetic",
|
||||
query: `SELECT cpu_usage + mem_usage`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Multiple columns in function",
|
||||
query: `SELECT concat(first_name, last_name)`,
|
||||
expected: "",
|
||||
},
|
||||
|
||||
// IF/CASE conditions - should return blank
|
||||
{
|
||||
name: "IF with single column in condition",
|
||||
query: `SELECT IF(error_count > 0, service, 'healthy')`,
|
||||
expected: "", // Multiple columns: error_count and service
|
||||
},
|
||||
{
|
||||
name: "IF with JSON and multiple columns",
|
||||
query: `SELECT if(JSONExtractInt(metadata, 'retry.count') > 3, toLower(JSONExtractString(metadata, 'user.id')), hostname)`,
|
||||
expected: "", // Multiple columns: metadata and hostname
|
||||
},
|
||||
{
|
||||
name: "String literal should return string",
|
||||
query: `SELECT 'constant'`,
|
||||
expected: "constant",
|
||||
},
|
||||
|
||||
// No columns - should return blank
|
||||
{
|
||||
name: "Number literal",
|
||||
query: `SELECT 42`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Multiple literals",
|
||||
query: `SELECT 'constant', 42`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Multiple string literals",
|
||||
query: `SELECT 'constant', '42'`,
|
||||
expected: "",
|
||||
},
|
||||
|
||||
// Excluded functions - should return blank
|
||||
{
|
||||
name: "now() function",
|
||||
query: `SELECT now()`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "today() function",
|
||||
query: `SELECT today()`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "count aggregate",
|
||||
query: `SELECT count(user_id)`,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "sum aggregate",
|
||||
query: `SELECT sum(amount)`,
|
||||
expected: "",
|
||||
},
|
||||
|
||||
// Single column simple cases
|
||||
{
|
||||
name: "Simple column reference",
|
||||
query: `SELECT user_id`,
|
||||
expected: "user_id",
|
||||
},
|
||||
{
|
||||
name: "Column with alias",
|
||||
query: `SELECT user_id AS id`,
|
||||
expected: "user_id",
|
||||
},
|
||||
{
|
||||
name: "Column in arithmetic with literals (multiplication)",
|
||||
query: `SELECT unix_milli * 1000`,
|
||||
expected: "unix_milli",
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "Nested functions with single column deep",
|
||||
query: `SELECT upper(lower(trim(column_name)))`,
|
||||
expected: "column_name",
|
||||
},
|
||||
// Qualified column names (Path)
|
||||
{
|
||||
name: "Column with table prefix",
|
||||
query: `SELECT table.column_name`,
|
||||
expected: "column_name", // IndexOperation: extracts column name from Index field
|
||||
},
|
||||
{
|
||||
name: "Qualified column in function",
|
||||
query: `SELECT lower(table.column_name)`,
|
||||
expected: "column_name",
|
||||
},
|
||||
{
|
||||
name: "Qualified column in arithmetic",
|
||||
query: `SELECT table.column_name * 100`,
|
||||
expected: "column_name",
|
||||
},
|
||||
{
|
||||
name: "Nested qualified column (schema.table.column)",
|
||||
query: `SELECT schema.table.column_name`,
|
||||
expected: "column_name", // Should extract the final column name
|
||||
},
|
||||
{
|
||||
name: "Multiple qualified columns",
|
||||
query: `SELECT table1.column1 + table2.column2`,
|
||||
expected: "", // Multiple columns: column1 and column2
|
||||
},
|
||||
{
|
||||
name: "Qualified column with CAST",
|
||||
query: `SELECT CAST(table.column_name AS String)`,
|
||||
expected: "column_name",
|
||||
},
|
||||
{
|
||||
name: "Multiple select items - return blank",
|
||||
query: `SELECT JSONExtractString(labels, 'service.name'), unix_milli / 1000, cpu_usage + mem_usage`,
|
||||
expected: "",
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
name: "Empty query",
|
||||
query: ``,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid SQL syntax",
|
||||
query: `SELECT FROM table`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Non-SELECT statement (CREATE TABLE)",
|
||||
query: `CREATE TABLE test (id Int32)`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Non-SELECT statement (INSERT)",
|
||||
query: `INSERT INTO test VALUES (1)`,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Malformed query",
|
||||
query: `SELECT * FROM`,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := extractCHOriginFieldFromQuery(tt.query)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Errorf("ExtractOriginField() expected error but got nil, result = %q", result)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("ExtractOriginField() unexpected error: %v", err)
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("ExtractOriginField() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
1279
pkg/parser/queryfilterextractor/clickhouse_test.go
Normal file
1279
pkg/parser/queryfilterextractor/clickhouse_test.go
Normal file
File diff suppressed because it is too large
Load Diff
129
pkg/parser/queryfilterextractor/promql.go
Normal file
129
pkg/parser/queryfilterextractor/promql.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package queryfilterextractor
|
||||
|
||||
import (
|
||||
"github.com/prometheus/prometheus/model/labels"
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
)
|
||||
|
||||
// PromQLFilterExtractor extracts metric names and grouping keys from PromQL queries
|
||||
type PromQLFilterExtractor struct{}
|
||||
|
||||
// NewPromQLFilterExtractor creates a new PromQL filter extractor
|
||||
func NewPromQLFilterExtractor() *PromQLFilterExtractor {
|
||||
return &PromQLFilterExtractor{}
|
||||
}
|
||||
|
||||
// Extract parses a PromQL query and extracts metric names and grouping keys
|
||||
func (e *PromQLFilterExtractor) Extract(query string) (*FilterResult, error) {
|
||||
expr, err := parser.ParseExpr(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &FilterResult{
|
||||
MetricNames: []string{},
|
||||
GroupByColumns: []ColumnInfo{},
|
||||
}
|
||||
|
||||
// Use a visitor to traverse the AST
|
||||
visitor := &promQLVisitor{
|
||||
metricNames: make(map[string]bool),
|
||||
groupBy: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Walk the AST
|
||||
if err := parser.Walk(visitor, expr, nil); err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Convert sets to slices
|
||||
for metric := range visitor.metricNames {
|
||||
result.MetricNames = append(result.MetricNames, metric)
|
||||
}
|
||||
for groupKey := range visitor.groupBy {
|
||||
result.GroupByColumns = append(result.GroupByColumns, ColumnInfo{Name: groupKey})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// promQLVisitor implements the parser.Visitor interface
|
||||
type promQLVisitor struct {
|
||||
metricNames map[string]bool
|
||||
groupBy map[string]bool
|
||||
// Track if we've already captured grouping from an outermost aggregation
|
||||
hasOutermostGrouping bool
|
||||
}
|
||||
|
||||
func (v *promQLVisitor) Visit(node parser.Node, path []parser.Node) (parser.Visitor, error) {
|
||||
if node == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch n := node.(type) {
|
||||
case *parser.VectorSelector:
|
||||
v.visitVectorSelector(n)
|
||||
case *parser.AggregateExpr:
|
||||
v.visitAggregateExpr(n, path)
|
||||
case *parser.Call:
|
||||
// Function calls may contain VectorSelectors, continue traversal
|
||||
return v, nil
|
||||
case *parser.BinaryExpr:
|
||||
// Binary expressions may contain VectorSelectors on both sides
|
||||
return v, nil
|
||||
case *parser.SubqueryExpr:
|
||||
// Subqueries may contain VectorSelectors
|
||||
return v, nil
|
||||
case *parser.ParenExpr:
|
||||
// Parentheses don't change semantics, continue traversal
|
||||
return v, nil
|
||||
case *parser.MatrixSelector:
|
||||
// Matrix selectors wrap VectorSelectors
|
||||
return v, nil
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (v *promQLVisitor) visitVectorSelector(vs *parser.VectorSelector) {
|
||||
// Check if metric name is specified directly
|
||||
if vs.Name != "" {
|
||||
v.metricNames[vs.Name] = true
|
||||
}
|
||||
|
||||
// Check for __name__ label matcher
|
||||
for _, matcher := range vs.LabelMatchers {
|
||||
if matcher.Name == labels.MetricName {
|
||||
switch matcher.Type {
|
||||
case labels.MatchEqual:
|
||||
v.metricNames[matcher.Value] = true
|
||||
// Skip for negative filters - negative filters don't extract metric names
|
||||
// case labels.MatchNotEqual, labels.MatchRegexp, labels.MatchNotRegexp:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v *promQLVisitor) visitAggregateExpr(ae *parser.AggregateExpr, path []parser.Node) {
|
||||
// Count how many AggregateExpr nodes are in the path (excluding current node)
|
||||
// This tells us the nesting level
|
||||
nestingLevel := 0
|
||||
for _, p := range path {
|
||||
if _, ok := p.(*parser.AggregateExpr); ok {
|
||||
nestingLevel++
|
||||
}
|
||||
}
|
||||
|
||||
// Only capture grouping from the outermost aggregation (nesting level 0)
|
||||
if nestingLevel == 0 && !v.hasOutermostGrouping {
|
||||
// If Without is true, we skip grouping per spec
|
||||
if !ae.Without && len(ae.Grouping) > 0 {
|
||||
v.hasOutermostGrouping = true
|
||||
for _, label := range ae.Grouping {
|
||||
v.groupBy[label] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continue traversal to find metrics in the expression
|
||||
}
|
||||
175
pkg/parser/queryfilterextractor/promql_test.go
Normal file
175
pkg/parser/queryfilterextractor/promql_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package queryfilterextractor
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPromQLFilterExtractor_Extract(t *testing.T) {
|
||||
extractor := NewPromQLFilterExtractor()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
wantMetrics []string
|
||||
wantGroupByColumns []ColumnInfo
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "P1 - Simple vector selector",
|
||||
query: `http_requests_total{job="api"}`,
|
||||
wantMetrics: []string{"http_requests_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P2 - Function call",
|
||||
query: `rate(cpu_usage_seconds_total[5m])`,
|
||||
wantMetrics: []string{"cpu_usage_seconds_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P3 - Aggregation with by()",
|
||||
query: `sum by (pod,region) (rate(http_requests_total[5m]))`,
|
||||
wantMetrics: []string{"http_requests_total"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "pod"}, {Name: "region"}},
|
||||
},
|
||||
{
|
||||
name: "P4 - Aggregation with without()",
|
||||
query: `sum without (instance) (rate(cpu_usage_total[1m]))`,
|
||||
wantMetrics: []string{"cpu_usage_total"},
|
||||
wantGroupByColumns: []ColumnInfo{}, // without() means no grouping keys per spec
|
||||
},
|
||||
{
|
||||
name: "P5 - Invalid: metric name set twice",
|
||||
query: `sum(rate(http_requests_total{__name__!="http_requests_error_total"}[5m]))`,
|
||||
wantMetrics: []string{},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "P6 - Regex negative label",
|
||||
query: `sum(rate(http_requests_total{status!~"5.."}[5m]))`,
|
||||
wantMetrics: []string{"http_requests_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P7 - Nested aggregations",
|
||||
query: `sum by (region) (max by (pod, region) (cpu_usage_total{env="prod"}))`,
|
||||
wantMetrics: []string{"cpu_usage_total"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "region"}}, // Only outermost grouping
|
||||
},
|
||||
{
|
||||
name: "P7a - Nested aggregation: inner grouping ignored",
|
||||
query: `sum(max by (pod) (cpu_usage_total{env="prod"}))`,
|
||||
wantMetrics: []string{"cpu_usage_total"},
|
||||
wantGroupByColumns: []ColumnInfo{}, // Inner grouping is ignored when outer has no grouping (nestingLevel != 0 case)
|
||||
},
|
||||
{
|
||||
name: "P8 - Arithmetic expression",
|
||||
query: `(http_requests_total{job="api"} + http_errors_total{job="api"})`,
|
||||
wantMetrics: []string{"http_requests_total", "http_errors_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P9 - Mix of positive metric & exclusion label",
|
||||
query: `sum by (region)(rate(foo{job!="db"}[5m]))`,
|
||||
wantMetrics: []string{"foo"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "region"}},
|
||||
},
|
||||
{
|
||||
name: "P10 - Function + aggregation",
|
||||
query: `histogram_quantile(0.9, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))`,
|
||||
wantMetrics: []string{"http_request_duration_seconds_bucket"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "le"}},
|
||||
},
|
||||
{
|
||||
name: "P11 - Subquery",
|
||||
query: `sum_over_time(cpu_usage_total[1h:5m])`,
|
||||
wantMetrics: []string{"cpu_usage_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P12 - Nested aggregation inside subquery",
|
||||
query: `max_over_time(sum(rate(cpu_usage_total[5m]))[1h:5m])`,
|
||||
wantMetrics: []string{"cpu_usage_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P13 - Subquery with multiple metrics",
|
||||
query: `avg_over_time((foo + bar)[10m:1m])`,
|
||||
wantMetrics: []string{"foo", "bar"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P14 - Simple meta-metric",
|
||||
query: `sum by (pod) (up)`,
|
||||
wantMetrics: []string{"up"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "pod"}},
|
||||
},
|
||||
{
|
||||
name: "P15 - Binary operator unless",
|
||||
query: `sum(rate(http_requests_total[5m])) unless avg(rate(http_errors_total[5m]))`,
|
||||
wantMetrics: []string{"http_requests_total", "http_errors_total"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P16 - Vector matching",
|
||||
query: `sum(rate(foo[5m])) / ignoring(instance) group_left(job) sum(rate(bar[5m]))`,
|
||||
wantMetrics: []string{"foo", "bar"},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
{
|
||||
name: "P17 - Offset modifier with aggregation",
|
||||
query: `sum by (env)(rate(cpu_usage_seconds_total{job="api"}[5m] offset 1h))`,
|
||||
wantMetrics: []string{"cpu_usage_seconds_total"},
|
||||
wantGroupByColumns: []ColumnInfo{{Name: "env"}},
|
||||
},
|
||||
{
|
||||
name: "P18 - Invalid syntax",
|
||||
query: `sum by ((foo)(bar))(http_requests_total)`,
|
||||
wantMetrics: []string{},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "P19 - Literal expression",
|
||||
query: `2 + 3`,
|
||||
wantMetrics: []string{},
|
||||
wantGroupByColumns: []ColumnInfo{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := extractor.Extract(tt.query)
|
||||
|
||||
// Check error expectation
|
||||
if tt.wantError {
|
||||
if err == nil {
|
||||
t.Errorf("Extract() expected error but got none, query: %s", tt.query)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("Extract() unexpected error = %v, query: %s", err, tt.query)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort for comparison
|
||||
gotMetrics := sortStrings(result.MetricNames)
|
||||
wantMetrics := sortStrings(tt.wantMetrics)
|
||||
|
||||
if !reflect.DeepEqual(gotMetrics, wantMetrics) {
|
||||
t.Errorf("Extract() MetricNames = %v, want %v", gotMetrics, wantMetrics)
|
||||
}
|
||||
|
||||
// Test GroupByColumns - need to normalize for comparison (order may vary)
|
||||
gotGroupByColumns := sortColumnInfo(result.GroupByColumns)
|
||||
wantGroupByColumns := sortColumnInfo(tt.wantGroupByColumns)
|
||||
|
||||
if !reflect.DeepEqual(gotGroupByColumns, wantGroupByColumns) {
|
||||
t.Errorf("Extract() GroupByColumns = %v, want %v", gotGroupByColumns, wantGroupByColumns)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
42
pkg/parser/queryfilterextractor/queryfilterextractor.go
Normal file
42
pkg/parser/queryfilterextractor/queryfilterextractor.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Package queryfilterextractor provides utilities for extracting metric names
|
||||
// and grouping keys.
|
||||
//
|
||||
// This is useful for metrics discovery, and query analysis.
|
||||
package queryfilterextractor
|
||||
|
||||
import "fmt"
|
||||
|
||||
const (
|
||||
ExtractorCH = "qfe_ch"
|
||||
ExtractorPromQL = "qfe_promql"
|
||||
)
|
||||
|
||||
// ColumnInfo represents a column in the query
|
||||
type ColumnInfo struct {
|
||||
Name string
|
||||
Alias string
|
||||
OriginExpr string
|
||||
OriginField string
|
||||
}
|
||||
|
||||
type FilterResult struct {
|
||||
// MetricNames are the metrics that are being filtered on
|
||||
MetricNames []string
|
||||
// GroupByColumns are the columns that are being grouped by
|
||||
GroupByColumns []ColumnInfo
|
||||
}
|
||||
|
||||
type FilterExtractor interface {
|
||||
Extract(query string) (*FilterResult, error)
|
||||
}
|
||||
|
||||
func NewExtractor(extractorType string) (FilterExtractor, error) {
|
||||
switch extractorType {
|
||||
case ExtractorCH:
|
||||
return NewClickHouseFilterExtractor(), nil
|
||||
case ExtractorPromQL:
|
||||
return NewPromQLFilterExtractor(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid extractor type: %s", extractorType)
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
if aggFunc.FuncCombinator {
|
||||
// Map the predicate (last argument)
|
||||
origPred := args[len(args)-1].String()
|
||||
whereClause, err := PrepareWhereClause(
|
||||
whereClause, err := PrepareWhereClause(
|
||||
origPred,
|
||||
FilterExprVisitorOpts{
|
||||
Logger: v.logger,
|
||||
@@ -200,7 +200,7 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
FullTextColumn: v.fullTextColumn,
|
||||
JsonBodyPrefix: v.jsonBodyPrefix,
|
||||
JsonKeyToKey: v.jsonKeyToKey,
|
||||
},
|
||||
}, 0, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -45,7 +45,7 @@ func CollisionHandledFinalExpr(
|
||||
|
||||
addCondition := func(key *telemetrytypes.TelemetryFieldKey) error {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
condition, err := cb.ConditionFor(ctx, key, qbtypes.FilterOperatorExists, nil, sb)
|
||||
condition, err := cb.ConditionFor(ctx, key, qbtypes.FilterOperatorExists, nil, sb, 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ func (b *defaultConditionBuilder) ConditionFor(
|
||||
op qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
_ uint64,
|
||||
_ uint64,
|
||||
) (string, error) {
|
||||
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
|
||||
@@ -206,7 +206,7 @@ func TestConditionBuilder(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cond, err := conditionBuilder.ConditionFor(context.Background(), tc.key, tc.op, tc.value, sb)
|
||||
cond, err := conditionBuilder.ConditionFor(context.Background(), tc.key, tc.op, tc.value, sb, 0, 0)
|
||||
sb.Where(cond)
|
||||
|
||||
if tc.expectedErr != nil {
|
||||
|
||||
@@ -169,7 +169,7 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
|
||||
// there is no need for "key" not found error for resource filtering
|
||||
IgnoreNotFoundKeys: true,
|
||||
Variables: variables,
|
||||
})
|
||||
}, start, end)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -42,6 +42,8 @@ type filterExpressionVisitor struct {
|
||||
variables map[string]qbtypes.VariableItem
|
||||
|
||||
keysWithWarnings map[string]bool
|
||||
startNs uint64
|
||||
endNs uint64
|
||||
}
|
||||
|
||||
type FilterExprVisitorOpts struct {
|
||||
@@ -58,6 +60,8 @@ type FilterExprVisitorOpts struct {
|
||||
SkipFunctionCalls bool
|
||||
IgnoreNotFoundKeys bool
|
||||
Variables map[string]qbtypes.VariableItem
|
||||
StartNs uint64
|
||||
EndNs uint64
|
||||
}
|
||||
|
||||
// newFilterExpressionVisitor creates a new filterExpressionVisitor
|
||||
@@ -77,6 +81,8 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
|
||||
ignoreNotFoundKeys: opts.IgnoreNotFoundKeys,
|
||||
variables: opts.Variables,
|
||||
keysWithWarnings: make(map[string]bool),
|
||||
startNs: opts.StartNs,
|
||||
endNs: opts.EndNs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +93,7 @@ type PreparedWhereClause struct {
|
||||
}
|
||||
|
||||
// PrepareWhereClause generates a ClickHouse compatible WHERE clause from the filter query
|
||||
func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*PreparedWhereClause, error) {
|
||||
func PrepareWhereClause(query string, opts FilterExprVisitorOpts, startNs uint64, endNs uint64) (*PreparedWhereClause, error) {
|
||||
// Setup the ANTLR parsing pipeline
|
||||
input := antlr.NewInputStream(query)
|
||||
lexer := grammar.NewFilterQueryLexer(input)
|
||||
@@ -120,6 +126,8 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*PreparedWher
|
||||
}
|
||||
tokens.Reset()
|
||||
|
||||
opts.StartNs = startNs
|
||||
opts.EndNs = endNs
|
||||
visitor := newFilterExpressionVisitor(opts)
|
||||
|
||||
// Handle syntax errors
|
||||
@@ -311,7 +319,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
|
||||
// create a full text search condition on the body field
|
||||
|
||||
keyText := keyCtx.GetText()
|
||||
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(keyText), v.builder)
|
||||
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(keyText), v.builder, v.startNs, v.endNs)
|
||||
if err != nil {
|
||||
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
|
||||
return ""
|
||||
@@ -331,7 +339,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
|
||||
v.errors = append(v.errors, fmt.Sprintf("unsupported value type: %s", valCtx.GetText()))
|
||||
return ""
|
||||
}
|
||||
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder)
|
||||
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder, v.startNs, v.endNs)
|
||||
if err != nil {
|
||||
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
|
||||
return ""
|
||||
@@ -375,7 +383,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
|
||||
}
|
||||
var conds []string
|
||||
for _, key := range keys {
|
||||
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, nil, v.builder)
|
||||
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, nil, v.builder, v.startNs, v.endNs)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -443,7 +451,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
|
||||
}
|
||||
var conds []string
|
||||
for _, key := range keys {
|
||||
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, values, v.builder)
|
||||
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, values, v.builder, v.startNs, v.endNs)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -475,7 +483,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
|
||||
|
||||
var conds []string
|
||||
for _, key := range keys {
|
||||
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, []any{value1, value2}, v.builder)
|
||||
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, []any{value1, value2}, v.builder, v.startNs, v.endNs)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -556,7 +564,7 @@ func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext
|
||||
|
||||
var conds []string
|
||||
for _, key := range keys {
|
||||
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, value, v.builder)
|
||||
condition, err := v.conditionBuilder.ConditionFor(context.Background(), key, op, value, v.builder, v.startNs, v.endNs)
|
||||
if err != nil {
|
||||
v.errors = append(v.errors, fmt.Sprintf("failed to build condition: %s", err.Error()))
|
||||
return ""
|
||||
@@ -635,7 +643,7 @@ func (v *filterExpressionVisitor) VisitFullText(ctx *grammar.FullTextContext) an
|
||||
v.errors = append(v.errors, "full text search is not supported")
|
||||
return ""
|
||||
}
|
||||
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder)
|
||||
cond, err := v.conditionBuilder.ConditionFor(context.Background(), v.fullTextColumn, qbtypes.FilterOperatorRegexp, FormatFullTextSearch(text), v.builder, v.startNs, v.endNs)
|
||||
if err != nil {
|
||||
v.errors = append(v.errors, fmt.Sprintf("failed to build full text search condition: %s", err.Error()))
|
||||
return ""
|
||||
|
||||
107
pkg/sqlstore/sqlitesqlstore/formatter.go
Normal file
107
pkg/sqlstore/sqlitesqlstore/formatter.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package sqlitesqlstore
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun/schema"
|
||||
)
|
||||
|
||||
type formatter struct {
|
||||
bunf schema.Formatter
|
||||
}
|
||||
|
||||
func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
|
||||
return &formatter{bunf: schema.NewFormatter(dialect)}
|
||||
}
|
||||
|
||||
func (f *formatter) JSONExtractString(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_extract("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, ", "...)
|
||||
sql = schema.Append(f.bunf, sql, path)
|
||||
sql = append(sql, ")"...)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONType(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_type("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, ", "...)
|
||||
sql = schema.Append(f.bunf, sql, path)
|
||||
sql = append(sql, ")"...)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONIsArray(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, f.JSONType(column, path)...)
|
||||
sql = append(sql, " = "...)
|
||||
sql = schema.Append(f.bunf, sql, "array")
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayElements(column, path, alias string) ([]byte, []byte) {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_each("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
if path != "$" && path != "" {
|
||||
sql = append(sql, ", "...)
|
||||
sql = schema.Append(f.bunf, sql, path)
|
||||
}
|
||||
sql = append(sql, ") AS "...)
|
||||
sql = f.bunf.AppendIdent(sql, alias)
|
||||
|
||||
return sql, append([]byte(alias), ".value"...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayOfStrings(column, path, alias string) ([]byte, []byte) {
|
||||
return f.JSONArrayElements(column, path, alias)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONKeys(column, path, alias string) ([]byte, []byte) {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_each("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
if path != "$" && path != "" {
|
||||
sql = append(sql, ", "...)
|
||||
sql = schema.Append(f.bunf, sql, path)
|
||||
}
|
||||
sql = append(sql, ") AS "...)
|
||||
sql = f.bunf.AppendIdent(sql, alias)
|
||||
|
||||
return sql, append([]byte(alias), ".key"...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayAgg(expression string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_group_array("...)
|
||||
sql = append(sql, expression...)
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayLiteral(values ...string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_array("...)
|
||||
for idx, value := range values {
|
||||
if idx > 0 {
|
||||
sql = append(sql, ", "...)
|
||||
}
|
||||
sql = schema.Append(f.bunf, sql, value)
|
||||
}
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) TextToJsonColumn(column string) []byte {
|
||||
return f.bunf.AppendIdent([]byte{}, column)
|
||||
}
|
||||
|
||||
func (f *formatter) LowerExpression(expression string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "lower("...)
|
||||
sql = append(sql, expression...)
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
422
pkg/sqlstore/sqlitesqlstore/formatter_test.go
Normal file
422
pkg/sqlstore/sqlitesqlstore/formatter_test.go
Normal file
@@ -0,0 +1,422 @@
|
||||
package sqlitesqlstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/uptrace/bun/dialect/sqlitedialect"
|
||||
)
|
||||
|
||||
func TestJSONExtractString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple path",
|
||||
column: "data",
|
||||
path: "$.field",
|
||||
expected: `json_extract("data", '$.field')`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.user.name",
|
||||
expected: `json_extract("metadata", '$.user.name')`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "$",
|
||||
expected: `json_extract("json_col", '$')`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "",
|
||||
expected: `json_extract("json_col", '')`,
|
||||
},
|
||||
{
|
||||
name: "array index path",
|
||||
column: "items",
|
||||
path: "$.list[0]",
|
||||
expected: `json_extract("items", '$.list[0]')`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got := string(f.JSONExtractString(tt.column, tt.path))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple path",
|
||||
column: "data",
|
||||
path: "$.field",
|
||||
expected: `json_type("data", '$.field')`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.user.age",
|
||||
expected: `json_type("metadata", '$.user.age')`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "$",
|
||||
expected: `json_type("json_col", '$')`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "",
|
||||
expected: `json_type("json_col", '')`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got := string(f.JSONType(tt.column, tt.path))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONIsArray(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple path",
|
||||
column: "data",
|
||||
path: "$.items",
|
||||
expected: `json_type("data", '$.items') = 'array'`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.user.tags",
|
||||
expected: `json_type("metadata", '$.user.tags') = 'array'`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "$",
|
||||
expected: `json_type("json_col", '$') = 'array'`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "",
|
||||
expected: `json_type("json_col", '') = 'array'`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got := string(f.JSONIsArray(tt.column, tt.path))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayElements(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
alias string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "root path with dollar sign",
|
||||
column: "data",
|
||||
path: "$",
|
||||
alias: "elem",
|
||||
expected: `json_each("data") AS "elem"`,
|
||||
},
|
||||
{
|
||||
name: "root path empty",
|
||||
column: "data",
|
||||
path: "",
|
||||
alias: "elem",
|
||||
expected: `json_each("data") AS "elem"`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.items",
|
||||
alias: "item",
|
||||
expected: `json_each("metadata", '$.items') AS "item"`,
|
||||
},
|
||||
{
|
||||
name: "deeply nested path",
|
||||
column: "json_col",
|
||||
path: "$.user.tags",
|
||||
alias: "tag",
|
||||
expected: `json_each("json_col", '$.user.tags') AS "tag"`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "",
|
||||
alias: "item",
|
||||
expected: `json_each("metadata") AS "item"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got, _ := f.JSONArrayElements(tt.column, tt.path, tt.alias)
|
||||
assert.Equal(t, tt.expected, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayOfStrings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
alias string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "root path with dollar sign",
|
||||
column: "data",
|
||||
path: "$",
|
||||
alias: "str",
|
||||
expected: `json_each("data") AS "str"`,
|
||||
},
|
||||
{
|
||||
name: "root path empty",
|
||||
column: "data",
|
||||
path: "",
|
||||
alias: "str",
|
||||
expected: `json_each("data") AS "str"`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.strings",
|
||||
alias: "s",
|
||||
expected: `json_each("metadata", '$.strings') AS "s"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got, _ := f.JSONArrayOfStrings(tt.column, tt.path, tt.alias)
|
||||
assert.Equal(t, tt.expected, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
alias string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "root path with dollar sign",
|
||||
column: "data",
|
||||
path: "$",
|
||||
alias: "k",
|
||||
expected: `json_each("data") AS "k"`,
|
||||
},
|
||||
{
|
||||
name: "root path empty",
|
||||
column: "data",
|
||||
path: "",
|
||||
alias: "k",
|
||||
expected: `json_each("data") AS "k"`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.object",
|
||||
alias: "key",
|
||||
expected: `json_each("metadata", '$.object') AS "key"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got, _ := f.JSONKeys(tt.column, tt.path, tt.alias)
|
||||
assert.Equal(t, tt.expected, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayAgg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expression string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple column",
|
||||
expression: "id",
|
||||
expected: "json_group_array(id)",
|
||||
},
|
||||
{
|
||||
name: "expression with function",
|
||||
expression: "DISTINCT name",
|
||||
expected: "json_group_array(DISTINCT name)",
|
||||
},
|
||||
{
|
||||
name: "complex expression",
|
||||
expression: "json_extract(data, '$.field')",
|
||||
expected: "json_group_array(json_extract(data, '$.field'))",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got := string(f.JSONArrayAgg(tt.expression))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayLiteral(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty array",
|
||||
values: []string{},
|
||||
expected: "json_array()",
|
||||
},
|
||||
{
|
||||
name: "single value",
|
||||
values: []string{"value1"},
|
||||
expected: "json_array('value1')",
|
||||
},
|
||||
{
|
||||
name: "multiple values",
|
||||
values: []string{"value1", "value2", "value3"},
|
||||
expected: "json_array('value1', 'value2', 'value3')",
|
||||
},
|
||||
{
|
||||
name: "values with special characters",
|
||||
values: []string{"test", "with space", "with-dash"},
|
||||
expected: "json_array('test', 'with space', 'with-dash')",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got := string(f.JSONArrayLiteral(tt.values...))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextToJsonColumn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple column name",
|
||||
column: "data",
|
||||
expected: `"data"`,
|
||||
},
|
||||
{
|
||||
name: "column with underscore",
|
||||
column: "user_data",
|
||||
expected: `"user_data"`,
|
||||
},
|
||||
{
|
||||
name: "column with special characters",
|
||||
column: "json-col",
|
||||
expected: `"json-col"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got := string(f.TextToJsonColumn(tt.column))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerExpression(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "json_extract expression",
|
||||
expr: "json_extract(data, '$.field')",
|
||||
expected: "lower(json_extract(data, '$.field'))",
|
||||
},
|
||||
{
|
||||
name: "nested json_extract",
|
||||
expr: "json_extract(metadata, '$.user.name')",
|
||||
expected: "lower(json_extract(metadata, '$.user.name'))",
|
||||
},
|
||||
{
|
||||
name: "json_type expression",
|
||||
expr: "json_type(data, '$.field')",
|
||||
expected: "lower(json_type(data, '$.field'))",
|
||||
},
|
||||
{
|
||||
name: "string concatenation",
|
||||
expr: "first_name || ' ' || last_name",
|
||||
expected: "lower(first_name || ' ' || last_name)",
|
||||
},
|
||||
{
|
||||
name: "CAST expression",
|
||||
expr: "CAST(value AS TEXT)",
|
||||
expected: "lower(CAST(value AS TEXT))",
|
||||
},
|
||||
{
|
||||
name: "COALESCE expression",
|
||||
expr: "COALESCE(name, 'default')",
|
||||
expected: "lower(COALESCE(name, 'default'))",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(sqlitedialect.New())
|
||||
got := string(f.LowerExpression(tt.expr))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,11 @@ import (
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
sqldb *sql.DB
|
||||
bundb *sqlstore.BunDB
|
||||
dialect *dialect
|
||||
settings factory.ScopedProviderSettings
|
||||
sqldb *sql.DB
|
||||
bundb *sqlstore.BunDB
|
||||
dialect *dialect
|
||||
formatter sqlstore.SQLFormatter
|
||||
}
|
||||
|
||||
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
|
||||
@@ -54,11 +55,14 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
settings.Logger().InfoContext(ctx, "connected to sqlite", "path", config.Sqlite.Path)
|
||||
sqldb.SetMaxOpenConns(config.Connection.MaxOpenConns)
|
||||
|
||||
sqliteDialect := sqlitedialect.New()
|
||||
bunDB := sqlstore.NewBunDB(settings, sqldb, sqliteDialect, hooks)
|
||||
return &provider{
|
||||
settings: settings,
|
||||
sqldb: sqldb,
|
||||
bundb: sqlstore.NewBunDB(settings, sqldb, sqlitedialect.New(), hooks),
|
||||
dialect: new(dialect),
|
||||
settings: settings,
|
||||
sqldb: sqldb,
|
||||
bundb: bunDB,
|
||||
dialect: new(dialect),
|
||||
formatter: newFormatter(bunDB.Dialect()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -74,6 +78,10 @@ func (provider *provider) Dialect() sqlstore.SQLDialect {
|
||||
return provider.dialect
|
||||
}
|
||||
|
||||
func (provider *provider) Formatter() sqlstore.SQLFormatter {
|
||||
return provider.formatter
|
||||
}
|
||||
|
||||
func (provider *provider) BunDBCtx(ctx context.Context) bun.IDB {
|
||||
return provider.bundb.BunDBCtx(ctx)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ type SQLStore interface {
|
||||
// Returns the dialect of the database.
|
||||
Dialect() SQLDialect
|
||||
|
||||
Formatter() SQLFormatter
|
||||
|
||||
// RunInTxCtx runs the given callback in a transaction. It creates and injects a new context with the transaction.
|
||||
// If a transaction is present in the context, it will be used.
|
||||
RunInTxCtx(ctx context.Context, opts *SQLStoreTxOptions, cb func(ctx context.Context) error) error
|
||||
@@ -86,3 +88,35 @@ type SQLDialect interface {
|
||||
// as an argument.
|
||||
ToggleForeignKeyConstraint(ctx context.Context, bun *bun.DB, enable bool) error
|
||||
}
|
||||
|
||||
type SQLFormatter interface {
|
||||
// JSONExtractString takes a JSON path (e.g., "$.labels.severity")
|
||||
JSONExtractString(column, path string) []byte
|
||||
|
||||
// JSONType used to determine the type of the value extracted from the path
|
||||
JSONType(column, path string) []byte
|
||||
|
||||
// JSONIsArray used to check whether the value is array or not
|
||||
JSONIsArray(column, path string) []byte
|
||||
|
||||
// JSONArrayElements returns query as well as columns alias to be used for select and where clause
|
||||
JSONArrayElements(column, path, alias string) ([]byte, []byte)
|
||||
|
||||
// JSONArrayOfStrings returns query as well as columns alias to be used for select and where clause
|
||||
JSONArrayOfStrings(column, path, alias string) ([]byte, []byte)
|
||||
|
||||
// JSONArrayAgg aggregates values into a JSON array
|
||||
JSONArrayAgg(expression string) []byte
|
||||
|
||||
// JSONArrayLiteral creates a literal JSON array from the given string values
|
||||
JSONArrayLiteral(values ...string) []byte
|
||||
|
||||
// JSONKeys return extracted key from json as well as alias to be used for select and where clause
|
||||
JSONKeys(column, path, alias string) ([]byte, []byte)
|
||||
|
||||
// TextToJsonColumn converts a text column to JSON type
|
||||
TextToJsonColumn(column string) []byte
|
||||
|
||||
// LowerExpression wraps any SQL expression with lower() function for case-insensitive operations
|
||||
LowerExpression(expression string) []byte
|
||||
}
|
||||
|
||||
107
pkg/sqlstore/sqlstoretest/formatter.go
Normal file
107
pkg/sqlstore/sqlstoretest/formatter.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package sqlstoretest
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun/schema"
|
||||
)
|
||||
|
||||
type formatter struct {
|
||||
bunf schema.Formatter
|
||||
}
|
||||
|
||||
func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
|
||||
return &formatter{bunf: schema.NewFormatter(dialect)}
|
||||
}
|
||||
|
||||
func (f *formatter) JSONExtractString(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_extract("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, ", "...)
|
||||
sql = schema.Append(f.bunf, sql, path)
|
||||
sql = append(sql, ")"...)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONType(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_type("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, ", "...)
|
||||
sql = schema.Append(f.bunf, sql, path)
|
||||
sql = append(sql, ")"...)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONIsArray(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, f.JSONType(column, path)...)
|
||||
sql = append(sql, " = "...)
|
||||
sql = schema.Append(f.bunf, sql, "array")
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayElements(column, path, alias string) ([]byte, []byte) {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_each("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
if path != "$" && path != "" {
|
||||
sql = append(sql, ", "...)
|
||||
sql = schema.Append(f.bunf, sql, path)
|
||||
}
|
||||
sql = append(sql, ") AS "...)
|
||||
sql = f.bunf.AppendIdent(sql, alias)
|
||||
|
||||
return sql, append([]byte(alias), ".value"...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayOfStrings(column, path, alias string) ([]byte, []byte) {
|
||||
return f.JSONArrayElements(column, path, alias)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONKeys(column, path, alias string) ([]byte, []byte) {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_each("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
if path != "$" && path != "" {
|
||||
sql = append(sql, ", "...)
|
||||
sql = schema.Append(f.bunf, sql, path)
|
||||
}
|
||||
sql = append(sql, ") AS "...)
|
||||
sql = f.bunf.AppendIdent(sql, alias)
|
||||
|
||||
return sql, append([]byte(alias), ".key"...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayAgg(expression string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_group_array("...)
|
||||
sql = append(sql, expression...)
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayLiteral(values ...string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "json_array("...)
|
||||
for idx, value := range values {
|
||||
if idx > 0 {
|
||||
sql = append(sql, ", "...)
|
||||
}
|
||||
sql = schema.Append(f.bunf, sql, value)
|
||||
}
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) TextToJsonColumn(column string) []byte {
|
||||
return f.bunf.AppendIdent([]byte{}, column)
|
||||
}
|
||||
|
||||
func (f *formatter) LowerExpression(expression string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "lower("...)
|
||||
sql = append(sql, expression...)
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
@@ -15,10 +15,11 @@ import (
|
||||
var _ sqlstore.SQLStore = (*Provider)(nil)
|
||||
|
||||
type Provider struct {
|
||||
db *sql.DB
|
||||
mock sqlmock.Sqlmock
|
||||
bunDB *bun.DB
|
||||
dialect *dialect
|
||||
db *sql.DB
|
||||
mock sqlmock.Sqlmock
|
||||
bunDB *bun.DB
|
||||
dialect *dialect
|
||||
formatter sqlstore.SQLFormatter
|
||||
}
|
||||
|
||||
func New(config sqlstore.Config, matcher sqlmock.QueryMatcher) *Provider {
|
||||
@@ -38,10 +39,11 @@ func New(config sqlstore.Config, matcher sqlmock.QueryMatcher) *Provider {
|
||||
}
|
||||
|
||||
return &Provider{
|
||||
db: db,
|
||||
mock: mock,
|
||||
bunDB: bunDB,
|
||||
dialect: new(dialect),
|
||||
db: db,
|
||||
mock: mock,
|
||||
bunDB: bunDB,
|
||||
dialect: new(dialect),
|
||||
formatter: newFormatter(bunDB.Dialect()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +63,8 @@ func (provider *Provider) Dialect() sqlstore.SQLDialect {
|
||||
return provider.dialect
|
||||
}
|
||||
|
||||
func (provider *Provider) Formatter() sqlstore.SQLFormatter { return provider.formatter }
|
||||
|
||||
func (provider *Provider) BunDBCtx(ctx context.Context) bun.IDB {
|
||||
return provider.bunDB
|
||||
}
|
||||
|
||||
@@ -218,6 +218,8 @@ func (c *conditionBuilder) ConditionFor(
|
||||
operator qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
_ uint64,
|
||||
_ uint64,
|
||||
) (string, error) {
|
||||
condition, err := c.conditionFor(ctx, key, operator, value, sb)
|
||||
if err != nil {
|
||||
|
||||
@@ -276,7 +276,7 @@ func TestConditionFor(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb)
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 0, 0)
|
||||
sb.Where(cond)
|
||||
|
||||
if tc.expectedError != nil {
|
||||
@@ -331,7 +331,7 @@ func TestConditionForMultipleKeys(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var err error
|
||||
for _, key := range tc.keys {
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &key, tc.operator, tc.value, sb)
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &key, tc.operator, tc.value, sb, 0, 0)
|
||||
sb.Where(cond)
|
||||
if err != nil {
|
||||
t.Fatalf("Error getting condition for key %s: %v", key.Name, err)
|
||||
@@ -528,7 +528,7 @@ func TestConditionForJSONBodySearch(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb)
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 0, 0)
|
||||
sb.Where(cond)
|
||||
|
||||
if tc.expectedError != nil {
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestLikeAndILikeWithoutWildcards_Warns(t *testing.T) {
|
||||
|
||||
for _, expr := range tests {
|
||||
t.Run(expr, func(t *testing.T) {
|
||||
clause, err := querybuilder.PrepareWhereClause(expr, opts)
|
||||
clause, err := querybuilder.PrepareWhereClause(expr, opts, 0, 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, clause)
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestLikeAndILikeWithWildcards_NoWarn(t *testing.T) {
|
||||
|
||||
for _, expr := range tests {
|
||||
t.Run(expr, func(t *testing.T) {
|
||||
clause, err := querybuilder.PrepareWhereClause(expr, opts)
|
||||
clause, err := querybuilder.PrepareWhereClause(expr, opts, 0, 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, clause)
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("%s: %s", tc.category, limitString(tc.query, 50)), func(t *testing.T) {
|
||||
|
||||
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
|
||||
clause, err := querybuilder.PrepareWhereClause(tc.query, opts, 0, 0)
|
||||
|
||||
if tc.shouldPass {
|
||||
if err != nil {
|
||||
|
||||
@@ -2387,7 +2387,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("%s: %s", tc.category, limitString(tc.query, 50)), func(t *testing.T) {
|
||||
|
||||
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
|
||||
clause, err := querybuilder.PrepareWhereClause(tc.query, opts, 0, 0)
|
||||
|
||||
if tc.shouldPass {
|
||||
if err != nil {
|
||||
@@ -2506,7 +2506,7 @@ func TestFilterExprLogsConflictNegation(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
t.Run(fmt.Sprintf("%s: %s", tc.category, limitString(tc.query, 50)), func(t *testing.T) {
|
||||
|
||||
clause, err := querybuilder.PrepareWhereClause(tc.query, opts)
|
||||
clause, err := querybuilder.PrepareWhereClause(tc.query, opts, 0, 0)
|
||||
|
||||
if tc.shouldPass {
|
||||
if err != nil {
|
||||
|
||||
@@ -592,7 +592,7 @@ func (b *logQueryStatementBuilder) addFilterCondition(
|
||||
JsonBodyPrefix: b.jsonBodyPrefix,
|
||||
JsonKeyToKey: b.jsonKeyToKey,
|
||||
Variables: variables,
|
||||
})
|
||||
}, start, end)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -25,6 +25,8 @@ func (c *conditionBuilder) ConditionFor(
|
||||
operator qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
_ uint64,
|
||||
_ uint64,
|
||||
) (string, error) {
|
||||
|
||||
switch operator {
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestConditionFor(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb)
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 0, 0)
|
||||
sb.Where(cond)
|
||||
|
||||
if tc.expectedError != nil {
|
||||
|
||||
@@ -978,7 +978,7 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel
|
||||
FieldMapper: t.fm,
|
||||
ConditionBuilder: t.conditionBuilder,
|
||||
FieldKeys: keys,
|
||||
})
|
||||
}, 0, 0)
|
||||
if err == nil {
|
||||
sb.AddWhereClause(whereClause.WhereClause)
|
||||
} else {
|
||||
@@ -1002,20 +1002,20 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel
|
||||
|
||||
// search on attributes
|
||||
key.FieldContext = telemetrytypes.FieldContextAttribute
|
||||
cond, err := t.conditionBuilder.ConditionFor(ctx, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
|
||||
cond, err := t.conditionBuilder.ConditionFor(ctx, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb, 0, 0)
|
||||
if err == nil {
|
||||
conds = append(conds, cond)
|
||||
}
|
||||
|
||||
// search on resource
|
||||
key.FieldContext = telemetrytypes.FieldContextResource
|
||||
cond, err = t.conditionBuilder.ConditionFor(ctx, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
|
||||
cond, err = t.conditionBuilder.ConditionFor(ctx, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb, 0, 0)
|
||||
if err == nil {
|
||||
conds = append(conds, cond)
|
||||
}
|
||||
key.FieldContext = origContext
|
||||
} else {
|
||||
cond, err := t.conditionBuilder.ConditionFor(ctx, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb)
|
||||
cond, err := t.conditionBuilder.ConditionFor(ctx, key, qbtypes.FilterOperatorContains, fieldValueSelector.Value, sb, 0, 0)
|
||||
if err == nil {
|
||||
conds = append(conds, cond)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
|
||||
FieldKeys: keys,
|
||||
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
|
||||
Variables: variables,
|
||||
})
|
||||
}, start, end)
|
||||
if err != nil {
|
||||
return "", []any{}, err
|
||||
}
|
||||
@@ -231,7 +231,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDelta(
|
||||
FieldKeys: keys,
|
||||
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
|
||||
Variables: variables,
|
||||
})
|
||||
}, start, end)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
@@ -295,7 +295,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggCumulativeOrUnspecified(
|
||||
FieldKeys: keys,
|
||||
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
|
||||
Variables: variables,
|
||||
})
|
||||
}, start, end)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
@@ -136,6 +136,8 @@ func (c *conditionBuilder) ConditionFor(
|
||||
operator qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
_ uint64,
|
||||
_ uint64,
|
||||
) (string, error) {
|
||||
condition, err := c.conditionFor(ctx, key, operator, value, sb)
|
||||
if err != nil {
|
||||
|
||||
@@ -234,7 +234,7 @@ func TestConditionFor(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb)
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 0, 0)
|
||||
sb.Where(cond)
|
||||
|
||||
if tc.expectedError != nil {
|
||||
@@ -289,7 +289,7 @@ func TestConditionForMultipleKeys(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var err error
|
||||
for _, key := range tc.keys {
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &key, tc.operator, tc.value, sb)
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &key, tc.operator, tc.value, sb, 0, 0)
|
||||
sb.Where(cond)
|
||||
if err != nil {
|
||||
t.Fatalf("Error getting condition for key %s: %v", key.Name, err)
|
||||
|
||||
@@ -348,7 +348,7 @@ func (b *MetricQueryStatementBuilder) buildTimeSeriesCTE(
|
||||
FieldKeys: keys,
|
||||
FullTextColumn: &telemetrytypes.TelemetryFieldKey{Name: "labels"},
|
||||
Variables: variables,
|
||||
})
|
||||
}, start, end)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
@@ -223,9 +223,11 @@ func (c *conditionBuilder) ConditionFor(
|
||||
operator qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
startNs uint64,
|
||||
_ uint64,
|
||||
) (string, error) {
|
||||
if c.isSpanScopeField(key.Name) {
|
||||
return c.buildSpanScopeCondition(key, operator, value)
|
||||
return c.buildSpanScopeCondition(key, operator, value, startNs)
|
||||
}
|
||||
|
||||
condition, err := c.conditionFor(ctx, key, operator, value, sb)
|
||||
@@ -257,7 +259,7 @@ func (c *conditionBuilder) isSpanScopeField(name string) bool {
|
||||
return keyName == SpanSearchScopeRoot || keyName == SpanSearchScopeEntryPoint
|
||||
}
|
||||
|
||||
func (c *conditionBuilder) buildSpanScopeCondition(key *telemetrytypes.TelemetryFieldKey, operator qbtypes.FilterOperator, value any) (string, error) {
|
||||
func (c *conditionBuilder) buildSpanScopeCondition(key *telemetrytypes.TelemetryFieldKey, operator qbtypes.FilterOperator, value any, startNs uint64) (string, error) {
|
||||
if operator != qbtypes.FilterOperatorEqual {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "span scope field %s only supports '=' operator", key.Name)
|
||||
}
|
||||
@@ -281,6 +283,11 @@ func (c *conditionBuilder) buildSpanScopeCondition(key *telemetrytypes.Telemetry
|
||||
case SpanSearchScopeRoot:
|
||||
return "parent_span_id = ''", nil
|
||||
case SpanSearchScopeEntryPoint:
|
||||
if startNs > 0 { // only add time filter if it is a valid time, else do not add
|
||||
startS := int64(startNs / 1_000_000_000)
|
||||
return fmt.Sprintf("((name, resource_string_service$$$name) GLOBAL IN (SELECT DISTINCT name, serviceName from %s.%s WHERE time >= toDateTime(%d))) AND parent_span_id != ''",
|
||||
DBName, TopLevelOperationsTableName, startS), nil
|
||||
}
|
||||
return fmt.Sprintf("((name, resource_string_service$$$name) GLOBAL IN (SELECT DISTINCT name, serviceName from %s.%s)) AND parent_span_id != ''",
|
||||
DBName, TopLevelOperationsTableName), nil
|
||||
default:
|
||||
|
||||
@@ -289,7 +289,7 @@ func TestConditionFor(t *testing.T) {
|
||||
for _, tc := range testCases {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb)
|
||||
cond, err := conditionBuilder.ConditionFor(ctx, &tc.key, tc.operator, tc.value, sb, 1761437108000000000, 1761458708000000000)
|
||||
sb.Where(cond)
|
||||
|
||||
if tc.expectedError != nil {
|
||||
|
||||
@@ -21,26 +21,37 @@ func TestSpanScopeFilterExpression(t *testing.T) {
|
||||
expression string
|
||||
expectedCondition string
|
||||
expectError bool
|
||||
startNs uint64
|
||||
}{
|
||||
{
|
||||
name: "simple isroot filter",
|
||||
expression: "isroot = true",
|
||||
expectedCondition: "parent_span_id = ''",
|
||||
startNs: 1761437108000000000,
|
||||
},
|
||||
{
|
||||
name: "simple isentrypoint filter",
|
||||
name: "simple isentrypoint filter (unbounded)",
|
||||
expression: "isentrypoint = true",
|
||||
expectedCondition: "((name, resource_string_service$$name) GLOBAL IN (SELECT DISTINCT name, serviceName from signoz_traces.distributed_top_level_operations)) AND parent_span_id != ''",
|
||||
startNs: 0,
|
||||
},
|
||||
{
|
||||
name: "simple isentrypoint filter (bounded)",
|
||||
expression: "isentrypoint = true",
|
||||
expectedCondition: "((name, resource_string_service$$name) GLOBAL IN (SELECT DISTINCT name, serviceName from signoz_traces.distributed_top_level_operations WHERE time >= toDateTime(1761437108))) AND parent_span_id != ''",
|
||||
startNs: 1761437108000000000,
|
||||
},
|
||||
{
|
||||
name: "combined filter with AND",
|
||||
expression: "isroot = true AND has_error = true",
|
||||
expectedCondition: "parent_span_id = ''",
|
||||
startNs: 1761437108000000000,
|
||||
},
|
||||
{
|
||||
name: "combined filter with OR",
|
||||
expression: "isentrypoint = true OR has_error = true",
|
||||
expectedCondition: "((name, resource_string_service$$name) GLOBAL IN (SELECT DISTINCT name, serviceName from signoz_traces.distributed_top_level_operations)) AND parent_span_id != ''",
|
||||
expectedCondition: "((name, resource_string_service$$name) GLOBAL IN (SELECT DISTINCT name, serviceName from signoz_traces.distributed_top_level_operations WHERE time >= toDateTime(1761437108))) AND parent_span_id != ''",
|
||||
startNs: 1761437108000000000,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -64,13 +75,13 @@ func TestSpanScopeFilterExpression(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
}}
|
||||
|
||||
whereClause, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
|
||||
whereClause, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
|
||||
Logger: instrumentationtest.New().Logger(),
|
||||
FieldMapper: fm,
|
||||
ConditionBuilder: cb,
|
||||
FieldKeys: fieldKeys,
|
||||
Builder: sb,
|
||||
})
|
||||
}, tt.startNs, 1761458708000000000)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
@@ -131,13 +142,13 @@ func TestSpanScopeWithResourceFilter(t *testing.T) {
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
}}
|
||||
|
||||
_, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
|
||||
_, err := querybuilder.PrepareWhereClause(tt.expression, querybuilder.FilterExprVisitorOpts{
|
||||
Logger: instrumentationtest.New().Logger(),
|
||||
FieldMapper: fm,
|
||||
ConditionBuilder: cb,
|
||||
FieldKeys: fieldKeys,
|
||||
SkipResourceFilter: false, // This would be set by the statement builder
|
||||
})
|
||||
}, 1761437108000000000, 1761458708000000000)
|
||||
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
@@ -746,7 +746,7 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
FieldKeys: keys,
|
||||
SkipResourceFilter: true,
|
||||
Variables: variables,
|
||||
})
|
||||
}, start, end)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -237,7 +237,7 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
|
||||
ConditionBuilder: b.stmtBuilder.cb,
|
||||
FieldKeys: keys,
|
||||
SkipResourceFilter: true,
|
||||
},
|
||||
}, b.start, b.end,
|
||||
)
|
||||
if err != nil {
|
||||
b.stmtBuilder.logger.ErrorContext(ctx, "Failed to prepare where clause", "error", err, "filter", query.Filter.Expression)
|
||||
|
||||
@@ -31,7 +31,8 @@ type FieldMapper interface {
|
||||
// ConditionBuilder builds the condition for the filter.
|
||||
type ConditionBuilder interface {
|
||||
// ConditionFor returns the condition for the given key, operator and value.
|
||||
ConditionFor(ctx context.Context, key *telemetrytypes.TelemetryFieldKey, operator FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error)
|
||||
// TODO(srikanthccv,nikhilmantri0902): remove startNs, endNs when top_level_operations can be replaced with `is_remote`
|
||||
ConditionFor(ctx context.Context, key *telemetrytypes.TelemetryFieldKey, operator FilterOperator, value any, sb *sqlbuilder.SelectBuilder, startNs uint64, endNs uint64) (string, error)
|
||||
}
|
||||
|
||||
type AggExprRewriter interface {
|
||||
|
||||
Reference in New Issue
Block a user