mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-08 15:40:40 +01:00
Compare commits
4 Commits
nv/functio
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1dcbe87fe | ||
|
|
a36081b00c | ||
|
|
48d2460c8e | ||
|
|
14d9bbfce9 |
6
.github/workflows/integrationci.yaml
vendored
6
.github/workflows/integrationci.yaml
vendored
@@ -48,7 +48,11 @@ jobs:
|
||||
- logspipelines
|
||||
- passwordauthn
|
||||
- preference
|
||||
- querier
|
||||
- querierlogs
|
||||
- queriertraces
|
||||
- queriermetrics
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- rawexportdata
|
||||
- role
|
||||
- rootuser
|
||||
|
||||
@@ -3,7 +3,6 @@ package postgressqlschema
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
@@ -215,20 +214,6 @@ 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().
|
||||
@@ -239,21 +224,19 @@ SELECT
|
||||
i.indisunique AS unique,
|
||||
i.indisprimary AS primary,
|
||||
a.attname AS column_name,
|
||||
n AS column_position,
|
||||
(i.indkey[n - 1] = 0) AS is_expression,
|
||||
pg_get_indexdef(i.indexrelid, n, true) AS key_def,
|
||||
array_position(i.indkey, a.attnum) AS column_position,
|
||||
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
|
||||
ct.relname = ?
|
||||
AND ct.relkind = 'r'
|
||||
a.attnum = ANY(i.indkey)
|
||||
AND con.oid IS NULL
|
||||
AND ct.relkind = 'r'
|
||||
AND ct.relname = ?
|
||||
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)
|
||||
@@ -268,10 +251,6 @@ 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)
|
||||
@@ -281,45 +260,30 @@ ORDER BY index_name, column_position`, string(name))
|
||||
indexName string
|
||||
unique bool
|
||||
primary bool
|
||||
columnName *string
|
||||
// starts from 1 and is unused in this function, this is to ensure that the column names are in the correct order
|
||||
columnName string
|
||||
// starts from 0 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, &isExpression, &keyDef, &predicate); err != nil {
|
||||
if err := rows.Scan(&tableName, &indexName, &unique, &primary, &columnName, &columnPosition, &predicate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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))
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -327,17 +291,6 @@ 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 {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
//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")
|
||||
})
|
||||
}
|
||||
@@ -513,6 +513,13 @@ 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,
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
padding: 0px 8px;
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
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));
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
@@ -35,21 +35,54 @@
|
||||
min-width: 150px;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
--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-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: 27px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l2-foreground) !important;
|
||||
color: var(--input-with-label-color, var(--l3-foreground)) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
&[type='number']::-webkit-inner-spin-button,
|
||||
@@ -63,25 +96,35 @@
|
||||
|
||||
.close-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
height: 38px;
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
height: 100%;
|
||||
width: 38px;
|
||||
position: relative;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
&.labelAfter {
|
||||
.input {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
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-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-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ function InputWithLabel({
|
||||
>
|
||||
{!labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
|
||||
<Input
|
||||
className="input"
|
||||
className={cx('input', {
|
||||
'input__has-label-after': !labelAfter,
|
||||
'input__has-close-button': !!onClose,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={inputValue}
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--l1-border),
|
||||
var(--l1-border) 4px,
|
||||
var(--query-builder-v2-border-color, var(--l2-border)),
|
||||
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
@@ -101,7 +101,8 @@
|
||||
top: 12px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-left: 6px dotted var(--l1-border);
|
||||
border-left: 6px dotted
|
||||
var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
|
||||
/* Horizontal line pointing from vertical to the item */
|
||||
@@ -114,8 +115,8 @@
|
||||
height: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--l1-border),
|
||||
var(--l1-border) 4px,
|
||||
var(--query-builder-v2-border-color, var(--l2-border)),
|
||||
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
@@ -241,7 +242,8 @@
|
||||
top: 12px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-left: 6px dotted var(--l1-border);
|
||||
border-left: 6px dotted
|
||||
var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
|
||||
/* Horizontal line pointing from vertical to the item */
|
||||
@@ -254,8 +256,8 @@
|
||||
height: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--l1-border),
|
||||
var(--l1-border) 4px,
|
||||
var(--query-builder-v2-border-color, var(--l2-border)),
|
||||
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
@@ -273,6 +275,16 @@
|
||||
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 {
|
||||
@@ -282,15 +294,42 @@
|
||||
.ant-input-group-addon {
|
||||
border-top-left-radius: 0px !important;
|
||||
border-top-right-radius: 0px !important;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
background: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
color: var(--query-builder-v2-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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,8 +362,8 @@
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--l1-border),
|
||||
var(--l1-border) 4px,
|
||||
var(--query-builder-v2-border-color, var(--l2-border)),
|
||||
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
@@ -395,8 +434,8 @@
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--l1-border),
|
||||
var(--l1-border) 4px,
|
||||
var(--query-builder-v2-border-color, var(--l2-border)),
|
||||
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
@@ -412,7 +451,7 @@
|
||||
min-width: 120px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: var(--l1-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@@ -457,13 +496,16 @@
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l1-background) !important;
|
||||
height: 34px !important;
|
||||
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;
|
||||
box-sizing: border-box !important;
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--l1-foreground);
|
||||
color: var(--query-builder-v2-color, var(--l1-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,11 @@
|
||||
|
||||
.ant-select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.ant-select-selection-search-input {
|
||||
min-width: max-content !important;
|
||||
max-width: 100% !important;
|
||||
@@ -100,9 +105,12 @@
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1.005px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
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));
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
@@ -123,6 +131,7 @@
|
||||
.input {
|
||||
flex: initial;
|
||||
width: 100px !important;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
.ant-select-selection-search-input {
|
||||
font-size: 12px !important;
|
||||
line-height: 27px;
|
||||
line-height: 25px;
|
||||
&::placeholder {
|
||||
color: var(--l2-foreground) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,12 @@
|
||||
.ant-select-selector {
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
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));
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
@@ -33,36 +36,49 @@
|
||||
min-height: 36px;
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--l2-foreground) !important;
|
||||
color: var(
|
||||
--query-builder-v2-placeholder-color,
|
||||
var(--l3-foreground)
|
||||
) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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);
|
||||
.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-item {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
.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% */
|
||||
|
||||
&:hover,
|
||||
&.ant-select-item-option-active {
|
||||
background: var(--l3-background) !important;
|
||||
}
|
||||
&:not(:last-of-type) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&.ant-select-item-option-selected {
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l1-border);
|
||||
font-weight: 600;
|
||||
}
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
{signalSourceChangeEnabled && (
|
||||
<Select
|
||||
className="source-selector"
|
||||
popupClassName="qb-select-popover"
|
||||
placeholder="Source"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={source}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
// 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;
|
||||
@@ -29,32 +44,33 @@
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
|
||||
color: var(--l2-foreground);
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
> button {
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-left: none;
|
||||
min-width: 120px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
border-left: 1px solid
|
||||
var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: var(--l1-border);
|
||||
background: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
color: var(--text-robin-500);
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
|
||||
display: none;
|
||||
|
||||
&::before {
|
||||
background: var(--l1-border);
|
||||
background: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +81,7 @@
|
||||
height: 30px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: var(--l3-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -78,10 +94,13 @@
|
||||
align-items: center;
|
||||
|
||||
.having-filter-select-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
padding-right: 38px;
|
||||
|
||||
.having-filter-select-editor {
|
||||
border-radius: 2px;
|
||||
@@ -106,15 +125,17 @@
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-left-width: 0px;
|
||||
border-right-width: 0px;
|
||||
padding: 0px !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border);
|
||||
border-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,17 +239,32 @@
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
line-height: 36px !important;
|
||||
min-height: 34px;
|
||||
line-height: 32px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&,
|
||||
.ͼ1a {
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
@@ -237,8 +273,11 @@
|
||||
}
|
||||
|
||||
.chip-decorator {
|
||||
background: var(--l3-background) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
background: var(
|
||||
--query-builder-v2-chip-decorator-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
margin-right: 4px;
|
||||
@@ -246,34 +285,38 @@
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
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(--l2-border);
|
||||
background: var(--l2-background);
|
||||
height: 38px;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
height: 100%;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,20 +343,8 @@
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
input {
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
|
||||
|
||||
&.error {
|
||||
.cm-editor {
|
||||
@@ -51,14 +51,15 @@
|
||||
|
||||
.cm-content {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
padding: 0px !important;
|
||||
background-color: var(--l1-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border);
|
||||
border-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@
|
||||
right: 0px !important;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
@@ -118,7 +119,7 @@
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
color: var(--l2-foreground) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
.cm-completionIcon {
|
||||
@@ -127,7 +128,10 @@
|
||||
|
||||
&:hover,
|
||||
&[aria-selected='true'] {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
@@ -142,15 +146,24 @@
|
||||
.cm-line {
|
||||
line-height: 36px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
@@ -159,8 +172,11 @@
|
||||
}
|
||||
|
||||
.chip-decorator {
|
||||
background: var(--l3-background) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
background: var(
|
||||
--query-builder-v2-chip-decorator-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
color: var(--query-builder-v2-color, var(--l1-foreground)) !important;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
margin-right: 4px;
|
||||
@@ -168,7 +184,10 @@
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
@@ -201,12 +220,11 @@
|
||||
|
||||
.close-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
height: 38px;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
height: 100%;
|
||||
width: 38px;
|
||||
|
||||
border-left: transparent;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
@@ -217,13 +235,13 @@
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l1-foreground);
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -238,9 +256,10 @@
|
||||
.query-aggregation-interval-input-container {
|
||||
.query-aggregation-interval-input {
|
||||
input {
|
||||
min-height: 36px;
|
||||
max-width: 120px;
|
||||
&::placeholder {
|
||||
color: var(--l2-foreground);
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,8 +270,8 @@
|
||||
|
||||
.query-aggregation-error-popover {
|
||||
.ant-popover-inner {
|
||||
background-color: var(--l1-border);
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.add-trace-operator-button,
|
||||
.add-new-query-button,
|
||||
.add-formula-button {
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -40,11 +40,14 @@ $max-recents-shown: 5;
|
||||
.query-status-container {
|
||||
width: 32px;
|
||||
|
||||
background-color: var(--l1-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-radius: 2px;
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
@@ -83,16 +86,16 @@ $max-recents-shown: 5;
|
||||
|
||||
.cm-content {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
padding: 0px !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border);
|
||||
border-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: 1px solid var(--l1-border);
|
||||
outline: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
@@ -183,11 +186,17 @@ $max-recents-shown: 5;
|
||||
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
background-color: var(--l1-background) !important;
|
||||
color: var(--l2-foreground) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@@ -205,7 +214,10 @@ $max-recents-shown: 5;
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
@@ -274,25 +286,49 @@ $max-recents-shown: 5;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
line-height: 34px !important;
|
||||
line-height: 36px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&,
|
||||
.ͼ1a {
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
color: var(
|
||||
--query-builder-v2-placeholder-color,
|
||||
var(--l3-foreground)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-position {
|
||||
|
||||
@@ -65,6 +65,14 @@
|
||||
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 {
|
||||
|
||||
@@ -57,10 +57,13 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
}
|
||||
|
||||
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
|
||||
const unit = getIntervalUnit(spread, offsetTimestamp);
|
||||
// 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 formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
|
||||
return `${formatted}${unit.name}`;
|
||||
}, [cursorXPercent, spread, offsetTimestamp]);
|
||||
}, [cursorXPercent, spread, offsetTimestamp, width]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
@@ -94,12 +97,17 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
>
|
||||
<text
|
||||
x={index === intervals.length - 1 ? -10 : 0}
|
||||
y={timelineHeight * 2}
|
||||
y={timelineHeight * 2 - 3}
|
||||
fill={strokeColor}
|
||||
>
|
||||
{interval.label}
|
||||
</text>
|
||||
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
|
||||
<line
|
||||
y1={0}
|
||||
y2={timelineHeight - 3}
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
63
frontend/src/components/TimelineV3/__tests__/utils.test.ts
Normal file
63
frontend/src/components/TimelineV3/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,14 +10,18 @@ export type { Interval };
|
||||
|
||||
/**
|
||||
* Select the interval unit matching the timeline's logic.
|
||||
* Exported so crosshair labels use the same unit as timeline ticks.
|
||||
* 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.
|
||||
*/
|
||||
export function getIntervalUnit(
|
||||
spread: number,
|
||||
intervalSpread: 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) {
|
||||
|
||||
@@ -89,6 +89,7 @@ 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',
|
||||
|
||||
@@ -41,6 +41,28 @@
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
@@ -0,0 +1,34 @@
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AttributeMappingHeader';
|
||||
@@ -39,6 +39,9 @@ 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 () => {
|
||||
@@ -54,6 +57,29 @@ 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, {
|
||||
|
||||
@@ -4,11 +4,13 @@ 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[];
|
||||
@@ -24,9 +26,12 @@ export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const activeTab = pathname.startsWith(CONFIGURATION_KEY)
|
||||
? CONFIGURATION_KEY
|
||||
: OVERVIEW_KEY;
|
||||
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 onTabChange = useCallback(
|
||||
(key: string): void => {
|
||||
@@ -46,6 +51,11 @@ export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
|
||||
label: 'Model pricing',
|
||||
children: <LLMObservabilityModelPricing />,
|
||||
},
|
||||
{
|
||||
key: ATTRIBUTE_MAPPING_KEY,
|
||||
label: 'Attribute Mapping',
|
||||
children: <LLMObservabilityAttributeMapping />,
|
||||
},
|
||||
];
|
||||
|
||||
return { items, activeTab, onTabChange };
|
||||
|
||||
@@ -25,6 +25,14 @@
|
||||
.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;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
.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;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -31,8 +31,14 @@
|
||||
min-width: 32px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
--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)
|
||||
);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,11 @@
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
|
||||
background-color: var(--l1-border) !important;
|
||||
opacity: 0.8;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
margin-left: -1px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
@@ -86,8 +89,8 @@
|
||||
border-bottom-left-radius: 3px;
|
||||
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--query-builder-v2-border-color, none);
|
||||
background: var(--query-builder-v2-background-color, var(--l3-background));
|
||||
}
|
||||
|
||||
&.showInput {
|
||||
@@ -101,12 +104,20 @@
|
||||
.query-function-value {
|
||||
width: 70px;
|
||||
border-left: 0;
|
||||
background: var(--l2-background);
|
||||
background: var(--query-builder-v2-background-color, 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: transparent !important;
|
||||
border-color: var(--internal-ant-border-color-focus);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--internal-ant-border-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +125,7 @@
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
|
||||
border-left: 1px solid var(--l2-border);
|
||||
border-left: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
|
||||
@@ -205,6 +205,7 @@ 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];
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
@@ -31,6 +31,7 @@ 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';
|
||||
@@ -48,6 +49,7 @@ export interface TraceMetadataForHeader {
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
rootSpanStatusCode: string;
|
||||
hasMissingSpans: boolean;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
@@ -229,6 +231,8 @@ function TraceDetailsHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
title="Preview fields"
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
: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) {
|
||||
@@ -98,6 +99,13 @@
|
||||
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;
|
||||
|
||||
@@ -49,59 +49,6 @@
|
||||
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;
|
||||
@@ -125,6 +72,9 @@
|
||||
|
||||
.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 {
|
||||
@@ -170,7 +120,7 @@
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l2-border);
|
||||
|
||||
// ResizableBox child renders with a global `.resizable-box__content` class
|
||||
// — give it independent horizontal scrolling.
|
||||
@@ -185,6 +135,18 @@
|
||||
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;
|
||||
}
|
||||
@@ -216,10 +178,12 @@
|
||||
|
||||
.treeRow:hover,
|
||||
.treeRow.hoveredSpan {
|
||||
border-radius: 4px;
|
||||
// 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;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 20%,
|
||||
var(--l3-background) 60%,
|
||||
transparent
|
||||
) !important;
|
||||
|
||||
@@ -262,20 +226,22 @@
|
||||
--badge-border-width: 0px;
|
||||
|
||||
&.hoveredSpan {
|
||||
border-radius: 4px;
|
||||
// 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;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 20%,
|
||||
var(--l3-background) 60%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
|
||||
&.isInterested,
|
||||
&.isSelectedNonMatching {
|
||||
border-radius: 4px;
|
||||
border-radius: 0;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 40%,
|
||||
var(--l3-background) 80%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
@@ -309,20 +275,21 @@
|
||||
|
||||
&:hover,
|
||||
&.hoveredSpan {
|
||||
border-radius: 4px;
|
||||
// Right end of the row band — round only the outer (right) corners.
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 20%,
|
||||
var(--l3-background) 60%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
|
||||
&:has(.isInterested),
|
||||
&:has(.isSelectedNonMatching) {
|
||||
border-radius: 4px;
|
||||
border-radius: 0 4px 4px 0;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 40%,
|
||||
var(--l3-background) 80%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
@@ -345,10 +312,11 @@
|
||||
|
||||
&.isInterested,
|
||||
&.isSelectedNonMatching {
|
||||
border-radius: 4px;
|
||||
// Left end of the row band — outer (left) corners only.
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l3-background) 40%,
|
||||
var(--l3-background) 80%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
@@ -471,7 +439,7 @@
|
||||
padding-left: 8px;
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(to left, var(--l1-background) 60%, transparent);
|
||||
background: linear-gradient(to left, var(--l2-background) 40%, transparent);
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@@ -599,6 +567,18 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -33,14 +33,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Link,
|
||||
ListPlus,
|
||||
} from '@signozhq/icons';
|
||||
import { ChevronDown, ChevronRight, 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';
|
||||
@@ -551,7 +544,9 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
cursorX,
|
||||
onMouseMove: onCrosshairMove,
|
||||
onMouseLeave: onCrosshairLeave,
|
||||
} = useCrosshair({ containerRef: timelineAreaRef, enabled: false });
|
||||
// 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 });
|
||||
|
||||
// Imperative DOM class toggling for hover highlights (avoids React re-renders)
|
||||
const applyHoverClass = useCallback((spanId: string | null): void => {
|
||||
@@ -854,28 +849,6 @@ 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 */}
|
||||
@@ -994,8 +967,8 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
data-span-id={span.span_id}
|
||||
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
|
||||
onMouseLeave={handleRowMouseLeave}
|
||||
onMouseEnter={(): void => applyHoverClass(span.span_id)}
|
||||
onMouseLeave={(): void => applyHoverClass(null)}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{span.response_status_code && (
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,16 @@ 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 {
|
||||
@@ -25,6 +35,7 @@ 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);
|
||||
@@ -39,11 +50,18 @@ export function useCrosshair({
|
||||
return;
|
||||
}
|
||||
|
||||
const x = e.clientX - rect.left;
|
||||
setCursorX(x);
|
||||
setCursorXPercent(x / rect.width);
|
||||
// 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);
|
||||
},
|
||||
[containerRef, enabled],
|
||||
[containerRef, enabled, insetX],
|
||||
);
|
||||
|
||||
const onMouseLeave = useCallback((): void => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { ChartNoAxesGantt, TriangleAlert } from '@signozhq/icons';
|
||||
import {
|
||||
ChartNoAxesGantt,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from '@signozhq/icons';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { Collapse } from 'antd';
|
||||
@@ -34,6 +40,16 @@ 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();
|
||||
@@ -329,6 +345,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
rootServiceName: payload.rootServiceName,
|
||||
rootServiceEntryPoint: payload.rootServiceEntryPoint,
|
||||
rootSpanStatusCode: rootSpan?.response_status_code || '',
|
||||
hasMissingSpans: payload.hasMissingSpans || false,
|
||||
};
|
||||
}, [traceData?.payload]);
|
||||
|
||||
@@ -388,6 +405,7 @@ function TraceDetailsV3(): JSX.Element {
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
expandIcon={renderPanelExpandIcon}
|
||||
className={styles.flameCollapse}
|
||||
items={[
|
||||
{
|
||||
@@ -401,7 +419,13 @@ 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 ? (
|
||||
@@ -442,6 +466,7 @@ 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,
|
||||
})}
|
||||
|
||||
@@ -67,6 +67,14 @@
|
||||
.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;
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
padding: 6px;
|
||||
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--periscope-btn-border-color, var(--l1-border));
|
||||
border-radius: 3px;
|
||||
background: var(--l2-background);
|
||||
background: var(--periscope-btn-background-color, var(--l2-background));
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
color: var(--l2-foreground);
|
||||
|
||||
@@ -135,11 +135,15 @@
|
||||
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(--l2-foreground);
|
||||
color: var(--input-with-label-color, var(--l2-foreground));
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
@@ -153,9 +157,8 @@
|
||||
text-overflow: ellipsis;
|
||||
padding: 0px 8px;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
color: var(--l1-foreground);
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
@@ -165,7 +168,6 @@
|
||||
.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;
|
||||
@@ -173,7 +175,7 @@
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
|
||||
background: var(--l1-background);
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
|
||||
min-width: 0;
|
||||
|
||||
@@ -189,21 +191,44 @@
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
background: var(--l2-background);
|
||||
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;
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--l2-foreground);
|
||||
color: var(--input-with-label-color, var(--l2-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
height: 38px;
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
height: 100%;
|
||||
width: 38px;
|
||||
margin-left: -1px;
|
||||
position: relative;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:focus:not(:focus-visible),
|
||||
&.ant-btn:focus:not(:focus-visible) {
|
||||
|
||||
@@ -839,6 +839,9 @@ 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'] {
|
||||
|
||||
@@ -136,6 +136,7 @@ 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'],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -88,60 +88,62 @@ func TestStore_Create_PopulatesIDsOnFreshInsert(t *testing.T) {
|
||||
assert.Equal(t, preIDB, stored["team\x00blr"].ID)
|
||||
}
|
||||
|
||||
func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlstore := newTestStore(t)
|
||||
s := NewStore(sqlstore)
|
||||
// todo (@namanverma): uncomment once unique index is there.
|
||||
//
|
||||
// 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")
|
||||
// }
|
||||
|
||||
@@ -218,7 +218,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewMigrateSSORoleMappingNamesFactory(sqlstore),
|
||||
sqlmigration.NewAddMetricReductionRulesFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewRemoveOrganizationTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
package sqlschema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -51,29 +49,13 @@ 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
|
||||
}
|
||||
@@ -83,14 +65,6 @@ 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("_")
|
||||
@@ -103,13 +77,10 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -130,42 +101,10 @@ 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{}
|
||||
|
||||
@@ -175,20 +114,12 @@ func (index *UniqueIndex) ToCreateSQL(fmter SQLFormatter) []byte {
|
||||
sql = fmter.AppendIdent(sql, string(index.TableName))
|
||||
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))
|
||||
for i, column := range index.ColumnNames {
|
||||
if i > 0 {
|
||||
sql = append(sql, ", "...)
|
||||
}
|
||||
|
||||
sql = fmter.AppendIdent(sql, string(column))
|
||||
}
|
||||
|
||||
sql = append(sql, ")"...)
|
||||
@@ -229,7 +160,7 @@ func (index *PartialUniqueIndex) Name() string {
|
||||
b.WriteString(string(column))
|
||||
}
|
||||
b.WriteString("_")
|
||||
b.WriteString((&expressionNormalizer{input: index.Where}).hash())
|
||||
b.WriteString((&whereNormalizer{input: index.Where}).hash())
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -267,7 +198,7 @@ func (index *PartialUniqueIndex) Equals(other Index) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
return index.Name() == other.Name() && slices.Equal(index.Columns(), other.Columns()) && (&expressionNormalizer{input: index.Where}).normalize() == (&expressionNormalizer{input: otherPartial.Where}).normalize()
|
||||
return index.Name() == other.Name() && slices.Equal(index.Columns(), other.Columns()) && (&whereNormalizer{input: index.Where}).normalize() == (&whereNormalizer{input: otherPartial.Where}).normalize()
|
||||
}
|
||||
|
||||
func (index *PartialUniqueIndex) ToCreateSQL(fmter SQLFormatter) []byte {
|
||||
|
||||
@@ -38,39 +38,6 @@ 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{
|
||||
@@ -262,54 +229,6 @@ 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 {
|
||||
@@ -319,142 +238,6 @@ 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",
|
||||
|
||||
@@ -6,26 +6,25 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type expressionNormalizer struct {
|
||||
input string
|
||||
foldCase bool
|
||||
type whereNormalizer struct {
|
||||
input string
|
||||
}
|
||||
|
||||
func (n *expressionNormalizer) hash() string {
|
||||
func (n *whereNormalizer) hash() string {
|
||||
hasher := fnv.New32a()
|
||||
_, _ = hasher.Write([]byte(n.normalize()))
|
||||
return fmt.Sprintf("%08x", hasher.Sum32())
|
||||
}
|
||||
|
||||
func (n *expressionNormalizer) normalize() string {
|
||||
expr := strings.TrimSpace(n.input)
|
||||
expr = n.stripOuterParentheses(expr)
|
||||
func (n *whereNormalizer) normalize() string {
|
||||
where := strings.TrimSpace(n.input)
|
||||
where = n.stripOuterParentheses(where)
|
||||
|
||||
var output strings.Builder
|
||||
output.Grow(len(expr))
|
||||
output.Grow(len(where))
|
||||
|
||||
for i := 0; i < len(expr); i++ {
|
||||
switch expr[i] {
|
||||
for i := 0; i < len(where); i++ {
|
||||
switch where[i] {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
if output.Len() > 0 {
|
||||
last := output.String()[output.Len()-1]
|
||||
@@ -34,25 +33,21 @@ func (n *expressionNormalizer) normalize() string {
|
||||
}
|
||||
}
|
||||
case '\'':
|
||||
end := n.consumeSingleQuotedLiteral(expr, i, &output)
|
||||
end := n.consumeSingleQuotedLiteral(where, i, &output)
|
||||
i = end
|
||||
case '"':
|
||||
token, end := n.consumeDoubleQuotedToken(expr, i)
|
||||
token, end := n.consumeDoubleQuotedToken(where, i)
|
||||
output.WriteString(token)
|
||||
i = end
|
||||
default:
|
||||
c := expr[i]
|
||||
if n.foldCase && c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
output.WriteByte(c)
|
||||
output.WriteByte(where[i])
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(output.String())
|
||||
}
|
||||
|
||||
func (n *expressionNormalizer) stripOuterParentheses(s string) string {
|
||||
func (n *whereNormalizer) stripOuterParentheses(s string) string {
|
||||
for {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 2 || s[0] != '(' || s[len(s)-1] != ')' || !n.hasWrappingParentheses(s) {
|
||||
@@ -62,7 +57,7 @@ func (n *expressionNormalizer) stripOuterParentheses(s string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (n *expressionNormalizer) hasWrappingParentheses(s string) bool {
|
||||
func (n *whereNormalizer) hasWrappingParentheses(s string) bool {
|
||||
depth := 0
|
||||
inSingleQuotedLiteral := false
|
||||
inDoubleQuotedToken := false
|
||||
@@ -106,7 +101,7 @@ func (n *expressionNormalizer) hasWrappingParentheses(s string) bool {
|
||||
return depth == 0
|
||||
}
|
||||
|
||||
func (n *expressionNormalizer) consumeSingleQuotedLiteral(s string, start int, output *strings.Builder) int {
|
||||
func (n *whereNormalizer) 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])
|
||||
@@ -123,7 +118,7 @@ func (n *expressionNormalizer) consumeSingleQuotedLiteral(s string, start int, o
|
||||
return len(s) - 1
|
||||
}
|
||||
|
||||
func (n *expressionNormalizer) consumeDoubleQuotedToken(s string, start int) (string, int) {
|
||||
func (n *whereNormalizer) consumeDoubleQuotedToken(s string, start int) (string, int) {
|
||||
var ident strings.Builder
|
||||
|
||||
for i := start + 1; i < len(s); i++ {
|
||||
@@ -147,7 +142,7 @@ func (n *expressionNormalizer) consumeDoubleQuotedToken(s string, start int) (st
|
||||
return s[start:], len(s) - 1
|
||||
}
|
||||
|
||||
func (n *expressionNormalizer) isSimpleUnquotedIdentifier(s string) bool {
|
||||
func (n *whereNormalizer) isSimpleUnquotedIdentifier(s string) bool {
|
||||
if s == "" || strings.ToLower(s) != s {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestExpressionNormalizerNormalize(t *testing.T) {
|
||||
func TestWhereNormalizerNormalize(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
@@ -47,16 +47,11 @@ func TestExpressionNormalizerNormalize(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, (&expressionNormalizer{input: testCase.input}).normalize())
|
||||
assert.Equal(t, testCase.output, (&whereNormalizer{input: testCase.input}).normalize())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package sqlitesqlschema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -93,6 +92,7 @@ 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,103 +108,53 @@ func (provider *provider) GetIndices(ctx context.Context, tableName sqlschema.Ta
|
||||
continue
|
||||
}
|
||||
|
||||
if !unique {
|
||||
continue
|
||||
}
|
||||
|
||||
columns, hasExpression, err := provider.indexKeys(ctx, name)
|
||||
if err != nil {
|
||||
if err := provider.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewRaw("SELECT name FROM PRAGMA_index_info(?)", string(name)).
|
||||
Scan(ctx, &columns); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// SQLite exposes expression keys and the predicate only in the DDL.
|
||||
var ddl string
|
||||
if hasExpression || partial {
|
||||
if unique && partial {
|
||||
var indexSQL string
|
||||
if err := provider.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewRaw("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", name).
|
||||
Scan(ctx, &ddl); err != nil {
|
||||
Scan(ctx, &indexSQL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
where := extractWhereClause(indexSQL)
|
||||
index := &sqlschema.PartialUniqueIndex{
|
||||
TableName: tableName,
|
||||
ColumnNames: columns,
|
||||
Where: extractWhereClause(ddl),
|
||||
Where: where,
|
||||
}
|
||||
} else if hasExpression {
|
||||
index = &sqlschema.UniqueIndex{
|
||||
TableName: tableName,
|
||||
Expressions: extractIndexColumns(ddl),
|
||||
|
||||
if index.Name() == name {
|
||||
indices = append(indices, index)
|
||||
} else {
|
||||
indices = append(indices, index.Named(name))
|
||||
}
|
||||
} else {
|
||||
index = &sqlschema.UniqueIndex{
|
||||
} else if unique {
|
||||
index := &sqlschema.UniqueIndex{
|
||||
TableName: tableName,
|
||||
ColumnNames: columns,
|
||||
}
|
||||
}
|
||||
|
||||
if index.Name() != name {
|
||||
index = index.Named(name)
|
||||
if index.Name() == name {
|
||||
indices = append(indices, index)
|
||||
} else {
|
||||
indices = append(indices, 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 {
|
||||
@@ -223,92 +173,6 @@ 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
|
||||
|
||||
@@ -50,93 +50,3 @@ 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
//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")
|
||||
})
|
||||
}
|
||||
22
tests/fixtures/querier.py
vendored
22
tests/fixtures/querier.py
vendored
@@ -950,3 +950,25 @@ 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
File diff suppressed because it is too large
Load Diff
@@ -1,928 +0,0 @@
|
||||
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",
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
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"]
|
||||
141
tests/integration/tests/queriercommon/04_filter_operators.py
Normal file
141
tests/integration/tests/queriercommon/04_filter_operators.py
Normal file
@@ -0,0 +1,141 @@
|
||||
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
|
||||
500
tests/integration/tests/querierlogs/01_list.py
Normal file
500
tests/integration/tests/querierlogs/01_list.py
Normal file
@@ -0,0 +1,500 @@
|
||||
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
|
||||
788
tests/integration/tests/querierlogs/02_aggregation.py
Normal file
788
tests/integration/tests/querierlogs/02_aggregation.py
Normal file
@@ -0,0 +1,788 @@
|
||||
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
|
||||
849
tests/integration/tests/querierlogs/03_fill.py
Normal file
849
tests/integration/tests/querierlogs/03_fill.py
Normal file
@@ -0,0 +1,849 @@
|
||||
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}",
|
||||
)
|
||||
193
tests/integration/tests/querierlogs/04_formula.py
Normal file
193
tests/integration/tests/querierlogs/04_formula.py
Normal file
@@ -0,0 +1,193 @@
|
||||
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]}"
|
||||
364
tests/integration/tests/querierlogs/05_trace_id_filter.py
Normal file
364
tests/integration/tests/querierlogs/05_trace_id_filter.py
Normal file
@@ -0,0 +1,364 @@
|
||||
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}"
|
||||
@@ -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", "")],
|
||||
[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", "")],
|
||||
[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)],
|
||||
],
|
||||
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]="", 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
|
||||
# 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
|
||||
# Behaviour:
|
||||
# AdjustKeys no-op
|
||||
),
|
||||
@@ -735,7 +735,6 @@ 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)):
|
||||
276
tests/integration/tests/querierlogs/09_json_body_functions.py
Normal file
276
tests/integration/tests/querierlogs/09_json_body_functions.py
Normal file
@@ -0,0 +1,276 @@
|
||||
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
|
||||
@@ -8,20 +8,15 @@ 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]:
|
||||
@@ -570,371 +565,3 @@ 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
|
||||
175
tests/integration/tests/queriermetrics/02_warnings.py
Normal file
175
tests/integration/tests/queriermetrics/02_warnings.py
Normal file
@@ -0,0 +1,175 @@
|
||||
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)}"
|
||||
217
tests/integration/tests/queriermetrics/03_namespace_metadata.py
Normal file
217
tests/integration/tests/queriermetrics/03_namespace_metadata.py
Normal file
@@ -0,0 +1,217 @@
|
||||
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
|
||||
341
tests/integration/tests/querierscalar/01_logs.py
Normal file
341
tests/integration/tests/querierscalar/01_logs.py
Normal file
@@ -0,0 +1,341 @@
|
||||
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",
|
||||
)
|
||||
316
tests/integration/tests/querierscalar/02_traces.py
Normal file
316
tests/integration/tests/querierscalar/02_traces.py
Normal file
@@ -0,0 +1,316 @@
|
||||
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",
|
||||
)
|
||||
269
tests/integration/tests/querierscalar/03_metrics.py
Normal file
269
tests/integration/tests/querierscalar/03_metrics.py
Normal file
@@ -0,0 +1,269 @@
|
||||
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",
|
||||
)
|
||||
58
tests/integration/tests/querierscalar/04_having.py
Normal file
58
tests/integration/tests/querierscalar/04_having.py
Normal file
@@ -0,0 +1,58 @@
|
||||
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")
|
||||
54
tests/integration/tests/querierscalar/05_reduce_to.py
Normal file
54
tests/integration/tests/querierscalar/05_reduce_to.py
Normal file
@@ -0,0 +1,54 @@
|
||||
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")
|
||||
905
tests/integration/tests/queriertraces/01_list.py
Normal file
905
tests/integration/tests/queriertraces/01_list.py
Normal file
@@ -0,0 +1,905 @@
|
||||
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)
|
||||
408
tests/integration/tests/queriertraces/02_aggregation.py
Normal file
408
tests/integration/tests/queriertraces/02_aggregation.py
Normal file
@@ -0,0 +1,408 @@
|
||||
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
|
||||
935
tests/integration/tests/queriertraces/03_fill.py
Normal file
935
tests/integration/tests/queriertraces/03_fill.py
Normal file
@@ -0,0 +1,935 @@
|
||||
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}",
|
||||
)
|
||||
267
tests/integration/tests/queriertraces/04_trace_id_filter.py
Normal file
267
tests/integration/tests/queriertraces/04_trace_id_filter.py
Normal file
@@ -0,0 +1,267 @@
|
||||
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}"
|
||||
53
tests/integration/tests/queriertraces/07_filter_errors.py
Normal file
53
tests/integration/tests/queriertraces/07_filter_errors.py
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
@@ -0,0 +1,98 @@
|
||||
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
|
||||
Reference in New Issue
Block a user