Compare commits

..

22 Commits

Author SHA1 Message Date
Naman Verma
4dd134494c Merge branch 'nv/functional-unique-index' into nv/functional-unique-index-tags 2026-07-08 16:36:00 +05:30
Naman Verma
0aff9ef889 test: add test for case insensitive expression equality 2026-07-08 15:26:12 +05:30
Naman Verma
2d653d1a6e chore: better comment for unique index 2026-07-08 15:25:53 +05:30
Naman Verma
0261b172f1 Merge branch 'nv/functional-unique-index' into nv/functional-unique-index-tags 2026-07-08 15:13:09 +05:30
Naman Verma
36aa3dcf0b Merge branch 'main' into nv/functional-unique-index 2026-07-08 15:12:56 +05:30
Naman Verma
6016f3d0b2 Merge branch 'nv/functional-unique-index' into nv/functional-unique-index-tags 2026-07-08 15:11:43 +05:30
Naman Verma
c31768f5fc fix: go lint fix 2026-07-08 15:11:27 +05:30
Naman Verma
84d75c637f fix: go lint fix 2026-07-08 15:10:27 +05:30
Naman Verma
226c3a978c chore: add functional unique index for tags 2026-07-08 15:04:50 +05:30
Naman Verma
ab9d78d314 chore: remove temporary tests 2026-07-08 14:51:31 +05:30
Naman Verma
2efad1c634 fix: remove tag unique index (will be added in a separate PR) 2026-07-08 14:47:15 +05:30
Naman Verma
1b75857a6b fix: remove tag unique index (will be added in a separate PR) 2026-07-08 14:47:00 +05:30
Naman Verma
531b637b6a chore: fetch expressions as well in getindices call 2026-07-08 14:35:47 +05:30
Naman Verma
9fdb62abbe Merge branch 'main' into nv/functional-unique-index 2026-07-08 12:36:43 +05:30
Naman Verma
fde7a5d818 Merge branch 'main' into nv/functional-unique-index 2026-07-07 12:32:09 +05:30
Naman Verma
83bbcd4b41 Merge branch 'main' into nv/functional-unique-index 2026-06-17 07:32:25 +05:30
Naman Verma
eafd71f205 Merge branch 'main' into nv/functional-unique-index 2026-06-12 23:23:39 +05:30
Naman Verma
e6a8736a1a Merge branch 'main' into nv/functional-unique-index 2026-06-12 14:00:25 +05:30
Naman Verma
0d744cf94c chore: add failing scratch test (to discuss) 2026-06-12 01:51:36 +05:30
Naman Verma
748dff9489 chore: add tag migration 2026-06-12 01:51:08 +05:30
Naman Verma
68da3b2beb Merge branch 'main' into nv/functional-unique-index 2026-06-11 22:37:11 +05:30
Naman Verma
5574e08ddc feat: add functional unique index 2026-05-14 15:24:46 +05:30
99 changed files with 7687 additions and 8540 deletions

View File

@@ -48,11 +48,7 @@ jobs:
- logspipelines
- passwordauthn
- preference
- querierlogs
- queriertraces
- queriermetrics
- querierscalar
- queriercommon
- querier
- rawexportdata
- role
- rootuser

View File

@@ -3,6 +3,7 @@ package postgressqlschema
import (
"context"
"database/sql"
"log/slog"
"github.com/uptrace/bun"
@@ -214,6 +215,20 @@ WHERE
}
func (provider *provider) GetIndices(ctx context.Context, name sqlschema.TableName) ([]sqlschema.Index, error) {
// A key is one entry an index is built on: a column (plain) or an expression
// like lower(x) (functional). Returns one row per key of each non-constraint-
// backed index on the table, ordered by position. Keys are enumerated with
// generate_series over indnkeyatts (count of key columns, excluding non-key
// INCLUDE columns), so expression keys are covered too. Reconstruction uses:
//
// index_name | index the key belongs to
// is_expression | true if the key is an expression, not a plain column
// key_def | rendered key text, e.g. org_id or lower(key)
// column_name | column name for plain keys; NULL for expression keys
// predicate | partial-index WHERE; NULL if not partial (same every row)
//
// table_name/unique/primary/column_position are carried over from the prior
// query (unique gates the loop, column_position orders the keys).
rows, err := provider.
sqlstore.
BunDB().
@@ -224,19 +239,21 @@ SELECT
i.indisunique AS unique,
i.indisprimary AS primary,
a.attname AS column_name,
array_position(i.indkey, a.attnum) AS column_position,
n AS column_position,
(i.indkey[n - 1] = 0) AS is_expression,
pg_get_indexdef(i.indexrelid, n, true) AS key_def,
pg_get_expr(i.indpred, i.indrelid) AS predicate
FROM
pg_index i
LEFT JOIN pg_class ct ON ct.oid = i.indrelid
LEFT JOIN pg_class ci ON ci.oid = i.indexrelid
LEFT JOIN pg_attribute a ON a.attrelid = ct.oid
LEFT JOIN pg_constraint con ON con.conindid = i.indexrelid
CROSS JOIN generate_series(1, i.indnkeyatts) AS n
LEFT JOIN pg_attribute a ON a.attrelid = ct.oid AND a.attnum = i.indkey[n - 1]
WHERE
a.attnum = ANY(i.indkey)
AND con.oid IS NULL
ct.relname = ?
AND ct.relkind = 'r'
AND ct.relname = ?
AND con.oid IS NULL
ORDER BY index_name, column_position`, string(name))
if err != nil {
return nil, provider.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "no indices for table (%s) found", name)
@@ -251,6 +268,10 @@ ORDER BY index_name, column_position`, string(name))
type indexEntry struct {
columns []sqlschema.ColumnName
predicate *string
// keyDefs holds every key rendered by pg_get_indexdef, in key order; used
// for functional indexes where a key may be an expression like lower(x).
keyDefs []string
hasExpression bool
}
uniqueIndicesMap := make(map[string]*indexEntry)
@@ -260,30 +281,45 @@ ORDER BY index_name, column_position`, string(name))
indexName string
unique bool
primary bool
columnName string
// starts from 0 and is unused in this function, this is to ensure that the column names are in the correct order
columnName *string
// starts from 1 and is unused in this function, this is to ensure that the column names are in the correct order
columnPosition int
isExpression bool
keyDef string
predicate *string
)
if err := rows.Scan(&tableName, &indexName, &unique, &primary, &columnName, &columnPosition, &predicate); err != nil {
if err := rows.Scan(&tableName, &indexName, &unique, &primary, &columnName, &columnPosition, &isExpression, &keyDef, &predicate); err != nil {
return nil, err
}
if unique {
if _, ok := uniqueIndicesMap[indexName]; !ok {
uniqueIndicesMap[indexName] = &indexEntry{
columns: []sqlschema.ColumnName{sqlschema.ColumnName(columnName)},
predicate: predicate,
}
} else {
uniqueIndicesMap[indexName].columns = append(uniqueIndicesMap[indexName].columns, sqlschema.ColumnName(columnName))
}
if !unique {
continue
}
entry, ok := uniqueIndicesMap[indexName]
if !ok {
entry = &indexEntry{predicate: predicate}
uniqueIndicesMap[indexName] = entry
}
entry.keyDefs = append(entry.keyDefs, keyDef)
if isExpression {
entry.hasExpression = true
} else if columnName != nil {
entry.columns = append(entry.columns, sqlschema.ColumnName(*columnName))
}
}
indices := make([]sqlschema.Index, 0)
for indexName, entry := range uniqueIndicesMap {
// functional partial indexes aren't representable (PartialUniqueIndex has no
// expressions); skip rather than misrepresent.
if entry.hasExpression && entry.predicate != nil {
provider.settings.Logger().WarnContext(ctx, "skipping functional partial unique index; not representable by sqlschema", slog.String("index", indexName), slog.String("table", string(name)))
continue
}
if entry.predicate != nil {
index := &sqlschema.PartialUniqueIndex{
TableName: name,
@@ -291,6 +327,17 @@ ORDER BY index_name, column_position`, string(name))
Where: *entry.predicate,
}
if index.Name() == indexName {
indices = append(indices, index)
} else {
indices = append(indices, index.Named(indexName))
}
} else if entry.hasExpression {
index := &sqlschema.UniqueIndex{
TableName: name,
Expressions: entry.keyDefs,
}
if index.Name() == indexName {
indices = append(indices, index)
} else {

View File

@@ -0,0 +1,112 @@
//go:build integration
// Requires a running postgres (make devenv-postgres); excluded from the default
// test run. Run with: go test -tags integration ./...
package postgressqlschema
import (
"context"
"os"
"testing"
"time"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/stretchr/testify/require"
)
// TestGetIndicesRoundTrip creates each index shape on a throwaway table in devenv
// postgres and checks GetIndices reconstructs an equal index, exercising every
// query branch: plain vs expression keys, multi-key ordering, partial predicate,
// and the skipped functional-partial case. Skips if postgres is unreachable.
func TestGetIndicesRoundTrip(t *testing.T) {
dsn := os.Getenv("TEST_POSTGRES_DSN")
if dsn == "" {
dsn = devenvPostgresDSN
}
ctx := context.Background()
cfg := sqlstore.Config{
Provider: "postgres",
Postgres: sqlstore.PostgresConfig{DSN: dsn},
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10, MaxConnLifetime: time.Minute},
}
providerSettings := instrumentationtest.New().ToProviderSettings()
store, err := postgressqlstore.New(ctx, providerSettings, cfg)
if err != nil {
t.Skipf("postgres unreachable at %s (run `make devenv-postgres`): %v", dsn, err)
}
pingCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
if err := store.SQLDB().PingContext(pingCtx); err != nil {
t.Skipf("postgres unreachable at %s (run `make devenv-postgres`): %v", dsn, err)
}
schema, err := New(ctx, providerSettings, sqlschema.Config{}, store)
require.NoError(t, err)
const table = "sqlschema_roundtrip"
exec := func(sql string) {
_, err := store.BunDB().ExecContext(ctx, sql)
require.NoError(t, err)
}
reset := func() {
exec(`DROP TABLE IF EXISTS ` + table)
exec(`CREATE TABLE ` + table + ` (a text, b text, c text)`)
}
t.Cleanup(func() { _, _ = store.BunDB().ExecContext(ctx, `DROP TABLE IF EXISTS `+table) })
roundTrip := func(t *testing.T, declared sqlschema.Index) {
reset()
for _, sql := range schema.Operator().CreateIndex(declared) {
exec(string(sql))
}
indices, err := schema.GetIndices(ctx, table)
require.NoError(t, err)
var got sqlschema.Index
for _, index := range indices {
if index.Name() == declared.Name() {
got = index
}
}
require.NotNil(t, got, "GetIndices did not return %q; returned %d indices", declared.Name(), len(indices))
require.True(t, declared.Equals(got), "round-tripped index should equal the declared one; got %#v", got)
}
t.Run("PlainSingleColumn", func(t *testing.T) {
roundTrip(t, &sqlschema.UniqueIndex{TableName: table, ColumnNames: []sqlschema.ColumnName{"a"}})
})
t.Run("PlainMultiColumn", func(t *testing.T) {
roundTrip(t, &sqlschema.UniqueIndex{TableName: table, ColumnNames: []sqlschema.ColumnName{"a", "b"}})
})
t.Run("FunctionalSingleExpression", func(t *testing.T) {
roundTrip(t, &sqlschema.UniqueIndex{TableName: table, Expressions: []string{"LOWER(a)"}})
})
t.Run("FunctionalMixedKeys", func(t *testing.T) {
roundTrip(t, &sqlschema.UniqueIndex{TableName: table, Expressions: []string{"a", "LOWER(b)", "LOWER(c)"}})
})
t.Run("PartialUnique", func(t *testing.T) {
roundTrip(t, &sqlschema.PartialUniqueIndex{TableName: table, ColumnNames: []sqlschema.ColumnName{"a"}, Where: "b IS NOT NULL"})
})
t.Run("FunctionalPartialIsSkipped", func(t *testing.T) {
reset()
exec(`CREATE UNIQUE INDEX rt_functional_partial ON ` + table + ` (LOWER(a)) WHERE b IS NOT NULL`)
indices, err := schema.GetIndices(ctx, table)
require.NoError(t, err)
require.Empty(t, indices, "functional partial unique index must be skipped, not reconstructed")
})
}

View File

@@ -513,13 +513,6 @@ const routes: AppRoutes[] = [
key: 'AI_ASSISTANT',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
exact: true,
component: LLMObservabilityPage,
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
isPrivate: true,
},
{
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
exact: true,

View File

@@ -20,9 +20,9 @@
padding: 0px 8px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
color: var(--input-with-label-color, var(--l2-foreground));
border: 1px solid var(--l2-border);
background: var(--l2-background);
color: var(--l2-foreground);
display: flex;
justify-content: flex-start;
@@ -35,54 +35,21 @@
min-width: 150px;
font-family: 'Space Mono', monospace !important;
--input-border-radius: 0px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
color: var(--input-with-label-color, var(--l2-foreground));
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-right: none;
border-left: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
font-size: 12px !important;
line-height: 25px;
position: relative;
&:hover,
&:focus {
z-index: 1;
}
.ant-select-selector {
position: relative;
border-radius: inherit;
}
.ant-select:hover .ant-select-selector,
.ant-select-focused .ant-select-selector {
z-index: 1;
}
&.input__has-label-after {
margin-left: -1px;
.ant-select-selector {
margin-left: -1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&.input__has-close-button {
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
~ .close-btn {
margin-left: -1px;
}
}
line-height: 27px;
&::placeholder {
color: var(--input-with-label-color, var(--l3-foreground)) !important;
color: var(--l2-foreground) !important;
font-size: 12px !important;
}
&[type='number']::-webkit-inner-spin-button,
@@ -96,35 +63,25 @@
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
height: 100%;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
width: 38px;
position: relative;
&:hover,
&:focus {
z-index: 2;
}
}
&.labelAfter {
.input {
border-radius: 2px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
.ant-select-selector {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
.label {
border-left: none;
border-radius: 0px 2px 2px 0px;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
}
}

View File

@@ -45,10 +45,7 @@ function InputWithLabel({
>
{!labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
<Input
className={cx('input', {
'input__has-label-after': !labelAfter,
'input__has-close-button': !!onClose,
})}
className="input"
placeholder={placeholder}
type={type}
value={inputValue}

View File

@@ -80,8 +80,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
var(--l1-border),
var(--l1-border) 4px,
transparent 4px,
transparent 8px
);
@@ -101,8 +101,7 @@
top: 12px;
width: 6px;
height: 6px;
border-left: 6px dotted
var(--query-builder-v2-border-color, var(--l2-border));
border-left: 6px dotted var(--l1-border);
}
/* Horizontal line pointing from vertical to the item */
@@ -115,8 +114,8 @@
height: 1px;
background: repeating-linear-gradient(
to right,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
var(--l1-border),
var(--l1-border) 4px,
transparent 4px,
transparent 8px
);
@@ -242,8 +241,7 @@
top: 12px;
width: 6px;
height: 6px;
border-left: 6px dotted
var(--query-builder-v2-border-color, var(--l2-border));
border-left: 6px dotted var(--l1-border);
}
/* Horizontal line pointing from vertical to the item */
@@ -256,8 +254,8 @@
height: 1px;
background: repeating-linear-gradient(
to right,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
var(--l1-border),
var(--l1-border) 4px,
transparent 4px,
transparent 8px
);
@@ -275,16 +273,6 @@
line-height: 16px; /* 128.571% */
resize: none;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
&:placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
}
}
.formula-legend {
@@ -294,42 +282,15 @@
.ant-input-group-addon {
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
background: var(
--query-builder-v2-background-color,
var(--l2-background)
);
color: var(--query-builder-v2-color, var(--l2-foreground));
background: var(--l2-background);
color: var(--l2-foreground);
font-size: 12px;
font-weight: 300;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
position: relative;
}
.ant-input {
border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important;
height: 36px;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
position: relative;
margin-left: -1px;
&:hover,
&:focus {
z-index: 1;
border-color: var(--internal-ant-border-color-hover);
}
&:placeholder {
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
}
}
}
}
@@ -362,8 +323,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
var(--l1-border),
var(--l1-border) 4px,
transparent 4px,
transparent 8px
);
@@ -434,8 +395,8 @@
width: 1px;
background: repeating-linear-gradient(
to bottom,
var(--query-builder-v2-border-color, var(--l2-border)),
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
var(--l1-border),
var(--l1-border) 4px,
transparent 4px,
transparent 8px
);
@@ -451,7 +412,7 @@
min-width: 120px;
border-radius: 2px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
@@ -496,16 +457,13 @@
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
height: 36px !important;
border: 1px solid var(--l1-border) !important;
background: var(--l1-background) !important;
height: 34px !important;
box-sizing: border-box !important;
.ant-select-selection-item {
color: var(--query-builder-v2-color, var(--l1-foreground));
color: var(--l1-foreground);
}
}

View File

@@ -92,11 +92,6 @@
.ant-select {
width: 100%;
.ant-select-selector {
min-height: 36px;
}
.ant-select-selection-search-input {
min-width: max-content !important;
max-width: 100% !important;
@@ -105,12 +100,9 @@
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground));
border: 1.005px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
@@ -131,7 +123,6 @@
.input {
flex: initial;
width: 100px !important;
min-height: 36px;
}
}
}

View File

@@ -8,9 +8,9 @@
.ant-select-selection-search-input {
font-size: 12px !important;
line-height: 25px;
line-height: 27px;
&::placeholder {
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
color: var(--l2-foreground) !important;
font-size: 12px !important;
}
}
@@ -22,12 +22,9 @@
.ant-select-selector {
width: 100%;
border-radius: 2px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground));
border: 1px solid var(--l1-border) !important;
background: var(--l1-background);
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
@@ -36,49 +33,36 @@
min-height: 36px;
.ant-select-selection-placeholder {
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
color: var(--l2-foreground) !important;
font-size: 12px !important;
}
}
}
.qb-select-popover.ant-select-dropdown {
border-radius: 4px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.ant-select-dropdown {
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
.ant-select-item {
color: var(--query-builder-v2-color, var(--l2-foreground));
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
.ant-select-item {
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
&:not(:last-of-type) {
margin-bottom: 4px;
}
&:hover,
&.ant-select-item-option-active {
background: var(--l3-background) !important;
}
&:hover,
&.ant-select-item-option-active {
background: var(
--query-builder-v2-selected-background-color,
var(--l3-background)
) !important;
}
&.ant-select-item-option-selected {
background: var(
--query-builder-v2-selected-background-color,
var(--l3-background)
) !important;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
font-weight: 600;
&.ant-select-item-option-selected {
background: var(--l3-background) !important;
border: 1px solid var(--l1-border);
font-weight: 600;
}
}
}
}

View File

@@ -142,7 +142,6 @@ export const MetricsSelect = memo(function MetricsSelect({
{signalSourceChangeEnabled && (
<Select
className="source-selector"
popupClassName="qb-select-popover"
placeholder="Source"
options={SOURCE_OPTIONS}
value={source}

View File

@@ -1,23 +1,8 @@
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
.query-add-ons {
width: 100%;
--toggle-group-secondary-bg: var(
--query-builder-v2-toggle-group-background-color,
var(--l1-background-hover)
);
--toggle-group-secondary-border: var(
--query-builder-v2-toggle-group-border-color,
var(--l2-border)
);
--toggle-group-secondary-active-bg: var(
--query-builder-v2-toggle-group-active-background-color,
var(--l1-background)
);
--toggle-group-secondary-bg-hover: var(
--query-builder-v2-toggle-group-background-color-hover,
var(--l2-background)
);
.add-on-tab-title {
display: flex;
align-items: center;
@@ -44,33 +29,32 @@
font-style: normal;
font-weight: var(--font-weight-normal);
color: var(--query-builder-v2-color, var(--l2-foreground));
color: var(--l2-foreground);
}
> button {
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--l1-border);
border-left: none;
min-width: 120px;
height: 36px;
line-height: 36px;
&:first-child {
border-left: 1px solid
var(--query-builder-v2-border-color, var(--l2-border));
border-left: 1px solid var(--l1-border);
}
&::before {
background: var(--query-builder-v2-border-color, var(--l2-border));
background: var(--l1-border);
}
&[data-state='on'] {
color: var(--text-robin-500);
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--l1-border);
display: none;
&::before {
background: var(--query-builder-v2-border-color, var(--l2-border));
background: var(--l1-border);
}
}
}
@@ -81,7 +65,7 @@
height: 30px;
border-radius: 2px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
@@ -94,13 +78,10 @@
align-items: center;
.having-filter-select-container {
position: relative;
width: 100%;
display: flex;
flex-direction: row;
align-items: flex-start;
background: var(--query-builder-v2-background-color, var(--l2-background));
padding-right: 38px;
align-items: center;
.having-filter-select-editor {
border-radius: 2px;
@@ -125,17 +106,15 @@
}
.cm-content {
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-left-width: 0px;
border-right-width: 0px;
border-radius: 2px;
border: 1px solid var(--l1-border);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
padding: 0px !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
background-color: var(--l2-background) !important;
&:focus-within {
border-color: var(--query-builder-v2-border-color, var(--l2-border));
border-color: var(--l1-border);
}
}
@@ -239,32 +218,17 @@
}
.cm-line {
min-height: 34px;
line-height: 32px !important;
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
background-color: var(--l2-background) !important;
::-moz-selection {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
opacity: 0.5 !important;
}
::selection {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
opacity: 0.5 !important;
}
@@ -273,11 +237,8 @@
}
.chip-decorator {
background: var(
--query-builder-v2-chip-decorator-background-color,
var(--l3-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
background: var(--l3-background) !important;
color: var(--l1-foreground) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
@@ -285,38 +246,34 @@
}
.cm-selectionBackground {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
opacity: 0.5 !important;
}
.cm-activeLine > span {
font-size: 12px !important;
}
.cm-placeholder {
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
}
}
}
.close-btn {
position: absolute;
top: 0;
right: 0;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
height: 100%;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
&:focus:not(:focus-visible),
&.ant-btn:focus:not(:focus-visible) {
border-color: var(--l2-border);
border-left-color: transparent;
outline: none;
box-shadow: none;
}
}
}
}
@@ -343,8 +300,20 @@
font-size: 12px !important;
}
input {
min-height: 36px;
$add-on-row-height: 38px;
.periscope-input-with-label {
.input {
.ant-select {
height: $add-on-row-height;
}
}
}
.input-with-label {
.input {
height: $add-on-row-height;
}
}
}
}

View File

@@ -23,7 +23,7 @@
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
color: var(--l2-foreground) !important;
&.error {
.cm-editor {
@@ -51,15 +51,14 @@
.cm-content {
border-radius: 2px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--l1-border);
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
padding: 0px !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
background-color: var(--l1-background) !important;
&:focus-within {
border-color: var(--query-builder-v2-border-color, var(--l2-border));
border-color: var(--l1-border);
}
}
@@ -75,7 +74,7 @@
right: 0px !important;
border-radius: 4px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--l1-border);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
@@ -119,7 +118,7 @@
box-sizing: border-box;
overflow: hidden;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
color: var(--l2-foreground) !important;
font-family: 'Space Mono', monospace !important;
.cm-completionIcon {
@@ -128,10 +127,7 @@
&:hover,
&[aria-selected='true'] {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
color: var(--l1-foreground) !important;
font-weight: 600 !important;
}
@@ -146,24 +142,15 @@
.cm-line {
line-height: 36px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
background-color: var(--l2-background) !important;
::-moz-selection {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
opacity: 0.5 !important;
}
::selection {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
opacity: 0.5 !important;
}
@@ -172,11 +159,8 @@
}
.chip-decorator {
background: var(
--query-builder-v2-chip-decorator-background-color,
var(--l3-background)
) !important;
color: var(--query-builder-v2-color, var(--l1-foreground)) !important;
background: var(--l3-background) !important;
color: var(--l1-foreground) !important;
border-radius: 4px;
padding: 2px 4px;
margin-right: 4px;
@@ -184,10 +168,7 @@
}
.cm-selectionBackground {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
opacity: 0.5 !important;
}
}
@@ -220,11 +201,12 @@
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
height: 100%;
border: 1px solid var(--l1-border);
background: var(--l1-background);
height: 38px;
width: 38px;
border-left: transparent;
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
@@ -235,13 +217,13 @@
height: 36px;
line-height: 36px;
border-radius: 2px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--l1-border);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
font-family: 'Space Mono', monospace !important;
&::placeholder {
color: var(--query-builder-v2-color, var(--l2-foreground));
color: var(--l1-foreground);
opacity: 0.5;
}
}
@@ -256,10 +238,9 @@
.query-aggregation-interval-input-container {
.query-aggregation-interval-input {
input {
min-height: 36px;
max-width: 120px;
&::placeholder {
color: var(--query-builder-v2-color, var(--l2-foreground));
color: var(--l2-foreground);
}
}
}
@@ -270,8 +251,8 @@
.query-aggregation-error-popover {
.ant-popover-inner {
background-color: var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background-color: var(--l1-border);
border: 1px solid var(--l1-border);
border-radius: 4px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
}

View File

@@ -1,7 +1,7 @@
.add-trace-operator-button,
.add-new-query-button,
.add-formula-button {
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
background: var(--query-builder-v2-background-color, var(--l2-background));
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}

View File

@@ -40,14 +40,11 @@ $max-recents-shown: 5;
.query-status-container {
width: 32px;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
background-color: var(--l1-background) !important;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--l1-border);
border-radius: 2px;
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
@@ -86,16 +83,16 @@ $max-recents-shown: 5;
.cm-content {
border-radius: 2px;
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border: 1px solid var(--l1-border);
padding: 0px !important;
&:focus-within {
border-color: var(--query-builder-v2-border-color, var(--l2-border));
border-color: var(--l1-border);
}
}
&.cm-focused {
outline: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
outline: 1px solid var(--l1-border);
}
.cm-tooltip-autocomplete {
@@ -186,17 +183,11 @@ $max-recents-shown: 5;
font-family: 'Space Mono', monospace !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
background-color: var(--l1-background) !important;
color: var(--l2-foreground) !important;
&:hover {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
}
.cm-completionIcon {
@@ -214,10 +205,7 @@ $max-recents-shown: 5;
}
&[aria-selected='true'] {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
font-weight: 600 !important;
}
}
@@ -286,49 +274,25 @@ $max-recents-shown: 5;
}
.cm-line {
line-height: 36px !important;
line-height: 34px !important;
font-family: 'Space Mono', monospace !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
&,
.ͼ1a {
color: var(--query-builder-v2-color, var(--l2-foreground));
}
background-color: var(--l2-background) !important;
::-moz-selection {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
opacity: 0.5 !important;
}
::selection {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
opacity: 0.5 !important;
}
}
.cm-selectionBackground {
background: var(
--query-builder-v2-selection-background-color,
var(--l3-background)
) !important;
background: var(--l3-background) !important;
opacity: 0.5 !important;
}
.cm-placeholder {
color: var(
--query-builder-v2-placeholder-color,
var(--l3-foreground)
) !important;
}
}
.cursor-position {

View File

@@ -65,14 +65,6 @@
display: flex;
flex-direction: column;
gap: 8px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
}
&-aggregation-container {

View File

@@ -57,13 +57,10 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
}
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
// Use the same width-derived interval spread the ticks use, so the badge
// unit always matches the tick unit (narrow rulers pick fewer intervals).
const intervalSpread = spread / getMinimumIntervalsBasedOnWidth(width);
const unit = getIntervalUnit(intervalSpread, offsetTimestamp);
const unit = getIntervalUnit(spread, offsetTimestamp);
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
return `${formatted}${unit.name}`;
}, [cursorXPercent, spread, offsetTimestamp, width]);
}, [cursorXPercent, spread, offsetTimestamp]);
if (endTimestamp < startTimestamp) {
console.error(
@@ -97,17 +94,12 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={timelineHeight * 2 - 3}
y={timelineHeight * 2}
fill={strokeColor}
>
{interval.label}
</text>
<line
y1={0}
y2={timelineHeight - 3}
stroke={strokeColor}
strokeWidth="1"
/>
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
</g>
))}
</svg>

View File

@@ -1,63 +0,0 @@
import {
getIntervals,
getIntervalUnit,
getMinimumIntervalsBasedOnWidth,
} from '../utils';
// A tick label looks like "200.00ms" / "1.10s" — grab the trailing unit name.
function unitOfLabel(label: string): string {
const match = label.match(/[a-z]+$/i);
return match ? match[0] : '';
}
describe('getMinimumIntervalsBasedOnWidth', () => {
it('returns fewer intervals for narrower rulers', () => {
expect(getMinimumIntervalsBasedOnWidth(500)).toBe(3);
expect(getMinimumIntervalsBasedOnWidth(700)).toBe(4);
expect(getMinimumIntervalsBasedOnWidth(900)).toBe(5);
expect(getMinimumIntervalsBasedOnWidth(1200)).toBe(6);
});
});
describe('getIntervalUnit', () => {
it('selects the unit from the interval spread', () => {
expect(getIntervalUnit(130, 0).name).toBe('ms');
expect(getIntervalUnit(1100, 0).name).toBe('s');
expect(getIntervalUnit(70_000, 0).name).toBe('m');
});
it('accounts for a large offset (deep-zoom labels stay readable)', () => {
// Cursor spread is tiny but the window starts 5,000,000ms into the trace.
expect(getIntervalUnit(100, 5_000_000).name).toBe('hr');
});
// Regression: the interval COUNT changes the chosen unit, so the crosshair
// badge must use the same width-derived count as the ticks. On a 5.5s trace a
// narrow ruler (5 intervals → 1100ms → "s") and a wide one (6 intervals →
// 916ms → "ms") pick different units.
it('can resolve to different units for the same spread at different counts', () => {
const spread = 5500;
expect(getIntervalUnit(spread / 5, 0).name).toBe('s');
expect(getIntervalUnit(spread / 6, 0).name).toBe('ms');
});
});
describe('badge/tick unit consistency', () => {
// The invariant the fix guarantees: when the badge and the ticks are fed the
// same width-derived intervalSpread, every tick label uses the badge's unit.
it.each([
{ spread: 1287, width: 900 },
{ spread: 5500, width: 900 }, // narrow → 5 intervals, the mismatch case
{ spread: 5500, width: 1200 }, // wide → 6 intervals
{ spread: 120_000, width: 700 },
])('spread=$spread width=$width', ({ spread, width }) => {
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalSpread = spread / minIntervals;
const badgeUnit = getIntervalUnit(intervalSpread, 0).name;
const intervals = getIntervals(intervalSpread, spread, 0);
intervals.forEach((interval) => {
expect(unitOfLabel(interval.label)).toBe(badgeUnit);
});
});
});

View File

@@ -10,18 +10,14 @@ export type { Interval };
/**
* Select the interval unit matching the timeline's logic.
* Exported so crosshair labels use the same unit as the timeline ticks.
*
* Takes the already-computed `intervalSpread` (spread / minIntervals) rather
* than deriving it from a hardcoded interval count — the tick count is
* width-dependent (`getMinimumIntervalsBasedOnWidth`), so callers must pass the
* same `intervalSpread` the ticks use or the badge unit can diverge from the
* ticks (e.g. ms vs s) on narrower rulers like the waterfall.
* Exported so crosshair labels use the same unit as timeline ticks.
*/
export function getIntervalUnit(
intervalSpread: number,
spread: number,
offsetTimestamp: number,
): IIntervalUnit {
const minIntervals = 6;
const intervalSpread = spread / minIntervals;
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let unit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {

View File

@@ -89,7 +89,6 @@ const ROUTES = {
AI_ASSISTANT_BASE: '/ai-assistant',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: '/llm-observability/attribute-mapping',
LLM_OBSERVABILITY_BASE: '/llm-observability',
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',

View File

@@ -41,28 +41,6 @@
.steps-container {
width: 80%;
.alert-query-section-container {
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
}
}
.qb-chart-preview-container {

View File

@@ -3,30 +3,6 @@
overflow-x: auto;
overflow-y: hidden;
--query-builder-v2-color: var(--l2-foreground);
--query-builder-v2-background-color: var(--l3-background);
--query-builder-v2-border-color: var(--l3-border);
--query-builder-v2-selection-background-color: var(--l2-background);
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
--query-builder-v2-placeholder-color: var(--l3-foreground);
--query-builder-v2-selected-background-color: var(--l3-foreground);
--query-search-background-color: var(--l3-background);
--query-search-background-color-selection: var(--l3-background);
--input-with-label-border-color: var(--l3-border);
--input-with-label-background-color: var(--l3-background);
--input-with-label-color: var(--l2-foreground);
--query-builder-v2-toggle-group-background-color: var(--l3-background);
--query-builder-v2-toggle-group-border-color: var(--l3-border);
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
--input-height: 36px;
--input-hover-background: var(--l3-background);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
--input-background: var(--l3-background);
--input-focus-background: var(--l3-background);
--input-border-color: var(--l3-border);
.full-view-header-container {
display: flex;
flex-direction: column;

View File

@@ -1,13 +0,0 @@
.llmObservabilityAttributeMapping {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
}
.tableEmpty {
padding: var(--spacing-12) var(--spacing-6);
text-align: center;
color: var(--l3-foreground);
font-size: var(--periscope-font-size-base);
}

View File

@@ -1,26 +0,0 @@
import AttributeMappingHeader from './components/AttributeMappingHeader';
import styles from './LLMObservabilityAttributeMapping.module.scss';
const noop = (): void => undefined;
function LLMObservabilityAttributeMapping(): JSX.Element {
return (
<div
className={styles.llmObservabilityAttributeMapping}
data-testid="llm-observability-attribute-mapping-page"
>
<AttributeMappingHeader
isDirty={false}
isSaving={false}
onDiscard={noop}
onSave={noop}
/>
<div className={styles.tableEmpty} data-testid="attribute-mapping-empty">
No mapping groups configured yet.
</div>
</div>
);
}
export default LLMObservabilityAttributeMapping;

View File

@@ -1,34 +0,0 @@
.pageHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-8);
}
.pageHeaderTitle {
display: flex;
flex-direction: column;
}
.title {
margin: 0;
font-size: var(--periscope-font-size-large);
font-weight: var(--font-weight-semibold);
}
.description {
margin: var(--spacing-2) 0 0;
font-size: var(--periscope-font-size-base);
color: var(--l3-foreground);
}
.pageHeaderActions {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.unsavedChanges {
font-size: var(--periscope-font-size-base);
color: var(--accent-amber);
}

View File

@@ -1,56 +0,0 @@
import { Button } from '@signozhq/ui/button';
import styles from './AttributeMappingHeader.module.scss';
interface AttributeMappingHeaderProps {
isDirty: boolean;
isSaving: boolean;
onDiscard: () => void;
onSave: () => void;
}
function AttributeMappingHeader({
isDirty,
isSaving,
onDiscard,
onSave,
}: AttributeMappingHeaderProps): JSX.Element {
return (
<header className={styles.pageHeader}>
<div className={styles.pageHeaderTitle}>
<h1 className={styles.title}>Attribute Mapping</h1>
<p className={styles.description}>
Configure source-to-target attribute remapping for LLM traces
</p>
</div>
<div className={styles.pageHeaderActions}>
{isDirty && (
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
Unsaved changes
</span>
)}
<Button
variant="outlined"
color="secondary"
onClick={onDiscard}
disabled={!isDirty || isSaving}
testId="discard-changes-btn"
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={onSave}
loading={isSaving}
disabled={!isDirty || isSaving}
testId="save-changes-btn"
>
{isSaving ? 'Saving…' : 'Save changes'}
</Button>
</div>
</header>
);
}
export default AttributeMappingHeader;

View File

@@ -1 +0,0 @@
export { default } from './AttributeMappingHeader';

View File

@@ -39,9 +39,6 @@ describe('LLMObservability (integration)', () => {
expect(
screen.getByRole('tab', { name: 'Model pricing' }),
).toBeInTheDocument();
expect(
screen.getByRole('tab', { name: 'Attribute Mapping' }),
).toBeInTheDocument();
});
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
@@ -57,29 +54,6 @@ describe('LLMObservability (integration)', () => {
);
});
it('navigates to the attribute mapping route when that tab is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
});
await user.click(screen.getByRole('tab', { name: 'Attribute Mapping' }));
expect(safeNavigateMock).toHaveBeenCalledWith(
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
);
});
it('renders the attribute mapping page on the attribute mapping route', () => {
render(<LLMObservability />, undefined, {
initialRoute: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
});
expect(
screen.getByTestId('llm-observability-attribute-mapping-page'),
).toBeInTheDocument();
});
it('renders the model-pricing page on the configuration route', async () => {
setupList();
render(<LLMObservability />, undefined, {

View File

@@ -4,13 +4,11 @@ import { type TabItemProps } from '@signozhq/ui/tabs';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import LLMObservabilityAttributeMapping from '../AttributeMapping/LLMObservabilityAttributeMapping';
import Overview from '../Overview/Overview';
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
const ATTRIBUTE_MAPPING_KEY = ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING;
interface UseLLMObservabilityTabsResult {
items: TabItemProps[];
@@ -26,12 +24,9 @@ export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
let activeTab: string = OVERVIEW_KEY;
if (pathname.startsWith(CONFIGURATION_KEY)) {
activeTab = CONFIGURATION_KEY;
} else if (pathname.startsWith(ATTRIBUTE_MAPPING_KEY)) {
activeTab = ATTRIBUTE_MAPPING_KEY;
}
const activeTab = pathname.startsWith(CONFIGURATION_KEY)
? CONFIGURATION_KEY
: OVERVIEW_KEY;
const onTabChange = useCallback(
(key: string): void => {
@@ -51,11 +46,6 @@ export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
label: 'Model pricing',
children: <LLMObservabilityModelPricing />,
},
{
key: ATTRIBUTE_MAPPING_KEY,
label: 'Attribute Mapping',
children: <LLMObservabilityAttributeMapping />,
},
];
return { items, activeTab, onTabChange };

View File

@@ -25,14 +25,6 @@
.meter-explorer-content-section {
width: 100%;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.explore-header {
display: flex;
align-items: center;

View File

@@ -1,14 +1,6 @@
.metrics-explorer-explore-container {
padding-bottom: 80px;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.explore-header {
display: flex;
align-items: center;

View File

@@ -1,12 +1,4 @@
.dashboard-navigation {
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.run-query-dashboard-btn {
min-width: 180px;
}

View File

@@ -31,14 +31,8 @@
min-width: 32px;
border-radius: 2px;
--periscope-btn-border-color: var(
--query-builder-v2-border-color,
var(--l2-border)
);
--periscope-btn-background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
);
border: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}

View File

@@ -27,11 +27,8 @@
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;
background-color: var(
--query-builder-v2-background-color,
var(--l2-background)
) !important;
margin-left: -1px;
background-color: var(--l1-border) !important;
opacity: 0.8;
&:disabled {
opacity: 0.4;
@@ -89,8 +86,8 @@
border-bottom-left-radius: 3px;
.ant-select-selector {
border: 1px solid var(--query-builder-v2-border-color, none);
background: var(--query-builder-v2-background-color, var(--l3-background));
border: none;
background: var(--l3-background);
}
&.showInput {
@@ -104,20 +101,12 @@
.query-function-value {
width: 70px;
border-left: 0;
background: var(--query-builder-v2-background-color, var(--l2-background));
background: var(--l2-background);
border-radius: 0;
border: 1px solid transparent;
border-top: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-bottom: 1px solid
var(--query-builder-v2-border-color, var(--l2-border));
height: 32px;
&:focus {
border-color: var(--internal-ant-border-color-focus);
}
&:hover {
border-color: var(--internal-ant-border-color-hover);
border-color: transparent !important;
}
}
@@ -125,7 +114,7 @@
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
border-left: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
border-left: 1px solid var(--l2-border);
border-top-left-radius: 0px !important;
border-bottom-left-radius: 0px !important;

View File

@@ -205,7 +205,6 @@ export const routesToSkip = [
ROUTES.SOMETHING_WENT_WRONG,
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];

View File

@@ -16,14 +16,6 @@
min-height: 0;
.log-explorer-query-container {
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
display: flex;
flex-direction: column;
flex: 1;

View File

@@ -1,25 +0,0 @@
.container {
// Gutter matches the header/subHeader 16px; bottom gap before the panels.
padding: 0 16px 12px;
}
.title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.link {
display: inline-flex;
align-items: center;
gap: 4px;
color: inherit;
font-weight: 500;
white-space: nowrap;
&:hover {
opacity: 0.85;
}
}

View File

@@ -1,47 +0,0 @@
import { useState } from 'react';
import { Callout } from '@signozhq/ui/callout';
import { ArrowUpRight } from '@signozhq/icons';
import styles from './MissingSpansBanner.module.scss';
const MISSING_SPANS_DOCS_URL =
'https://signoz.io/docs/userguide/traces/#missing-spans';
function MissingSpansBanner(): JSX.Element | null {
// Session-only dismissal — not persisted, so the banner returns on reload.
const [isDismissed, setIsDismissed] = useState(false);
if (isDismissed) {
return null;
}
// Wrapper owns the gutter: Callout is width:100%, so putting the gutter as a
// margin on it would overflow the parent by the margin width. Pad instead.
return (
<div className={styles.container}>
<Callout
type="info"
size="small"
showIcon
action="dismissible"
onClick={(): void => setIsDismissed(true)}
testId="missing-spans-banner"
title={
<span className={styles.title}>
This trace has missing spans
<a
className={styles.link}
href={MISSING_SPANS_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
>
Learn More <ArrowUpRight size={14} />
</a>
</span>
}
/>
</div>
);
}
export default MissingSpansBanner;

View File

@@ -31,7 +31,6 @@ import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceStore } from '../stores/traceStore';
import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import MissingSpansBanner from './MissingSpansBanner';
import TraceOptionsMenu from './TraceOptionsMenu';
import styles from './TraceDetailsHeader.module.scss';
@@ -49,7 +48,6 @@ export interface TraceMetadataForHeader {
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
hasMissingSpans: boolean;
}
interface TraceDetailsHeaderProps {
@@ -231,8 +229,6 @@ function TraceDetailsHeader({
</div>
)}
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}
title="Preview fields"

View File

@@ -41,7 +41,6 @@
:global(.ant-collapse-header) {
border-top: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
border-radius: 0 !important;
}
:global(.ant-collapse-content) {
@@ -99,13 +98,6 @@
flex-direction: column;
overflow: hidden;
// The flamegraph's ResizableBox above renders a 1px resize handle at its
// bottom edge; drop the header's own top border so the two don't stack
// into a double border at the flamegraph/waterfall juncture.
:global(.ant-collapse-header) {
border-top: none;
}
:global(.ant-collapse-item) {
flex: 1;
display: flex;

View File

@@ -49,6 +49,59 @@
flex-direction: column;
}
.missingSpans {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
margin: 16px;
padding: 12px;
border-radius: 4px;
background: rgba(69, 104, 220, 0.1);
}
.leftInfo {
display: flex;
align-items: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
.text {
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
.rightInfo {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
&:hover {
background-color: unset;
color: var(--bg-robin-200);
}
}
.splitPanel {
flex: 1;
min-height: 0;
@@ -72,9 +125,6 @@
.sidebarHeader {
flex-shrink: 0;
// Matches the body `.sidebar` border-right so the span-name / timeline
// divider is continuous from the top of the container through the ruler.
border-right: 1px solid var(--l2-border);
}
.resizeHandleHeader {
@@ -120,7 +170,7 @@
overflow-x: auto;
overflow-y: hidden;
flex-shrink: 0;
border-right: 1px solid var(--l2-border);
border-right: 1px solid var(--l1-border);
// ResizableBox child renders with a global `.resizable-box__content` class
// — give it independent horizontal scrolling.
@@ -135,18 +185,6 @@
scrollbar-width: none;
}
// The drag handle is painted --l2-border by default, which would stack with
// our border-right into a 2px divider. Keep it as a hover-only affordance so
// the border-right stays the sole 1px divider (matching the header segment).
:global(.resizable-box__handle--right) {
background: transparent;
&:hover,
&:active {
background: var(--primary);
}
}
&::-webkit-scrollbar {
height: 0.3rem;
}
@@ -178,12 +216,10 @@
.treeRow:hover,
.treeRow.hoveredSpan {
// Left end of the row band — round only the outer (left) corners so the
// highlight joins the status + timeline segments into one continuous band.
border-radius: 4px 0 0 4px;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 60%,
var(--l3-background) 20%,
transparent
) !important;
@@ -226,22 +262,20 @@
--badge-border-width: 0px;
&.hoveredSpan {
// Middle segment of the row band — square so it butts up against the
// name and timeline segments (no rounded corner at the badge column).
border-radius: 0;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 60%,
var(--l3-background) 20%,
transparent
) !important;
}
&.isInterested,
&.isSelectedNonMatching {
border-radius: 0;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 80%,
var(--l3-background) 40%,
transparent
) !important;
}
@@ -275,21 +309,20 @@
&:hover,
&.hoveredSpan {
// Right end of the row band — round only the outer (right) corners.
border-radius: 0 4px 4px 0;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 60%,
var(--l3-background) 20%,
transparent
) !important;
}
&:has(.isInterested),
&:has(.isSelectedNonMatching) {
border-radius: 0 4px 4px 0;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 80%,
var(--l3-background) 40%,
transparent
) !important;
}
@@ -312,11 +345,10 @@
&.isInterested,
&.isSelectedNonMatching {
// Left end of the row band — outer (left) corners only.
border-radius: 4px 0 0 4px;
border-radius: 4px;
background: color-mix(
in srgb,
var(--l3-background) 80%,
var(--l3-background) 40%,
transparent
) !important;
}
@@ -439,7 +471,7 @@
padding-left: 8px;
flex-shrink: 0;
height: 100%;
background: linear-gradient(to left, var(--l2-background) 40%, transparent);
background: linear-gradient(to left, var(--l1-background) 60%, transparent);
z-index: 2;
opacity: 0;
pointer-events: none;
@@ -567,18 +599,6 @@
opacity: 0.15;
}
// A dimmed span must still show the full-opacity hover state when hovered.
// These win over `.isDimmed` on specificity so brightness is restored across
// the whole row (name column, status cell, and timeline bar) on hover.
.treeRow:hover .isDimmed,
.treeRow.hoveredSpan .isDimmed,
.timelineRow:hover .isDimmed,
.timelineRow.hoveredSpan .isDimmed,
.statusCell:hover.isDimmed,
.statusCell.hoveredSpan.isDimmed {
opacity: 1;
}
.isHighlighted {
opacity: 1;
}

View File

@@ -33,7 +33,14 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import { ChevronDown, ChevronRight, Link, ListPlus } from '@signozhq/icons';
import {
ArrowUpRight,
ChevronDown,
ChevronRight,
CircleAlert,
Link,
ListPlus,
} from '@signozhq/icons';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { resolveSpanColor } from 'pages/TraceDetailsV3/utils';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
@@ -544,9 +551,7 @@ function Success(props: ISuccessProps): JSX.Element {
cursorX,
onMouseMove: onCrosshairMove,
onMouseLeave: onCrosshairLeave,
// Rows are padded 0 15px while `.timeline` spans full width — inset the
// crosshair by the same 15px so it aligns with the ruler ticks and bars.
} = useCrosshair({ containerRef: timelineAreaRef, insetX: 15 });
} = useCrosshair({ containerRef: timelineAreaRef, enabled: false });
// Imperative DOM class toggling for hover highlights (avoids React re-renders)
const applyHoverClass = useCallback((spanId: string | null): void => {
@@ -849,6 +854,28 @@ function Success(props: ISuccessProps): JSX.Element {
return (
<div className={styles.root}>
{traceMetadata.hasMissingSpans && (
<div className={styles.missingSpans}>
<section className={styles.leftInfo}>
<CircleAlert size={14} />
<span className={styles.text}>This trace has missing spans</span>
</section>
<Button
variant="ghost"
color="secondary"
className={styles.rightInfo}
suffix={<ArrowUpRight size={14} />}
onClick={(): WindowProxy | null =>
window.open(
'https://signoz.io/docs/traces-management/troubleshooting/faqs/#q-why-are-some-spans-missing-from-a-trace',
'_blank',
)
}
>
Learn More
</Button>
</div>
)}
{isFetching && <div className={styles.loadingBar} />}
<div className={styles.splitPanel} ref={scrollContainerRef}>
{/* Sticky header row */}
@@ -967,8 +994,8 @@ function Success(props: ISuccessProps): JSX.Element {
transform: `translateY(${virtualRow.start}px)`,
}}
data-span-id={span.span_id}
onMouseEnter={(): void => applyHoverClass(span.span_id)}
onMouseLeave={(): void => applyHoverClass(null)}
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
onMouseLeave={handleRowMouseLeave}
onClick={(): void => handleSpanClick(span)}
>
{span.response_status_code && (

View File

@@ -1,96 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { RefObject } from 'react';
import { useCrosshair } from '../useCrosshair';
// Container spanning [left, left+width]; getBoundingClientRect is all the hook reads.
function mockContainer(left: number, width: number): RefObject<HTMLElement> {
const el = document.createElement('div');
el.getBoundingClientRect = jest.fn(
(): DOMRect =>
({
left,
width,
top: 0,
height: 0,
x: left,
y: 0,
right: left + width,
bottom: 0,
toJSON: (): Record<string, unknown> => ({}),
}) as DOMRect,
);
return { current: el };
}
function move(clientX: number): React.MouseEvent {
return { clientX } as React.MouseEvent;
}
describe('useCrosshair', () => {
it('maps the cursor to 0 at the container edge with no inset', () => {
const containerRef = mockContainer(100, 1000);
const { result } = renderHook(() => useCrosshair({ containerRef }));
act(() => result.current.onMouseMove(move(600)));
expect(result.current.cursorX).toBe(500);
expect(result.current.cursorXPercent).toBeCloseTo(0.5);
});
it('offsets and rescales by insetX so 0% aligns with the content start', () => {
const containerRef = mockContainer(100, 1000); // content = [115, 1085], width 970
const { result } = renderHook(() =>
useCrosshair({ containerRef, insetX: 15 }),
);
// At the content start (left + inset) → 0ms, line sits at the inset.
act(() => result.current.onMouseMove(move(115)));
expect(result.current.cursorX).toBe(15);
expect(result.current.cursorXPercent).toBe(0);
// Halfway through the 970px content → 50%.
act(() => result.current.onMouseMove(move(600)));
expect(result.current.cursorX).toBe(500);
expect(result.current.cursorXPercent).toBeCloseTo(485 / 970);
});
it('clamps the dead padding zones to [0, 1]', () => {
const containerRef = mockContainer(100, 1000);
const { result } = renderHook(() =>
useCrosshair({ containerRef, insetX: 15 }),
);
// Left of the content (inside the left padding) → clamped to start.
act(() => result.current.onMouseMove(move(100)));
expect(result.current.cursorX).toBe(15);
expect(result.current.cursorXPercent).toBe(0);
// Right of the content (container right edge) → clamped to end.
act(() => result.current.onMouseMove(move(1100)));
expect(result.current.cursorXPercent).toBe(1);
});
it('resets on mouse leave', () => {
const containerRef = mockContainer(0, 800);
const { result } = renderHook(() => useCrosshair({ containerRef }));
act(() => result.current.onMouseMove(move(400)));
expect(result.current.cursorX).not.toBeNull();
act(() => result.current.onMouseLeave());
expect(result.current.cursorX).toBeNull();
expect(result.current.cursorXPercent).toBeNull();
});
it('is inert when disabled', () => {
const containerRef = mockContainer(0, 800);
const { result } = renderHook(() =>
useCrosshair({ containerRef, enabled: false }),
);
act(() => result.current.onMouseMove(move(400)));
expect(result.current.cursorX).toBeNull();
expect(result.current.cursorXPercent).toBeNull();
});
});

View File

@@ -3,16 +3,6 @@ import { RefObject, useCallback, useState } from 'react';
interface UseCrosshairArgs {
containerRef: RefObject<HTMLElement>;
enabled?: boolean;
/**
* Symmetric horizontal inset (px) of the content (ruler ticks + bars) inside
* the container: shifts the origin right by `insetX` and shrinks the usable
* width by `2 * insetX`. The waterfall pads its rows by 15px while the
* crosshair container spans the full width, so the crosshair must map the
* cursor into that inset content box to line up with 0ms/ticks/bars.
* Flamegraph pads its parent instead, so its container is already the content
* box → 0 (default).
*/
insetX?: number;
}
interface UseCrosshairReturn {
@@ -35,7 +25,6 @@ interface UseCrosshairReturn {
export function useCrosshair({
containerRef,
enabled = true,
insetX = 0,
}: UseCrosshairArgs): UseCrosshairReturn {
const [cursorX, setCursorX] = useState<number | null>(null);
const [cursorXPercent, setCursorXPercent] = useState<number | null>(null);
@@ -50,18 +39,11 @@ export function useCrosshair({
return;
}
// Map the cursor into the inset content box so 0% aligns with the first
// tick / bar origin (not the container edge). Clamp so the dead padding
// zones don't produce a line/time before 0ms or past the end.
const contentWidth = Math.max(1, rect.width - insetX * 2);
const xInContent = Math.max(
0,
Math.min(e.clientX - rect.left - insetX, contentWidth),
);
setCursorX(xInContent + insetX);
setCursorXPercent(xInContent / contentWidth);
const x = e.clientX - rect.left;
setCursorX(x);
setCursorXPercent(x / rect.width);
},
[containerRef, enabled, insetX],
[containerRef, enabled],
);
const onMouseLeave = useCallback((): void => {

View File

@@ -1,13 +1,7 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
ChartNoAxesGantt,
ChevronDown,
ChevronRight,
Info,
TriangleAlert,
} from '@signozhq/icons';
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { Collapse } from 'antd';
@@ -40,16 +34,6 @@ import cx from 'classnames';
import styles from './TraceDetailsV3.module.scss';
// Lucide chevrons for the flame/waterfall accordion headers, matching the
// span-tree chevrons in the waterfall.
function renderPanelExpandIcon({
isActive,
}: {
isActive?: boolean;
}): JSX.Element {
return isActive ? <ChevronDown size={14} /> : <ChevronRight size={14} />;
}
function TraceDetailsV3(): JSX.Element {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const urlQuery = useUrlQuery();
@@ -345,7 +329,6 @@ function TraceDetailsV3(): JSX.Element {
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
hasMissingSpans: payload.hasMissingSpans || false,
};
}, [traceData?.payload]);
@@ -405,7 +388,6 @@ function TraceDetailsV3(): JSX.Element {
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
expandIcon={renderPanelExpandIcon}
className={styles.flameCollapse}
items={[
{
@@ -419,13 +401,7 @@ function TraceDetailsV3(): JSX.Element {
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomLeft"
>
<Info
size={16}
color="var(--l2-foreground)"
style={{ cursor: 'pointer' }}
/>
</WarningPopover>
/>
)}
</span>
{traceData?.payload?.totalSpansCount ? (
@@ -466,7 +442,6 @@ function TraceDetailsV3(): JSX.Element {
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
expandIcon={renderPanelExpandIcon}
className={cx(styles.waterfallCollapse, {
[styles.isDocked]: isWaterfallDocked,
})}

View File

@@ -67,14 +67,6 @@
.trace-explorer-page {
display: flex;
// Meant to fix the query builder colors
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);
--input-focus-background: var(--l2-background);
--input-border-color: var(--l2-border);
--input-hover-border-color: var(--internal-ant-border-color-hover);
--input-focus-border-color: var(--internal-ant-border-color-hover);
.filter {
// Width is owned by ResizableBox (inline style).
flex-shrink: 0;

View File

@@ -13,9 +13,9 @@
padding: 6px;
border: 1px solid var(--periscope-btn-border-color, var(--l1-border));
border: 1px solid var(--l1-border);
border-radius: 3px;
background: var(--periscope-btn-background-color, var(--l2-background));
background: var(--l2-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
color: var(--l2-foreground);
@@ -135,15 +135,11 @@
display: flex;
flex-direction: row;
border-radius: 2px 0px 0px 2px;
background-color: var(
--input-with-label-background-color,
var(--l2-background)
);
.label {
font-size: 12px;
color: var(--input-with-label-color, var(--l2-foreground));
color: var(--l2-foreground);
font-size: 12px;
font-style: normal;
font-weight: 500;
@@ -157,8 +153,9 @@
text-overflow: ellipsis;
padding: 0px 8px;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
border: 1px solid var(--l2-border);
background: var(--l2-background);
color: var(--l1-foreground);
display: flex;
justify-content: flex-start;
align-items: center;
@@ -168,6 +165,7 @@
.input {
flex: 1;
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--l1-border);
border-right: none;
border-left: none;
border-top-right-radius: 0px;
@@ -175,7 +173,7 @@
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
background: var(--input-with-label-background-color, var(--l2-background));
background: var(--l1-background);
min-width: 0;
@@ -191,44 +189,21 @@
}
.ant-select-selector {
height: 36px;
border-color: var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
border-radius: 0;
position: relative;
margin-left: -1px;
}
.ant-select:hover .ant-select-selector,
.ant-select-focused .ant-select-selector {
z-index: 1;
}
.ant-select-disabled .ant-select-selector {
background: var(
--input-with-label-background-color,
var(--l2-background)
) !important;
border: none;
background: var(--l2-background);
}
.ant-select-selection-placeholder {
color: var(--input-with-label-color, var(--l2-foreground));
color: var(--l2-foreground);
}
}
.close-btn {
border-radius: 0px 2px 2px 0px;
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
background: var(--input-with-label-background-color, var(--l2-background));
height: 100%;
border: 1px solid var(--l2-border);
background: var(--l2-background);
height: 38px;
width: 38px;
margin-left: -1px;
position: relative;
&:hover,
&:focus {
z-index: 2;
}
&:focus:not(:focus-visible),
&.ant-btn:focus:not(:focus-visible) {

View File

@@ -839,9 +839,6 @@ body.ai-assistant-panel-open {
// design libraries.
--dropdown-menu-content-z-index: 1050;
--dropdown-menu-sub-content-z-index: 1050;
--internal-ant-border-color-focus: #3a52a8;
--internal-ant-border-color-hover: #6e8de8;
}
div[data-slot='callout'] {

View File

@@ -136,7 +136,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
AI_ASSISTANT_ICON_PREVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
MCP_SERVER: ['ADMIN', 'EDITOR', 'VIEWER'],
AI_ASSISTANT_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
LLM_OBSERVABILITY_CONFIGURATION: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -98,7 +98,7 @@ func (s *store) CreateOrGet(ctx context.Context, tags []*tagtypes.Tag) ([]*tagty
BunDBCtx(ctx).
NewInsert().
Model(&tags).
// On("CONFLICT (org_id, kind, (LOWER(key)), (LOWER(value))) DO UPDATE").
On("CONFLICT (org_id, kind, (LOWER(key)), (LOWER(value))) DO UPDATE").
Set("key = tag.key").
Returning("*").
Scan(ctx)

View File

@@ -88,62 +88,60 @@ func TestStore_Create_PopulatesIDsOnFreshInsert(t *testing.T) {
assert.Equal(t, preIDB, stored["team\x00blr"].ID)
}
// todo (@namanverma): uncomment once unique index is there.
//
// func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
// ctx := context.Background()
// sqlstore := newTestStore(t)
// s := NewStore(sqlstore)
func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
// orgID := valuer.GenerateUUID()
orgID := valuer.GenerateUUID()
// // Simulate a concurrent insert: someone else has already inserted "tag:Database".
// winner := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// _, err := s.CreateOrGet(ctx, []*tagtypes.Tag{winner})
// require.NoError(t, err)
// winnerID := winner.ID
// Simulate a concurrent insert: someone else has already inserted "tag:Database".
winner := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{winner})
require.NoError(t, err)
winnerID := winner.ID
// // Now our request runs with a different pre-generated ID for the same
// // (key, value) — case differs but the functional unique index collapses
// // them. RETURNING should overwrite our stale ID with winner's ID.
// loser := tagtypes.NewTag(orgID, dashboardKind, "TAG", "DATABASE")
// loserPreID := loser.ID
// require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
// Now our request runs with a different pre-generated ID for the same
// (key, value) — case differs but the functional unique index collapses
// them. RETURNING should overwrite our stale ID with winner's ID.
loser := tagtypes.NewTag(orgID, dashboardKind, "TAG", "DATABASE")
loserPreID := loser.ID
require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
// got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{loser})
// require.NoError(t, err)
// require.Len(t, got, 1)
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{loser})
require.NoError(t, err)
require.Len(t, got, 1)
// assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
// assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
// // And the DB still has exactly one row for that (lower(key), lower(value)) — winner's, with winner's casing.
// stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
// require.Len(t, stored, 1)
// assert.Equal(t, winnerID, stored["tag\x00database"].ID)
// assert.Equal(t, "tag", stored["tag\x00database"].Key, "winner's casing preserved in key")
// assert.Equal(t, "Database", stored["tag\x00database"].Value, "winner's casing preserved in value")
// }
// And the DB still has exactly one row for that (lower(key), lower(value)) — winner's, with winner's casing.
stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
require.Len(t, stored, 1)
assert.Equal(t, winnerID, stored["tag\x00database"].ID)
assert.Equal(t, "tag", stored["tag\x00database"].Key, "winner's casing preserved in key")
assert.Equal(t, "Database", stored["tag\x00database"].Value, "winner's casing preserved in value")
}
// func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
// ctx := context.Background()
// sqlstore := newTestStore(t)
// s := NewStore(sqlstore)
func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
// orgID := valuer.GenerateUUID()
// pre := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// _, err := s.CreateOrGet(ctx, []*tagtypes.Tag{pre})
// require.NoError(t, err)
// preExistingID := pre.ID
orgID := valuer.GenerateUUID()
pre := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{pre})
require.NoError(t, err)
preExistingID := pre.ID
// conflict := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
// fresh := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
// freshPreID := fresh.ID
conflict := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
fresh := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
freshPreID := fresh.ID
// got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{conflict, fresh})
// require.NoError(t, err)
// require.Len(t, got, 2)
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{conflict, fresh})
require.NoError(t, err)
require.Len(t, got, 2)
// assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
// assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
// }
assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
}

View File

@@ -218,6 +218,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,59 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addTagUniqueIndex struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddTagUniqueIndexFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_tag_unique_index"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTagUniqueIndex{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addTagUniqueIndex) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTagUniqueIndex) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "tag",
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
},
)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addTagUniqueIndex) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -1,6 +1,8 @@
package sqlschema
import (
"fmt"
"hash/fnv"
"slices"
"strings"
@@ -49,13 +51,29 @@ type Index interface {
ToDropSQL(fmter SQLFormatter) []byte
}
// UniqueIndex is either plain or functional: ColumnNames and Expressions are
// mutually exclusive and setting both panics.
//
// - Plain: set only ColumnNames. Columns are identifier-quoted and the
// auto-generated name is a readable join (uq_t_a_b ON t (a, b)).
// - Functional (e.g. LOWER(col)): set only Expressions, one per key, emitted
// verbatim (caller owns well-formedness); plain columns go in as bare
// identifiers. The auto-name uses a hash suffix (uq_t_<hash>) since
// expressions aren't valid identifier fragments. Use this only when at least
// one key is a real expression; an all-identifier Expressions reconstructs as
// ColumnNames on read-back and won't compare equal — use ColumnNames instead.
type UniqueIndex struct {
TableName TableName
ColumnNames []ColumnName
Expressions []string
name string
}
func (index *UniqueIndex) Name() string {
if len(index.ColumnNames) > 0 && len(index.Expressions) > 0 {
panic("sqlschema: UniqueIndex sets both ColumnNames and Expressions; they are mutually exclusive — for a functional index put every key (plain columns as bare identifiers) in Expressions and leave ColumnNames empty")
}
if index.name != "" {
return index.name
}
@@ -65,6 +83,14 @@ func (index *UniqueIndex) Name() string {
b.WriteString("_")
b.WriteString(string(index.TableName))
b.WriteString("_")
if len(index.Expressions) > 0 {
hasher := fnv.New32a()
_, _ = hasher.Write([]byte(strings.Join(normalizeExpressions(index.Expressions), "\x00")))
fmt.Fprintf(&b, "%08x", hasher.Sum32())
return b.String()
}
for i, column := range index.ColumnNames {
if i > 0 {
b.WriteString("_")
@@ -77,10 +103,13 @@ func (index *UniqueIndex) Name() string {
func (index *UniqueIndex) Named(name string) Index {
copyOfColumnNames := make([]ColumnName, len(index.ColumnNames))
copy(copyOfColumnNames, index.ColumnNames)
copyOfExpressions := make([]string, len(index.Expressions))
copy(copyOfExpressions, index.Expressions)
return &UniqueIndex{
TableName: index.TableName,
ColumnNames: copyOfColumnNames,
Expressions: copyOfExpressions,
name: name,
}
}
@@ -101,10 +130,42 @@ func (index *UniqueIndex) Equals(other Index) bool {
if other.Type() != IndexTypeUnique {
return false
}
otherUnique, ok := other.(*UniqueIndex)
if !ok {
return false
}
// Plain and functional indexes produce different SQL even if their column
// sets overlap; require both shapes to match.
if (len(index.Expressions) == 0) != (len(otherUnique.Expressions) == 0) {
return false
}
// Expressions are compared normalized so a declared `LOWER(x)` matches the
// `lower(x)` a backend renders on read-back.
if len(index.Expressions) > 0 && !slices.Equal(normalizeExpressions(index.Expressions), normalizeExpressions(otherUnique.Expressions)) {
return false
}
return index.Name() == other.Name() && slices.Equal(index.Columns(), other.Columns())
}
// normalizeExpressions canonicalizes each Expressions entry (case, whitespace,
// redundant parens, identifier quoting) so a declared LOWER(x) matches the
// lower(x) a backend renders on read-back. Reuses expressionNormalizer: an index key
// is the same SQL grammar as a WHERE predicate. It does not handle opclass,
// COLLATE, or ASC/DESC/NULLS suffixes: postgres strips them on read-back
// (pg_get_indexdef), while sqlite keeps them verbatim, so an index declared with
// one round-trips on sqlite but not on postgres.
func normalizeExpressions(expressions []string) []string {
if len(expressions) == 0 {
return nil
}
normalized := make([]string, len(expressions))
for i, expression := range expressions {
normalized[i] = (&expressionNormalizer{input: expression, foldCase: true}).normalize()
}
return normalized
}
func (index *UniqueIndex) ToCreateSQL(fmter SQLFormatter) []byte {
sql := []byte{}
@@ -114,12 +175,20 @@ func (index *UniqueIndex) ToCreateSQL(fmter SQLFormatter) []byte {
sql = fmter.AppendIdent(sql, string(index.TableName))
sql = append(sql, " ("...)
for i, column := range index.ColumnNames {
if i > 0 {
sql = append(sql, ", "...)
if len(index.Expressions) > 0 {
for i, expr := range index.Expressions {
if i > 0 {
sql = append(sql, ", "...)
}
sql = append(sql, expr...)
}
} else {
for i, column := range index.ColumnNames {
if i > 0 {
sql = append(sql, ", "...)
}
sql = fmter.AppendIdent(sql, string(column))
}
sql = fmter.AppendIdent(sql, string(column))
}
sql = append(sql, ")"...)
@@ -160,7 +229,7 @@ func (index *PartialUniqueIndex) Name() string {
b.WriteString(string(column))
}
b.WriteString("_")
b.WriteString((&whereNormalizer{input: index.Where}).hash())
b.WriteString((&expressionNormalizer{input: index.Where}).hash())
return b.String()
}
@@ -198,7 +267,7 @@ func (index *PartialUniqueIndex) Equals(other Index) bool {
return false
}
return index.Name() == other.Name() && slices.Equal(index.Columns(), other.Columns()) && (&whereNormalizer{input: index.Where}).normalize() == (&whereNormalizer{input: otherPartial.Where}).normalize()
return index.Name() == other.Name() && slices.Equal(index.Columns(), other.Columns()) && (&expressionNormalizer{input: index.Where}).normalize() == (&expressionNormalizer{input: otherPartial.Where}).normalize()
}
func (index *PartialUniqueIndex) ToCreateSQL(fmter SQLFormatter) []byte {

View File

@@ -38,6 +38,39 @@ func TestIndexToCreateSQL(t *testing.T) {
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "my_index" ON "users" ("id", "name", "email")`,
},
{
name: "Unique_Functional_SingleExpression",
index: &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_users_72852951" ON "users" (LOWER(email))`,
},
{
name: "Unique_Functional_MixedColumnsAndExpressions",
index: &UniqueIndex{
TableName: "tag",
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_tag_a36c51df" ON "tag" (org_id, kind, LOWER(key), LOWER(value))`,
},
{
name: "Unique_Functional_ComplexExpression",
index: &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(TRIM(first_name) || ' ' || TRIM(last_name))"},
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_users_37e845f3" ON "users" (LOWER(TRIM(first_name) || ' ' || TRIM(last_name)))`,
},
{
name: "Unique_Functional_Named",
index: &UniqueIndex{
TableName: "tag",
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
name: "uq_tag_org_kind_lower_key_lower_value",
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_tag_org_kind_lower_key_lower_value" ON "tag" (org_id, kind, LOWER(key), LOWER(value))`,
},
{
name: "PartialUnique_1Column",
index: &PartialUniqueIndex{
@@ -229,6 +262,54 @@ func TestIndexEquals(t *testing.T) {
},
equals: false,
},
{
name: "Unique_Functional_Same",
a: &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
},
b: &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
},
equals: true,
},
{
name: "Unique_Functional_CaseInsensitiveEqual",
a: &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
},
b: &UniqueIndex{
TableName: "users",
Expressions: []string{"lower(email)"},
},
equals: true,
},
{
name: "Unique_Functional_DifferentExpressions",
a: &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
},
b: &UniqueIndex{
TableName: "users",
Expressions: []string{"UPPER(email)"},
},
equals: false,
},
{
name: "Unique_Functional_NotEqualToPlainSameColumns",
a: &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
},
b: &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
},
equals: false,
},
}
for _, testCase := range testCases {
@@ -238,6 +319,142 @@ func TestIndexEquals(t *testing.T) {
}
}
func TestUniqueIndexFunctionalName(t *testing.T) {
t.Run("autogen uses uq_<table>_<hash>", func(t *testing.T) {
idx := &UniqueIndex{
TableName: "tag",
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
}
assert.Equal(t, "uq_tag_a36c51df", idx.Name())
})
t.Run("same expressions produce the same name", func(t *testing.T) {
a := &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
}
b := &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
}
assert.Equal(t, a.Name(), b.Name())
})
t.Run("different expressions produce different names", func(t *testing.T) {
a := &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
}
b := &UniqueIndex{
TableName: "users",
Expressions: []string{"UPPER(email)"},
}
assert.NotEqual(t, a.Name(), b.Name())
})
t.Run("expressions in different order produce different names", func(t *testing.T) {
a := &UniqueIndex{
TableName: "tag",
Expressions: []string{"org_id", "LOWER(key)"},
}
b := &UniqueIndex{
TableName: "tag",
Expressions: []string{"LOWER(key)", "org_id"},
}
assert.NotEqual(t, a.Name(), b.Name())
})
t.Run("functional autogen differs from plain autogen for same columns", func(t *testing.T) {
plain := &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
}
functional := &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
}
assert.Equal(t, "uq_users_email", plain.Name())
assert.NotEqual(t, plain.Name(), functional.Name())
})
t.Run("Named() override wins over hash", func(t *testing.T) {
idx := (&UniqueIndex{
TableName: "tag",
Expressions: []string{"org_id", "LOWER(key)"},
}).Named("my_functional_index")
assert.Equal(t, "my_functional_index", idx.Name())
})
t.Run("setting both ColumnNames and Expressions panics", func(t *testing.T) {
idx := &UniqueIndex{
TableName: "tag",
ColumnNames: []ColumnName{"org_id"},
Expressions: []string{"LOWER(key)"},
}
assert.Panics(t, func() { _ = idx.Name() })
})
}
func TestNormalizeExpressions(t *testing.T) {
testCases := []struct {
name string
expressions []string
output []string
}{
{
name: "Empty",
expressions: nil,
output: nil,
},
{
name: "PlainColumnsUnchanged",
expressions: []string{"org_id", "kind"},
output: []string{"org_id", "kind"},
},
{
name: "FunctionNameCaseFolded",
expressions: []string{"LOWER(key)", "LOWER(value)"},
output: []string{"lower(key)", "lower(value)"},
},
{
name: "PostgresRenderedMatchesDeclared",
expressions: []string{"lower(key)"},
output: []string{"lower(key)"},
},
{
name: "WhitespaceCollapsedPerExpression",
expressions: []string{"LOWER( key )", "org_id"},
output: []string{"lower( key )", "org_id"},
},
{
name: "RedundantOuterParenthesesStripped",
expressions: []string{"(LOWER(key))"},
output: []string{"lower(key)"},
},
{
name: "QuotedIdentifierCaseByIdentifierPreserved",
expressions: []string{`LOWER("Key")`},
output: []string{`lower("Key")`},
},
{
name: "StringLiteralCasePreserved",
expressions: []string{"COALESCE(status, 'Deleted')"},
output: []string{"coalesce(status, 'Deleted')"},
},
{
name: "NormalizedIndependentlyPerElement",
expressions: []string{"UPPER(a)", "b", "LOWER(c)"},
output: []string{"upper(a)", "b", "lower(c)"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.output, normalizeExpressions(testCase.expressions))
})
}
}
func TestPartialUniqueIndexName(t *testing.T) {
a := &PartialUniqueIndex{
TableName: "users",

View File

@@ -6,25 +6,26 @@ import (
"strings"
)
type whereNormalizer struct {
input string
type expressionNormalizer struct {
input string
foldCase bool
}
func (n *whereNormalizer) hash() string {
func (n *expressionNormalizer) hash() string {
hasher := fnv.New32a()
_, _ = hasher.Write([]byte(n.normalize()))
return fmt.Sprintf("%08x", hasher.Sum32())
}
func (n *whereNormalizer) normalize() string {
where := strings.TrimSpace(n.input)
where = n.stripOuterParentheses(where)
func (n *expressionNormalizer) normalize() string {
expr := strings.TrimSpace(n.input)
expr = n.stripOuterParentheses(expr)
var output strings.Builder
output.Grow(len(where))
output.Grow(len(expr))
for i := 0; i < len(where); i++ {
switch where[i] {
for i := 0; i < len(expr); i++ {
switch expr[i] {
case ' ', '\t', '\n', '\r':
if output.Len() > 0 {
last := output.String()[output.Len()-1]
@@ -33,21 +34,25 @@ func (n *whereNormalizer) normalize() string {
}
}
case '\'':
end := n.consumeSingleQuotedLiteral(where, i, &output)
end := n.consumeSingleQuotedLiteral(expr, i, &output)
i = end
case '"':
token, end := n.consumeDoubleQuotedToken(where, i)
token, end := n.consumeDoubleQuotedToken(expr, i)
output.WriteString(token)
i = end
default:
output.WriteByte(where[i])
c := expr[i]
if n.foldCase && c >= 'A' && c <= 'Z' {
c += 'a' - 'A'
}
output.WriteByte(c)
}
}
return strings.TrimSpace(output.String())
}
func (n *whereNormalizer) stripOuterParentheses(s string) string {
func (n *expressionNormalizer) stripOuterParentheses(s string) string {
for {
s = strings.TrimSpace(s)
if len(s) < 2 || s[0] != '(' || s[len(s)-1] != ')' || !n.hasWrappingParentheses(s) {
@@ -57,7 +62,7 @@ func (n *whereNormalizer) stripOuterParentheses(s string) string {
}
}
func (n *whereNormalizer) hasWrappingParentheses(s string) bool {
func (n *expressionNormalizer) hasWrappingParentheses(s string) bool {
depth := 0
inSingleQuotedLiteral := false
inDoubleQuotedToken := false
@@ -101,7 +106,7 @@ func (n *whereNormalizer) hasWrappingParentheses(s string) bool {
return depth == 0
}
func (n *whereNormalizer) consumeSingleQuotedLiteral(s string, start int, output *strings.Builder) int {
func (n *expressionNormalizer) consumeSingleQuotedLiteral(s string, start int, output *strings.Builder) int {
output.WriteByte(s[start])
for i := start + 1; i < len(s); i++ {
output.WriteByte(s[i])
@@ -118,7 +123,7 @@ func (n *whereNormalizer) consumeSingleQuotedLiteral(s string, start int, output
return len(s) - 1
}
func (n *whereNormalizer) consumeDoubleQuotedToken(s string, start int) (string, int) {
func (n *expressionNormalizer) consumeDoubleQuotedToken(s string, start int) (string, int) {
var ident strings.Builder
for i := start + 1; i < len(s); i++ {
@@ -142,7 +147,7 @@ func (n *whereNormalizer) consumeDoubleQuotedToken(s string, start int) (string,
return s[start:], len(s) - 1
}
func (n *whereNormalizer) isSimpleUnquotedIdentifier(s string) bool {
func (n *expressionNormalizer) isSimpleUnquotedIdentifier(s string) bool {
if s == "" || strings.ToLower(s) != s {
return false
}

View File

@@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestWhereNormalizerNormalize(t *testing.T) {
func TestExpressionNormalizerNormalize(t *testing.T) {
testCases := []struct {
name string
input string
@@ -47,11 +47,16 @@ func TestWhereNormalizerNormalize(t *testing.T) {
input: `NOT ("deleted_at" IS NOT NULL AND ("active" = false OR "status" = 'archived'))`,
output: `NOT (deleted_at IS NOT NULL AND (active = false OR status = 'archived'))`,
},
{
name: "CaseNotFoldedWithoutFlag",
input: `LOWER("email") IS NOT NULL`,
output: `LOWER(email) IS NOT NULL`,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.output, (&whereNormalizer{input: testCase.input}).normalize())
assert.Equal(t, testCase.output, (&expressionNormalizer{input: testCase.input}).normalize())
})
}
}

View File

@@ -2,6 +2,7 @@ package sqlitesqlschema
import (
"context"
"log/slog"
"strconv"
"strings"
@@ -92,7 +93,6 @@ func (provider *provider) GetIndices(ctx context.Context, tableName sqlschema.Ta
unique bool
origin string
partial bool
columns []sqlschema.ColumnName
)
if err := rows.Scan(&seq, &name, &unique, &origin, &partial); err != nil {
return nil, err
@@ -108,53 +108,103 @@ func (provider *provider) GetIndices(ctx context.Context, tableName sqlschema.Ta
continue
}
if err := provider.
sqlstore.
BunDB().
NewRaw("SELECT name FROM PRAGMA_index_info(?)", string(name)).
Scan(ctx, &columns); err != nil {
if !unique {
continue
}
columns, hasExpression, err := provider.indexKeys(ctx, name)
if err != nil {
return nil, err
}
if unique && partial {
var indexSQL string
// SQLite exposes expression keys and the predicate only in the DDL.
var ddl string
if hasExpression || partial {
if err := provider.
sqlstore.
BunDB().
NewRaw("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", name).
Scan(ctx, &indexSQL); err != nil {
Scan(ctx, &ddl); err != nil {
return nil, err
}
}
where := extractWhereClause(indexSQL)
index := &sqlschema.PartialUniqueIndex{
// functional partial indexes aren't representable (PartialUniqueIndex has no
// expressions); skip rather than misrepresent.
if hasExpression && partial {
provider.settings.Logger().WarnContext(ctx, "skipping functional partial unique index; not representable by sqlschema", slog.String("index", name), slog.String("table", string(tableName)))
continue
}
var index sqlschema.Index
if partial {
index = &sqlschema.PartialUniqueIndex{
TableName: tableName,
ColumnNames: columns,
Where: where,
Where: extractWhereClause(ddl),
}
if index.Name() == name {
indices = append(indices, index)
} else {
indices = append(indices, index.Named(name))
} else if hasExpression {
index = &sqlschema.UniqueIndex{
TableName: tableName,
Expressions: extractIndexColumns(ddl),
}
} else if unique {
index := &sqlschema.UniqueIndex{
} else {
index = &sqlschema.UniqueIndex{
TableName: tableName,
ColumnNames: columns,
}
if index.Name() == name {
indices = append(indices, index)
} else {
indices = append(indices, index.Named(name))
}
}
if index.Name() != name {
index = index.Named(name)
}
indices = append(indices, index)
}
return indices, nil
}
// indexKeys returns an index's plain key columns and whether any key is an
// expression (SQLite marks expression keys with cid = -2 and a NULL name).
func (provider *provider) indexKeys(ctx context.Context, name string) ([]sqlschema.ColumnName, bool, error) {
rows, err := provider.
sqlstore.
BunDB().
QueryContext(ctx, "SELECT cid, name FROM PRAGMA_index_info(?)", name)
if err != nil {
return nil, false, err
}
defer func() {
if err := rows.Close(); err != nil {
provider.settings.Logger().ErrorContext(ctx, "error closing rows", errors.Attr(err))
}
}()
var (
columns []sqlschema.ColumnName
hasExpression bool
)
for rows.Next() {
var (
cid int
columnName *string
)
if err := rows.Scan(&cid, &columnName); err != nil {
return nil, false, err
}
if cid == -2 {
hasExpression = true
continue
}
if columnName != nil {
columns = append(columns, sqlschema.ColumnName(*columnName))
}
}
return columns, hasExpression, nil
}
func (provider *provider) ToggleFKEnforcement(ctx context.Context, db bun.IDB, on bool) error {
_, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ?", on)
if err != nil {
@@ -173,6 +223,92 @@ func (provider *provider) ToggleFKEnforcement(ctx context.Context, db bun.IDB, o
return errors.NewInternalf(errors.CodeInternal, "foreign_keys(actual: %s, expected: %s), maybe a transaction is in progress?", strconv.FormatBool(val), strconv.FormatBool(on))
}
// extractIndexColumns returns an index's key entries: the comma-separated items
// in the first top-level parenthesised list after ON, each verbatim (trimmed).
// Quotes and nested parens are respected so their commas don't split keys.
// SQLite reports expression keys with a NULL name in PRAGMA_index_info, so their
// text is only recoverable from the DDL.
func extractIndexColumns(sql string) []string {
var (
inSingleQuotedLiteral bool
inDoubleQuotedIdentifier bool
inBacktickQuotedIdentifier bool
inBracketQuotedIdentifier bool
depth int
started bool
keyStart int
columns []string
)
for i := 0; i < len(sql); i++ {
switch sql[i] {
case '\'':
if inDoubleQuotedIdentifier || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
break
}
if inSingleQuotedLiteral && i+1 < len(sql) && sql[i+1] == '\'' {
i++
continue
}
inSingleQuotedLiteral = !inSingleQuotedLiteral
continue
case '"':
if inSingleQuotedLiteral || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
break
}
if inDoubleQuotedIdentifier && i+1 < len(sql) && sql[i+1] == '"' {
i++
continue
}
inDoubleQuotedIdentifier = !inDoubleQuotedIdentifier
continue
case '`':
if inSingleQuotedLiteral || inDoubleQuotedIdentifier || inBracketQuotedIdentifier {
break
}
inBacktickQuotedIdentifier = !inBacktickQuotedIdentifier
continue
case '[':
if inSingleQuotedLiteral || inDoubleQuotedIdentifier || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
break
}
inBracketQuotedIdentifier = true
continue
case ']':
if inBracketQuotedIdentifier {
inBracketQuotedIdentifier = false
}
continue
}
if inSingleQuotedLiteral || inDoubleQuotedIdentifier || inBacktickQuotedIdentifier || inBracketQuotedIdentifier {
continue
}
switch sql[i] {
case '(':
depth++
if depth == 1 && !started {
started = true
keyStart = i + 1
}
case ')':
depth--
if depth == 0 && started {
columns = append(columns, strings.TrimSpace(sql[keyStart:i]))
return columns
}
case ',':
if depth == 1 {
columns = append(columns, strings.TrimSpace(sql[keyStart:i]))
keyStart = i + 1
}
}
}
return columns
}
func extractWhereClause(sql string) string {
lastWhere := -1
inSingleQuotedLiteral := false

View File

@@ -50,3 +50,93 @@ func TestExtractWhereClause(t *testing.T) {
})
}
}
func TestExtractIndexColumns(t *testing.T) {
testCases := []struct {
name string
sql string
columns []string
}{
{
name: "SingleColumn",
sql: `CREATE UNIQUE INDEX "idx" ON "users" ("email")`,
columns: []string{`"email"`},
},
{
name: "SingleExpression",
sql: `CREATE UNIQUE INDEX "idx" ON "users" (LOWER(email))`,
columns: []string{"LOWER(email)"},
},
{
name: "MixedColumnsAndExpressions",
sql: `CREATE UNIQUE INDEX "idx" ON "tag" (org_id, kind, LOWER(key), LOWER(value))`,
columns: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
},
{
name: "CommaInsideStringLiteralNotSplit",
sql: `CREATE UNIQUE INDEX "idx" ON "users" (LOWER(TRIM(first_name) || ', ' || last_name))`,
columns: []string{"LOWER(TRIM(first_name) || ', ' || last_name)"},
},
{
name: "CommaInsideQuotedIdentifierNotSplit",
sql: `CREATE UNIQUE INDEX "idx" ON "t" ("weird,col", LOWER(x))`,
columns: []string{`"weird,col"`, "LOWER(x)"},
},
{
name: "WhereClauseAfterKeyListIgnored",
sql: `CREATE UNIQUE INDEX "idx" ON "tag" (LOWER(key)) WHERE deleted = 0`,
columns: []string{"LOWER(key)"},
},
{
name: "WhereClauseWithParensAfterKeyListIgnored",
sql: `CREATE UNIQUE INDEX "idx" ON "tag" (LOWER(key)) WHERE (deleted = 0 AND kind = 'a')`,
columns: []string{"LOWER(key)"},
},
{
name: "CommaInsideBacktickIdentifierNotSplit",
sql: "CREATE UNIQUE INDEX `idx` ON `t` (`weird,col`, x)",
columns: []string{"`weird,col`", "x"},
},
{
name: "CommaInsideBracketIdentifierNotSplit",
sql: `CREATE UNIQUE INDEX [idx] ON [t] ([weird,col], x)`,
columns: []string{"[weird,col]", "x"},
},
{
name: "EscapedDoubleQuoteInIdentifier",
sql: `CREATE UNIQUE INDEX "idx" ON "t" ("a""b", x)`,
columns: []string{`"a""b"`, "x"},
},
{
name: "EscapedSingleQuoteInLiteral",
sql: `CREATE UNIQUE INDEX "idx" ON "t" (COALESCE(x, 'it''s, ok'))`,
columns: []string{"COALESCE(x, 'it''s, ok')"},
},
{
name: "NestedFunctionCallCommasNotSplit",
sql: `CREATE UNIQUE INDEX "idx" ON "t" (COALESCE(a, b, c), d)`,
columns: []string{"COALESCE(a, b, c)", "d"},
},
{
name: "NewlinesAndTabsTrimmed",
sql: "CREATE UNIQUE INDEX \"idx\" ON \"t\" (\n\tLOWER(key),\n\torg_id\n)",
columns: []string{"LOWER(key)", "org_id"},
},
{
name: "CollateAndOrderSuffixKeptVerbatim",
sql: `CREATE UNIQUE INDEX "idx" ON "t" (LOWER(key) COLLATE NOCASE, value DESC)`,
columns: []string{"LOWER(key) COLLATE NOCASE", "value DESC"},
},
{
name: "NoKeyListReturnsNil",
sql: `CREATE INDEX "idx" ON "t"`,
columns: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.columns, extractIndexColumns(testCase.sql))
})
}
}

View File

@@ -0,0 +1,103 @@
//go:build integration
// Stands up a real sqlite store (temp file); excluded from the default test run.
// Run with: go test -tags integration ./...
package sqlitesqlschema
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/stretchr/testify/require"
)
// TestGetIndicesRoundTrip creates each index shape on a fresh sqlite database and
// checks GetIndices reconstructs an equal index, exercising every branch: plain
// vs expression keys parsed from the DDL, multi-key ordering, partial predicate,
// and the skipped functional-partial case.
func TestGetIndicesRoundTrip(t *testing.T) {
ctx := context.Background()
cfg := sqlstore.Config{
Provider: "sqlite",
Sqlite: sqlstore.SqliteConfig{
Path: filepath.Join(t.TempDir(), "roundtrip.db"),
Mode: "wal",
BusyTimeout: 10 * time.Second,
TransactionMode: "deferred",
},
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
}
providerSettings := instrumentationtest.New().ToProviderSettings()
store, err := sqlitesqlstore.New(ctx, providerSettings, cfg)
require.NoError(t, err)
schema, err := New(ctx, providerSettings, sqlschema.Config{}, store)
require.NoError(t, err)
const table = "sqlschema_roundtrip"
exec := func(sql string) {
_, err := store.BunDB().ExecContext(ctx, sql)
require.NoError(t, err)
}
reset := func() {
exec(`DROP TABLE IF EXISTS ` + table)
exec(`CREATE TABLE ` + table + ` (a text, b text, c text)`)
}
roundTrip := func(t *testing.T, declared sqlschema.Index) {
reset()
for _, sql := range schema.Operator().CreateIndex(declared) {
exec(string(sql))
}
indices, err := schema.GetIndices(ctx, table)
require.NoError(t, err)
var got sqlschema.Index
for _, index := range indices {
if index.Name() == declared.Name() {
got = index
}
}
require.NotNil(t, got, "GetIndices did not return %q; returned %d indices", declared.Name(), len(indices))
require.True(t, declared.Equals(got), "round-tripped index should equal the declared one; got %#v", got)
}
t.Run("PlainSingleColumn", func(t *testing.T) {
roundTrip(t, &sqlschema.UniqueIndex{TableName: table, ColumnNames: []sqlschema.ColumnName{"a"}})
})
t.Run("PlainMultiColumn", func(t *testing.T) {
roundTrip(t, &sqlschema.UniqueIndex{TableName: table, ColumnNames: []sqlschema.ColumnName{"a", "b"}})
})
t.Run("FunctionalSingleExpression", func(t *testing.T) {
roundTrip(t, &sqlschema.UniqueIndex{TableName: table, Expressions: []string{"LOWER(a)"}})
})
t.Run("FunctionalMixedKeys", func(t *testing.T) {
roundTrip(t, &sqlschema.UniqueIndex{TableName: table, Expressions: []string{"a", "LOWER(b)", "LOWER(c)"}})
})
t.Run("PartialUnique", func(t *testing.T) {
roundTrip(t, &sqlschema.PartialUniqueIndex{TableName: table, ColumnNames: []sqlschema.ColumnName{"a"}, Where: "b IS NOT NULL"})
})
t.Run("FunctionalPartialIsSkipped", func(t *testing.T) {
reset()
exec(`CREATE UNIQUE INDEX rt_functional_partial ON ` + table + ` (LOWER(a)) WHERE b IS NOT NULL`)
indices, err := schema.GetIndices(ctx, table)
require.NoError(t, err)
require.Empty(t, indices, "functional partial unique index must be skipped, not reconstructed")
})
}

View File

@@ -950,25 +950,3 @@ def generate_traces_with_corrupt_metadata() -> list[Traces]:
},
),
]
def make_scalar_query_request(
signoz: types.SigNoz,
token: str,
now: datetime,
queries: list[dict],
lookback_minutes: int = 5,
) -> requests.Response:
return requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": int((now - timedelta(minutes=lookback_minutes)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"requestType": "scalar",
"compositeQuery": {"queries": queries},
"formatOptions": {"formatTableResultForUI": True, "fillGaps": False},
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -8,15 +8,20 @@ import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.metrics import Metrics
from fixtures.querier import (
assert_minutely_bucket_values,
build_builder_query,
find_named_result,
get_all_warnings,
index_series_by_label,
make_query_request,
)
FILL_GAPS = "fillGaps"
FILL_ZERO = "fillZero"
HISTOGRAM_FILE = get_testdata_file_path("histogram_data_1h.jsonl")
def _build_format_options(fill_mode: str) -> dict[str, Any]:
@@ -565,3 +570,371 @@ def test_metrics_fill_formula_with_group_by(
expected_by_ts=expectations[group],
context=f"metrics/{fill_mode}/F1/{group}",
)
def test_histogram_p90_returns_warning_outside_data_window(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metric_name = "test_p90_last_seen_bucket"
metrics = Metrics.load_from_file(
HISTOGRAM_FILE,
base_time=now - timedelta(minutes=90),
metric_name_override=metric_name,
)
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"doesnotreallymatter",
"p90",
)
end_ms = int(now.timestamp() * 1000)
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
response = make_query_request(signoz, token, start_2h, end_ms, [query])
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
start_15m = int((now - timedelta(minutes=15)).timestamp() * 1000)
response = make_query_request(signoz, token, start_15m, end_ms, [query])
assert response.status_code == HTTPStatus.OK
data = response.json()
warnings = get_all_warnings(data)
assert len(warnings) == 1
assert warnings[0]["message"].startswith(f"no data found for the metric {metric_name}")
def test_non_existent_metrics_returns_warning(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metric_name = "whatevergoennnsgoeshere"
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"doesnotreallymatter",
"sum",
)
end_ms = int(now.timestamp() * 1000)
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
response = make_query_request(signoz, token, start_2h, end_ms, [query])
assert response.status_code == HTTPStatus.OK
data = response.json()
warnings = get_all_warnings(data)
assert any("whatevergoennnsgoeshere" in w["message"] and "has never been received" in w["message"] for w in warnings), f"expected never-seen metric warning, got: {warnings}"
def test_non_existent_internal_metrics_returns_no_warning(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metric_name = "signoz_calls_total"
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"doesnotreallymatter",
"sum",
)
end_ms = int(now.timestamp() * 1000)
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
response = make_query_request(signoz, token, start_2h, end_ms, [query])
assert response.status_code == HTTPStatus.OK
data = response.json()
assert get_all_warnings(data) == []
def test_variable_in_filter_returns_no_warning(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
"""
A dashboard variable used in a metric filter expression (e.g.
`my_tag = $tag`) sits in value position but is lexed as a key token. It
must not be mistaken for a missing attribute key and must not produce a
"key not found" warning.
Regression test for https://github.com/SigNoz/engineering-pod/issues/5481
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metric_name = "test_variable_filter_metric"
metrics: list[Metrics] = [
Metrics(
metric_name=metric_name,
labels={"my_tag": "service-a"},
timestamp=now - timedelta(minutes=3),
value=10.0,
temporality="Cumulative",
),
Metrics(
metric_name=metric_name,
labels={"my_tag": "service-a"},
timestamp=now - timedelta(minutes=2),
value=30.0,
temporality="Cumulative",
),
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"increase",
"sum",
temporality="cumulative",
filter_expression="my_tag = $tag",
)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[query],
variables={"tag": {"type": "query", "value": "service-a"}},
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert data["status"] == "success"
# `my_tag` is a real label and `$tag` is a value-position variable, so
# neither should be flagged as a missing key on the metric.
assert get_all_warnings(data) == [], f"expected no warnings, got: {get_all_warnings(data)}"
# Verify /api/v1/fields/values filters label values by metricNamespace prefix.
# Inserts metrics under ns.a and ns.b, then asserts a specific prefix returns
# only matching values while a common prefix returns both.
def test_metric_namespace_values_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metrics: list[Metrics] = [
Metrics(
metric_name="ns.a.requests_total",
labels={"service": "svc-a"},
timestamp=now - timedelta(minutes=2),
value=10.0,
),
Metrics(
metric_name="ns.b.requests_total",
labels={"service": "svc-b"},
timestamp=now - timedelta(minutes=2),
value=20.0,
),
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=ns.a should return only svc-a
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "service",
"searchText": "",
"metricNamespace": "ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "svc-a" in values
assert "svc-b" not in values
# Common prefix: metricNamespace=ns should return both
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "service",
"searchText": "",
"metricNamespace": "ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "svc-a" in values
assert "svc-b" in values
# Verify /api/v1/fields/values with name=metric_name filters metric names by
# metricNamespace prefix. A specific prefix returns only its metric names;
# a common prefix returns metric names from all matching namespaces.
def test_metric_namespace_metric_name_values_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metrics: list[Metrics] = [
Metrics(
metric_name="ns.a.cpu.utilization",
labels={"host": "host-a"},
timestamp=now - timedelta(minutes=2),
value=50.0,
),
Metrics(
metric_name="ns.b.cpu.utilization",
labels={"host": "host-b"},
timestamp=now - timedelta(minutes=2),
value=60.0,
),
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=ns.a should return only ns.a.* metric names
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "metric_name",
"searchText": "",
"metricNamespace": "ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "ns.a.cpu.utilization" in values
assert "ns.b.cpu.utilization" not in values
# Common prefix: metricNamespace=ns should return both
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "metric_name",
"searchText": "",
"metricNamespace": "ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "ns.a.cpu.utilization" in values
assert "ns.b.cpu.utilization" in values
# Verify /api/v1/fields/keys filters attribute keys by metricNamespace prefix.
# Metrics under ns.a and ns.b carry distinct labels; a specific prefix returns
# only its keys while a common prefix returns keys from both namespaces.
def test_metric_namespace_keys_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metrics: list[Metrics] = [
Metrics(
metric_name="ns.a.cpu.utilization",
labels={"a_only_label": "val-a"},
timestamp=now - timedelta(minutes=2),
value=10.0,
),
Metrics(
metric_name="ns.b.cpu.utilization",
labels={"b_only_label": "val-b"},
timestamp=now - timedelta(minutes=2),
value=20.0,
),
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=ns.a should return only a_only_label
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"searchText": "label",
"metricNamespace": "ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
keys = response.json()["data"]["keys"]
assert "a_only_label" in keys
assert "b_only_label" not in keys
# Common prefix: metricNamespace=ns should return both keys
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"searchText": "label",
"metricNamespace": "ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
keys = response.json()["data"]["keys"]
assert "a_only_label" in keys
assert "b_only_label" in keys

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,928 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import requests
from fixtures import querier, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.metrics import Metrics
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
log_or_trace_service_counts = {
"service-a": 5,
"service-b": 3,
"service-c": 7,
"service-d": 1,
}
metric_values_for_test = {
"service-a": 50.0,
"service-b": 30.0,
"service-c": 70.0,
"service-d": 10.0,
}
def generate_logs_with_counts(
now: datetime,
service_counts: dict[str, int],
) -> list[Logs]:
logs = []
for service, count in service_counts.items():
for i in range(count):
logs.append(
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": service},
body=f"{service} log {i}",
)
)
return logs
def generate_traces_with_counts(
now: datetime,
service_counts: dict[str, int],
) -> list[Traces]:
traces = []
for service, count in service_counts.items():
for i in range(count):
trace_id = TraceIdGenerator.trace_id()
span_id = TraceIdGenerator.span_id()
traces.append(
Traces(
timestamp=now - timedelta(seconds=i + 1),
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
trace_id=trace_id,
span_id=span_id,
resources={"service.name": service},
name=f"{service} span {i}",
)
)
return traces
def generate_metrics_with_values(
now: datetime,
service_values: dict[str, float],
) -> list[Metrics]:
metrics = []
for service, value in service_values.items():
metrics.append(
Metrics(
metric_name="test.metric",
labels={"service.name": service},
timestamp=now - timedelta(seconds=1),
temporality="Unspecified",
type_="Gauge",
is_monotonic=False,
value=value,
)
)
return metrics
def make_scalar_query_request(
signoz: types.SigNoz,
token: str,
now: datetime,
queries: list[dict],
lookback_minutes: int = 5,
) -> requests.Response:
return requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": int((now - timedelta(minutes=lookback_minutes)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"requestType": "scalar",
"compositeQuery": {"queries": queries},
"formatOptions": {"formatTableResultForUI": True, "fillGaps": False},
},
)
def build_logs_query(
name: str = "A",
aggregations: list[str] | None = None,
group_by: list[str] | None = None,
order_by: list[tuple[str, str]] | None = None,
limit: int | None = None,
) -> dict:
if aggregations is None:
aggregations = ["count()"]
aggs = [querier.build_logs_aggregation(expr) for expr in aggregations]
gb = [querier.build_group_by_field(f, "string", "resource") for f in group_by] if group_by else None
order = [querier.build_order_by(name, direction) for name, direction in order_by] if order_by else None
return querier.build_scalar_query(
name=name,
signal="logs",
aggregations=aggs,
group_by=gb,
order=order,
limit=limit,
)
def build_traces_query(
name: str = "A",
aggregations: list[str] | None = None,
group_by: list[str] | None = None,
order_by: list[tuple[str, str]] | None = None,
limit: int | None = None,
) -> dict:
if aggregations is None:
aggregations = ["count()"]
aggs = [querier.build_logs_aggregation(expr) for expr in aggregations]
gb = [querier.build_group_by_field(f, "string", "resource") for f in group_by] if group_by else None
order = [querier.build_order_by(name, direction) for name, direction in order_by] if order_by else None
return querier.build_scalar_query(
name=name,
signal="traces",
aggregations=aggs,
group_by=gb,
order=order,
limit=limit,
)
def build_metrics_query(
name: str = "A",
metric_name: str = "test.metric",
time_aggregation: str = "latest",
space_aggregation: str = "sum",
group_by: list[str] | None = None,
order_by: list[tuple[str, str]] | None = None,
limit: int | None = None,
) -> dict:
aggs = [querier.build_metrics_aggregation(metric_name, time_aggregation, space_aggregation, "unspecified")]
gb = [querier.build_group_by_field(f, "string", "attribute") for f in group_by] if group_by else None
order = [querier.build_order_by(name, direction) for name, direction in order_by] if order_by else None
return querier.build_scalar_query(
name=name,
signal="metrics",
aggregations=aggs,
group_by=gb,
order=order,
limit=limit,
)
def test_logs_scalar_group_by_single_agg_no_order(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3), ("service-d", 1)],
"Logs no order - default desc",
)
def test_logs_scalar_group_by_single_agg_order_by_agg_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("count()", "asc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-b", 3), ("service-a", 5), ("service-c", 7)],
"Logs order by agg asc",
)
def test_logs_scalar_group_by_single_agg_order_by_agg_desc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("count()", "desc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3), ("service-d", 1)],
"Logs order by agg desc",
)
def test_logs_scalar_group_by_single_agg_order_by_grouping_key_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("service.name", "asc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-a", 5), ("service-b", 3), ("service-c", 7), ("service-d", 1)],
"Logs order by grouping key asc",
)
def test_logs_scalar_group_by_single_agg_order_by_grouping_key_desc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("service.name", "desc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-c", 7), ("service-b", 3), ("service-a", 5)],
"Logs order by grouping key desc",
)
def test_logs_scalar_group_by_multiple_aggs_order_by_first_agg_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_logs_query(
group_by=["service.name"],
aggregations=["count()", "count_distinct(body)"],
order_by=[("count()", "asc")],
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_column_order(data, 0, ["service-d", "service-b", "service-a", "service-c"], "First column")
def test_logs_scalar_group_by_multiple_aggs_order_by_second_agg_desc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_logs_query(
group_by=["service.name"],
aggregations=["count()", "count_distinct(body)"],
order_by=[("count_distinct(body)", "desc")],
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
# count_distinct(body) should equal count() since each log has unique body
querier.assert_scalar_column_order(data, 0, ["service-c", "service-a", "service-b", "service-d"], "First column")
def test_logs_scalar_group_by_single_agg_order_by_agg_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("count()", "asc")], limit=2)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-b", 3)],
"Logs order by agg asc with limit 2",
)
def test_logs_scalar_group_by_single_agg_order_by_agg_desc_limit_3(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("count()", "desc")], limit=3)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3)],
"Logs order by agg desc with limit 3",
)
def test_logs_scalar_group_by_order_by_grouping_key_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("service.name", "asc")], limit=2)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-a", 5), ("service-b", 3)],
"Logs order by grouping key asc with limit 2",
)
def test_traces_scalar_group_by_single_agg_no_order(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3), ("service-d", 1)],
"Traces no order - default desc",
)
def test_traces_scalar_group_by_single_agg_order_by_agg_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("count()", "asc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-b", 3), ("service-a", 5), ("service-c", 7)],
"Traces order by agg asc",
)
def test_traces_scalar_group_by_single_agg_order_by_agg_desc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("count()", "desc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3), ("service-d", 1)],
"Traces order by agg desc",
)
def test_traces_scalar_group_by_single_agg_order_by_grouping_key_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("service.name", "asc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-a", 5), ("service-b", 3), ("service-c", 7), ("service-d", 1)],
"Traces order by grouping key asc",
)
def test_traces_scalar_group_by_single_agg_order_by_grouping_key_desc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("service.name", "desc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-c", 7), ("service-b", 3), ("service-a", 5)],
"Traces order by grouping key desc",
)
def test_traces_scalar_group_by_multiple_aggs_order_by_first_agg_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_traces_query(
group_by=["service.name"],
aggregations=["count()", "count_distinct(trace_id)"],
order_by=[("count()", "asc")],
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_column_order(data, 0, ["service-d", "service-b", "service-a", "service-c"], "First column")
def test_traces_scalar_group_by_single_agg_order_by_agg_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("count()", "asc")], limit=2)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-b", 3)],
"Traces order by agg asc with limit 2",
)
def test_traces_scalar_group_by_single_agg_order_by_agg_desc_limit_3(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("count()", "desc")], limit=3)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3)],
"Traces order by agg desc with limit 3",
)
def test_traces_scalar_group_by_order_by_grouping_key_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("service.name", "asc")], limit=2)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-a", 5), ("service-b", 3)],
"Traces order by grouping key asc with limit 2",
)
def test_metrics_scalar_group_by_single_agg_no_order(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_metrics_query(group_by=["service.name"])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[
("service-c", 70.0),
("service-a", 50.0),
("service-b", 30.0),
("service-d", 10.0),
],
"Metrics no order - default desc",
)
def test_metrics_scalar_group_by_single_agg_order_by_agg_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_metrics_query(
group_by=["service.name"],
order_by=[("sum(test.metric)", "asc")],
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[
("service-d", 10.0),
("service-b", 30.0),
("service-a", 50.0),
("service-c", 70.0),
],
"Metrics order by agg asc",
)
def test_metrics_scalar_group_by_single_agg_order_by_grouping_key_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_metrics_query(
group_by=["service.name"],
order_by=[("service.name", "asc")],
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[
("service-a", 50.0),
("service-b", 30.0),
("service-c", 70.0),
("service-d", 10.0),
],
"Metrics order by grouping key asc",
)
def test_metrics_scalar_group_by_single_agg_order_by_agg_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_metrics_query(
group_by=["service.name"],
order_by=[("sum(test.metric)", "asc")],
limit=2,
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 10.0), ("service-b", 30.0)],
"Metrics order by agg asc with limit 2",
)
def test_metrics_scalar_group_by_single_agg_order_by_agg_desc_limit_3(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_metrics_query(
group_by=["service.name"],
order_by=[("sum(test.metric)", "desc")],
limit=3,
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 70.0), ("service-a", 50.0), ("service-b", 30.0)],
"Metrics order by agg desc with limit 3",
)
def test_metrics_scalar_group_by_order_by_grouping_key_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_metrics_query(
group_by=["service.name"],
order_by=[("service.name", "asc")],
limit=2,
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-a", 50.0), ("service-b", 30.0)],
"Metrics order by grouping key asc with limit 2",
)

View File

@@ -602,17 +602,17 @@ def test_logs_list_query_timestamp_expectations(
order=[OrderBy(TelemetryFieldKey("resource.trace_id"), "desc")],
),
lambda x: [
[x[2].id, x[2].timestamp, x[2].resources_string.get("trace_id", None)],
[x[1].id, x[1].timestamp, x[1].resources_string.get("trace_id", None)],
[x[0].id, x[0].timestamp, x[0].resources_string.get("trace_id", None)],
[x[3].id, x[3].timestamp, x[3].resources_string.get("trace_id", None)],
[x[2].id, x[2].timestamp, x[2].resources_string.get("trace_id", "")],
[x[1].id, x[1].timestamp, x[1].resources_string.get("trace_id", "")],
[x[0].id, x[0].timestamp, x[0].resources_string.get("trace_id", "")],
[x[3].id, x[3].timestamp, x[3].resources_string.get("trace_id", "")],
],
id="select-resource-trace-id-order-resource-trace-id-desc",
# Justification (expected values and row order):
# AdjustKeys: no-op for both select and order, "resource.trace_id" is a valid resource key
# Field mapping: "resource.trace_id" → resources_string["trace_id"]
# Values: x[0]=None, x[1]=None, x[2]="3", x[3]=None (only x[2] has resource.trace_id set)
# Order: resource.trace_id DESC → x[2]("3") first, then x[1](None), x[0](None), x[3](None) in storage order
# Values: x[0]="", x[1]="", x[2]="3", x[3]="" (only x[2] has resource.trace_id set)
# Order: resource.trace_id DESC → x[2]("3") first, then x[1](""), x[0](""), x[3]("") in storage order
# Behaviour:
# AdjustKeys no-op
),
@@ -735,6 +735,7 @@ def test_logs_list_query_trace_id_expectations(
# No results expected
assert response.json()["data"]["data"]["results"][0]["rows"] is None
else:
print(response.json())
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == len(results(logs)), f"Expected {len(results(logs))} rows, got {len(rows)}"
for row, expected_row in zip(rows, results(logs)):

View File

@@ -1,114 +0,0 @@
import json
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import get_rows, make_query_request
# Positive coverage for the body-JSON array functions hasAny / hasAll in JSON-body
# mode (BODY_JSON_QUERY_ENABLED=true). Here body_v2 arrays resolve to real ClickHouse
# Arrays via dynamicElement, so these succeed — unlike legacy body mode, where
# hasAny/hasAll xfail (see querierlogs/09_json_body_functions.py).
# export_json_types registers the body paths + array element types (tags -> []string,
# ids -> []int64) so the builder resolves body.tags/body.ids as arrays.
def test_logs_json_body_has_any_string(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
export_json_types: Callable[[list[Logs]], None],
) -> None:
"""hasAny over a []string body array: matches logs sharing ANY listed value."""
now = datetime.now(tz=UTC)
logs = [
Logs(timestamp=now - timedelta(seconds=3), resources={"service.name": "app-service"}, body_v2=json.dumps({"tags": ["production", "api", "critical"]}), body_promoted="", severity_text="INFO"),
Logs(timestamp=now - timedelta(seconds=2), resources={"service.name": "app-service"}, body_v2=json.dumps({"tags": ["staging", "api", "test"]}), body_promoted="", severity_text="INFO"),
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "app-service"}, body_v2=json.dumps({"tags": ["production", "web", "important"]}), body_promoted="", severity_text="INFO"),
]
export_json_types(logs)
insert_logs(logs)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
# log1 has "critical", log2 has "test", log3 has neither -> 2 matches
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=10)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "disabled": False, "limit": 100, "offset": 0, "filter": {"expression": "hasAny(body.tags, ['critical', 'test'])"}, "order": [{"key": {"name": "timestamp"}, "direction": "desc"}], "aggregations": [{"expression": "count()"}]}}],
)
assert response.status_code == HTTPStatus.OK
rows = get_rows(response)
assert len(rows) == 2
assert all(("critical" in row["data"]["body"]["tags"]) or ("test" in row["data"]["body"]["tags"]) for row in rows)
def test_logs_json_body_has_all_string(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
export_json_types: Callable[[list[Logs]], None],
) -> None:
"""hasAll over a []string body array: matches only logs having ALL listed values."""
now = datetime.now(tz=UTC)
logs = [
Logs(timestamp=now - timedelta(seconds=2), resources={"service.name": "app-service"}, body_v2=json.dumps({"tags": ["production", "api", "critical"]}), body_promoted="", severity_text="INFO"),
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "app-service"}, body_v2=json.dumps({"tags": ["production", "web", "important"]}), body_promoted="", severity_text="INFO"),
]
export_json_types(logs)
insert_logs(logs)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
# only the second log has both "production" AND "web"
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=10)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "disabled": False, "limit": 100, "offset": 0, "filter": {"expression": "hasAll(body.tags, ['production', 'web'])"}, "order": [{"key": {"name": "timestamp"}, "direction": "desc"}], "aggregations": [{"expression": "count()"}]}}],
)
assert response.status_code == HTTPStatus.OK
rows = get_rows(response)
assert len(rows) == 1
tags = rows[0]["data"]["body"]["tags"]
assert "production" in tags and "web" in tags
def test_logs_json_body_has_any_number(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
export_json_types: Callable[[list[Logs]], None],
) -> None:
"""hasAny over a []int64 body array."""
now = datetime.now(tz=UTC)
logs = [
Logs(timestamp=now - timedelta(seconds=2), resources={"service.name": "app-service"}, body_v2=json.dumps({"ids": [100, 200, 300]}), body_promoted="", severity_text="INFO"),
Logs(timestamp=now - timedelta(seconds=1), resources={"service.name": "app-service"}, body_v2=json.dumps({"ids": [400, 600, 700]}), body_promoted="", severity_text="INFO"),
]
export_json_types(logs)
insert_logs(logs)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
# only the first log has 300 in ids; 999 matches nothing
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=10)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "disabled": False, "limit": 100, "offset": 0, "filter": {"expression": "hasAny(body.ids, [300, 999])"}, "order": [{"key": {"name": "timestamp"}, "direction": "desc"}], "aggregations": [{"expression": "count()"}]}}],
)
assert response.status_code == HTTPStatus.OK
rows = get_rows(response)
assert len(rows) == 1
assert 300 in rows[0]["data"]["body"]["ids"]

View File

@@ -1,141 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import get_column_data_from_response, make_query_request
# Filter-operator coverage that 01_filter_expression.py (NOT semantics) and
# 06_json_body.py (CONTAINS) leave out: ILIKE / NOT LIKE / NOT CONTAINS, the
# `key:number` data-type-suffix disambiguator on an ambiguous key, and a
# truly-unknown key (rejected as a bad request on this HEAD).
#
# Data model mirrors 01_filter_expression.py::test_not_filter_expression:
# alpha-log: resources region="us-east"; attributes status_code=200 (number)
# beta-log: resources status_code="500" (string); attributes region=1 (number)
# so region/status_code each appear as both a resource and an attribute across the
# two logs — the intentional overlap that context prefix + :type disambiguate.
@pytest.mark.parametrize(
"expression,expected_bodies",
[
pytest.param('resource.region ILIKE "%US-EAST%"', {"alpha-log"}, id="ilike_resource"),
pytest.param('body ILIKE "%ALPHA%"', {"alpha-log"}, id="ilike_body"),
pytest.param('body NOT CONTAINS "alpha"', {"beta-log"}, id="not_contains_body"),
pytest.param('NOT body LIKE "%alpha%"', {"beta-log"}, id="not_like_body"),
pytest.param("attribute.status_code:number = 200", {"alpha-log"}, id="datatype_suffix_number"),
pytest.param('resource.status_code:string = "500"', {"beta-log"}, id="datatype_suffix_string"),
],
)
def test_logs_filter_operators_and_datatype_suffix(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
expression: str,
expected_bodies: set[str],
) -> None:
"""ILIKE / NOT LIKE / NOT CONTAINS and the key:number|string suffix resolve correctly."""
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=5),
body="alpha-log",
resources={"region": "us-east", "env": "production", "hostname": "host-alpha"},
attributes={"status_code": 200, "latency_ms": 350, "error_count": 3},
),
Logs(
timestamp=now - timedelta(seconds=3),
body="beta-log",
resources={"status_code": "500", "latency_ms": "2500", "error_count": "10"},
attributes={"region": 1, "env": 2, "hostname": 3},
),
]
)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": expression},
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
assert set(get_column_data_from_response(response.json(), "body")) == expected_bodies
def test_logs_filter_key_not_found(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""A filter on a key that exists in no context is rejected (400).
NOTE: reflects current HEAD behavior. The parked `convert-not-found-to-warning`
change will turn this into a 200 with a warning — update this assertion when it lands.
"""
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=3),
body="alpha-log",
resources={"region": "us-east"},
attributes={"status_code": 200},
),
]
)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": 'totally.unknown.key = "x"'},
"order": [{"key": {"name": "timestamp"}, "direction": "desc"}],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.BAD_REQUEST

View File

@@ -1,500 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
assert_identical_query_response,
make_query_request,
)
def test_logs_list(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Setup:
Insert 2 logs with different attributes
Tests:
1. Query logs for the last 10 seconds and check if the logs are returned in the correct order
2. Query values of severity_text attribute from the autocomplete API
3. Query values of severity_text attribute from the fields API
4. Query values of code.file attribute from the autocomplete API
5. Query values of code.file attribute from the fields API
6. Query values of code.line attribute from the autocomplete API
7. Query values of code.line attribute from the fields API
"""
insert_logs(
[
Logs(
timestamp=datetime.now(tz=UTC) - timedelta(seconds=1),
resources={
"deployment.environment": "production",
"service.name": "java",
"os.type": "linux",
"host.name": "linux-001",
"cloud.provider": "integration",
"cloud.account.id": "001",
},
attributes={
"log.iostream": "stdout",
"logtag": "F",
"code.file": "/opt/Integration.java",
"code.function": "com.example.Integration.process",
"code.line": 120,
"telemetry.sdk.language": "java",
},
body="This is a log message, coming from a java application",
severity_text="DEBUG",
),
Logs(
timestamp=datetime.now(tz=UTC),
resources={
"deployment.environment": "production",
"service.name": "go",
"os.type": "linux",
"host.name": "linux-001",
"cloud.provider": "integration",
"cloud.account.id": "001",
},
attributes={
"log.iostream": "stdout",
"logtag": "F",
"code.file": "/opt/integration.go",
"code.function": "com.example.Integration.process",
"code.line": 120,
"metric.domain_id": "d-001",
"telemetry.sdk.language": "go",
},
body="This is a log message, coming from a go application",
severity_text="INFO",
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Query Logs for the last 10 seconds and check if the logs are returned in the correct order
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC) - timedelta(seconds=10)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).timestamp() * 1000),
"requestType": "raw",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0]["rows"]
assert len(rows) == 2
assert rows[0]["data"]["body"] == "This is a log message, coming from a go application"
assert rows[0]["data"]["resources_string"] == {
"cloud.account.id": "001",
"cloud.provider": "integration",
"deployment.environment": "production",
"host.name": "linux-001",
"os.type": "linux",
"service.name": "go",
}
assert rows[0]["data"]["attributes_string"] == {
"code.file": "/opt/integration.go",
"code.function": "com.example.Integration.process",
"log.iostream": "stdout",
"logtag": "F",
"metric.domain_id": "d-001",
"telemetry.sdk.language": "go",
}
assert rows[0]["data"]["attributes_number"] == {"code.line": 120}
assert rows[1]["data"]["body"] == "This is a log message, coming from a java application"
assert rows[1]["data"]["resources_string"] == {
"cloud.account.id": "001",
"cloud.provider": "integration",
"deployment.environment": "production",
"host.name": "linux-001",
"os.type": "linux",
"service.name": "java",
}
assert rows[1]["data"]["attributes_string"] == {
"code.file": "/opt/Integration.java",
"code.function": "com.example.Integration.process",
"log.iostream": "stdout",
"logtag": "F",
"telemetry.sdk.language": "java",
}
assert rows[1]["data"]["attributes_number"] == {"code.line": 120}
# Query values of severity_text attribute from the autocomplete API
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v3/autocomplete/attribute_values"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"aggregateOperator": "noop",
"dataSource": "logs",
"aggregateAttribute": "",
"attributeKey": "severity_text",
"searchText": "",
"filterAttributeKeyDataType": "string",
"tagType": "resource",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["stringAttributeValues"]
assert len(values) == 2
assert "DEBUG" in values
assert "INFO" in values
# Query values of severity_text attribute from the fields API
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"signal": "logs",
"name": "severity_text",
"searchText": "",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert len(values) == 2
assert "DEBUG" in values
assert "INFO" in values
# Query values of code.file attribute from the autocomplete API
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v3/autocomplete/attribute_values"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"aggregateOperator": "noop",
"dataSource": "logs",
"aggregateAttribute": "",
"attributeKey": "code.file",
"searchText": "",
"filterAttributeKeyDataType": "string",
"tagType": "tag",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["stringAttributeValues"]
assert len(values) == 2
assert "/opt/Integration.java" in values
assert "/opt/integration.go" in values
# Query values of code.file attribute from the fields API
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"signal": "logs",
"name": "code.file",
"searchText": "",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert len(values) == 2
assert "/opt/Integration.java" in values
assert "/opt/integration.go" in values
# Query values of code.line attribute from the autocomplete API
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v3/autocomplete/attribute_values"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"aggregateOperator": "noop",
"dataSource": "logs",
"aggregateAttribute": "",
"attributeKey": "code.line",
"searchText": "",
"filterAttributeKeyDataType": "float64",
"tagType": "tag",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["numberAttributeValues"]
assert len(values) == 1
assert 120 in values
# Query values of code.line attribute from the fields API
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"signal": "logs",
"name": "code.line",
"searchText": "",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["numberValues"]
assert len(values) == 1
assert 120 in values
# Query keys from the fields API with context specified in the key
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"signal": "logs",
"searchText": "resource.servic",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
keys = response.json()["data"]["keys"]
assert "service.name" in keys
assert any(k["fieldContext"] == "resource" for k in keys["service.name"])
# Do not treat `metric.` as a context prefix for logs
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"signal": "logs",
"searchText": "metric.do",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
keys = response.json()["data"]["keys"]
assert "metric.domain_id" in keys
# Query values of service.name resource attribute using context-prefixed key
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"signal": "logs",
"name": "resource.service.name",
"searchText": "",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "go" in values
assert "java" in values
# Query values of metric.domain_id (string attribute) and ensure context collision doesn't break it
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"signal": "logs",
"name": "metric.domain_id",
"searchText": "",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "d-001" in values
@pytest.mark.parametrize(
"order_by_context,expected_order",
####
# Tests:
# 1. Query logs ordered by attribute.service.name descending
# 2. Query logs ordered by resource.service.name descending
# 3. Query logs ordered by service.name descending
###
[
pytest.param("attribute", ["log-002", "log-001", "log-004", "log-003"]),
pytest.param("resource", ["log-003", "log-004", "log-001", "log-002"]),
pytest.param("", ["log-002", "log-001", "log-003", "log-004"]),
],
)
def test_logs_list_with_order_by(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
order_by_context: str,
expected_order: list[str],
) -> None:
"""
Setup:
Insert 3 logs with service.name in attributes and resources
"""
attribute_resource_pair = [
[{"id": "log-001", "service.name": "c"}, {}],
[{"id": "log-002", "service.name": "d"}, {}],
[{"id": "log-003"}, {"service.name": "b"}],
[{"id": "log-004"}, {"service.name": "a"}],
]
insert_logs(
[
Logs(
timestamp=datetime.now(tz=UTC) - timedelta(seconds=3),
attributes=attribute_resource_pair[i][0],
resources=attribute_resource_pair[i][1],
body="Log with DEBUG severity",
severity_text="DEBUG",
)
for i in range(4)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = {
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"order": [
{
"key": {
"name": "service.name",
"fieldContext": order_by_context,
},
"direction": "desc",
}
],
},
}
query_with_inline_context = {
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"order": [
{
"key": {
"name": f"{order_by_context + '.' if order_by_context else ''}service.name",
},
"direction": "desc",
}
],
},
}
response = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=1)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type="raw",
queries=[query],
)
# Verify that both queries return the same results with specifying context with key name
response_with_inline_context = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=1)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type="raw",
queries=[query_with_inline_context],
)
assert_identical_query_response(response, response_with_inline_context)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
rows = results[0]["rows"]
ids = [row["data"]["attributes_string"].get("id", "") for row in rows]
assert ids == expected_order

View File

@@ -1,788 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
assert_identical_query_response,
make_query_request,
)
def test_logs_time_series_count(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Setup:
Insert 17 logs with service.name attribute set to "java" and severity_text attribute set to "DEBUG", 23 logs with service.name attribute set to "erlang" and severity_text attribute set to "ERROR", 29 logs with service.name attribute set to "go" and severity_text attribute set to "WARNING".
All logs have incrementing code.line attribute, modulo 2 for host.name and cloud.account.id.
Tests:
1. count() of all logs for the last 5 minutes
2. count() of all logs where code.line = 7 for last 5 minutes
3. count() of all logs where service.name = "erlang" OR cloud.account.id = "000" for last 5 minutes
4. count() of all logs grouped by host.name for the last 5 minutes
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = []
for i in range(17):
logs.append(
Logs(
timestamp=now - timedelta(microseconds=i + 1), # These logs will be grouped in the now - 1 minute bucket
resources={
"deployment.environment": "production",
"service.name": "java",
"os.type": "linux",
"host.name": f"linux-00{i % 2}",
"cloud.provider": "integration",
"cloud.account.id": f"00{i % 2}",
},
attributes={
"log.iostream": "stdout",
"logtag": "F",
"code.file": "/opt/Integration.java",
"code.function": "com.example.Integration.process",
"code.line": i + 1,
"telemetry.sdk.language": "java",
},
body=f"This is a log message, number {i + 1} coming from a java application",
severity_text="DEBUG",
)
)
for i in range(23):
logs.append(
Logs(
timestamp=now - timedelta(minutes=1) - timedelta(microseconds=i + 1), # These logs will be grouped in the now - 2 minute bucket
resources={
"deployment.environment": "production",
"service.name": "erlang",
"os.type": "linux",
"host.name": f"linux-00{i % 2}",
"cloud.provider": "integration",
"cloud.account.id": f"00{i % 2}",
},
attributes={
"log.iostream": "stdout",
"logtag": "F",
"code.file": "/opt/Integration.erlang",
"code.function": "com.example.Integration.process",
"code.line": i + 1,
"telemetry.sdk.language": "erlang",
},
body=f"This is a log message, number {i + 1} coming from a erlang application",
severity_text="ERROR",
)
)
for i in range(29):
logs.append(
Logs(
timestamp=now - timedelta(minutes=2) - timedelta(microseconds=i + 1), # These logs will be grouped in the now - 3 minute bucket
resources={
"deployment.environment": "production",
"service.name": "go",
"os.type": "linux",
"host.name": f"linux-00{i % 2}",
"cloud.provider": "integration",
"cloud.account.id": f"00{i % 2}",
},
attributes={
"log.iostream": "stdout",
"logtag": "F",
"code.file": "/opt/Integration.go",
"code.function": "com.example.Integration.process",
"code.line": i + 1,
"telemetry.sdk.language": "go",
},
body=f"This is a log message, number {i + 1} coming from a go application",
severity_text="WARNING",
)
)
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# count() of all logs for the last 5 minutes
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).replace(second=0, microsecond=0).timestamp() * 1000),
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 1
values = series[0]["values"]
assert len(values) == 3
# Care about the order of the values
assert [
i
for i in values
if i
not in [
{
"timestamp": int((now - timedelta(minutes=3)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 29,
},
{
"timestamp": int((now - timedelta(minutes=2)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 23,
},
{
"timestamp": int((now - timedelta(minutes=1)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 17,
},
]
] == []
# count() of all logs where code.line = 7 for last 5 minutes
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).replace(second=0, microsecond=0).timestamp() * 1000),
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": "code.line = 7"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 1
values = series[0]["values"]
assert len(values) == 3
# Care about the order of the values
assert [
i
for i in values
if i
not in [
{
"timestamp": int((now - timedelta(minutes=3)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 1,
},
{
"timestamp": int((now - timedelta(minutes=2)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 1,
},
{
"timestamp": int((now - timedelta(minutes=1)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 1,
},
]
] == []
# count() of all logs where service.name = "erlang" OR cloud.account.id = "000" for last 5 minutes
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).replace(second=0, microsecond=0).timestamp() * 1000),
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": "service.name = 'erlang' OR cloud.account.id = '000'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 1
values = series[0]["values"]
assert len(values) == 3
# Do not care about the order of the values
assert {
"timestamp": int((now - timedelta(minutes=3)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 15,
} in values
assert {
"timestamp": int((now - timedelta(minutes=2)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 23,
} in values
assert {
"timestamp": int((now - timedelta(minutes=1)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 9,
} in values
# count() of all logs grouped by host.name for the last 5 minutes
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).replace(second=0, microsecond=0).timestamp() * 1000),
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"groupBy": [
{
"name": "host.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"order": [{"key": {"name": "host.name"}, "direction": "desc"}],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
response_with_inline_context = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).replace(second=0, microsecond=0).timestamp() * 1000),
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"groupBy": [
{
"name": "resource.host.name:string",
}
],
"order": [{"key": {"name": "host.name"}, "direction": "desc"}],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert_identical_query_response(response, response_with_inline_context)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 2
# Care about the order of the values
assert series[0]["labels"] == [
{
"key": {
"name": "host.name",
},
"value": "linux-001",
}
]
assert {
"timestamp": int((now - timedelta(minutes=3)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 14,
} in series[0]["values"]
assert {
"timestamp": int((now - timedelta(minutes=2)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 11,
} in series[0]["values"]
assert {
"timestamp": int((now - timedelta(minutes=1)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 8,
} in series[0]["values"]
assert series[1]["labels"] == [
{
"key": {
"name": "host.name",
},
"value": "linux-000",
}
]
assert {
"timestamp": int((now - timedelta(minutes=3)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 15,
} in series[1]["values"]
assert {
"timestamp": int((now - timedelta(minutes=2)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 12,
} in series[1]["values"]
assert {
"timestamp": int((now - timedelta(minutes=1)).replace(second=0, microsecond=0).timestamp() * 1000),
"value": 9,
} in series[1]["values"]
def test_datatype_collision(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Setup:
Insert logs with data type collision scenarios to test DataTypeCollisionHandledFieldName function
Tests:
1. severity_number comparison with string value
2. http.status_code with mixed string/number values
3. response.time with string values in numeric field
4. Edge cases: empty strings
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = []
# Logs with string values in numeric fields
severity_levels = ["DEBUG", "INFO", "WARN"]
for i in range(3):
logs.append(
Logs(
timestamp=now - timedelta(microseconds=i + 1),
resources={
"deployment.environment": "production",
"service.name": "java",
"os.type": "linux",
"host.name": f"linux-00{i % 2}",
"cloud.provider": "integration",
"cloud.account.id": f"00{i % 2}",
},
attributes={
"log.iostream": "stdout",
"logtag": "F",
"code.file": "/opt/Integration.java",
"code.function": "com.example.Integration.process",
"code.line": i + 1,
"telemetry.sdk.language": "java",
"http.status_code": "200", # String value
"response.time": "123.45", # String value
},
body=f"Test log {i + 1} with string values",
severity_text=severity_levels[i], # DEBUG(5-8), INFO(9-12), WARN(13-16)
)
)
# Logs with numeric values in string fields
severity_levels_2 = ["ERROR", "FATAL", "TRACE", "DEBUG"]
for i in range(4):
logs.append(
Logs(
timestamp=now - timedelta(microseconds=i + 10),
resources={
"deployment.environment": "production",
"service.name": "go",
"os.type": "linux",
"host.name": f"linux-00{i % 2}",
"cloud.provider": "integration",
"cloud.account.id": f"00{i % 2}",
},
attributes={
"log.iostream": "stdout",
"logtag": "F",
"code.file": "/opt/integration.go",
"code.function": "com.example.Integration.process",
"code.line": i + 1,
"telemetry.sdk.language": "go",
"http.status_code": 404, # Numeric value
"response.time": 456.78, # Numeric value
},
body=f"Test log {i + 4} with numeric values",
severity_text=severity_levels_2[i], # ERROR(17-20), FATAL(21-24), TRACE(1-4), DEBUG(5-8)
)
)
# Edge case: empty string and zero value
logs.append(
Logs(
timestamp=now - timedelta(microseconds=20),
resources={
"deployment.environment": "production",
"service.name": "python",
"os.type": "linux",
"host.name": "linux-002",
"cloud.provider": "integration",
"cloud.account.id": "002",
},
attributes={
"log.iostream": "stdout",
"logtag": "F",
"code.file": "/opt/integration.py",
"code.function": "com.example.Integration.process",
"code.line": 1,
"telemetry.sdk.language": "python",
"http.status_code": "", # Empty string
"response.time": 0, # Zero value
},
body="Edge case test log",
severity_text="ERROR",
)
)
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# count() of all logs for the where severity_number > '7'
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).replace(second=0, microsecond=0).timestamp() * 1000),
"requestType": "scalar",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": "severity_number > '7'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": True, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
count = results[0]["data"][0][0]
assert count == 5
# count() of all logs for the where severity_number > '7.0'
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).replace(second=0, microsecond=0).timestamp() * 1000),
"requestType": "scalar",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": "severity_number > '7.0'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": True, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
count = results[0]["data"][0][0]
assert count == 5
# Test 2: severity_number comparison with string value
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).replace(second=0, microsecond=0).timestamp() * 1000),
"requestType": "scalar",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": "severity_number = '13'"}, # String comparison with numeric field
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": True, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
count = results[0]["data"][0][0]
# WARN severity maps to 13-16 range, so should find 1 log with severity_number = 13
assert count == 1
# Test 3: http.status_code with numeric value (query contains number, actual value is string "200")
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).replace(second=0, microsecond=0).timestamp() * 1000),
"requestType": "scalar",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": "http.status_code = 200"}, # Numeric comparison with string field
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": True, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
count = results[0]["data"][0][0]
# Should return 3 logs with http.status_code = "200" (first 3 logs have string value "200")
assert count == 3
# Test 4: http.status_code with string value (query contains string, actual value is numeric 404)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).replace(second=0, microsecond=0).timestamp() * 1000),
"requestType": "scalar",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": "http.status_code = '404'"}, # String comparison with numeric field
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": True, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
count = results[0]["data"][0][0]
# Should return 4 logs with http.status_code = 404 (next 4 logs have numeric value 404)
assert count == 4
# Test 5: Edge case - empty string comparison
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).replace(second=0, microsecond=0).timestamp() * 1000),
"requestType": "scalar",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": "http.status_code = ''"}, # Empty string comparison
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": True, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
count = results[0]["data"][0][0]
# Should return 1 log with empty http.status_code (edge case log)
assert count == 1

View File

@@ -1,849 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
assert_minutely_bucket_values,
find_named_result,
index_series_by_label,
)
def test_logs_fill_gaps(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Test fillGaps for logs without groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = [
Logs(
timestamp=now - timedelta(minutes=3),
resources={"service.name": "test-service"},
attributes={"code.file": "test.py"},
body="Log at minute 3",
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(minutes=1),
resources={"service.name": "test-service"},
attributes={"code.file": "test.py"},
body="Log at minute 1",
severity_text="INFO",
),
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": True},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) >= 1
values = series[0]["values"]
# Logs are exactly at minute -3 and minute -1, so counts should be 1 there and 0 everywhere else
ts_min_1 = int((now - timedelta(minutes=1)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
assert_minutely_bucket_values(
values,
now,
expected_by_ts={ts_min_1: 1, ts_min_3: 1},
context="logs/fillGaps",
)
def test_logs_fill_gaps_with_group_by(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Test fillGaps for logs with groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = [
Logs(
timestamp=now - timedelta(minutes=3),
resources={"service.name": "service-a"},
attributes={"code.file": "test.py"},
body="Log from service A",
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(minutes=2),
resources={"service.name": "service-b"},
attributes={"code.file": "test.py"},
body="Log from service B",
severity_text="ERROR",
),
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": True},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 2, "Expected 2 series for 2 service groups"
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
series_by_service = index_series_by_label(series, "service.name")
assert set(series_by_service.keys()) == {"service-a", "service-b"}
# service-a has one log at minute -3, service-b at minute -2
expectations = {
"service-a": {ts_min_3: 1.0},
"service-b": {ts_min_2: 1.0},
}
for service_name, s in series_by_service.items():
assert_minutely_bucket_values(
s["values"],
now,
expected_by_ts=expectations[service_name],
context=f"logs/fillGaps/{service_name}",
)
def test_logs_fill_gaps_formula(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Test fillGaps for logs with formula.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = [
Logs(
timestamp=now - timedelta(minutes=3),
resources={"service.name": "test"},
attributes={"code.file": "test.py"},
body="Test log",
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(minutes=2),
resources={"service.name": "another-test"},
attributes={"code.file": "test.py"},
body="Another test log",
severity_text="INFO",
),
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": True,
"filter": {"expression": "service.name = 'test'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "logs",
"stepInterval": 60,
"disabled": True,
"filter": {"expression": "service.name = 'another-test'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A + B",
"disabled": False,
},
},
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": True},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
f1 = find_named_result(results, "F1")
assert f1 is not None, "Expected formula result named F1"
aggregations = f1.get("aggregations") or []
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) >= 1
assert_minutely_bucket_values(
series[0]["values"],
now,
expected_by_ts={ts_min_3: 1, ts_min_2: 1},
context="logs/fillGaps/F1",
)
def test_logs_fill_gaps_formula_with_group_by(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Test fillGaps for logs with formula and groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = [
Logs(
timestamp=now - timedelta(minutes=3),
resources={"service.name": "group1"},
attributes={"code.file": "test.py"},
body="Test log",
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(minutes=2),
resources={"service.name": "group2"},
attributes={"code.file": "test.py"},
body="Test log 2",
severity_text="INFO",
),
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": True,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "logs",
"stepInterval": 60,
"disabled": True,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A + B",
"disabled": False,
},
},
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": True},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
f1 = find_named_result(results, "F1")
assert f1 is not None, "Expected formula result named F1"
aggregations = f1.get("aggregations") or []
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 2
series_by_service = index_series_by_label(series, "service.name")
assert set(series_by_service.keys()) == {"group1", "group2"}
expectations = {
"group1": {ts_min_3: 2.0},
"group2": {ts_min_2: 2.0},
}
for service_name, s in series_by_service.items():
assert_minutely_bucket_values(
s["values"],
now,
expected_by_ts=expectations[service_name],
context=f"logs/fillGaps/F1/{service_name}",
)
def test_logs_fill_zero(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Test fillZero function for logs without groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = [
Logs(
timestamp=now - timedelta(minutes=3),
resources={"service.name": "test"},
attributes={"code.file": "test.py"},
body="Test log",
severity_text="INFO",
),
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
"functions": [{"name": "fillZero"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0].get("aggregations") or []
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) >= 1
values = series[0]["values"]
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
assert_minutely_bucket_values(
values,
now,
expected_by_ts={ts_min_3: 1},
context="logs/fillZero",
)
def test_logs_fill_zero_with_group_by(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Test fillZero function for logs with groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = [
Logs(
timestamp=now - timedelta(minutes=3),
resources={"service.name": "service-a"},
attributes={"code.file": "test.py"},
body="Log A",
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(minutes=2),
resources={"service.name": "service-b"},
attributes={"code.file": "test.py"},
body="Log B",
severity_text="ERROR",
),
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
"functions": [{"name": "fillZero"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 2, "Expected 2 series for 2 service groups"
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
series_by_service = index_series_by_label(series, "service.name")
assert set(series_by_service.keys()) == {"service-a", "service-b"}
expectations = {
"service-a": {ts_min_3: 1.0},
"service-b": {ts_min_2: 1.0},
}
for service_name, s in series_by_service.items():
assert_minutely_bucket_values(
s["values"],
now,
expected_by_ts=expectations[service_name],
context=f"logs/fillZero/{service_name}",
)
def test_logs_fill_zero_formula(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Test fillZero function for logs with formula.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = [
Logs(
timestamp=now - timedelta(minutes=3),
resources={"service.name": "test"},
attributes={"code.file": "test.py"},
body="Test log",
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(minutes=2),
resources={"service.name": "another-test"},
attributes={"code.file": "test.py"},
body="Another log",
severity_text="INFO",
),
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": True,
"filter": {"expression": "service.name = 'test'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "logs",
"stepInterval": 60,
"disabled": True,
"filter": {"expression": "service.name = 'another-test'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A + B",
"disabled": False,
"functions": [{"name": "fillZero"}],
},
},
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
f1 = find_named_result(results, "F1")
assert f1 is not None, "Expected formula result named F1"
aggregations = f1.get("aggregations") or []
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) >= 1
values = series[0]["values"]
assert_minutely_bucket_values(
values,
now,
expected_by_ts={ts_min_3: 1, ts_min_2: 1},
context="logs/fillZero/F1",
)
def test_logs_fill_zero_formula_with_group_by(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Test fillZero function for logs with formula and groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = [
Logs(
timestamp=now - timedelta(minutes=3),
resources={"service.name": "group1"},
attributes={"code.file": "test.py"},
body="Test log",
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(minutes=2),
resources={"service.name": "group2"},
attributes={"code.file": "test.py"},
body="Test log 2",
severity_text="INFO",
),
]
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": True,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "logs",
"stepInterval": 60,
"disabled": True,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A + B",
"disabled": False,
"functions": [{"name": "fillZero"}],
},
},
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
f1 = find_named_result(results, "F1")
assert f1 is not None, "Expected formula result named F1"
aggregations = f1.get("aggregations") or []
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 2
series_by_service = index_series_by_label(series, "service.name")
assert set(series_by_service.keys()) == {"group1", "group2"}
expectations = {
"group1": {ts_min_3: 2.0},
"group2": {ts_min_2: 2.0},
}
for service_name, s in series_by_service.items():
assert_minutely_bucket_values(
s["values"],
now,
expected_by_ts=expectations[service_name],
context=f"logs/fillZero/F1/{service_name}",
)

View File

@@ -1,193 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
build_formula_query,
build_group_by_field,
build_logs_aggregation,
build_order_by,
build_scalar_query,
find_named_result,
make_query_request,
)
def test_logs_formula_orderby_and_limit(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Test that formula results are correctly ordered and limited when
order and limit are applied on the formula.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = []
# For service-i (i in 0..9): insert (10 - i) ERROR logs and 2 INFO logs.
# A counts ERROR, B counts INFO, so A/B = (10 - i) / 2.
# service-0 ratio = 5.0 (highest), service-9 ratio = 0.5 (lowest).
for i in range(10):
for j in range(10 - i):
logs.append(
Logs(
timestamp=now - timedelta(minutes=j + 1),
resources={"service.name": f"service-{i}"},
attributes={"code.file": "test.py"},
body=f"Error log {i}-{j}",
severity_text="ERROR",
)
)
for k in range(2):
logs.append(
Logs(
timestamp=now - timedelta(minutes=k + 1),
resources={"service.name": f"service-{i}"},
attributes={"code.file": "test.py"},
body=f"Info log {i}-{k}",
severity_text="INFO",
)
)
# Extra INFO-only services that appear in B but not in A. The formula
for name in ("service-info-only-1", "service-info-only-2"):
for k in range(2):
logs.append(
Logs(
timestamp=now - timedelta(minutes=k + 1),
resources={"service.name": name},
attributes={"code.file": "test.py"},
body=f"Info log {name}-{k}",
severity_text="INFO",
)
)
# Logs look like this (columns = minutes before `now`; query range is
# (now - 15m, now], so the `now` column is the exclusive upper bound and
# no log lands there). E = ERROR, I = INFO, X = both at that minute.
#
# t-10 t-9 t-8 t-7 t-6 t-5 t-4 t-3 t-2 t-1 |now | A B A/B
# service-0: E E E E E E E E X X | | 10 2 5.0
# service-1: . E E E E E E E X X | | 9 2 4.5
# service-2: . . E E E E E E X X | | 8 2 4.0
# service-3: . . . E E E E E X X | | 7 2 3.5
# service-4: . . . . E E E E X X | | 6 2 3.0
# service-5: . . . . . E E E X X | | 5 2 2.5
# service-6: . . . . . . E E X X | | 4 2 2.0
# service-7: . . . . . . . E X X | | 3 2 1.5
# service-8: . . . . . . . . X X | | 2 2 1.0
# service-9: . . . . . . . . I X | | 1 2 0.5
# info-only-1: . . . . . . . . I I | | 0* 2 0.0
# info-only-2: . . . . . . . . I I | | 0* 2 0.0
#
# * A is missing for the info-only services; because A is count(), the
# formula evaluator defaults missing A to 0, yielding A/B = 0.
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
result = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=15)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="scalar",
queries=[
build_scalar_query(
name="A",
signal="logs",
aggregations=[build_logs_aggregation("count()")],
group_by=[build_group_by_field("service.name")],
filter_expression="severity_text = 'ERROR'",
disabled=True,
),
build_scalar_query(
name="B",
signal="logs",
aggregations=[build_logs_aggregation("count()")],
group_by=[build_group_by_field("service.name")],
filter_expression="severity_text = 'INFO'",
disabled=True,
),
build_formula_query(
"F1",
"A / B",
order=[build_order_by("__result", "desc")],
limit=3,
),
build_formula_query(
"F2",
"A / B",
order=[build_order_by("__result", "desc")],
),
build_formula_query(
"F3",
"A / B",
order=[build_order_by("__result", "asc")],
limit=3,
),
build_formula_query(
"F4",
"A / B",
order=[build_order_by("__result", "asc")],
),
],
)
assert result.status_code == HTTPStatus.OK
assert result.json()["status"] == "success"
results = result.json()["data"]["data"]["results"]
def extract_services_and_values(query_name: str) -> tuple[list, list]:
res = find_named_result(results, query_name)
assert res is not None, f"Expected formula result named {query_name}"
cols = res["columns"]
s_col = next(i for i, c in enumerate(cols) if c["name"] == "service.name")
v_col = next(i for i, c in enumerate(cols) if c["name"] == "__result")
rows = res["data"]
return [row[s_col] for row in rows], [row[v_col] for row in rows]
# Because A is count(), canDefaultZero["A"] is true; the formula evaluator
# defaults A to 0 for services that exist only in B. So the two INFO-only
# services appear in the formula result with value 0.0 (extreme bottom in
# desc order, extreme top in asc order). Their relative ordering is not
# deterministic across separate formula evaluations (tied values).
info_only_services = {"service-info-only-1", "service-info-only-2"}
# F2: desc, no limit -> 12 rows in descending order by value.
f2_services, f2_values = extract_services_and_values("F2")
assert len(f2_services) == 12, f"F2: expected 12 rows with no limit, got {len(f2_services)}"
assert f2_values == [5.0, 4.5, 4.0, 3.5, 3.0, 2.5, 2.0, 1.5, 1.0, 0.5, 0.0, 0.0], f2_values
# Top 10 have distinct positive values -> deterministic service ordering.
assert f2_services[:10] == [f"service-{i}" for i in range(10)], f2_services[:10]
# Tail 2 are the INFO-only services tied at 0.0 (order between them not guaranteed).
assert set(f2_services[10:]) == info_only_services, f2_services[10:]
# F1: desc + limit 3 -> must be exactly the first 3 rows of F2.
# Top 3 are not in the tie region, so prefix equality is safe.
f1_services, f1_values = extract_services_and_values("F1")
assert len(f1_services) == 3, f"F1: expected 3 rows after limit, got {len(f1_services)}"
assert f1_services == f2_services[:3], f"F1 services {f1_services} are not the prefix of F2 services {f2_services}"
assert f1_values == f2_values[:3], f"F1 values {f1_values} are not the prefix of F2 values {f2_values}"
# F4: asc, no limit -> 12 rows in ascending order by value.
f4_services, f4_values = extract_services_and_values("F4")
assert len(f4_services) == 12, f"F4: expected 12 rows with no limit, got {len(f4_services)}"
assert f4_values == sorted(f4_values), f"F4 not ascending: {f4_values}"
# First 2 are the INFO-only services tied at 0.0 (order between them not guaranteed).
assert set(f4_services[:2]) == info_only_services, f4_services[:2]
assert f4_values[:2] == [0.0, 0.0], f4_values[:2]
# Tail 10 are service-9 down to service-0 by value.
assert f4_services[2:] == [f"service-{i}" for i in reversed(range(10))], f4_services[2:]
assert f4_values[2:] == [(10 - i) / 2 for i in reversed(range(10))], f4_values[2:]
# F3: asc + limit 3 -> values must match F4[:3] exactly; service set must
# match too. Direct prefix equality on services would be flaky because the
# two tied INFO-only entries can swap order between formula evaluations.
f3_services, f3_values = extract_services_and_values("F3")
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"

View File

@@ -1,364 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
def test_logs_list_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that filtering logs by trace_id uses the trace_summary lookup to
narrow the query window before scanning the logs table:
1. Returns the matching logs (narrow window, single bucket), including a log
flushed shortly after the span ends — kept by the configured padding.
2. Does not return duplicate logs when the query window should span multiple
exponential buckets (>1 h). The window is clamped to the trace's recorded
range widened by the padding, so the post-span log survives the clamp.
3. Returns no results when the query window does not contain the trace.
4. Logs carrying a trace_id whose trace is NOT in trace_summary (e.g.
traces disabled) are still returned — the lookup miss must not
short-circuit logs queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-filter-service",
"cloud.provider": "integration",
}
# Populate signoz_traces.distributed_trace_summary by inserting spans for
# the target trace_id. trace_summary records min/max of span timestamps
# (it ignores span duration), so two spans are inserted to give the trace
# a non-trivial recorded window of [now-10s, now-5s].
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# Insert logs:
# - one with the target trace_id, at a timestamp within the trace's
# recorded window (now-10s..now-5s, padded ±1s).
# - one with the target trace_id flushed ~3s AFTER the span's recorded end
# (now-2s). This is outside the ±1s base pad but inside the multi-minute
# log_trace_id_window_padding, so it must still be returned.
# - one with an orphan trace_id whose trace was never ingested — used to
# verify the lookup miss does NOT short-circuit logs queries.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=7),
resources=common_resources,
attributes={"http.method": "GET"},
body="log inside the target trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={"http.method": "POST"},
body="log flushed after the span ends, within padding window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={"http.method": "PUT"},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _query(start_ms: int, end_ms: int, trace_id: str) -> tuple[list, list[str]]:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
{"key": {"name": "id"}, "direction": "desc"},
],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
rows = response.json()["data"]["data"]["results"][0]["rows"] or []
warning = (response.json().get("data") or {}).get("warning") or {}
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
return rows, messages
outside_range_msg = "lies outside the selected time range"
now_ms = int(now.timestamp() * 1000)
inside_window_body = "log inside the target trace window"
post_span_body = "log flushed after the span ends, within padding window"
# --- Test 1: narrow window (single bucket, <1 h) ---
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
narrow_rows, narrow_warnings = _query(narrow_start_ms, now_ms, target_trace_id)
assert len(narrow_rows) == 2, f"Expected 2 logs for trace_id filter (narrow window), got {len(narrow_rows)}"
assert {r["data"]["trace_id"] for r in narrow_rows} == {target_trace_id}
narrow_bodies = {r["data"]["body"] for r in narrow_rows}
assert inside_window_body in narrow_bodies
assert post_span_body in narrow_bodies, "post-span log should be returned within the padding window"
assert not any(outside_range_msg in m for m in narrow_warnings), f"Did not expect outside-range warning, got {narrow_warnings}"
# --- Test 2: wide window (>1 h, clamp to the padded timerange from trace_summary) ---
# Should return exactly the two target logs — no duplicates from multi-bucket
# scan, and the post-span log survives the clamp only because of the padding.
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_rows, wide_warnings = _query(wide_start_ms, now_ms, target_trace_id)
assert len(wide_rows) == 2, f"Expected 2 logs for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-log regression or padding not applied"
assert {r["data"]["trace_id"] for r in wide_rows} == {target_trace_id}
wide_bodies = {r["data"]["body"] for r in wide_rows}
assert inside_window_body in wide_bodies
assert post_span_body in wide_bodies, "post-span log should survive the clamp because of the padding"
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
# --- Test 3: window that does not contain the trace returns no results + warning ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_rows, past_warnings = _query(past_start_ms, past_end_ms, target_trace_id)
assert len(past_rows) == 0, f"Expected 0 logs for trace_id filter outside time window, got {len(past_rows)}"
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
# --- Test 4: trace_id not present in trace_summary still returns logs (no warning) ---
orphan_rows, orphan_warnings = _query(narrow_start_ms, now_ms, orphan_trace_id)
assert len(orphan_rows) == 1, f"Expected 1 log for orphan trace_id (no trace_summary entry), got {len(orphan_rows)} — logs query may have been incorrectly short-circuited"
assert orphan_rows[0]["data"]["trace_id"] == orphan_trace_id
assert not any(outside_range_msg in m for m in orphan_warnings), f"Did not expect outside-range warning for orphan trace_id, got {orphan_warnings}"
def test_logs_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) logs queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to an
empty result.
3. A trace_id with no row in trace_summary (e.g. traces disabled) still
returns the matching logs — the lookup miss must not short-circuit
logs aggregation queries.
"""
target_trace_id = TraceIdGenerator.trace_id()
orphan_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
orphan_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "logs-trace-agg-service",
"cloud.provider": "integration",
}
# trace_summary records min/max of span timestamps (it ignores duration),
# so insert two spans to give the trace a recorded window wide enough to
# comfortably contain the log timestamps below.
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
# Two logs for the target trace_id, both inside the recorded trace window.
# One additional log carries an orphan trace_id with no row in
# trace_summary — used to verify that the lookup miss does not
# short-circuit logs aggregations.
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=7),
resources=common_resources,
attributes={},
body="log A inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=6),
resources=common_resources,
attributes={},
body="log B inside trace window",
severity_text="INFO",
trace_id=target_trace_id,
span_id=target_root_span_id,
),
Logs(
timestamp=now - timedelta(seconds=2),
resources=common_resources,
attributes={},
body="log with a trace_id absent from trace_summary",
severity_text="INFO",
trace_id=orphan_trace_id,
span_id=orphan_span_id,
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> tuple[float, list[str]]:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
warning = (response.json().get("data") or {}).get("warning") or {}
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0, messages
series = aggregations[0].get("series") or []
if not series:
return 0, messages
return sum(v["value"] for v in series[0]["values"]), messages
outside_range_msg = "lies outside the selected time range"
now_ms = int(now.timestamp() * 1000)
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns 2 logs ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count, wide_warnings = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
# --- Test 2: window outside the trace short-circuits to empty + warning ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count, past_warnings = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
# --- Test 3: trace_id not present in trace_summary still returns logs (no warning) ---
orphan_count, orphan_warnings = _count(narrow_start_ms, now_ms, orphan_trace_id)
assert orphan_count == 1, f"Expected count=1 for orphan trace_id aggregation, got {orphan_count} — query may have been incorrectly short-circuited"
assert not any(outside_range_msg in m for m in orphan_warnings), f"Did not expect outside-range warning for orphan trace_id, got {orphan_warnings}"

View File

@@ -1,276 +0,0 @@
import json
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
# Body-JSON array/token functions on the logs body. has() success paths are already
# covered by 06_json_body.py::test_logs_json_body_array_membership; this file adds the
# sibling functions hasAny / hasAll / hasToken (success) and the function-operator error
# paths (has/hasToken on a non-body key, non-string token) which must be rejected.
@pytest.mark.xfail(
reason="hasAny/hasAll over a body-JSON array return 500 in legacy body mode (use_json_body off): ClickHouse 'Argument 0 for hasAny must be an array but has type String'. has() handles body arrays here; hasAny/hasAll do not (they work only in JSON-body mode).",
strict=False,
)
def test_logs_json_body_has_any(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""hasAny(body.tags, [...]) matches a log whose array shares ANY value."""
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=3),
resources={"service.name": "app-service"},
body=json.dumps({"tags": ["production", "api", "critical"]}),
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(seconds=2),
resources={"service.name": "app-service"},
body=json.dumps({"tags": ["staging", "api", "test"]}),
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(seconds=1),
resources={"service.name": "app-service"},
body=json.dumps({"tags": ["production", "web", "important"]}),
severity_text="INFO",
),
]
)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
# log1 has "critical", log2 has "test", log3 has neither -> 2 matches
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": int((now - timedelta(seconds=10)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"requestType": "raw",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": "hasAny(body.tags, ['critical', 'test'])"},
"order": [{"key": {"name": "timestamp"}, "direction": "desc"}],
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 2
tags_list = [json.loads(row["data"]["body"])["tags"] for row in rows]
assert all(("critical" in tags) or ("test" in tags) for tags in tags_list)
@pytest.mark.xfail(
reason="hasAny/hasAll over a body-JSON array return 500 in legacy body mode (use_json_body off): ClickHouse 'Argument 0 for hasAll must be an array but has type String'. has() handles body arrays here; hasAny/hasAll do not (they work only in JSON-body mode).",
strict=False,
)
def test_logs_json_body_has_all(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""hasAll(body.tags, [...]) matches only a log whose array has ALL values."""
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=3),
resources={"service.name": "app-service"},
body=json.dumps({"tags": ["production", "api", "critical"]}),
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(seconds=2),
resources={"service.name": "app-service"},
body=json.dumps({"tags": ["production", "web", "important"]}),
severity_text="INFO",
),
]
)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
# only the second log has both "production" AND "web"
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": int((now - timedelta(seconds=10)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"requestType": "raw",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": "hasAll(body.tags, ['production', 'web'])"},
"order": [{"key": {"name": "timestamp"}, "direction": "desc"}],
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 1
tags = json.loads(rows[0]["data"]["body"])["tags"]
assert "production" in tags and "web" in tags
def test_logs_json_body_has_token(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""hasToken(body, 'token') matches logs whose body text contains the token."""
now = datetime.now(tz=UTC)
insert_logs(
[
Logs(
timestamp=now - timedelta(seconds=3),
resources={"service.name": "app-service"},
body=json.dumps({"tags": ["production", "api", "critical"]}),
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(seconds=2),
resources={"service.name": "app-service"},
body=json.dumps({"tags": ["staging", "api", "test"]}),
severity_text="INFO",
),
Logs(
timestamp=now - timedelta(seconds=1),
resources={"service.name": "app-service"},
body=json.dumps({"tags": ["production", "web", "important"]}),
severity_text="INFO",
),
]
)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
# "production" appears in the first and third log bodies
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": int((now - timedelta(seconds=10)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"requestType": "raw",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": 'hasToken(body, "production")'},
"order": [{"key": {"name": "timestamp"}, "direction": "desc"}],
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 2
assert all("production" in row["data"]["body"] for row in rows)
@pytest.mark.parametrize(
"expression",
[
pytest.param('has(code.function, "main")', id="has_non_body_key"),
pytest.param('hasToken(code.function, "main")', id="hastoken_non_body_key"),
pytest.param("hasToken(body, 123)", id="hastoken_non_string_value"),
],
)
def test_logs_json_body_function_errors(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
expression: str,
) -> None:
"""has/hasToken support only the body JSON field; misuse is rejected (400)."""
now = datetime.now(tz=UTC)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": int((now - timedelta(seconds=10)).timestamp() * 1000),
"end": int(now.timestamp() * 1000),
"requestType": "raw",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "logs",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": expression},
"order": [{"key": {"name": "timestamp"}, "direction": "desc"}],
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.BAD_REQUEST

View File

@@ -1,175 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.metrics import Metrics
from fixtures.querier import (
build_builder_query,
get_all_warnings,
make_query_request,
)
HISTOGRAM_FILE = get_testdata_file_path("histogram_data_1h.jsonl")
def test_histogram_p90_returns_warning_outside_data_window(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metric_name = "test_p90_last_seen_bucket"
metrics = Metrics.load_from_file(
HISTOGRAM_FILE,
base_time=now - timedelta(minutes=90),
metric_name_override=metric_name,
)
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"doesnotreallymatter",
"p90",
)
end_ms = int(now.timestamp() * 1000)
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
response = make_query_request(signoz, token, start_2h, end_ms, [query])
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
start_15m = int((now - timedelta(minutes=15)).timestamp() * 1000)
response = make_query_request(signoz, token, start_15m, end_ms, [query])
assert response.status_code == HTTPStatus.OK
data = response.json()
warnings = get_all_warnings(data)
assert len(warnings) == 1
assert warnings[0]["message"].startswith(f"no data found for the metric {metric_name}")
def test_non_existent_metrics_returns_warning(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metric_name = "whatevergoennnsgoeshere"
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"doesnotreallymatter",
"sum",
)
end_ms = int(now.timestamp() * 1000)
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
response = make_query_request(signoz, token, start_2h, end_ms, [query])
assert response.status_code == HTTPStatus.OK
data = response.json()
warnings = get_all_warnings(data)
assert any("whatevergoennnsgoeshere" in w["message"] and "has never been received" in w["message"] for w in warnings), f"expected never-seen metric warning, got: {warnings}"
def test_non_existent_internal_metrics_returns_no_warning(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metric_name = "signoz_calls_total"
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"doesnotreallymatter",
"sum",
)
end_ms = int(now.timestamp() * 1000)
start_2h = int((now - timedelta(hours=2)).timestamp() * 1000)
response = make_query_request(signoz, token, start_2h, end_ms, [query])
assert response.status_code == HTTPStatus.OK
data = response.json()
assert get_all_warnings(data) == []
def test_variable_in_filter_returns_no_warning(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
"""
A dashboard variable used in a metric filter expression (e.g.
`my_tag = $tag`) sits in value position but is lexed as a key token. It
must not be mistaken for a missing attribute key and must not produce a
"key not found" warning.
Regression test for https://github.com/SigNoz/engineering-pod/issues/5481
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metric_name = "test_variable_filter_metric"
metrics: list[Metrics] = [
Metrics(
metric_name=metric_name,
labels={"my_tag": "service-a"},
timestamp=now - timedelta(minutes=3),
value=10.0,
temporality="Cumulative",
),
Metrics(
metric_name=metric_name,
labels={"my_tag": "service-a"},
timestamp=now - timedelta(minutes=2),
value=30.0,
temporality="Cumulative",
),
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = build_builder_query(
"A",
metric_name,
"increase",
"sum",
temporality="cumulative",
filter_expression="my_tag = $tag",
)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[query],
variables={"tag": {"type": "query", "value": "service-a"}},
)
assert response.status_code == HTTPStatus.OK
data = response.json()
assert data["status"] == "success"
# `my_tag` is a real label and `$tag` is a value-position variable, so
# neither should be flagged as a missing key on the metric.
assert get_all_warnings(data) == [], f"expected no warnings, got: {get_all_warnings(data)}"

View File

@@ -1,217 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
# Verify /api/v1/fields/values filters label values by metricNamespace prefix.
# Inserts metrics under ns.a and ns.b, then asserts a specific prefix returns
# only matching values while a common prefix returns both.
def test_metric_namespace_values_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metrics: list[Metrics] = [
Metrics(
metric_name="ns.a.requests_total",
labels={"service": "svc-a"},
timestamp=now - timedelta(minutes=2),
value=10.0,
),
Metrics(
metric_name="ns.b.requests_total",
labels={"service": "svc-b"},
timestamp=now - timedelta(minutes=2),
value=20.0,
),
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=ns.a should return only svc-a
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "service",
"searchText": "",
"metricNamespace": "ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "svc-a" in values
assert "svc-b" not in values
# Common prefix: metricNamespace=ns should return both
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "service",
"searchText": "",
"metricNamespace": "ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "svc-a" in values
assert "svc-b" in values
# Verify /api/v1/fields/values with name=metric_name filters metric names by
# metricNamespace prefix. A specific prefix returns only its metric names;
# a common prefix returns metric names from all matching namespaces.
def test_metric_namespace_metric_name_values_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metrics: list[Metrics] = [
Metrics(
metric_name="ns.a.cpu.utilization",
labels={"host": "host-a"},
timestamp=now - timedelta(minutes=2),
value=50.0,
),
Metrics(
metric_name="ns.b.cpu.utilization",
labels={"host": "host-b"},
timestamp=now - timedelta(minutes=2),
value=60.0,
),
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=ns.a should return only ns.a.* metric names
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "metric_name",
"searchText": "",
"metricNamespace": "ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "ns.a.cpu.utilization" in values
assert "ns.b.cpu.utilization" not in values
# Common prefix: metricNamespace=ns should return both
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"name": "metric_name",
"searchText": "",
"metricNamespace": "ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert "ns.a.cpu.utilization" in values
assert "ns.b.cpu.utilization" in values
# Verify /api/v1/fields/keys filters attribute keys by metricNamespace prefix.
# Metrics under ns.a and ns.b carry distinct labels; a specific prefix returns
# only its keys while a common prefix returns keys from both namespaces.
def test_metric_namespace_keys_filtering(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
metrics: list[Metrics] = [
Metrics(
metric_name="ns.a.cpu.utilization",
labels={"a_only_label": "val-a"},
timestamp=now - timedelta(minutes=2),
value=10.0,
),
Metrics(
metric_name="ns.b.cpu.utilization",
labels={"b_only_label": "val-b"},
timestamp=now - timedelta(minutes=2),
value=20.0,
),
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Specific prefix: metricNamespace=ns.a should return only a_only_label
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"searchText": "label",
"metricNamespace": "ns.a",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
keys = response.json()["data"]["keys"]
assert "a_only_label" in keys
assert "b_only_label" not in keys
# Common prefix: metricNamespace=ns should return both keys
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
params={
"signal": "metrics",
"searchText": "label",
"metricNamespace": "ns",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
keys = response.json()["data"]["keys"]
assert "a_only_label" in keys
assert "b_only_label" in keys

View File

@@ -1,341 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import querier, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import make_scalar_query_request
log_or_trace_service_counts = {
"service-a": 5,
"service-b": 3,
"service-c": 7,
"service-d": 1,
}
def generate_logs_with_counts(
now: datetime,
service_counts: dict[str, int],
) -> list[Logs]:
logs = []
for service, count in service_counts.items():
for i in range(count):
logs.append(
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": service},
body=f"{service} log {i}",
)
)
return logs
def build_logs_query(
name: str = "A",
aggregations: list[str] | None = None,
group_by: list[str] | None = None,
order_by: list[tuple[str, str]] | None = None,
limit: int | None = None,
) -> dict:
if aggregations is None:
aggregations = ["count()"]
aggs = [querier.build_logs_aggregation(expr) for expr in aggregations]
gb = [querier.build_group_by_field(f, "string", "resource") for f in group_by] if group_by else None
order = [querier.build_order_by(name, direction) for name, direction in order_by] if order_by else None
return querier.build_scalar_query(
name=name,
signal="logs",
aggregations=aggs,
group_by=gb,
order=order,
limit=limit,
)
def test_logs_scalar_group_by_single_agg_no_order(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3), ("service-d", 1)],
"Logs no order - default desc",
)
def test_logs_scalar_group_by_single_agg_order_by_agg_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("count()", "asc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-b", 3), ("service-a", 5), ("service-c", 7)],
"Logs order by agg asc",
)
def test_logs_scalar_group_by_single_agg_order_by_agg_desc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("count()", "desc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3), ("service-d", 1)],
"Logs order by agg desc",
)
def test_logs_scalar_group_by_single_agg_order_by_grouping_key_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("service.name", "asc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-a", 5), ("service-b", 3), ("service-c", 7), ("service-d", 1)],
"Logs order by grouping key asc",
)
def test_logs_scalar_group_by_single_agg_order_by_grouping_key_desc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("service.name", "desc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-c", 7), ("service-b", 3), ("service-a", 5)],
"Logs order by grouping key desc",
)
def test_logs_scalar_group_by_multiple_aggs_order_by_first_agg_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_logs_query(
group_by=["service.name"],
aggregations=["count()", "count_distinct(body)"],
order_by=[("count()", "asc")],
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_column_order(data, 0, ["service-d", "service-b", "service-a", "service-c"], "First column")
def test_logs_scalar_group_by_multiple_aggs_order_by_second_agg_desc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_logs_query(
group_by=["service.name"],
aggregations=["count()", "count_distinct(body)"],
order_by=[("count_distinct(body)", "desc")],
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
# count_distinct(body) should equal count() since each log has unique body
querier.assert_scalar_column_order(data, 0, ["service-c", "service-a", "service-b", "service-d"], "First column")
def test_logs_scalar_group_by_single_agg_order_by_agg_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("count()", "asc")], limit=2)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-b", 3)],
"Logs order by agg asc with limit 2",
)
def test_logs_scalar_group_by_single_agg_order_by_agg_desc_limit_3(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("count()", "desc")], limit=3)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3)],
"Logs order by agg desc with limit 3",
)
def test_logs_scalar_group_by_order_by_grouping_key_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_logs(generate_logs_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_logs_query(group_by=["service.name"], order_by=[("service.name", "asc")], limit=2)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-a", 5), ("service-b", 3)],
"Logs order by grouping key asc with limit 2",
)

View File

@@ -1,316 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import querier, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import make_scalar_query_request
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
log_or_trace_service_counts = {
"service-a": 5,
"service-b": 3,
"service-c": 7,
"service-d": 1,
}
def generate_traces_with_counts(
now: datetime,
service_counts: dict[str, int],
) -> list[Traces]:
traces = []
for service, count in service_counts.items():
for i in range(count):
trace_id = TraceIdGenerator.trace_id()
span_id = TraceIdGenerator.span_id()
traces.append(
Traces(
timestamp=now - timedelta(seconds=i + 1),
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
trace_id=trace_id,
span_id=span_id,
resources={"service.name": service},
name=f"{service} span {i}",
)
)
return traces
def build_traces_query(
name: str = "A",
aggregations: list[str] | None = None,
group_by: list[str] | None = None,
order_by: list[tuple[str, str]] | None = None,
limit: int | None = None,
) -> dict:
if aggregations is None:
aggregations = ["count()"]
aggs = [querier.build_logs_aggregation(expr) for expr in aggregations]
gb = [querier.build_group_by_field(f, "string", "resource") for f in group_by] if group_by else None
order = [querier.build_order_by(name, direction) for name, direction in order_by] if order_by else None
return querier.build_scalar_query(
name=name,
signal="traces",
aggregations=aggs,
group_by=gb,
order=order,
limit=limit,
)
def test_traces_scalar_group_by_single_agg_no_order(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3), ("service-d", 1)],
"Traces no order - default desc",
)
def test_traces_scalar_group_by_single_agg_order_by_agg_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("count()", "asc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-b", 3), ("service-a", 5), ("service-c", 7)],
"Traces order by agg asc",
)
def test_traces_scalar_group_by_single_agg_order_by_agg_desc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("count()", "desc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3), ("service-d", 1)],
"Traces order by agg desc",
)
def test_traces_scalar_group_by_single_agg_order_by_grouping_key_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("service.name", "asc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-a", 5), ("service-b", 3), ("service-c", 7), ("service-d", 1)],
"Traces order by grouping key asc",
)
def test_traces_scalar_group_by_single_agg_order_by_grouping_key_desc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("service.name", "desc")])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-c", 7), ("service-b", 3), ("service-a", 5)],
"Traces order by grouping key desc",
)
def test_traces_scalar_group_by_multiple_aggs_order_by_first_agg_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_traces_query(
group_by=["service.name"],
aggregations=["count()", "count_distinct(trace_id)"],
order_by=[("count()", "asc")],
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_column_order(data, 0, ["service-d", "service-b", "service-a", "service-c"], "First column")
def test_traces_scalar_group_by_single_agg_order_by_agg_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("count()", "asc")], limit=2)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 1), ("service-b", 3)],
"Traces order by agg asc with limit 2",
)
def test_traces_scalar_group_by_single_agg_order_by_agg_desc_limit_3(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("count()", "desc")], limit=3)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 7), ("service-a", 5), ("service-b", 3)],
"Traces order by agg desc with limit 3",
)
def test_traces_scalar_group_by_order_by_grouping_key_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(generate_traces_with_counts(now, log_or_trace_service_counts))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_traces_query(group_by=["service.name"], order_by=[("service.name", "asc")], limit=2)],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-a", 5), ("service-b", 3)],
"Traces order by grouping key asc with limit 2",
)

View File

@@ -1,269 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import querier, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import make_scalar_query_request
metric_values_for_test = {
"service-a": 50.0,
"service-b": 30.0,
"service-c": 70.0,
"service-d": 10.0,
}
def generate_metrics_with_values(
now: datetime,
service_values: dict[str, float],
) -> list[Metrics]:
metrics = []
for service, value in service_values.items():
metrics.append(
Metrics(
metric_name="test.metric",
labels={"service.name": service},
timestamp=now - timedelta(seconds=1),
temporality="Unspecified",
type_="Gauge",
is_monotonic=False,
value=value,
)
)
return metrics
def build_metrics_query(
name: str = "A",
metric_name: str = "test.metric",
time_aggregation: str = "latest",
space_aggregation: str = "sum",
group_by: list[str] | None = None,
order_by: list[tuple[str, str]] | None = None,
limit: int | None = None,
) -> dict:
aggs = [querier.build_metrics_aggregation(metric_name, time_aggregation, space_aggregation, "unspecified")]
gb = [querier.build_group_by_field(f, "string", "attribute") for f in group_by] if group_by else None
order = [querier.build_order_by(name, direction) for name, direction in order_by] if order_by else None
return querier.build_scalar_query(
name=name,
signal="metrics",
aggregations=aggs,
group_by=gb,
order=order,
limit=limit,
)
def test_metrics_scalar_group_by_single_agg_no_order(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[build_metrics_query(group_by=["service.name"])],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[
("service-c", 70.0),
("service-a", 50.0),
("service-b", 30.0),
("service-d", 10.0),
],
"Metrics no order - default desc",
)
def test_metrics_scalar_group_by_single_agg_order_by_agg_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_metrics_query(
group_by=["service.name"],
order_by=[("sum(test.metric)", "asc")],
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[
("service-d", 10.0),
("service-b", 30.0),
("service-a", 50.0),
("service-c", 70.0),
],
"Metrics order by agg asc",
)
def test_metrics_scalar_group_by_single_agg_order_by_grouping_key_asc(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_metrics_query(
group_by=["service.name"],
order_by=[("service.name", "asc")],
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[
("service-a", 50.0),
("service-b", 30.0),
("service-c", 70.0),
("service-d", 10.0),
],
"Metrics order by grouping key asc",
)
def test_metrics_scalar_group_by_single_agg_order_by_agg_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_metrics_query(
group_by=["service.name"],
order_by=[("sum(test.metric)", "asc")],
limit=2,
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-d", 10.0), ("service-b", 30.0)],
"Metrics order by agg asc with limit 2",
)
def test_metrics_scalar_group_by_single_agg_order_by_agg_desc_limit_3(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_metrics_query(
group_by=["service.name"],
order_by=[("sum(test.metric)", "desc")],
limit=3,
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-c", 70.0), ("service-a", 50.0), ("service-b", 30.0)],
"Metrics order by agg desc with limit 3",
)
def test_metrics_scalar_group_by_order_by_grouping_key_asc_limit_2(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_metrics(generate_metrics_with_values(now, metric_values_for_test))
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_scalar_query_request(
signoz,
token,
now,
[
build_metrics_query(
group_by=["service.name"],
order_by=[("service.name", "asc")],
limit=2,
)
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = querier.get_scalar_table_data(response.json())
querier.assert_scalar_result_order(
data,
[("service-a", 50.0), ("service-b", 30.0)],
"Metrics order by grouping key asc with limit 2",
)

View File

@@ -1,58 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
assert_scalar_result_order,
build_group_by_field,
build_logs_aggregation,
build_order_by,
build_scalar_query,
get_scalar_table_data,
make_scalar_query_request,
)
# Non-empty HAVING (a post-aggregation filter) — every existing querier test uses
# only the empty `{"expression": ""}` HAVING boilerplate.
def test_logs_scalar_group_by_having(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""A scalar group-by with `having count() > 3` returns only the qualifying groups."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
service_counts = {"service-a": 5, "service-b": 3, "service-c": 7, "service-d": 1}
logs = []
for service, count in service_counts.items():
for i in range(count):
logs.append(
Logs(
timestamp=now - timedelta(seconds=i + 1),
resources={"service.name": service},
body=f"{service} log {i}",
)
)
insert_logs(logs)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
query = build_scalar_query(
name="A",
signal="logs",
aggregations=[build_logs_aggregation("count()")],
group_by=[build_group_by_field("service.name", "string", "resource")],
order=[build_order_by("count()", "desc")],
having_expression="count() > 3",
)
response = make_scalar_query_request(signoz, token, now, [query])
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = get_scalar_table_data(response.json())
# only service-c (7) and service-a (5) have count() > 3; service-b (3) and service-d (1) dropped
assert_scalar_result_order(data, [("service-c", 7), ("service-a", 5)], "having count() > 3")

View File

@@ -1,54 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import (
assert_scalar_result_order,
build_group_by_field,
build_metrics_aggregation,
build_scalar_query,
get_scalar_table_data,
make_scalar_query_request,
)
# Metric scalar `reduceTo`: collapse a time series to a single value for a value/table
# panel. Metrics scalar group-by is covered by 03_metrics.py, but reduceTo (last/sum/
# avg/min/max) is not exercised anywhere.
def test_metrics_scalar_reduce_to_sum(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
"""reduceTo=sum collapses a series' per-step points (10, 20, 30) to 60."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
# same metric_name + labels => one fingerprint/series; three samples in three
# distinct 60s step buckets so latest-per-step yields 10, 20, 30.
insert_metrics(
[
Metrics(metric_name="test.reduce.metric", labels={"service.name": "service-a"}, timestamp=now - timedelta(seconds=121), value=10.0, temporality="Unspecified", type_="Gauge", is_monotonic=False),
Metrics(metric_name="test.reduce.metric", labels={"service.name": "service-a"}, timestamp=now - timedelta(seconds=61), value=20.0, temporality="Unspecified", type_="Gauge", is_monotonic=False),
Metrics(metric_name="test.reduce.metric", labels={"service.name": "service-a"}, timestamp=now - timedelta(seconds=1), value=30.0, temporality="Unspecified", type_="Gauge", is_monotonic=False),
]
)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
aggregation = build_metrics_aggregation("test.reduce.metric", "latest", "sum", "unspecified")
aggregation["reduceTo"] = "sum"
query = build_scalar_query(
name="A",
signal="metrics",
aggregations=[aggregation],
group_by=[build_group_by_field("service.name", "string", "attribute")],
)
response = make_scalar_query_request(signoz, token, now, [query])
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
data = get_scalar_table_data(response.json())
assert_scalar_result_order(data, [("service-a", 60.0)], "metrics reduceTo=sum")

View File

@@ -1,905 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from typing import Any
import pytest
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
assert_identical_query_response,
format_timestamp,
generate_traces_with_corrupt_metadata,
make_query_request,
)
from fixtures.traces import (
ALL_SELECT_FIELDS,
TraceIdGenerator,
Traces,
TracesEvent,
TracesKind,
TracesLink,
TracesRefType,
TracesStatusCode,
)
def test_traces_list(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Setup:
Insert 4 traces with different attributes.
http-service: POST /integration -> SELECT, HTTP PATCH
topic-service: topic publish
Tests:
1. Query traces for the last 5 minutes and check if the spans are returned in the correct order
2. Query root spans for the last 5 minutes and check if the spans are returned in the correct order
3. Query values of http.request.method attribute from the autocomplete API
4. Query values of http.request.method attribute from the fields API
"""
http_service_trace_id = TraceIdGenerator.trace_id()
http_service_span_id = TraceIdGenerator.span_id()
http_service_db_span_id = TraceIdGenerator.span_id()
http_service_patch_span_id = TraceIdGenerator.span_id()
topic_service_trace_id = TraceIdGenerator.trace_id()
topic_service_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=3),
trace_id=http_service_trace_id,
span_id=http_service_span_id,
parent_span_id="",
name="POST /integration",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
},
attributes={
"net.transport": "IP.TCP",
"http.scheme": "http",
"http.user_agent": "Integration Test",
"http.request.method": "POST",
"http.response.status_code": "200",
},
),
Traces(
timestamp=now - timedelta(seconds=3.5),
duration=timedelta(seconds=0.5),
trace_id=http_service_trace_id,
span_id=http_service_db_span_id,
parent_span_id=http_service_span_id,
name="SELECT",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
},
attributes={
"db.name": "integration",
"db.operation": "SELECT",
"db.statement": "SELECT * FROM integration",
},
),
Traces(
timestamp=now - timedelta(seconds=3),
duration=timedelta(seconds=1),
trace_id=http_service_trace_id,
span_id=http_service_patch_span_id,
parent_span_id=http_service_span_id,
name="HTTP PATCH",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
},
attributes={
"http.request.method": "PATCH",
"http.status_code": "404",
},
),
Traces(
timestamp=now - timedelta(seconds=1),
duration=timedelta(seconds=4),
trace_id=topic_service_trace_id,
span_id=topic_service_span_id,
parent_span_id="",
name="topic publish",
kind=TracesKind.SPAN_KIND_PRODUCER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "topic-service",
"os.type": "linux",
"host.name": "linux-001",
"cloud.provider": "integration",
"cloud.account.id": "001",
},
attributes={
"message.type": "SENT",
"messaging.operation": "publish",
"messaging.message.id": "001",
},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Query all traces for the past 5 minutes
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).timestamp() * 1000),
"requestType": "raw",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"limit": 10,
"offset": 0,
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
],
"selectFields": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
"signal": "traces",
},
{
"name": "name",
"fieldDataType": "string",
"fieldContext": "span",
"signal": "traces",
},
{
"name": "duration_nano",
"fieldDataType": "",
"fieldContext": "span",
"signal": "traces",
},
{
"name": "http_method",
"fieldDataType": "",
"fieldContext": "span",
"signal": "traces",
},
{
"name": "response_status_code",
"fieldDataType": "",
"fieldContext": "span",
"signal": "traces",
},
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
# Query results with context appended to key names
response_with_inline_context = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"limit": 10,
"offset": 0,
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
],
"selectFields": [
{
"name": "resource.service.name",
"fieldDataType": "string",
"signal": "traces",
},
{
"name": "span.name:string",
"signal": "traces",
},
{
"name": "span.duration_nano",
"signal": "traces",
},
{
"name": "span.http_method",
"signal": "traces",
},
{
"name": "span.response_status_code",
"signal": "traces",
},
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert_identical_query_response(response, response_with_inline_context)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0]["rows"]
assert len(rows) == 4
# Care about the order of the rows
row_0 = dict(rows[0]["data"])
assert row_0.pop("timestamp") is not None
assert row_0 == {
"duration_nano": 4 * 1e9,
"http_method": "",
"name": "topic publish",
"response_status_code": "",
"service.name": "topic-service",
"span_id": topic_service_span_id,
"trace_id": topic_service_trace_id,
}
row_2 = dict(rows[1]["data"])
assert row_2.pop("timestamp") is not None
assert row_2 == {
"duration_nano": 1 * 1e9,
"http_method": "PATCH",
"name": "HTTP PATCH",
"response_status_code": "404",
"service.name": "http-service",
"span_id": http_service_patch_span_id,
"trace_id": http_service_trace_id,
}
row_3 = dict(rows[2]["data"])
assert row_3.pop("timestamp") is not None
assert row_3 == {
"duration_nano": 0.5 * 1e9,
"http_method": "",
"name": "SELECT",
"response_status_code": "",
"service.name": "http-service",
"span_id": http_service_db_span_id,
"trace_id": http_service_trace_id,
}
row_1 = dict(rows[3]["data"])
assert row_1.pop("timestamp") is not None
assert row_1 == {
"duration_nano": 3 * 1e9,
"http_method": "POST",
"name": "POST /integration",
"response_status_code": "200",
"service.name": "http-service",
"span_id": http_service_span_id,
"trace_id": http_service_trace_id,
}
# Query root spans for the last 5 minutes and check if the spans are returned in the correct order
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
json={
"schemaVersion": "v1",
"start": int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
"end": int(datetime.now(tz=UTC).timestamp() * 1000),
"requestType": "raw",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"limit": 10,
"offset": 0,
"filter": {"expression": "isRoot = 'true'"},
"order": [
{"key": {"name": "timestamp"}, "direction": "desc"},
],
"selectFields": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
rows = results[0]["rows"]
assert len(rows) == 2
assert rows[0]["data"]["service.name"] == "topic-service"
assert rows[1]["data"]["service.name"] == "http-service"
# Query values of http.request.method attribute from the autocomplete API
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v3/autocomplete/attribute_values"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"aggregateOperator": "noop",
"dataSource": "traces",
"aggregateAttribute": "",
"attributeKey": "http.request.method",
"searchText": "",
"filterAttributeKeyDataType": "string",
"tagType": "tag",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["stringAttributeValues"]
assert len(values) == 2
assert set(values) == set(["POST", "PATCH"])
# Query values of http.request.method attribute from the fields API
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"signal": "traces",
"name": "http.request.method",
"searchText": "",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert len(values) == 2
assert set(values) == set(["POST", "PATCH"])
# Query keys from the fields API with context specified in the key
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/keys"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"signal": "traces",
"searchText": "resource.servic",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
keys = response.json()["data"]["keys"]
assert "service.name" in keys
assert any(k["fieldContext"] == "resource" for k in keys["service.name"])
# Query values of service.name resource attribute using context-prefixed key
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/fields/values"),
timeout=2,
headers={
"authorization": f"Bearer {token}",
},
params={
"signal": "traces",
"name": "resource.service.name",
"searchText": "",
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
values = response.json()["data"]["values"]["stringValues"]
assert set(values) == set(["topic-service", "http-service"])
@pytest.mark.parametrize(
"payload,status_code,results",
[
# Case 1: order by timestamp; empty selectFields returns the full
# response shape (all intrinsic + calculated columns plus the merged
# `attributes` and `resource` maps). x[3] (topic-service) is latest.
pytest.param(
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"order": [{"key": {"name": "timestamp"}, "direction": "desc"}],
"limit": 1,
},
},
HTTPStatus.OK,
lambda x: [
{
**x[3].attribute_string,
**x[3].attributes_number,
**x[3].attributes_bool,
}, # attributes
x[3].db_name,
x[3].db_operation,
int(x[3].duration_nano),
x[3].events,
x[3].external_http_method,
x[3].external_http_url,
int(x[3].flags),
x[3].has_error,
x[3].http_host,
x[3].http_method,
x[3].http_url,
x[3].is_remote,
int(x[3].kind),
x[3].kind_string,
x[3].links,
x[3].name,
x[3].parent_span_id,
x[3].resources_string,
x[3].response_status_code,
x[3].span_id,
int(x[3].status_code),
x[3].status_code_string,
x[3].status_message,
format_timestamp(x[3].timestamp),
x[3].trace_id,
x[3].trace_state,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 2: order by attribute.timestamp. The key resolves to the
# intrinsic span.timestamp column, so the latest span (x[3]) is
# returned with the same full response shape as Case 1.
pytest.param(
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"order": [{"key": {"name": "attribute.timestamp"}, "direction": "desc"}],
"limit": 1,
},
},
HTTPStatus.OK,
lambda x: [
{
**x[3].attribute_string,
**x[3].attributes_number,
**x[3].attributes_bool,
}, # attributes
x[3].db_name,
x[3].db_operation,
int(x[3].duration_nano),
x[3].events,
x[3].external_http_method,
x[3].external_http_url,
int(x[3].flags),
x[3].has_error,
x[3].http_host,
x[3].http_method,
x[3].http_url,
x[3].is_remote,
int(x[3].kind),
x[3].kind_string,
x[3].links,
x[3].name,
x[3].parent_span_id,
x[3].resources_string,
x[3].response_status_code,
x[3].span_id,
int(x[3].status_code),
x[3].status_code_string,
x[3].status_message,
format_timestamp(x[3].timestamp),
x[3].trace_id,
x[3].trace_state,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 3: select timestamp with empty order by
pytest.param(
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"selectFields": [{"name": "timestamp"}],
"limit": 1,
},
},
HTTPStatus.OK,
lambda x: [
x[2].span_id,
format_timestamp(x[2].timestamp),
x[2].trace_id,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 4: select attribute.timestamp with empty order by
# This returns the one span which has attribute.timestamp
pytest.param(
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {"expression": "attribute.timestamp exists"},
"disabled": False,
"selectFields": [{"name": "attribute.timestamp"}],
"limit": 1,
},
},
HTTPStatus.OK,
lambda x: [
x[0].span_id,
format_timestamp(x[0].timestamp),
x[0].trace_id,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 5: select timestamp with timestamp order by
pytest.param(
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"selectFields": [{"name": "timestamp"}],
"limit": 1,
"order": [{"key": {"name": "timestamp"}, "direction": "asc"}],
},
},
HTTPStatus.OK,
lambda x: [
x[0].span_id,
format_timestamp(x[0].timestamp),
x[0].trace_id,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 6: select duration_nano with duration order by
pytest.param(
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"selectFields": [{"name": "duration_nano"}],
"limit": 1,
"order": [{"key": {"name": "duration_nano"}, "direction": "desc"}],
},
},
HTTPStatus.OK,
lambda x: [
x[1].duration_nano,
x[1].span_id,
format_timestamp(x[1].timestamp),
x[1].trace_id,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 7: select attribute.duration_nano with attribute.duration_nano order by
pytest.param(
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"selectFields": [{"name": "attribute.duration_nano"}],
"filter": {"expression": "attribute.duration_nano exists"},
"limit": 1,
"order": [
{
"key": {"name": "attribute.duration_nano"},
"direction": "desc",
}
],
},
},
HTTPStatus.OK,
lambda x: [
"corrupt_data",
x[3].span_id,
format_timestamp(x[3].timestamp),
x[3].trace_id,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 8: select attribute.duration_nano with duration order by
pytest.param(
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"selectFields": [{"name": "attribute.duration_nano"}],
"limit": 1,
"order": [{"key": {"name": "duration_nano"}, "direction": "desc"}],
},
},
HTTPStatus.OK,
lambda x: [
x[1].duration_nano,
x[1].span_id,
format_timestamp(x[1].timestamp),
x[1].trace_id,
], # type: Callable[[List[Traces]], List[Any]]
),
],
)
def test_traces_list_with_corrupt_data(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
payload: dict[str, Any],
status_code: HTTPStatus,
results: Callable[[list[Traces]], list[Any]],
) -> None:
"""
Setup:
Insert 4 traces with corrupt attributes.
Tests:
"""
traces = generate_traces_with_corrupt_metadata()
insert_traces(traces)
# 4 Traces with corrupt metadata inserted
# traces[i] occured before traces[j] where i < j
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type="raw",
queries=[payload],
)
assert response.status_code == status_code
if response.status_code == HTTPStatus.OK:
if not results(traces):
# No results expected
assert response.json()["data"]["data"]["results"][0]["rows"] is None
else:
data = response.json()["data"]["data"]["results"][0]["rows"][0]["data"]
# Cannot compare values as they are randomly generated
for key, value in zip(list(data.keys()), results(traces)):
assert data[key] == value
def _verify_events_links_full(rows: list[dict], traces: list[Traces]) -> None:
"""Empty-selectFields case: events/links arrive parsed into structured objects.
Every row's events/links should match the fixture's stored parsed shape
(the fixture's `.events`/`.links` mirror the API response shape directly).
"""
for row, trace in zip(rows, traces, strict=True):
assert row["data"]["events"] == trace.events
assert row["data"]["links"] == trace.links
# Jaeger-era `refType` is dropped at the consume layer.
for link in row["data"]["links"]:
assert "refType" not in link
def _verify_events_links_skip(rows: list[dict], traces: list[Traces]) -> None:
"""Projected-selectFields case: nothing to verify beyond the key set."""
@pytest.mark.parametrize(
"select_fields,status_code,expected_keys,verify_values",
[
pytest.param(
[],
HTTPStatus.OK,
ALL_SELECT_FIELDS,
_verify_events_links_full,
),
pytest.param(
[
{"name": "service.name"},
],
HTTPStatus.OK,
["timestamp", "trace_id", "span_id", "service.name"],
_verify_events_links_skip,
),
],
)
def test_traces_list_with_select_fields(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
select_fields: list[dict],
status_code: HTTPStatus,
expected_keys: list[str],
verify_values: Callable[[list[dict], list[Traces]], None],
) -> None:
"""
Setup:
Insert a root span with no events/links and a child span carrying two
events and one user-supplied link.
Tests:
1. Empty select fields should return all the fields, and the `events` /
`links` columns should arrive parsed into structured objects (events
carry `attributes`, links carry only `traceId`/`spanId` — refType is
dropped at the consume layer).
2. Non-empty select field should return the select field along with
timestamp, trace_id and span_id.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
parent_trace_id = TraceIdGenerator.trace_id()
parent_span_id = TraceIdGenerator.span_id()
child_span_id = TraceIdGenerator.span_id()
linked_trace_id = TraceIdGenerator.trace_id()
linked_span_id = TraceIdGenerator.span_id()
event_one = TracesEvent(
name="request_received",
timestamp=now - timedelta(seconds=3, microseconds=500_000),
attribute_map={"http.method": "GET", "http.route": "/api/chat"},
)
event_two = TracesEvent(
name="cache_lookup",
timestamp=now - timedelta(seconds=3, microseconds=400_000),
attribute_map={"cache.hit": "true", "cache.key": "user:123:prompt"},
)
user_link = TracesLink(
trace_id=linked_trace_id,
span_id=linked_span_id,
ref_type=TracesRefType.REF_TYPE_FOLLOWS_FROM,
)
traces = [
# Root span: no events, no links. Verifies the empty-case parsed shape.
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=3),
trace_id=parent_trace_id,
span_id=parent_span_id,
parent_span_id="",
name="root span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "events-links-service"},
attributes={"http.request.method": "GET"},
),
# Child span: two events + one user-supplied link. The fixture
# auto-inserts a CHILD_OF link for the parent, so the parsed response
# contains two links total — the auto-inserted one first.
Traces(
timestamp=now - timedelta(seconds=3),
duration=timedelta(seconds=1),
trace_id=parent_trace_id,
span_id=child_span_id,
parent_span_id=parent_span_id,
name="child span",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "events-links-service"},
attributes={"http.request.method": "GET"},
events=[event_one, event_two],
links=[user_link],
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
payload = {
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {"expression": "resource.service.name = 'events-links-service'"},
"selectFields": select_fields,
"order": [{"key": {"name": "timestamp"}, "direction": "asc"}],
"limit": 10,
},
}
response = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type="raw",
queries=[payload],
)
assert response.status_code == status_code
if response.status_code != HTTPStatus.OK:
return
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 2
for row in rows:
assert set(row["data"].keys()) == set(expected_keys)
verify_values(rows, traces)

View File

@@ -1,408 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
make_query_request,
)
from fixtures.traces import (
TraceIdGenerator,
Traces,
TracesKind,
TracesStatusCode,
)
@pytest.mark.parametrize(
"order_by,aggregation_alias,expected_status",
[
# Case 1a: count by count()
pytest.param({"name": "count()"}, "count_", HTTPStatus.OK),
# Case 1b: count by count() with alias span.count_
pytest.param({"name": "count()"}, "span.count_", HTTPStatus.OK),
# Case 2a: count by count() with context specified in the key
pytest.param({"name": "count()", "fieldContext": "span"}, "count_", HTTPStatus.OK),
# Case 2b: count by count() with context specified in the key with alias span.count_
pytest.param({"name": "count()", "fieldContext": "span"}, "span.count_", HTTPStatus.OK),
# Case 3a: count by span.count() and context specified in the key [BAD REQUEST]
pytest.param(
{"name": "span.count()", "fieldContext": "span"},
"count_",
HTTPStatus.BAD_REQUEST,
),
# Case 3b: count by span.count() and context specified in the key with alias span.count_ [BAD REQUEST]
pytest.param(
{"name": "span.count()", "fieldContext": "span"},
"span.count_",
HTTPStatus.BAD_REQUEST,
),
# Case 4a: count by span.count() and context specified in the key
pytest.param({"name": "span.count()", "fieldContext": ""}, "count_", HTTPStatus.OK),
# Case 4b: count by span.count() and context specified in the key with alias span.count_
pytest.param({"name": "span.count()", "fieldContext": ""}, "span.count_", HTTPStatus.OK),
# Case 5a: count by count_
pytest.param({"name": "count_"}, "count_", HTTPStatus.OK),
# Case 5b: count by count_ with alias span.count_
pytest.param({"name": "count_"}, "count_", HTTPStatus.OK),
# Case 6a: count by span.count_
pytest.param({"name": "span.count_"}, "count_", HTTPStatus.OK),
# Case 6b: count by span.count_ with alias span.count_
pytest.param({"name": "span.count_"}, "span.count_", HTTPStatus.OK),
# Case 7a: count by span.count_ and context specified in the key [BAD REQUEST]
pytest.param(
{"name": "span.count_", "fieldContext": "span"},
"count_",
HTTPStatus.BAD_REQUEST,
),
# Case 7b: count by span.count_ and context specified in the key with alias span.count_
pytest.param(
{"name": "span.count_", "fieldContext": "span"},
"span.count_",
HTTPStatus.OK,
),
],
)
def test_traces_aggergate_order_by_count(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
order_by: dict[str, str],
aggregation_alias: str,
expected_status: HTTPStatus,
) -> None:
"""
Setup:
Insert 4 traces with different attributes.
http-service: POST /integration -> SELECT, HTTP PATCH
topic-service: topic publish
Tests:
1. Query traces count for spans grouped by service.name and host.name
"""
http_service_trace_id = TraceIdGenerator.trace_id()
http_service_span_id = TraceIdGenerator.span_id()
http_service_db_span_id = TraceIdGenerator.span_id()
http_service_patch_span_id = TraceIdGenerator.span_id()
topic_service_trace_id = TraceIdGenerator.trace_id()
topic_service_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=3),
trace_id=http_service_trace_id,
span_id=http_service_span_id,
parent_span_id="",
name="POST /integration",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
},
attributes={
"net.transport": "IP.TCP",
"http.scheme": "http",
"http.user_agent": "Integration Test",
"http.request.method": "POST",
"http.response.status_code": "200",
},
),
Traces(
timestamp=now - timedelta(seconds=3.5),
duration=timedelta(seconds=0.5),
trace_id=http_service_trace_id,
span_id=http_service_db_span_id,
parent_span_id=http_service_span_id,
name="SELECT",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
},
attributes={
"db.name": "integration",
"db.operation": "SELECT",
"db.statement": "SELECT * FROM integration",
},
),
Traces(
timestamp=now - timedelta(seconds=3),
duration=timedelta(seconds=1),
trace_id=http_service_trace_id,
span_id=http_service_patch_span_id,
parent_span_id=http_service_span_id,
name="HTTP PATCH",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
},
attributes={
"http.request.method": "PATCH",
"http.status_code": "404",
},
),
Traces(
timestamp=now - timedelta(seconds=1),
duration=timedelta(seconds=4),
trace_id=topic_service_trace_id,
span_id=topic_service_span_id,
parent_span_id="",
name="topic publish",
kind=TracesKind.SPAN_KIND_PRODUCER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "topic-service",
"os.type": "linux",
"host.name": "linux-001",
"cloud.provider": "integration",
"cloud.account.id": "001",
},
attributes={
"message.type": "SENT",
"messaging.operation": "publish",
"messaging.message.id": "001",
},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = {
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"order": [{"key": {"name": "count()"}, "direction": "desc"}],
"aggregations": [{"expression": "count()", "alias": "count_"}],
},
}
# Query traces count for spans
query["spec"]["order"][0]["key"] = order_by
query["spec"]["aggregations"][0]["alias"] = aggregation_alias
response = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type="time_series",
queries=[query],
)
assert response.status_code == expected_status
if expected_status != HTTPStatus.OK:
return
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 1
assert series[0]["values"][0]["value"] == 4
def test_traces_aggregate_with_mixed_field_selectors(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Setup:
Insert 4 traces with different attributes.
http-service: POST /integration -> SELECT, HTTP PATCH
topic-service: topic publish
Tests:
1. Query traces count for spans grouped by service.name
"""
http_service_trace_id = TraceIdGenerator.trace_id()
http_service_span_id = TraceIdGenerator.span_id()
http_service_db_span_id = TraceIdGenerator.span_id()
http_service_patch_span_id = TraceIdGenerator.span_id()
topic_service_trace_id = TraceIdGenerator.trace_id()
topic_service_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=3),
trace_id=http_service_trace_id,
span_id=http_service_span_id,
parent_span_id="",
name="POST /integration",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
},
attributes={
"net.transport": "IP.TCP",
"http.scheme": "http",
"http.user_agent": "Integration Test",
"http.request.method": "POST",
"http.response.status_code": "200",
},
),
Traces(
timestamp=now - timedelta(seconds=3.5),
duration=timedelta(seconds=0.5),
trace_id=http_service_trace_id,
span_id=http_service_db_span_id,
parent_span_id=http_service_span_id,
name="SELECT",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
},
attributes={
"db.name": "integration",
"db.operation": "SELECT",
"db.statement": "SELECT * FROM integration",
},
),
Traces(
timestamp=now - timedelta(seconds=3),
duration=timedelta(seconds=1),
trace_id=http_service_trace_id,
span_id=http_service_patch_span_id,
parent_span_id=http_service_span_id,
name="HTTP PATCH",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "http-service",
"os.type": "linux",
"host.name": "linux-000",
"cloud.provider": "integration",
"cloud.account.id": "000",
},
attributes={
"http.request.method": "PATCH",
"http.status_code": "404",
},
),
Traces(
timestamp=now - timedelta(seconds=1),
duration=timedelta(seconds=4),
trace_id=topic_service_trace_id,
span_id=topic_service_span_id,
parent_span_id="",
name="topic publish",
kind=TracesKind.SPAN_KIND_PRODUCER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={
"deployment.environment": "production",
"service.name": "topic-service",
"os.type": "linux",
"host.name": "linux-001",
"cloud.provider": "integration",
"cloud.account.id": "001",
},
attributes={
"message.type": "SENT",
"messaging.operation": "publish",
"messaging.message.id": "001",
},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
query = {
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"groupBy": [
{
"name": "service.name",
"fieldContext": "resource",
"fieldDataType": "string",
}
],
"aggregations": [
{"expression": "p99(duration_nano)", "alias": "p99"},
{"expression": "avg(duration_nano)", "alias": "avgDuration"},
{"expression": "count()", "alias": "numCalls"},
{"expression": "countIf(status_code = 2)", "alias": "numErrors"},
{
"expression": "countIf(response_status_code >= 400 AND response_status_code < 500)",
"alias": "num4XX",
},
],
"order": [{"key": {"name": "count()"}, "direction": "desc"}],
},
}
# Query traces count for spans
response = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type="time_series",
queries=[query],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert aggregations[0]["series"][0]["values"][0]["value"] >= 2.5 * 1e9 # p99 for http-service

View File

@@ -1,935 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import requests
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
assert_minutely_bucket_values,
find_named_result,
index_series_by_label,
)
from fixtures.traces import (
TraceIdGenerator,
Traces,
TracesKind,
TracesStatusCode,
)
def test_traces_fill_gaps(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Test fillGaps for traces without groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
trace_id = TraceIdGenerator.trace_id()
traces: list[Traces] = [
Traces(
timestamp=now - timedelta(minutes=3),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="test-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "test-service"},
attributes={"http.method": "GET"},
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": False,
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": True},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) >= 1
values = series[0]["values"]
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
assert_minutely_bucket_values(
values,
now,
expected_by_ts={ts_min_3: 1},
context="traces/fillGaps",
)
def test_traces_fill_gaps_with_group_by(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Test fillGaps for traces with groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
traces: list[Traces] = [
Traces(
timestamp=now - timedelta(minutes=3),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="span-a",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "service-a"},
attributes={"http.method": "GET"},
),
Traces(
timestamp=now - timedelta(minutes=2),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="span-b",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "service-b"},
attributes={"http.method": "POST"},
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": False,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": True},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 2, "Expected 2 series for 2 service groups"
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
series_by_service = index_series_by_label(series, "service.name")
assert set(series_by_service.keys()) == {"service-a", "service-b"}
expectations: dict[str, dict[int, float]] = {
"service-a": {ts_min_3: 1},
"service-b": {ts_min_2: 1},
}
for service_name, s in series_by_service.items():
assert_minutely_bucket_values(
s["values"],
now,
expected_by_ts=expectations[service_name],
context=f"traces/fillGaps/{service_name}",
)
def test_traces_fill_gaps_formula(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Test fillGaps for traces with formula.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
traces: list[Traces] = [
Traces(
timestamp=now - timedelta(minutes=3),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="test-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "test"},
attributes={"http.method": "GET"},
),
Traces(
timestamp=now - timedelta(minutes=2),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="another-test-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "another-test"},
attributes={"http.method": "POST"},
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": True,
"filter": {"expression": "service.name = 'test'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"stepInterval": 60,
"disabled": True,
"filter": {"expression": "service.name = 'another-test'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A + B",
"disabled": False,
},
},
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": True},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
f1 = find_named_result(results, "F1")
assert f1 is not None, "Expected formula result named F1"
aggregations = f1.get("aggregations") or []
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) >= 1
assert_minutely_bucket_values(
series[0]["values"],
now,
expected_by_ts={ts_min_3: 1, ts_min_2: 1},
context="traces/fillGaps/F1",
)
def test_traces_fill_gaps_formula_with_group_by(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Test fillGaps for traces with formula and groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
traces: list[Traces] = [
Traces(
timestamp=now - timedelta(minutes=3),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="span-group1",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "group1"},
attributes={"http.method": "GET"},
),
Traces(
timestamp=now - timedelta(minutes=2),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="span-group2",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "group2"},
attributes={"http.method": "POST"},
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": True,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"stepInterval": 60,
"disabled": True,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A + B",
"disabled": False,
},
},
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": True},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
f1 = find_named_result(results, "F1")
assert f1 is not None, "Expected formula result named F1"
aggregations = f1.get("aggregations") or []
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 2
series_by_service = index_series_by_label(series, "service.name")
assert set(series_by_service.keys()) == {"group1", "group2"}
expectations: dict[str, dict[int, float]] = {
"group1": {ts_min_3: 2},
"group2": {ts_min_2: 2},
}
for service_name, s in series_by_service.items():
assert_minutely_bucket_values(
s["values"],
now,
expected_by_ts=expectations[service_name],
context=f"traces/fillGaps/F1/{service_name}",
)
def test_traces_fill_zero(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Test fillZero function for traces without groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
traces: list[Traces] = [
Traces(
timestamp=now - timedelta(minutes=3),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="test-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "test"},
attributes={"http.method": "GET"},
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": False,
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
"functions": [{"name": "fillZero"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0].get("aggregations") or []
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) >= 1
values = series[0]["values"]
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
assert_minutely_bucket_values(
values,
now,
expected_by_ts={ts_min_3: 1},
context="traces/fillZero",
)
def test_traces_fill_zero_with_group_by(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Test fillZero function for traces with groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
traces: list[Traces] = [
Traces(
timestamp=now - timedelta(minutes=3),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="span-a",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "service-a"},
attributes={"http.method": "GET"},
),
Traces(
timestamp=now - timedelta(minutes=2),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="span-b",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "service-b"},
attributes={"http.method": "POST"},
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": False,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
"functions": [{"name": "fillZero"}],
},
}
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
aggregations = results[0]["aggregations"]
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 2, "Expected 2 series for 2 service groups"
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
series_by_service = index_series_by_label(series, "service.name")
assert set(series_by_service.keys()) == {"service-a", "service-b"}
expectations: dict[str, dict[int, float]] = {
"service-a": {ts_min_3: 1},
"service-b": {ts_min_2: 1},
}
for service_name, s in series_by_service.items():
assert_minutely_bucket_values(
s["values"],
now,
expected_by_ts=expectations[service_name],
context=f"traces/fillZero/{service_name}",
)
def test_traces_fill_zero_formula(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Test fillZero function for traces with formula.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
traces: list[Traces] = [
Traces(
timestamp=now - timedelta(minutes=3),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="test-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "test"},
attributes={"http.method": "GET"},
),
Traces(
timestamp=now - timedelta(minutes=2),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="another-test-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "another-test"},
attributes={"http.method": "POST"},
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": True,
"filter": {"expression": "service.name = 'test'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"stepInterval": 60,
"disabled": True,
"filter": {"expression": "service.name = 'another-test'"},
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A + B",
"disabled": False,
"functions": [{"name": "fillZero"}],
},
},
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
f1 = find_named_result(results, "F1")
assert f1 is not None, "Expected formula result named F1"
aggregations = f1.get("aggregations") or []
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) >= 1
assert_minutely_bucket_values(
series[0]["values"],
now,
expected_by_ts={ts_min_3: 1, ts_min_2: 1},
context="traces/fillZero/F1",
)
def test_traces_fill_zero_formula_with_group_by(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Test fillZero function for traces with formula and groupBy.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
traces: list[Traces] = [
Traces(
timestamp=now - timedelta(minutes=3),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="span-group1",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "group1"},
attributes={"http.method": "GET"},
),
Traces(
timestamp=now - timedelta(minutes=2),
duration=timedelta(seconds=1),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
parent_span_id="",
name="span-group2",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources={"service.name": "group2"},
attributes={"http.method": "POST"},
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v5/query_range"),
timeout=5,
headers={"authorization": f"Bearer {token}"},
json={
"schemaVersion": "v1",
"start": start_ms,
"end": end_ms,
"requestType": "time_series",
"compositeQuery": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": True,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"stepInterval": 60,
"disabled": True,
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "resource",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A + B",
"disabled": False,
"functions": [{"name": "fillZero"}],
},
},
]
},
"formatOptions": {"formatTableResultForUI": False, "fillGaps": False},
},
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
ts_min_2 = int((now - timedelta(minutes=2)).timestamp() * 1000)
ts_min_3 = int((now - timedelta(minutes=3)).timestamp() * 1000)
f1 = find_named_result(results, "F1")
assert f1 is not None, "Expected formula result named F1"
aggregations = f1.get("aggregations") or []
assert len(aggregations) == 1
series = aggregations[0]["series"]
assert len(series) == 2
series_by_service = index_series_by_label(series, "service.name")
assert set(series_by_service.keys()) == {"group1", "group2"}
expectations: dict[str, dict[int, float]] = {
"group1": {ts_min_3: 2},
"group2": {ts_min_2: 2},
}
for service_name, s in series_by_service.items():
assert_minutely_bucket_values(
s["values"],
now,
expected_by_ts=expectations[service_name],
context=f"traces/fillZero/F1/{service_name}",
)

View File

@@ -1,267 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import (
make_query_request,
)
from fixtures.traces import (
TraceIdGenerator,
Traces,
TracesKind,
TracesStatusCode,
)
def test_traces_list_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that filtering by trace_id:
1. Returns the matching span (narrow window, single bucket).
2. Does not return duplicate spans when the query window spans multiple
exponential buckets (>1 h)
3. Returns no results when the query window does not contain the trace.
"""
target_trace_id = TraceIdGenerator.trace_id()
other_trace_id = TraceIdGenerator.trace_id()
span_id_root = TraceIdGenerator.span_id()
other_span_id = TraceIdGenerator.span_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "trace-filter-service",
"cloud.provider": "integration",
}
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=target_trace_id,
span_id=span_id_root,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={"http.request.method": "GET"},
),
# span from a different trace — must not appear in results
Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=1),
trace_id=other_trace_id,
span_id=other_span_id,
parent_span_id="",
name="other-root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
trace_filter = f"trace_id = '{target_trace_id}'"
def _query(start_ms: int, end_ms: int) -> tuple[list, list[str]]:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="raw",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"disabled": False,
"limit": 100,
"offset": 0,
"filter": {"expression": trace_filter},
"order": [{"key": {"name": "timestamp"}, "direction": "desc"}],
"selectFields": [
{
"name": "name",
"fieldDataType": "string",
"fieldContext": "span",
"signal": "traces",
}
],
"having": {"expression": ""},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
rows = response.json()["data"]["data"]["results"][0]["rows"] or []
warning = (response.json().get("data") or {}).get("warning") or {}
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
return rows, messages
outside_range_msg = "lies outside the selected time range"
now_ms = int(now.timestamp() * 1000)
# --- Test 1: narrow window (single bucket, <1 h) ---
narrow_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
narrow_rows, narrow_warnings = _query(narrow_start_ms, now_ms)
assert len(narrow_rows) == 1, f"Expected 1 span for trace_id filter (narrow window), got {len(narrow_rows)}"
assert narrow_rows[0]["data"]["span_id"] == span_id_root
assert narrow_rows[0]["data"]["trace_id"] == target_trace_id
assert not any(outside_range_msg in m for m in narrow_warnings), f"Did not expect outside-range warning, got {narrow_warnings}"
# --- Test 2: wide window (>1 h, triggers multiple exponential buckets) ---
# should just return 1 span, not duplicate
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_rows, wide_warnings = _query(wide_start_ms, now_ms)
assert len(wide_rows) == 1, f"Expected 1 span for trace_id filter (wide window, multi-bucket), got {len(wide_rows)} — possible duplicate-span regression"
assert wide_rows[0]["data"]["span_id"] == span_id_root
assert wide_rows[0]["data"]["trace_id"] == target_trace_id
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
# --- Test 3: window that does not contain the trace returns no results + warning ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_rows, past_warnings = _query(past_start_ms, past_end_ms)
assert len(past_rows) == 0, f"Expected 0 spans for trace_id filter outside time window, got {len(past_rows)}"
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
def test_traces_aggregation_filter_by_trace_id(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""
Tests that the trace_id time-range optimization also applies to
non-window-list (time_series / aggregation) traces queries:
1. Wide query window containing the trace returns the correct count.
2. Query window outside the trace's time range short-circuits to empty.
3. Filter referencing a trace_id with no row in trace_summary
short-circuits to empty (trace_summary is authoritative for traces).
"""
target_trace_id = TraceIdGenerator.trace_id()
target_root_span_id = TraceIdGenerator.span_id()
target_child_span_id = TraceIdGenerator.span_id()
missing_trace_id = TraceIdGenerator.trace_id()
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
common_resources = {
"deployment.environment": "production",
"service.name": "traces-agg-filter-service",
"cloud.provider": "integration",
}
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=10),
duration=timedelta(seconds=5),
trace_id=target_trace_id,
span_id=target_root_span_id,
parent_span_id="",
name="root-span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={"http.request.method": "GET"},
),
Traces(
timestamp=now - timedelta(seconds=9),
duration=timedelta(seconds=1),
trace_id=target_trace_id,
span_id=target_child_span_id,
parent_span_id=target_root_span_id,
name="child-span",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
status_message="",
resources=common_resources,
attributes={},
),
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
def _count(start_ms: int, end_ms: int, trace_id: str) -> tuple[float, list[str]]:
response = make_query_request(
signoz,
token,
start_ms=start_ms,
end_ms=end_ms,
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"stepInterval": 60,
"disabled": False,
"filter": {"expression": f"trace_id = '{trace_id}'"},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
results = response.json()["data"]["data"]["results"]
assert len(results) == 1
warning = (response.json().get("data") or {}).get("warning") or {}
messages = [w.get("message", "") for w in (warning.get("warnings") or [])]
aggregations = results[0].get("aggregations") or []
if not aggregations:
return 0, messages
series = aggregations[0].get("series") or []
if not series:
return 0, messages
return sum(v["value"] for v in series[0]["values"]), messages
outside_range_msg = "lies outside the selected time range"
now_ms = int(now.timestamp() * 1000)
# --- Test 1: wide window (>1 h) containing the trace returns both spans ---
wide_start_ms = int((now - timedelta(hours=12)).timestamp() * 1000)
wide_count, wide_warnings = _count(wide_start_ms, now_ms, target_trace_id)
assert wide_count == 2, f"Expected count=2 for trace_id aggregation (wide window), got {wide_count}"
assert not any(outside_range_msg in m for m in wide_warnings), f"Did not expect outside-range warning, got {wide_warnings}"
# --- Test 2: window outside the trace short-circuits to empty + warning ---
past_start_ms = int((now - timedelta(hours=6)).timestamp() * 1000)
past_end_ms = int((now - timedelta(hours=2)).timestamp() * 1000)
past_count, past_warnings = _count(past_start_ms, past_end_ms, target_trace_id)
assert past_count == 0, f"Expected count=0 for trace_id aggregation outside time window, got {past_count}"
assert any(outside_range_msg in m for m in past_warnings), f"Expected outside-range warning, got warnings={past_warnings}"
# --- Test 3: trace_id with no entry in trace_summary short-circuits (no warning) ---
missing_start_ms = int((now - timedelta(minutes=5)).timestamp() * 1000)
missing_count, missing_warnings = _count(missing_start_ms, now_ms, missing_trace_id)
assert missing_count == 0, f"Expected count=0 for trace_id absent from trace_summary, got {missing_count}"
assert not any(outside_range_msg in m for m in missing_warnings), f"Did not expect outside-range warning for missing trace_id, got {missing_warnings}"

View File

@@ -1,53 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import make_query_request
# Traces filter validation: a truly-unknown key is rejected, and the body-JSON
# functions has()/hasToken() are not valid on the traces signal. All are pre-query
# validation errors (400) independent of ingested data.
@pytest.mark.parametrize(
"expression",
[
# unknown key. NOTE: current HEAD rejects (400); the parked
# convert-not-found-to-warning change will make this a 200 + warning.
pytest.param('totally.unknown.key = "x"', id="key_not_found"),
# has()/hasToken() support only the logs body JSON field.
pytest.param('has(service.name, "x")', id="has_on_traces"),
pytest.param('hasToken(name, "x")', id="hastoken_on_traces"),
],
)
def test_traces_filter_validation_errors(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
expression: str,
) -> None:
now = datetime.now(tz=UTC)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(seconds=30)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="scalar",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {"expression": expression},
"aggregations": [{"expression": "count()"}],
},
}
],
)
assert response.status_code == HTTPStatus.BAD_REQUEST

View File

@@ -1,98 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.querier import index_series_by_label, make_query_request
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
# min()/max() aggregation expressions over a span field. The traces aggregation
# suite (02_aggregation.py) covers count/countIf/avg/p99 but not min/max.
def test_traces_aggregate_min_max(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
) -> None:
"""max(duration_nano)/min(duration_nano) grouped by service.name return the extremes."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
insert_traces(
[
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=3), # 3e9
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="POST /x",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "http-service"},
),
Traces(
timestamp=now - timedelta(seconds=3),
duration=timedelta(milliseconds=500), # 0.5e9 (min)
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="SELECT",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "http-service"},
),
Traces(
timestamp=now - timedelta(seconds=2),
duration=timedelta(seconds=1), # 1e9
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="PATCH",
kind=TracesKind.SPAN_KIND_CLIENT,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "http-service"},
),
Traces(
timestamp=now - timedelta(seconds=1),
duration=timedelta(seconds=4), # 4e9
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="topic publish",
kind=TracesKind.SPAN_KIND_PRODUCER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "topic-service"},
),
]
)
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int((now + timedelta(seconds=5)).timestamp() * 1000),
request_type="time_series",
queries=[
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"groupBy": [{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"}],
"aggregations": [
{"expression": "max(duration_nano)", "alias": "maxDuration"},
{"expression": "min(duration_nano)", "alias": "minDuration"},
],
},
}
],
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
aggregations = response.json()["data"]["data"]["results"][0]["aggregations"]
max_by_svc = index_series_by_label(aggregations[0]["series"], "service.name")
min_by_svc = index_series_by_label(aggregations[1]["series"], "service.name")
assert max_by_svc["http-service"]["values"][0]["value"] == 3_000_000_000.0
assert min_by_svc["http-service"]["values"][0]["value"] == 500_000_000.0
assert max_by_svc["topic-service"]["values"][0]["value"] == 4_000_000_000.0
assert min_by_svc["topic-service"]["values"][0]["value"] == 4_000_000_000.0