Compare commits
10 Commits
tvats-quer
...
fix/compos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a570b1d31 | ||
|
|
555cb142db | ||
|
|
a6b6655e35 | ||
|
|
330b41f0ed | ||
|
|
e1b717fe36 | ||
|
|
d2565071e1 | ||
|
|
2139580419 | ||
|
|
9ba2a25eb9 | ||
|
|
30839d390f | ||
|
|
2483c41452 |
8
.github/CODEOWNERS
vendored
@@ -290,6 +290,14 @@ go.mod @therealpandey
|
||||
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
|
||||
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
|
||||
|
||||
## Saved Views
|
||||
/frontend/src/container/SavedViews/ @aks07
|
||||
/frontend/src/container/Home/SavedViews/ @aks07
|
||||
/frontend/src/hooks/saveViews/ @aks07
|
||||
|
||||
## Bottom Strip
|
||||
/frontend/src/container/BottomStrip/ @aks07
|
||||
|
||||
## Storybook
|
||||
/frontend/.storybook/ @H4ad
|
||||
/frontend/src/storybook/ @H4ad
|
||||
|
||||
@@ -121,8 +121,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
func(_ context.Context, _ factory.ProviderSettings, _ flagger.Flagger, _ licensing.Licensing, _ telemetrystore.TelemetryStore, _ retention.Getter, _ organization.Getter, _ zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string) {
|
||||
return signoz.NewMeterReporterProviderFactories(), "noop"
|
||||
},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics, cfg querier.Config) querier.Handler {
|
||||
return querier.NewHandler(ps, q, a, cfg)
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
return querier.NewHandler(ps, q, a)
|
||||
},
|
||||
func(_ sqlstore.SQLStore, _ dashboard.Module, _ global.Global, _ zeus.Zeus, _ gateway.Gateway, _ licensing.Licensing, _ serviceaccount.Module, _ cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
return implcloudintegration.NewModule(), nil
|
||||
|
||||
@@ -172,9 +172,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return factories, "http"
|
||||
},
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics, cfg querier.Config) querier.Handler {
|
||||
communityHandler := querier.NewHandler(ps, q, a, cfg)
|
||||
return eequerier.NewHandler(ps, q, communityHandler, cfg)
|
||||
func(ps factory.ProviderSettings, q querier.Querier, a analytics.Analytics) querier.Handler {
|
||||
communityHandler := querier.NewHandler(ps, q, a)
|
||||
return eequerier.NewHandler(ps, q, communityHandler)
|
||||
},
|
||||
func(sqlStore sqlstore.SQLStore, dashboardModule dashboard.Module, global global.Global, zeus zeus.Zeus, gateway gateway.Gateway, licensing licensing.Licensing, serviceAccount serviceaccount.Module, config cloudintegration.Config) (cloudintegration.Module, error) {
|
||||
defStore := pkgcloudintegration.NewServiceDefinitionStore()
|
||||
|
||||
@@ -178,8 +178,6 @@ querier:
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
log_trace_id_window_padding: 5m
|
||||
# The maximum time range of a query range request. Set to 0 to disable.
|
||||
max_time_range: 17520h
|
||||
|
||||
##################### TelemetryStore #####################
|
||||
telemetrystore:
|
||||
|
||||
@@ -21,15 +21,13 @@ type handler struct {
|
||||
set factory.ProviderSettings
|
||||
querier querier.Querier
|
||||
community querier.Handler
|
||||
config querier.Config
|
||||
}
|
||||
|
||||
func NewHandler(set factory.ProviderSettings, querier querier.Querier, community querier.Handler, config querier.Config) querier.Handler {
|
||||
func NewHandler(set factory.ProviderSettings, querier querier.Querier, community querier.Handler) querier.Handler {
|
||||
return &handler{
|
||||
set: set,
|
||||
querier: querier,
|
||||
community: community,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +53,7 @@ func (h *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := queryRangeRequest.Validate(qbtypes.WithMaxTimeRange(h.config.MaxTimeRange)); err != nil {
|
||||
if err := queryRangeRequest.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
BIN
frontend/src/assets/Images/users/anselm.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/src/assets/Images/users/daniel.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src/assets/Images/users/dhruv-garg.webp
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/src/assets/Images/users/go-frendi.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src/assets/Images/users/pranay-narang.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
frontend/src/assets/Images/users/shey.webp
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
frontend/src/assets/Images/users/subomi-oluwalana.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src/assets/Images/users/vivek-bhakta.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
@@ -2,6 +2,7 @@ export const GlobalShortcuts = {
|
||||
NavigateToServices: 'shift+s',
|
||||
NavigateToDashboards: 'shift+d',
|
||||
NavigateToAlerts: 'shift+a',
|
||||
NavigateToNotificationChannels: 'shift+g+n',
|
||||
NavigateToExceptions: 'shift+e',
|
||||
NavigateToMessagingQueues: 'shift+q',
|
||||
ToggleSidebar: 'shift+b',
|
||||
@@ -23,7 +24,6 @@ export const GlobalShortcuts = {
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToSettingsServiceAccounts: 'shift+g+k',
|
||||
NavigateToSettingsRoles: 'shift+g+r',
|
||||
NavigateToSettingsMembers: 'shift+g+m',
|
||||
@@ -34,6 +34,7 @@ export const GlobalShortcutsName = {
|
||||
NavigateToTraces: 'shift+t',
|
||||
NavigateToDashboards: 'shift+d',
|
||||
NavigateToAlerts: 'shift+a',
|
||||
NavigateToNotificationChannels: 'shift+g+n',
|
||||
NavigateToExceptions: 'shift+e',
|
||||
NavigateToMessagingQueues: 'shift+q',
|
||||
ToggleSidebar: 'shift+b',
|
||||
@@ -44,7 +45,6 @@ export const GlobalShortcutsName = {
|
||||
NavigateToSettings: 'shift+g',
|
||||
NavigateToSettingsIngestion: 'shift+g+i',
|
||||
NavigateToSettingsBilling: 'shift+g+b',
|
||||
NavigateToSettingsNotificationChannels: 'shift+g+n',
|
||||
NavigateToSettingsServiceAccounts: 'shift+g+k',
|
||||
NavigateToSettingsRoles: 'shift+g+r',
|
||||
NavigateToSettingsMembers: 'shift+g+m',
|
||||
@@ -59,6 +59,7 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToLogs: 'Navigate to Logs Explorer',
|
||||
NavigateToDashboards: 'Navigate to Dashboards List',
|
||||
NavigateToAlerts: 'Navigate to Alerts List',
|
||||
NavigateToNotificationChannels: 'Navigate to Notification Channels',
|
||||
NavigateToExceptions: 'Navigate to Exceptions List',
|
||||
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
|
||||
ToggleSidebar: 'Toggle sidebar visibility',
|
||||
@@ -68,8 +69,6 @@ export const GlobalShortcutsDescription = {
|
||||
NavigateToSettings: 'Navigate to Settings',
|
||||
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
|
||||
NavigateToSettingsBilling: 'Navigate to Billing Settings',
|
||||
NavigateToSettingsNotificationChannels:
|
||||
'Navigate to Notification Channels Settings',
|
||||
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
|
||||
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
|
||||
NavigateToSettingsRoles: 'Navigate to Roles Settings',
|
||||
|
||||
@@ -65,8 +65,7 @@ import { useAppContext } from 'providers/App/App';
|
||||
import { AppState } from 'store/reducers';
|
||||
import AppReducer from 'types/reducer/app';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { checkVersionState } from 'utils/app';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
import { checkVersionState, isModifierKeyPressed } from 'utils/app';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -714,7 +713,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsBilling, () =>
|
||||
onClickHandler(ROUTES.BILLING, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
|
||||
registerShortcut(GlobalShortcuts.NavigateToNotificationChannels, () =>
|
||||
onClickHandler(ROUTES.ALL_CHANNELS, null),
|
||||
);
|
||||
registerShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts, () =>
|
||||
@@ -744,7 +743,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettings);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToNotificationChannels);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsRoles);
|
||||
deregisterShortcut(GlobalShortcuts.NavigateToSettingsMembers);
|
||||
|
||||
@@ -343,14 +343,6 @@ export const settingsNavSections: SettingsNavSection[] = [
|
||||
isEnabled: true,
|
||||
itemKey: 'account',
|
||||
},
|
||||
// TODO(@SigNoz/pulse-frontend): https://github.com/SigNoz/engineering-pod/issues/5323
|
||||
{
|
||||
key: ROUTES.ALL_CHANNELS,
|
||||
label: 'Notification Channels',
|
||||
icon: <FileKey2 size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'notification-channels',
|
||||
},
|
||||
{
|
||||
key: ROUTES.BILLING,
|
||||
label: 'Billing',
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, initialQueryState } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
const UNCONSTRAINED_ROUTE = '/unconstrained';
|
||||
|
||||
const renderOn = (
|
||||
compositeQuery: string,
|
||||
route: string = UNCONSTRAINED_ROUTE,
|
||||
): Query | null => {
|
||||
const search = new URLSearchParams();
|
||||
search.set('compositeQuery', compositeQuery);
|
||||
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={`${route}?${search}`}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
return result.current;
|
||||
};
|
||||
|
||||
const logsWith = (item: object): string =>
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [{ ...initialQueriesMap.logs.builder.queryData[0], ...item }],
|
||||
},
|
||||
});
|
||||
|
||||
const withHavingConverted = (queryData: object[]): object[] =>
|
||||
queryData.map((item) => ({ ...item, having: { expression: '' } }));
|
||||
|
||||
describe('useGetCompositeQueryParam', () => {
|
||||
it('treats a non-object or broken compositeQuery as no query', () => {
|
||||
['[]', '"x"', '5', 'null', '{not json'].forEach((raw) =>
|
||||
expect(renderOn(raw)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('repairs the builder shape and converts the legacy having', () => {
|
||||
const defaultQueryData = withHavingConverted(
|
||||
initialQueryState.builder.queryData,
|
||||
);
|
||||
[{ a: 1 }, [null, initialQueriesMap.logs.builder.queryData[0]]].forEach(
|
||||
(queryData) =>
|
||||
expect(
|
||||
renderOn(
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
builder: { ...initialQueriesMap.logs.builder, queryData },
|
||||
}),
|
||||
)?.builder.queryData,
|
||||
).toStrictEqual(defaultQueryData),
|
||||
);
|
||||
|
||||
expect(
|
||||
renderOn(logsWith({ having: [null] }))?.builder.queryData[0].having,
|
||||
).toStrictEqual({ expression: '' });
|
||||
expect(renderOn(JSON.stringify(initialQueriesMap.logs))).toStrictEqual({
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: withHavingConverted(initialQueriesMap.logs.builder.queryData),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('gives an id-less query with a filter the same id on every parse', () => {
|
||||
const { id: _, ...withoutId } = JSON.parse(
|
||||
logsWith({ filter: { expression: "service.name='x'" } }),
|
||||
);
|
||||
const first = renderOn(JSON.stringify(withoutId));
|
||||
const again = renderOn(JSON.stringify(withoutId));
|
||||
const other = renderOn(
|
||||
JSON.stringify({ ...withoutId, queryType: EQueryType.PROM }),
|
||||
);
|
||||
|
||||
expect(first?.id).toHaveLength(36);
|
||||
expect(again?.id).toBe(first?.id);
|
||||
expect(other?.id).not.toBe(first?.id);
|
||||
});
|
||||
|
||||
it('applies the page constraints on every constrained route', () => {
|
||||
const clickhouseWithOperator = JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.metrics.builder.queryData[0],
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
},
|
||||
],
|
||||
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
|
||||
},
|
||||
});
|
||||
|
||||
[
|
||||
[ROUTES.LOGS_EXPLORER, DataSource.LOGS, ''],
|
||||
[ROUTES.AI_OBSERVABILITY_EXPLORER, DataSource.TRACES, ''],
|
||||
[ROUTES.METRICS_EXPLORER_EXPLORER, DataSource.METRICS, ''],
|
||||
[ROUTES.METER_EXPLORER, DataSource.METRICS, TelemetrytypesSourceDTO.meter],
|
||||
].forEach(([route, dataSource, source]) => {
|
||||
const query = renderOn(clickhouseWithOperator, route);
|
||||
|
||||
expect(query?.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(query?.builder.queryData[0].dataSource).toBe(dataSource);
|
||||
expect(query?.builder.queryData[0].source ?? '').toBe(source);
|
||||
expect(query?.builder.queryTraceOperator).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the query type on a route without constraints', () => {
|
||||
const query = renderOn(
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.metrics,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(query?.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
expect(query?.builder.queryData[0].dataSource).toBe(DataSource.METRICS);
|
||||
});
|
||||
});
|
||||
297
frontend/src/hooks/queryBuilder/compositeQuerySchema.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
QueryMode,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
buildPageQuerySchema,
|
||||
normalizeCompositeQuery,
|
||||
} from './compositeQuerySchema';
|
||||
|
||||
const logsSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.LOGS,
|
||||
});
|
||||
const tracesSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
traceOperator: true,
|
||||
});
|
||||
const meterSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.METRICS,
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
});
|
||||
const aiSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
});
|
||||
|
||||
const builderlessUrlQuery = {
|
||||
queryType: 'clickhouse_sql',
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: 'b8084e0c-c13d-4172-a775-f61aedadb79e',
|
||||
unit: '',
|
||||
};
|
||||
|
||||
const logsItem = (overrides: object = {}): object => ({
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const queryOf = (
|
||||
base: typeof initialQueriesMap.logs,
|
||||
queryData: unknown[],
|
||||
builder: object = {},
|
||||
): object => ({
|
||||
...base,
|
||||
builder: { ...base.builder, queryData, ...builder },
|
||||
});
|
||||
|
||||
const logsQuery = (queryData: unknown[], builder?: object): object =>
|
||||
queryOf(initialQueriesMap.logs, queryData, builder);
|
||||
|
||||
const without = (item: object, key: keyof IBuilderQuery): object => {
|
||||
const { [key]: _, ...rest } = item as IBuilderQuery;
|
||||
return rest;
|
||||
};
|
||||
|
||||
describe('normalizeCompositeQuery', () => {
|
||||
it('passes valid queries through unchanged', () => {
|
||||
const withoutSource = logsQuery([
|
||||
without(initialQueriesMap.logs.builder.queryData[0], 'source'),
|
||||
]);
|
||||
const clickhouse = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
};
|
||||
const unmodeled = normalizeCompositeQuery({
|
||||
...logsQuery([logsItem({ builderQueryType: QueryMode.AI_QUERY_BUILDER })]),
|
||||
extra: 'kept',
|
||||
}).query;
|
||||
|
||||
expect(normalizeCompositeQuery(initialQueriesMap.logs)).toStrictEqual({
|
||||
query: initialQueriesMap.logs,
|
||||
changed: false,
|
||||
});
|
||||
expect(
|
||||
normalizeCompositeQuery(initialQueriesMap.logs, logsSchema),
|
||||
).toStrictEqual({ query: initialQueriesMap.logs, changed: false });
|
||||
expect(normalizeCompositeQuery(withoutSource, logsSchema).changed).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
clickhouse,
|
||||
buildPageQuerySchema({ dataSource: DataSource.LOGS }),
|
||||
),
|
||||
).toStrictEqual({ query: clickhouse, changed: false });
|
||||
expect(unmodeled.builder.queryData[0].builderQueryType).toBe(
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
);
|
||||
expect((unmodeled as { extra?: string }).extra).toBe('kept');
|
||||
});
|
||||
|
||||
it('fills missing blocks with defaults and a stable id', () => {
|
||||
const sharedBefore = JSON.stringify(initialQueryState.builder);
|
||||
const builderless = normalizeCompositeQuery(builderlessUrlQuery);
|
||||
builderless.query.builder.queryFormulas.push(
|
||||
{} as (typeof builderless.query.builder.queryFormulas)[number],
|
||||
);
|
||||
const noArrays = normalizeCompositeQuery({
|
||||
...initialQueriesMap.logs,
|
||||
builder: { queryData: initialQueriesMap.logs.builder.queryData },
|
||||
});
|
||||
const omittedBlocks = normalizeCompositeQuery(
|
||||
{
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [logsItem()], queryFormulas: [] },
|
||||
},
|
||||
logsSchema,
|
||||
);
|
||||
const { id: _, ...withoutId } = initialQueriesMap.logs;
|
||||
const id = normalizeCompositeQuery(withoutId).query.id;
|
||||
|
||||
expect(builderless.query.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
expect(builderless.query.id).toBe(builderlessUrlQuery.id);
|
||||
expect(builderless.changed).toBe(true);
|
||||
expect(JSON.stringify(initialQueryState.builder)).toBe(sharedBefore);
|
||||
expect(noArrays.query.builder.queryFormulas).toStrictEqual([]);
|
||||
expect(noArrays.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(noArrays.changed).toBe(true);
|
||||
[[], [5, logsItem({ queryName: 'B' })]].forEach((queryData) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(logsQuery(queryData)).query.builder.queryData,
|
||||
).toStrictEqual(initialQueryState.builder.queryData),
|
||||
);
|
||||
expect(omittedBlocks.query.promql).toStrictEqual(initialQueryState.promql);
|
||||
expect(omittedBlocks.query.clickhouse_sql).toStrictEqual(
|
||||
initialQueryState.clickhouse_sql,
|
||||
);
|
||||
expect(omittedBlocks.query.builder.queryData[0].dataSource).toBe(
|
||||
DataSource.LOGS,
|
||||
);
|
||||
expect(id).toHaveLength(36);
|
||||
expect(normalizeCompositeQuery(withoutId).query.id).toBe(id);
|
||||
expect(
|
||||
normalizeCompositeQuery({ ...withoutId, queryType: EQueryType.PROM }).query
|
||||
.id,
|
||||
).not.toBe(id);
|
||||
});
|
||||
|
||||
it('resets query types and text the page cannot run', () => {
|
||||
const builderless = normalizeCompositeQuery(builderlessUrlQuery, logsSchema);
|
||||
const withText = normalizeCompositeQuery(
|
||||
{
|
||||
...initialQueriesMap.logs,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
promql: [{ name: 'A', legend: '', disabled: false, query: 'up' }],
|
||||
},
|
||||
logsSchema,
|
||||
);
|
||||
|
||||
expect(builderless.query.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(builderless.query.builder.queryData).toStrictEqual([
|
||||
{ ...initialQueryBuilderFormValuesMap.logs, source: '' },
|
||||
]);
|
||||
expect(withText.query.clickhouse_sql).toStrictEqual(
|
||||
initialQueryState.clickhouse_sql,
|
||||
);
|
||||
expect(withText.query.promql).toStrictEqual(initialQueryState.promql);
|
||||
expect(withText.changed).toBe(true);
|
||||
[EQueryType.CLICKHOUSE, EQueryType.PROM].forEach((queryType) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
{ ...initialQueriesMap.traces, queryType },
|
||||
tracesSchema,
|
||||
).query.queryType,
|
||||
).toBe(EQueryType.QUERY_BUILDER),
|
||||
);
|
||||
});
|
||||
|
||||
it('resets items that do not match the page and keeps their names', () => {
|
||||
const mixed = normalizeCompositeQuery(
|
||||
logsQuery([
|
||||
logsItem({
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
filter: { expression: "name='GET /api'" },
|
||||
}),
|
||||
without(
|
||||
logsItem({ filter: { expression: "severity='error'" } }),
|
||||
'dataSource',
|
||||
),
|
||||
]),
|
||||
logsSchema,
|
||||
).query.builder.queryData;
|
||||
const meterFilled = normalizeCompositeQuery(
|
||||
queryOf(initialQueriesMap.metrics, [
|
||||
{
|
||||
...without(initialQueriesMap.metrics.builder.queryData[0], 'source'),
|
||||
filter: { expression: "x='y'" },
|
||||
},
|
||||
]),
|
||||
meterSchema,
|
||||
).query.builder.queryData[0];
|
||||
const metricsOnMeter = normalizeCompositeQuery(
|
||||
initialQueriesMap.metrics,
|
||||
meterSchema,
|
||||
);
|
||||
const tracesItem = {
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
filter: { expression: "name='llm'" },
|
||||
};
|
||||
const [aiMissing, aiWrong, aiLogs] = [
|
||||
[tracesItem],
|
||||
[
|
||||
{
|
||||
...tracesItem,
|
||||
queryName: 'B',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
},
|
||||
],
|
||||
[logsItem({ queryName: 'C' })],
|
||||
].map(
|
||||
(queryData) =>
|
||||
normalizeCompositeQuery(
|
||||
queryOf(initialQueriesMap.traces, queryData),
|
||||
aiSchema,
|
||||
).query.builder.queryData[0],
|
||||
);
|
||||
|
||||
expect(mixed[0]).toStrictEqual({
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
source: '',
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
});
|
||||
expect(mixed[1]).toMatchObject({
|
||||
dataSource: DataSource.LOGS,
|
||||
filter: { expression: "severity='error'" },
|
||||
});
|
||||
expect(meterFilled).toMatchObject({
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
filter: { expression: "x='y'" },
|
||||
});
|
||||
expect(metricsOnMeter.query.builder.queryData[0].source).toBe(
|
||||
TelemetrytypesSourceDTO.meter,
|
||||
);
|
||||
expect(metricsOnMeter.changed).toBe(true);
|
||||
expect(aiMissing).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
filter: { expression: "name='llm'" },
|
||||
});
|
||||
expect(aiWrong).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
queryName: 'B',
|
||||
filter: { expression: '' },
|
||||
});
|
||||
expect(aiLogs).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'C',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a trace operator unless the page allows it', () => {
|
||||
const operator = {
|
||||
queryTraceOperator: [initialQueryBuilderFormValuesMap.traces],
|
||||
};
|
||||
const onLogs = normalizeCompositeQuery(
|
||||
logsQuery([logsItem()], operator),
|
||||
logsSchema,
|
||||
);
|
||||
const onTraces = normalizeCompositeQuery(
|
||||
queryOf(
|
||||
initialQueriesMap.traces,
|
||||
initialQueriesMap.traces.builder.queryData,
|
||||
operator,
|
||||
),
|
||||
tracesSchema,
|
||||
);
|
||||
|
||||
expect(onLogs.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(onLogs.changed).toBe(true);
|
||||
expect(onTraces.query.builder.queryTraceOperator).toHaveLength(1);
|
||||
expect(onTraces.changed).toBe(false);
|
||||
});
|
||||
});
|
||||
134
frontend/src/hooks/queryBuilder/compositeQuerySchema.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import { cloneDeep, isEqual, omitBy } from 'lodash-es';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { BuilderQuerySource, DataSource } from 'types/common/queryBuilder';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
import { z } from 'zod';
|
||||
|
||||
const COMPOSITE_QUERY_ID_NAMESPACE = '2f1c6a4e-9b3d-4c57-8e21-7a0d5f6b9c13';
|
||||
|
||||
export type QueryConstraints = {
|
||||
dataSource: DataSource;
|
||||
source?: BuilderQuerySource;
|
||||
builderQueryType?: BuilderQueryType;
|
||||
traceOperator?: boolean;
|
||||
queryTypes?: EQueryType[];
|
||||
};
|
||||
|
||||
export type NormalizedQuery = {
|
||||
query: Query;
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
const fallback = (value: unknown) => (): never => cloneDeep(value) as never;
|
||||
|
||||
const builderItemSchema = z
|
||||
.looseObject({
|
||||
dataSource: z.enum(DataSource).optional().catch(undefined),
|
||||
source: z.string().optional().catch(undefined),
|
||||
})
|
||||
.transform((item) => omitBy(item, (value) => value === undefined));
|
||||
|
||||
const textItemSchema = z.looseObject({ query: z.string().catch('') });
|
||||
|
||||
const builderSchema = z.looseObject({
|
||||
queryData: z
|
||||
.array(builderItemSchema)
|
||||
.min(1)
|
||||
.catch(fallback(initialQueryState.builder.queryData)),
|
||||
queryFormulas: z.unknown().transform((value) => value ?? []),
|
||||
queryTraceOperator: z.unknown().transform((value) => value ?? []),
|
||||
});
|
||||
|
||||
const compositeQuerySchema = z.looseObject({
|
||||
queryType: z.enum(EQueryType).catch(fallback(EQueryType.QUERY_BUILDER)),
|
||||
builder: builderSchema.catch(fallback(initialQueryState.builder)),
|
||||
promql: z.array(textItemSchema).catch(fallback(initialQueryState.promql)),
|
||||
clickhouse_sql: z
|
||||
.array(textItemSchema)
|
||||
.catch(fallback(initialQueryState.clickhouse_sql)),
|
||||
});
|
||||
|
||||
const mustBeEmpty = (value: unknown): z.ZodType =>
|
||||
z.array(z.looseObject({ query: z.literal('') })).catch(fallback(value));
|
||||
|
||||
export function buildPageQuerySchema(
|
||||
constraints: QueryConstraints,
|
||||
): z.ZodType<unknown> {
|
||||
const { queryTypes } = constraints;
|
||||
const itemDefault: IBuilderQuery = {
|
||||
...initialQueryBuilderFormValuesMap[constraints.dataSource],
|
||||
source: constraints.source ?? '',
|
||||
...(constraints.builderQueryType && {
|
||||
builderQueryType: constraints.builderQueryType,
|
||||
}),
|
||||
};
|
||||
const allows = (type: EQueryType): boolean =>
|
||||
!queryTypes || queryTypes.includes(type);
|
||||
|
||||
const pageItemSchema = builderItemSchema
|
||||
.transform((item) => ({
|
||||
...item,
|
||||
dataSource: item.dataSource ?? constraints.dataSource,
|
||||
...(constraints.source && { source: item.source ?? constraints.source }),
|
||||
...(constraints.builderQueryType && {
|
||||
builderQueryType: item.builderQueryType ?? constraints.builderQueryType,
|
||||
}),
|
||||
}))
|
||||
.refine(
|
||||
(item) =>
|
||||
item.dataSource === constraints.dataSource &&
|
||||
(item.source ?? '') === (constraints.source ?? '') &&
|
||||
(!constraints.builderQueryType ||
|
||||
item.builderQueryType === constraints.builderQueryType),
|
||||
)
|
||||
.catch(({ input }): never => {
|
||||
const { queryName, expression } = input as Partial<IBuilderQuery>;
|
||||
return {
|
||||
...cloneDeep(itemDefault),
|
||||
queryName: queryName ?? itemDefault.queryName,
|
||||
expression: expression ?? itemDefault.expression,
|
||||
} as never;
|
||||
});
|
||||
|
||||
return compositeQuerySchema.extend({
|
||||
queryType: queryTypes
|
||||
? z
|
||||
.enum(EQueryType)
|
||||
.refine((type) => queryTypes.includes(type))
|
||||
.catch(fallback(queryTypes[0]))
|
||||
: compositeQuerySchema.shape.queryType,
|
||||
promql: allows(EQueryType.PROM)
|
||||
? compositeQuerySchema.shape.promql
|
||||
: mustBeEmpty(initialQueryState.promql),
|
||||
clickhouse_sql: allows(EQueryType.CLICKHOUSE)
|
||||
? compositeQuerySchema.shape.clickhouse_sql
|
||||
: mustBeEmpty(initialQueryState.clickhouse_sql),
|
||||
builder: builderSchema
|
||||
.extend({
|
||||
queryData: z
|
||||
.array(pageItemSchema)
|
||||
.min(1)
|
||||
.catch(fallback([itemDefault])),
|
||||
queryTraceOperator: constraints.traceOperator
|
||||
? builderSchema.shape.queryTraceOperator
|
||||
: z.array(z.never()).catch(fallback([])),
|
||||
})
|
||||
.catch(fallback({ ...initialQueryState.builder, queryData: [itemDefault] })),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCompositeQuery(
|
||||
raw: unknown,
|
||||
pageSchema?: z.ZodType<unknown>,
|
||||
): NormalizedQuery {
|
||||
const base = compositeQuerySchema.parse(raw) as unknown as Query;
|
||||
base.id ??= uuidv5(JSON.stringify(raw), COMPOSITE_QUERY_ID_NAMESPACE);
|
||||
const query = (pageSchema ? pageSchema.parse(base) : base) as Query;
|
||||
return { query, changed: !isEqual(query, raw) };
|
||||
}
|
||||
45
frontend/src/hooks/queryBuilder/pageQueryConstraints.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildPageQuerySchema, QueryConstraints } from './compositeQuerySchema';
|
||||
|
||||
const PAGE_QUERY_CONSTRAINTS: Partial<Record<string, QueryConstraints>> = {
|
||||
[ROUTES.LOGS_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.LOGS,
|
||||
},
|
||||
[ROUTES.TRACES_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
traceOperator: true,
|
||||
},
|
||||
[ROUTES.AI_OBSERVABILITY_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
},
|
||||
[ROUTES.METRICS_EXPLORER_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.METRICS,
|
||||
},
|
||||
[ROUTES.METER_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.METRICS,
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
},
|
||||
};
|
||||
|
||||
const pageSchemas = new Map<string, z.ZodType<unknown>>(
|
||||
Object.entries(PAGE_QUERY_CONSTRAINTS).map(([route, constraints]) => [
|
||||
route,
|
||||
buildPageQuerySchema(constraints as QueryConstraints),
|
||||
]),
|
||||
);
|
||||
|
||||
export const pageQuerySchemaFor = (
|
||||
pathname: string,
|
||||
): z.ZodType<unknown> | undefined => pageSchemas.get(pathname);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
@@ -6,15 +7,24 @@ import {
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { isPlainObject } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null => {
|
||||
import {
|
||||
NormalizedQuery,
|
||||
normalizeCompositeQuery,
|
||||
} from './compositeQuerySchema';
|
||||
import { pageQuerySchemaFor } from './pageQueryConstraints';
|
||||
|
||||
export const useNormalizedCompositeQueryParam = (): NormalizedQuery | null => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return useMemo(() => {
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
|
||||
let parsedCompositeQuery: Query | null = null;
|
||||
let changed = false;
|
||||
|
||||
try {
|
||||
if (!compositeQuery) {
|
||||
@@ -27,13 +37,10 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Add default values for optional fields if empty
|
||||
if (parsedCompositeQuery?.builder) {
|
||||
parsedCompositeQuery.builder.queryFormulas =
|
||||
parsedCompositeQuery.builder.queryFormulas ?? [];
|
||||
parsedCompositeQuery.builder.queryTraceOperator =
|
||||
parsedCompositeQuery.builder.queryTraceOperator ?? [];
|
||||
}
|
||||
({ query: parsedCompositeQuery, changed } = normalizeCompositeQuery(
|
||||
parsedCompositeQuery,
|
||||
pageQuerySchemaFor(pathname),
|
||||
));
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
@@ -51,7 +58,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
|
||||
// Convert having if needed
|
||||
if (Array.isArray(query.having)) {
|
||||
const convertedHaving = convertHavingToExpression(query.having);
|
||||
const convertedHaving = convertHavingToExpression(
|
||||
query.having.filter(isPlainObject),
|
||||
);
|
||||
convertedQuery.having = convertedHaving;
|
||||
}
|
||||
|
||||
@@ -75,6 +84,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
parsedCompositeQuery = null;
|
||||
}
|
||||
|
||||
return parsedCompositeQuery;
|
||||
}, [urlQuery]);
|
||||
return parsedCompositeQuery ? { query: parsedCompositeQuery, changed } : null;
|
||||
}, [urlQuery, pathname]);
|
||||
};
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null =>
|
||||
useNormalizedCompositeQueryParam()?.query ?? null;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export function replaceIncorrectObjectFields<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
TargetValue extends object,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
ResultValue extends object,
|
||||
>(
|
||||
targetObject: TargetValue,
|
||||
defaultObject: ResultValue,
|
||||
): { isValid: boolean; validData: ResultValue } {
|
||||
const targetObjectKeys = Object.keys(targetObject);
|
||||
const defaultObjectKeys = Object.keys(defaultObject);
|
||||
|
||||
let isValid = true;
|
||||
|
||||
const result: ResultValue = { ...defaultObject };
|
||||
|
||||
defaultObjectKeys.forEach((key) => {
|
||||
if (targetObjectKeys.includes(key)) {
|
||||
result[key as keyof ResultValue] = targetObject[
|
||||
key as keyof TargetValue
|
||||
] as unknown as ResultValue[keyof ResultValue];
|
||||
} else {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { isValid, validData: result };
|
||||
}
|
||||
@@ -44,7 +44,6 @@ describe('SettingsPage nav sections', () => {
|
||||
'settings-page-sidenav',
|
||||
'workspace',
|
||||
'account',
|
||||
'notification-channels',
|
||||
'billing',
|
||||
'roles',
|
||||
'members',
|
||||
@@ -56,6 +55,12 @@ describe('SettingsPage nav sections', () => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render "notification-channels" element', () => {
|
||||
expect(
|
||||
screen.queryByTestId('notification-channels'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['Identity & Access', 'Authentication'])(
|
||||
'renders "%s" section title',
|
||||
(text) => {
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import anselmEickhoffUrl from '@/assets/Images/users/anselm.jpg';
|
||||
import danielSchellUrl from '@/assets/Images/users/daniel.webp';
|
||||
import dhruvGargUrl from '@/assets/Images/users/dhruv-garg.webp';
|
||||
import goFrendiGunawanUrl from '@/assets/Images/users/go-frendi.webp';
|
||||
import pranayNarangUrl from '@/assets/Images/users/pranay-narang.webp';
|
||||
import sheheryarSewaniUrl from '@/assets/Images/users/shey.webp';
|
||||
import subomiOluwalanaUrl from '@/assets/Images/users/subomi-oluwalana.webp';
|
||||
import vivekBhaktaUrl from '@/assets/Images/users/vivek-bhakta.webp';
|
||||
|
||||
export const infoData = [
|
||||
{
|
||||
id: 'infoBlock-1',
|
||||
@@ -43,7 +52,7 @@ export const enterpriseGradeValuesData = [
|
||||
export const customerStoriesData = [
|
||||
{
|
||||
key: 'story-subomi-oluwalana',
|
||||
avatar: 'https://signoz.io/img/users/subomi-oluwalana.webp',
|
||||
avatar: subomiOluwalanaUrl,
|
||||
personName: 'Subomi Oluwalana',
|
||||
role: 'Founder & CEO at Convoy',
|
||||
customerName: 'Convoy',
|
||||
@@ -54,7 +63,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-dhruv-garg',
|
||||
avatar: 'https://signoz.io/img/users/dhruv-garg.webp',
|
||||
avatar: dhruvGargUrl,
|
||||
personName: 'Dhruv Garg',
|
||||
role: 'Tech Lead at Nudge',
|
||||
customerName: 'Nudge',
|
||||
@@ -65,7 +74,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-vivek-bhakta',
|
||||
avatar: 'https://signoz.io/img/users/vivek-bhakta.webp',
|
||||
avatar: vivekBhaktaUrl,
|
||||
personName: 'Vivek Bhakta',
|
||||
role: 'CTO at Wombo AI',
|
||||
customerName: 'Wombo AI',
|
||||
@@ -75,7 +84,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-pranay-narang',
|
||||
avatar: 'https://signoz.io/img/users/pranay-narang.webp',
|
||||
avatar: pranayNarangUrl,
|
||||
personName: 'Pranay Narang',
|
||||
role: 'Engineering at Azodha',
|
||||
customerName: 'Azodha',
|
||||
@@ -85,7 +94,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-Sheheryar-Sewani',
|
||||
avatar: 'https://signoz.io/img/users/shey.webp',
|
||||
avatar: sheheryarSewaniUrl,
|
||||
personName: 'Sheheryar Sewani',
|
||||
role: 'Seasoned Rails Dev & Founder',
|
||||
customerName: '',
|
||||
@@ -96,7 +105,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-daniel-schell',
|
||||
avatar: 'https://signoz.io/img/users/daniel.webp',
|
||||
avatar: danielSchellUrl,
|
||||
personName: 'Daniel Schell',
|
||||
role: 'Founder & CTO at Airlockdigital',
|
||||
customerName: 'Airlockdigital',
|
||||
@@ -106,7 +115,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'c-story-6',
|
||||
avatar: 'https://signoz.io/img/users/go-frendi.webp',
|
||||
avatar: goFrendiGunawanUrl,
|
||||
personName: 'Go Frendi Gunawan',
|
||||
role: 'Data Engineer at Ctlyst.id',
|
||||
customerName: 'Ctlyst.id',
|
||||
@@ -116,7 +125,7 @@ export const customerStoriesData = [
|
||||
},
|
||||
{
|
||||
key: 'story-anselm-eickhoff',
|
||||
avatar: 'https://signoz.io/img/users/anselm.jpg',
|
||||
avatar: anselmEickhoffUrl,
|
||||
personName: 'Anselm Eickhoff',
|
||||
role: 'Software Architect',
|
||||
customerName: '',
|
||||
|
||||
@@ -34,14 +34,13 @@ import {
|
||||
PartialPanelTypes,
|
||||
} from 'lib/query/panelTypeDataSourceFormValuesMap';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useNormalizedCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
|
||||
import { saveRecentQuery } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { replaceIncorrectObjectFields } from 'lib/replaceIncorrectObjectFields';
|
||||
import { cloneDeep, get, isEqual, set } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
// ** Types
|
||||
@@ -114,7 +113,8 @@ export function QueryBuilderProvider({
|
||||
const [calledFromHandleRunQuery, setCalledFromHandleRunQuery] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const compositeQueryParam = useGetCompositeQueryParam();
|
||||
const normalizedCompositeQuery = useNormalizedCompositeQueryParam();
|
||||
const compositeQueryParam = normalizedCompositeQuery?.query ?? null;
|
||||
const { queryType: queryTypeParam, ...queryState } =
|
||||
compositeQueryParam || initialQueriesMap.metrics;
|
||||
|
||||
@@ -1115,20 +1115,22 @@ export function QueryBuilderProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
const { isValid, validData } = replaceIncorrectObjectFields(
|
||||
compositeQueryParam,
|
||||
initialQueriesMap.metrics,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
redirectWithQueryBuilderData(validData);
|
||||
} else {
|
||||
initQueryBuilderData(compositeQueryParam);
|
||||
if (normalizedCompositeQuery?.changed) {
|
||||
const urlQuery = getUnstableCurrentSearchParams();
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQueryParam)),
|
||||
);
|
||||
safeNavigate(`${location.pathname}?${urlQuery}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
initQueryBuilderData(compositeQueryParam);
|
||||
}
|
||||
}, [
|
||||
initQueryBuilderData,
|
||||
redirectWithQueryBuilderData,
|
||||
safeNavigate,
|
||||
normalizedCompositeQuery,
|
||||
compositeQueryParam,
|
||||
stagedQuery,
|
||||
location.pathname,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
act,
|
||||
AllTheProviders,
|
||||
renderHook,
|
||||
RenderHookResult,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () =>
|
||||
jest
|
||||
.requireActual('tests/browser-history-safe-navigate')
|
||||
.createBrowserHistorySafeNavigateMock(),
|
||||
);
|
||||
|
||||
const builderlessUrlQuery = {
|
||||
queryType: 'clickhouse_sql',
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: 'b8084e0c-c13d-4172-a775-f61aedadb79e',
|
||||
unit: '',
|
||||
};
|
||||
|
||||
const clickhouseUrlQuery = {
|
||||
...builderlessUrlQuery,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
};
|
||||
|
||||
const tracesWithOperator = {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
|
||||
},
|
||||
};
|
||||
|
||||
const urlQuery = (): Query =>
|
||||
JSON.parse(
|
||||
decodeURIComponent(
|
||||
new URLSearchParams(window.location.search).get('compositeQuery') ?? '',
|
||||
),
|
||||
);
|
||||
|
||||
const activeLogIdParam = (): string | null =>
|
||||
new URLSearchParams(window.location.search).get('activeLogId');
|
||||
|
||||
const renderOn = (
|
||||
route: string,
|
||||
compositeQuery: object,
|
||||
params: Record<string, string> = {},
|
||||
): RenderHookResult<QueryBuilderContextType, unknown> => {
|
||||
const search = new URLSearchParams(params);
|
||||
search.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
const url = `${route}?${search}`;
|
||||
window.history.replaceState(null, '', url);
|
||||
|
||||
return renderHook(() => useQueryBuilder(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={url}>{children}</AllTheProviders>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
describe('QueryBuilderProvider on a url compositeQuery without builder', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('seeds supersetQuery with a builder so the first edit does not throw', () => {
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, builderlessUrlQuery);
|
||||
|
||||
expect(result.current.supersetQuery.builder).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetQueryData(0, {
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.supersetQuery.builder.queryData[0].limit).toBe(10);
|
||||
expect(result.current.currentQuery.builder.queryData[0].limit).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryBuilderProvider with page constraints', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('seeds and stages the logs query and rewrites only compositeQuery', async () => {
|
||||
const replaceState = jest.spyOn(window.history, 'replaceState');
|
||||
const pushState = jest.spyOn(window.history, 'pushState');
|
||||
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, clickhouseUrlQuery, {
|
||||
activeLogId: '"log-1"',
|
||||
});
|
||||
|
||||
expect(result.current.currentQuery.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(result.current.supersetQuery.builder.queryData[0].dataSource).toBe(
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.QUERY_BUILDER),
|
||||
);
|
||||
expect(result.current.stagedQuery?.clickhouse_sql[0].query).toBe('');
|
||||
|
||||
expect(urlQuery().queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(urlQuery().builder.queryData[0].dataSource).toBe(DataSource.LOGS);
|
||||
expect(urlQuery().clickhouse_sql[0].query).toBe('');
|
||||
expect(activeLogIdParam()).toBe('"log-1"');
|
||||
expect(replaceState).toHaveBeenCalled();
|
||||
expect(pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a trace operator on the traces explorer', async () => {
|
||||
const { result } = renderOn(ROUTES.TRACES_EXPLORER, tracesWithOperator);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.builder.queryTraceOperator).toHaveLength(
|
||||
1,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a valid url without source untouched and keeps activeLogId', async () => {
|
||||
const { source: _, ...item } = initialQueriesMap.logs.builder.queryData[0];
|
||||
const query = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: { ...initialQueriesMap.logs.builder, queryData: [item] },
|
||||
};
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, query, {
|
||||
activeLogId: '"log-1"',
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.id).toBe(initialQueriesMap.logs.id),
|
||||
);
|
||||
expect(
|
||||
new URLSearchParams(window.location.search).get('compositeQuery'),
|
||||
).toBe(JSON.stringify(query));
|
||||
expect(activeLogIdParam()).toBe('"log-1"');
|
||||
});
|
||||
|
||||
it('leaves a clickhouse query alone on a route without constraints', async () => {
|
||||
const { result } = renderOn('/unconstrained', clickhouseUrlQuery);
|
||||
|
||||
expect(result.current.currentQuery.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.CLICKHOUSE),
|
||||
);
|
||||
expect(urlQuery().clickhouse_sql[0].query).toBe('SELECT 1');
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ func newConfig() factory.Config {
|
||||
Agent: AgentConfig{
|
||||
// we will maintain the latest version of cloud integration agent from here,
|
||||
// till we automate it externally or figure out a way to validate it.
|
||||
Version: "v0.0.15",
|
||||
Version: "v0.0.16",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,10 @@ type handler struct {
|
||||
set factory.ProviderSettings
|
||||
analytics analytics.Analytics
|
||||
querier Querier
|
||||
config Config
|
||||
}
|
||||
|
||||
func NewHandler(set factory.ProviderSettings, querier Querier, analytics analytics.Analytics, config Config) Handler {
|
||||
return &handler{set: set, querier: querier, analytics: analytics, config: config}
|
||||
func NewHandler(set factory.ProviderSettings, querier Querier, analytics analytics.Analytics) Handler {
|
||||
return &handler{set: set, querier: querier, analytics: analytics}
|
||||
}
|
||||
|
||||
func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
@@ -60,7 +59,7 @@ func (handler *handler) QueryRange(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
// Validate the query request
|
||||
if err := queryRangeRequest.Validate(qbtypes.WithMaxTimeRange(handler.config.MaxTimeRange)); err != nil {
|
||||
if err := queryRangeRequest.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
@@ -141,9 +140,7 @@ func (handler *handler) QueryRawStream(rw http.ResponseWriter, req *http.Request
|
||||
}
|
||||
// create the v5 request param
|
||||
queryRangeRequest := qbtypes.QueryRangeRequest{
|
||||
// Coerce the window to epoch milliseconds up front (End may be 0 for the
|
||||
// open-ended stream, which ToMilliSecs leaves untouched).
|
||||
Start: qbtypes.ToMilliSecs(start),
|
||||
Start: start,
|
||||
RequestType: qbtypes.RequestTypeRawStream,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
|
||||
const DefaultMaxConcurrentQueries = 8
|
||||
|
||||
const DefaultMaxTimeRange = 2 * 365 * 24 * time.Hour
|
||||
|
||||
// Config represents the configuration for the querier.
|
||||
type Config struct {
|
||||
// CacheTTL is the TTL for cached query results
|
||||
@@ -22,8 +20,6 @@ type Config struct {
|
||||
MaxConcurrentQueries int `yaml:"max_concurrent_queries" mapstructure:"max_concurrent_queries"`
|
||||
// LogTraceIDWindowPadding is the padding added to narrowed down timerange from trace summary to logs with trace_id filter.
|
||||
LogTraceIDWindowPadding time.Duration `yaml:"log_trace_id_window_padding" mapstructure:"log_trace_id_window_padding"`
|
||||
// MaxTimeRange is the maximum allowed span between start and end of a query range request; 0 disables the limit.
|
||||
MaxTimeRange time.Duration `yaml:"max_time_range" mapstructure:"max_time_range"`
|
||||
|
||||
// Keys sit under querier.skip_resource_fingerprint.
|
||||
statementbuilder.Config `mapstructure:",squash" yaml:",squash"`
|
||||
@@ -41,7 +37,6 @@ func newConfig() factory.Config {
|
||||
FluxInterval: 5 * time.Minute,
|
||||
MaxConcurrentQueries: DefaultMaxConcurrentQueries,
|
||||
LogTraceIDWindowPadding: 5 * time.Minute,
|
||||
MaxTimeRange: DefaultMaxTimeRange,
|
||||
Config: statementbuilder.NewConfig(),
|
||||
}
|
||||
}
|
||||
@@ -60,9 +55,6 @@ func (c Config) Validate() error {
|
||||
if c.LogTraceIDWindowPadding < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "log_trace_id_window_padding must not be negative, got %v", c.LogTraceIDWindowPadding)
|
||||
}
|
||||
if c.MaxTimeRange < 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "max_time_range must not be negative, got %v", c.MaxTimeRange)
|
||||
}
|
||||
// Embedded Validate is shadowed by this one; call it explicitly.
|
||||
if err := c.Config.Validate(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -20,9 +20,9 @@ func makeBuckets(start, end uint64) []tsRange {
|
||||
curEnd := endNS
|
||||
|
||||
for {
|
||||
curStart := startNS
|
||||
if curEnd-startNS > bucket {
|
||||
curStart = curEnd - bucket
|
||||
curStart := curEnd - bucket
|
||||
if curStart < startNS {
|
||||
curStart = startNS
|
||||
}
|
||||
out = append(out, tsRange{fromNS: curStart, toNS: curEnd})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ func (q *querier) QueryRangePreview(
|
||||
opts qbtypes.QueryRangePreviewOptions,
|
||||
) (*qbtypes.QueryRangePreviewResponse, error) {
|
||||
|
||||
validationOpts, err := req.ValidateRequestScope(qbtypes.WithMaxTimeRange(q.maxTimeRange))
|
||||
validationOpts, err := req.ValidateRequestScope()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ type querier struct {
|
||||
liveDataRefresh time.Duration
|
||||
builderConfig builderConfig
|
||||
maxConcurrentQueries int
|
||||
maxTimeRange time.Duration
|
||||
}
|
||||
|
||||
var _ Querier = (*querier)(nil)
|
||||
@@ -83,7 +82,6 @@ func New(
|
||||
flagger flagger.Flagger,
|
||||
logTraceIDWindowPadding time.Duration,
|
||||
maxConcurrentQueries int,
|
||||
maxTimeRange time.Duration,
|
||||
) *querier {
|
||||
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
|
||||
if maxConcurrentQueries <= 0 {
|
||||
@@ -108,12 +106,17 @@ func New(
|
||||
logTraceIDWindowPaddingMS: uint64(logTraceIDWindowPadding.Milliseconds()),
|
||||
},
|
||||
maxConcurrentQueries: maxConcurrentQueries,
|
||||
maxTimeRange: maxTimeRange,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
event := &qbtypes.QBEvent{
|
||||
Version: "v5",
|
||||
NumberOfQueries: len(req.CompositeQuery.Queries),
|
||||
@@ -531,6 +534,11 @@ func familyTemporality(temporalities map[string]metrictypes.Temporality, names [
|
||||
|
||||
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)
|
||||
|
||||
event := &qbtypes.QBEvent{
|
||||
Version: "v5",
|
||||
NumberOfQueries: len(req.CompositeQuery.Queries),
|
||||
|
||||
@@ -59,7 +59,6 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
|
||||
flaggertest.New(t), // flagger
|
||||
0, // logTraceIDWindowPadding
|
||||
0, // maxConcurrentQueries
|
||||
0, // maxTimeRange
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
@@ -133,7 +132,6 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
|
||||
flaggertest.New(t), // flagger
|
||||
0, // logTraceIDWindowPadding
|
||||
0, // maxConcurrentQueries
|
||||
0, // maxTimeRange
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
|
||||
@@ -52,7 +52,6 @@ func NewFactory(
|
||||
flagger,
|
||||
cfg.LogTraceIDWindowPadding,
|
||||
cfg.MaxConcurrentQueries,
|
||||
cfg.MaxTimeRange,
|
||||
), nil
|
||||
},
|
||||
)
|
||||
|
||||
@@ -52,7 +52,6 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
|
||||
fl,
|
||||
0,
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
0, // maxTimeRange (0 means no limit)
|
||||
), metadataStore
|
||||
}
|
||||
|
||||
@@ -88,7 +87,6 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
|
||||
fl,
|
||||
5*time.Minute, // logTraceIDWindowPadding
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
0, // maxTimeRange (0 means no limit)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,7 +123,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
|
||||
fl,
|
||||
0,
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
0, // maxTimeRange (0 means no limit)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -162,6 +159,5 @@ func prepareQuerierForAITraces(t *testing.T, telemetryStore telemetrystore.Telem
|
||||
fl,
|
||||
0,
|
||||
0, // maxConcurrentQueries (0 means default)
|
||||
0, // maxTimeRange (0 means no limit)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,28 @@ 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 {
|
||||
|
||||
@@ -60,3 +60,51 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
spanMapperModule := implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), spanMapperRegistry, providerSettings)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, spanMapperModule)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil, querier.Config{})
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil, nil)
|
||||
reflectVal := reflect.ValueOf(handlers)
|
||||
|
||||
@@ -182,7 +182,7 @@ func New(
|
||||
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
|
||||
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
|
||||
meterReporterProviderFactories func(context.Context, factory.ProviderSettings, flagger.Flagger, licensing.Licensing, telemetrystore.TelemetryStore, retention.Getter, organization.Getter, zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string),
|
||||
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics, querier.Config) querier.Handler,
|
||||
querierHandlerCallback func(factory.ProviderSettings, querier.Querier, analytics.Analytics) querier.Handler,
|
||||
cloudIntegrationCallback func(sqlstore.SQLStore, dashboard.Module, global.Global, zeus.Zeus, gateway.Gateway, licensing.Licensing, serviceaccount.Module, cloudintegration.Config) (cloudintegration.Module, error),
|
||||
metricReductionRuleModuleCallback func(sqlstore.SQLStore, telemetrystore.TelemetryStore, dashboard.Module, queryparser.QueryParser, licensing.Licensing, flagger.Flagger, telemetrytypes.MetadataStore, factory.ProviderSettings, int) metricreductionrule.Module,
|
||||
rulerProviderFactories func(cache.Cache, alertmanager.Alertmanager, sqlstore.SQLStore, telemetrystore.TelemetryStore, telemetrytypes.MetadataStore, prometheus.Prometheus, organization.Getter, rulestatehistory.Module, querier.Querier, queryparser.QueryParser) factory.NamedMap[factory.ProviderFactory[ruler.Ruler, ruler.Config]],
|
||||
@@ -553,7 +553,7 @@ func New(
|
||||
userService := impluser.NewService(providerSettings, impluser.NewStore(sqlstore, providerSettings), modules.UserGetter, modules.UserSetter, orgGetter, authz, config.User.Root)
|
||||
|
||||
// Initialize the querier handler via callback (allows EE to decorate with anomaly detection)
|
||||
querierHandler := querierHandlerCallback(providerSettings, querier, analytics, config.Querier)
|
||||
querierHandler := querierHandlerCallback(providerSettings, querier, analytics)
|
||||
|
||||
// Create a list of all stats collectors
|
||||
statsCollectors := []statsreporter.StatsCollector{
|
||||
|
||||
@@ -673,12 +673,6 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
|
||||
// Copy the decoded values back to the original struct
|
||||
*r = QueryRangeRequest(temp)
|
||||
|
||||
// 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.
|
||||
r.Start = ToMilliSecs(r.Start)
|
||||
r.End = ToMilliSecs(r.End)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import "math"
|
||||
|
||||
// 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)))
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
import "testing"
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,12 @@ import (
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/metrictypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// maxEndTimeAhead tolerates client clock skew; anything further ahead is a malformed timestamp.
|
||||
const maxEndTimeAhead = 24 * time.Hour
|
||||
|
||||
// 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()
|
||||
@@ -86,7 +82,6 @@ type validationConfig struct {
|
||||
skipGroupByValidation bool
|
||||
withTimestampGroupByValidation bool
|
||||
withReduceToValidation bool
|
||||
maxTimeRange time.Duration
|
||||
}
|
||||
|
||||
func applyValidationOptions(opts []ValidationOption) validationConfig {
|
||||
@@ -97,13 +92,6 @@ func applyValidationOptions(opts []ValidationOption) validationConfig {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// WithMaxTimeRange rejects requests whose window is longer than maxTimeRange; 0 disables the limit.
|
||||
func WithMaxTimeRange(maxTimeRange time.Duration) ValidationOption {
|
||||
return func(cfg *validationConfig) {
|
||||
cfg.maxTimeRange = maxTimeRange
|
||||
}
|
||||
}
|
||||
|
||||
// SkipLimitOffsetValidation returns a ValidationOption that skips the limit and offset range checks.
|
||||
// Use this when the caller has already validated limits and offsets with different constraints.
|
||||
func WithSkipLimitOffsetValidation() ValidationOption {
|
||||
@@ -586,8 +574,12 @@ func (q *QueryBuilderQuery[T]) validateOrderByForAggregation() error {
|
||||
|
||||
// Validate validates the entire query range request.
|
||||
func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
if err := r.validateTimeRange(applyValidationOptions(opts)); err != nil {
|
||||
return err
|
||||
// Validate time range
|
||||
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
|
||||
return errors.NewInvalidInputf(
|
||||
errors.CodeInvalidInput,
|
||||
"start time must be before end time",
|
||||
)
|
||||
}
|
||||
|
||||
// Validate request type
|
||||
@@ -638,14 +630,15 @@ func (r *QueryRangeRequest) Validate(opts ...ValidationOption) error {
|
||||
// specs) and returns the request type's ValidationOptions. The dry-run path uses
|
||||
// this so per-query errors can be attributed individually via QueryEnvelope.Validate
|
||||
// instead of failing fast like Validate does.
|
||||
func (r *QueryRangeRequest) ValidateRequestScope(opts ...ValidationOption) ([]ValidationOption, error) {
|
||||
if err := r.validateTimeRange(applyValidationOptions(opts)); err != nil {
|
||||
return nil, err
|
||||
func (r *QueryRangeRequest) ValidateRequestScope() ([]ValidationOption, error) {
|
||||
if r.RequestType != RequestTypeRawStream && r.Start >= r.End {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "start time must be before end time")
|
||||
}
|
||||
|
||||
var opts []ValidationOption
|
||||
switch r.RequestType {
|
||||
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace, RequestTypeTimeSeries, RequestTypeScalar, RequestTypeHeatmap:
|
||||
opts = append(opts, GetValidationOptions(r.RequestType)...)
|
||||
opts = GetValidationOptions(r.RequestType)
|
||||
default:
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid request type: %s", r.RequestType).
|
||||
WithAdditional("Valid request types are: raw, timeseries, scalar, heatmap")
|
||||
@@ -706,25 +699,6 @@ func (e QueryEnvelope) Validate(opts ...ValidationOption) error {
|
||||
return validateQueryEnvelope(e, opts...)
|
||||
}
|
||||
|
||||
func (r *QueryRangeRequest) validateTimeRange(cfg validationConfig) error {
|
||||
if r.RequestType == RequestTypeRawStream {
|
||||
return nil
|
||||
}
|
||||
if r.Start == 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start time is required")
|
||||
}
|
||||
if r.Start >= r.End {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start time must be before end time")
|
||||
}
|
||||
if r.End > uint64(time.Now().Add(maxEndTimeAhead).UnixMilli()) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "end time %s is too far in the future", time.UnixMilli(int64(r.End)).UTC().Format(time.RFC3339))
|
||||
}
|
||||
if cfg.maxTimeRange > 0 && time.Duration(r.End-r.Start)*time.Millisecond > cfg.maxTimeRange {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "query time range must not exceed %d days", int64(cfg.maxTimeRange/(24*time.Hour)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAllQueriesNotDisabled validates that at least one query in the composite query is enabled.
|
||||
func (r *QueryRangeRequest) validateAllQueriesNotDisabled() error {
|
||||
for _, envelope := range r.CompositeQuery.Queries {
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logs import Logs
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import (
|
||||
Aggregation,
|
||||
BuilderQuery,
|
||||
MetricAggregation,
|
||||
get_all_warnings,
|
||||
get_error_message,
|
||||
get_series_values,
|
||||
make_query_request,
|
||||
)
|
||||
from fixtures.traces import TraceIdGenerator, Traces
|
||||
|
||||
TWO_YEARS = timedelta(days=730)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start_ms,end_ms,message",
|
||||
[
|
||||
pytest.param(0, 1_700_000_000_000, "start time is required", id="zero_start"),
|
||||
pytest.param(1_700_000_000_000, 1_700_000_000_000, "start time must be before end time", id="start_equals_end"),
|
||||
# 2592000000 is 30 days in ms; read as an epoch in seconds it is year 2052.
|
||||
pytest.param(1_700_000_000_000, 2_592_000_000, "end time 2052-02-20T00:00:00Z is too far in the future", id="end_in_2052"),
|
||||
pytest.param(1_700_000_000_000 - int((TWO_YEARS + timedelta(days=1)).total_seconds() * 1000), 1_700_000_000_000, "query time range must not exceed 730 days", id="range_over_two_years"),
|
||||
],
|
||||
)
|
||||
def test_time_range_rejected(
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
message: str,
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
queries=[BuilderQuery(signal="logs", aggregations=[Aggregation(expression="count()")], step_interval=60).to_dict()],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert get_error_message(response.json()) == message
|
||||
|
||||
|
||||
def test_two_year_logs_timeseries_step_clamped(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
# A 1s step over 2 years is clamped to 42045s (at most ~1500 points); end is aligned to it so the bucket count is stable.
|
||||
step_seconds = 42045
|
||||
end = datetime.fromtimestamp(int(datetime.now(tz=UTC).timestamp()) // step_seconds * step_seconds, tz=UTC)
|
||||
insert_logs([Logs(timestamp=end - timedelta(minutes=1), resources={"service.name": "time-range-logs"}, body="two year span")])
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((end - TWO_YEARS).timestamp() * 1000),
|
||||
end_ms=int(end.timestamp() * 1000),
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="logs",
|
||||
filter_expression="service.name = 'time-range-logs'",
|
||||
aggregations=[Aggregation(expression="count()")],
|
||||
step_interval=1,
|
||||
).to_dict()
|
||||
],
|
||||
format_options={"formatTableResultForUI": False, "fillGaps": True},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["data"]["meta"]["stepIntervals"] == {"A": step_seconds}
|
||||
assert get_all_warnings(response.json()) == [
|
||||
{"message": f"Query A is requesting aggregation interval 1 seconds, which is smaller than the minimum allowed interval of {step_seconds} seconds for selected time range. Using the minimum instead"},
|
||||
]
|
||||
values = get_series_values(response.json(), "A")
|
||||
assert len(values) == 1502
|
||||
assert sum(v["value"] for v in values) == 1
|
||||
|
||||
|
||||
def test_two_year_traces_timeseries_step_clamped(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
) -> None:
|
||||
# Same clamp as logs: 42045s; end is aligned to it so the bucket count is stable.
|
||||
step_seconds = 42045
|
||||
end = datetime.fromtimestamp(int(datetime.now(tz=UTC).timestamp()) // step_seconds * step_seconds, tz=UTC)
|
||||
insert_traces([Traces(timestamp=end - timedelta(minutes=1), trace_id=TraceIdGenerator.trace_id(), span_id=TraceIdGenerator.span_id(), resources={"service.name": "time-range-traces"})])
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((end - TWO_YEARS).timestamp() * 1000),
|
||||
end_ms=int(end.timestamp() * 1000),
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="traces",
|
||||
filter_expression="service.name = 'time-range-traces'",
|
||||
aggregations=[Aggregation(expression="count()")],
|
||||
step_interval=1,
|
||||
).to_dict()
|
||||
],
|
||||
format_options={"formatTableResultForUI": False, "fillGaps": True},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["data"]["meta"]["stepIntervals"] == {"A": step_seconds}
|
||||
assert get_all_warnings(response.json()) == [
|
||||
{"message": f"Query A is requesting aggregation interval 1 seconds, which is smaller than the minimum allowed interval of {step_seconds} seconds for selected time range. Using the minimum instead"},
|
||||
]
|
||||
values = get_series_values(response.json(), "A")
|
||||
assert len(values) == 1502
|
||||
assert sum(v["value"] for v in values) == 1
|
||||
|
||||
|
||||
def test_two_year_metrics_timeseries_step_clamped(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
# A 1s step over 2 years is clamped to 210600s (~300 points); end is aligned to it so the bucket count is stable.
|
||||
step_seconds = 210600
|
||||
metric_name = "time_range_two_year_gauge"
|
||||
end = datetime.fromtimestamp(int(datetime.now(tz=UTC).timestamp()) // step_seconds * step_seconds, tz=UTC)
|
||||
insert_metrics([Metrics(metric_name=metric_name, labels={"service": "time-range-metrics"}, timestamp=end - timedelta(minutes=1), value=1.0, temporality="Unspecified", type_="Gauge", is_monotonic=False)])
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((end - TWO_YEARS).timestamp() * 1000),
|
||||
end_ms=int(end.timestamp() * 1000),
|
||||
queries=[
|
||||
BuilderQuery(
|
||||
signal="metrics",
|
||||
aggregations=[MetricAggregation(metric_name=metric_name, time_aggregation="latest", space_aggregation="sum", temporality="unspecified")],
|
||||
step_interval=1,
|
||||
).to_dict()
|
||||
],
|
||||
format_options={"formatTableResultForUI": False, "fillGaps": True},
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["data"]["meta"]["stepIntervals"] == {"A": step_seconds}
|
||||
assert get_all_warnings(response.json()) == [
|
||||
{"message": f"Query A is requesting aggregation interval 1 seconds, which is smaller than the minimum allowed interval of {step_seconds} seconds for selected time range. Using the minimum instead"},
|
||||
]
|
||||
assert len(get_series_values(response.json(), "A")) == 301
|
||||