Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
d92e48e853 test: pin the semconv family matrix on a live stack
A dedicated package runs SigNoz with resolve_semconv_families on and a
second instance with the flag at its default. The fleet has one
identity per state: OLD (old spelling only), NEW (current only), BOTH
(current staging and old production), NEITHER (keyless).

- 36 filter cells: nine operators, both spellings, both contexts. The
  result sets do not depend on the requested spelling; the current
  spelling wins on the conflict row; negative operators keep keyless
  rows, exactly like a single key.
- Group-by merges the fleet into one production group, a staging group,
  and a NULL group, and the group column carries the requested spelling.
- A bare name with the family under two contexts warns and keeps the
  resource side; the family survives the collision as one unit.
- Logs stay literal with the flag on, and everything stays literal with
  the flag off.

Verified: 42 passed against the live stack.

Assisted-by: Claude Fable 5
2026-08-18 04:22:53 +05:30
18 changed files with 395 additions and 201 deletions

View File

@@ -1,30 +0,0 @@
#!/bin/sh
# Installs dispatcher stubs into the repository's hooks directory. Safe to run
# repeatedly; invoked automatically from the Makefile and from the frontend
# postinstall so no manual setup is required.
set -e
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
# core.hooksPath shadows the hooks directory git would otherwise use, and an
# absolute value leaks one worktree's path into every other worktree.
if [ -n "$(git config --get core.hooksPath 2>/dev/null || true)" ]; then
git config --unset-all core.hooksPath 2>/dev/null || true
fi
# Resolves to the common directory for linked worktrees, which is where git
# looks for hooks regardless of which worktree the commit happens in.
hooks_dir="$(git rev-parse --git-path hooks)"
mkdir -p "$hooks_dir"
for hook in pre-commit commit-msg; do
cat >"$hooks_dir/$hook" <<EOF
#!/bin/sh
# Generated by .githooks/install.
root="\$(git rev-parse --show-toplevel)"
[ -x "\$root/.githooks/$hook" ] || exit 0
exec "\$root/.githooks/$hook" "\$@"
EOF
chmod +x "$hooks_dir/$hook"
done

View File

@@ -1,22 +0,0 @@
#!/bin/sh
set -e
staged_go_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.go')
if [ -n "$staged_go_files" ]; then
if command -v gofmt >/dev/null 2>&1; then
echo "$staged_go_files" | xargs gofmt -l -w
echo "$staged_go_files" | xargs git add
else
echo "pre-commit: gofmt not found, skipping go formatting" >&2
fi
fi
staged_web_files=$(git diff --cached --name-only --diff-filter=ACM -- 'frontend/*')
if [ -n "$staged_web_files" ]; then
if [ -d frontend/node_modules ] && command -v pnpm >/dev/null 2>&1; then
cd frontend && pnpm lint-staged
else
echo "pre-commit: frontend dependencies not installed, skipping lint-staged" >&2
fi
fi

View File

@@ -35,13 +35,6 @@ DOCKERFILE_ENTERPRISE = $(SRC)/cmd/enterprise/Dockerfile
DOCKER_REGISTRY_ENTERPRISE ?= docker.io/signoz/signoz
JS_BUILD_CONTEXT = $(SRC)/frontend
##############################################################
# git hooks
##############################################################
# Hooks live outside the working tree, so they cannot be checked in. Expanded
# while this file is parsed, which wires them up on any make invocation.
GITHOOKS_INSTALL := $(shell sh $(SRC)/.githooks/install >/dev/null 2>&1 || true)
##############################################################
# directories
##############################################################

View File

@@ -1,12 +1,7 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
set -e
if [ -d frontend/node_modules ] && command -v pnpm >/dev/null 2>&1; then
(cd frontend && pnpm run commitlint --edit "$1")
else
echo "commit-msg: frontend dependencies not installed, skipping commitlint" >&2
fi
cd frontend && pnpm run commitlint --edit $1
branch="$(git rev-parse --abbrev-ref HEAD)"

4
frontend/.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd frontend && pnpm lint-staged

View File

@@ -20,7 +20,8 @@
"jest": "jest",
"jest:coverage": "jest --coverage",
"jest:watch": "jest --watch",
"postinstall": "pnpm i18n:generate-hash && (is-ci || sh ../.githooks/install) && node scripts/update-registry.cjs",
"postinstall": "pnpm i18n:generate-hash && (is-ci || pnpm husky:configure) && node scripts/update-registry.cjs",
"husky:configure": "cd .. && husky install frontend/.husky && cd frontend && chmod ug+x .husky/*",
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
@@ -185,6 +186,7 @@
"@typescript/native-preview": "7.0.0-dev.20260430.1",
"eslint-plugin-sonarjs": "4.0.2",
"glob": "^13.0.6",
"husky": "^7.0.4",
"is-ci": "^3.0.1",
"jest-environment-jsdom": "29.7.0",
"jest-styled-components": "^7.2.0",

View File

@@ -440,6 +440,9 @@ importers:
glob:
specifier: ^13.0.6
version: 13.0.6
husky:
specifier: ^7.0.4
version: 7.0.4
is-ci:
specifier: ^3.0.1
version: 3.0.1
@@ -5526,6 +5529,11 @@ packages:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
husky@7.0.4:
resolution: {integrity: sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==}
engines: {node: '>=12'}
hasBin: true
hyphenate-style-name@1.0.4:
resolution: {integrity: sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==}
@@ -14628,6 +14636,8 @@ snapshots:
human-signals@8.0.1: {}
husky@7.0.4: {}
hyphenate-style-name@1.0.4: {}
i18next-browser-languagedetector@6.1.8:

View File

@@ -2,17 +2,14 @@ import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Form, Select, Space } from 'antd';
import { FeatureKeys } from 'constants/features';
import { ModalFooterTitle } from 'container/PipelinePage/styles';
import { useAppContext } from 'providers/App/App';
import { ProcessorData } from 'types/api/pipeline/def';
import { formValidationRules } from '../config';
import { ProcessorFormField } from './config';
import { processorFields, ProcessorFormField } from './config';
import CSVInput from './FormFields/CSVInput';
import JsonFlattening from './FormFields/JsonFlattening';
import { FormWrapper, PipelineIndexIcon, StyledSelect } from './styles';
import { resolveProcessorFields } from './utils';
import './styles.scss';
@@ -136,23 +133,16 @@ function ProcessorForm({
selectedProcessorData,
isAdd,
}: ProcessorFormProps): JSX.Element {
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return (
<div className="processor-form-container">
{resolveProcessorFields(processorType, isBodyJsonEnabled).map(
(fieldData: ProcessorFormField) => (
<ProcessorFieldInput
key={fieldData.name + String(fieldData.initialValue)}
fieldData={fieldData}
selectedProcessorData={selectedProcessorData}
isAdd={isAdd}
/>
),
)}
{processorFields[processorType]?.map((fieldData: ProcessorFormField) => (
<ProcessorFieldInput
key={fieldData.name + String(fieldData.initialValue)}
fieldData={fieldData}
selectedProcessorData={selectedProcessorData}
isAdd={isAdd}
/>
))}
</div>
);
}

View File

@@ -1,24 +0,0 @@
import { processorFields, ProcessorFormField } from './config';
const BODY_PARSE_FROM = 'body';
const JSON_BODY_PARSE_FROM = 'body.message';
// With use_json_body the collector normalizes every body into a map before user
// operators run, so a parser pointed at `body` gets a map it cannot read and
// silently extracts nothing. The log text lives at body.message.
export function resolveProcessorFields(
processorType: string,
isBodyJsonEnabled: boolean,
): Array<ProcessorFormField> {
const fields = processorFields[processorType] ?? [];
if (!isBodyJsonEnabled) {
return fields;
}
return fields.map((field) =>
field.name === 'parse_from' && field.initialValue === BODY_PARSE_FROM
? { ...field, initialValue: JSON_BODY_PARSE_FROM }
: field,
);
}

View File

@@ -1,45 +0,0 @@
import { processorFields } from '../PipelineListsView/AddNewProcessor/config';
import { resolveProcessorFields } from '../PipelineListsView/AddNewProcessor/utils';
const parseFromDefault = (
fields: ReturnType<typeof resolveProcessorFields>,
): unknown => fields.find((field) => field.name === 'parse_from')?.initialValue;
describe('resolveProcessorFields', () => {
it.each(['grok_parser', 'regex_parser', 'json_parser'])(
'defaults %s parse_from to body.message when use_json_body is on',
(processorType) => {
expect(parseFromDefault(resolveProcessorFields(processorType, true))).toBe(
'body.message',
);
},
);
it.each(['grok_parser', 'regex_parser', 'json_parser'])(
'keeps %s parse_from as body when use_json_body is off',
(processorType) => {
expect(parseFromDefault(resolveProcessorFields(processorType, false))).toBe(
'body',
);
},
);
it('leaves parse_from defaults that do not point at the body alone', () => {
expect(parseFromDefault(resolveProcessorFields('time_parser', true))).toBe(
'attributes.timestamp',
);
expect(
parseFromDefault(resolveProcessorFields('severity_parser', true)),
).toBe('attributes.logLevel');
});
it('does not mutate the shared config', () => {
resolveProcessorFields('grok_parser', true);
expect(parseFromDefault(processorFields.grok_parser)).toBe('body');
});
it('returns an empty list for an unknown processor type', () => {
expect(resolveProcessorFields('does_not_exist', true)).toStrictEqual([]);
});
});

View File

@@ -29,7 +29,6 @@ type builderQuery[T any] struct {
telemetryStore telemetrystore.TelemetryStore
orgID valuer.UUID
stmtBuilder qbtypes.StatementBuilder[T]
queryType qbtypes.QueryType
spec qbtypes.QueryBuilderQuery[T]
variables map[string]qbtypes.VariableItem
@@ -52,7 +51,6 @@ func newBuilderQuery[T any](
telemetryStore telemetrystore.TelemetryStore,
orgID valuer.UUID,
stmtBuilder qbtypes.StatementBuilder[T],
queryType qbtypes.QueryType,
spec qbtypes.QueryBuilderQuery[T],
tr qbtypes.TimeRange,
kind qbtypes.RequestType,
@@ -64,7 +62,6 @@ func newBuilderQuery[T any](
telemetryStore: telemetryStore,
orgID: orgID,
stmtBuilder: stmtBuilder,
queryType: queryType,
spec: spec,
variables: variables,
fromMS: tr.From,
@@ -84,7 +81,7 @@ func (q *builderQuery[T]) Fingerprint() string {
// Create a deterministic fingerprint for builder queries
// This needs to include all fields that affect the query results
parts := []string{q.queryType.StringValue()}
parts := []string{"builder"}
// Add signal type
parts = append(parts, fmt.Sprintf("signal=%s", q.spec.Signal.StringValue()))

View File

@@ -3,7 +3,6 @@ package querier
import (
"strings"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -21,8 +20,7 @@ func TestBuilderQueryFingerprint(t *testing.T) {
{
name: "fingerprint includes shiftby when ShiftBy field is set",
query: &builderQuery[qbtypes.MetricAggregation]{
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
kind: qbtypes.RequestTypeTimeSeries,
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
ShiftBy: 3600,
@@ -42,8 +40,7 @@ func TestBuilderQueryFingerprint(t *testing.T) {
{
name: "fingerprint includes shiftby but not other functions",
query: &builderQuery[qbtypes.MetricAggregation]{
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
kind: qbtypes.RequestTypeTimeSeries,
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
ShiftBy: 3600,
@@ -66,8 +63,7 @@ func TestBuilderQueryFingerprint(t *testing.T) {
{
name: "no shiftby in fingerprint when ShiftBy is zero",
query: &builderQuery[qbtypes.MetricAggregation]{
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
kind: qbtypes.RequestTypeTimeSeries,
spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
ShiftBy: 0,
@@ -98,29 +94,6 @@ func TestBuilderQueryFingerprint(t *testing.T) {
}
}
func TestBuilderQueryFingerprintQueryType(t *testing.T) {
spec := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model EXISTS"},
}
regular := &builderQuery[qbtypes.TraceAggregation]{
queryType: qbtypes.QueryTypeBuilder,
kind: qbtypes.RequestTypeTimeSeries,
spec: spec,
}
ai := &builderQuery[qbtypes.TraceAggregation]{
queryType: qbtypes.QueryTypeBuilderAI,
kind: qbtypes.RequestTypeTimeSeries,
spec: spec,
}
assert.True(t, strings.HasPrefix(regular.Fingerprint(), qbtypes.QueryTypeBuilder.StringValue()+"&"))
assert.True(t, strings.HasPrefix(ai.Fingerprint(), qbtypes.QueryTypeBuilderAI.StringValue()+"&"))
assert.NotEqual(t, regular.Fingerprint(), ai.Fingerprint())
}
func TestMakeBucketsOrder(t *testing.T) {
// Test that makeBuckets returns buckets in reverse chronological order by default
// Using milliseconds as input - need > 1 hour range to get multiple buckets

View File

@@ -305,7 +305,7 @@ func (q *querier) buildQueries(
}
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.aiTraceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryTypeBuilder:
@@ -313,7 +313,7 @@ func (q *querier) buildQueries(
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
spec.ShiftBy = extractShiftFromBuilderQuery(spec)
timeRange := adjustTimeRangeForShift(spec, qbtypes.TimeRange{From: req.Start, To: req.End}, req.RequestType)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, q.traceStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
@@ -323,7 +323,7 @@ func (q *querier) buildQueries(
if spec.Source == telemetrytypes.SourceAudit {
stmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, stmtBuilder, spec, timeRange, req.RequestType, tmplVars, q.builderConfig)
queries[spec.Name] = bq
steps[spec.Name] = spec.StepInterval
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
@@ -340,9 +340,9 @@ func (q *querier) buildQueries(
if spec.Source == telemetrytypes.SourceMeter {
event.Source = telemetrytypes.SourceMeter.StringValue()
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.meterStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
} else {
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, query.Type, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
bq = newBuilderQuery(q.logger, q.telemetryStore, orgID, q.metricStmtBuilder, spec, timeRange, req.RequestType, tmplVars, builderConfig{})
}
queries[spec.Name] = bq
@@ -618,7 +618,7 @@ func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qb
if spec.Source == telemetrytypes.SourceAudit {
liveTailStmtBuilder = q.auditStmtBuilder
}
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, liveTailStmtBuilder, query.Type, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
bq := newBuilderQuery(q.logger, q.telemetryStore, orgID, liveTailStmtBuilder, spec, timeRange, req.RequestType, map[string]qbtypes.VariableItem{
"id": {
Value: updatedLogID,
},
@@ -941,9 +941,8 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
// reuse the original query's statement builder and type so an AI query
// keeps its AI builder and cache key
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, qt.builderConfig)
// reuse the original query's statement builder so an AI query keeps its AI builder
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, qt.stmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
case *builderQuery[qbtypes.LogAggregation]:
specCopy := qt.spec.Copy()
@@ -953,16 +952,16 @@ func (q *querier) createRangedQuery(_ valuer.UUID, originalQuery qbtypes.Query,
if qt.spec.Source == telemetrytypes.SourceAudit {
shiftStmtBuilder = q.auditStmtBuilder
}
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, shiftStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, q.builderConfig)
case *builderQuery[qbtypes.MetricAggregation]:
specCopy := qt.spec.Copy()
specCopy.ShiftBy = extractShiftFromBuilderQuery(specCopy)
adjustedTimeRange := adjustTimeRangeForShift(specCopy, timeRange, qt.kind)
if qt.spec.Source == telemetrytypes.SourceMeter {
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.meterStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
}
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, qt.queryType, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
return newBuilderQuery(q.logger, q.telemetryStore, qt.orgID, q.metricStmtBuilder, specCopy, adjustedTimeRange, qt.kind, qt.variables, builderConfig{})
case *traceOperatorQuery:
specCopy := qt.spec.Copy()
return &traceOperatorQuery{

View File

@@ -19,6 +19,7 @@ pytest_plugins = [
"fixtures.traces",
"fixtures.metrics",
"fixtures.queriercommon",
"fixtures.semconvfamilies",
"fixtures.metadata",
"fixtures.meter",
"fixtures.browser",

75
tests/fixtures/semconvfamilies.py vendored Normal file
View File

@@ -0,0 +1,75 @@
"""Seed data for the semconv family matrix tests.
Four identities cover every fleet state of the deployment.environment(.name)
family. The tests assert which identities a filter returns, so BOTH (a row
that carries the two spellings with different values) and NEITHER (a keyless
row) are the point of most cases.
Each row carries its family pairs in the resource attributes and in the span
attributes, so one fleet serves the resource-context and attribute-context
matrices. The same rows exist as logs for the logs literalness guard.
"""
from collections.abc import Callable, Generator
from datetime import UTC, datetime, timedelta
import pytest
from fixtures.logs import Logs
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
PREFIX = "semconv-fam"
CURRENT_KEY = "deployment.environment.name"
OLD_KEY = "deployment.environment"
# Row identities. The span name, the log body, and service.name are the identity.
OLD = f"{PREFIX}-old" # only the old spelling, value "production"
NEW = f"{PREFIX}-new" # only the current spelling, value "production"
BOTH = f"{PREFIX}-both" # current "staging" and old "production" - the conflict row
NEITHER = f"{PREFIX}-neither" # no member at all
_ROWS = [
(OLD, {OLD_KEY: "production"}, timedelta(seconds=4)),
(NEW, {CURRENT_KEY: "production"}, timedelta(seconds=3)),
(BOTH, {CURRENT_KEY: "staging", OLD_KEY: "production"}, timedelta(seconds=2)),
(NEITHER, {}, timedelta(seconds=1)),
]
@pytest.fixture(name="family_fleet", scope="function")
def family_fleet(
insert_logs: Callable[[list[Logs]], None],
insert_traces: Callable[[list[Traces]], None],
) -> Generator[datetime]:
"""Inserts one span and one log per identity and yields the base
timestamp."""
now = datetime.now(tz=UTC).replace(microsecond=0) - timedelta(minutes=1)
insert_traces(
[
Traces(
timestamp=now - offset,
duration=timedelta(milliseconds=10),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name=identity,
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": identity, **family},
attributes=dict(family),
)
for identity, family, offset in _ROWS
]
)
insert_logs(
[
Logs(
timestamp=now - offset,
body=identity,
resources={"service.name": identity, **family},
attributes=dict(family),
)
for identity, family, offset in _ROWS
]
)
yield now

View File

@@ -0,0 +1,220 @@
"""The phase-1 matrix for semantic-convention family resolution.
The package runs SigNoz with resolve_semconv_families on. The fleet in
fixtures/semconvfamilies.py has one identity per state: OLD (old spelling
only), NEW (current only), BOTH (current "staging" and old "production"),
NEITHER (keyless). Each case asserts which identities a filter returns, with
either spelling as the requested name and for both contexts.
The pinned facts:
- Both spellings resolve to the same merged field; the result sets do not
depend on the requested spelling.
- The current spelling wins on a row that carries both (BOTH reads
"staging", never "production").
- Negative operators keep keyless rows (NEITHER), exactly like a single
key; presence stays an explicit EXISTS opt-in.
- Logs stay literal: only traces have family support today.
- With the flag off, everything stays literal.
"""
from collections.abc import Callable
from datetime import 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 (
RequestType,
build_aggregation,
build_group_by_field,
build_order_by,
build_raw_query,
build_traces_scalar_query,
get_column_data_from_response,
make_query_request,
)
from fixtures.semconvfamilies import (
BOTH,
CURRENT_KEY,
NEITHER,
NEW,
OLD,
OLD_KEY,
PREFIX,
)
FILTER_MATRIX = [
pytest.param("{key} = 'production'", {OLD, NEW}, id="eq_matches_either_spelling"),
pytest.param("{key} = 'staging'", {BOTH}, id="eq_current_wins_on_conflict"),
pytest.param("{key} != 'production'", {BOTH, NEITHER}, id="neq_keeps_keyless_and_conflict"),
pytest.param("{key} IN ['production', 'staging']", {OLD, NEW, BOTH}, id="in_matches_merged_value"),
pytest.param("{key} NOT IN ['production']", {BOTH, NEITHER}, id="not_in_keeps_keyless"),
pytest.param("{key} LIKE '%prod%'", {OLD, NEW}, id="like_matches_merged_value"),
pytest.param("{key} EXISTS", {OLD, NEW, BOTH}, id="exists_is_any_member"),
pytest.param("{key} NOT EXISTS", {NEITHER}, id="not_exists_is_no_member"),
pytest.param("{key} != 'production' AND {key} EXISTS", {BOTH}, id="neq_composed_with_exists"),
]
LITERAL_MATRIX = [
pytest.param("{key} = 'production'", {NEW}, id="literal_eq_reads_one_spelling"),
pytest.param("{key} != 'production'", {OLD, BOTH, NEITHER}, id="literal_neq_reads_one_spelling"),
]
def _trace_identities(
signoz: types.SigNoz,
token: str,
base: datetime,
expression: str,
signal: str = "traces",
) -> set[str]:
identity_field = "span.name" if signal == "traces" else "body"
identity_column = "name" if signal == "traces" else "body"
response = make_query_request(
signoz,
token,
start_ms=int((base - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((base + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
signal,
limit=100,
filter_expression=expression,
order=[build_order_by("timestamp", "asc")],
select_fields=[{"name": identity_field}],
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
# Sets keep the assertion stable when the shared stack is reused and older
# rows with the same identities remain.
return {name for name in get_column_data_from_response(response.json(), identity_column) if name.startswith(PREFIX)}
@pytest.mark.parametrize("expression_template,expected", FILTER_MATRIX)
@pytest.mark.parametrize("requested_key", [CURRENT_KEY, OLD_KEY], ids=["current", "old"])
@pytest.mark.parametrize("context", ["resource", "attribute"])
def test_family_filters(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
family_fleet: datetime,
context: str,
requested_key: str,
expression_template: str,
expected: set[str],
) -> None:
"""One matrix cell: a filter on one spelling, in one context. The result
set is a property of the family, not of the requested spelling."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
expression = expression_template.format(key=f"{context}.{requested_key}")
assert _trace_identities(signoz, token, family_fleet, expression) == expected, expression
@pytest.mark.parametrize("expression_template,expected", LITERAL_MATRIX)
def test_flag_off_stays_literal(
signoz_families_off: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
family_fleet: datetime,
expression_template: str,
expected: set[str],
) -> None:
"""The same fleet through an instance with the flag at its default: the
current spelling reads only rows that carry the current spelling."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
expression = expression_template.format(key=f"resource.{CURRENT_KEY}")
assert _trace_identities(signoz_families_off, token, family_fleet, expression) == expected, expression
@pytest.mark.parametrize("expression_template,expected", LITERAL_MATRIX)
def test_logs_stay_literal_with_flag_on(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
family_fleet: datetime,
expression_template: str,
expected: set[str],
) -> None:
"""Only traces have family support. The same filters on the logs copy of
the fleet behave literally even with the flag on."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
expression = expression_template.format(key=f"resource.{CURRENT_KEY}")
assert _trace_identities(signoz, token, family_fleet, expression, signal="logs") == expected, expression
def test_group_by_merges_and_echoes_requested_spelling(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
family_fleet: datetime,
) -> None:
"""Group by the current spelling over the fleet: OLD and NEW land in one
"production" group, BOTH lands in "staging", and the group column carries
the requested spelling."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((family_fleet - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((family_fleet + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.SCALAR,
queries=[
build_traces_scalar_query(
[build_aggregation("count()")],
filter_expression=f"service.name LIKE '{PREFIX}%'",
group_by=[build_group_by_field(CURRENT_KEY, "string", "resource")],
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
result = response.json()["data"]["data"]["results"][0]
group_column = result["columns"][0]
assert group_column["name"] == CURRENT_KEY, group_column
assert group_column["columnType"] == "group", group_column
groups = {row[0] for row in result["data"]}
assert {"production", "staging"}.issubset(groups), groups
assert None in groups, groups
def test_bare_name_prefers_resource_and_warns(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
family_fleet: datetime,
) -> None:
"""The fleet stores the family under the resource and the attribute
contexts, so a bare name is ambiguous. Resolution warns and keeps the
resource side; the family survives the collision as one unit."""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = make_query_request(
signoz,
token,
start_ms=int((family_fleet - timedelta(minutes=2)).timestamp() * 1000),
end_ms=int((family_fleet + timedelta(minutes=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
build_raw_query(
"A",
"traces",
limit=100,
filter_expression=f"{CURRENT_KEY} = 'production'",
order=[build_order_by("timestamp", "asc")],
select_fields=[{"name": "span.name"}],
)
],
)
assert response.status_code == HTTPStatus.OK, response.text
matched = {name for name in get_column_data_from_response(response.json(), "name") if name.startswith(PREFIX)}
assert matched == {OLD, NEW}
warning = response.json()["data"].get("warning") or {}
messages = " ".join(entry.get("message", "") for entry in warning.get("warnings", []))
assert "ambiguous" in messages.lower(), messages

View File

@@ -0,0 +1,56 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz_semconv_families(
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""Package-scoped SigNoz with resolve_semconv_families on."""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-semconv-families",
env_overrides={
"SIGNOZ_FLAGGER_CONFIG_BOOLEAN_RESOLVE__SEMCONV__FAMILIES": True,
},
)
@pytest.fixture(name="signoz_families_off", scope="package")
def signoz_families_off(
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""A second instance with the flag at its default (off). It shares the
sqlstore and clickhouse, so the same admin token and seeded rows work."""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-semconv-families-off",
env_overrides={},
)