Compare commits

...

32 Commits

Author SHA1 Message Date
aniket
44080a1d59 chore: resolved conflict 2025-11-02 11:09:13 +05:30
aniket
554c498209 Merge branch 'chore/json_formatter' of github.com:SigNoz/signoz into chore/filter-rules 2025-11-02 11:06:03 +05:30
aniket
156de83626 chore: minor changes 2025-11-01 22:16:25 +05:30
aniket
7c4a18687a chore: minor changes 2025-11-01 22:07:01 +05:30
aniket
7214f51e98 chore: minor changes 2025-11-01 21:06:58 +05:30
aniket
290e0754c6 chore: minor changes 2025-11-01 21:02:56 +05:30
aniket
0ea16f9472 chore: updated queries: 2025-11-01 16:52:33 +05:30
aniket
0d773211af chore: resolved pr comments 2025-11-01 16:10:40 +05:30
aniket
7028031e01 Merge branch 'chore/json_formatter' of github.com:SigNoz/signoz into chore/filter-rules 2025-11-01 16:07:18 +05:30
aniket
2a4407280d chore: resolved pr comments 2025-11-01 15:32:54 +05:30
Vikrant Gupta
8c75fb29a6 Merge branch 'main' into chore/json_formatter 2025-11-01 14:56:53 +05:30
aniket
85ea6105f8 chore: resolved pr comments 2025-11-01 14:55:42 +05:30
aniket
dc8fba6944 chore: resolved pr comments 2025-11-01 14:51:55 +05:30
Vishal Sharma
4b21c9d5f9 feat: add result count to data source search analytics event (#9444) 2025-10-31 12:35:24 +00:00
aniket
97bbc95aab Merge branch 'chore/json_formatter' of github.com:SigNoz/signoz into chore/filter-rules 2025-10-31 14:35:35 +05:30
aniket
fbcb17006d chore: added apend ident 2025-10-31 14:21:48 +05:30
Yunus M
5ef0a18867 Update CODEOWNERS for frontend code (#9456) 2025-10-31 12:52:37 +05:30
SagarRajput-7
c8266d1aec fix: upgraded the axios resolution to fix vulnerability (#9454) 2025-10-31 11:53:10 +05:30
aniket
d642b69f8e Merge branch 'main' of github.com:SigNoz/signoz into chore/filter-rules 2025-10-31 01:42:16 +05:30
aniket
7230069de6 chore: minor changes 2025-10-31 01:39:24 +05:30
SagarRajput-7
adfd16ce1b fix: adapt the scroll reset fix in alert and histogram panels (#9322) 2025-10-30 13:31:17 +00:00
SagarRajput-7
6db74a5585 feat: allow custom precision in dashboard panels (#9054) 2025-10-30 18:50:40 +05:30
aniket
80fff10273 chore: added keys, values api for rule filtering 2025-10-30 16:58:48 +05:30
aniket
39a6e3865e Merge branch 'chore/json_formatter' of github.com:SigNoz/signoz into chore/json_formatter 2025-10-30 16:53:32 +05:30
aniket
492e249c29 chore: updated json extract columns 2025-10-30 16:52:27 +05:30
Pandey
f8e0db0085 chore: bump golangci-lint to the latest version (#9445) 2025-10-30 11:21:35 +00:00
Shaheer Kochai
01e0b36d62 fix: overall improvements to span logs drawer empty state (i.e. trace logs empty state vs. span logs empty state + UI improvements) (#9252)
* chore: remove the applied filters in related signals drawer

* chore: make the span logs highlight color more prominent

* fix: add label to open trace logs in logs explorer button

* feat: improve the span logs empty state i.e. add support for no logs for trace_id

* refactor: refactor the span logs content and make it readable

* test: add tests for span logs

* chore: improve tests

* refactor: simplify condition

* chore: remove redundant test

* fix: make trace_id logs request only if drawer is open

* chore: fix failing tests + overall improvements

* Update frontend/src/container/SpanDetailsDrawer/__tests__/SpanDetailsDrawer.test.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* chore: fix the failing test

* fix: fix the light mode styles for empty logs component

* chore: update the empty state copy

* chore: fix the failing tests by updating the assertions with correct empty state copy

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-10-29 16:20:52 +00:00
Ekansh Gupta
e90bb016f7 feat: add span percentile for traces (#8955)
* feat: add span percentile for traces

* feat: fixed merge conflicts

* feat: fixed merge conflicts

* feat: fixed merge conflicts

* feat: added span percentile

* feat: added span percentile

* feat: added test for span percentiles

* feat: added test for span percentiles

* feat: added test for span percentiles

* feat: added test for span percentiles

* feat: removed comments

* feat: moved everything to module

* feat: refactored span percentile

* feat: refactored span percentile

* feat: refactored module package

* feat: fixed tests for span percentile

* feat: refactored span percentile and changed query

* feat: refactored span percentile and changed query

* feat: refactored span percentile and changed query

* feat: refactored span percentile and changed query

* feat: added better error handling

* feat: added better error handling

* feat: addressed pr comments

* feat: addressed pr comments

* feat: renamed translator.go

* feat: added query settings

* feat: added full query test

* feat: added fingerprinting

* feat: refactored tests

* feat: refactored to use fingerprinting and changed tests

* feat: refactored to use fingerprinting and changed tests

* feat: refactored to use fingerprinting and changed tests

* feat: changed errors

* feat: removed redundant tests

* feat: removed redundant tests

* feat: moved everything to trace aggregation and updated tests

* feat: addressed comments regarding metadatastore

* feat: addressed comments regarding metadatastore

* feat: addressed comments regarding metadatastore

* feat: addressed comments for float64

* feat: cleaned up code

* feat: cleaned up code
2025-10-29 21:35:59 +05:30
Amlan Kumar Nandy
bdecbfb7f5 chore: add missing unit tests for getLegend (#9374) 2025-10-29 16:27:20 +05:30
Nageshbansal
3dced2b082 chore(costmeter): enable costmeter by default in docker installations (#9432)
* chore(costmeter): enable costmeter by default in docker installations

* chore(costmeter): enable costmeter by default in docker installations
2025-10-29 15:24:54 +05:30
aniketio-ctrl
d68affd1d6 Merge branch 'main' into chore/json_formatter 2025-10-27 17:34:32 +05:30
aniket
7bad6d5377 chore: added sql formatter for json 2025-10-27 17:14:29 +05:30
86 changed files with 4170 additions and 373 deletions

2
.github/CODEOWNERS vendored
View File

@@ -2,7 +2,7 @@
# Owners are automatically requested for review for PRs that changes code
# that they own.
/frontend/ @SigNoz/frontend @YounixM
/frontend/ @YounixM @aks07
/frontend/src/container/MetricsApplication @srikanthccv
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv

View File

@@ -1,43 +1,63 @@
version: "2"
linters:
default: standard
default: none
enable:
- bodyclose
- depguard
- errcheck
- forbidigo
- govet
- iface
- ineffassign
- misspell
- nilnil
- sloglint
- depguard
- iface
- unparam
- forbidigo
linters-settings:
sloglint:
no-mixed-args: true
kv-only: true
no-global: all
context: all
static-msg: true
msg-style: lowercased
key-naming-case: snake
depguard:
rules:
nozap:
deny:
- pkg: "go.uber.org/zap"
desc: "Do not use zap logger. Use slog instead."
noerrors:
deny:
- pkg: "errors"
desc: "Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead."
iface:
enable:
- identical
forbidigo:
forbid:
- fmt.Errorf
- ^(fmt\.Print.*|print|println)$
issues:
exclude-dirs:
- "pkg/query-service"
- "ee/query-service"
- "scripts/"
- unused
settings:
depguard:
rules:
noerrors:
deny:
- pkg: errors
desc: Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead.
nozap:
deny:
- pkg: go.uber.org/zap
desc: Do not use zap logger. Use slog instead.
forbidigo:
forbid:
- pattern: fmt.Errorf
- pattern: ^(fmt\.Print.*|print|println)$
iface:
enable:
- identical
sloglint:
no-mixed-args: true
kv-only: true
no-global: all
context: all
static-msg: true
key-naming-case: snake
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- pkg/query-service
- ee/query-service
- scripts/
- tmp/
- third_party$
- builtin$
- examples$
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -1,3 +1,10 @@
connectors:
signozmeter:
metrics_flush_interval: 1h
dimensions:
- name: service.name
- name: deployment.environment
- name: host.name
receivers:
otlp:
protocols:
@@ -21,6 +28,10 @@ processors:
send_batch_size: 10000
send_batch_max_size: 11000
timeout: 10s
batch/meter:
send_batch_max_size: 25000
send_batch_size: 20000
timeout: 1s
resourcedetection:
# Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels.
detectors: [env, system]
@@ -66,6 +77,11 @@ exporters:
dsn: tcp://clickhouse:9000/signoz_logs
timeout: 10s
use_new_schema: true
signozclickhousemeter:
dsn: tcp://clickhouse:9000/signoz_meter
timeout: 45s
sending_queue:
enabled: false
service:
telemetry:
logs:
@@ -77,16 +93,20 @@ service:
traces:
receivers: [otlp]
processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces]
exporters: [clickhousetraces, signozmeter]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [signozclickhousemetrics]
exporters: [signozclickhousemetrics, signozmeter]
metrics/prometheus:
receivers: [prometheus]
processors: [batch]
exporters: [signozclickhousemetrics]
exporters: [signozclickhousemetrics, signozmeter]
logs:
receivers: [otlp]
processors: [batch]
exporters: [clickhouselogsexporter]
exporters: [clickhouselogsexporter, signozmeter]
metrics/meter:
receivers: [signozmeter]
processors: [batch/meter]
exporters: [signozclickhousemeter]

View File

@@ -1,3 +1,10 @@
connectors:
signozmeter:
metrics_flush_interval: 1h
dimensions:
- name: service.name
- name: deployment.environment
- name: host.name
receivers:
otlp:
protocols:
@@ -21,6 +28,10 @@ processors:
send_batch_size: 10000
send_batch_max_size: 11000
timeout: 10s
batch/meter:
send_batch_max_size: 25000
send_batch_size: 20000
timeout: 1s
resourcedetection:
# Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels.
detectors: [env, system]
@@ -66,6 +77,11 @@ exporters:
dsn: tcp://clickhouse:9000/signoz_logs
timeout: 10s
use_new_schema: true
signozclickhousemeter:
dsn: tcp://clickhouse:9000/signoz_meter
timeout: 45s
sending_queue:
enabled: false
service:
telemetry:
logs:
@@ -77,16 +93,20 @@ service:
traces:
receivers: [otlp]
processors: [signozspanmetrics/delta, batch]
exporters: [clickhousetraces]
exporters: [clickhousetraces, signozmeter]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [signozclickhousemetrics]
exporters: [signozclickhousemetrics, signozmeter]
metrics/prometheus:
receivers: [prometheus]
processors: [batch]
exporters: [signozclickhousemetrics]
exporters: [signozclickhousemetrics, signozmeter]
logs:
receivers: [otlp]
processors: [batch]
exporters: [clickhouselogsexporter]
exporters: [clickhouselogsexporter, signozmeter]
metrics/meter:
receivers: [signozmeter]
processors: [batch/meter]
exporters: [signozclickhousemeter]

View File

@@ -0,0 +1,157 @@
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, " = '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)
if path != "$" && path != "" {
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)
if path != "$" && path != "" {
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
}
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, []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)
if path != "$" && path != "" {
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
}
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, []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 {
if len(values) == 0 {
return []byte("jsonb_build_array()")
}
var sql []byte
sql = append(sql, "jsonb_build_array("...)
for i, v := range values {
if i > 0 {
sql = append(sql, ", "...)
}
sql = append(sql, '\'')
sql = append(sql, v...)
sql = append(sql, '\'')
}
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) string {
return f.convertJSONPathToPostgresWithMode(jsonPath, true)
}
func (f *formatter) convertJSONPathToPostgresWithMode(jsonPath string, asText bool) string {
path := strings.TrimPrefix(jsonPath, "$")
if path == "" || path == "." {
return ""
}
parts := strings.Split(strings.TrimPrefix(path, "."), ".")
if len(parts) == 0 {
return ""
}
var result strings.Builder
for i, part := range parts {
if i < len(parts)-1 {
result.WriteString("->")
result.WriteString("'")
result.WriteString(part)
result.WriteString("'")
} else {
if asText {
result.WriteString("->>")
} else {
result.WriteString("->")
}
result.WriteString("'")
result.WriteString(part)
result.WriteString("'")
}
}
return result.String()
}
func (f *formatter) LowerExpression(expression string) []byte {
var sql []byte
sql = append(sql, "lower("...)
sql = append(sql, expression...)
sql = append(sql, ')')
return sql
}

View File

@@ -0,0 +1,488 @@
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")`,
},
}
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'`,
},
}
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 := 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)
})
}
}

View File

@@ -3,7 +3,6 @@ package postgressqlstore
import (
"context"
"database/sql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -15,10 +14,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 +55,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: sqlstore.NewBunDB(settings, sqldb, pgDialect, hooks),
dialect: new(dialect),
formatter: newFormatter(bunDB.Dialect()),
}, nil
}
@@ -75,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)
}

View File

@@ -69,7 +69,7 @@
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
"antlr4": "4.13.2",
"axios": "1.8.2",
"axios": "1.12.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^29.6.4",
"babel-loader": "9.1.3",

View File

@@ -0,0 +1,371 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { getYAxisFormattedValue, PrecisionOptionsEnum } from '../yAxisConfig';
const testFullPrecisionGetYAxisFormattedValue = (
value: string,
format: string,
): string => getYAxisFormattedValue(value, format, PrecisionOptionsEnum.FULL);
describe('getYAxisFormattedValue - none (full precision legacy assertions)', () => {
test('large integers and decimals', () => {
expect(testFullPrecisionGetYAxisFormattedValue('250034', 'none')).toBe(
'250034',
);
expect(
testFullPrecisionGetYAxisFormattedValue('250034897.12345', 'none'),
).toBe('250034897.12345');
expect(
testFullPrecisionGetYAxisFormattedValue('250034897.02354', 'none'),
).toBe('250034897.02354');
expect(testFullPrecisionGetYAxisFormattedValue('9999999.9999', 'none')).toBe(
'9999999.9999',
);
});
test('preserves leading zeros after decimal until first non-zero', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1.0000234', 'none')).toBe(
'1.0000234',
);
expect(testFullPrecisionGetYAxisFormattedValue('0.00003', 'none')).toBe(
'0.00003',
);
});
test('trims to three significant decimals and removes trailing zeros', () => {
expect(
testFullPrecisionGetYAxisFormattedValue('0.000000250034', 'none'),
).toBe('0.000000250034');
expect(testFullPrecisionGetYAxisFormattedValue('0.00000025', 'none')).toBe(
'0.00000025',
);
// Big precision, limiting the javascript precision (~16 digits)
expect(
testFullPrecisionGetYAxisFormattedValue('1.0000000000000001', 'none'),
).toBe('1');
expect(
testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'none'),
).toBe('1.005555555595958');
expect(testFullPrecisionGetYAxisFormattedValue('0.000000001', 'none')).toBe(
'0.000000001',
);
expect(
testFullPrecisionGetYAxisFormattedValue('0.000000250000', 'none'),
).toBe('0.00000025');
});
test('whole numbers normalize', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1000', 'none')).toBe('1000');
expect(testFullPrecisionGetYAxisFormattedValue('99.5458', 'none')).toBe(
'99.5458',
);
expect(testFullPrecisionGetYAxisFormattedValue('1.234567', 'none')).toBe(
'1.234567',
);
expect(testFullPrecisionGetYAxisFormattedValue('99.998', 'none')).toBe(
'99.998',
);
});
test('strip redundant decimal zeros', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1000.000', 'none')).toBe(
'1000',
);
expect(testFullPrecisionGetYAxisFormattedValue('99.500', 'none')).toBe(
'99.5',
);
expect(testFullPrecisionGetYAxisFormattedValue('1.000', 'none')).toBe('1');
});
test('edge values', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0', 'none')).toBe('0');
expect(testFullPrecisionGetYAxisFormattedValue('-0', 'none')).toBe('0');
expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'none')).toBe('∞');
expect(testFullPrecisionGetYAxisFormattedValue('-Infinity', 'none')).toBe(
'-∞',
);
expect(testFullPrecisionGetYAxisFormattedValue('invalid', 'none')).toBe(
'NaN',
);
expect(testFullPrecisionGetYAxisFormattedValue('', 'none')).toBe('NaN');
expect(testFullPrecisionGetYAxisFormattedValue('abc123', 'none')).toBe('NaN');
});
test('small decimals keep precision as-is', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0.0001', 'none')).toBe(
'0.0001',
);
expect(testFullPrecisionGetYAxisFormattedValue('-0.0001', 'none')).toBe(
'-0.0001',
);
expect(testFullPrecisionGetYAxisFormattedValue('0.000000001', 'none')).toBe(
'0.000000001',
);
});
test('simple decimals preserved', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0.1', 'none')).toBe('0.1');
expect(testFullPrecisionGetYAxisFormattedValue('0.2', 'none')).toBe('0.2');
expect(testFullPrecisionGetYAxisFormattedValue('0.3', 'none')).toBe('0.3');
expect(testFullPrecisionGetYAxisFormattedValue('1.0000000001', 'none')).toBe(
'1.0000000001',
);
});
});
describe('getYAxisFormattedValue - units (full precision legacy assertions)', () => {
test('ms', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1500', 'ms')).toBe('1.5 s');
expect(testFullPrecisionGetYAxisFormattedValue('500', 'ms')).toBe('500 ms');
expect(testFullPrecisionGetYAxisFormattedValue('60000', 'ms')).toBe('1 min');
expect(testFullPrecisionGetYAxisFormattedValue('295.429', 'ms')).toBe(
'295.429 ms',
);
expect(testFullPrecisionGetYAxisFormattedValue('4353.81', 'ms')).toBe(
'4.35381 s',
);
});
test('s', () => {
expect(testFullPrecisionGetYAxisFormattedValue('90', 's')).toBe('1.5 mins');
expect(testFullPrecisionGetYAxisFormattedValue('30', 's')).toBe('30 s');
expect(testFullPrecisionGetYAxisFormattedValue('3600', 's')).toBe('1 hour');
});
test('m', () => {
expect(testFullPrecisionGetYAxisFormattedValue('90', 'm')).toBe('1.5 hours');
expect(testFullPrecisionGetYAxisFormattedValue('30', 'm')).toBe('30 min');
expect(testFullPrecisionGetYAxisFormattedValue('1440', 'm')).toBe('1 day');
});
test('bytes', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'bytes')).toBe(
'1 KiB',
);
expect(testFullPrecisionGetYAxisFormattedValue('512', 'bytes')).toBe('512 B');
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'bytes')).toBe(
'1.5 KiB',
);
});
test('mbytes', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'mbytes')).toBe(
'1 GiB',
);
expect(testFullPrecisionGetYAxisFormattedValue('512', 'mbytes')).toBe(
'512 MiB',
);
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'mbytes')).toBe(
'1.5 GiB',
);
});
test('kbytes', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'kbytes')).toBe(
'1 MiB',
);
expect(testFullPrecisionGetYAxisFormattedValue('512', 'kbytes')).toBe(
'512 KiB',
);
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'kbytes')).toBe(
'1.5 MiB',
);
});
test('short', () => {
expect(testFullPrecisionGetYAxisFormattedValue('1000', 'short')).toBe('1 K');
expect(testFullPrecisionGetYAxisFormattedValue('1500', 'short')).toBe(
'1.5 K',
);
expect(testFullPrecisionGetYAxisFormattedValue('999', 'short')).toBe('999');
expect(testFullPrecisionGetYAxisFormattedValue('1000000', 'short')).toBe(
'1 Mil',
);
expect(testFullPrecisionGetYAxisFormattedValue('1555600', 'short')).toBe(
'1.5556 Mil',
);
expect(testFullPrecisionGetYAxisFormattedValue('999999', 'short')).toBe(
'999.999 K',
);
expect(testFullPrecisionGetYAxisFormattedValue('1000000000', 'short')).toBe(
'1 Bil',
);
expect(testFullPrecisionGetYAxisFormattedValue('1500000000', 'short')).toBe(
'1.5 Bil',
);
expect(testFullPrecisionGetYAxisFormattedValue('999999999', 'short')).toBe(
'999.999999 Mil',
);
});
test('percent', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0.15', 'percent')).toBe(
'0.15%',
);
expect(testFullPrecisionGetYAxisFormattedValue('0.1234', 'percent')).toBe(
'0.1234%',
);
expect(testFullPrecisionGetYAxisFormattedValue('0.123499', 'percent')).toBe(
'0.123499%',
);
expect(testFullPrecisionGetYAxisFormattedValue('1.5', 'percent')).toBe(
'1.5%',
);
expect(testFullPrecisionGetYAxisFormattedValue('0.0001', 'percent')).toBe(
'0.0001%',
);
expect(
testFullPrecisionGetYAxisFormattedValue('0.000000001', 'percent'),
).toBe('1e-9%');
expect(
testFullPrecisionGetYAxisFormattedValue('0.000000250034', 'percent'),
).toBe('0.000000250034%');
expect(testFullPrecisionGetYAxisFormattedValue('0.00000025', 'percent')).toBe(
'0.00000025%',
);
// Big precision, limiting the javascript precision (~16 digits)
expect(
testFullPrecisionGetYAxisFormattedValue('1.0000000000000001', 'percent'),
).toBe('1%');
expect(
testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'percent'),
).toBe('1.005555555595958%');
});
test('ratio', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0.5', 'ratio')).toBe(
'0.5 ratio',
);
expect(testFullPrecisionGetYAxisFormattedValue('1.25', 'ratio')).toBe(
'1.25 ratio',
);
expect(testFullPrecisionGetYAxisFormattedValue('2.0', 'ratio')).toBe(
'2 ratio',
);
});
test('temperature units', () => {
expect(testFullPrecisionGetYAxisFormattedValue('25', 'celsius')).toBe(
'25 °C',
);
expect(testFullPrecisionGetYAxisFormattedValue('0', 'celsius')).toBe('0 °C');
expect(testFullPrecisionGetYAxisFormattedValue('-10', 'celsius')).toBe(
'-10 °C',
);
expect(testFullPrecisionGetYAxisFormattedValue('77', 'fahrenheit')).toBe(
'77 °F',
);
expect(testFullPrecisionGetYAxisFormattedValue('32', 'fahrenheit')).toBe(
'32 °F',
);
expect(testFullPrecisionGetYAxisFormattedValue('14', 'fahrenheit')).toBe(
'14 °F',
);
});
test('ms edge cases', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0', 'ms')).toBe('0 ms');
expect(testFullPrecisionGetYAxisFormattedValue('-1500', 'ms')).toBe('-1.5 s');
expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'ms')).toBe('∞');
});
test('bytes edge cases', () => {
expect(testFullPrecisionGetYAxisFormattedValue('0', 'bytes')).toBe('0 B');
expect(testFullPrecisionGetYAxisFormattedValue('-1024', 'bytes')).toBe(
'-1 KiB',
);
});
});
describe('getYAxisFormattedValue - precision option tests', () => {
test('precision 0 drops decimal part', () => {
expect(getYAxisFormattedValue('1.2345', 'none', 0)).toBe('1');
expect(getYAxisFormattedValue('0.9999', 'none', 0)).toBe('0');
expect(getYAxisFormattedValue('12345.6789', 'none', 0)).toBe('12345');
expect(getYAxisFormattedValue('0.0000123456', 'none', 0)).toBe('0');
expect(getYAxisFormattedValue('1000.000', 'none', 0)).toBe('1000');
expect(getYAxisFormattedValue('0.000000250034', 'none', 0)).toBe('0');
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 0)).toBe('1');
// with unit
expect(getYAxisFormattedValue('4353.81', 'ms', 0)).toBe('4 s');
});
test('precision 1,2,3,4 decimals', () => {
expect(getYAxisFormattedValue('1.2345', 'none', 1)).toBe('1.2');
expect(getYAxisFormattedValue('1.2345', 'none', 2)).toBe('1.23');
expect(getYAxisFormattedValue('1.2345', 'none', 3)).toBe('1.234');
expect(getYAxisFormattedValue('1.2345', 'none', 4)).toBe('1.2345');
expect(getYAxisFormattedValue('0.0000123456', 'none', 1)).toBe('0.00001');
expect(getYAxisFormattedValue('0.0000123456', 'none', 2)).toBe('0.000012');
expect(getYAxisFormattedValue('0.0000123456', 'none', 3)).toBe('0.0000123');
expect(getYAxisFormattedValue('0.0000123456', 'none', 4)).toBe('0.00001234');
expect(getYAxisFormattedValue('1000.000', 'none', 1)).toBe('1000');
expect(getYAxisFormattedValue('1000.000', 'none', 2)).toBe('1000');
expect(getYAxisFormattedValue('1000.000', 'none', 3)).toBe('1000');
expect(getYAxisFormattedValue('1000.000', 'none', 4)).toBe('1000');
expect(getYAxisFormattedValue('0.000000250034', 'none', 1)).toBe('0.0000002');
expect(getYAxisFormattedValue('0.000000250034', 'none', 2)).toBe(
'0.00000025',
); // leading zeros + 2 significant => same trimmed
expect(getYAxisFormattedValue('0.000000250034', 'none', 3)).toBe(
'0.00000025',
);
expect(getYAxisFormattedValue('0.000000250304', 'none', 4)).toBe(
'0.0000002503',
);
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 1)).toBe(
'1.005',
);
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 2)).toBe(
'1.0055',
);
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 3)).toBe(
'1.00555',
);
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 4)).toBe(
'1.005555',
);
// with unit
expect(getYAxisFormattedValue('4353.81', 'ms', 1)).toBe('4.4 s');
expect(getYAxisFormattedValue('4353.81', 'ms', 2)).toBe('4.35 s');
expect(getYAxisFormattedValue('4353.81', 'ms', 3)).toBe('4.354 s');
expect(getYAxisFormattedValue('4353.81', 'ms', 4)).toBe('4.3538 s');
// Percentages
expect(getYAxisFormattedValue('0.123456', 'percent', 2)).toBe('0.12%');
expect(getYAxisFormattedValue('0.123456', 'percent', 4)).toBe('0.1235%'); // approximation
});
test('precision full uses up to DEFAULT_SIGNIFICANT_DIGITS significant digits', () => {
expect(
getYAxisFormattedValue(
'0.00002625429914148441',
'none',
PrecisionOptionsEnum.FULL,
),
).toBe('0.000026254299141');
expect(
getYAxisFormattedValue(
'0.000026254299141484417',
's',
PrecisionOptionsEnum.FULL,
),
).toBe('26254299141484417000000 µs');
expect(
getYAxisFormattedValue('4353.81', 'ms', PrecisionOptionsEnum.FULL),
).toBe('4.35381 s');
expect(getYAxisFormattedValue('500', 'ms', PrecisionOptionsEnum.FULL)).toBe(
'500 ms',
);
});
});

View File

@@ -1,58 +1,158 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { formattedValueToString, getValueFormat } from '@grafana/data';
import * as Sentry from '@sentry/react';
import { isNaN } from 'lodash-es';
const DEFAULT_SIGNIFICANT_DIGITS = 15;
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
const MAX_DECIMALS = 15;
export enum PrecisionOptionsEnum {
ZERO = 0,
ONE = 1,
TWO = 2,
THREE = 3,
FOUR = 4,
FULL = 'full',
}
export type PrecisionOption = 0 | 1 | 2 | 3 | 4 | PrecisionOptionsEnum.FULL;
/**
* Formats a number for display, preserving leading zeros after the decimal point
* and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit.
* It avoids scientific notation and removes unnecessary trailing zeros.
*
* @example
* formatDecimalWithLeadingZeros(1.2345); // "1.2345"
* formatDecimalWithLeadingZeros(0.0012345); // "0.0012345"
* formatDecimalWithLeadingZeros(5.0); // "5"
*
* @param value The number to format.
* @returns The formatted string.
*/
const formatDecimalWithLeadingZeros = (
value: number,
precision: PrecisionOption,
): string => {
if (value === 0) {
return '0';
}
// Use toLocaleString to get a full decimal representation without scientific notation.
const numStr = value.toLocaleString('en-US', {
useGrouping: false,
maximumFractionDigits: 20,
});
const [integerPart, decimalPart = ''] = numStr.split('.');
// If there's no decimal part, the integer part is the result.
if (!decimalPart) {
return integerPart;
}
// Find the index of the first non-zero digit in the decimal part.
const firstNonZeroIndex = decimalPart.search(/[^0]/);
// If the decimal part consists only of zeros, return just the integer part.
if (firstNonZeroIndex === -1) {
return integerPart;
}
// Determine the number of decimals to keep: leading zeros + up to N significant digits.
const significantDigits =
precision === PrecisionOptionsEnum.FULL
? DEFAULT_SIGNIFICANT_DIGITS
: precision;
const decimalsToKeep = firstNonZeroIndex + (significantDigits || 0);
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
const finalDecimalsToKeep = Math.min(decimalsToKeep, MAX_DECIMALS);
const trimmedDecimalPart = decimalPart.substring(0, finalDecimalsToKeep);
// If precision is 0, we drop the decimal part entirely.
if (precision === 0) {
return integerPart;
}
// Remove any trailing zeros from the result to keep it clean.
const finalDecimalPart = trimmedDecimalPart.replace(/0+$/, '');
// Return the integer part, or the integer and decimal parts combined.
return finalDecimalPart ? `${integerPart}.${finalDecimalPart}` : integerPart;
};
/**
* Formats a Y-axis value based on a given format string.
*
* @param value The string value from the axis.
* @param format The format identifier (e.g. 'none', 'ms', 'bytes', 'short').
* @returns A formatted string ready for display.
*/
export const getYAxisFormattedValue = (
value: string,
format: string,
precision: PrecisionOption = 2, // default precision requested
): string => {
let decimalPrecision: number | undefined;
const parsedValue = getValueFormat(format)(
parseFloat(value),
undefined,
undefined,
undefined,
);
try {
const decimalSplitted = parsedValue.text.split('.');
if (decimalSplitted.length === 1) {
decimalPrecision = 0;
} else {
const decimalDigits = decimalSplitted[1].split('');
decimalPrecision = decimalDigits.length;
let nonZeroCtr = 0;
for (let idx = 0; idx < decimalDigits.length; idx += 1) {
if (decimalDigits[idx] !== '0') {
nonZeroCtr += 1;
if (nonZeroCtr >= 2) {
decimalPrecision = idx + 1;
}
} else if (nonZeroCtr) {
decimalPrecision = idx;
break;
}
}
const numValue = parseFloat(value);
// Handle non-numeric or special values first.
if (isNaN(numValue)) return 'NaN';
if (numValue === Infinity) return '∞';
if (numValue === -Infinity) return '-∞';
const decimalPlaces = value.split('.')[1]?.length || undefined;
// Use custom formatter for the 'none' format honoring precision
if (format === 'none') {
return formatDecimalWithLeadingZeros(numValue, precision);
}
// For all other standard formats, delegate to grafana/data's built-in formatter.
const computeDecimals = (): number | undefined => {
if (precision === PrecisionOptionsEnum.FULL) {
return decimalPlaces && decimalPlaces >= DEFAULT_SIGNIFICANT_DIGITS
? decimalPlaces
: DEFAULT_SIGNIFICANT_DIGITS;
}
return precision;
};
return formattedValueToString(
getValueFormat(format)(
parseFloat(value),
decimalPrecision,
undefined,
undefined,
),
);
} catch (error) {
console.error(error);
}
return `${parseFloat(value)}`;
};
const fallbackFormat = (): string => {
if (precision === PrecisionOptionsEnum.FULL) return numValue.toString();
if (precision === 0) return Math.round(numValue).toString();
return precision !== undefined
? numValue
.toFixed(precision)
.replace(/(\.[0-9]*[1-9])0+$/, '$1') // trimming zeros
.replace(/\.$/, '')
: numValue.toString();
};
export const getToolTipValue = (value: string, format?: string): string => {
try {
return formattedValueToString(
getValueFormat(format)(parseFloat(value), undefined, undefined, undefined),
);
const formatter = getValueFormat(format);
const formattedValue = formatter(numValue, computeDecimals(), undefined);
if (formattedValue.text && formattedValue.text.includes('.')) {
formattedValue.text = formatDecimalWithLeadingZeros(
parseFloat(formattedValue.text),
precision,
);
}
return formattedValueToString(formattedValue);
} catch (error) {
console.error(error);
Sentry.captureEvent({
message: `Error applying formatter: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
level: 'error',
});
return fallbackFormat();
}
return `${value}`;
};
export const getToolTipValue = (
value: string | number,
format?: string,
precision?: PrecisionOption,
): string =>
getYAxisFormattedValue(value?.toString(), format || 'none', precision);

View File

@@ -60,6 +60,14 @@ function Metrics({
setElement,
} = useMultiIntersectionObserver(hostWidgetInfo.length, { threshold: 0.1 });
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const queryPayloads = useMemo(
() =>
getHostQueryPayload(
@@ -147,6 +155,13 @@ function Metrics({
maxTimeScale: graphTimeIntervals[idx].end,
onDragSelect: (start, end) => onDragSelect(start, end, idx),
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
),
[

View File

@@ -57,8 +57,8 @@ export const RawLogViewContainer = styled(Row)<{
transition: background-color 2s ease-in;`
: ''}
${({ $isCustomHighlighted, $isDarkMode, $logType }): string =>
getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)}
${({ $isCustomHighlighted }): string =>
getCustomHighlightBackground($isCustomHighlighted)}
`;
export const InfoIconWrapper = styled(Info)`

View File

@@ -86,6 +86,7 @@ export const REACT_QUERY_KEY = {
SPAN_LOGS: 'SPAN_LOGS',
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
TRACE_ONLY_LOGS: 'TRACE_ONLY_LOGS',
// Routing Policies Query Keys
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',

View File

@@ -69,6 +69,13 @@ function StatusCodeBarCharts({
} = endPointStatusCodeLatencyBarChartsDataQuery;
const { startTime: minTime, endTime: maxTime } = timeRange;
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
@@ -207,6 +214,13 @@ function StatusCodeBarCharts({
onDragSelect,
colorMapping,
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
[
minTime,

View File

@@ -171,3 +171,30 @@
}
}
}
.lightMode {
.empty-logs-search {
&__resources-card {
background: var(--bg-vanilla-100);
border: 1px solid var(--bg-vanilla-300);
}
&__resources-title {
color: var(--bg-ink-400);
}
&__resources-description,
&__description-list,
&__subtitle {
color: var(--bg-ink-300);
}
&__title {
color: var(--bg-ink-500);
}
&__clear-filters-btn {
border: 1px dashed var(--bg-vanilla-300);
color: var(--bg-ink-400);
}
}
}

View File

@@ -108,6 +108,13 @@ function ChartPreview({
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const { currentQuery } = useQueryBuilder();
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
@@ -296,6 +303,13 @@ function ChartPreview({
setGraphsVisibilityStates: setGraphVisibility,
enhancedLegend: true,
legendPosition,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
[
yAxisUnit,

View File

@@ -48,6 +48,7 @@ function GridTableComponent({
widgetId,
panelType,
queryRangeRequest,
decimalPrecision,
...props
}: GridTableComponentProps): JSX.Element {
const { t } = useTranslation(['valueGraph']);
@@ -87,10 +88,19 @@ function GridTableComponent({
const newValue = { ...val };
Object.keys(val).forEach((k) => {
const unit = getColumnUnit(k, columnUnits);
if (unit) {
// Apply formatting if:
// 1. Column has a unit defined, OR
// 2. decimalPrecision is specified (format all values)
const shouldFormat = unit || decimalPrecision !== undefined;
if (shouldFormat) {
// 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);
newValue[k] = getYAxisFormattedValue(
String(val[k]),
unit || 'none',
decimalPrecision,
);
} else if (val[k] === null) {
newValue[k] = 'n/a';
}
@@ -103,7 +113,7 @@ function GridTableComponent({
return mutateDataSource;
},
[columnUnits],
[columnUnits, decimalPrecision],
);
const dataSource = useMemo(() => applyColumnUnits(originalDataSource), [

View File

@@ -1,4 +1,5 @@
import { TableProps } from 'antd';
import { PrecisionOption } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
import {
@@ -15,6 +16,7 @@ export type GridTableComponentProps = {
query: Query;
thresholds?: ThresholdProps[];
columnUnits?: ColumnUnit;
decimalPrecision?: PrecisionOption;
tableProcessedDataRef?: React.MutableRefObject<RowData[]>;
sticky?: TableProps<RowData>['sticky'];
searchTerm?: string;

View File

@@ -99,7 +99,11 @@ function GridValueComponent({
rawValue={value}
value={
yAxisUnit
? getYAxisFormattedValue(String(value), yAxisUnit)
? getYAxisFormattedValue(
String(value),
yAxisUnit,
widget?.decimalPrecision,
)
: value.toString()
}
/>

View File

@@ -115,6 +115,13 @@ function EntityMetrics<T>({
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { currentQuery } = useQueryBuilder();
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const chartData = useMemo(
() =>
@@ -184,6 +191,13 @@ function EntityMetrics<T>({
maxTimeScale: graphTimeIntervals[idx].end,
onDragSelect: (start, end) => onDragSelect(start, end, idx),
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
});
}),
[

View File

@@ -83,6 +83,13 @@ function NodeMetrics({
const isDarkMode = useIsDarkMode();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const chartData = useMemo(
() => queries.map(({ data }) => getUPlotChartData(data?.payload)),
@@ -109,6 +116,13 @@ function NodeMetrics({
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
),
[

View File

@@ -45,6 +45,14 @@ function PodMetrics({
};
}, [logLineTimestamp]);
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
@@ -91,6 +99,13 @@ function PodMetrics({
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
timezone: timezone.value,
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
),
[

View File

@@ -158,7 +158,8 @@
}
}
.log-scale {
.log-scale,
.decimal-precision-selector {
margin-top: 16px;
display: flex;
justify-content: space-between;

View File

@@ -192,3 +192,17 @@ export const panelTypeVsContextLinks: {
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsDecimalPrecision: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: true,
[PANEL_TYPES.TABLE]: true,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: true,
[PANEL_TYPES.BAR]: true,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;

View File

@@ -12,6 +12,10 @@ import {
Switch,
Typography,
} from 'antd';
import {
PrecisionOption,
PrecisionOptionsEnum,
} from 'components/Graph/yAxisConfig';
import TimePreference from 'components/TimePreferenceDropDown';
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
import GraphTypes, {
@@ -48,6 +52,7 @@ import {
panelTypeVsColumnUnitPreferences,
panelTypeVsContextLinks,
panelTypeVsCreateAlert,
panelTypeVsDecimalPrecision,
panelTypeVsFillSpan,
panelTypeVsLegendColors,
panelTypeVsLegendPosition,
@@ -95,6 +100,8 @@ function RightContainer({
selectedTime,
yAxisUnit,
setYAxisUnit,
decimalPrecision,
setDecimalPrecision,
setGraphHandler,
thresholds,
combineHistogram,
@@ -160,6 +167,7 @@ function RightContainer({
panelTypeVsColumnUnitPreferences[selectedGraph];
const allowContextLinks =
panelTypeVsContextLinks[selectedGraph] && enableDrillDown;
const allowDecimalPrecision = panelTypeVsDecimalPrecision[selectedGraph];
const { currentQuery } = useQueryBuilder();
@@ -356,6 +364,30 @@ function RightContainer({
}
/>
)}
{allowDecimalPrecision && (
<section className="decimal-precision-selector">
<Typography.Text className="typography">
Decimal Precision
</Typography.Text>
<Select
options={[
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
{ label: '4 decimals', value: PrecisionOptionsEnum.FOUR },
{ label: 'Full Precision', value: PrecisionOptionsEnum.FULL },
]}
value={decimalPrecision}
style={{ width: '100%' }}
className="panel-type-select"
defaultValue={PrecisionOptionsEnum.TWO}
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
/>
</section>
)}
{allowSoftMinMax && (
<section className="soft-min-max">
<section className="container">
@@ -553,6 +585,8 @@ interface RightContainerProps {
setBucketWidth: Dispatch<SetStateAction<number>>;
setBucketCount: Dispatch<SetStateAction<number>>;
setYAxisUnit: Dispatch<SetStateAction<string>>;
decimalPrecision: PrecisionOption;
setDecimalPrecision: Dispatch<SetStateAction<PrecisionOption>>;
setGraphHandler: (type: PANEL_TYPES) => void;
thresholds: ThresholdProps[];
setThresholds: Dispatch<SetStateAction<ThresholdProps[]>>;

View File

@@ -4,6 +4,10 @@ import './NewWidget.styles.scss';
import { WarningOutlined } from '@ant-design/icons';
import { Button, Flex, Modal, Space, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import {
PrecisionOption,
PrecisionOptionsEnum,
} from 'components/Graph/yAxisConfig';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { adjustQueryForV5 } from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
@@ -178,6 +182,10 @@ function NewWidget({
selectedWidget?.yAxisUnit || 'none',
);
const [decimalPrecision, setDecimalPrecision] = useState<PrecisionOption>(
selectedWidget?.decimalPrecision ?? PrecisionOptionsEnum.TWO,
);
const [stackedBarChart, setStackedBarChart] = useState<boolean>(
selectedWidget?.stackedBarChart || false,
);
@@ -257,6 +265,7 @@ function NewWidget({
opacity,
nullZeroValues: selectedNullZeroValue,
yAxisUnit,
decimalPrecision,
thresholds,
softMin,
softMax,
@@ -290,6 +299,7 @@ function NewWidget({
thresholds,
title,
yAxisUnit,
decimalPrecision,
bucketWidth,
bucketCount,
combineHistogram,
@@ -493,6 +503,8 @@ function NewWidget({
title: selectedWidget?.title,
stackedBarChart: selectedWidget?.stackedBarChart || false,
yAxisUnit: selectedWidget?.yAxisUnit,
decimalPrecision:
selectedWidget?.decimalPrecision || PrecisionOptionsEnum.TWO,
panelTypes: graphType,
query: adjustedQueryForV5,
thresholds: selectedWidget?.thresholds,
@@ -522,6 +534,8 @@ function NewWidget({
title: selectedWidget?.title,
stackedBarChart: selectedWidget?.stackedBarChart || false,
yAxisUnit: selectedWidget?.yAxisUnit,
decimalPrecision:
selectedWidget?.decimalPrecision || PrecisionOptionsEnum.TWO,
panelTypes: graphType,
query: adjustedQueryForV5,
thresholds: selectedWidget?.thresholds,
@@ -836,6 +850,8 @@ function NewWidget({
setSelectedTime={setSelectedTime}
selectedTime={selectedTime}
setYAxisUnit={setYAxisUnit}
decimalPrecision={decimalPrecision}
setDecimalPrecision={setDecimalPrecision}
thresholds={thresholds}
setThresholds={setThresholds}
selectedWidget={selectedWidget}

View File

@@ -1,5 +1,6 @@
import { DefaultOptionType } from 'antd/es/select';
import { omitIdFromQuery } from 'components/ExplorerCard/utils';
import { PrecisionOptionsEnum } from 'components/Graph/yAxisConfig';
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
@@ -554,6 +555,7 @@ export const getDefaultWidgetData = (
softMax: null,
softMin: null,
stackedBarChart: name === PANEL_TYPES.BAR,
decimalPrecision: PrecisionOptionsEnum.TWO, // default decimal precision
selectedLogFields: defaultLogsSelectedColumns.map((field) => ({
...field,
type: field.fieldContext ?? '',

View File

@@ -347,6 +347,7 @@ function OnboardingAddDataSource(): JSX.Element {
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_SEARCHED}`,
{
searchedDataSource: query,
resultCount: filteredDataSources.length,
},
);
}, 300);

View File

@@ -26,6 +26,7 @@ function HistogramPanelWrapper({
enableDrillDown = false,
}: PanelWrapperProps): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const legendScrollPositionRef = useRef<number>(0);
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
const isDarkMode = useIsDarkMode();
const containerDimensions = useResizeObserver(graphRef);
@@ -129,6 +130,10 @@ function HistogramPanelWrapper({
onClickHandler: enableDrillDown
? clickHandlerWithContextMenu
: onClickHandler ?? _noop,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: number) => {
legendScrollPositionRef.current = position;
},
}),
[
containerDimensions,

View File

@@ -104,6 +104,7 @@ function PiePanelWrapper({
const formattedTotal = getYAxisFormattedValue(
totalValue.toString(),
widget?.yAxisUnit || 'none',
widget?.decimalPrecision,
);
// Extract numeric part and unit separately for styling
@@ -219,6 +220,7 @@ function PiePanelWrapper({
const displayValue = getYAxisFormattedValue(
arc.data.value,
widget?.yAxisUnit || 'none',
widget?.decimalPrecision,
);
// Determine text anchor based on position in the circle

View File

@@ -40,6 +40,7 @@ function TablePanelWrapper({
enableDrillDown={enableDrillDown}
panelType={widget.panelTypes}
queryRangeRequest={queryRangeRequest}
decimalPrecision={widget.decimalPrecision}
// eslint-disable-next-line react/jsx-props-no-spreading
{...GRID_TABLE_CONFIG}
/>

View File

@@ -249,6 +249,7 @@ function UplotPanelWrapper({
}) => {
legendScrollPositionRef.current = position;
},
decimalPrecision: widget.decimalPrecision,
}),
[
queryResponse.data?.payload,

View File

@@ -27,7 +27,7 @@ describe('Value panel wrappper tests', () => {
);
// selected y axis unit as miliseconds (ms)
expect(getByText('295')).toBeInTheDocument();
expect(getByText('295.43')).toBeInTheDocument();
expect(getByText('ms')).toBeInTheDocument();
});

View File

@@ -330,7 +330,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<div
class="line-clamped-wrapper__text"
>
431 ms
431.25 ms
</div>
</div>
</div>
@@ -368,7 +368,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<div
class="line-clamped-wrapper__text"
>
431 ms
431.25 ms
</div>
</div>
</div>
@@ -406,7 +406,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<div
class="line-clamped-wrapper__text"
>
287 ms
287.11 ms
</div>
</div>
</div>
@@ -444,7 +444,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<div
class="line-clamped-wrapper__text"
>
230 ms
230.02 ms
</div>
</div>
</div>
@@ -482,7 +482,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
<div
class="line-clamped-wrapper__text"
>
66.4 ms
66.37 ms
</div>
</div>
</div>

View File

@@ -51,7 +51,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
class="ant-typography value-graph-text css-dev-only-do-not-override-2i2tap"
style="color: Blue; font-size: 16px;"
>
295
295.43
</span>
<span
class="ant-typography value-graph-unit css-dev-only-do-not-override-2i2tap"

View File

@@ -0,0 +1,289 @@
import { getLegend } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { getMockQuery, getMockQueryData } from './testUtils';
const mockQueryData = getMockQueryData();
const mockQuery = getMockQuery();
const MOCK_LABEL_NAME = 'mock-label-name';
describe('getLegend', () => {
it('should directly return the label name for clickhouse query', () => {
const legendsData = getLegend(
mockQueryData,
getMockQuery({
queryType: EQueryType.CLICKHOUSE,
}),
MOCK_LABEL_NAME,
);
expect(legendsData).toBeDefined();
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it('should directly return the label name for promql query', () => {
const legendsData = getLegend(
mockQueryData,
getMockQuery({
queryType: EQueryType.PROM,
}),
MOCK_LABEL_NAME,
);
expect(legendsData).toBeDefined();
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it('should return alias when single builder query with single aggregation and alias (logs)', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [{ expression: "sum(bytes) as 'alias_sum'" }],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe('alias_sum');
});
it('should return legend when single builder query with no alias but legend set (builder)', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
legend: 'custom-legend',
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe('custom-legend');
});
it('should return label when grouped by with single aggregation (builder)', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it("should return '<alias>-<label>' when grouped by with multiple aggregations (builder)", () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'sum_b'" },
{ expression: 'count()' },
],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`sum_b-${MOCK_LABEL_NAME}`);
});
it('should fallback to label or query name when no alias/expression', () => {
const legendsData = getLegend(mockQueryData, mockQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(MOCK_LABEL_NAME);
});
it('should return alias when single query with multiple aggregations and no group by', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'total'" },
{ expression: 'count()' },
],
groupBy: [],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe('total');
});
it("should return '<alias>-<label>' when multiple queries with group by", () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'sum_b'" },
{ expression: 'count()' },
],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
{
...mockQuery.builder.queryData[0],
queryName: 'B',
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`sum_b-${MOCK_LABEL_NAME}`);
});
it('should return label according to the index of the query', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [
{ expression: "sum(bytes) as 'sum_a'" },
{ expression: 'count()' },
],
groupBy: [
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
],
},
{
...mockQuery.builder.queryData[0],
queryName: 'B',
dataSource: DataSource.LOGS,
aggregations: [{ expression: 'count()' }],
},
],
},
});
const legendsData = getLegend(
{
...mockQueryData,
metaData: {
...mockQueryData.metaData,
index: 1,
},
} as QueryData,
payloadQuery,
MOCK_LABEL_NAME,
);
expect(legendsData).toBe(`count()-${MOCK_LABEL_NAME}`);
});
it('should handle trace operator with multiple queries and group by', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: 'A',
dataSource: DataSource.TRACES,
aggregations: [{ expression: 'count()' }],
},
],
queryTraceOperator: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.TRACES,
aggregations: [
{ expression: "count() as 'total_count' avg(duration_nano)" },
],
groupBy: [
{ key: 'service.name', dataType: DataTypes.String, type: 'resource' },
],
expression: 'A',
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`total_count-${MOCK_LABEL_NAME}`);
});
it('should handle single trace operator query with group by', () => {
const payloadQuery = getMockQuery({
...mockQuery,
builder: {
...mockQuery.builder,
queryData: [],
queryTraceOperator: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.TRACES,
aggregations: [{ expression: "count() as 'total' avg(duration_nano)" }],
groupBy: [
{ key: 'service.name', dataType: DataTypes.String, type: 'resource' },
],
expression: 'A && B',
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe(`total-${MOCK_LABEL_NAME}`);
});
});

View File

@@ -0,0 +1,118 @@
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
import uPlot from 'uplot';
// Mock dependencies
jest.mock('lib/uPlotLib/plugins/tooltipPlugin', () => jest.fn(() => ({})));
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => jest.fn(() => ({})));
const mockApiResponse = {
data: {
result: [
{
metric: { __name__: 'test_metric' },
queryName: 'test_query',
values: [[1640995200, '10'] as [number, string]],
},
],
resultType: 'time_series',
newResult: {
data: {
result: [],
resultType: 'time_series',
},
},
},
};
const mockDimensions = { width: 800, height: 400 };
const mockHistogramData: uPlot.AlignedData = [[1640995200], [10]];
const TEST_HISTOGRAM_ID = 'test-histogram';
describe('Histogram Chart Options Legend Scroll Position', () => {
let originalRequestAnimationFrame: typeof global.requestAnimationFrame;
beforeEach(() => {
jest.clearAllMocks();
originalRequestAnimationFrame = global.requestAnimationFrame;
});
afterEach(() => {
global.requestAnimationFrame = originalRequestAnimationFrame;
});
it('should set up scroll position tracking in histogram chart ready hook', () => {
const mockSetScrollPosition = jest.fn();
const options = getUplotHistogramChartOptions({
id: TEST_HISTOGRAM_ID,
dimensions: mockDimensions,
isDarkMode: false,
apiResponse: mockApiResponse,
histogramData: mockHistogramData,
legendScrollPosition: 0,
setLegendScrollPosition: mockSetScrollPosition,
});
// Create mock chart with legend element
const mockChart = ({
root: document.createElement('div'),
} as unknown) as uPlot;
const legend = document.createElement('div');
legend.className = 'u-legend';
mockChart.root.appendChild(legend);
const addEventListenerSpy = jest.spyOn(legend, 'addEventListener');
// Execute ready hook
if (options.hooks?.ready) {
options.hooks.ready.forEach((hook) => hook?.(mockChart));
}
// Verify that scroll event listener was added and cleanup function was stored
expect(addEventListenerSpy).toHaveBeenCalledWith(
'scroll',
expect.any(Function),
);
expect(
(mockChart as uPlot & { _legendScrollCleanup?: () => void })
._legendScrollCleanup,
).toBeDefined();
});
it('should restore histogram chart scroll position when provided', () => {
const mockScrollPosition = 50;
const mockSetScrollPosition = jest.fn();
const options = getUplotHistogramChartOptions({
id: TEST_HISTOGRAM_ID,
dimensions: mockDimensions,
isDarkMode: false,
apiResponse: mockApiResponse,
histogramData: mockHistogramData,
legendScrollPosition: mockScrollPosition,
setLegendScrollPosition: mockSetScrollPosition,
});
// Create mock chart with legend element
const mockChart = ({
root: document.createElement('div'),
} as unknown) as uPlot;
const legend = document.createElement('div');
legend.className = 'u-legend';
legend.scrollTop = 0;
mockChart.root.appendChild(legend);
// Mock requestAnimationFrame
const mockRequestAnimationFrame = jest.fn((callback) => callback());
global.requestAnimationFrame = mockRequestAnimationFrame;
// Execute ready hook
if (options.hooks?.ready) {
options.hooks.ready.forEach((hook) => hook?.(mockChart));
}
// Verify that requestAnimationFrame was called and scroll position was restored
expect(mockRequestAnimationFrame).toHaveBeenCalledWith(expect.any(Function));
expect(legend.scrollTop).toBe(mockScrollPosition);
});
});

View File

@@ -0,0 +1,36 @@
import { initialQueryState } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { EQueryType } from 'types/common/dashboard';
export function getMockQueryData(): QueryData {
return {
lowerBoundSeries: [],
upperBoundSeries: [],
predictedSeries: [],
anomalyScores: [],
metric: {},
queryName: 'test-query-name',
legend: 'test-legend',
values: [],
quantity: [],
unit: 'test-unit',
table: {
rows: [],
columns: [],
},
metaData: {
alias: 'test-alias',
index: 0,
queryName: 'test-query-name',
},
};
}
export function getMockQuery(overrides?: Partial<Query>): Query {
return {
...initialQueryState,
queryType: EQueryType.QUERY_BUILDER,
...overrides,
};
}

View File

@@ -12,7 +12,9 @@ import {
PANEL_TYPES,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import LogsError from 'container/LogsError/LogsError';
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
@@ -30,8 +32,6 @@ import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { v4 as uuid } from 'uuid';
import { useSpanContextLogs } from './useSpanContextLogs';
interface SpanLogsProps {
traceId: string;
spanId: string;
@@ -39,29 +39,29 @@ interface SpanLogsProps {
startTime: number;
endTime: number;
};
logs: ILog[];
isLoading: boolean;
isError: boolean;
isFetching: boolean;
isLogSpanRelated: (logId: string) => boolean;
handleExplorerPageRedirect: () => void;
emptyStateConfig?: EmptyLogsListConfig;
}
function SpanLogs({
traceId,
spanId,
timeRange,
logs,
isLoading,
isError,
isFetching,
isLogSpanRelated,
handleExplorerPageRedirect,
emptyStateConfig,
}: SpanLogsProps): JSX.Element {
const { updateAllQueriesOperators } = useQueryBuilder();
const {
logs,
isLoading,
isError,
isFetching,
isLogSpanRelated,
} = useSpanContextLogs({
traceId,
spanId,
timeRange,
});
// Create trace_id and span_id filters for logs explorer navigation
const createLogsFilter = useCallback(
(targetSpanId: string): TagFilter => {
@@ -236,9 +236,7 @@ function SpanLogs({
<img src="/Icons/no-data.svg" alt="no-data" className="no-data-img" />
<Typography.Text className="no-data-text-1">
No logs found for selected span.
<span className="no-data-text-2">
Try viewing logs for the current trace.
</span>
<span className="no-data-text-2">View logs for the current trace.</span>
</Typography.Text>
</section>
<section className="action-section">
@@ -249,24 +247,45 @@ function SpanLogs({
onClick={handleExplorerPageRedirect}
size="md"
>
Log Explorer
View Logs
</Button>
</section>
</div>
);
const renderSpanLogsContent = (): JSX.Element | null => {
if (isLoading || isFetching) {
return <LogsLoading />;
}
if (isError) {
return <LogsError />;
}
if (logs.length === 0) {
if (emptyStateConfig) {
return (
<EmptyLogsSearch
dataSource={DataSource.LOGS}
panelType="LIST"
customMessage={emptyStateConfig}
/>
);
}
return renderNoLogsFound();
}
return renderContent;
};
return (
<div className={cx('span-logs', { 'span-logs-empty': logs.length === 0 })}>
{(isLoading || isFetching) && <LogsLoading />}
{!isLoading &&
!isFetching &&
!isError &&
logs.length === 0 &&
renderNoLogsFound()}
{isError && !isLoading && !isFetching && <LogsError />}
{!isLoading && !isFetching && !isError && logs.length > 0 && renderContent}
{renderSpanLogsContent()}
</div>
);
}
SpanLogs.defaultProps = {
emptyStateConfig: undefined,
};
export default SpanLogs;

View File

@@ -0,0 +1,214 @@
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import SpanLogs from '../SpanLogs';
// Mock external dependencies
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: jest.fn().mockReturnValue({
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: 'noop',
filter: { expression: "trace_id = 'test-trace-id'" },
expression: 'A',
disabled: false,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
groupBy: [],
limit: null,
having: [],
},
],
queryFormulas: [],
},
queryType: 'builder',
}),
}),
}));
// Mock window.open
const mockWindowOpen = jest.fn();
Object.defineProperty(window, 'open', {
writable: true,
value: mockWindowOpen,
});
// Mock Virtuoso to avoid complex virtualization
jest.mock('react-virtuoso', () => ({
Virtuoso: jest.fn(({ data, itemContent }: any) => (
<div data-testid="virtuoso">
{data?.map((item: any, index: number) => (
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
{itemContent(index, item)}
</div>
))}
</div>
)),
}));
// Mock RawLogView component
jest.mock(
'components/Logs/RawLogView',
() =>
function MockRawLogView({
data,
onLogClick,
isHighlighted,
helpTooltip,
}: any): JSX.Element {
return (
<button
type="button"
data-testid={`raw-log-${data.id}`}
className={isHighlighted ? 'log-highlighted' : 'log-context'}
title={helpTooltip}
onClick={(e): void => onLogClick?.(data, e)}
>
<div>{data.body}</div>
<div>{data.timestamp}</div>
</button>
);
},
);
// Mock PreferenceContextProvider
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({ children }: any): JSX.Element => (
<div>{children}</div>
),
}));
// Mock OverlayScrollbar
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
default: ({ children }: any): JSX.Element => (
<div data-testid="overlay-scrollbar">{children}</div>
),
}));
// Mock LogsLoading component
jest.mock('container/LogsLoading/LogsLoading', () => ({
LogsLoading: function MockLogsLoading(): JSX.Element {
return <div data-testid="logs-loading">Loading logs...</div>;
},
}));
// Mock LogsError component
jest.mock(
'container/LogsError/LogsError',
() =>
function MockLogsError(): JSX.Element {
return <div data-testid="logs-error">Error loading logs</div>;
},
);
// Don't mock EmptyLogsSearch - test the actual component behavior
const TEST_TRACE_ID = 'test-trace-id';
const TEST_SPAN_ID = 'test-span-id';
const defaultProps = {
traceId: TEST_TRACE_ID,
spanId: TEST_SPAN_ID,
timeRange: {
startTime: 1640995200000,
endTime: 1640995260000,
},
logs: [],
isLoading: false,
isError: false,
isFetching: false,
isLogSpanRelated: jest.fn().mockReturnValue(false),
handleExplorerPageRedirect: jest.fn(),
};
describe('SpanLogs', () => {
beforeEach(() => {
jest.clearAllMocks();
mockWindowOpen.mockClear();
});
afterEach(() => {
server.resetHandlers();
});
it('should show simple empty state when emptyStateConfig is not provided', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<SpanLogs {...defaultProps} />);
// Should show simple empty state (no emptyStateConfig provided)
expect(
screen.getByText('No logs found for selected span.'),
).toBeInTheDocument();
expect(
screen.getByText('View logs for the current trace.'),
).toBeInTheDocument();
expect(
screen.getByRole('button', {
name: /view logs/i,
}),
).toBeInTheDocument();
// Should NOT show enhanced empty state
expect(screen.queryByTestId('empty-logs-search')).not.toBeInTheDocument();
expect(screen.queryByTestId('documentation-links')).not.toBeInTheDocument();
});
it('should show enhanced empty state when entire trace has no logs', () => {
render(
<SpanLogs
// eslint-disable-next-line react/jsx-props-no-spreading
{...defaultProps}
emptyStateConfig={getEmptyLogsListConfig(jest.fn())}
/>,
);
// Should show enhanced empty state with custom message
expect(screen.getByText('No logs found for this trace.')).toBeInTheDocument();
expect(screen.getByText('This could be because :')).toBeInTheDocument();
// Should show description list
expect(
screen.getByText('Logs are not linked to Traces.'),
).toBeInTheDocument();
expect(
screen.getByText('Logs are not being sent to SigNoz.'),
).toBeInTheDocument();
expect(
screen.getByText('No logs are associated with this particular trace/span.'),
).toBeInTheDocument();
// Should show documentation links
expect(screen.getByText('RESOURCES')).toBeInTheDocument();
expect(screen.getByText('Sending logs to SigNoz')).toBeInTheDocument();
expect(screen.getByText('Correlate traces and logs')).toBeInTheDocument();
// Should NOT show simple empty state
expect(
screen.queryByText('No logs found for selected span.'),
).not.toBeInTheDocument();
});
it('should call handleExplorerPageRedirect when Log Explorer button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockHandleExplorerPageRedirect = jest.fn();
render(
<SpanLogs
// eslint-disable-next-line react/jsx-props-no-spreading
{...defaultProps}
handleExplorerPageRedirect={mockHandleExplorerPageRedirect}
/>,
);
const logExplorerButton = screen.getByRole('button', {
name: /view logs/i,
});
await user.click(logExplorerButton);
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
});
});

View File

@@ -85,7 +85,7 @@ export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
type: '',
key: 'trace_id',
},
op: 'in',
op: '=',
value: traceId,
},
],

View File

@@ -11,7 +11,7 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Filter } from 'types/api/v5/queryRange';
import { v4 as uuid } from 'uuid';
import { getSpanLogsQueryPayload } from './constants';
import { getSpanLogsQueryPayload, getTraceOnlyFilters } from './constants';
interface UseSpanContextLogsProps {
traceId: string;
@@ -20,6 +20,7 @@ interface UseSpanContextLogsProps {
startTime: number;
endTime: number;
};
isDrawerOpen?: boolean;
}
interface UseSpanContextLogsReturn {
@@ -29,6 +30,7 @@ interface UseSpanContextLogsReturn {
isFetching: boolean;
spanLogIds: Set<string>;
isLogSpanRelated: (logId: string) => boolean;
hasTraceIdLogs: boolean;
}
const traceIdKey = {
@@ -110,6 +112,7 @@ export const useSpanContextLogs = ({
traceId,
spanId,
timeRange,
isDrawerOpen = true,
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
const [allLogs, setAllLogs] = useState<ILog[]>([]);
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
@@ -264,6 +267,43 @@ export const useSpanContextLogs = ({
setAllLogs(combined);
}, [beforeLogs, spanLogs, afterLogs]);
// Phase 4: Check for trace_id-only logs when span has no logs
// This helps differentiate between "no logs for span" vs "no logs for trace"
const traceOnlyFilter = useMemo(() => {
if (spanLogs.length > 0) return null;
const filters = getTraceOnlyFilters(traceId);
return convertFiltersToExpression(filters);
}, [traceId, spanLogs.length]);
const traceOnlyQueryPayload = useMemo(() => {
if (!traceOnlyFilter) return null;
return getSpanLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
traceOnlyFilter,
);
}, [timeRange.startTime, timeRange.endTime, traceOnlyFilter]);
const { data: traceOnlyData } = useQuery({
queryKey: [
REACT_QUERY_KEY.TRACE_ONLY_LOGS,
traceId,
timeRange.startTime,
timeRange.endTime,
],
queryFn: () =>
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
staleTime: FIVE_MINUTES_IN_MS,
});
const hasTraceIdLogs = useMemo(() => {
if (spanLogs.length > 0) return true;
return !!(
traceOnlyData?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0
);
}, [spanLogs.length, traceOnlyData]);
// Helper function to check if a log belongs to the span
const isLogSpanRelated = useCallback(
(logId: string): boolean => spanLogIds.has(logId),
@@ -277,5 +317,6 @@ export const useSpanContextLogs = ({
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
spanLogIds,
isLogSpanRelated,
hasTraceIdLogs,
};
};

View File

@@ -37,7 +37,8 @@
align-items: center;
justify-content: space-between;
.open-in-explorer {
width: 30px;
display: flex;
align-items: center;
height: 30px;
border-radius: 2px;
border: 1px solid var(--bg-slate-400);

View File

@@ -11,39 +11,20 @@ import {
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass, X } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { Span } from 'types/api/trace/getTraceV2';
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
import { RelatedSignalsViews } from '../constants';
import SpanLogs from '../SpanLogs/SpanLogs';
import { useSpanContextLogs } from '../SpanLogs/useSpanContextLogs';
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
interface AppliedFiltersProps {
filters: TagFilterItem[];
}
function AppliedFilters({ filters }: AppliedFiltersProps): JSX.Element {
return (
<div className="span-related-signals-drawer__applied-filters">
<div className="span-related-signals-drawer__filters-list">
{filters.map((filter) => (
<div key={filter.id} className="span-related-signals-drawer__filter-tag">
<Typography.Text>
{filter.key?.key}={filter.value}
</Typography.Text>
</div>
))}
</div>
</div>
);
}
interface SpanRelatedSignalsProps {
selectedSpan: Span;
traceStartTime: number;
@@ -66,6 +47,23 @@ function SpanRelatedSignals({
);
const isDarkMode = useIsDarkMode();
const {
logs,
isLoading,
isError,
isFetching,
isLogSpanRelated,
hasTraceIdLogs,
} = useSpanContextLogs({
traceId: selectedSpan.traceId,
spanId: selectedSpan.spanId,
timeRange: {
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
},
isDrawerOpen: isOpen,
});
const handleTabChange = useCallback((e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
}, []);
@@ -75,25 +73,6 @@ function SpanRelatedSignals({
onClose();
}, [onClose]);
const appliedFilters = useMemo(
(): TagFilterItem[] => [
{
id: 'trace-id-filter',
key: {
key: 'trace_id',
id: 'trace-id-key',
dataType: 'string' as const,
isColumn: true,
type: '',
isJSON: false,
} as BaseAutocompleteData,
op: '=',
value: selectedSpan.traceId,
},
],
[selectedSpan.traceId],
);
const handleExplorerPageRedirect = useCallback((): void => {
const startTimeMs = traceStartTime - FIVE_MINUTES_IN_MS;
const endTimeMs = traceEndTime + FIVE_MINUTES_IN_MS;
@@ -146,6 +125,14 @@ function SpanRelatedSignals({
);
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
const emptyStateConfig = useMemo(
() => ({
...getEmptyLogsListConfig(() => {}),
showClearFiltersButton: false,
}),
[],
);
return (
<Drawer
width="50%"
@@ -210,23 +197,28 @@ function SpanRelatedSignals({
icon={<Compass size={18} />}
className="open-in-explorer"
onClick={handleExplorerPageRedirect}
/>
>
Open in Logs Explorer
</Button>
)}
</div>
{selectedView === RelatedSignalsViews.LOGS && (
<>
<AppliedFilters filters={appliedFilters} />
<SpanLogs
traceId={selectedSpan.traceId}
spanId={selectedSpan.spanId}
timeRange={{
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
}}
handleExplorerPageRedirect={handleExplorerPageRedirect}
/>
</>
<SpanLogs
traceId={selectedSpan.traceId}
spanId={selectedSpan.spanId}
timeRange={{
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
}}
logs={logs}
isLoading={isLoading}
isError={isError}
isFetching={isFetching}
isLogSpanRelated={isLogSpanRelated}
handleExplorerPageRedirect={handleExplorerPageRedirect}
emptyStateConfig={!hasTraceIdLogs ? emptyStateConfig : undefined}
/>
)}
</div>
)}

View File

@@ -16,6 +16,7 @@ import {
expectedAfterFilterExpression,
expectedBeforeFilterExpression,
expectedSpanFilterExpression,
expectedTraceOnlyFilterExpression,
mockAfterLogsResponse,
mockBeforeLogsResponse,
mockEmptyLogsResponse,
@@ -217,19 +218,22 @@ const renderSpanDetailsDrawer = (props = {}): void => {
};
describe('SpanDetailsDrawer', () => {
let apiCallHistory: any[] = [];
let apiCallHistory: any = {};
beforeEach(() => {
jest.clearAllMocks();
apiCallHistory = [];
apiCallHistory = {
span_logs: null,
before_logs: null,
after_logs: null,
trace_only_logs: null,
};
mockSafeNavigate.mockClear();
mockWindowOpen.mockClear();
mockUpdateAllQueriesOperators.mockClear();
// Setup API call tracking
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
apiCallHistory.push(query);
// Determine response based on v5 filter expressions
const filterExpression =
query.query?.builder?.queryData?.[0]?.filter?.expression;
@@ -238,14 +242,23 @@ describe('SpanDetailsDrawer', () => {
// Check for span logs query (contains both trace_id and span_id)
if (filterExpression.includes('span_id')) {
apiCallHistory.span_logs = query;
return Promise.resolve(mockSpanLogsResponse);
}
// Check for before logs query (contains trace_id and id <)
if (filterExpression.includes('id <')) {
apiCallHistory.before_logs = query;
return Promise.resolve(mockBeforeLogsResponse);
}
// Check for after logs query (contains trace_id and id >)
if (filterExpression.includes('id >')) {
apiCallHistory.after_logs = query;
return Promise.resolve(mockAfterLogsResponse);
}
// Check for trace only logs query (contains trace_id)
if (filterExpression.includes('trace_id =')) {
apiCallHistory.trace_only_logs = query;
return Promise.resolve(mockAfterLogsResponse);
}
@@ -287,7 +300,7 @@ describe('SpanDetailsDrawer', () => {
});
});
it('should make three API queries when logs tab is opened', async () => {
it('should make 4 API queries when logs tab is opened', async () => {
renderSpanDetailsDrawer();
// Click on logs tab to trigger API calls
@@ -296,11 +309,16 @@ describe('SpanDetailsDrawer', () => {
// Wait for all API calls to complete
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
});
// Verify the three distinct queries were made
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
// Verify the four distinct queries were made
const {
span_logs: spanQuery,
before_logs: beforeQuery,
after_logs: afterQuery,
trace_only_logs: traceOnlyQuery,
} = apiCallHistory;
// 1. Span logs query (trace_id + span_id)
expect(spanQuery.query.builder.queryData[0].filter.expression).toBe(
@@ -316,6 +334,11 @@ describe('SpanDetailsDrawer', () => {
expect(afterQuery.query.builder.queryData[0].filter.expression).toBe(
expectedAfterFilterExpression,
);
// 4. Trace only logs query (trace_id)
expect(traceOnlyQuery.query.builder.queryData[0].filter.expression).toBe(
expectedTraceOnlyFilterExpression,
);
});
it('should use correct timestamp ordering for different query types', async () => {
@@ -327,10 +350,14 @@ describe('SpanDetailsDrawer', () => {
// Wait for all API calls to complete
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
});
const [spanQuery, beforeQuery, afterQuery] = apiCallHistory;
const {
span_logs: spanQuery,
before_logs: beforeQuery,
after_logs: afterQuery,
} = apiCallHistory;
// Verify ordering: span query should use 'desc' (default)
expect(spanQuery.query.builder.queryData[0].orderBy[0].order).toBe('desc');
@@ -463,24 +490,6 @@ describe('SpanDetailsDrawer', () => {
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('should handle empty logs state', async () => {
// Mock empty response for all queries
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockEmptyLogsResponse);
renderSpanDetailsDrawer();
// Open logs view
const logsButton = screen.getByRole('radio', { name: /logs/i });
fireEvent.click(logsButton);
// Wait and verify empty state is shown
await waitFor(() => {
expect(
screen.getByText(/No logs found for selected span/),
).toBeInTheDocument();
});
});
it('should display span logs as highlighted and context logs as regular', async () => {
renderSpanDetailsDrawer();
@@ -490,7 +499,7 @@ describe('SpanDetailsDrawer', () => {
// Wait for all API calls to complete first
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
expect(GetMetricQueryRange).toHaveBeenCalledTimes(4);
});
// Wait for all logs to be rendered - both span logs and context logs

View File

@@ -12,7 +12,7 @@ export const mockSpan: Span = {
traceId: TEST_TRACE_ID,
name: TEST_SERVICE,
serviceName: TEST_SERVICE,
timestamp: 1640995200000000, // 2022-01-01 00:00:00 in microseconds
timestamp: 1640995200000, // 2022-01-01 00:00:00 in milliseconds
durationNano: 1000000000, // 1 second in nanoseconds
spanKind: 'server',
statusCodeString: 'STATUS_CODE_OK',
@@ -207,3 +207,4 @@ export const mockEmptyLogsResponse = {
export const expectedSpanFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND span_id = '${TEST_SPAN_ID}'`;
export const expectedBeforeFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id < 'span-log-1'`;
export const expectedAfterFilterExpression = `trace_id = '${TEST_TRACE_ID}' AND id > 'span-log-2'`;
export const expectedTraceOnlyFilterExpression = `trace_id = '${TEST_TRACE_ID}'`;

View File

@@ -81,6 +81,13 @@ function TimeSeriesView({
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
AppState,
@@ -203,6 +210,13 @@ function TimeSeriesView({
setGraphsVisibilityStates: setGraphVisibility,
enhancedLegend: true,
legendPosition: LegendPosition.BOTTOM,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
});
return (

View File

@@ -21,7 +21,7 @@ import { convertNewDataToOld } from 'lib/newQueryBuilder/convertNewDataToOld';
import { isEmpty } from 'lodash-es';
import { SuccessResponse, SuccessResponseV2, Warning } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { prepareQueryRangePayload } from './prepareQueryRangePayload';
@@ -76,14 +76,13 @@ const getQueryDataSource = (
const getLegendForSingleAggregation = (
queryData: QueryData,
payloadQuery: Query,
allQueries: IBuilderQuery[],
aggregationAlias: string,
aggregationExpression: string,
labelName: string,
singleAggregation: boolean,
) => {
// Find the corresponding query in payloadQuery
const queryItem = payloadQuery.builder?.queryData.find(
const queryItem = allQueries.find(
(query) => query.queryName === queryData.queryName,
);
@@ -108,14 +107,13 @@ const getLegendForSingleAggregation = (
const getLegendForMultipleAggregations = (
queryData: QueryData,
payloadQuery: Query,
allQueries: IBuilderQuery[],
aggregationAlias: string,
aggregationExpression: string,
labelName: string,
singleAggregation: boolean,
) => {
// Find the corresponding query in payloadQuery
const queryItem = payloadQuery.builder?.queryData.find(
const queryItem = allQueries.find(
(query) => query.queryName === queryData.queryName,
);
@@ -148,15 +146,18 @@ export const getLegend = (
return labelName;
}
const aggregationPerQuery = payloadQuery?.builder?.queryData.reduce(
(acc, query) => {
if (query.queryName === queryData.queryName) {
acc[query.queryName] = createAggregation(query);
}
return acc;
},
{},
);
// Combine queryData and queryTraceOperator
const allQueries = [
...(payloadQuery?.builder?.queryData || []),
...(payloadQuery?.builder?.queryTraceOperator || []),
];
const aggregationPerQuery = allQueries.reduce((acc, query) => {
if (query.queryName === queryData.queryName) {
acc[query.queryName] = createAggregation(query);
}
return acc;
}, {});
const metaData = queryData?.metaData;
const aggregation =
@@ -165,8 +166,8 @@ export const getLegend = (
const aggregationAlias = aggregation?.alias || '';
const aggregationExpression = aggregation?.expression || '';
// Check if there's only one total query (queryData)
const singleQuery = payloadQuery?.builder?.queryData?.length === 1;
// Check if there's only one total query
const singleQuery = allQueries.length === 1;
const singleAggregation =
aggregationPerQuery?.[metaData?.queryName]?.length === 1;
@@ -174,7 +175,7 @@ export const getLegend = (
return singleQuery
? getLegendForSingleAggregation(
queryData,
payloadQuery,
allQueries,
aggregationAlias,
aggregationExpression,
labelName,
@@ -182,7 +183,7 @@ export const getLegend = (
)
: getLegendForMultipleAggregations(
queryData,
payloadQuery,
allQueries,
aggregationAlias,
aggregationExpression,
labelName,

View File

@@ -47,6 +47,7 @@ export interface GetUPlotChartOptions {
panelType?: PANEL_TYPES;
onDragSelect?: (startTime: number, endTime: number) => void;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
onClickHandler?: OnClickPluginOpts['onClick'];
graphsVisibilityStates?: boolean[];
setGraphsVisibilityStates?: FullViewProps['setGraphsVisibilityStates'];
@@ -192,6 +193,7 @@ export const getUPlotChartOptions = ({
apiResponse,
onDragSelect,
yAxisUnit,
decimalPrecision,
minTimeScale,
maxTimeScale,
onClickHandler = _noop,
@@ -359,6 +361,7 @@ export const getUPlotChartOptions = ({
colorMapping,
customTooltipElement,
query: query || currentQuery,
decimalPrecision,
}),
onClickPlugin({
onClick: onClickHandler,

View File

@@ -17,6 +17,11 @@ import { drawStyles } from './utils/constants';
import { generateColor } from './utils/generateColor';
import getAxes from './utils/getAxes';
// Extended uPlot interface with custom properties
interface ExtendedUPlot extends uPlot {
_legendScrollCleanup?: () => void;
}
type GetUplotHistogramChartOptionsProps = {
id?: string;
apiResponse?: MetricRangePayloadProps;
@@ -30,6 +35,8 @@ type GetUplotHistogramChartOptionsProps = {
setGraphsVisibilityStates?: Dispatch<SetStateAction<boolean[]>>;
mergeAllQueries?: boolean;
onClickHandler?: OnClickPluginOpts['onClick'];
legendScrollPosition?: number;
setLegendScrollPosition?: (position: number) => void;
};
type GetHistogramSeriesProps = {
@@ -124,6 +131,8 @@ export const getUplotHistogramChartOptions = ({
mergeAllQueries,
onClickHandler = _noop,
panelType,
legendScrollPosition,
setLegendScrollPosition,
}: GetUplotHistogramChartOptionsProps): uPlot.Options =>
({
id,
@@ -179,33 +188,94 @@ export const getUplotHistogramChartOptions = ({
(self): void => {
const legend = self.root.querySelector('.u-legend');
if (legend) {
const legendElement = legend as HTMLElement;
// Enhanced legend scroll position preservation
if (setLegendScrollPosition && typeof legendScrollPosition === 'number') {
const handleScroll = (): void => {
setLegendScrollPosition(legendElement.scrollTop);
};
// Add scroll event listener to save position
legendElement.addEventListener('scroll', handleScroll);
// Restore scroll position
requestAnimationFrame(() => {
legendElement.scrollTop = legendScrollPosition;
});
// Store cleanup function
const extSelf = self as ExtendedUPlot;
extSelf._legendScrollCleanup = (): void => {
legendElement.removeEventListener('scroll', handleScroll);
};
}
const seriesEls = legend.querySelectorAll('.u-series');
const seriesArray = Array.from(seriesEls);
seriesArray.forEach((seriesEl, index) => {
seriesEl.addEventListener('click', () => {
if (graphsVisibilityStates) {
setGraphsVisibilityStates?.((prev) => {
const newGraphVisibilityStates = [...prev];
if (
newGraphVisibilityStates[index + 1] &&
newGraphVisibilityStates.every((value, i) =>
i === index + 1 ? value : !value,
)
) {
newGraphVisibilityStates.fill(true);
} else {
newGraphVisibilityStates.fill(false);
newGraphVisibilityStates[index + 1] = true;
// Add click handlers for marker and text separately
const thElement = seriesEl.querySelector('th');
if (thElement) {
const currentMarker = thElement.querySelector('.u-marker');
const textElement =
thElement.querySelector('.legend-text') || thElement;
// Marker click handler - checkbox behavior (toggle individual series)
if (currentMarker) {
currentMarker.addEventListener('click', (e) => {
e.stopPropagation?.(); // Prevent event bubbling to text handler
if (graphsVisibilityStates) {
setGraphsVisibilityStates?.((prev) => {
const newGraphVisibilityStates = [...prev];
// Toggle the specific series visibility (checkbox behavior)
newGraphVisibilityStates[index + 1] = !newGraphVisibilityStates[
index + 1
];
saveLegendEntriesToLocalStorage({
options: self,
graphVisibilityState: newGraphVisibilityStates,
name: id || '',
});
return newGraphVisibilityStates;
});
}
saveLegendEntriesToLocalStorage({
options: self,
graphVisibilityState: newGraphVisibilityStates,
name: id || '',
});
return newGraphVisibilityStates;
});
}
});
// Text click handler - show only/show all behavior (existing behavior)
textElement.addEventListener('click', (e) => {
e.stopPropagation?.(); // Prevent event bubbling
if (graphsVisibilityStates) {
setGraphsVisibilityStates?.((prev) => {
const newGraphVisibilityStates = [...prev];
// Show only this series / show all behavior
if (
newGraphVisibilityStates[index + 1] &&
newGraphVisibilityStates.every((value, i) =>
i === index + 1 ? value : !value,
)
) {
// If only this series is visible, show all
newGraphVisibilityStates.fill(true);
} else {
// Otherwise, show only this series
newGraphVisibilityStates.fill(false);
newGraphVisibilityStates[index + 1] = true;
}
saveLegendEntriesToLocalStorage({
options: self,
graphVisibilityState: newGraphVisibilityStates,
name: id || '',
});
return newGraphVisibilityStates;
});
}
});
}
});
}
},

View File

@@ -1,4 +1,4 @@
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { getToolTipValue, PrecisionOption } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { themeColors } from 'constants/theme';
import dayjs from 'dayjs';
@@ -44,6 +44,7 @@ const generateTooltipContent = (
idx: number,
isDarkMode: boolean,
yAxisUnit?: string,
decimalPrecision?: PrecisionOption,
series?: uPlot.Options['series'],
isBillingUsageGraphs?: boolean,
isHistogramGraphs?: boolean,
@@ -127,7 +128,7 @@ const generateTooltipContent = (
let tooltipItemLabel = label;
if (Number.isFinite(value)) {
const tooltipValue = getToolTipValue(value, yAxisUnit);
const tooltipValue = getToolTipValue(value, yAxisUnit, decimalPrecision);
const dataIngestedFormated = getToolTipValue(dataIngested);
if (
duplicatedLegendLabels[label] ||
@@ -239,6 +240,7 @@ type ToolTipPluginProps = {
isBillingUsageGraphs?: boolean;
isHistogramGraphs?: boolean;
isMergedSeries?: boolean;
decimalPrecision?: PrecisionOption;
stackBarChart?: boolean;
isDarkMode: boolean;
customTooltipElement?: HTMLDivElement;
@@ -259,6 +261,7 @@ const tooltipPlugin = ({
timezone,
colorMapping,
query,
decimalPrecision,
}: // eslint-disable-next-line sonarjs/cognitive-complexity
ToolTipPluginProps): any => {
let over: HTMLElement;
@@ -320,6 +323,7 @@ ToolTipPluginProps): any => {
idx,
isDarkMode,
yAxisUnit,
decimalPrecision,
u.series,
isBillingUsageGraphs,
isHistogramGraphs,

View File

@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import { getToolTipValue, PrecisionOption } from 'components/Graph/yAxisConfig';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { uPlotXAxisValuesFormat } from './constants';
@@ -18,11 +18,13 @@ const getAxes = ({
yAxisUnit,
panelType,
isLogScale,
decimalPrecision,
}: {
isDarkMode: boolean;
yAxisUnit?: string;
panelType?: PANEL_TYPES;
isLogScale?: boolean;
decimalPrecision?: PrecisionOption;
// eslint-disable-next-line sonarjs/cognitive-complexity
}): any => [
{
@@ -61,7 +63,7 @@ const getAxes = ({
if (v === null || v === undefined || Number.isNaN(v)) {
return '';
}
const value = getToolTipValue(v.toString(), yAxisUnit);
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
return `${value}`;
}),
gap: 5,

View File

@@ -1,3 +1,4 @@
import { PrecisionOption } from 'components/Graph/yAxisConfig';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
@@ -113,6 +114,7 @@ export interface IBaseWidget {
timePreferance: timePreferenceType;
stepSize?: number;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption; // number of decimals or 'full precision'
stackedBarChart?: boolean;
bucketCount?: number;
bucketWidth?: number;

View File

@@ -49,12 +49,8 @@ export const getHightLightedLogBackground = (
return `background-color: ${orange[3]};`;
};
export const getCustomHighlightBackground = (
isHighlighted = false,
isDarkMode = true,
$logType: string,
): string => {
export const getCustomHighlightBackground = (isHighlighted = false): string => {
if (!isHighlighted) return '';
return getActiveLogBackground(true, isDarkMode, $logType);
return `background-color: ${Color.BG_ROBIN_500}20;`;
};

View File

@@ -6369,13 +6369,13 @@ axe-core@^4.6.2:
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz"
integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==
axios@1.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979"
integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==
axios@1.12.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.0.tgz#11248459be05a5ee493485628fa0e4323d0abfc3"
integrity sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.0"
form-data "^4.0.4"
proxy-from-env "^1.1.0"
axobject-query@^3.1.1:
@@ -9677,7 +9677,7 @@ force-graph@1:
kapsule "^1.14"
lodash-es "4"
form-data@4.0.4, form-data@^3.0.0, form-data@^4.0.0:
form-data@4.0.4, form-data@^3.0.0, form-data@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4"
integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==

View File

@@ -2,7 +2,6 @@ package alertmanagerbatcher
import (
"context"
"io"
"log/slog"
"testing"
@@ -11,7 +10,7 @@ import (
)
func TestBatcherWithOneAlertAndDefaultConfigs(t *testing.T) {
batcher := New(slog.New(slog.NewTextHandler(io.Discard, nil)), NewConfig())
batcher := New(slog.New(slog.DiscardHandler), NewConfig())
_ = batcher.Start(context.Background())
batcher.Add(context.Background(), &alertmanagertypes.PostableAlert{Alert: alertmanagertypes.AlertModel{
@@ -25,7 +24,7 @@ func TestBatcherWithOneAlertAndDefaultConfigs(t *testing.T) {
}
func TestBatcherWithBatchSize(t *testing.T) {
batcher := New(slog.New(slog.NewTextHandler(io.Discard, nil)), Config{Size: 2, Capacity: 4})
batcher := New(slog.New(slog.DiscardHandler), Config{Size: 2, Capacity: 4})
_ = batcher.Start(context.Background())
var alerts alertmanagertypes.PostableAlerts
@@ -45,7 +44,7 @@ func TestBatcherWithBatchSize(t *testing.T) {
}
func TestBatcherWithCClosed(t *testing.T) {
batcher := New(slog.New(slog.NewTextHandler(io.Discard, nil)), Config{Size: 2, Capacity: 4})
batcher := New(slog.New(slog.DiscardHandler), Config{Size: 2, Capacity: 4})
_ = batcher.Start(context.Background())
var alerts alertmanagertypes.PostableAlerts

View File

@@ -2,14 +2,14 @@ package alertmanagerserver
import (
"context"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/prometheus/alertmanager/dispatch"
"io"
"log/slog"
"net/http"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/prometheus/alertmanager/dispatch"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/nfroutingstoretest"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/rulebasednotification"
@@ -89,7 +89,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
srvCfg := NewConfig()
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
logger := slog.New(slog.DiscardHandler)
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)

View File

@@ -3,7 +3,6 @@ package alertmanagerserver
import (
"bytes"
"context"
"io"
"log/slog"
"net"
"net/http"
@@ -26,7 +25,7 @@ import (
func TestServerSetConfigAndStop(t *testing.T) {
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -38,7 +37,7 @@ func TestServerSetConfigAndStop(t *testing.T) {
func TestServerTestReceiverTypeWebhook(t *testing.T) {
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -86,7 +85,7 @@ func TestServerPutAlerts(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -134,7 +133,7 @@ func TestServerTestAlert(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -239,7 +238,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.NewTextHandler(io.Discard, nil)), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")

View File

@@ -2,7 +2,6 @@ package factory
import (
"context"
"io"
"log/slog"
"sync"
"testing"
@@ -33,7 +32,7 @@ func TestRegistryWith2Services(t *testing.T) {
s1 := newTestService(t)
s2 := newTestService(t)
registry, err := NewRegistry(slog.New(slog.NewTextHandler(io.Discard, nil)), NewNamedService(MustNewName("s1"), s1), NewNamedService(MustNewName("s2"), s2))
registry, err := NewRegistry(slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1), NewNamedService(MustNewName("s2"), s2))
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
@@ -54,7 +53,7 @@ func TestRegistryWith2ServicesWithoutWait(t *testing.T) {
s1 := newTestService(t)
s2 := newTestService(t)
registry, err := NewRegistry(slog.New(slog.NewTextHandler(io.Discard, nil)), NewNamedService(MustNewName("s1"), s1), NewNamedService(MustNewName("s2"), s2))
registry, err := NewRegistry(slog.New(slog.DiscardHandler), NewNamedService(MustNewName("s1"), s1), NewNamedService(MustNewName("s2"), s2))
require.NoError(t, err)
ctx := context.Background()

View File

@@ -1,7 +1,6 @@
package middleware
import (
"io"
"log/slog"
"net"
"net/http"
@@ -17,7 +16,7 @@ func TestTimeout(t *testing.T) {
writeTimeout := 6 * time.Second
defaultTimeout := 2 * time.Second
maxTimeout := 4 * time.Second
m := NewTimeout(slog.New(slog.NewTextHandler(io.Discard, nil)), []string{"/excluded"}, defaultTimeout, maxTimeout)
m := NewTimeout(slog.New(slog.DiscardHandler), []string{"/excluded"}, defaultTimeout, maxTimeout)
listener, err := net.Listen("tcp", "localhost:0")
require.NoError(t, err)

View File

@@ -1,7 +1,6 @@
package instrumentationtest
import (
"io"
"log/slog"
"github.com/SigNoz/signoz/pkg/factory"
@@ -21,7 +20,7 @@ type noopInstrumentation struct {
func New() instrumentation.Instrumentation {
return &noopInstrumentation{
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
logger: slog.New(slog.DiscardHandler),
meterProvider: noopmetric.NewMeterProvider(),
tracerProvider: nooptrace.NewTracerProvider(),
}

View File

@@ -0,0 +1,58 @@
package implspanpercentile
import (
"encoding/json"
"net/http"
errorsV2 "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type handler struct {
module spanpercentile.Module
}
func NewHandler(module spanpercentile.Module) spanpercentile.Handler {
return &handler{
module: module,
}
}
func (h *handler) GetSpanPercentileDetails(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
spanPercentileRequest, err := parseSpanPercentileRequestBody(r)
if err != nil {
render.Error(w, err)
return
}
result, err := h.module.GetSpanPercentile(r.Context(), valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID), spanPercentileRequest)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, result)
}
func parseSpanPercentileRequestBody(r *http.Request) (*spanpercentiletypes.SpanPercentileRequest, error) {
req := new(spanpercentiletypes.SpanPercentileRequest)
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
return nil, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "cannot parse the request body: %v", err)
}
if err := req.Validate(); err != nil {
return nil, err
}
return req, nil
}

View File

@@ -0,0 +1,126 @@
package implspanpercentile
import (
"context"
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/querier"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
querier querier.Querier
}
func NewModule(
querier querier.Querier,
_ factory.ProviderSettings,
) spanpercentile.Module {
return &module{
querier: querier,
}
}
func (m *module) GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error) {
queryRangeRequest, err := buildSpanPercentileQuery(ctx, req)
if err != nil {
return nil, err
}
if err := queryRangeRequest.Validate(); err != nil {
return nil, err
}
result, err := m.querier.QueryRange(ctx, orgID, queryRangeRequest)
if err != nil {
return nil, err
}
return transformToSpanPercentileResponse(result)
}
func transformToSpanPercentileResponse(queryResult *qbtypes.QueryRangeResponse) (*spanpercentiletypes.SpanPercentileResponse, error) {
if len(queryResult.Data.Results) == 0 {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "no data returned from query")
}
scalarData, ok := queryResult.Data.Results[0].(*qbtypes.ScalarData)
if !ok {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "unexpected result type")
}
if len(scalarData.Data) == 0 {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "no rows returned from query")
}
row := scalarData.Data[0]
columnMap := make(map[string]int)
for i, col := range scalarData.Columns {
columnMap[col.Name] = i
}
p50Idx, ok := columnMap["__result_0"]
if !ok {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "missing __result_0 column")
}
p90Idx, ok := columnMap["__result_1"]
if !ok {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "missing __result_1 column")
}
p99Idx, ok := columnMap["__result_2"]
if !ok {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "missing __result_2 column")
}
positionIdx, ok := columnMap["__result_3"]
if !ok {
return nil, errors.New(errors.TypeInternal, errors.CodeInternal, "missing __result_3 column")
}
p50, err := toFloat64(row[p50Idx])
if err != nil {
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "no spans found matching the specified criteria")
}
p90, err := toFloat64(row[p90Idx])
if err != nil {
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "no spans found matching the specified criteria")
}
p99, err := toFloat64(row[p99Idx])
if err != nil {
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "no spans found matching the specified criteria")
}
position, err := toFloat64(row[positionIdx])
if err != nil {
return nil, errors.New(errors.TypeNotFound, errors.CodeNotFound, "no spans found matching the specified criteria")
}
description := fmt.Sprintf("faster than %.1f%% of spans", position)
if position < 50 {
description = fmt.Sprintf("slower than %.1f%% of spans", 100-position)
}
return &spanpercentiletypes.SpanPercentileResponse{
Percentiles: spanpercentiletypes.PercentileStats{
P50: p50,
P90: p90,
P99: p99,
},
Position: spanpercentiletypes.PercentilePosition{
Percentile: position,
Description: description,
},
}, nil
}
func toFloat64(val any) (float64, error) {
result, ok := val.(float64)
if !ok {
return 0, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot convert %T to float64", val)
}
return result, nil
}

View File

@@ -0,0 +1,118 @@
package implspanpercentile
import (
"context"
"fmt"
"sort"
"strings"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func buildSpanPercentileQuery(
_ context.Context,
req *spanpercentiletypes.SpanPercentileRequest,
) (*qbtypes.QueryRangeRequest, error) {
if err := req.Validate(); err != nil {
return nil, err
}
var attrKeys []string
for key := range req.ResourceAttributes {
attrKeys = append(attrKeys, key)
}
sort.Strings(attrKeys)
filterConditions := []string{
fmt.Sprintf("service.name = '%s'", strings.ReplaceAll(req.ServiceName, "'", `\'`)),
fmt.Sprintf("name = '%s'", strings.ReplaceAll(req.Name, "'", `\'`)),
}
for _, key := range attrKeys {
value := req.ResourceAttributes[key]
filterConditions = append(filterConditions,
fmt.Sprintf("%s = '%s'", key, strings.ReplaceAll(value, "'", `\'`)))
}
filterExpr := strings.Join(filterConditions, " AND ")
groupByKeys := []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "service.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextSpan,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
},
}
for _, key := range attrKeys {
groupByKeys = append(groupByKeys, qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: key,
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
})
}
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "span_percentile",
Signal: telemetrytypes.SignalTraces,
Aggregations: []qbtypes.TraceAggregation{
{
Expression: "p50(duration_nano)",
Alias: "p50_duration_nano",
},
{
Expression: "p90(duration_nano)",
Alias: "p90_duration_nano",
},
{
Expression: "p99(duration_nano)",
Alias: "p99_duration_nano",
},
{
Expression: fmt.Sprintf(
"(100.0 * countIf(duration_nano <= %d)) / count()",
req.DurationNano,
),
Alias: "percentile_position",
},
},
GroupBy: groupByKeys,
Filter: &qbtypes.Filter{
Expression: filterExpr,
},
}
queryEnvelope := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: query,
}
return &qbtypes.QueryRangeRequest{
SchemaVersion: "v5",
Start: req.Start,
End: req.End,
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{queryEnvelope},
},
FormatOptions: &qbtypes.FormatOptions{
FormatTableResultForUI: true,
},
}, nil
}

View File

@@ -0,0 +1,149 @@
package implspanpercentile
import (
"context"
"fmt"
"sort"
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/require"
)
func TestBuildSpanPercentileQuery(t *testing.T) {
req := &spanpercentiletypes.SpanPercentileRequest{
DurationNano: 100000,
Name: "test",
ServiceName: "test-service",
ResourceAttributes: map[string]string{},
Start: 1640995200000,
End: 1640995800000,
}
ctx := context.Background()
result, err := buildSpanPercentileQuery(ctx, req)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, len(result.CompositeQuery.Queries))
require.Equal(t, qbtypes.QueryTypeBuilder, result.CompositeQuery.Queries[0].Type)
query, ok := result.CompositeQuery.Queries[0].Spec.(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
require.True(t, ok, "Spec should be QueryBuilderQuery type")
require.Equal(t, "span_percentile", query.Name)
require.Equal(t, telemetrytypes.SignalTraces, query.Signal)
require.Equal(t, 4, len(query.Aggregations))
require.Equal(t, "p50(duration_nano)", query.Aggregations[0].Expression)
require.Equal(t, "p50_duration_nano", query.Aggregations[0].Alias)
require.Equal(t, "p90(duration_nano)", query.Aggregations[1].Expression)
require.Equal(t, "p90_duration_nano", query.Aggregations[1].Alias)
require.Equal(t, "p99(duration_nano)", query.Aggregations[2].Expression)
require.Equal(t, "p99_duration_nano", query.Aggregations[2].Alias)
require.Equal(t, "(100.0 * countIf(duration_nano <= 100000)) / count()", query.Aggregations[3].Expression)
require.Equal(t, "percentile_position", query.Aggregations[3].Alias)
require.NotNil(t, query.Filter)
require.Equal(t, "service.name = 'test-service' AND name = 'test'", query.Filter.Expression)
require.Equal(t, 2, len(query.GroupBy))
require.Equal(t, "service.name", query.GroupBy[0].TelemetryFieldKey.Name)
require.Equal(t, telemetrytypes.FieldContextResource, query.GroupBy[0].TelemetryFieldKey.FieldContext)
require.Equal(t, "name", query.GroupBy[1].TelemetryFieldKey.Name)
require.Equal(t, telemetrytypes.FieldContextSpan, query.GroupBy[1].TelemetryFieldKey.FieldContext)
require.Equal(t, qbtypes.RequestTypeScalar, result.RequestType)
}
func TestBuildSpanPercentileQueryWithResourceAttributes(t *testing.T) {
testCases := []struct {
name string
request *spanpercentiletypes.SpanPercentileRequest
expectedFilterExpr string
}{
{
name: "query with service.name only (no additional resource attributes)",
request: &spanpercentiletypes.SpanPercentileRequest{
DurationNano: 100000,
Name: "GET /api/users",
ServiceName: "user-service",
ResourceAttributes: map[string]string{},
Start: 1640995200000,
End: 1640995800000,
},
expectedFilterExpr: "service.name = 'user-service' AND name = 'GET /api/users'",
},
{
name: "query with service.name and deployment.environment",
request: &spanpercentiletypes.SpanPercentileRequest{
DurationNano: 250000,
Name: "POST /api/orders",
ServiceName: "order-service",
ResourceAttributes: map[string]string{
"deployment.environment": "production",
},
Start: 1640995200000,
End: 1640995800000,
},
expectedFilterExpr: "service.name = 'order-service' AND name = 'POST /api/orders' AND deployment.environment = 'production'",
},
{
name: "query with multiple resource attributes",
request: &spanpercentiletypes.SpanPercentileRequest{
DurationNano: 500000,
Name: "DELETE /api/items",
ServiceName: "inventory-service",
ResourceAttributes: map[string]string{
"cloud.platform": "aws",
"deployment.environment": "staging",
"k8s.cluster.name": "staging-cluster",
},
Start: 1640995200000,
End: 1640995800000,
},
expectedFilterExpr: "service.name = 'inventory-service' AND name = 'DELETE /api/items' AND cloud.platform = 'aws' AND deployment.environment = 'staging' AND k8s.cluster.name = 'staging-cluster'",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
result, err := buildSpanPercentileQuery(ctx, tc.request)
require.NoError(t, err)
require.NotNil(t, result)
query, ok := result.CompositeQuery.Queries[0].Spec.(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
require.True(t, ok, "Spec should be QueryBuilderQuery type")
require.Equal(t, tc.expectedFilterExpr, query.Filter.Expression)
require.Equal(t, 4, len(query.Aggregations))
require.Equal(t, "p50(duration_nano)", query.Aggregations[0].Expression)
require.Equal(t, "p90(duration_nano)", query.Aggregations[1].Expression)
require.Equal(t, "p99(duration_nano)", query.Aggregations[2].Expression)
require.Contains(t, query.Aggregations[3].Expression, fmt.Sprintf("countIf(duration_nano <= %d)", tc.request.DurationNano))
expectedGroupByCount := 2 + len(tc.request.ResourceAttributes)
require.Equal(t, expectedGroupByCount, len(query.GroupBy))
require.Equal(t, "service.name", query.GroupBy[0].TelemetryFieldKey.Name)
require.Equal(t, "name", query.GroupBy[1].TelemetryFieldKey.Name)
for i, key := range getSortedKeys(tc.request.ResourceAttributes) {
require.Equal(t, key, query.GroupBy[2+i].TelemetryFieldKey.Name)
require.Equal(t, telemetrytypes.FieldContextResource, query.GroupBy[2+i].TelemetryFieldKey.FieldContext)
}
})
}
}
func getSortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -0,0 +1,17 @@
package spanpercentile
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/spanpercentiletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
GetSpanPercentile(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, req *spanpercentiletypes.SpanPercentileRequest) (*spanpercentiletypes.SpanPercentileResponse, error)
}
type Handler interface {
GetSpanPercentileDetails(http.ResponseWriter, *http.Request)
}

View File

@@ -499,6 +499,9 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/alerts", am.ViewAccess(aH.AlertmanagerAPI.GetAlerts)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules/keys", am.ViewAccess(aH.getRuleAttributeKeys)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules/values", am.ViewAccess(aH.getRuleAttributeValues)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
@@ -625,6 +628,8 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// Export
router.HandleFunc("/api/v1/export_raw_data", am.ViewAccess(aH.Signoz.Handlers.RawDataExport.ExportRawData)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/span_percentile", am.ViewAccess(aH.Signoz.Handlers.SpanPercentile.GetSpanPercentileDetails)).Methods(http.MethodPost)
}
func (ah *APIHandler) MetricExplorerRoutes(router *mux.Router, am *middleware.AuthZ) {
@@ -1150,6 +1155,63 @@ func (aH *APIHandler) getRuleStateHistoryTopContributors(w http.ResponseWriter,
aH.Respond(w, res)
}
func (aH *APIHandler) getRuleAttributeKeys(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, errorsV2.NewInternalf(errorsV2.CodeInternal, "failed to get claims from context: %v", err))
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, errorsV2.NewInternalf(errorsV2.CodeInternal, "failed to get orgId from claims: %v", err))
return
}
searchText := r.URL.Query().Get("searchText")
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || limit <= 0 {
limit = 10
}
keys, err := aH.ruleManager.GetSearchKeys(r.Context(), searchText, limit, orgID)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, keys)
}
func (aH *APIHandler) getRuleAttributeValues(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, errorsV2.NewInternalf(errorsV2.CodeInternal, "failed to get claims from context: %v", err))
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, errorsV2.NewInternalf(errorsV2.CodeInternal, "failed to get orgId from claims: %v", err))
return
}
attributeKey := r.URL.Query().Get("attributeKey")
if attributeKey == "" {
render.Error(w, errorsV2.NewInternalf(errorsV2.CodeInvalidInput, "attributeKey is required"))
return
}
searchText := r.URL.Query().Get("searchText")
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
if err != nil || limit <= 0 {
limit = 10
}
keys, err := aH.ruleManager.GetSearchValues(r.Context(), searchText, limit, attributeKey, orgID)
if err != nil {
render.Error(w, errorsV2.NewInternalf(errorsV2.CodeInternal, "failed to get rule search values: %v", err))
return
}
render.Success(w, http.StatusOK, keys)
}
func (aH *APIHandler) listRules(w http.ResponseWriter, r *http.Request) {
rules, err := aH.ruleManager.ListRuleStates(r.Context())

View File

@@ -36,6 +36,16 @@ func (s AlertState) String() string {
panic(errors.Errorf("unknown alert state: %d", s))
}
func GetAllRuleStates() []string {
return []string{
StateInactive.String(),
StatePending.String(),
StateFiring.String(),
StateNoData.String(),
StateDisabled.String(),
}
}
func (s AlertState) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"log/slog"
"sort"
"strings"
@@ -1083,3 +1084,60 @@ func (m *Manager) GetAlertDetailsForMetricNames(ctx context.Context, metricNames
return result, nil
}
func (m *Manager) GetSearchKeys(ctx context.Context, searchText string, limit int, orgId valuer.UUID) ([]ruletypes.GetRuleAttributeKeys, error) {
keys, err := m.ruleStore.GetRuleLabelKeys(ctx, searchText, limit, orgId.String())
if err != nil {
return nil, errors.NewInternalf(errors.CodeInternal, "failed to get rule label keys: %v", err)
}
result := make([]ruletypes.GetRuleAttributeKeys, len(ruletypes.FixedRuleAttributeKeys))
copy(result, ruletypes.FixedRuleAttributeKeys)
for _, key := range keys {
result = append(result, ruletypes.GetRuleAttributeKeys{
Key: key,
Type: ruletypes.RuleAttributeTypeLabel,
DataType: telemetrytypes.FieldDataTypeString,
})
}
return result, nil
}
func (m *Manager) GetSearchValues(ctx context.Context, searchText string, limit int, key string, orgId valuer.UUID) ([]string, error) {
switch key {
case ruletypes.RuleAttributeKeyChannel:
return m.ruleStore.GetChannel(ctx, searchText, limit, orgId.String())
case ruletypes.RuleAttributeKeyThresholdName:
return m.ruleStore.GetThresholdNames(ctx, searchText, limit, orgId.String())
case ruletypes.RuleAttributeKeyCreatedBy:
return m.ruleStore.GetCreatedBy(ctx, searchText, limit, orgId.String())
case ruletypes.RuleAttributeKeyUpdatedBy:
return m.ruleStore.GetUpdatedBy(ctx, searchText, limit, orgId.String())
case ruletypes.RuleAttributeKeyName:
return m.ruleStore.GetNames(ctx, searchText, limit, orgId.String())
case ruletypes.RuleAttributeKeyState:
allStates := model.GetAllRuleStates()
if searchText == "" {
if limit > 0 && limit < len(allStates) {
return allStates[:limit], nil
}
return allStates, nil
}
filtered := make([]string, 0)
searchLower := strings.ToLower(searchText)
for _, state := range allStates {
if strings.Contains(strings.ToLower(state), searchLower) {
filtered = append(filtered, state)
if limit > 0 && len(filtered) >= limit {
break
}
}
}
return filtered, nil
default:
return m.ruleStore.GetRuleLabelValues(ctx, searchText, limit, key, orgId.String())
}
}

View File

@@ -2,6 +2,9 @@ package sqlrulestore
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/uptrace/bun"
"strings"
"github.com/SigNoz/signoz/pkg/sqlstore"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
@@ -101,3 +104,205 @@ func (r *rule) GetStoredRule(ctx context.Context, id valuer.UUID) (*ruletypes.Ru
}
return rule, nil
}
func (r *rule) GetRuleLabelKeys(ctx context.Context, searchText string, limit int, orgId string) ([]string, error) {
labelKeys := make([]string, 0)
searchText = strings.ToLower(searchText) + "%"
fmter := r.sqlstore.Formatter()
elements, elementsAlias := fmter.JSONKeys("data", "$.labels", "keys")
elementsAliasStr := string(fmter.LowerExpression(string(elementsAlias)))
query := r.sqlstore.BunDB().
NewSelect().
Distinct().
ColumnExpr("?", bun.SafeQuery(elementsAliasStr)).
TableExpr("rule, ?", bun.SafeQuery(string(elements))).
Where("? LIKE ?", bun.SafeQuery(elementsAliasStr), searchText).
Where("org_id = ?", orgId).
Limit(limit)
err := query.Scan(ctx, &labelKeys)
if err != nil {
return nil, r.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "search keys for rule with orgId %s not found", orgId)
}
return labelKeys, nil
}
func (r *rule) GetThresholdNames(ctx context.Context, searchText string, limit int, orgId string) ([]string, error) {
names := make([]string, 0)
searchText = strings.ToLower(searchText) + "%"
fmter := r.sqlstore.Formatter()
// Query threshold spec names
specQuery, specCol := fmter.JSONArrayElements("data", "$.condition.thresholds.spec", "spec")
nameQuery := string(fmter.JSONExtractString(string(specCol), "$.name"))
lowerNameQuery := string(fmter.LowerExpression(nameQuery))
query := r.sqlstore.BunDB().
NewSelect().
Distinct().
ColumnExpr("?", bun.SafeQuery(nameQuery)).
TableExpr("rule, ?", bun.SafeQuery(string(specQuery))).
Where("? LIKE ?", bun.SafeQuery(lowerNameQuery), searchText).
Where("org_id = ?", orgId).
Limit(limit)
err := query.Scan(ctx, &names)
if err != nil {
return nil, r.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "threshold names for rule with orgId %s not found", orgId)
}
if len(names) >= limit {
return names[:limit], nil
}
severityQuery := string(fmter.JSONExtractString("data", "$.labels.severity"))
lowerSeverityQuery := string(fmter.LowerExpression(severityQuery))
thresholds := make([]string, 0)
query = r.sqlstore.BunDB().
NewSelect().
Distinct().
ColumnExpr("?", bun.SafeQuery(severityQuery)).
TableExpr("rule").
Where("org_id = ?", orgId).
Where("? LIKE ?", bun.SafeQuery(lowerSeverityQuery), searchText).
Limit(limit - len(names))
err = query.Scan(ctx, &thresholds)
if err != nil {
return nil, r.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "threshold names for rule with orgId %s not found", orgId)
}
names = append(names, thresholds...)
return names, nil
}
func (r *rule) GetChannel(ctx context.Context, searchText string, limit int, orgId string) ([]string, error) {
names := make([]string, 0)
searchText = strings.ToLower(searchText) + "%"
fmter := r.sqlstore.Formatter()
// Query v2 threshold channels
specSQL, specCol := fmter.JSONArrayElements("data", "$.condition.thresholds.spec", "spec")
channelSQL, channelCol := fmter.JSONArrayOfStrings(string(specCol), "$.channels", "channels")
lowerChannelCol := string(fmter.LowerExpression(string(channelCol)))
query := r.sqlstore.BunDB().
NewSelect().
Distinct().
ColumnExpr("?", bun.SafeQuery(string(channelCol))).
TableExpr("rule, ?, ?",
bun.SafeQuery(string(specSQL)),
bun.SafeQuery(string(channelSQL))).
Where("? LIKE ?", bun.SafeQuery(lowerChannelCol), searchText).
Where("org_id = ?", orgId).
Limit(limit)
err := query.Scan(ctx, &names)
if err != nil {
return nil, r.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "channel for rule with orgId %s not found", orgId)
}
if len(names) >= limit {
return names[:limit], nil
}
// Query v1 preferred channels
channelsSQL, channelsCol := fmter.JSONArrayOfStrings("data", "$.preferredChannels", "channels")
lowerChannelsCol := fmter.LowerExpression(string(channelsCol))
channels := make([]string, 0)
query = r.sqlstore.BunDB().
NewSelect().
Distinct().
ColumnExpr("?", bun.SafeQuery(string(channelsCol))).
TableExpr("rule, ?", bun.SafeQuery(string(channelsSQL))).
Where("? LIKE ?", bun.SafeQuery(string(lowerChannelsCol)), searchText).
Where("org_id = ?", orgId).
Limit(limit - len(names))
err = query.Scan(ctx, &channels)
if err != nil {
return nil, r.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "channel for rule with orgId %s not found", orgId)
}
names = append(names, channels...)
return names, nil
}
func (r *rule) GetNames(ctx context.Context, searchText string, limit int, orgId string) ([]string, error) {
names := make([]string, 0)
searchText = strings.ToLower(searchText) + "%"
fmter := r.sqlstore.Formatter()
namePath := fmter.JSONExtractString("data", "$.alert")
lowerNamePath := fmter.LowerExpression(string(namePath))
query := r.sqlstore.BunDB().
NewSelect().
Distinct().
ColumnExpr("?", bun.SafeQuery(string(namePath))).
TableExpr("?", bun.SafeQuery("rule")).
Where("? LIKE ?", bun.SafeQuery(string(lowerNamePath)), searchText).
Where("org_id = ?", orgId).
Limit(limit)
err := query.Scan(ctx, &names)
if err != nil {
return nil, r.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "names for rule with orgId %s not found", orgId)
}
return names, nil
}
func (r *rule) GetCreatedBy(ctx context.Context, searchText string, limit int, orgId string) ([]string, error) {
names := make([]string, 0)
searchText = strings.ToLower(searchText) + "%"
query := r.sqlstore.BunDB().NewSelect().
Distinct().
Column("created_by").
TableExpr("?", bun.SafeQuery("rule")).
Where("org_id = ?", orgId).
Where("? LIKE ?", bun.SafeQuery(string(r.sqlstore.Formatter().LowerExpression("created_by"))), searchText).
Limit(limit)
err := query.Scan(ctx, &names)
if err != nil {
return nil, err
}
return names, nil
}
func (r *rule) GetUpdatedBy(ctx context.Context, searchText string, limit int, orgId string) ([]string, error) {
names := make([]string, 0)
searchText = strings.ToLower(searchText) + "%"
query := r.sqlstore.BunDB().NewSelect().
Distinct().
Column("updated_by").
TableExpr("?", bun.SafeQuery("rule")).
Where("org_id = ?", orgId).
Where("? LIKE ?", bun.SafeQuery(string(r.sqlstore.Formatter().LowerExpression("updated_by"))), searchText).
Limit(limit)
err := query.Scan(ctx, &names)
if err != nil {
return nil, err
}
return names, nil
}
func (r *rule) GetRuleLabelValues(ctx context.Context, searchText string, limit int, labelKey string, orgId string) ([]string, error) {
names := make([]string, 0)
labelPath := r.sqlstore.Formatter().JSONExtractString("data", "$.labels."+labelKey)
searchText = strings.ToLower(searchText) + "%"
query := r.sqlstore.BunDB().NewSelect().
Distinct().
ColumnExpr("?", bun.SafeQuery(string(labelPath))).
TableExpr("?", bun.SafeQuery("rule")).
Where("org_id = ?", orgId).
Where("? LIKE ?", bun.SafeQuery(string(r.sqlstore.Formatter().LowerExpression(string(labelPath)))), searchText).Limit(limit)
err := query.Scan(ctx, &names)
if err != nil {
return nil, r.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "search values for rule with orgId %s not found", orgId)
}
return names, nil
}

View File

@@ -2,7 +2,6 @@ package signoz
import (
"context"
"io"
"log/slog"
"testing"
@@ -13,7 +12,7 @@ import (
// This is a test to ensure that all fields of config implement the factory.Config interface and are valid with
// their default values.
func TestValidateConfig(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
logger := slog.New(slog.DiscardHandler)
_, err := NewConfig(context.Background(), logger, configtest.NewResolverConfig(), DeprecatedFlags{})
assert.NoError(t, err)
}

View File

@@ -20,6 +20,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -27,31 +29,33 @@ import (
)
type Handlers struct {
Organization organization.Handler
Preference preference.Handler
User user.Handler
SavedView savedview.Handler
Apdex apdex.Handler
Dashboard dashboard.Handler
QuickFilter quickfilter.Handler
TraceFunnel tracefunnel.Handler
RawDataExport rawdataexport.Handler
AuthDomain authdomain.Handler
Session session.Handler
Organization organization.Handler
Preference preference.Handler
User user.Handler
SavedView savedview.Handler
Apdex apdex.Handler
Dashboard dashboard.Handler
QuickFilter quickfilter.Handler
TraceFunnel tracefunnel.Handler
RawDataExport rawdataexport.Handler
AuthDomain authdomain.Handler
Session session.Handler
SpanPercentile spanpercentile.Handler
}
func NewHandlers(modules Modules, providerSettings factory.ProviderSettings) Handlers {
return Handlers{
Organization: implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
Preference: implpreference.NewHandler(modules.Preference),
User: impluser.NewHandler(modules.User, modules.UserGetter),
SavedView: implsavedview.NewHandler(modules.SavedView),
Apdex: implapdex.NewHandler(modules.Apdex),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings),
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),
AuthDomain: implauthdomain.NewHandler(modules.AuthDomain),
Session: implsession.NewHandler(modules.Session),
Organization: implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
Preference: implpreference.NewHandler(modules.Preference),
User: impluser.NewHandler(modules.User, modules.UserGetter),
SavedView: implsavedview.NewHandler(modules.SavedView),
Apdex: implapdex.NewHandler(modules.Apdex),
Dashboard: impldashboard.NewHandler(modules.Dashboard, providerSettings),
QuickFilter: implquickfilter.NewHandler(modules.QuickFilter),
TraceFunnel: impltracefunnel.NewHandler(modules.TraceFunnel),
RawDataExport: implrawdataexport.NewHandler(modules.RawDataExport),
AuthDomain: implauthdomain.NewHandler(modules.AuthDomain),
Session: implsession.NewHandler(modules.Session),
SpanPercentile: implspanpercentile.NewHandler(modules.SpanPercentile),
}
}

View File

@@ -24,6 +24,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/savedview/implsavedview"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -36,19 +38,20 @@ import (
)
type Modules struct {
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
User user.Module
UserGetter user.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
User user.Module
UserGetter user.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
SpanPercentile spanpercentile.Module
}
func NewModules(
@@ -66,19 +69,21 @@ func NewModules(
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
user := impluser.NewModule(impluser.NewStore(sqlstore, providerSettings), tokenizer, emailing, providerSettings, orgSetter, analytics)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings))
return Modules{
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(sqlstore),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: impldashboard.NewModule(sqlstore, providerSettings, analytics),
User: user,
UserGetter: userGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore)),
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore)), tokenizer, orgGetter),
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(sqlstore),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: impldashboard.NewModule(sqlstore, providerSettings, analytics),
User: user,
UserGetter: userGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: implauthdomain.NewModule(implauthdomain.NewStore(sqlstore)),
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore)), tokenizer, orgGetter),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
}
}

View File

@@ -0,0 +1,113 @@
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 = append(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 = append(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, " = '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 = append(sql, path...)
sql = append(sql, "'"...)
}
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, []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 = append(sql, path...)
sql = append(sql, "'"...)
}
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, []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 {
if len(values) == 0 {
return []byte("json_array()")
}
var sql []byte
sql = append(sql, "json_array("...)
for i, v := range values {
if i > 0 {
sql = append(sql, ", "...)
}
sql = append(sql, '\'')
sql = append(sql, v...)
sql = append(sql, '\'')
}
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
}

View File

@@ -0,0 +1,397 @@
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: "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", '$')`,
},
}
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'`,
},
}
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"`,
},
}
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)
})
}
}

View File

@@ -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)
}

View File

@@ -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 path in sqlite format like "$.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
}

View File

@@ -0,0 +1,112 @@
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 = append(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 = append(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, " = '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 = append(sql, path...)
sql = append(sql, "'"...)
}
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, []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 = append(sql, path...)
sql = append(sql, "'"...)
}
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, []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 {
if len(values) == 0 {
return []byte("json_array()")
}
var sql []byte
sql = append(sql, "json_array("...)
for i, v := range values {
if i > 0 {
sql = append(sql, ", "...)
}
sql = append(sql, '\'')
sql = append(sql, v...)
sql = append(sql, '\'')
}
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
}

View File

@@ -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
}

View File

@@ -20,6 +20,7 @@ var (
NameNavShortcuts = Name{valuer.NewString("nav_shortcuts")}
NameLastSeenChangelogVersion = Name{valuer.NewString("last_seen_changelog_version")}
NameSpanDetailsPinnedAttributes = Name{valuer.NewString("span_details_pinned_attributes")}
NameSpanPercentileResourceAttributes = Name{valuer.NewString("span_percentile_resource_attributes")}
)
type Name struct{ valuer.String }
@@ -39,6 +40,7 @@ func NewName(name string) (Name, error) {
NameNavShortcuts.StringValue(),
NameLastSeenChangelogVersion.StringValue(),
NameSpanDetailsPinnedAttributes.StringValue(),
NameSpanPercentileResourceAttributes.StringValue(),
},
name,
)

View File

@@ -163,6 +163,15 @@ func NewAvailablePreference() map[Name]Preference {
AllowedValues: []string{},
Value: MustNewValue([]any{}, ValueTypeArray),
},
NameSpanPercentileResourceAttributes: {
Name: NameSpanPercentileResourceAttributes,
Description: "Additional resource attributes for span percentile filtering (beyond mandatory name and service.name).",
ValueType: ValueTypeArray,
DefaultValue: MustNewValue([]any{"deployment.environment"}, ValueTypeArray),
AllowedScopes: []Scope{ScopeUser},
AllowedValues: []string{},
Value: MustNewValue([]any{"deployment.environment"}, ValueTypeArray),
},
}
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"slices"
"time"
"unicode/utf8"
@@ -452,3 +454,18 @@ func (g *GettableRule) MarshalJSON() ([]byte, error) {
return json.Marshal(aux)
}
}
type RuleAttributeKeyType struct {
valuer.String
}
var (
RuleAttributeTypeFixed = RuleAttributeKeyType{valuer.NewString("fixed")}
RuleAttributeTypeLabel = RuleAttributeKeyType{valuer.NewString("label")}
)
type GetRuleAttributeKeys struct {
Key string `json:"key"`
DataType telemetrytypes.FieldDataType `json:"dataType"`
Type RuleAttributeKeyType `json:"type"`
}

View File

@@ -1,5 +1,31 @@
package ruletypes
const CriticalThresholdName = "CRITICAL"
const LabelThresholdName = "threshold.name"
const LabelRuleId = "ruleId"
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
const (
CriticalThresholdName = "CRITICAL"
LabelThresholdName = "threshold.name"
LabelRuleId = "ruleId"
// Rule attribute key constants for search and filtering
RuleAttributeKeyCreatedBy = "created_by"
RuleAttributeKeyUpdatedBy = "updated_by"
RuleAttributeKeyName = "name"
RuleAttributeKeyThresholdName = "threshold.name"
RuleAttributeKeyPolicy = "policy"
RuleAttributeKeyChannel = "channel"
RuleAttributeKeyState = "state"
//RuleAttributeKeyRuleType = "type"
)
var (
FixedRuleAttributeKeys = []GetRuleAttributeKeys{
{Key: RuleAttributeKeyCreatedBy, DataType: telemetrytypes.FieldDataTypeString, Type: RuleAttributeTypeFixed},
{Key: RuleAttributeKeyUpdatedBy, DataType: telemetrytypes.FieldDataTypeString, Type: RuleAttributeTypeFixed},
{Key: RuleAttributeKeyName, DataType: telemetrytypes.FieldDataTypeString, Type: RuleAttributeTypeFixed},
{Key: RuleAttributeKeyThresholdName, DataType: telemetrytypes.FieldDataTypeString, Type: RuleAttributeTypeFixed},
{Key: RuleAttributeKeyChannel, DataType: telemetrytypes.FieldDataTypeString, Type: RuleAttributeTypeFixed},
{Key: RuleAttributeKeyPolicy, DataType: telemetrytypes.FieldDataTypeBool, Type: RuleAttributeTypeFixed},
{Key: RuleAttributeKeyState, DataType: telemetrytypes.FieldDataTypeString, Type: RuleAttributeTypeFixed},
}
)

View File

@@ -53,4 +53,11 @@ type RuleStore interface {
DeleteRule(context.Context, valuer.UUID, func(context.Context) error) error
GetStoredRules(context.Context, string) ([]*Rule, error)
GetStoredRule(context.Context, valuer.UUID) (*Rule, error)
GetRuleLabelKeys(ctx context.Context, searchText string, limit int, orgId string) ([]string, error)
GetThresholdNames(ctx context.Context, searchText string, limit int, orgId string) ([]string, error)
GetChannel(ctx context.Context, searchText string, limit int, orgId string) ([]string, error)
GetNames(ctx context.Context, searchText string, limit int, orgId string) ([]string, error)
GetCreatedBy(ctx context.Context, searchText string, limit int, orgId string) ([]string, error)
GetUpdatedBy(ctx context.Context, searchText string, limit int, orgId string) ([]string, error)
GetRuleLabelValues(ctx context.Context, searchText string, limit int, labelKey string, orgId string) ([]string, error)
}

View File

@@ -0,0 +1,17 @@
package spanpercentiletypes
type SpanPercentileResponse struct {
Percentiles PercentileStats `json:"percentiles"`
Position PercentilePosition `json:"position"`
}
type PercentileStats struct {
P50 float64 `json:"p50"`
P90 float64 `json:"p90"`
P99 float64 `json:"p99"`
}
type PercentilePosition struct {
Percentile float64 `json:"percentile"`
Description string `json:"description"`
}

View File

@@ -0,0 +1,43 @@
package spanpercentiletypes
import (
"github.com/SigNoz/signoz/pkg/errors"
)
type SpanPercentileRequest struct {
DurationNano int64 `json:"spanDuration"`
Name string `json:"name"`
ServiceName string `json:"serviceName"`
ResourceAttributes map[string]string `json:"resourceAttributes"`
Start uint64 `json:"start"`
End uint64 `json:"end"`
}
func (req *SpanPercentileRequest) Validate() error {
if req.Name == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "name is required")
}
if req.ServiceName == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "service_name is required")
}
if req.DurationNano <= 0 {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "duration_nano must be greater than 0")
}
if req.Start >= req.End {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "start time must be before end time")
}
for key, val := range req.ResourceAttributes {
if key == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "resource attribute key cannot be empty")
}
if val == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "resource attribute value cannot be empty")
}
}
return nil
}