Compare commits

...

3 Commits

Author SHA1 Message Date
Naman Verma
ecb5e419f8 test: add integration tests 2026-07-28 17:53:00 +05:30
Naman Verma
c91a0dbaba Merge branch 'main' into nv/delta-temporality 2026-07-28 16:36:50 +05:30
Naman Verma
60c927faa2 chore: delta temporality metrics should always be considered as a sum metric 2026-07-28 13:40:17 +05:30
14 changed files with 321 additions and 14 deletions

View File

@@ -1,5 +1,8 @@
// ** Helpers
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
@@ -389,11 +392,17 @@ const METRIC_TYPE_TO_ATTRIBUTE_TYPE: Record<
export function toAttributeType(
metricType: MetrictypesTypeDTO | undefined,
isMonotonic?: boolean,
temporality?: MetrictypesTemporalityDTO,
): ATTRIBUTE_TYPES | '' {
if (!metricType) {
return '';
}
if (metricType === MetrictypesTypeDTO.sum && isMonotonic === false) {
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
if (
metricType === MetrictypesTypeDTO.sum &&
isMonotonic === false &&
temporality === MetrictypesTemporalityDTO.cumulative
) {
return ATTRIBUTE_TYPES.GAUGE;
}
return METRIC_TYPE_TO_ATTRIBUTE_TYPE[metricType] || '';

View File

@@ -33,6 +33,7 @@ function AllAttributes({
metricName,
metricType,
isMonotonic,
temporality,
minTime,
maxTime,
}: AllAttributesProps): JSX.Element {
@@ -71,6 +72,7 @@ function AllAttributes({
groupBy,
limit,
isMonotonic,
temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -89,7 +91,7 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeKey]: groupBy,
});
},
[metricName, metricType, isMonotonic, handleExplorerTabChange],
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
);
const goToMetricsExploreWithAppliedAttribute = useCallback(
@@ -101,6 +103,7 @@ function AllAttributes({
undefined,
undefined,
isMonotonic,
temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -120,7 +123,7 @@ function AllAttributes({
[MetricsExplorerEventKeys.AttributeValue]: value,
});
},
[metricName, metricType, isMonotonic, handleExplorerTabChange],
[metricName, metricType, isMonotonic, temporality, handleExplorerTabChange],
);
const handleKeyMenuItemClick = useCallback(

View File

@@ -86,6 +86,7 @@ function MetricDetails({
undefined,
undefined,
metadata?.isMonotonic,
metadata?.temporality,
);
handleExplorerTabChange(
PANEL_TYPES.TIME_SERIES,
@@ -108,6 +109,7 @@ function MetricDetails({
handleExplorerTabChange,
metadata?.type,
metadata?.isMonotonic,
metadata?.temporality,
]);
useEffect(() => {
@@ -196,6 +198,7 @@ function MetricDetails({
metricName={metricName}
metricType={metadata?.type}
isMonotonic={metadata?.isMonotonic}
temporality={metadata?.temporality}
minTime={minTime}
maxTime={maxTime}
/>

View File

@@ -147,6 +147,44 @@ describe('MetricDetails utils', () => {
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('treats a cumulative non-monotonic Sum as a Gauge', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetrictypesTypeDTO.sum,
undefined,
undefined,
undefined,
false,
MetrictypesTemporalityDTO.cumulative,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.GAUGE,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('avg');
expect(query.builder.queryData[0]?.timeAggregation).toBe('avg');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('avg');
});
it('treats a delta non-monotonic Sum as a Sum', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,
MetrictypesTypeDTO.sum,
undefined,
undefined,
undefined,
false,
MetrictypesTemporalityDTO.delta,
);
expect(query.builder.queryData[0]?.aggregateAttribute?.type).toBe(
ATTRIBUTE_TYPES.SUM,
);
expect(query.builder.queryData[0]?.aggregateOperator).toBe('rate');
expect(query.builder.queryData[0]?.timeAggregation).toBe('rate');
expect(query.builder.queryData[0]?.spaceAggregation).toBe('sum');
});
it('should create correct query for GAUGE metric type', () => {
const query = getMetricDetailsQuery(
TEST_METRIC_NAME,

View File

@@ -35,6 +35,7 @@ export interface AllAttributesProps {
metricName: string;
metricType: MetrictypesTypeDTO | undefined;
isMonotonic?: boolean;
temporality?: MetrictypesTemporalityDTO;
minTime?: number;
maxTime?: number;
}

View File

@@ -89,12 +89,16 @@ export function getMetricDetailsQuery(
groupBy?: string,
limit?: number,
isMonotonic?: boolean,
temporality?: MetrictypesTemporalityDTO,
): Query {
let timeAggregation;
let spaceAggregation;
let aggregateOperator;
// Only non-monotonic cumulative sums are treated as gauges; delta sums stay Sum
const isNonMonotonicSum =
metricType === MetrictypesTypeDTO.sum && isMonotonic === false;
metricType === MetrictypesTypeDTO.sum &&
isMonotonic === false &&
temporality === MetrictypesTemporalityDTO.cumulative;
switch (metricType) {
case MetrictypesTypeDTO.sum:
@@ -131,7 +135,7 @@ export function getMetricDetailsQuery(
break;
}
const attributeType = toAttributeType(metricType, isMonotonic);
const attributeType = toAttributeType(metricType, isMonotonic, temporality);
return {
...initialQueriesMap[DataSource.METRICS],

View File

@@ -393,12 +393,13 @@ describe('selecting a metric type updates the aggregation options', () => {
]);
});
it('non-monotonic Sum metric is treated as Gauge', () => {
it('cumulative non-monotonic Sum metric is treated as Gauge', () => {
returnMetrics([
makeMetric({
metricName: 'active_connections',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
temporality: 'cumulative' as never,
}),
]);
@@ -427,6 +428,36 @@ describe('selecting a metric type updates the aggregation options', () => {
]);
});
it('delta non-monotonic Sum metric is treated as Sum', () => {
returnMetrics([
makeMetric({
metricName: 'queue_depth_delta',
type: MetrictypesTypeDTO.sum,
isMonotonic: false,
temporality: 'delta' as never,
}),
]);
render(<MetricQueryHarness query={makeQuery()} />);
const input = screen.getByRole('combobox');
fireEvent.change(input, {
target: { value: 'queue_depth_delta' },
});
fireEvent.blur(input);
expect(getOptionLabels('time-agg-options')).toStrictEqual([
'Rate',
'Increase',
]);
expect(getOptionLabels('space-agg-options')).toStrictEqual([
'Sum',
'Avg',
'Min',
'Max',
]);
});
it('Histogram metric shows no time options and P50P99 space options', () => {
returnMetrics([
makeMetric({

View File

@@ -34,7 +34,7 @@ export type MetricNameSelectorProps = {
function getAttributeType(
metric: MetricsexplorertypesListMetricDTO,
): ATTRIBUTE_TYPES | '' {
return toAttributeType(metric.type, metric.isMonotonic);
return toAttributeType(metric.type, metric.isMonotonic, metric.temporality);
}
function createAutocompleteData(

View File

@@ -825,6 +825,7 @@ func (m *module) validateAndNormalizeMetricType(req *metricsexplorertypes.Update
if req.Temporality != metrictypes.Delta && req.Temporality != metrictypes.Cumulative {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid value for temporality")
}
// Cumulative gate already present here; metadata.go was aligned to match.
// Special case: if Sum is not monotonic and cumulative, convert to Gauge
if !req.IsMonotonic && req.Temporality == metrictypes.Cumulative {
req.Type = metrictypes.GaugeType

View File

@@ -3016,6 +3016,7 @@ func (r *ClickHouseReader) GetMetricAggregateAttributes(ctx context.Context, org
temporality := string(metadata.Temporality)
isMonotonic := metadata.IsMonotonic
// Cumulative gate already present here; metadata.go was aligned to match.
// Non-monotonic cumulative sums are treated as gauges
if typ == "Sum" && !isMonotonic && temporality == string(v3.Cumulative) {
typ = "Gauge"
@@ -3074,6 +3075,7 @@ func (r *ClickHouseReader) GetMeterAggregateAttributes(ctx context.Context, orgI
return nil, fmt.Errorf("error while scanning meter name: %s", err.Error())
}
// Cumulative gate already present here; metadata.go was aligned to match.
// Non-monotonic cumulative sums are treated as gauges
if typ == "Sum" && !isMonotonic && temporality == string(v3.Cumulative) {
typ = "Gauge"

View File

@@ -2323,6 +2323,15 @@ func unionTemporalities(existing, additional []metrictypes.Temporality) []metric
return existing
}
// resolveMetricType applies the non-monotonic-cumulative-sum-as-gauge rule.
// Monotonicity is only meaningful for cumulative sums; delta sums always stay Sum.
func resolveMetricType(metricType metrictypes.Type, isMonotonic bool, temporality metrictypes.Temporality) metrictypes.Type {
if metricType == metrictypes.SumType && !isMonotonic && temporality == metrictypes.Cumulative {
return metrictypes.GaugeType
}
return metricType
}
func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, tableName string, adjustedStartTs, adjustedEndTs uint64, metricNames []string, extraConds ...string) (map[string][]metrictypes.Temporality, map[string]metrictypes.Type, error) {
temporalities := make(map[string][]metrictypes.Temporality)
types := make(map[string]metrictypes.Type)
@@ -2359,9 +2368,7 @@ func (t *telemetryMetaStore) fetchTemporalityTypeForTable(ctx context.Context, t
if temporality != metrictypes.Unknown {
temporalities[metricName] = append(temporalities[metricName], temporality)
}
if metricType == metrictypes.SumType && !isMonotonic {
metricType = metrictypes.GaugeType
}
metricType = resolveMetricType(metricType, isMonotonic, temporality)
types[metricName] = metricType
}
if err := rows.Err(); err != nil {
@@ -2412,9 +2419,7 @@ func (t *telemetryMetaStore) fetchMeterSourceMetricsTemporalityAndType(ctx conte
if err := rows.Scan(&metricName, &temporality, &metricType, &isMonotonic); err != nil {
return nil, nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to scan temporality result")
}
if metricType == metrictypes.SumType && !isMonotonic {
metricType = metrictypes.GaugeType
}
metricType = resolveMetricType(metricType, isMonotonic, temporality)
temporalities[metricName] = temporality
types[metricName] = metricType
}

View File

@@ -0,0 +1,64 @@
package telemetrymetadata
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/stretchr/testify/assert"
)
func TestResolveMetricType(t *testing.T) {
testCases := []struct {
description string
inputMetricType metrictypes.Type
inputIsMonotonic bool
inputTemporality metrictypes.Temporality
expectedMetricType metrictypes.Type
}{
{
description: "delta non-monotonic sum stays a sum",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: false,
inputTemporality: metrictypes.Delta,
expectedMetricType: metrictypes.SumType,
},
{
description: "cumulative non-monotonic sum becomes a gauge",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: false,
inputTemporality: metrictypes.Cumulative,
expectedMetricType: metrictypes.GaugeType,
},
{
description: "cumulative monotonic sum stays a sum",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: true,
inputTemporality: metrictypes.Cumulative,
expectedMetricType: metrictypes.SumType,
},
{
description: "delta monotonic sum stays a sum",
inputMetricType: metrictypes.SumType,
inputIsMonotonic: true,
inputTemporality: metrictypes.Delta,
expectedMetricType: metrictypes.SumType,
},
{
description: "gauge is unaffected by monotonicity",
inputMetricType: metrictypes.GaugeType,
inputIsMonotonic: false,
inputTemporality: metrictypes.Unspecified,
expectedMetricType: metrictypes.GaugeType,
},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
assert.Equal(
t,
testCase.expectedMetricType,
resolveMetricType(testCase.inputMetricType, testCase.inputIsMonotonic, testCase.inputTemporality),
)
})
}
}

View File

@@ -0,0 +1,84 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import (
MetricsReducedSampleLast60s,
MetricsReducedSampleSum60s,
MetricsReducedTimeSeries,
)
from fixtures.querier import aligned_epoch, query_metric_values
# A delta, non-monotonic Sum must be treated as a Sum (read from the sum_60s
# reduced table), not downgraded to a Gauge (last_60s). The type is resolved
# server-side from the reduced time series, so the query carries no explicit
# type. sum_60s holds the true counter values; last_60s holds decoy values that
# a gauge misclassification would surface instead.
def test_reduced_delta_nonmonotonic_sum_is_treated_as_sum(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_reduced_metrics: Callable[..., None],
) -> None:
metric_name = "test_reduction_delta_nonmonotonic_sum"
base_epoch = aligned_epoch(timedelta(hours=30), step_seconds=300)
time_series = MetricsReducedTimeSeries(
metric_name=metric_name,
kept_labels={"service": "a"},
timestamp=datetime.fromtimestamp(base_epoch, tz=UTC),
temporality="Delta",
type_="Sum",
is_monotonic=False,
)
assert time_series.temporality == "Delta"
insert_reduced_metrics(
[time_series],
sum_samples=[
MetricsReducedSampleSum60s(
metric_name=metric_name,
reduced_fingerprint=time_series.fingerprint,
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
sum_value=30.0,
count_series=1,
count_samples=1,
temporality="Delta",
)
for minute in range(20)
],
last_samples=[
MetricsReducedSampleLast60s(
metric_name=metric_name,
reduced_fingerprint=time_series.fingerprint,
timestamp=datetime.fromtimestamp(base_epoch + minute * 60, tz=UTC),
sum_last=999.0,
min_value=999.0,
max_value=999.0,
sum_values=999.0,
count_series=1,
count_samples=1,
temporality="Delta",
)
for minute in range(20)
],
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
values = query_metric_values(
signoz,
token,
metric_name,
base_epoch,
base_epoch + 20 * 60,
"increase",
"sum",
step_interval=300,
)
# 5 one-minute buckets x 30.0 per 300s step, read from sum_60s.
# A gauge misclassification would read last_60s and never produce 150.0.
assert [v["timestamp"] for v in values] == [(base_epoch + step * 300) * 1000 for step in range(4)]
assert [v["value"] for v in values] == [150.0] * 4

View File

@@ -0,0 +1,62 @@
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.metrics import Metrics
from fixtures.querier import build_builder_query, get_series_values, make_query_request
# A delta, non-monotonic Sum queried without an explicit type must be treated as
# a Sum: the server resolves the type and the delta rate/increase values must be
# correct. Non-reduced delta values are temporality-driven, so this is a
# forward-looking guard against a future change routing the delta path by type
# (e.g. gauge -> avg/last).
@pytest.mark.parametrize(
"time_aggregation, expected",
[
("rate", 1.0), # 60 per 60s bucket / 60s
("increase", 60.0),
],
)
def test_delta_nonmonotonic_sum_is_treated_as_sum(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
time_aggregation: str,
expected: float,
) -> None:
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
start_ms = int((now - timedelta(minutes=6)).timestamp() * 1000)
end_ms = int(now.timestamp() * 1000)
metric_name = f"test_delta_nonmonotonic_sum_{time_aggregation}"
metrics = [
Metrics(
metric_name=metric_name,
labels={"service": "a"},
timestamp=now - timedelta(minutes=minute),
value=60.0,
temporality="Delta",
type_="Sum",
is_monotonic=False,
)
for minute in range(1, 6)
]
insert_metrics(metrics)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# No type and no temporality: the server resolves both from the seeded series.
query = build_builder_query("A", metric_name, time_aggregation, "sum")
response = make_query_request(signoz, token, start_ms, end_ms, [query])
assert response.status_code == HTTPStatus.OK, response.text
values = get_series_values(response.json(), "A")
assert len(values) == 5, f"Expected 5 buckets, got {values}"
for value in values:
assert value["value"] == expected, f"Expected {expected}, got {value['value']}"