Compare commits

..

4 Commits

Author SHA1 Message Date
Naman Verma
29a393e47e Merge branch 'main' into nv/exec-stats 2026-11-05 10:51:11 +05:30
ashu8912
98f9850c53 fix(alerts): fall back to default range on invalid relativeTime (#13375)
#### Description

- Alert Details crashed to the error boundary when the URL
`relativeTime` couldn't be parsed (e.g. `?relativeTime=1y`). It now
falls back to the default 30m range.
- The "triggers in …" and stats card labels show the range actually
queried, not the raw param.
- `relativeTime=3months` (accepted by validation since #12402) silently
resolved to 30 minutes. Multi-month values now resolve to 30 days per
month.
- Moved `useRouteTabUtils` into its own file to break the `hooks.tsx` ↔
`container/AlertHistory` import cycle.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#674

#### Additional Information

- Repro: open `/alerts/overview?ruleId=<id>&relativeTime=1y` (crashes on
`main`), or `relativeTime=3months` (shows 30m of history).
- No story change. The fixed page renders the same as the existing
AlertOverview story.
2026-11-05 03:12:55 +00:00
Naman Verma
c58f76f998 Merge branch 'main' into nv/exec-stats 2026-11-03 11:34:10 +05:30
Naman Verma
e39ad9e221 test: check how exec stats fail 2026-11-02 22:24:22 +05:30
18 changed files with 235 additions and 464 deletions

View File

@@ -3,6 +3,7 @@ import { Tooltip } from 'antd';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { ArrowDownLeft, ArrowUpRight, Calendar } from '@signozhq/icons';
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
import { AlertRuleStats } from 'types/api/alerts/def';
import { calculateChange } from 'utils/calculateChange';
@@ -86,7 +87,7 @@ function StatsCard({
}: StatsCardProps): JSX.Element {
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const { relativeTime } = useAlertHistoryQueryParams();
const { changePercentage, changeDirection } = calculateChange(
totalCurrentCount,

View File

@@ -1,6 +1,8 @@
import { useMemo } from 'react';
import useUrlQuery from 'hooks/useUrlQuery';
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
import {
useAlertHistoryQueryParams,
useGetAlertRuleDetailsTimelineGraphData,
} from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
@@ -13,9 +15,7 @@ function GraphWrapper({
}: {
totalCurrentTriggers: number;
}): JSX.Element {
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const { relativeTime } = useAlertHistoryQueryParams();
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsTimelineGraphData();

View File

@@ -0,0 +1,25 @@
import GetMinMax from './getMinMax';
const DAY_NS = 24 * 60 * 60 * 1000 * 1e6;
function rangeInDays(interval: string): number {
const { minTime, maxTime } = GetMinMax(interval);
return Math.round((maxTime - minTime) / DAY_NS);
}
describe('GetMinMax', () => {
it.each([
['1month', 30],
['2months', 60],
['3months', 90],
['6month', 180],
['10d', 10],
['3w', 21],
])('resolves %p to %p days', (interval, days) => {
expect(rangeInDays(interval)).toBe(days);
});
it('throws on an unknown interval', () => {
expect(() => GetMinMax('1y')).toThrow('invalid time type');
});
});

View File

@@ -14,7 +14,7 @@ export const isValidShortHandDateTimeFormat = (time: string): boolean =>
const extractTimeAndUnit = (time: string): { time: number; unit: string } => {
// Match the pattern
const match = /^(\d+)([mhdw])$/.exec(time);
const match = validCustomTimeRegex.exec(time);
if (match) {
return { time: parseInt(match[1], 10), unit: match[2] };
@@ -39,13 +39,16 @@ export const getMinTimeForRelativeTimes = (
return getMinAgo({ minutes: 24 * 60 * time }).getTime();
case 'w':
return getMinAgo({ minutes: 24 * 60 * 7 * time }).getTime();
case 'month':
case 'months':
return getMinAgo({ minutes: 24 * 60 * 30 * time }).getTime();
default:
return getMinAgo({ minutes: 1 }).getTime();
}
};
const GetMinMax = (
interval: Time | string,
interval: string,
dateTimeRange?: [number, number],
// eslint-disable-next-line sonarjs/cognitive-complexity
): GetMinMaxPayload => {

View File

@@ -21,7 +21,8 @@ import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
import AlertHeader from './AlertHeader/AlertHeader';
import AlertNotFound from './AlertNotFound';
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
import { useGetAlertRuleDetails } from './hooks';
import { useRouteTabUtils } from './useRouteTabUtils';
import './AlertDetails.styles.scss';

View File

@@ -0,0 +1,53 @@
import { renderHook } from '@testing-library/react';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAlertHistoryQueryParams } from '../hooks';
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(),
}));
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
function renderWithSearch(
search: string,
): ReturnType<typeof useAlertHistoryQueryParams> {
jest.mocked(useUrlQuery).mockReturnValue(new URLSearchParams(search));
return renderHook(() => useAlertHistoryQueryParams()).result.current;
}
describe('useAlertHistoryQueryParams', () => {
it.each(['1y', '90s', 'now-1h', 'custom', 'null', ''])(
'falls back to the default range for relativeTime=%p',
(param) => {
const { startTime, endTime, relativeTime } = renderWithSearch(
`ruleId=1&relativeTime=${param}`,
);
expect(endTime - startTime).toBeCloseTo(THIRTY_MINUTES_MS, -3);
expect(relativeTime).toBe('30m');
},
);
it('uses a valid relativeTime', () => {
const { startTime, endTime, relativeTime } = renderWithSearch(
'ruleId=1&relativeTime=6h',
);
expect(endTime - startTime).toBeCloseTo(6 * 60 * 60 * 1000, -3);
expect(relativeTime).toBe('6h');
});
it('prefers startTime and endTime over relativeTime', () => {
const { startTime, endTime, relativeTime } = renderWithSearch(
'ruleId=1&relativeTime=1y&startTime=1000&endTime=2000',
);
expect({ startTime, endTime, relativeTime }).toStrictEqual({
startTime: 1000,
endTime: 2000,
relativeTime: null,
});
});
});

View File

@@ -29,27 +29,21 @@ import {
type RuletypesPostableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { TabRoutes } from 'components/RouteTab/types';
import { QueryParams } from 'constants/query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import AlertHistory from 'container/AlertHistory';
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import {
computeCursorForPage,
useTimelineTableOrder,
useTimelineTablePage,
} from 'container/AlertHistory/Timeline/Table/useTimelineTableCursor';
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
import { TimelineFilter } from 'container/AlertHistory/types';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import { buildRoutePath } from 'lib/router/buildRoutePath';
import GetMinMax, { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
import { navigate } from 'lib/router/navigation';
import { History, Table } from '@signozhq/icons';
import EditRules from 'pages/EditRules';
import BetaTag from 'periscope/components/BetaTag/BetaTag';
import { useAlertRule } from 'providers/Alert';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
@@ -63,6 +57,7 @@ export const useAlertHistoryQueryParams = (): {
startTime: number;
endTime: number;
hasStartAndEndParams: boolean;
relativeTime: string | null;
params: URLSearchParams;
} => {
const params = useUrlQuery();
@@ -72,8 +67,9 @@ export const useAlertHistoryQueryParams = (): {
const relativeTimeParam = params.get(QueryParams.relativeTime);
const relativeTime =
(relativeTimeParam === 'null' ? null : relativeTimeParam) ||
DEFAULT_TIME_RANGE;
relativeTimeParam && isValidShortHandDateTimeFormat(relativeTimeParam)
? relativeTimeParam
: DEFAULT_TIME_RANGE;
const intStartTime = parseInt(startTime || '0', 10);
const intEndTime = parseInt(endTime || '0', 10);
@@ -93,63 +89,10 @@ export const useAlertHistoryQueryParams = (): {
startTime: Math.floor(nanoToMilli(minTime)),
endTime: Math.floor(nanoToMilli(maxTime)),
hasStartAndEndParams,
relativeTime: hasStartAndEndParams ? null : relativeTime,
params,
};
};
export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
const urlQuery = useUrlQuery();
const getRouteUrl = (tab: AlertDetailsTab): string => {
let route = '';
let params = urlQuery.toString();
const ruleIdKey = QueryParams.ruleId;
const relativeTimeKey = QueryParams.relativeTime;
switch (tab) {
case AlertDetailsTab.OVERVIEW:
route = ROUTES.ALERT_OVERVIEW;
break;
case AlertDetailsTab.HISTORY:
params = `${ruleIdKey}=${urlQuery.get(
ruleIdKey,
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
route = ROUTES.ALERT_HISTORY;
break;
default:
return '';
}
return `${buildRoutePath(route)}?${params}`;
};
const routes = [
{
Component: EditRules,
name: (
<div className="tab-item" data-testid="alert-details-tab-overview">
<Table size={14} />
Overview
</div>
),
route: getRouteUrl(AlertDetailsTab.OVERVIEW),
key: ROUTES.ALERT_OVERVIEW,
},
{
Component: AlertHistory,
name: (
<div className="tab-item" data-testid="alert-details-tab-history">
<History size={14} />
History
<BetaTag />
</div>
),
route: getRouteUrl(AlertDetailsTab.HISTORY),
key: ROUTES.ALERT_HISTORY,
},
];
return { routes };
};
type Props = {
ruleId: string | null;
isValidRuleId: boolean;
@@ -356,12 +299,7 @@ export const useTimelineTable = ({
nextCursor?: string;
}): {
paginationConfig: TablePaginationConfig;
onChangeHandler: (
pagination: TablePaginationConfig,
sorter: any,
filters: any,
extra: any,
) => void;
onChangeHandler: TableProps<AlertRuleTimelineTableResponse>['onChange'];
handleNextPage: () => void;
handlePrevPage: () => void;
hasNextPage: boolean;
@@ -448,8 +386,11 @@ export const useAlertRuleStatusToggle = ({
{
onSuccess: (data) => {
setAlertRuleState(data.data.state);
invalidateGetRuleByID(queryClient, { id: ruleId });
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
void invalidateGetRuleByID(queryClient, { id: ruleId });
void queryClient.refetchQueries([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
ruleId,
]);
notifications.success({
message: `Alert has been ${
data.data.state === 'disabled' ? 'disabled' : 'enabled'
@@ -457,8 +398,11 @@ export const useAlertRuleStatusToggle = ({
});
},
onError: (error) => {
invalidateGetRuleByID(queryClient, { id: ruleId });
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
void invalidateGetRuleByID(queryClient, { id: ruleId });
void queryClient.refetchQueries([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
ruleId,
]);
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);

View File

@@ -0,0 +1,65 @@
import { TabRoutes } from 'components/RouteTab/types';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import AlertHistory from 'container/AlertHistory';
import { AlertDetailsTab } from 'container/AlertHistory/types';
import useUrlQuery from 'hooks/useUrlQuery';
import { buildRoutePath } from 'lib/router/buildRoutePath';
import { History, Table } from '@signozhq/icons';
import EditRules from 'pages/EditRules';
import BetaTag from 'periscope/components/BetaTag/BetaTag';
export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
const urlQuery = useUrlQuery();
const getRouteUrl = (tab: AlertDetailsTab): string => {
let route = '';
let params = urlQuery.toString();
const ruleIdKey = QueryParams.ruleId;
const relativeTimeKey = QueryParams.relativeTime;
switch (tab) {
case AlertDetailsTab.OVERVIEW:
route = ROUTES.ALERT_OVERVIEW;
break;
case AlertDetailsTab.HISTORY:
params = `${ruleIdKey}=${urlQuery.get(
ruleIdKey,
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
route = ROUTES.ALERT_HISTORY;
break;
default:
return '';
}
return `${buildRoutePath(route)}?${params}`;
};
const routes = [
{
Component: EditRules,
name: (
<div className="tab-item" data-testid="alert-details-tab-overview">
<Table size={14} />
Overview
</div>
),
route: getRouteUrl(AlertDetailsTab.OVERVIEW),
key: ROUTES.ALERT_OVERVIEW,
},
{
Component: AlertHistory,
name: (
<div className="tab-item" data-testid="alert-details-tab-history">
<History size={14} />
History
<BetaTag />
</div>
),
route: getRouteUrl(AlertDetailsTab.HISTORY),
key: ROUTES.ALERT_HISTORY,
},
];
return { routes };
};

View File

@@ -3,10 +3,8 @@ package querier
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"slices"
"strconv"
"strings"
"time"
@@ -257,10 +255,6 @@ func (q *builderQuery[T]) Execute(ctx context.Context) (*qbtypes.Result, error)
if q.kind == qbtypes.RequestTypeRaw && q.isWindowList() {
return q.executeWindowList(ctx)
}
if q.kind == qbtypes.RequestTypeRaw && strings.TrimSpace(q.spec.Cursor) != "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "cursor needs a raw query ordered by timestamp first").
WithSuggestions("order logs by timestamp then id, both in one direction, and traces by timestamp")
}
fromMS, toMS := q.fromMS, q.toMS
if q.spec.Signal == telemetrytypes.SignalTraces || q.spec.Signal == telemetrytypes.SignalLogs {
@@ -523,50 +517,20 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
isAsc := len(q.spec.Order) > 0 &&
strings.ToLower(string(q.spec.Order[0].Direction.StringValue())) == "asc"
// A cursor names one row only when the order ends in the signal's tie-break
// columns. Logs already order by timestamp then id. A traces order of
// timestamp alone gets trace_id and span_id.
rowKeyed := q.spec.Signal == telemetrytypes.SignalLogs || len(q.spec.Order) == 1
if q.spec.Signal == telemetrytypes.SignalTraces && rowKeyed {
direction := q.spec.Order[0].Direction
q.spec.Order = append(slices.Clip(q.spec.Order),
qbtypes.OrderBy{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: tracestelemetryschema.SpanTraceIDColumn, FieldContext: telemetrytypes.FieldContextSpan}}, Direction: direction},
qbtypes.OrderBy{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: tracestelemetryschema.SpanSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan}}, Direction: direction},
)
}
fromMS, toMS := q.fromMS, q.toMS
// Adjust [fromMS,toMS] window if a cursor was supplied
if cur := strings.TrimSpace(q.spec.Cursor); cur != "" {
after, legacyMS, err := decodeCursor(cur)
if err != nil {
return nil, err
}
switch {
case after == nil:
// a cursor issued before the row key moves the window edge as it always did
if ts, err := decodeCursor(cur); err == nil {
if isAsc {
if uint64(legacyMS) >= fromMS {
fromMS = uint64(legacyMS + 1)
if uint64(ts) >= fromMS {
fromMS = uint64(ts + 1)
}
} else { // DESC
if uint64(legacyMS) <= toMS {
toMS = uint64(legacyMS - 1)
if uint64(ts) <= toMS {
toMS = uint64(ts - 1)
}
}
case !rowKeyed || (q.spec.Signal == telemetrytypes.SignalLogs && after.ID == "") || (q.spec.Signal == telemetrytypes.SignalTraces && (after.TraceID == "" || after.SpanID == "")):
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "cursor does not belong to this query").
WithSuggestions("send the nextCursor with the same signal and order as the query that returned it")
default:
q.spec.StartAfter = after
// the window keeps the whole boundary millisecond; StartAfter cuts inside it
boundaryMS := after.TimeUnixNano / uint64(time.Millisecond)
if isAsc {
fromMS = max(fromMS, boundaryMS)
} else {
toMS = min(toMS, boundaryMS+1)
}
}
}
@@ -673,29 +637,8 @@ func (q *builderQuery[T]) executeWindowList(ctx context.Context) (*qbtypes.Resul
nextCursor := ""
if len(rows) == reqLimit {
last := rows[len(rows)-1]
if rowKeyed {
key := qbtypes.RawRowKey{TimeUnixNano: uint64(last.Timestamp.UnixNano())}
var ok bool
if q.spec.Signal == telemetrytypes.SignalLogs {
key.ID, ok = last.Data["id"].(string)
} else {
key.TraceID, ok = last.Data["trace_id"].(string)
if ok {
key.SpanID, ok = last.Data["span_id"].(string)
}
}
if !ok {
return nil, errors.NewInternalf(errors.CodeInternal, "the last %s row has no order key for the cursor", q.spec.Signal.StringValue())
}
data, err := json.Marshal(key)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to encode the cursor")
}
nextCursor = base64.RawURLEncoding.EncodeToString(data)
} else {
nextCursor = encodeCursor(last.Timestamp.UnixMilli())
}
lastTS := rows[len(rows)-1].Timestamp.UnixMilli()
nextCursor = encodeCursor(lastTS)
}
return &qbtypes.Result{
@@ -719,23 +662,10 @@ func encodeCursor(tsMilli int64) string {
return base64.StdEncoding.EncodeToString([]byte(strconv.FormatInt(tsMilli, 10)))
}
// decodeCursor reads a raw nextCursor. A cursor issued before the row key
// holds only the last row's millisecond; it comes back as legacyMS.
func decodeCursor(cursor string) (*qbtypes.RawRowKey, int64, error) {
if data, err := base64.StdEncoding.DecodeString(cursor); err == nil {
if legacyMS, err := strconv.ParseInt(string(data), 10, 64); err == nil {
return nil, legacyMS, nil
}
func decodeCursor(cur string) (int64, error) {
b, err := base64.StdEncoding.DecodeString(cur)
if err != nil {
return 0, err
}
after := &qbtypes.RawRowKey{}
data, err := base64.RawURLEncoding.DecodeString(cursor)
if err == nil {
err = json.Unmarshal(data, after)
}
if err != nil || after.TimeUnixNano == 0 {
return nil, 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid cursor %q", cursor).
WithSuggestions("send the nextCursor of the previous page unchanged")
}
return after, 0, nil
return strconv.ParseInt(string(b), 10, 64)
}

View File

@@ -1,20 +0,0 @@
package querybuilder
import "github.com/huandu/go-sqlbuilder"
// RowsAfter returns the condition for the rows strictly after one row in a
// lexicographic order over columns, with every column in the same direction.
// values holds that row's value for each column. The caller keeps its time
// window, so granule pruning does not depend on this condition.
func RowsAfter(sb *sqlbuilder.SelectBuilder, columns []string, values []any, descending bool) string {
compare := sb.G
if descending {
compare = sb.L
}
last := len(columns) - 1
condition := compare(columns[last], values[last])
for i := last - 1; i >= 0; i-- {
condition = sb.Or(compare(columns[i], values[i]), sb.And(sb.E(columns[i], values[i]), condition))
}
return condition
}

View File

@@ -393,13 +393,6 @@ func (b *logQueryStatementBuilder) buildListQuery(
return nil, err
}
if after := query.StartAfter; after != nil {
sb.Where(querybuilder.RowsAfter(sb,
[]string{logstelemetryschema.LogsV2TimestampColumn, logstelemetryschema.LogsV2IDColumn},
[]any{fmt.Sprintf("%d", after.TimeUnixNano), after.ID},
query.Order[0].Direction == qbtypes.OrderDirectionDesc))
}
// Add order by
for _, orderBy := range query.Order {

View File

@@ -404,13 +404,6 @@ func (b *traceQueryStatementBuilder) buildListQuery(
return nil, err
}
if after := query.StartAfter; after != nil {
sb.Where(querybuilder.RowsAfter(sb,
[]string{tracestelemetryschema.SpanTimestampColumn, tracestelemetryschema.SpanTraceIDColumn, tracestelemetryschema.SpanSpanIDColumn},
[]any{fmt.Sprintf("%d", after.TimeUnixNano), after.TraceID, after.SpanID},
query.Order[0].Direction == qbtypes.OrderDirectionDesc))
}
// Add order by
for _, orderBy := range query.Order {
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &orderBy.Key.TelemetryFieldKey, telemetrytypes.FieldDataTypeUnspecified, keys)

View File

@@ -53,20 +53,6 @@ type QueryBuilderQuery[T any] struct {
// ShiftBy is extracted from timeShift function for internal use
// This field is not serialized to JSON
ShiftBy int64 `json:"-"`
// StartAfter is the row a raw page continues from, decoded from Cursor.
// The page holds only rows strictly after it in the requested order.
StartAfter *RawRowKey `json:"-"`
}
// RawRowKey is the full order key of one raw row: the timestamp, then the
// signal's tie-break columns, id for logs, trace_id and span_id for traces.
// It is the content of a raw nextCursor.
type RawRowKey struct {
TimeUnixNano uint64 `json:"timeUnixNano"`
ID string `json:"id,omitempty"`
TraceID string `json:"traceId,omitempty"`
SpanID string `json:"spanId,omitempty"`
}
// PrepareJSONSchema pins `signal` to the single value implied by the aggregation
@@ -157,11 +143,6 @@ func (q QueryBuilderQuery[T]) Copy() QueryBuilderQuery[T] {
c.BucketOptions = &bucketOptionsCopy
}
if q.StartAfter != nil {
startAfterCopy := *q.StartAfter
c.StartAfter = &startAfterCopy
}
return c
}

View File

@@ -437,13 +437,6 @@ func (q *QueryBuilderQuery[T]) validateLimitAndPagination(cfg validationConfig)
)
}
// the cursor already starts after the last row returned, so an offset
// would skip rows nobody has seen
if q.Cursor != "" && q.Offset > 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be combined with cursor, got offset %d", q.Offset).
WithSuggestions("send offset 0 with the cursor; the cursor continues after the last row of the previous page")
}
return nil
}

View File

@@ -82,7 +82,6 @@ class BuilderQuery:
query_type: str = "builder_query"
limit: int | None = None
offset: int | None = None
cursor: str | None = None
filter_expression: str | None = None
having_expression: str | None = None
select_fields: list[TelemetryFieldKey] | None = None
@@ -102,8 +101,6 @@ class BuilderQuery:
spec["limit"] = self.limit
if self.offset is not None:
spec["offset"] = self.offset
if self.cursor:
spec["cursor"] = self.cursor
if self.filter_expression:
spec["filter"] = {"expression": self.filter_expression}
if self.having_expression:

View File

@@ -9,12 +9,7 @@ from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logs import Logs
from fixtures.querier import (
BuilderQuery,
OrderBy,
RequestType,
TelemetryFieldKey,
assert_identical_query_response,
get_rows,
make_query_request,
)
@@ -503,103 +498,3 @@ def test_logs_list_with_order_by(
ids = [row["data"]["attributes_string"].get("id", "") for row in rows]
assert ids == expected_order
@pytest.mark.parametrize("direction", ["desc", "asc"])
def test_logs_list_cursor_pages_every_row_once(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
direction: str,
) -> None:
"""
Setup:
7 logs: one alone in its millisecond at each end, 5 inside one millisecond,
3 of them at the same instant.
Tests:
Paging by nextCursor with limit 2 returns every log once, ordered by
timestamp then id, although page boundaries fall inside the shared
millisecond and inside the shared instant.
"""
now = datetime.now(tz=UTC).replace(microsecond=0)
shared = now - timedelta(seconds=5) + timedelta(microseconds=300)
instants = [
now - timedelta(seconds=6),
shared,
shared + timedelta(microseconds=1),
shared + timedelta(microseconds=2),
shared + timedelta(microseconds=2),
shared + timedelta(microseconds=2),
now - timedelta(seconds=4),
]
logs = [Logs(timestamp=instant, resources={"service.name": "cursor-service"}, body=f"log-{i}") for i, instant in enumerate(instants)]
insert_logs(logs)
expected = [log.id for log in sorted(logs, key=lambda log: (int(log.timestamp), log.id), reverse=direction == "desc")]
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seen: list[str] = []
cursor = None
for _ in range(len(logs) + 1):
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=1)).timestamp() * 1000),
end_ms=int((now + timedelta(seconds=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
BuilderQuery(
signal="logs",
limit=2,
cursor=cursor,
filter_expression="service.name = 'cursor-service'",
order=[OrderBy(TelemetryFieldKey("timestamp"), direction), OrderBy(TelemetryFieldKey("id"), direction)],
).to_dict()
],
)
assert response.status_code == HTTPStatus.OK, response.text
seen += [row["data"]["id"] for row in get_rows(response)]
cursor = response.json()["data"]["data"]["results"][0].get("nextCursor")
if not cursor:
break
assert seen == expected
@pytest.mark.parametrize(
"order,offset,cursor,message",
[
pytest.param(["timestamp", "id"], 2, "MTc5MzA0MDgzNjc5NQ==", "offset cannot be combined with cursor, got offset 2", id="offset_with_cursor"),
pytest.param(["timestamp"], 0, "MTc5MzA0MDgzNjc5NQ==", "cursor needs a raw query ordered by timestamp first", id="order_without_id"),
pytest.param(["timestamp", "id"], 0, "not-a-cursor", 'invalid cursor "not-a-cursor"', id="invalid_cursor"),
],
)
def test_logs_list_cursor_rejections(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
order: list[str],
offset: int,
cursor: str,
message: str,
) -> None:
now = datetime.now(tz=UTC)
response = make_query_request(
signoz,
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
BuilderQuery(
signal="logs",
limit=2,
offset=offset,
cursor=cursor,
order=[OrderBy(TelemetryFieldKey(name), "desc") for name in order],
).to_dict()
],
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert message in response.json()["error"]["message"], response.text

View File

@@ -442,3 +442,51 @@ def test_builder_refreshing_a_sliding_time_range(
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_results_equal(from_cache.json(), uncached.json(), "A", "the twentieth refresh")
def test_builder_rows_scanned_when_refreshing(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_rows_scanned_{uuid4().hex[:8]}"
# 90 minutes back so even the last refresh closes clear of the flux
# interval, which holds recent data out of the cache
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=90)).timestamp()) // 60 * 60, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
query = [build_builder_query("A", metric_name, "max", "max")]
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=minute,
type_="Gauge",
is_monotonic=False,
)
for minute in range(70)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# a dashboard left open on a one hour range, re-running a minute later each
# time. rowsScanned is what answering the request cost, so a refresh served
# mostly out of the cache cannot report more than reading its range afresh
for refresh in range(10):
refresh_start_ms = start_time_ms + refresh * MINUTE_MS
from_cache = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 60 * MINUTE_MS, query, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 60 * MINUTE_MS, query, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
rows_scanned_from_cache = from_cache.json()["data"]["meta"]["rowsScanned"]
rows_scanned_uncached = uncached.json()["data"]["meta"]["rowsScanned"]
assert rows_scanned_uncached > 0, f"refresh {refresh}"
assert rows_scanned_from_cache <= rows_scanned_uncached, f"refresh {refresh}: {rows_scanned_from_cache} rows scanned when served from the cache, {rows_scanned_uncached} when read afresh"

View File

@@ -1,4 +1,3 @@
import base64
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
@@ -918,136 +917,6 @@ def test_traces_list_pagination(
assert [row["data"]["span_id"] for row in rows] == expected_ids
@pytest.mark.parametrize("direction", ["desc", "asc"])
def test_traces_list_cursor_pages_every_row_once(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
direction: str,
) -> None:
"""
Setup:
7 spans: one alone in its millisecond at each end, 5 inside one millisecond,
3 of them at the same instant.
Tests:
Paging by nextCursor with limit 2 returns every span once, ordered by
timestamp then trace_id and span_id, although page boundaries fall inside
the shared millisecond and inside the shared instant.
"""
now = datetime.now(tz=UTC).replace(microsecond=0)
shared = now - timedelta(seconds=5) + timedelta(microseconds=300)
instants = [
now - timedelta(seconds=6),
shared,
shared + timedelta(microseconds=1),
shared + timedelta(microseconds=2),
shared + timedelta(microseconds=2),
shared + timedelta(microseconds=2),
now - timedelta(seconds=4),
]
spans = [
Traces(
timestamp=instant,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name=f"span-{i}",
resources={"service.name": "cursor-service"},
)
for i, instant in enumerate(instants)
]
insert_traces(spans)
expected = [span.span_id for span in sorted(spans, key=lambda span: (span.timestamp, span.trace_id, span.span_id), reverse=direction == "desc")]
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
seen: list[str] = []
cursor = None
for _ in range(len(spans) + 1):
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=1)).timestamp() * 1000),
end_ms=int((now + timedelta(seconds=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
BuilderQuery(
signal="traces",
limit=2,
cursor=cursor,
filter_expression="service.name = 'cursor-service'",
order=[OrderBy(TelemetryFieldKey("timestamp"), direction)],
).to_dict()
],
)
assert response.status_code == HTTPStatus.OK, response.text
seen += [row["data"]["span_id"] for row in get_rows(response)]
cursor = response.json()["data"]["data"]["results"][0].get("nextCursor")
if not cursor:
break
assert seen == expected
@pytest.mark.parametrize(
"direction,boundary,expected",
[
pytest.param("desc", 2, [1, 0], id="desc"),
pytest.param("asc", 1, [2, 3], id="asc"),
],
)
def test_traces_list_millisecond_cursor_still_pages(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
direction: str,
boundary: int,
expected: list[int],
) -> None:
"""
Setup:
4 spans 10 ms apart.
Tests:
A cursor in the earlier format, the base64 of a millisecond, still continues
from that millisecond as it did, so a page a client holds before an upgrade
keeps working.
"""
now = datetime.now(tz=UTC).replace(microsecond=0)
spans = [
Traces(
timestamp=now - timedelta(seconds=5) + timedelta(milliseconds=10 * i),
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name=f"span-{i}",
resources={"service.name": "legacy-cursor-service"},
)
for i in range(4)
]
insert_traces(spans)
cursor = base64.b64encode(str(int(spans[boundary].timestamp.timestamp() * 1000)).encode()).decode()
response = make_query_request(
signoz,
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
start_ms=int((now - timedelta(minutes=1)).timestamp() * 1000),
end_ms=int((now + timedelta(seconds=1)).timestamp() * 1000),
request_type=RequestType.RAW,
queries=[
BuilderQuery(
signal="traces",
limit=10,
cursor=cursor,
filter_expression="service.name = 'legacy-cursor-service'",
order=[OrderBy(TelemetryFieldKey("timestamp"), direction)],
).to_dict()
],
)
assert response.status_code == HTTPStatus.OK, response.text
assert [row["data"]["span_id"] for row in get_rows(response)] == [spans[i].span_id for i in expected]
# ============================================================================
# Time window
# ============================================================================