Compare commits

..

4 Commits

Author SHA1 Message Date
Nityananda Gohain
599ae273c9 Merge branch 'main' into issue_5123_2 2026-07-06 09:03:03 -07:00
nityanandagohain
36edbeca38 fix: move error from normalize to validate 2026-07-06 21:32:00 +05:30
Nityananda Gohain
0436655a43 Merge branch 'main' into issue_5123_2 2026-06-08 12:34:18 +05:30
nityanandagohain
301d496092 chore: add normalize for QueryRangeRequest 2026-06-05 12:31:19 +05:30
13 changed files with 183 additions and 589 deletions

View File

@@ -177,13 +177,7 @@ export default function ConversationView({
conversationId={conversationId}
messages={messages}
isStreaming={isStreamingHere}
onSendSuggestedPrompt={(text): void => {
handleSend(
text,
undefined,
autoContexts.length > 0 ? autoContexts : undefined,
);
}}
onSendSuggestedPrompt={(text): void => handleSend(text)}
/>
{showDisclaimer && (
<div className={disclaimerClass} role="note" aria-live="polite">

View File

@@ -1,142 +0,0 @@
import { MemoryRouter } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { fireEvent, render } from '@testing-library/react';
import { MessageContext } from 'api/ai-assistant/chat';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { VariantContext } from 'container/AIAssistant/VariantContext';
const CHIP_ID = 'recent-errors';
const CHIP_TEXT = 'Show me recent errors';
// Auto-derived page context that a normal (typed) send would attach. The chip
// send must forward the exact same array.
const mockAutoContexts: MessageContext[] = [
{
source: 'auto',
type: 'dashboard',
resourceId: 'dashboard-123',
resourceName: 'Checkout dashboard',
},
];
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/getAutoContexts', () => ({
getAutoContexts: jest.fn(() => mockAutoContexts),
}));
jest.mock('container/AIAssistant/hooks/useAIAssistantAnalyticsContext', () => ({
normalizePage: (page: string): string => page,
useAIAssistantAnalyticsContext: (): unknown => ({ threadId: 'thread-1' }),
}));
// ChatInput is heavy and irrelevant here — the chip path lives entirely in the
// empty state. Provide a lightweight stub plus the `autoContextKey` named export
// ConversationView imports for its dismissed-context filter.
jest.mock('container/AIAssistant/components/ChatInput', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="chat-input" />,
autoContextKey: (): string => '',
}));
jest.mock('container/AIAssistant/components/ConversationSkeleton', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="skeleton" />,
}));
// VirtualizedMessages renders the real empty-state chips; stub only its
// never-rendered-in-empty-state children and the virtual list.
jest.mock('components/Noz/Noz', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="noz" />,
}));
jest.mock('container/AIAssistant/components/MessageBubble', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('container/AIAssistant/components/StreamingMessage', () => ({
__esModule: true,
default: (): null => null,
}));
jest.mock('react-virtuoso', () => ({
__esModule: true,
Virtuoso: (): null => null,
}));
jest.mock(
'container/AIAssistant/components/VirtualizedMessages/useEmptyStateChips',
() => ({
useEmptyStateChips: (): { chips: { id: string; text: string }[] } => ({
chips: [{ id: CHIP_ID, text: CHIP_TEXT }],
}),
}),
);
// eslint-disable-next-line import/first
import ConversationView from '../ConversationView';
const CONVERSATION_ID = 'conv-1';
function renderView(variant: 'panel' | 'page' | 'modal'): {
getByTestId: (id: string) => HTMLElement;
} {
return render(
<MemoryRouter initialEntries={['/dashboard/dashboard-123']}>
<VariantContext.Provider value={variant}>
<ConversationView conversationId={CONVERSATION_ID} />
</VariantContext.Provider>
</MemoryRouter>,
);
}
describe('ConversationView — empty-state chip context', () => {
let sendMessage: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
sendMessage = jest.fn();
useAIAssistantStore.setState({
conversations: {
[CONVERSATION_ID]: {
id: CONVERSATION_ID,
messages: [],
createdAt: 1,
updatedAt: 1,
},
},
streams: {},
activeConversationId: CONVERSATION_ID,
isLoadingThread: false,
sendMessage,
} as unknown as Partial<ReturnType<typeof useAIAssistantStore.getState>>);
});
it('forwards the page auto-contexts when a chip is clicked (embedded variant)', () => {
const { getByTestId } = renderView('panel');
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
// The chip send must carry the same auto-contexts a typed message would.
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
CHIP_TEXT,
undefined,
mockAutoContexts,
);
});
it('sends undefined contexts on the standalone page (no page context to attach)', () => {
const { getByTestId } = renderView('page');
fireEvent.click(getByTestId(`empty-state-chip-${CHIP_ID}`));
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(CHIP_TEXT, undefined, undefined);
});
});

View File

@@ -1625,9 +1625,6 @@ export const getHostQueryPayload = (
const diskPendingKey = dotMetricsEnabled
? 'system.disk.pending_operations'
: 'system_disk_pending_operations';
const fsUsageKey = dotMetricsEnabled
? 'system.filesystem.usage'
: 'system_filesystem_usage';
return [
{
@@ -2660,155 +2657,6 @@ export const getHostQueryPayload = (
start,
end,
},
{
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: [
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_filesystem_usage--float64--Gauge--true',
key: fsUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: true,
expression: 'A',
filters: {
items: [
{
id: 'fs_f1',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
key: hostNameKey,
type: 'tag',
},
op: '=',
value: hostName,
},
{
id: 'fs_f2',
key: {
dataType: DataTypes.String,
id: 'state--string--tag--false',
key: 'state',
type: 'tag',
},
op: '=',
value: 'used',
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'mountpoint--string--tag--false',
key: 'mountpoint',
type: 'tag',
},
],
having: [
{
columnName: `SUM(${fsUsageKey})`,
op: '>',
value: 0,
},
],
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
queryName: 'A',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: 'system_filesystem_usage--float64--Gauge--true',
key: fsUsageKey,
type: 'Gauge',
},
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: true,
expression: 'B',
filters: {
items: [
{
id: 'fs_f3',
key: {
dataType: DataTypes.String,
id: 'host_name--string--tag--false',
key: hostNameKey,
type: 'tag',
},
op: '=',
value: hostName,
},
],
op: 'AND',
},
functions: [],
groupBy: [
{
dataType: DataTypes.String,
id: 'mountpoint--string--tag--false',
key: 'mountpoint',
type: 'tag',
},
],
having: [
{
columnName: `SUM(${fsUsageKey})`,
op: '>',
value: 0,
},
],
legend: '{{mountpoint}}',
limit: null,
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'avg',
},
],
queryFormulas: [
{
disabled: false,
expression: 'A/B',
legend: '{{mountpoint}}',
queryName: 'F1',
},
],
queryTraceOperator: [],
},
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
},
];
};
@@ -2884,5 +2732,4 @@ export const hostWidgetInfo = [
{ title: 'System disk operations/s', yAxisUnit: 'short' },
{ title: 'Queue size', yAxisUnit: 'short' },
{ title: 'System disk operation time/s', yAxisUnit: 's' },
{ title: 'Disk Usage (%) by mountpoint', yAxisUnit: 'percentunit' },
];

View File

@@ -26,8 +26,8 @@ export default function AIAssistantPage(): JSX.Element {
// Skip the mount-time Opened fire when the user expanded an already-open
// drawer/modal — that surface already emitted Opened with the right source.
// Router state (vs a module flag) survives page remounts and aborted
// navigations.
// Router state (vs a module flag) survives StrictMode double-mount and
// aborted navigations.
const fromInApp = location.state?.fromInApp === true;
useEffect(() => {
if (fromInApp) {
@@ -52,34 +52,18 @@ export default function AIAssistantPage(): JSX.Element {
(s) => s.startNewConversation,
);
// Keep refs so the effect can read the latest store state without re-firing
// when it mutates the store mid-effect (it only depends on the URL param).
// Keep a ref so the effect can read latest conversations without re-firing
// when startNewConversation mutates the store mid-effect.
const conversationsRef = useRef(conversations);
conversationsRef.current = conversations;
const activeConversationIdRef = useRef(activeConversationId);
activeConversationIdRef.current = activeConversationId;
useEffect(() => {
// URL points at a known conversation → just activate it.
if (conversationId && conversationsRef.current[conversationId]) {
if (conversationsRef.current[conversationId]) {
setActiveConversation(conversationId);
return;
} else {
const newId = startNewConversation();
history.replace(ROUTES.AI_ASSISTANT.replace(':conversationId', newId));
}
// The URL has no usable conversation id (bare `/ai-assistant`, or a stale
// param). Prefer resuming the active conversation — including the
// rehydrating placeholder for the persisted thread — over minting a new
// one. This is what stops a throwaway blank chat from flashing as a
// second thread during load, and stops a duplicate when the page
// remounts during startup route churn (the active id is already set, so
// we resume instead of create). Starting fresh is the last resort, only
// when there is genuinely nothing to resume.
const activeId = activeConversationIdRef.current;
const resumeId =
activeId && conversationsRef.current[activeId]
? activeId
: startNewConversation();
history.replace(ROUTES.AI_ASSISTANT.replace(':conversationId', resumeId));
// Only re-run when the URL param changes, not when conversations mutates.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId]);

View File

@@ -1,181 +0,0 @@
import { MemoryRouter, Route } from 'react-router-dom';
// eslint-disable-next-line no-restricted-imports
import { render } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/ConversationView', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="conversation-view" />,
}));
jest.mock('container/AIAssistant/components/ConversationsList', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="conversations-list" />,
}));
jest.mock('components/Noz/Noz', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="noz" />,
}));
jest.mock('container/AIAssistant/hooks/useAIAssistantAnalyticsContext', () => ({
normalizePage: (page: string): string => page,
useAIAssistantAnalyticsContext: (): unknown => ({ mode: 'page' }),
}));
// eslint-disable-next-line import/first
import AIAssistantPage from '../AIAssistantPage';
function renderAt(entry: string): { unmount: () => void } {
return render(
<MemoryRouter initialEntries={[entry]}>
<Route
exact
path={[ROUTES.AI_ASSISTANT_BASE, ROUTES.AI_ASSISTANT]}
component={AIAssistantPage}
/>
</MemoryRouter>,
);
}
function renderAtBase(): { unmount: () => void } {
return renderAt(ROUTES.AI_ASSISTANT_BASE);
}
function conversationCount(): number {
return Object.keys(useAIAssistantStore.getState().conversations).length;
}
function conversationIds(): string[] {
return Object.keys(useAIAssistantStore.getState().conversations);
}
function activeId(): string | null {
return useAIAssistantStore.getState().activeConversationId;
}
describe('AIAssistantPage', () => {
beforeEach(() => {
useAIAssistantStore.setState({
conversations: {},
streams: {},
activeConversationId: null,
});
});
it('opens exactly one conversation when navigating to /ai-assistant', () => {
const { unmount } = renderAtBase();
expect(conversationCount()).toBe(1);
unmount();
});
it('does not stack a second conversation when the page remounts at the bare URL (route churn)', () => {
// First mount at `/ai-assistant` creates one blank conversation and
// redirects to `/ai-assistant/:id`.
const { unmount } = renderAtBase();
expect(conversationCount()).toBe(1);
const firstId = conversationIds()[0];
// Startup route-list churn unmounts and remounts the page while the URL
// is momentarily back at the bare `/ai-assistant`. This previously
// created a second blank conversation — now it reuses the first.
unmount();
const { unmount: unmount2 } = renderAtBase();
expect(conversationCount()).toBe(1);
// The surviving conversation is the original one, resumed — not a fresh mint.
expect(conversationIds()).toStrictEqual([firstId]);
expect(activeId()).toBe(firstId);
unmount2();
});
it('activates the conversation named in the URL without creating a new one', () => {
useAIAssistantStore.setState({
conversations: {
existing: {
id: 'existing',
messages: [],
createdAt: 1,
updatedAt: 1,
},
},
streams: {},
activeConversationId: null,
});
const { unmount } = renderAt(
ROUTES.AI_ASSISTANT.replace(':conversationId', 'existing'),
);
expect(conversationCount()).toBe(1);
expect(activeId()).toBe('existing');
unmount();
});
it('resumes the active conversation on /ai-assistant/new instead of minting a new one', () => {
// The sidenav only routes to `/ai-assistant/new` as a fallback, but if an
// active conversation exists the page must resume it rather than spawn a
// throwaway blank thread for the unknown "new" param.
useAIAssistantStore.setState({
conversations: {
active: {
id: 'active',
messages: [],
createdAt: 1,
updatedAt: 1,
},
},
streams: {},
activeConversationId: 'active',
});
const { unmount } = renderAt(
ROUTES.AI_ASSISTANT.replace(':conversationId', 'new'),
);
expect(conversationCount()).toBe(1);
expect(conversationIds()).toStrictEqual(['active']);
expect(activeId()).toBe('active');
unmount();
});
it('resumes the persisted (hydrating) conversation during load instead of creating a second', () => {
// Simulates `onRehydrateStorage` priming the persisted active
// conversation as a hydrating placeholder before `fetchThreads` resolves.
useAIAssistantStore.setState({
conversations: {
persisted: {
id: 'persisted',
messages: [],
createdAt: 1,
updatedAt: 1,
isHydrating: true,
},
},
streams: {},
activeConversationId: 'persisted',
});
const { unmount } = renderAtBase();
// Opening the bare URL must resume the persisted conversation, not mint a
// throwaway blank alongside it (which flashed as a 2nd thread during load).
expect(conversationCount()).toBe(1);
expect(
Object.keys(useAIAssistantStore.getState().conversations),
).toStrictEqual(['persisted']);
unmount();
});
});

View File

@@ -29,7 +29,7 @@ type module struct {
}
func NewModule(store llmpricingruletypes.Store, flagger flagger.Flagger, querier querier.Querier) llmpricingrule.Module {
return &module{store: store, flagger: flagger, querier: querier}
return &module{store: store, flagger: flagger}
}
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {

View File

@@ -178,6 +178,8 @@ func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request
return
}
queryRangeRequest.Normalize()
// Validate the query request
if err := queryRangeRequest.Validate(); err != nil {
render.Error(rw, err)

View File

@@ -91,11 +91,10 @@ func New(
func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error) {
// Coerce the window to epoch milliseconds up front so every downstream
// consumer (TimeRange, narrowWindowByTraceID, step interval, etc.) can
// safely assume ms regardless of the resolution the caller sent.
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
// Normalize Start/End to ms. UnmarshalJSON covers HTTP requests; callers
// that build the request programmatically skip it, so this is the catch-all
// (idempotent for the already-normalized path).
req.Normalize()
event := &qbtypes.QBEvent{
Version: "v5",
@@ -456,10 +455,9 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
// Coerce the window to epoch milliseconds up front (End may be 0 for the
// open-ended stream, which ToMilliSecs leaves untouched).
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
// Catch-all normalization for programmatic callers (see QueryRange). End is
// 0 here for the open-ended stream, which Normalize leaves untouched.
req.Normalize()
event := &qbtypes.QBEvent{
Version: "v5",

View File

@@ -33,28 +33,6 @@ func ToNanoSecs(epoch uint64) uint64 {
return temp * uint64(math.Pow(10, float64(19-count)))
}
// ToMilliSecs takes an epoch whose resolution is inferred from its magnitude
// (s/ms/µs/ns) and returns it in milliseconds. A millisecond epoch for the
// current era has 13 digits (e.g. ~1.7e12 in 2026), so the value is scaled so
// its digit-width matches: smaller values (seconds) are scaled up, larger ones
// (micro/nanoseconds) are scaled down. Zero is returned unchanged.
func ToMilliSecs(epoch uint64) uint64 {
if epoch == 0 {
return 0
}
temp := epoch
count := 0
for epoch != 0 {
epoch /= 10
count++
}
const msDigits = 13
if count < msDigits {
return temp * uint64(math.Pow(10, float64(msDigits-count)))
}
return temp / uint64(math.Pow(10, float64(count-msDigits)))
}
// TODO(srikanthccv): should these be rounded to nearest multiple of 60 instead of 5 if step > 60?
// That would make graph look nice but "nice" but should be less important than the usefulness.
func RecommendedStepInterval(start, end uint64) uint64 {

View File

@@ -60,51 +60,3 @@ func TestToNanoSecs(t *testing.T) {
})
}
}
func TestToMilliSecs(t *testing.T) {
tests := []struct {
name string
epoch uint64
expected uint64
}{
{
name: "10-digit Unix timestamp (seconds) - 2023-01-01 00:00:00 UTC",
epoch: 1672531200, // seconds
expected: 1672531200000, // * 10^3
},
{
name: "13-digit Unix timestamp (milliseconds) - already ms",
epoch: 1672531200000,
expected: 1672531200000, // unchanged
},
{
name: "16-digit Unix timestamp (microseconds)",
epoch: 1672531200000000, // microseconds
expected: 1672531200000, // / 10^3
},
{
name: "19-digit Unix timestamp (nanoseconds)",
epoch: 1672531200000000000, // nanoseconds
expected: 1672531200000, // / 10^6
},
{
name: "Unix epoch start - zero is unchanged",
epoch: 0,
expected: 0,
},
{
name: "Recent timestamp in seconds - 2024-05-25 12:00:00 UTC",
epoch: 1716638400,
expected: 1716638400000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ToMilliSecs(tt.epoch)
if result != tt.expected {
t.Errorf("ToMilliSecs(%d) = %d, want %d", tt.epoch, result, tt.expected)
}
})
}
}

View File

@@ -614,7 +614,14 @@ func (r *QueryRangeRequest) SkipFillGaps(name string) bool {
return false
}
// UnmarshalJSON implements custom JSON unmarshaling to disallow unknown fields.
// Normalize coerces Start and End to epoch milliseconds, inferring the source
// resolution (s/ms/µs/ns) from each value's magnitude. Lets downstream consumers
// assume ms regardless of what the caller sent. This is a pure transformation;
// range validation of the resulting values lives in Validate.
func (r *QueryRangeRequest) Normalize() {
r.Start, r.End = toMilliSecs(r.Start), toMilliSecs(r.End)
}
func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Define a type alias to avoid infinite recursion
type Alias QueryRangeRequest
@@ -655,6 +662,10 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Copy the decoded values back to the original struct
*r = QueryRangeRequest(temp)
// Coerce Start/End to ms at decode time for HTTP requests. Range validation
// of the coerced values happens later in Validate.
r.Normalize()
return nil
}
@@ -708,3 +719,19 @@ func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
return canDefaultZero
}
// toMilliSecs scales an epoch to milliseconds based on its magnitude: seconds are
// scaled up, micro/nanoseconds down, milliseconds left as-is. Zero is returned
// unchanged.
func toMilliSecs(epoch uint64) uint64 {
switch {
case epoch < 1e12: // seconds
return epoch * 1_000
case epoch < 1e15: // milliseconds
return epoch
case epoch < 1e18: // microseconds
return epoch / 1_000
default: // nanoseconds
return epoch / 1_000_000
}
}

View File

@@ -1903,3 +1903,114 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
})
}
}
func TestQueryRangeRequest_Normalize(t *testing.T) {
tests := []struct {
name string
start uint64
end uint64
wantStart uint64
wantEnd uint64
}{
{
name: "seconds are scaled up to ms",
start: 1672531200, // 2023-01-01 in seconds
end: 1716638400, // 2024-05-25 in seconds
wantStart: 1672531200000, // * 10^3
wantEnd: 1716638400000,
},
{
name: "milliseconds pass through unchanged",
start: 1672531200000,
end: 1716638400000,
wantStart: 1672531200000,
wantEnd: 1716638400000,
},
{
name: "microseconds are scaled down to ms",
start: 1672531200000000, // µs
end: 1716638400000000,
wantStart: 1672531200000, // / 10^3
wantEnd: 1716638400000,
},
{
name: "nanoseconds are scaled down to ms",
start: 1672531200000000000, // ns
end: 1716638400000000000,
wantStart: 1672531200000, // / 10^6
wantEnd: 1716638400000,
},
{
name: "zero end (open-ended stream) is left untouched",
start: 1672531200000,
end: 0,
wantStart: 1672531200000,
wantEnd: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &QueryRangeRequest{Start: tt.start, End: tt.end}
r.Normalize()
assert.Equal(t, tt.wantStart, r.Start)
assert.Equal(t, tt.wantEnd, r.End)
})
}
}
func TestQueryRangeRequest_ValidateTimeRange(t *testing.T) {
tests := []struct {
name string
start uint64
end uint64
wantErr bool
}{
{
name: "in-range ms timestamps pass",
start: 1672531200000, // 2023-01-01
end: 1716638400000, // 2024-05-25
},
{
name: "start below the 1990 bound is rejected",
start: 1000, // ~1970
end: 1716638400000,
wantErr: true,
},
{
name: "end beyond the 2100 bound is rejected",
start: 1672531200000,
end: 5_000_000_000_000, // ~year 2128 in ms
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &QueryRangeRequest{
Start: tt.start,
End: tt.end,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Signal: telemetrytypes.SignalLogs,
Aggregations: []LogAggregation{{Expression: "count()"}},
},
},
},
},
}
err := r.Validate()
if tt.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), "outside the supported range")
return
}
require.NoError(t, err)
})
}
}

View File

@@ -10,6 +10,13 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
// minEpochMs and maxEpochMs bound a plausible ms timestamp to
// 1990-01-01 .. 2100-01-01, used to reject malformed Start/End values.
minEpochMs uint64 = 631_152_000_000
maxEpochMs uint64 = 4_102_444_800_000
)
// getQueryIdentifier returns a friendly identifier for a query based on its type and name/content.
func getQueryIdentifier(envelope QueryEnvelope, index int) string {
name := envelope.GetQueryName()
@@ -527,7 +534,24 @@ func (q *QueryBuilderQuery[T]) validateOrderByForAggregation() error {
// Validate validates the entire query range request.
func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
// Validate time range
// Validate time range. Start/End are assumed to be normalized to ms (see
// QueryRangeRequest.Normalize); reject non-zero values outside the plausible
// 1990-2100 range as malformed.
if r.Start != 0 && (r.Start < minEpochMs || r.Start > maxEpochMs) {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"start timestamp %d is outside the supported range (1990-2100)",
r.Start,
)
}
if r.End != 0 && (r.End < minEpochMs || r.End > maxEpochMs) {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"end timestamp %d is outside the supported range (1990-2100)",
r.End,
)
}
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,