Compare commits

..

18 Commits

Author SHA1 Message Date
aks07
c9f66c037a feat(quick-filters): fetch values from fields/values (CheckboxV2) on all pages
Signal pages (logs, traces, exceptions, api monitoring, meter) now render
CheckboxV2 so quick filter values come from fields/values instead of the old v3
attribute_values. Added a shared useSignalFieldApis hook for the time range;
related values stay off (infra only). Meter's source is derived from the quick
filter source inside CheckboxV2. Bool fields synthesize true/false since the api
returns empty for them.
2026-08-31 13:53:47 +05:30
aks07
45ce679014 fix(quick-filters): follow backend rename to /api/v2/quick_filters
The backend moved the endpoint from /orgs/me/filters to quick_filters and the
generated hook from useGetSignalQuickFilters to useGetQuickFilters. Point our
load hook and test mocks at the new names.
2026-08-31 11:37:19 +05:30
aks07
3a730e3674 Merge remote-tracking branch 'origin/issue_5947' into qf-me-filters-v2-b1 2026-08-31 11:35:38 +05:30
nityanandagohain
45a8bb424c Merge remote-tracking branch 'origin/main' into issue_5947 2026-08-28 20:28:22 +05:30
nityanandagohain
17afa7a3cf fix: address comments 2026-08-28 20:25:14 +05:30
aks07
c6fd0a6ccf test(quick-filters): update tests and mocks for the new field APIs
Point the /me/filters and suggestions mocks at the v2 and fields/keys endpoints
and update the fixture shapes. Adds a test that same-name fields with different
context/datatype stay distinct (both addable, independently removable).
2026-08-28 15:29:09 +05:30
aks07
14f728ae68 feat(quick-filters): migrate to new field APIs (fields/keys + v2 /me/filters)
Quick filters now run on the new field apis. Keys come from fields/keys for all
pages (logs, traces, exceptions, api monitoring, meter), and save/load moves to
the v2 /me/filters that stores TelemetryFieldKey. Dropped the old hand-written
v1 clients.

Filters are now identified by name + fieldContext + fieldDataType, so same-name
fields with different context/datatype don't clash.

Values still use the old v3 path...that migration comes next.
2026-08-28 15:28:50 +05:30
nityanandagohain
6dc9bf7b16 Merge remote-tracking branch 'origin/main' into issue_5947 2026-08-27 14:13:30 +05:30
nityanandagohain
d15a1452f8 fix: minor fixes 2026-08-27 14:13:08 +05:30
nityanandagohain
2fe033ec78 fix: trigger build 2026-08-26 18:20:23 +05:30
nityanandagohain
02a2800f89 Merge remote-tracking branch 'origin/main' into issue_5947 2026-08-26 18:20:04 +05:30
nityanandagohain
915aa2eb70 Merge remote-tracking branch 'origin/issue_5947' into issue_5947 2026-08-26 16:44:26 +05:30
nityanandagohain
518caff0f2 Merge remote-tracking branch 'origin/main' into issue_5947 2026-08-26 16:44:09 +05:30
nityanandagohain
0d3ac28286 fix: update openapi spec 2026-08-26 16:41:50 +05:30
Nityananda Gohain
47beef07de Merge branch 'main' into issue_5947 2026-08-26 16:32:51 +05:30
nityanandagohain
5f2891bd6c fix: test cleanup 2026-08-26 16:32:01 +05:30
nityanandagohain
97f0e832ab fix: minor cleanup 2026-08-26 16:31:17 +05:30
nityanandagohain
770a8f7b0a fix: add quick filters v2 api to support TelemetryFieldKey 2026-08-26 11:12:59 +05:30
76 changed files with 2126 additions and 2431 deletions

View File

@@ -50,6 +50,7 @@ jobs:
- logspipelines
- passwordauthn
- preference
- quickfilter
- querierlogs
- queriertraces
- queriermetrics

View File

@@ -46,7 +46,6 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -104,8 +103,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()

View File

@@ -63,7 +63,6 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -137,8 +136,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule, systemDashboardRegistry)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)

View File

@@ -2944,46 +2944,6 @@ components:
publicDashboard:
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
type: object
DashboardtypesGettableSystemDashboard:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
image:
type: string
locked:
type: boolean
name:
type: string
orgId:
type: string
schemaVersion:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
spec:
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
tags:
items:
$ref: '#/components/schemas/TagtypesGettableTag'
nullable: true
type: array
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- orgId
- locked
- source
- schemaVersion
- name
- tags
- spec
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -7423,6 +7383,26 @@ components:
- custom
- text
type: string
QuickfiltertypesSignalFilters:
properties:
filters:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
signal:
type: string
type: object
QuickfiltertypesUpdatableQuickFilters:
properties:
filters:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
nullable: true
type: array
signal:
type: string
type: object
RenderErrorResponse:
properties:
error:
@@ -15399,73 +15379,6 @@ paths:
summary: Migrate dashboard to v2
tags:
- dashboard
/api/v2/dashboards/system/{name}:
get:
deprecated: false
description: Returns a dashboard SigNoz ships and owns, addressed by its stable
definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards
are read-only and upgraded through releases. The dashboard's own `name` field
carries a reserved prefix that the path segment must not include.
operationId: GetSystemDashboard
parameters:
- in: path
name: name
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableSystemDashboard'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- dashboard:read
- tokenizer:
- dashboard:read
summary: Get system dashboard
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false
@@ -18426,6 +18339,165 @@ paths:
summary: Get query range result (v2)
tags:
- dashboard
/api/v2/quick_filters:
get:
deprecated: false
description: Returns the org's quick filters for every signal, each filter as
a telemetry field key.
operationId: ListQuickFilters
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/QuickfiltertypesSignalFilters'
nullable: true
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- quick-filter:list
- tokenizer:
- quick-filter:list
summary: List quick filters
tags:
- quick_filter
put:
deprecated: false
description: Replaces the org's quick filters for the signal named in the body.
operationId: UpdateQuickFilters
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/QuickfiltertypesUpdatableQuickFilters'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- quick-filter:update
- tokenizer:
- quick-filter:update
summary: Update quick filters
tags:
- quick_filter
/api/v2/quick_filters/{signal_name}:
get:
deprecated: false
description: Returns the org's quick filters for one signal, each filter as
a telemetry field key.
operationId: GetQuickFilters
parameters:
- in: path
name: signal_name
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/QuickfiltertypesSignalFilters'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- quick-filter:read
- tokenizer:
- quick-filter:read
summary: Get a signal's quick filters
tags:
- quick_filter
/api/v2/readyz:
get:
operationId: Readyz

View File

@@ -32,9 +32,9 @@ type module struct {
tagModule tag.Module
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule)
return &module{
pkgDashboardModule: pkgDashboardModule,
@@ -361,14 +361,6 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
return module.pkgDashboardModule.ReconcileSystemDashboards(ctx, orgID)
}
func (module *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetSystemDashboard(ctx, orgID, name)
}
func (module *module) delete(ctx context.Context, orgID, id valuer.UUID) error {
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {

View File

@@ -46,8 +46,6 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
GetSystemDashboard200,
GetSystemDashboardPathParameters,
ListDashboardViews200,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
@@ -1887,108 +1885,6 @@ export const useMigrateDashboardV2 = <
> => {
return useMutation(getMigrateDashboardV2MutationOptions(options));
};
/**
* Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.
* @summary Get system dashboard
*/
export const getSystemDashboard = (
{ name }: GetSystemDashboardPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetSystemDashboard200>({
url: `/api/v2/dashboards/system/${name}`,
method: 'GET',
signal,
});
};
export const getGetSystemDashboardQueryKey = ({
name,
}: GetSystemDashboardPathParameters) => {
return [`/api/v2/dashboards/system/${name}`] as const;
};
export const getGetSystemDashboardQueryOptions = <
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ name }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ name });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getSystemDashboard>>
> = ({ signal }) => getSystemDashboard({ name }, signal);
return {
queryKey,
queryFn,
enabled: !!name,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSystemDashboardQueryResult = NonNullable<
Awaited<ReturnType<typeof getSystemDashboard>>
>;
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get system dashboard
*/
export function useGetSystemDashboard<
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ name }: GetSystemDashboardPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSystemDashboard>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSystemDashboardQueryOptions({ name }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get system dashboard
*/
export const invalidateGetSystemDashboard = async (
queryClient: QueryClient,
{ name }: GetSystemDashboardPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetSystemDashboardQueryKey({ name }) },
options,
);
return queryClient;
};
/**
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
* @summary Get public dashboard data (v2)

View File

@@ -0,0 +1,298 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
GetQuickFilters200,
GetQuickFiltersPathParameters,
ListQuickFilters200,
QuickfiltertypesUpdatableQuickFiltersDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* Returns the org's quick filters for every signal, each filter as a telemetry field key.
* @summary List quick filters
*/
export const listQuickFilters = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListQuickFilters200>({
url: `/api/v2/quick_filters`,
method: 'GET',
signal,
});
};
export const getListQuickFiltersQueryKey = () => {
return [`/api/v2/quick_filters`] as const;
};
export const getListQuickFiltersQueryOptions = <
TData = Awaited<ReturnType<typeof listQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListQuickFiltersQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listQuickFilters>>> = ({
signal,
}) => listQuickFilters(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListQuickFiltersQueryResult = NonNullable<
Awaited<ReturnType<typeof listQuickFilters>>
>;
export type ListQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List quick filters
*/
export function useListQuickFilters<
TData = Awaited<ReturnType<typeof listQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listQuickFilters>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListQuickFiltersQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List quick filters
*/
export const invalidateListQuickFilters = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListQuickFiltersQueryKey() },
options,
);
return queryClient;
};
/**
* Replaces the org's quick filters for the signal named in the body.
* @summary Update quick filters
*/
export const updateQuickFilters = (
quickfiltertypesUpdatableQuickFiltersDTO?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/quick_filters`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: quickfiltertypesUpdatableQuickFiltersDTO,
signal,
});
};
export const getUpdateQuickFiltersMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
> => {
const mutationKey = ['updateQuickFilters'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateQuickFilters>>,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> }
> = (props) => {
const { data } = props ?? {};
return updateQuickFilters(data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateQuickFiltersMutationResult = NonNullable<
Awaited<ReturnType<typeof updateQuickFilters>>
>;
export type UpdateQuickFiltersMutationBody =
| BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>
| undefined;
export type UpdateQuickFiltersMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update quick filters
*/
export const useUpdateQuickFilters = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateQuickFilters>>,
TError,
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
TContext
> => {
return useMutation(getUpdateQuickFiltersMutationOptions(options));
};
/**
* Returns the org's quick filters for one signal, each filter as a telemetry field key.
* @summary Get a signal's quick filters
*/
export const getQuickFilters = (
{ signalName }: GetQuickFiltersPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetQuickFilters200>({
url: `/api/v2/quick_filters/${signalName}`,
method: 'GET',
signal,
});
};
export const getGetQuickFiltersQueryKey = ({
signalName,
}: GetQuickFiltersPathParameters) => {
return [`/api/v2/quick_filters/${signalName}`] as const;
};
export const getGetQuickFiltersQueryOptions = <
TData = Awaited<ReturnType<typeof getQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ signalName }: GetQuickFiltersPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getQuickFilters>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetQuickFiltersQueryKey({ signalName });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getQuickFilters>>> = ({
signal,
}) => getQuickFilters({ signalName }, signal);
return {
queryKey,
queryFn,
enabled: !!signalName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getQuickFilters>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetQuickFiltersQueryResult = NonNullable<
Awaited<ReturnType<typeof getQuickFilters>>
>;
export type GetQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get a signal's quick filters
*/
export function useGetQuickFilters<
TData = Awaited<ReturnType<typeof getQuickFilters>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ signalName }: GetQuickFiltersPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getQuickFilters>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetQuickFiltersQueryOptions({ signalName }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get a signal's quick filters
*/
export const invalidateGetQuickFilters = async (
queryClient: QueryClient,
{ signalName }: GetQuickFiltersPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetQuickFiltersQueryKey({ signalName }) },
options,
);
return queryClient;
};

View File

@@ -4960,53 +4960,6 @@ export interface DashboardtypesGettablePublicDashboardDataV2DTO {
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export interface DashboardtypesGettableSystemDashboardDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesDashboardSpecDTO;
/**
* @type array,null
*/
tags: TagtypesGettableTagDTO[] | null;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export enum DashboardtypesPatchOpDTO {
add = 'add',
remove = 'remove',
@@ -8484,6 +8437,28 @@ export enum Querybuildertypesv5QueryTypeDTO {
clickhouse_sql = 'clickhouse_sql',
promql = 'promql',
}
export interface QuickfiltertypesSignalFiltersDTO {
/**
* @type array,null
*/
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @type string
*/
signal?: string;
}
export interface QuickfiltertypesUpdatableQuickFiltersDTO {
/**
* @type array,null
*/
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
/**
* @type string
*/
signal?: string;
}
export interface RenderErrorResponseDTO {
error: ErrorsJSONDTO;
/**
@@ -11360,17 +11335,6 @@ export type MigrateDashboardV2200 = {
status: string;
};
export type GetSystemDashboardPathParameters = {
name: string;
};
export type GetSystemDashboard200 = {
data: DashboardtypesGettableSystemDashboardDTO;
/**
* @type string
*/
status: string;
};
export type GetFeatures200 = {
/**
* @type array
@@ -11885,6 +11849,28 @@ export type GetPublicDashboardPanelQueryRangeV2200 = {
status: string;
};
export type ListQuickFilters200 = {
/**
* @type array,null
*/
data: QuickfiltertypesSignalFiltersDTO[] | null;
/**
* @type string
*/
status: string;
};
export type GetQuickFiltersPathParameters = {
signalName: string;
};
export type GetQuickFilters200 = {
data: QuickfiltertypesSignalFiltersDTO;
/**
* @type string
*/
status: string;
};
export type Readyz200 = {
data: FactoryResponseDTO;
/**

View File

@@ -1,25 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/quickFilters/getCustomFilters';
const getCustomFilters = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const { signal } = props;
try {
const response = await axios.get(`/orgs/me/filters/${signal}`);
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default getCustomFilters;

View File

@@ -1,13 +0,0 @@
import axios from 'api';
import { AxiosError } from 'axios';
import { SuccessResponse } from 'types/api';
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
const updateCustomFiltersAPI = async (
props: UpdateCustomFiltersProps,
): Promise<SuccessResponse<void> | AxiosError> =>
axios.put(`/orgs/me/filters`, {
...props.data,
});
export default updateCustomFiltersAPI;

View File

@@ -3,6 +3,7 @@ import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { LoaderCircle } from '@signozhq/icons';
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
IQuickFiltersConfig,
QuickFilterChangeEventData,
@@ -74,6 +75,10 @@ export default function CheckboxFilterV2(
searchText,
existingQuery,
metricNamespace: useFieldApis.metricNamespace,
source:
source === QuickFiltersSource.METER_EXPLORER
? TelemetrytypesSourceDTO.meter
: undefined,
startUnixMilli: useFieldApis.startUnixMilli,
endUnixMilli: useFieldApis.endUnixMilli,
enabled: isOpen,

View File

@@ -1,7 +1,11 @@
import { useMemo } from 'react';
import { useGetFieldsValues } from 'api/generated/services/fields';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import {
TelemetrytypesSignalDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { FIELD_API_CACHE_TIME } from 'constants/queryCacheTime';
@@ -10,6 +14,7 @@ interface UseFieldValuesProps {
searchText: string;
existingQuery?: string;
metricNamespace?: string;
source?: TelemetrytypesSourceDTO;
startUnixMilli?: number;
endUnixMilli?: number;
enabled: boolean;
@@ -33,6 +38,7 @@ export function useFieldValues({
searchText,
existingQuery,
metricNamespace,
source,
startUnixMilli,
endUnixMilli,
enabled,
@@ -46,6 +52,7 @@ export function useFieldValues({
searchText,
existingQuery,
metricNamespace,
source,
startUnixMilli,
// This field does not affect the backend but I wanted to keep it here
// in case we add the support in the future
@@ -75,6 +82,12 @@ export function useFieldValues({
}, [data]);
const allValues: string[] = useMemo(() => {
// Bool fields should always offer true/false.
// The values api returns nothing for them.
if (filter.attributeKey.dataType === DataTypes.bool) {
return ['true', 'false'];
}
const values = data?.data?.values;
if (!values) {
return [];
@@ -91,7 +104,7 @@ export function useFieldValues({
.map((value) => value.toString()) || [];
return [...stringValues, ...numberValues];
}, [data]);
}, [data, filter.attributeKey.dataType]);
return { relatedValues, allValues, isLoading, isFetching };
}

View File

@@ -17,7 +17,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Button } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from '@signozhq/icons';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
function SortableFilter({
filter,
@@ -25,13 +25,13 @@ function SortableFilter({
allowDrag,
allowRemove,
}: {
filter: FilterType;
onRemove: (filter: FilterType) => void;
filter: TelemetryFieldKey;
onRemove: (filter: TelemetryFieldKey) => void;
allowDrag: boolean;
allowRemove: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: filter.key });
useSortable({ id: filter.key as string });
const style = {
transform: CSS.Transform.toString(transform),
@@ -46,14 +46,14 @@ function SortableFilter({
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={16} />}
{filter.key}
{filter.name}
</div>
{allowRemove && (
<Button
className="remove-filter-btn periscope-btn"
size="small"
onClick={(): void => {
onRemove(filter as FilterType);
onRemove(filter);
}}
>
Remove
@@ -69,8 +69,8 @@ function AddedFilters({
setAddedFilters,
}: {
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
@@ -90,12 +90,12 @@ function AddedFilters({
const filteredAddedFilters = useMemo(
() =>
addedFilters.filter((filter) =>
filter.key.toLowerCase().includes(inputValue.toLowerCase()),
filter.name.toLowerCase().includes(inputValue.toLowerCase()),
),
[addedFilters, inputValue],
);
const handleRemoveFilter = (filter: FilterType): void => {
const handleRemoveFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => prev.filter((f) => f.key !== filter.key));
};
@@ -116,7 +116,7 @@ function AddedFilters({
<div className="no-values-found">No values found</div>
) : (
<SortableContext
items={addedFilters.map((f) => f.key)}
items={addedFilters.map((f) => f.key as string)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>

View File

@@ -4,14 +4,9 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { SignalType } from 'components/QuickFilters/types';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { DataSource } from 'types/common/queryBuilder';
import { FieldContext, TelemetryFieldKey } from 'types/api/v5/queryRange';
function OtherFiltersSkeleton(): JSX.Element {
return (
@@ -37,106 +32,49 @@ function OtherFilters({
}: {
signal: SignalType | undefined;
inputValue: string;
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
}): JSX.Element {
const isLogDataSource = useMemo(
() => SIGNAL_DATA_SOURCE_MAP[signal as SignalType] === DataSource.LOGS,
[signal],
);
const isMeterDataSource = useMemo(
() => signal && signal === SignalType.METER_EXPLORER,
[signal],
const isMeterDataSource = signal === SignalType.METER_EXPLORER;
const { data, isFetching } = useGetQueryKeySuggestions(
{
searchText: inputValue,
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
signalSource: isMeterDataSource ? 'meter' : '',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, signal, inputValue],
enabled: !!signal,
},
);
const { data: suggestionsData, isFetching: isFetchingSuggestions } =
useGetAttributeSuggestions(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
filters: {} as TagFilter,
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isLogDataSource,
},
);
const otherFilters = useMemo<TelemetryFieldKey[]>(() => {
const rawSuggestions = Object.values(data?.data?.data?.keys || {}).flat();
// Normalize: synthesize the composite `key` once so downstream reads (dedupe,
// add, render) can trust it.
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
name: attr.name,
signal: attr.signal,
fieldContext: attr.fieldContext as FieldContext,
fieldDataType: attr.fieldDataType,
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
}));
const { data: aggregateKeysData, isFetching: isFetchingAggregateKeys } =
useGetAggregateKeys(
{
searchText: inputValue,
dataSource: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && !isLogDataSource && !isMeterDataSource,
},
const addedKeys = new Set(
addedFilters.map((filter) =>
buildCompositeKey(filter.name, filter.fieldContext, filter.fieldDataType),
),
);
return suggestions.filter((attr) => !addedKeys.has(attr.key as string));
}, [data, addedFilters]);
const { data: fieldKeysData, isLoading: isLoadingFieldKeys } =
useGetQueryKeySuggestions(
{
searchText: inputValue,
signal: SIGNAL_DATA_SOURCE_MAP[signal as SignalType],
signalSource: 'meter',
},
{
queryKey: [REACT_QUERY_KEY.GET_OTHER_FILTERS, inputValue],
enabled: !!signal && isMeterDataSource,
},
);
const otherFilters = useMemo(() => {
let filterAttributes;
if (isLogDataSource) {
filterAttributes = suggestionsData?.payload?.attributes || [];
} else if (isMeterDataSource) {
const fieldKeys: QueryKeyDataSuggestionsProps[] = Object.values(
fieldKeysData?.data?.data?.keys || {},
)?.flat();
filterAttributes = fieldKeys.map(
(attr) =>
({
key: attr.name,
dataType: attr.fieldDataType,
type: attr.fieldContext,
signal: attr.signal,
}) as BaseAutocompleteData,
);
} else {
filterAttributes = aggregateKeysData?.payload?.attributeKeys || [];
}
return filterAttributes?.filter(
(attr) => !addedFilters.some((filter) => filter.key === attr.key),
);
}, [
suggestionsData,
aggregateKeysData,
addedFilters,
isLogDataSource,
fieldKeysData,
isMeterDataSource,
]);
const handleAddFilter = (filter: FilterType): void => {
setAddedFilters((prev) => [
...prev,
{
key: filter.key,
dataType: filter.dataType,
type: filter.type,
},
]);
const handleAddFilter = (filter: TelemetryFieldKey): void => {
setAddedFilters((prev) => [...prev, filter]);
};
const renderFilters = (): React.ReactNode => {
const isLoading =
isFetchingSuggestions || isFetchingAggregateKeys || isLoadingFieldKeys;
if (isLoading) {
if (isFetching) {
return <OtherFiltersSkeleton />;
}
if (!otherFilters?.length) {
@@ -145,11 +83,11 @@ function OtherFilters({
return otherFilters.map((filter) => (
<div key={filter.key} className="qf-filter-item other-filters-item">
<div className="qf-filter-key">{filter.key}</div>
<div className="qf-filter-key">{filter.name}</div>
<Button
className="add-filter-btn periscope-btn"
size="small"
onClick={(): void => handleAddFilter(filter as FilterType)}
onClick={(): void => handleAddFilter(filter)}
>
Add
</Button>

View File

@@ -1,8 +1,7 @@
import { useMemo } from 'react';
import { Input } from '@signozhq/ui/input';
import { Button } from 'antd';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { SignalType } from '../types';
import AddedFilters from './AddedFilters';
@@ -19,7 +18,7 @@ function QuickFiltersSettings({
}: {
signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
refetchCustomFilters: () => void;
}): JSX.Element {
const {
@@ -28,6 +27,7 @@ function QuickFiltersSettings({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
handleInputChange,
@@ -39,18 +39,6 @@ function QuickFiltersSettings({
signal,
});
const hasUnsavedChanges = useMemo(
() =>
// check if both arrays have the same length and same order of elements
!(
addedFilters.length === customFilters.length &&
addedFilters.every(
(filter, index) => filter.key === customFilters[index].key,
)
),
[addedFilters, customFilters],
);
return (
<>
<div className="qf-header">

View File

@@ -1,27 +1,31 @@
import { useCallback, useState } from 'react';
import { useMutation } from 'react-query';
import { useCallback, useMemo, useState } from 'react';
import { useUpdateQuickFilters } from 'api/generated/services/quick-filter';
import logEvent from 'api/common/logEvent';
import updateCustomFiltersAPI from 'api/quickFilters/updateCustomFilters';
import axios, { AxiosError } from 'axios';
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SignalType } from 'components/QuickFilters/types';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
refetchCustomFilters: () => void;
signal?: SignalType;
}
interface UseQuickFilterSettingsReturn {
addedFilters: FilterType[];
setAddedFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
addedFilters: TelemetryFieldKey[];
setAddedFilters: React.Dispatch<React.SetStateAction<TelemetryFieldKey[]>>;
handleSettingsClose: () => void;
handleDiscardChanges: () => void;
handleSaveChanges: () => void;
hasUnsavedChanges: boolean;
isUpdatingCustomFilters: boolean;
inputValue: string;
setInputValue: React.Dispatch<React.SetStateAction<string>>;
@@ -37,27 +41,43 @@ const useQuickFilterSettings = ({
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
const [debouncedInputValue, setDebouncedInputValue] = useState<string>('');
const [addedFilters, setAddedFilters] = useState<FilterType[]>(customFilters);
const normalizedCustomFilters = useMemo<TelemetryFieldKey[]>(
() =>
customFilters.map((filter) => ({
...filter,
key: buildCompositeKey(
filter.name,
filter.fieldContext,
filter.fieldDataType,
),
})),
[customFilters],
);
const [addedFilters, setAddedFilters] = useState<TelemetryFieldKey[]>(
normalizedCustomFilters,
);
const { notifications } = useNotifications();
const { mutate: updateCustomFilters, isLoading: isUpdatingCustomFilters } =
useMutation(updateCustomFiltersAPI, {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error: AxiosError) => {
notifications.error({
message: axios.isAxiosError(error) ? error.message : SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
useUpdateQuickFilters({
mutation: {
onSuccess: () => {
setIsSettingsOpen(false);
refetchCustomFilters();
void logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});
notifications.success({
message: 'Quick filters updated successfully',
placement: 'bottomRight',
});
},
onError: (error) => {
notifications.error({
message: error.message || SOMETHING_WENT_WRONG,
placement: 'bottomRight',
});
},
},
});
const debouncedUpdate = useDebouncedFn((value) => {
@@ -78,17 +98,30 @@ const useQuickFilterSettings = ({
}, [setIsSettingsOpen]);
const handleDiscardChanges = useCallback((): void => {
setAddedFilters(customFilters);
}, [customFilters, setAddedFilters]);
setAddedFilters(normalizedCustomFilters);
}, [normalizedCustomFilters, setAddedFilters]);
const hasUnsavedChanges = useMemo(
() =>
!(
addedFilters.length === normalizedCustomFilters.length &&
addedFilters.every(
(filter, index) => filter.key === normalizedCustomFilters[index].key,
)
),
[addedFilters, normalizedCustomFilters],
);
const handleSaveChanges = useCallback((): void => {
if (signal) {
updateCustomFilters({
data: {
// Send only the stored TelemetryFieldKey fields; the composite `key`
// is UI-only.
filters: addedFilters.map((filter) => ({
key: filter.key,
datatype: filter.dataType,
type: filter.type,
name: filter.name,
fieldContext: filter.fieldContext as TelemetrytypesFieldContextDTO,
fieldDataType: filter.fieldDataType as TelemetrytypesFieldDataTypeDTO,
})),
signal,
},
@@ -102,6 +135,7 @@ const useQuickFilterSettings = ({
addedFilters,
setAddedFilters,
handleSaveChanges,
hasUnsavedChanges,
isUpdatingCustomFilters,
inputValue,
setInputValue,

View File

@@ -1,8 +1,6 @@
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import getCustomFilters from 'api/quickFilters/getCustomFilters';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { useGetQuickFilters } from 'api/generated/services/quick-filter';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils';
@@ -13,7 +11,7 @@ interface UseFilterConfigProps {
}
interface UseFilterConfigReturn {
filterConfig: IQuickFiltersConfig[];
customFilters: FilterType[];
customFilters: TelemetryFieldKey[];
isCustomFiltersLoading: boolean;
isDynamicFilters: boolean;
refetchCustomFilters: () => void;
@@ -25,17 +23,16 @@ const useFilterConfig = ({
}: UseFilterConfigProps): UseFilterConfigReturn => {
const {
isFetching: isCustomFiltersLoading,
data: customFilters = [],
data,
refetch,
} = useQuery<FilterType[], Error>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
async () => {
const res = await getCustomFilters({ signal: signal || '' });
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
},
{
enabled: !!signal,
},
} = useGetQuickFilters(
{ signalName: signal ?? '' },
{ query: { enabled: !!signal } },
);
const customFilters = useMemo<TelemetryFieldKey[]>(
() => (data?.data?.filters ?? []) as TelemetryFieldKey[],
[data],
);
const isDynamicFilters = useMemo(

View File

@@ -0,0 +1,24 @@
import { useMemo } from 'react';
import {
NANO_SECOND_MULTIPLIER,
useLastComputedMinMax,
} from 'store/globalTime';
import { QuickFilterCheckboxUseFieldApis } from '../types';
/**
* Builds the `useFieldApis` config for a signal quick-filter page.
* if existingQuery is sent null, related values are not fetched
*/
export function useSignalFieldApis(): QuickFilterCheckboxUseFieldApis {
const { minTime, maxTime } = useLastComputedMinMax();
return useMemo(
() => ({
startUnixMilli: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endUnixMilli: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
existingQuery: null,
}),
[minTime, maxTime],
);
}

View File

@@ -11,7 +11,7 @@ import {
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import '@testing-library/jest-dom';
@@ -34,9 +34,9 @@ const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/${SIGNAL}`;
const saveQuickFiltersURL = `${BASE_URL}/api/v2/quick_filters`;
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v1/fields/keys`;
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
@@ -338,6 +338,63 @@ describe('Quick Filters with custom filters', () => {
);
});
it('keeps same-name fields with different context as distinct entries', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(quickFiltersSuggestionsURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
complete: true,
keys: {
level: [
{
name: 'level',
fieldContext: 'attribute',
fieldDataType: 'string',
signal: 'logs',
},
{
name: 'level',
fieldContext: 'span',
fieldDataType: 'string',
signal: 'logs',
},
],
},
},
}),
),
),
);
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
// Both `level` variants are shown despite sharing a name.
await waitFor(() =>
expect(within(otherSection).getAllByText('level')).toHaveLength(2),
);
// Adding one variant removes only that one; the other stays.
const firstLevel = within(otherSection).getAllByText('level')[0];
const addButton = firstLevel.parentElement?.querySelector('button');
await user.click(addButton as HTMLButtonElement);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
await waitFor(() => {
expect(within(addedSection).getAllByText('level')).toHaveLength(1);
expect(within(otherSection).getAllByText('level')).toHaveLength(1);
});
});
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -458,7 +515,7 @@ describe('Quick Filters with custom filters', () => {
const requestBody = putHandler.mock.calls[0][0];
expect(requestBody.filters).toStrictEqual(
expect.arrayContaining([
expect.not.objectContaining({ key: FILTER_OS_DESCRIPTION }),
expect.not.objectContaining({ name: FILTER_OS_DESCRIPTION }),
]),
);
expect(requestBody.signal).toBe(SIGNAL);
@@ -612,9 +669,9 @@ describe('Quick Filters refetch behavior', () => {
filters: [
...(quickFiltersListResponse.data.filters ?? []),
{
key: 'new.custom.filter',
dataType: 'string',
type: 'resource',
name: 'new.custom.filter',
fieldDataType: 'string',
fieldContext: 'resource',
} as const,
],
},

View File

@@ -1,5 +1,10 @@
import {
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SIGNAL_DATA_SOURCE_MAP } from 'components/QuickFilters/QuickFiltersSettings/constants';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { FiltersType, IQuickFiltersConfig, SignalType } from './types';
@@ -12,6 +17,39 @@ const FILTER_TYPE_MAP: Record<string, FiltersType> = {
duration_nano: FiltersType.DURATION,
};
// Both maps below (and mapFieldDataType/mapFieldContext) exist only for the old
// v3 attribute-values fetch in useCheckboxFilterValues, which is the sole reader
// of attributeKey.dataType/type. Query/list endpoints don't consume them (v5 and
// the API-monitoring/exceptions/infra paths all send a name-based expression).
// Once the values fetch moves to fields/values (by name) in Phase A, this whole
// mapping can be removed and attributeKey reduced to { id, key }.
// The new field data types are rendered down to the v3 spellings the
// attribute-values call expects, matching the backend's legacy conversion
// (number -> float64).
const FIELD_DATA_TYPE_TO_DATA_TYPE: Record<string, DataTypes> = {
[TelemetrytypesFieldDataTypeDTO.string]: DataTypes.String,
[TelemetrytypesFieldDataTypeDTO.bool]: DataTypes.bool,
[TelemetrytypesFieldDataTypeDTO.float64]: DataTypes.Float64,
[TelemetrytypesFieldDataTypeDTO.int64]: DataTypes.Int64,
[TelemetrytypesFieldDataTypeDTO.number]: DataTypes.Float64,
};
// Only tag and resource exist in the v3 attribute-type enum; other contexts
// render as empty so the still-live v3 values path never sees a spelling it
// can't use, matching the backend's legacy conversion.
const FIELD_CONTEXT_TO_ATTRIBUTE_TYPE: Record<string, string> = {
[TelemetrytypesFieldContextDTO.attribute]: 'tag',
[TelemetrytypesFieldContextDTO.resource]: 'resource',
};
const mapFieldDataType = (fieldDataType?: string): DataTypes =>
(fieldDataType && FIELD_DATA_TYPE_TO_DATA_TYPE[fieldDataType]) ||
DataTypes.EMPTY;
const mapFieldContext = (fieldContext?: string): string =>
(fieldContext && FIELD_CONTEXT_TO_ATTRIBUTE_TYPE[fieldContext]) || '';
const getFilterName = (str: string): string => {
if (FILTER_TITLE_MAP[str]) {
return FILTER_TITLE_MAP[str];
@@ -26,16 +64,16 @@ const getFilterName = (str: string): string => {
.join(' ');
};
const getFilterType = (att: FilterType): FiltersType => {
if (FILTER_TYPE_MAP[att.key]) {
return FILTER_TYPE_MAP[att.key];
const getFilterType = (att: TelemetryFieldKey): FiltersType => {
if (FILTER_TYPE_MAP[att.name]) {
return FILTER_TYPE_MAP[att.name];
}
return FiltersType.CHECKBOX;
};
export const getFilterConfig = (
signal?: SignalType,
customFilters?: FilterType[],
customFilters?: TelemetryFieldKey[],
config?: IQuickFiltersConfig[],
): IQuickFiltersConfig[] => {
if (!customFilters?.length || !signal) {
@@ -46,13 +84,13 @@ export const getFilterConfig = (
(att, index) =>
({
type: getFilterType(att),
title: getFilterName(att.key),
title: getFilterName(att.name),
dataSource: SIGNAL_DATA_SOURCE_MAP[signal],
attributeKey: {
id: att.key,
key: att.key,
dataType: att.dataType,
type: att.type,
id: att.name,
key: att.name,
dataType: mapFieldDataType(att.fieldDataType),
type: mapFieldContext(att.fieldContext),
},
defaultOpen: index < 2,
}) as IQuickFiltersConfig,

View File

@@ -3,6 +3,7 @@ import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
@@ -11,6 +12,8 @@ import DomainList from './Domains/DomainList';
import './Explorer.styles.scss';
function Explorer(): JSX.Element {
const quickFilterFieldApis = useSignalFieldApis();
useEffect(() => {
logEvent('API Monitoring: Landing page visited', {});
}, []);
@@ -26,6 +29,7 @@ function Explorer(): JSX.Element {
showFilterCollapse={false}
showQueryName={false}
handleFilterVisibilityChange={(): void => {}}
useFieldApis={quickFilterFieldApis}
/>
</section>
<DomainList />

View File

@@ -6,6 +6,7 @@ import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
@@ -31,6 +32,7 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
import './Explorer.styles.scss';
function Explorer(): JSX.Element {
const quickFilterFieldApis = useSignalFieldApis();
const {
handleRunQuery,
stagedQuery,
@@ -141,6 +143,7 @@ function Explorer(): JSX.Element {
handleFilterVisibilityChange={(): void => {
setShowQuickFilters(!showQuickFilters);
}}
useFieldApis={quickFilterFieldApis}
/>
</div>

View File

@@ -4,114 +4,85 @@ export const quickFiltersListResponse = {
signal: 'logs',
filters: [
{
key: 'os.description',
dataType: 'string',
type: 'resource',
name: 'os.description',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'service.name',
dataType: 'string',
type: 'resource',
name: 'service.name',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'duration_nano',
dataType: 'float64',
type: 'tag',
name: 'duration_nano',
fieldDataType: 'float64',
fieldContext: 'attribute',
},
{
key: 'quantity',
dataType: 'float64',
type: 'tag',
name: 'quantity',
fieldDataType: 'float64',
fieldContext: 'attribute',
},
{
key: 'body',
dataType: 'string',
type: '',
name: 'body',
fieldDataType: 'string',
fieldContext: '',
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
name: 'deployment.environment',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'service.namespace',
dataType: 'string',
type: 'resource',
name: 'service.namespace',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
name: 'k8s.namespace.name',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
name: 'service.instance.id',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
name: 'k8s.pod.name',
fieldDataType: 'string',
fieldContext: 'resource',
},
{
key: 'process.owner',
dataType: 'string',
type: 'resource',
name: 'process.owner',
fieldDataType: 'string',
fieldContext: 'resource',
},
],
},
};
const otherFilterName = (name: string): { [k: string]: unknown[] } => ({
[name]: [
{ name, fieldContext: 'resource', fieldDataType: 'string', signal: 'logs' },
],
});
export const otherFiltersResponse = {
status: 'success',
data: {
attributes: [
{
key: 'service.name',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.deployment.name',
dataType: 'string',
type: 'resource',
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
},
{
key: 'service.namespace',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.namespace.name',
dataType: 'string',
type: 'resource',
},
{
key: 'service.instance.id',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.pod.name',
dataType: 'string',
type: 'resource',
},
{
key: 'k8s.pod.uid',
dataType: 'string',
type: 'resource',
},
{
key: 'os.description',
dataType: 'string',
type: 'resource',
},
],
complete: true,
keys: {
...otherFilterName('service.name'),
...otherFilterName('k8s.deployment.name'),
...otherFilterName('deployment.environment'),
...otherFilterName('service.namespace'),
...otherFilterName('k8s.namespace.name'),
...otherFilterName('service.instance.id'),
...otherFilterName('k8s.pod.name'),
...otherFilterName('k8s.pod.uid'),
...otherFilterName('os.description'),
},
},
};

View File

@@ -8,6 +8,7 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
import cx from 'classnames';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import RouteTab from 'components/RouteTab';
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
@@ -55,6 +56,8 @@ function AllErrors(): JSX.Element {
setShowFilters((prev) => !prev);
};
const quickFilterFieldApis = useSignalFieldApis();
return (
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
{showFilters && (
@@ -64,6 +67,7 @@ function AllErrors(): JSX.Element {
source={QuickFiltersSource.EXCEPTIONS}
signal={SignalType.EXCEPTIONS}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={quickFilterFieldApis}
/>
</section>
)}

View File

@@ -7,6 +7,7 @@ import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -74,6 +75,8 @@ function LogsExplorer(): JSX.Element {
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const quickFilterFieldApis = useSignalFieldApis();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const listQueryKeyRef = useRef<any>();
@@ -232,6 +235,7 @@ function LogsExplorer(): JSX.Element {
signal={SignalType.LOGS}
source={QuickFiltersSource.LOGS_EXPLORER}
handleFilterVisibilityChange={handleFilterVisibilityChange}
useFieldApis={quickFilterFieldApis}
/>
</section>
)}

View File

@@ -504,7 +504,7 @@ jest.mock('hooks/useHandleExplorerTabChange', () => ({
let capturedPayload: QueryRangePayloadV5;
describe('TracesExplorer -', () => {
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/traces`;
const quickFiltersListURL = `${BASE_URL}/api/v2/quick_filters/traces`;
const setupServer = (): void => {
server.use(

View File

@@ -8,6 +8,7 @@ import cx from 'classnames';
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -128,6 +129,8 @@ function TracesExplorer(): JSX.Element {
);
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const quickFilterFieldApis = useSignalFieldApis();
const { safeNavigate } = useSafeNavigate();
const getExportToDashboardLink = useGetExportToDashboardLink();
@@ -267,6 +270,7 @@ function TracesExplorer(): JSX.Element {
handleFilterVisibilityChange={(): void => {
setOpen(!isOpen);
}}
useFieldApis={quickFilterFieldApis}
/>
</Card>
<div

View File

@@ -1,14 +0,0 @@
export interface Filter {
key: string;
dataType: string;
type: string;
}
export interface Props {
signal: string;
}
export type PayloadProps = {
filters: Filter[];
signal: string;
};

View File

@@ -1,14 +0,0 @@
import { SignalType } from 'components/QuickFilters/types';
interface FilterType {
key: string;
datatype: string;
type: string;
}
export interface UpdateCustomFiltersProps {
data: {
filters: FilterType[];
signal: SignalType;
};
}

View File

@@ -332,33 +332,6 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/dashboards/system/{name}", handler.New(
provider.authzMiddleware.CheckResources(provider.dashboardHandler.GetSystemDashboard, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetSystemDashboard",
Tags: []string{"dashboard"},
Summary: "Get system dashboard",
Description: "Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableSystemDashboard),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: provider.systemDashboardID(),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
// Pinning mutates the calling user's pin list, not the dashboard, so it rides
// on the collection-level list permission rather than a per-dashboard check.
// The id is still extracted, for audit.
@@ -745,23 +718,3 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return nil
}
// systemDashboardID resolves the {name} path param to the dashboard's id. Authz
// tuples and audit records are written against ids, so the name has to be
// resolved before either runs.
func (provider *provider) systemDashboardID() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
ctx := ec.Request.Context()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return "", err
}
systemDashboard, err := provider.dashboardModule.GetSystemDashboard(ctx, valuer.MustNewUUID(claims.OrgID), mux.Vars(ec.Request)["name"])
if err != nil {
return "", err
}
return systemDashboard.ID.StringValue(), nil
})
}

View File

@@ -24,6 +24,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/savedview"
@@ -80,6 +81,7 @@ type provider struct {
llmPricingRuleHandler llmpricingrule.Handler
statsHandler statsreporter.Handler
savedViewHandler savedview.Handler
quickFilterHandler quickfilter.Handler
}
func NewFactory(
@@ -118,6 +120,7 @@ func NewFactory(
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
quickFilterHandler quickfilter.Handler,
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
return newProvider(
@@ -159,6 +162,7 @@ func NewFactory(
rulerHandler,
statsHandler,
savedViewHandler,
quickFilterHandler,
)
})
}
@@ -202,6 +206,7 @@ func newProvider(
rulerHandler ruler.Handler,
statsHandler statsreporter.Handler,
savedViewHandler savedview.Handler,
quickFilterHandler quickfilter.Handler,
) (apiserver.APIServer, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
router := mux.NewRouter().UseEncodedPath()
@@ -244,6 +249,7 @@ func newProvider(
llmPricingRuleHandler: llmPricingRuleHandler,
statsHandler: statsHandler,
savedViewHandler: savedViewHandler,
quickFilterHandler: quickFilterHandler,
}
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
@@ -384,6 +390,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
return err
}
if err := provider.addQuickFilterRoutes(router); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,93 @@
package signozapiserver
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/gorilla/mux"
)
func (provider *provider) addQuickFilterRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/quick_filters", handler.New(
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.ListQuickFiltersV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListQuickFilters",
Tags: []string{"quick_filter"},
Summary: "List quick filters",
Description: "Returns the org's quick filters for every signal, each filter as a telemetry field key.",
Request: nil,
RequestContentType: "",
Response: new([]*quickfiltertypes.SignalFilters),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceQuickFilter,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/quick_filters/{signal_name}", handler.New(
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.GetQuickFiltersV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetQuickFilters",
Tags: []string{"quick_filter"},
Summary: "Get a signal's quick filters",
Description: "Returns the org's quick filters for one signal, each filter as a telemetry field key.",
Request: nil,
RequestContentType: "",
Response: new(quickfiltertypes.SignalFilters),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceQuickFilter,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/quick_filters", handler.New(
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.UpdateQuickFiltersV2, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateQuickFilters",
Tags: []string{"quick_filter"},
Summary: "Update quick filters",
Description: "Replaces the org's quick filters for the signal named in the body.",
Request: new(quickfiltertypes.UpdatableQuickFilters),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceQuickFilter,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -99,14 +99,6 @@ type Module interface {
DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error)
// ════════════════════════════════════════════════════════════════════════
// System dashboard methods
// ════════════════════════════════════════════════════════════════════════
ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error
GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
}
type Handler interface {
@@ -170,6 +162,4 @@ type Handler interface {
UpdateView(http.ResponseWriter, *http.Request)
DeleteView(http.ResponseWriter, *http.Request)
GetSystemDashboard(http.ResponseWriter, *http.Request)
}

View File

@@ -1,17 +0,0 @@
{
"version": 1,
"definition": {
"schemaVersion": "v6",
"name": "signoz---ai-o11y-overview",
"tags": [],
"spec": {
"display": {
"name": "AI Observability Overview",
"description": "Overview of LLM traffic. Panels ship in an upcoming release."
},
"variables": [],
"panels": {},
"layouts": []
}
}
}

View File

@@ -21,25 +21,23 @@ import (
)
type module struct {
store dashboardtypes.Store
settings factory.ScopedProviderSettings
analytics analytics.Analytics
orgGetter organization.Getter
queryParser queryparser.QueryParser
tagModule tag.Module
systemDashboardRegistry dashboardtypes.SystemDashboardRegistry
store dashboardtypes.Store
settings factory.ScopedProviderSettings
analytics analytics.Analytics
orgGetter organization.Getter
queryParser queryparser.QueryParser
tagModule tag.Module
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard")
return &module{
store: store,
settings: scopedProviderSettings,
analytics: analytics,
orgGetter: orgGetter,
queryParser: queryParser,
tagModule: tagModule,
systemDashboardRegistry: systemDashboardRegistry,
store: store,
settings: scopedProviderSettings,
analytics: analytics,
orgGetter: orgGetter,
queryParser: queryParser,
tagModule: tagModule,
}
}

View File

@@ -3,7 +3,6 @@ package impldashboard
import (
"context"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -65,23 +64,6 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
func (store *store) GetByName(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableDashboard, error) {
storableDashboard := new(dashboardtypes.StorableDashboard)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(storableDashboard).
Where("name = ?", name).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with name %s doesn't exist", name)
}
return storableDashboard, nil
}
// ListForUser emits the joined dashboard ⨝ user_dashboard_preference query the
// spec calls for. Aliases:
//
@@ -631,60 +613,3 @@ func (store *store) DeleteDashboardView(ctx context.Context, orgID valuer.UUID,
}
return nil
}
func (store *store) CreateSystemDashboard(ctx context.Context, storable *dashboardtypes.StorableSystemDashboard) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(storable).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, dashboardtypes.ErrCodeSystemDashboardAlreadyProvisioned, "system dashboard %s is already provisioned", storable.Name)
}
return nil
}
func (store *store) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableSystemDashboard, error) {
storable := new(dashboardtypes.StorableSystemDashboard)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(storable).
Where("org_id = ?", orgID).
Where("name = ?", name).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
}
return storable, nil
}
func (store *store) UpdateSystemDashboardVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error {
result, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model(new(dashboardtypes.StorableSystemDashboard)).
Set("version = ?", version).
Set("updated_at = ?", time.Now()).
Where("org_id = ?", orgID).
Where("name = ?", name).
Exec(ctx)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
}
return nil
}

View File

@@ -1,46 +0,0 @@
package impldashboard
import (
"embed"
"io/fs"
"path"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
)
const definitionsRoot = "fs/definitions"
//go:embed fs/definitions/*.json
var definitionFiles embed.FS
// NewSystemDashboardRegistry parses every embedded definition. Definitions are
// build-time assets validated by a test, so a failure here means the binary
// shipped broken JSON.
func NewSystemDashboardRegistry() (dashboardtypes.SystemDashboardRegistry, error) {
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
if err != nil {
return dashboardtypes.SystemDashboardRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read system dashboard definitions")
}
definitions := make([]dashboardtypes.SystemDashboardDefinition, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
file := path.Join(definitionsRoot, entry.Name())
raw, err := definitionFiles.ReadFile(file)
if err != nil {
return dashboardtypes.SystemDashboardRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
}
definition, err := dashboardtypes.NewSystemDashboardDefinition(raw)
if err != nil {
return dashboardtypes.SystemDashboardRegistry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
}
definitions = append(definitions, definition)
}
return dashboardtypes.NewSystemDashboardRegistry(definitions)
}

View File

@@ -1,81 +0,0 @@
package impldashboard
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
)
const reconcileRetryInterval = 30 * time.Second
type service struct {
settings factory.ScopedProviderSettings
module dashboard.Module
orgGetter organization.Getter
stopC chan struct{}
healthyC chan struct{}
}
// NewService reconciles every org's system dashboards once at startup. Orgs
// created later are reconciled by the organization setter instead.
func NewService(providerSettings factory.ProviderSettings, module dashboard.Module, orgGetter organization.Getter) factory.Service {
return &service{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"),
module: module,
orgGetter: orgGetter,
stopC: make(chan struct{}),
healthyC: make(chan struct{}),
}
}
func (service *service) Start(ctx context.Context) error {
ticker := time.NewTicker(reconcileRetryInterval)
defer ticker.Stop()
for {
err := service.reconcile(ctx)
if err == nil {
close(service.healthyC)
<-service.stopC
return nil
}
service.settings.Logger().WarnContext(ctx, "system dashboard reconciliation failed, retrying", errors.Attr(err))
select {
case <-service.stopC:
return nil
case <-ticker.C:
}
}
}
func (service *service) Healthy() <-chan struct{} {
return service.healthyC
}
func (service *service) Stop(_ context.Context) error {
close(service.stopC)
return nil
}
func (service *service) reconcile(ctx context.Context) error {
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
if err != nil {
return err
}
for _, org := range orgs {
if err := service.module.ReconcileSystemDashboards(ctx, org.ID); err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile system dashboards for org %s", org.ID.StringValue())
}
}
service.settings.Logger().InfoContext(ctx, "system dashboard reconciliation completed", slog.Int("orgs", len(orgs)))
return nil
}

View File

@@ -502,28 +502,3 @@ func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *h
render.Success(rw, http.StatusOK, queryRangeResults)
}
func (handler *handler) GetSystemDashboard(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
name := mux.Vars(r)["name"]
if name == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "name is missing in the path"))
return
}
systemDashboard, err := handler.module.GetSystemDashboard(ctx, valuer.MustNewUUID(claims.OrgID), name)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, systemDashboard.ToGettableSystemDashboard())
}

View File

@@ -2,8 +2,6 @@ package impldashboard
import (
"context"
"log/slog"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/transition"
@@ -21,12 +19,9 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
return nil, err
}
dashboard, err := postable.NewDashboardV2(orgID, createdBy, source)
if err != nil {
return nil, err
}
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
err = m.store.RunInTx(ctx, func(ctx context.Context) error {
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
if err != nil {
return err
@@ -125,20 +120,6 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return storable.ToDashboardV2(tags)
}
func (module *module) getByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
storable, err := module.store.GetByName(ctx, orgID, name)
if err != nil {
return nil, err
}
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, storable.ID)
if err != nil {
return nil, err
}
return storable.ToDashboardV2(tags)
}
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
@@ -198,33 +179,13 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
return nil, err
}
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.Update)
}
// updateUnsafeV2 updates a dashboard bypassing the guards. Intended for internal system callers.
func (module *module) updateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updatable.Validate(); err != nil {
return nil, err
}
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.UpdateUnsafe)
}
// apply is existing.Update or existing.UpdateUnsafe, so the gated path keeps its
// in-transaction checks and only updateUnsafeV2 skips them.
func (module *module) updateV2(ctx context.Context, orgID valuer.UUID, existing *dashboardtypes.DashboardV2, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2, apply func(dashboardtypes.UpdatableDashboardV2, string, []*tagtypes.Tag) error) (*dashboardtypes.DashboardV2, error) {
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, existing.ID, updatable.Tags)
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
if err != nil {
return err
}
err = apply(updatable, updatedBy, resolvedTags)
err = existing.Update(updatable, updatedBy, resolvedTags)
if err != nil {
return err
}
@@ -335,98 +296,3 @@ func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID val
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
return module.store.DeletePreferencesForUser(ctx, orgID, userID)
}
func (m *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
for _, definition := range m.systemDashboardRegistry.List() {
if err := m.reconcileSystemDashboard(ctx, orgID, definition); err != nil {
return err
}
}
return nil
}
func (m *module) reconcileSystemDashboard(ctx context.Context, orgID valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
existing, err := m.getByNameV2(ctx, orgID, definition.Name())
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return err
}
return m.provisionSystemDashboard(ctx, orgID, definition)
}
state, err := m.store.GetSystemDashboard(ctx, orgID, definition.Name())
if err != nil {
return err
}
// Only ever move forward: a downgrade must not rewrite the newer content.
if state.Version >= definition.Version {
return nil
}
return m.upgradeSystemDashboard(ctx, orgID, existing.ID, definition)
}
// provisionSystemDashboard creates the dashboard and its state row in one transaction,
// so a system dashboard can never exist without the version it was provisioned at.
// A concurrent provisioner (another replica, or the org-creation hook racing the
// startup sweep) loses on the state row's unique (org_id, name) index and rolls back.
func (m *module) provisionSystemDashboard(ctx context.Context, orgID valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
created, err := m.CreateV2(
ctx,
orgID,
dashboardtypes.ProvisionerIdentity,
valuer.UUID{},
dashboardtypes.SourceSystem,
definition.Dashboard,
)
if err != nil {
return err
}
return m.store.CreateSystemDashboard(ctx, dashboardtypes.NewStorableSystemDashboard(orgID, created.ID, definition.Name(), definition.Version))
})
if err != nil {
if errors.Ast(err, errors.TypeAlreadyExists) {
m.settings.Logger().DebugContext(ctx, "system dashboard already provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
return nil
}
return err
}
m.settings.Logger().InfoContext(ctx, "provisioned system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
return nil
}
func (m *module) upgradeSystemDashboard(ctx context.Context, orgID valuer.UUID, id valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
if _, err := m.updateUnsafeV2(ctx, orgID, id, dashboardtypes.ProvisionerIdentity, definition.ToUpdatable()); err != nil {
return err
}
return m.store.UpdateSystemDashboardVersion(ctx, orgID, definition.Name(), definition.Version)
})
if err != nil {
return err
}
m.settings.Logger().InfoContext(ctx, "upgraded system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
return nil
}
func (m *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
if strings.HasPrefix(name, dashboardtypes.SystemDashboardNamePrefix) {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "name must not carry the %q prefix", dashboardtypes.SystemDashboardNamePrefix)
}
existing, err := m.getByNameV2(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+name)
if err != nil {
return nil, err
}
if err := existing.ErrIfNotSystem(); err != nil {
return nil, err
}
return existing, nil
}

View File

@@ -1,191 +0,0 @@
package impldashboard
import (
"context"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testDashboardName = "test-overview"
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
Sqlite: sqlstore.SqliteConfig{
Path: filepath.Join(t.TempDir(), "test.db"),
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
for _, model := range []any{
(*dashboardtypes.StorableDashboard)(nil),
(*tagtypes.Tag)(nil),
(*tagtypes.TagRelation)(nil),
(*dashboardtypes.StorableSystemDashboard)(nil),
} {
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
require.NoError(t, err)
}
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_system_dashboard_org_name ON system_dashboard (org_id, name)`)
require.NoError(t, err)
return store
}
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...dashboardtypes.SystemDashboardDefinition) *module {
t.Helper()
registry, err := dashboardtypes.NewSystemDashboardRegistry(definitions)
require.NoError(t, err)
providerSettings := factorytest.NewSettings()
return NewModule(
NewStore(sqlStore),
providerSettings,
analyticstest.New(),
nil,
queryparser.New(providerSettings),
impltag.NewModule(impltag.NewStore(sqlStore)),
registry,
).(*module)
}
func newTestDefinition(t *testing.T, version int, displayName string) dashboardtypes.SystemDashboardDefinition {
t.Helper()
raw := `{
"version": ` + strconv.Itoa(version) + `,
"definition": {
"schemaVersion": "` + dashboardtypes.SchemaVersion + `",
"name": "` + dashboardtypes.SystemDashboardNamePrefix + testDashboardName + `",
"tags": [],
"spec": {"display": {"name": "` + displayName + `"}, "variables": [], "panels": {}, "layouts": []}
}
}`
definition, err := dashboardtypes.NewSystemDashboardDefinition([]byte(raw))
require.NoError(t, err)
return definition
}
func TestReconcileProvisionsThenUpgrades(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
provisioned, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, dashboardtypes.SourceSystem, provisioned.Source)
assert.Equal(t, dashboardtypes.ProvisionerIdentity, provisioned.CreatedBy)
assert.Equal(t, "v1", provisioned.Spec.Display.Name)
assert.Equal(t, 1, stateVersion(t, dashboardModule, ctx, orgID))
// Reconciling the same version again is a no-op.
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
unchanged, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, provisioned.UpdatedAt, unchanged.UpdatedAt)
// An unmodified copy is upgraded in place, keeping its id.
upgradingModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
require.NoError(t, upgradingModule.ReconcileSystemDashboards(ctx, orgID))
upgraded, err := upgradingModule.GetSystemDashboard(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, provisioned.ID, upgraded.ID)
assert.Equal(t, "v2", upgraded.Spec.Display.Name)
assert.Equal(t, 2, stateVersion(t, upgradingModule, ctx, orgID))
}
func stateVersion(t *testing.T, module *module, ctx context.Context, orgID valuer.UUID) int {
t.Helper()
state, err := module.store.GetSystemDashboard(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
require.NoError(t, err)
return state.Version
}
func TestSystemDashboardsAreImmutableToUsers(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
provisioned, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
require.NoError(t, err)
_, err = dashboardModule.UpdateV2(ctx, orgID, provisioned.ID, "user@signoz.io", newTestDefinition(t, 1, "edited").ToUpdatable())
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot be modified")
}
func TestReconcileDoesNotDowngrade(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
newerModule := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
require.NoError(t, newerModule.ReconcileSystemDashboards(ctx, orgID))
olderModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
require.NoError(t, olderModule.ReconcileSystemDashboards(ctx, orgID))
got, err := newerModule.GetSystemDashboard(ctx, orgID, testDashboardName)
require.NoError(t, err)
assert.Equal(t, "v3", got.Spec.Display.Name)
assert.Equal(t, 3, stateVersion(t, newerModule, ctx, orgID))
}
func TestGetRejectsANonSystemDashboard(t *testing.T) {
ctx := context.Background()
sqlStore := newTestSQLStore(t)
orgID := valuer.GenerateUUID()
dashboardModule := newTestModule(t, sqlStore)
var postable dashboardtypes.PostableDashboardV2
require.NoError(t, postable.UnmarshalJSON([]byte(`{
"schemaVersion": "`+dashboardtypes.SchemaVersion+`",
"name": "a-user-dashboard",
"tags": [],
"spec": {"display": {"name": "user"}, "variables": [], "panels": {}, "layouts": []}
}`)))
_, err := dashboardModule.CreateV2(ctx, orgID, "user@signoz.io", valuer.GenerateUUID(), dashboardtypes.SourceUser, postable)
require.NoError(t, err)
// The server-side prefix makes user names structurally unreachable here.
_, err = dashboardModule.GetSystemDashboard(ctx, orgID, "a-user-dashboard")
require.Error(t, err)
_, err = dashboardModule.GetSystemDashboard(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
require.Error(t, err)
assert.Contains(t, err.Error(), "must not carry")
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/types"
@@ -15,11 +14,10 @@ type setter struct {
store types.OrganizationStore
alertmanager alertmanager.Alertmanager
quickfilter quickfilter.Module
dashboard dashboard.Module
}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard}
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module) organization.Setter {
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
}
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
@@ -39,10 +37,6 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
return err
}
if err := module.dashboard.ReconcileSystemDashboards(ctx, organization.ID); err != nil {
return err
}
return nil
}

View File

@@ -4,10 +4,13 @@ import (
"encoding/json"
"net/http"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -20,6 +23,89 @@ func NewHandler(module quickfilter.Module) quickfilter.Handler {
return &handler{module: module}
}
// legacySignalFilters is the v1 API shape: filters as v3 attribute keys.
type legacySignalFilters struct {
Signal quickfiltertypes.Signal `json:"signal"`
Filters []v3.AttributeKey `json:"filters"`
}
// newTelemetryFieldKeysFromLegacy converts a v1 write payload with the same
// normalizations as the storage migration: alias contexts, numerics to number.
// The v1 shape carries no per filter signal, so meter keys get it restored.
func newTelemetryFieldKeysFromLegacy(signal quickfiltertypes.Signal, filters []v3.AttributeKey) ([]telemetrytypes.TelemetryFieldKey, error) {
var fieldSignal telemetrytypes.Signal
if signal == quickfiltertypes.SignalMeter {
fieldSignal = telemetrytypes.SignalMetrics
}
fieldKeys := make([]telemetrytypes.TelemetryFieldKey, 0, len(filters))
for _, filter := range filters {
if err := filter.Validate(); err != nil {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter: %v", err)
}
fieldContext, ok := telemetrytypes.FieldContextFromText(string(filter.Type))
if !ok {
fieldContext = telemetrytypes.FieldContextUnspecified
}
var fieldDataType telemetrytypes.FieldDataType
if err := fieldDataType.Scan(string(filter.DataType)); err != nil {
fieldDataType = telemetrytypes.FieldDataTypeUnspecified
}
if fieldDataType == telemetrytypes.FieldDataTypeInt64 {
fieldDataType = telemetrytypes.FieldDataTypeNumber
}
fieldKeys = append(fieldKeys, telemetrytypes.TelemetryFieldKey{
Name: filter.Key,
Signal: fieldSignal,
FieldContext: fieldContext,
FieldDataType: fieldDataType,
})
}
return fieldKeys, nil
}
// newLegacySignalFiltersFromSignalFilters renders stored telemetry field keys
// back into the v1 shape, restoring the legacy spellings v1 clients expect.
func newLegacySignalFiltersFromSignalFilters(signalFilters *quickfiltertypes.SignalFilters) *legacySignalFilters {
filters := make([]v3.AttributeKey, 0, len(signalFilters.Filters))
for _, fieldKey := range signalFilters.Filters {
// Only tag and resource exist in the v3 enum; other contexts render as
// unspecified so v1 clients never see spellings their queries can't use.
var attributeType v3.AttributeKeyType
switch fieldKey.FieldContext {
case telemetrytypes.FieldContextAttribute:
attributeType = v3.AttributeKeyTypeTag
case telemetrytypes.FieldContextResource:
attributeType = v3.AttributeKeyTypeResource
default:
attributeType = v3.AttributeKeyTypeUnspecified
}
var dataType v3.AttributeKeyDataType
switch fieldKey.FieldDataType {
case telemetrytypes.FieldDataTypeNumber:
dataType = v3.AttributeKeyDataTypeFloat64
default:
dataType = v3.AttributeKeyDataType(fieldKey.FieldDataType.StringValue())
}
filters = append(filters, v3.AttributeKey{
Key: fieldKey.Name,
Type: attributeType,
DataType: dataType,
})
}
return &legacySignalFilters{
Signal: signalFilters.Signal,
Filters: filters,
}
}
func (handler *handler) GetQuickFilters(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
@@ -27,36 +113,18 @@ func (handler *handler) GetQuickFilters(rw http.ResponseWriter, r *http.Request)
return
}
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID))
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), quickfiltertypes.Signal{})
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, filters)
}
func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
legacyFilters := make([]*legacySignalFilters, 0, len(filters))
for _, signalFilters := range filters {
legacyFilters = append(legacyFilters, newLegacySignalFiltersFromSignalFilters(signalFilters))
}
var req quickfiltertypes.UpdatableQuickFilters
decodeErr := json.NewDecoder(r.Body).Decode(&req)
if decodeErr != nil {
render.Error(rw, decodeErr)
return
}
err = handler.module.UpdateQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, req.Filters)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
render.Success(rw, http.StatusOK, legacyFilters)
}
func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request) {
@@ -73,7 +141,51 @@ func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request
return
}
filters, err := handler.module.GetSignalFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal)
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, newLegacySignalFiltersFromSignalFilters(filters[0]))
}
func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
var req legacySignalFilters
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
fieldKeys, err := newTelemetryFieldKeysFromLegacy(req.Signal, req.Filters)
if err != nil {
render.Error(rw, err)
return
}
err = handler.module.UpsertQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, fieldKeys)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) ListQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), quickfiltertypes.Signal{})
if err != nil {
render.Error(rw, err)
return
@@ -81,3 +193,48 @@ func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request
render.Success(rw, http.StatusOK, filters)
}
func (handler *handler) UpdateQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
var req quickfiltertypes.UpdatableQuickFilters
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
err = handler.module.UpsertQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, req.Filters)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) GetQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(rw, err)
return
}
signal := mux.Vars(r)["signal_name"]
validatedSignal, err := quickfiltertypes.NewSignal(signal)
if err != nil {
render.Error(rw, err)
return
}
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, filters[0])
}

View File

@@ -0,0 +1,62 @@
package implquickfilter
import (
"testing"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewTelemetryFieldKeysFromLegacy(t *testing.T) {
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalTraces, []v3.AttributeKey{
{Key: "service.name", Type: v3.AttributeKeyTypeResource, DataType: v3.AttributeKeyDataTypeString},
{Key: "http.method", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeString},
{Key: "duration_nano", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeFloat64},
{Key: "code_line", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeInt64},
})
require.NoError(t, err)
require.Len(t, fieldKeys, 4)
assert.Equal(t, telemetrytypes.TelemetryFieldKey{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString}, fieldKeys[0])
assert.Equal(t, telemetrytypes.FieldContextAttribute, fieldKeys[1].FieldContext)
assert.Equal(t, telemetrytypes.FieldDataTypeNumber, fieldKeys[2].FieldDataType)
assert.Equal(t, telemetrytypes.FieldDataTypeNumber, fieldKeys[3].FieldDataType)
t.Run("meter writes restore the per-filter signal", func(t *testing.T) {
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalMeter, []v3.AttributeKey{
{Key: "host.name", DataType: v3.AttributeKeyDataTypeString},
})
require.NoError(t, err)
require.Len(t, fieldKeys, 1)
assert.Equal(t, telemetrytypes.SignalMetrics, fieldKeys[0].Signal)
})
t.Run("rejects a filter without a key", func(t *testing.T) {
_, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalTraces, []v3.AttributeKey{{DataType: v3.AttributeKeyDataTypeString}})
require.Error(t, err)
})
}
func TestNewLegacySignalFiltersFromSignalFilters(t *testing.T) {
legacy := newLegacySignalFiltersFromSignalFilters(&quickfiltertypes.SignalFilters{
Signal: quickfiltertypes.SignalLogs,
Filters: []telemetrytypes.TelemetryFieldKey{
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "duration_nano", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
{Name: "severity_text", FieldContext: telemetrytypes.FieldContextLog, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "host.name", Signal: telemetrytypes.SignalMetrics},
},
})
assert.Equal(t, quickfiltertypes.SignalLogs, legacy.Signal)
require.Len(t, legacy.Filters, 5)
assert.Equal(t, v3.AttributeKey{Key: "service.name", Type: v3.AttributeKeyTypeResource, DataType: v3.AttributeKeyDataTypeString}, legacy.Filters[0])
assert.Equal(t, v3.AttributeKeyTypeTag, legacy.Filters[1].Type)
assert.Equal(t, v3.AttributeKeyDataTypeFloat64, legacy.Filters[2].DataType)
assert.Equal(t, v3.AttributeKeyTypeUnspecified, legacy.Filters[3].Type, "contexts outside the v3 enum must render as unspecified")
assert.Equal(t, v3.AttributeKey{Key: "host.name"}, legacy.Filters[4])
}

View File

@@ -2,12 +2,11 @@ package implquickfilter
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -19,91 +18,50 @@ func NewModule(store quickfiltertypes.QuickFilterStore) quickfilter.Module {
return &module{store: store}
}
// GetQuickFilters returns all quick filters for an organization.
func (module *module) GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error) {
storedFilters, err := module.store.Get(ctx, orgID)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error fetching organization filters")
}
result := make([]*quickfiltertypes.SignalFilters, 0, len(storedFilters))
for _, storedFilter := range storedFilters {
signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter)
// GetQuickFilters returns quick filters for a signal, or for every signal when signal is zero.
func (module *module) GetQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) ([]*quickfiltertypes.SignalFilters, error) {
if signal.IsZero() {
storedFilters, err := module.store.Get(ctx, orgID)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal)
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error fetching organization filters")
}
result = append(result, signalFilter)
result := make([]*quickfiltertypes.SignalFilters, 0, len(storedFilters))
for _, storedFilter := range storedFilters {
signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal)
}
result = append(result, signalFilter)
}
return result, nil
}
return result, nil
}
// GetSignalFilters returns quick filters for a specific signal in an organization.
func (m *module) GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error) {
storedFilter, err := m.store.GetBySignal(ctx, orgID, signal.StringValue())
storedFilter, err := module.store.GetBySignal(ctx, orgID, signal.StringValue())
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
return []*quickfiltertypes.SignalFilters{quickfiltertypes.NewSignalFiltersFromSignal(signal)}, nil
}
return nil, err
}
// If no filter exists for this signal, return empty filters with the requested signal
if storedFilter == nil {
return &quickfiltertypes.SignalFilters{
Signal: signal,
Filters: []v3.AttributeKey{},
}, nil
}
// Convert stored filter to signal filter
signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal)
}
return signalFilter, nil
return []*quickfiltertypes.SignalFilters{signalFilter}, nil
}
// UpdateQuickFilters updates quick filters for a specific signal in an organization.
func (module *module) UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error {
// Validate each filter
for _, filter := range filters {
if err := filter.Validate(); err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter: %v", err)
}
}
// Marshal filters to JSON
filterJSON, err := json.Marshal(filters)
// UpsertQuickFilters replaces quick filters for a specific signal in an organization, creating them if absent.
func (module *module) UpsertQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []telemetrytypes.TelemetryFieldKey) error {
filter, err := quickfiltertypes.NewStorableQuickFilter(orgID, signal, filters)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error marshalling filters")
}
// Check if filter exists
existingFilter, err := module.store.GetBySignal(ctx, orgID, signal.StringValue())
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error checking existing filters")
}
var filter *quickfiltertypes.StorableQuickFilter
if existingFilter != nil {
// Update in place
if err := existingFilter.Update(filterJSON); err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "error updating existing filter")
}
filter = existingFilter
} else {
// Create new
filter, err = quickfiltertypes.NewStorableQuickFilter(orgID, signal, filterJSON)
if err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "error creating new filter")
}
}
// Persist filter
if err := module.store.Upsert(ctx, filter); err != nil {
return err
}
return nil
return module.store.Upsert(ctx, filter)
}
func (module *module) SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error {

View File

@@ -62,7 +62,7 @@ func (s *store) Upsert(ctx context.Context, filter *quickfiltertypes.StorableQui
BunDB().
NewInsert().
Model(filter).
On("CONFLICT (id) DO UPDATE").
On("CONFLICT (org_id, signal) DO UPDATE").
Set("filter = EXCLUDED.filter").
Set("updated_at = EXCLUDED.updated_at").
Exec(ctx)

View File

@@ -4,20 +4,25 @@ import (
"context"
"net/http"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error)
UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error
GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error)
// GetQuickFilters returns quick filters for a signal, or for every signal when signal is zero.
GetQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) ([]*quickfiltertypes.SignalFilters, error)
UpsertQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []telemetrytypes.TelemetryFieldKey) error
SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error
}
type Handler interface {
// Legacy v1 endpoints, served by converting to and from the v3 attribute key shape.
GetQuickFilters(http.ResponseWriter, *http.Request)
UpdateQuickFilters(http.ResponseWriter, *http.Request)
GetSignalFilters(http.ResponseWriter, *http.Request)
ListQuickFiltersV2(http.ResponseWriter, *http.Request)
GetQuickFiltersV2(http.ResponseWriter, *http.Request)
UpdateQuickFiltersV2(http.ResponseWriter, *http.Request)
}

View File

@@ -2,7 +2,6 @@ package prometheus
import (
"log/slog"
"time"
"github.com/prometheus/prometheus/promql"
)
@@ -24,11 +23,5 @@ func NewEngine(logger *slog.Logger, cfg Config) *Engine {
Timeout: cfg.Timeout,
ActiveQueryTracker: activeQueryTracker,
LookbackDelta: cfg.LookbackDelta,
// The engine calls this for subqueries that do not set a step, such as
// `metric[5m:]`, and segfaults if it is nil. 1m matches the default
// global evaluation_interval that Prometheus wires here.
NoStepSubqueryIntervalFn: func(int64) int64 {
return time.Minute.Milliseconds()
},
})
}

View File

@@ -1,33 +0,0 @@
package prometheus
import (
"context"
"log/slog"
"testing"
"time"
"github.com/prometheus/prometheus/storage"
"github.com/stretchr/testify/require"
)
func TestNoStepSubqueryDoesNotPanic(t *testing.T) {
engine := NewEngine(slog.New(slog.DiscardHandler), Config{Timeout: time.Minute})
queryable := storage.QueryableFunc(func(int64, int64) (storage.Querier, error) {
return storage.NoopQuerier(), nil
})
qry, err := engine.NewRangeQuery(
context.Background(),
queryable,
nil,
"max_over_time(some_metric[5m:])",
time.Now().Add(-time.Hour),
time.Now(),
time.Minute,
)
require.NoError(t, err)
defer qry.Close()
res := qry.Exec(context.Background())
require.NoError(t, res.Err)
}

View File

@@ -450,7 +450,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/disks", am.ViewAccess(aH.getDisks)).Methods(http.MethodGet)
// Quick Filters
// Quick Filters (v1 routes serve the legacy v3 shape; v2 lives in signozapiserver)
router.HandleFunc("/api/v1/orgs/me/filters", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetQuickFilters)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetSignalFilters)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.Signoz.Handlers.QuickFilter.UpdateQuickFilters)).Methods(http.MethodPut)

View File

@@ -49,9 +49,7 @@ func TestNewHandlers(t *testing.T) {
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
systemDashboardRegistry, err := impldashboard.NewSystemDashboardRegistry()
require.NoError(t, err)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule, systemDashboardRegistry)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)

View File

@@ -67,35 +67,35 @@ import (
)
type Modules struct {
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
UserSetter user.Setter
UserGetter user.Getter
RetentionGetter retention.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
Services services.Module
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
MetricReductionRule metricreductionrule.Module
InfraMonitoring inframonitoring.Module
OrgGetter organization.Getter
OrgSetter organization.Setter
Preference preference.Module
UserSetter user.Setter
UserGetter user.Getter
RetentionGetter retention.Getter
SavedView savedview.Module
Apdex apdex.Module
Dashboard dashboard.Module
QuickFilter quickfilter.Module
TraceFunnel tracefunnel.Module
RawDataExport rawdataexport.Module
AuthDomain authdomain.Module
Session session.Module
Services services.Module
SpanPercentile spanpercentile.Module
MetricsExplorer metricsexplorer.Module
MetricReductionRule metricreductionrule.Module
InfraMonitoring inframonitoring.Module
Promote promote.Module
ServiceAccount serviceaccount.Module
ServiceAccountGetter serviceaccount.Getter
CloudIntegration cloudintegration.Module
LogsPipeline logspipeline.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
Tag tag.Module
LogsPipeline logspipeline.Module
RuleStateHistory rulestatehistory.Module
TraceDetail tracedetail.Module
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
Tag tag.Module
}
func NewModules(
@@ -126,7 +126,7 @@ func NewModules(
metricReductionRule metricreductionrule.Module,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard)
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
// Cleanup callbacks from other modules, invoked when a user is deleted.
onDeleteUser := []user.OnDeleteUser{
dashboard.DeletePreferencesForUser,
@@ -136,34 +136,34 @@ func NewModules(
authDomainModule := implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs, authz)
return Modules{
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard,
UserSetter: userSetter,
UserGetter: userGetter,
RetentionGetter: retentionGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
OrgGetter: orgGetter,
OrgSetter: orgSetter,
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
Apdex: implapdex.NewModule(sqlstore),
Dashboard: dashboard,
UserSetter: userSetter,
UserGetter: userGetter,
RetentionGetter: retentionGetter,
QuickFilter: quickfilter,
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
RawDataExport: implrawdataexport.NewModule(querier),
AuthDomain: authDomainModule,
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
ServiceAccount: serviceAccount,
ServiceAccountGetter: serviceAccountGetter,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
Tag: tagModule,
LogsPipeline: impllogspipeline.NewModule(sqlstore),
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
Tag: tagModule,
}
}

View File

@@ -51,9 +51,7 @@ func TestNewModules(t *testing.T) {
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
systemDashboardRegistry, err := impldashboard.NewSystemDashboardRegistry()
require.NoError(t, err)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule, systemDashboardRegistry)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)

View File

@@ -29,6 +29,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/preference"
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/savedview"
@@ -93,6 +94,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
struct{ ruler.Handler }{},
struct{ statsreporter.Handler }{},
struct{ savedview.Handler }{},
struct{ quickfilter.Handler }{},
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
if err != nil {
return nil, err

View File

@@ -245,7 +245,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateLambdaDashboardsFactory(),
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
sqlmigration.NewAddDeploymentHostTuplesFactory(sqlstore),
sqlmigration.NewAddSystemDashboardFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateQuickFiltersFactory(sqlstore),
sqlmigration.NewAddQuickFilterTuplesFactory(sqlstore),
)
}
@@ -349,6 +350,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
handlers.RulerHandler,
handlers.StatsHandler,
handlers.SavedView,
handlers.QuickFilter,
),
)
}

View File

@@ -60,7 +60,6 @@ import (
"github.com/SigNoz/signoz/pkg/telemetrystore"
pkgtokenizer "github.com/SigNoz/signoz/pkg/tokenizer"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/version"
@@ -176,7 +175,7 @@ func New(
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
authzCallback func(context.Context, sqlstore.SQLStore, authz.Config, licensing.Licensing, []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module, dashboardtypes.SystemDashboardRegistry) dashboard.Module,
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module) dashboard.Module,
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),
@@ -441,13 +440,8 @@ func New(
// Initialize query parser (needed for dashboard module)
queryParser := queryparser.New(providerSettings)
// Initialize dashboard module. The system dashboard registry is parsed here so
// a malformed embedded definition fails startup instead of a request.
systemDashboardRegistry, err := impldashboard.NewSystemDashboardRegistry()
if err != nil {
return nil, err
}
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule, systemDashboardRegistry)
// Initialize dashboard module
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
// Initialize user getter
userGetter := impluser.NewGetter(userStore, userRoleStore, flagger)
@@ -616,7 +610,6 @@ func New(
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
factory.NewNamedService(factory.MustNewName("meterreporter"), meterReporter, factory.MustNewName("licensing")),
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
factory.NewNamedService(factory.MustNewName("systemdashboard"), impldashboard.NewService(providerSettings, dashboard, orgGetter)),
)
if err != nil {
return nil, err

View File

@@ -1,93 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addSystemDashboard struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddSystemDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_system_dashboard"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addSystemDashboard{sqlstore: sqlstore, sqlschema: sqlschema}, nil
},
)
}
func (migration *addSystemDashboard) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addSystemDashboard) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "system_dashboard",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "version", DataType: sqlschema.DataTypeBigInt, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
{
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
ReferencedTableName: sqlschema.TableName("dashboard"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
// (org_id, name) is what makes provisioning safe across replicas: the state
// row is written in the same transaction as the dashboard, so a losing racer
// rolls back its dashboard too.
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "system_dashboard",
ColumnNames: []sqlschema.ColumnName{"org_id", "name"},
},
)...)
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "system_dashboard",
ColumnNames: []sqlschema.ColumnName{"dashboard_id"},
},
)...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addSystemDashboard) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,170 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"strings"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
type storableQuickFilterRow struct {
bun.BaseModel `bun:"table:quick_filter"`
ID string `bun:"id,pk"`
Filter string `bun:"filter"`
}
// legacyQuickFilterEntry carries both shapes a stored entry can be in: the
// legacy key/type/dataType shape and the current name-carrying shape.
type legacyQuickFilterEntry struct {
Name string `json:"name"`
Key string `json:"key"`
Type string `json:"type"`
DataType string `json:"dataType"`
Signal string `json:"signal"`
}
// quickFilterLegacyTypeToFieldContext maps the v3 attribute key types the v1
// write path could store. Materialized top-level fields carried no type, and
// anything unknown (e.g. "Sum" in the old meter defaults) normalizes to
// unspecified, matching what the v1 write path does at runtime.
var quickFilterLegacyTypeToFieldContext = map[string]string{
"tag": "attribute",
"resource": "resource",
"scope": "scope",
}
// quickFilterLegacyDataTypeToFieldDataType maps the v3 attribute key data
// types the v1 write path could store, with numerics collapsed to number,
// matching the fields API and the v1 write path.
var quickFilterLegacyDataTypeToFieldDataType = map[string]string{
"string": "string",
"bool": "bool",
"int64": "number",
"float64": "number",
}
// quickFilterFieldDataType resolves legacy datatype spellings, with unknowns
// normalized to unspecified.
func quickFilterFieldDataType(legacyDataType string) string {
return quickFilterLegacyDataTypeToFieldDataType[strings.ToLower(strings.TrimSpace(legacyDataType))]
}
// quickFilterFieldContext resolves legacy type spellings, with unknowns
// normalized to unspecified.
func quickFilterFieldContext(legacyType string) string {
return quickFilterLegacyTypeToFieldContext[strings.ToLower(strings.TrimSpace(legacyType))]
}
type migrateQuickFilters struct {
sqlstore sqlstore.SQLStore
settings factory.ProviderSettings
}
func NewMigrateQuickFiltersFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("migrate_quick_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateQuickFilters{sqlstore: sqlstore, settings: ps}, nil
})
}
func (migration *migrateQuickFilters) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *migrateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var rows []*storableQuickFilterRow
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
var migrated, skipped int
for _, row := range rows {
migratedFilter, changed, ok := migrateQuickFilterEntries(row.Filter)
if !ok {
migration.settings.Logger.WarnContext(ctx, "quick filter could not be parsed, leaving it untouched", slog.String("quick_filter_id", row.ID), slog.String("raw_filter", row.Filter))
skipped++
continue
}
if !changed {
continue
}
migrated++
if _, err := tx.NewUpdate().Model((*storableQuickFilterRow)(nil)).Set("filter = ?", migratedFilter).Where("id = ?", row.ID).Exec(ctx); err != nil {
return err
}
}
migration.settings.Logger.InfoContext(ctx, "migrated quick filters to telemetry field keys", slog.Int("total", len(rows)), slog.Int("migrated", migrated), slog.Int("skipped", skipped))
return tx.Commit()
}
func (migration *migrateQuickFilters) Down(context.Context, *bun.DB) error {
return nil
}
// migrateQuickFilterEntries rewrites a stored filter list from the legacy
// key/dataType/type shape to telemetry field keys; ok=false means unparseable.
func migrateQuickFilterEntries(filter string) (migrated string, changed bool, ok bool) {
var entriesRaw []json.RawMessage
if err := json.Unmarshal([]byte(filter), &entriesRaw); err != nil {
return "", false, false
}
migratedEntries := make([]json.RawMessage, 0, len(entriesRaw))
for _, rawEntry := range entriesRaw {
var entry legacyQuickFilterEntry
if err := json.Unmarshal(rawEntry, &entry); err != nil {
// Some stored entries are plain strings rather than objects; treat
// the string as the filter key name, dropping empty ones.
var name string
if err := json.Unmarshal(rawEntry, &name); err != nil {
return "", false, false
}
entry = legacyQuickFilterEntry{Key: name}
}
switch {
case entry.Name != "":
migratedEntries = append(migratedEntries, rawEntry)
case entry.Key != "":
migratedJSON, err := marshalUnescaped(telemetryFieldKeyOutput{
Name: entry.Key,
Signal: entry.Signal,
FieldContext: quickFilterFieldContext(entry.Type),
FieldDataType: quickFilterFieldDataType(entry.DataType),
})
if err != nil {
return "", false, false
}
migratedEntries = append(migratedEntries, migratedJSON)
changed = true
default:
changed = true
}
}
if !changed {
return "", false, true
}
migratedJSON, err := marshalUnescaped(migratedEntries)
if err != nil {
return "", false, false
}
return string(migratedJSON), true, true
}

View File

@@ -0,0 +1,139 @@
package sqlmigration
import (
"context"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addQuickFilterTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddQuickFilterTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_quick_filter_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addQuickFilterTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addQuickFilterTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addQuickFilterTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
// quick-filter moved from the legacy ViewAccess/AdminAccess role gate to
// CheckResources, which on enterprise requires real tuples -- existing orgs
// never had these written, only new orgs get them from the registry at bootstrap.
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "metaresource", "quick-filter", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "quick-filter", "update"},
{authtypes.SigNozAdminRoleName, "metaresource", "quick-filter", "list"},
{authtypes.SigNozEditorRoleName, "metaresource", "quick-filter", "read"},
{authtypes.SigNozEditorRoleName, "metaresource", "quick-filter", "list"},
{authtypes.SigNozViewerRoleName, "metaresource", "quick-filter", "read"},
{authtypes.SigNozViewerRoleName, "metaresource", "quick-filter", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
return tx.Commit()
}
func (migration *addQuickFilterTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -25,10 +25,6 @@ const (
dashboardNameSuffixLen = 8
)
// SystemDashboardNamePrefix is reserved for dashboards SigNoz ships and owns. Generated
// names never contain consecutive hyphens, so only a typed name can carry it — create rejects that.
const SystemDashboardNamePrefix = "signoz---"
const (
dashboardIconPathPrefix = "/assets/Icons/"
dashboardLogoPathPrefix = "/assets/Logos/"
@@ -79,8 +75,8 @@ type DashboardV2 struct {
}
func (d *DashboardV2) ErrIfNotMutable() error {
if d.Source != SourceUser {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be modified", d.Source)
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
}
return nil
}
@@ -99,11 +95,6 @@ func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, r
if err := d.ErrIfNotUpdatable(); err != nil {
return err
}
return d.UpdateUnsafe(updatable, updatedBy, resolvedTags)
}
// UpdateUnsafe applies the update without the source/lock gate. Intended for internal system callers.
func (d *DashboardV2) UpdateUnsafe(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if updatable.Name != d.Name {
return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updatable.Name)
}
@@ -138,13 +129,6 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
return nil
}
func (d *DashboardV2) ErrIfNotSystem() error {
if d.Source != SourceSystem {
return errors.Newf(errors.TypeNotFound, ErrCodeDashboardNotFound, "dashboard %q is not a system dashboard", d.Name)
}
return nil
}
func (d *DashboardV2) ErrIfNotClonable() error {
if !d.Source.isClonable() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be cloned", d.Source)
@@ -221,21 +205,13 @@ type PostableDashboardV2 struct {
Spec DashboardSpec `json:"spec" required:"true"`
}
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) (*DashboardV2, error) {
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) *DashboardV2 {
now := time.Now()
name := postable.Name
if postable.GenerateName {
name = generateDashboardName(postable.Spec.Display.Name)
}
// Checked on the final name, here rather than in validateName, because only
// the constructor knows the source.
if source != SourceSystem && strings.HasPrefix(name, SystemDashboardNamePrefix) {
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name %q is invalid: the %q prefix is reserved for system dashboards", name, SystemDashboardNamePrefix)
}
if source == SourceSystem && !strings.HasPrefix(name, SystemDashboardNamePrefix) {
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name %q is invalid: system dashboard names must start with the %q prefix", name, SystemDashboardNamePrefix)
}
return &DashboardV2{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
@@ -248,7 +224,7 @@ func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy
Name: name,
Tags: tagtypes.NewTagsFromPostableTags(orgID, coretypes.KindDashboard, postable.Tags),
Spec: postable.Spec,
}, nil
}
}
func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {
@@ -389,36 +365,6 @@ func (d DashboardV2) ToGettableDashboardV2() GettableDashboardV2 {
}
}
// GettableSystemDashboard is the system-dashboard endpoint's response. System
// dashboards are addressed by their stable definition name, so it carries no id.
type GettableSystemDashboard struct {
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId" required:"true"`
Locked bool `json:"locked" required:"true"`
Source Source `json:"source" required:"true"`
DashboardV2MetadataBase
Name string `json:"name" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true"`
Spec DashboardSpec `json:"spec" required:"true"`
}
func (d DashboardV2) ToGettableSystemDashboard() GettableSystemDashboard {
return GettableSystemDashboard{
TimeAuditable: d.TimeAuditable,
UserAuditable: d.UserAuditable,
OrgID: d.OrgID,
Locked: d.Locked,
Source: d.Source,
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
Name: d.Name,
Tags: tagtypes.NewGettableTagsFromTags(d.Tags),
Spec: d.Spec,
}
}
// ════════════════════════════════════════════════════════════════════════
// Storable
// ════════════════════════════════════════════════════════════════════════

View File

@@ -89,25 +89,21 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
cases := []struct {
scenario string
source Source
name string
expectedLocked bool
}{
{
scenario: "user source is not locked",
source: SourceUser,
name: "my-dashboard",
expectedLocked: false,
},
{
scenario: "system source is not locked",
source: SourceSystem,
name: SystemDashboardNamePrefix + "my-dashboard",
expectedLocked: false,
},
{
scenario: "integration source is locked",
source: SourceIntegration,
name: "my-dashboard",
expectedLocked: true,
},
}
@@ -119,7 +115,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
SchemaVersion: SchemaVersion,
Image: "img",
},
Name: tc.name,
Name: "my-dashboard",
Tags: []tagtypes.PostableTag{
{Key: "team", Value: "platform"},
{Key: "env", Value: "prod"},
@@ -128,8 +124,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
}
before := time.Now()
dashboard, err := postable.NewDashboardV2(orgID, "alice", tc.source)
require.NoError(t, err)
dashboard := postable.NewDashboardV2(orgID, "alice", tc.source)
after := time.Now()
require.NotNil(t, dashboard)
@@ -165,10 +160,8 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
Spec: DashboardSpec{},
}
first, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
require.NoError(t, err)
second, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
require.NoError(t, err)
first := postable.NewDashboardV2(orgID, "alice", SourceUser)
second := postable.NewDashboardV2(orgID, "alice", SourceUser)
assert.NotEqual(t, first.ID, second.ID, "expected distinct UUIDs across invocations")
})
@@ -181,8 +174,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
},
}
dashboard, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
require.NoError(t, err)
dashboard := postable.NewDashboardV2(orgID, "alice", SourceUser)
assert.True(t, strings.HasPrefix(dashboard.Name, "my-dashboard-"), "expected slug prefix, got %q", dashboard.Name)
assert.Len(t, dashboard.Name, len("my-dashboard-")+dashboardNameSuffixLen)
})

View File

@@ -109,8 +109,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
var p PostableDashboardV2
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
testOrgID := valuer.GenerateUUID()
base, err := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
require.NoError(t, err)
base := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
base.Tags = []*tagtypes.Tag{
{Key: "team", Value: "alpha"},
{Key: "env", Value: "prod"},

View File

@@ -8,7 +8,6 @@ import (
"testing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/spec/go/dashboard"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -1929,37 +1928,3 @@ func TestEnsureSingleExpressionAggregation(t *testing.T) {
})
}
}
// Guards the constant: a prefixed name must stay a valid DNS-1123 label.
func TestSystemDashboardNamePrefix(t *testing.T) {
require.NoError(t, validateDashboardName(SystemDashboardNamePrefix+"ai-o11y-overview"))
}
func TestNewDashboardV2RejectsReservedName(t *testing.T) {
testCases := []struct {
description string
name string
source Source
errContains string
}{
{description: "reserved name for a system dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceSystem},
{description: "reserved name for a user dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceUser, errContains: "reserved for system dashboards"},
{description: "reserved name for an integration dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceIntegration, errContains: "reserved for system dashboards"},
{description: "unprefixed name for a system dashboard", name: "overview", source: SourceSystem, errContains: "must start with"},
{description: "ordinary name for a user dashboard", name: "overview", source: SourceUser},
{description: "fewer hyphens than the prefix for a user dashboard", name: "signoz--overview", source: SourceUser},
}
for _, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
postable := PostableDashboardV2{Name: testCase.name}
_, err := postable.NewDashboardV2(valuer.GenerateUUID(), "user@signoz.io", testCase.source)
if testCase.errContains != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), testCase.errContains)
return
}
require.NoError(t, err)
})
}
}

View File

@@ -13,9 +13,6 @@ type Store interface {
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableDashboard, error)
// GetByName resolves a dashboard by its per-org unique name.
GetByName(ctx context.Context, orgID valuer.UUID, name string) (*StorableDashboard, error)
GetPublic(context.Context, string) (*StorablePublicDashboard, error)
GetDashboardByOrgsAndPublicID(context.Context, []string, string) (*StorableDashboard, error)
@@ -75,13 +72,4 @@ type Store interface {
UpdateDashboardView(ctx context.Context, view *DashboardView) error
DeleteDashboardView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
// ════════════════════════════════════════════════════════════════════════
// System dashboard methods
// ════════════════════════════════════════════════════════════════════════
CreateSystemDashboard(ctx context.Context, storable *StorableSystemDashboard) error
GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*StorableSystemDashboard, error)
UpdateSystemDashboardVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error
}

View File

@@ -1,45 +0,0 @@
package dashboardtypes
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
var (
ErrCodeSystemDashboardNotFound = errors.MustNewCode("system_dashboard_not_found")
ErrCodeSystemDashboardDefinitionInvalid = errors.MustNewCode("system_dashboard_definition_invalid")
ErrCodeSystemDashboardAlreadyProvisioned = errors.MustNewCode("system_dashboard_already_provisioned")
)
// ProvisionerIdentity is stamped into created_by/updated_by by the reconciler.
const ProvisionerIdentity = "signoz"
// StorableSystemDashboard records the shipped version each org's copy of a system
// dashboard was last provisioned at. That version is the only thing the dashboard
// row cannot answer, since the binary only embeds the latest definition.
type StorableSystemDashboard struct {
bun.BaseModel `bun:"table:system_dashboard"`
types.Identifiable
types.TimeAuditable
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
DashboardID valuer.UUID `bun:"dashboard_id,type:text,notnull"`
Name string `bun:"name,type:text,notnull"`
Version int `bun:"version,notnull"`
}
func NewStorableSystemDashboard(orgID valuer.UUID, dashboardID valuer.UUID, name string, version int) *StorableSystemDashboard {
now := time.Now()
return &StorableSystemDashboard{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
OrgID: orgID,
DashboardID: dashboardID,
Name: name,
Version: version,
}
}

View File

@@ -1,95 +0,0 @@
package dashboardtypes
import (
"bytes"
"encoding/json"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
)
// SystemDashboardDefinition is one shipped system dashboard. Version is bumped on
// every content change and drives upgrade detection; the name is the stable key
// and never changes.
type SystemDashboardDefinition struct {
Version int `json:"version"`
Dashboard PostableDashboardV2 `json:"definition"`
}
func (definition SystemDashboardDefinition) Name() string {
return definition.Dashboard.Name
}
func NewSystemDashboardDefinition(raw []byte) (SystemDashboardDefinition, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var definition SystemDashboardDefinition
if err := decoder.Decode(&definition); err != nil {
return SystemDashboardDefinition{}, errors.WrapInvalidInputf(err, ErrCodeSystemDashboardDefinitionInvalid, "%s", err.Error())
}
if err := definition.validate(); err != nil {
return SystemDashboardDefinition{}, err
}
return definition, nil
}
func (definition SystemDashboardDefinition) validate() error {
if definition.Version < 1 {
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "version must be at least 1, got %d", definition.Version)
}
if !strings.HasPrefix(definition.Name(), SystemDashboardNamePrefix) {
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "name %q must start with %q", definition.Name(), SystemDashboardNamePrefix)
}
if definition.Dashboard.GenerateName {
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "%s: generateName is not allowed, the name is the stable key", definition.Name())
}
return nil
}
// ToUpdatable is how an upgrade re-applies a definition onto an existing row:
// everything but the dashboard's identity comes from the shipped definition.
func (definition SystemDashboardDefinition) ToUpdatable() UpdatableDashboardV2 {
return UpdatableDashboardV2{
DashboardV2MetadataBase: definition.Dashboard.DashboardV2MetadataBase,
Name: definition.Dashboard.Name,
Tags: definition.Dashboard.Tags,
Spec: definition.Dashboard.Spec,
}
}
// SystemDashboardRegistry holds every definition embedded in the binary, keyed by name.
type SystemDashboardRegistry struct {
definitions map[string]SystemDashboardDefinition
}
func NewSystemDashboardRegistry(definitions []SystemDashboardDefinition) (SystemDashboardRegistry, error) {
byName := make(map[string]SystemDashboardDefinition, len(definitions))
for _, definition := range definitions {
if _, duplicate := byName[definition.Name()]; duplicate {
return SystemDashboardRegistry{}, errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "duplicate system dashboard name %q", definition.Name())
}
byName[definition.Name()] = definition
}
return SystemDashboardRegistry{definitions: byName}, nil
}
func (registry SystemDashboardRegistry) Get(name string) (SystemDashboardDefinition, bool) {
definition, ok := registry.definitions[name]
return definition, ok
}
// List returns the definitions sorted by name so provisioning order is stable.
func (registry SystemDashboardRegistry) List() []SystemDashboardDefinition {
definitions := make([]SystemDashboardDefinition, 0, len(registry.definitions))
for _, definition := range registry.definitions {
definitions = append(definitions, definition)
}
slices.SortFunc(definitions, func(a, b SystemDashboardDefinition) int { return strings.Compare(a.Name(), b.Name()) })
return definitions
}

View File

@@ -23,7 +23,6 @@ var (
NameSpanDetailsPreviewAttributes = Name{valuer.NewString("span_details_preview_attributes")}
NameSpanDetailsColorByAttribute = Name{valuer.NewString("span_details_color_by_attribute")}
NameSpanPercentileResourceAttributes = Name{valuer.NewString("span_percentile_resource_attributes")}
NameLogDetailsPinnedAttributes = Name{valuer.NewString("log_details_pinned_attributes")}
)
type Name struct{ valuer.String }
@@ -46,7 +45,6 @@ func NewName(name string) (Name, error) {
NameSpanDetailsPreviewAttributes.StringValue(),
NameSpanDetailsColorByAttribute.StringValue(),
NameSpanPercentileResourceAttributes.StringValue(),
NameLogDetailsPinnedAttributes.StringValue(),
},
name,
)

View File

@@ -190,15 +190,6 @@ func NewAvailablePreference() map[Name]Preference {
AllowedValues: []string{},
Value: MustNewValue([]any{}, ValueTypeArray),
},
NameLogDetailsPinnedAttributes: {
Name: NameLogDetailsPinnedAttributes,
Description: "List of pinned attributes in log details drawer.",
ValueType: ValueTypeArray,
DefaultValue: MustNewValue([]any{}, ValueTypeArray),
AllowedScopes: []Scope{ScopeUser},
AllowedValues: []string{},
Value: MustNewValue([]any{}, ValueTypeArray),
},
}
}

View File

@@ -5,9 +5,9 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -70,18 +70,27 @@ type StorableQuickFilter struct {
}
type SignalFilters struct {
Signal Signal `json:"signal"`
Filters []v3.AttributeKey `json:"filters"`
Signal Signal `json:"signal"`
Filters []telemetrytypes.TelemetryFieldKey `json:"filters"`
}
type UpdatableQuickFilters struct {
Signal Signal `json:"signal"`
Filters []v3.AttributeKey `json:"filters"`
Signal Signal `json:"signal"`
Filters []telemetrytypes.TelemetryFieldKey `json:"filters"`
}
func validateFilters(filters []telemetrytypes.TelemetryFieldKey) error {
for _, filter := range filters {
if filter.Name == "" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "filter name is required")
}
}
return nil
}
// NewStorableQuickFilter creates a new StorableQuickFilter after validation.
func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filterJSON []byte) (*StorableQuickFilter, error) {
if orgID.StringValue() == "" {
func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filters []telemetrytypes.TelemetryFieldKey) (*StorableQuickFilter, error) {
if orgID.IsZero() {
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is required")
}
@@ -89,9 +98,13 @@ func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filterJSON []byte)
return nil, err
}
var filters []v3.AttributeKey
if err := json.Unmarshal(filterJSON, &filters); err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter JSON")
if err := validateFilters(filters); err != nil {
return nil, err
}
filterJSON, err := json.Marshal(filters)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error marshalling filters")
}
now := time.Now()
@@ -109,16 +122,12 @@ func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filterJSON []byte)
}, nil
}
// Update updates an existing StorableQuickFilter with new filter data after validation.
func (quickfilter *StorableQuickFilter) Update(filterJSON []byte) error {
var filters []v3.AttributeKey
if err := json.Unmarshal(filterJSON, &filters); err != nil {
return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter JSON")
// NewSignalFiltersFromSignal creates a SignalFilters with no filters for a signal.
func NewSignalFiltersFromSignal(signal Signal) *SignalFilters {
return &SignalFilters{
Signal: signal,
Filters: []telemetrytypes.TelemetryFieldKey{},
}
quickfilter.Filter = string(filterJSON)
quickfilter.UpdatedAt = time.Now()
return nil
}
// NewSignalFilterFromStorableQuickFilter converts a StorableQuickFilter to a SignalFilters object.
@@ -127,7 +136,7 @@ func NewSignalFilterFromStorableQuickFilter(storableQuickFilter *StorableQuickFi
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "storableQuickFilter cannot be nil")
}
var filters []v3.AttributeKey
var filters []telemetrytypes.TelemetryFieldKey
if storableQuickFilter.Filter != "" {
err := json.Unmarshal([]byte(storableQuickFilter.Filter), &filters)
if err != nil {
@@ -143,170 +152,88 @@ func NewSignalFilterFromStorableQuickFilter(storableQuickFilter *StorableQuickFi
// NewDefaultQuickFilter generates default filters for all supported signals.
func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) {
tracesFilters := []map[string]interface{}{
{"key": "duration_nano", "dataType": "float64", "type": "tag"},
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "hasError", "dataType": "bool", "type": "tag"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "name", "dataType": "string", "type": "tag"},
{"key": "rpc.method", "dataType": "string", "type": "tag"},
{"key": "response_status_code", "dataType": "string", "type": "tag"},
{"key": "http_host", "dataType": "string", "type": "tag"},
{"key": "http.method", "dataType": "string", "type": "tag"},
{"key": "http.route", "dataType": "string", "type": "tag"},
{"key": "http_url", "dataType": "string", "type": "tag"},
{"key": "trace_id", "dataType": "string", "type": "tag"},
tracesFilters := []telemetrytypes.TelemetryFieldKey{
{Name: "duration_nano", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "hasError", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeBool},
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "name", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "rpc.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "response_status_code", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http_host", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http.route", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "http_url", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "trace_id", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
}
logsFilters := []map[string]interface{}{
{"key": "severity_text", "dataType": "string", "type": "resource"},
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "host.name", "dataType": "string", "type": "resource"},
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
logsFilters := []telemetrytypes.TelemetryFieldKey{
{Name: "severity_text", FieldContext: telemetrytypes.FieldContextLog, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "host.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "k8s.cluster.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "k8s.deployment.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "k8s.namespace.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "k8s.pod.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
}
apiMonitoringFilters := []map[string]interface{}{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "rpc.method", "dataType": "string", "type": "tag"},
apiMonitoringFilters := []telemetrytypes.TelemetryFieldKey{
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "rpc.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
}
exceptionsFilters := []map[string]interface{}{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "host.name", "dataType": "string", "type": "resource"},
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
exceptionsFilters := []telemetrytypes.TelemetryFieldKey{
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "host.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "k8s.cluster.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "k8s.deployment.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "k8s.namespace.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "k8s.pod.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
}
meterFilters := []map[string]interface{}{
{"key": "deployment.environment", "dataType": "float64", "type": "Sum"},
{"key": "service.name", "dataType": "float64", "type": "Sum"},
{"key": "host.name", "dataType": "float64", "type": "Sum"},
// Meter keys are label names with no context or datatype: the meter fields
// API returns them as name+signal only, so the defaults mirror that shape.
meterFilters := []telemetrytypes.TelemetryFieldKey{
{Name: "deployment.environment", Signal: telemetrytypes.SignalMetrics},
{Name: "service.name", Signal: telemetrytypes.SignalMetrics},
{Name: "host.name", Signal: telemetrytypes.SignalMetrics},
}
// AI observability (builder_ai_query trace explorer), ordered by expected
// usage: env scoping, the LLM identity keys, then service and the rest.
aiObservabilityFilters := []map[string]interface{}{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": aiobservabilitytypes.GenAIOperationName, "dataType": "string", "type": "tag"},
{"key": aiobservabilitytypes.GenAIProviderName, "dataType": "string", "type": "tag"},
{"key": aiobservabilitytypes.GenAIRequestModel, "dataType": "string", "type": "tag"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": aiobservabilitytypes.GenAIToolName, "dataType": "string", "type": "tag"},
{"key": aiobservabilitytypes.GenAIAgentName, "dataType": "string", "type": "tag"},
aiObservabilityFilters := []telemetrytypes.TelemetryFieldKey{
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: aiobservabilitytypes.GenAIOperationName, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: aiobservabilitytypes.GenAIProviderName, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: aiobservabilitytypes.GenAIRequestModel, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: aiobservabilitytypes.GenAIToolName, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: aiobservabilitytypes.GenAIAgentName, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
}
tracesJSON, err := json.Marshal(tracesFilters)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal traces filters")
defaults := []struct {
signal Signal
filters []telemetrytypes.TelemetryFieldKey
}{
{SignalTraces, tracesFilters},
{SignalLogs, logsFilters},
{SignalApiMonitoring, apiMonitoringFilters},
{SignalExceptions, exceptionsFilters},
{SignalMeter, meterFilters},
{SignalAiObservability, aiObservabilityFilters},
}
logsJSON, err := json.Marshal(logsFilters)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal logs filters")
storableQuickFilters := make([]*StorableQuickFilter, 0, len(defaults))
for _, def := range defaults {
storableQuickFilter, err := NewStorableQuickFilter(orgID, def.signal, def.filters)
if err != nil {
return nil, err
}
storableQuickFilters = append(storableQuickFilters, storableQuickFilter)
}
apiMonitoringJSON, err := json.Marshal(apiMonitoringFilters)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal api monitoring filters")
}
exceptionsJSON, err := json.Marshal(exceptionsFilters)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal exceptions filters")
}
meterJSON, err := json.Marshal(meterFilters)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal meter filters")
}
aiObservabilityJSON, err := json.Marshal(aiObservabilityFilters)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal ai observability filters")
}
timeRightNow := time.Now()
return []*StorableQuickFilter{
{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
OrgID: orgID,
Filter: string(tracesJSON),
Signal: SignalTraces,
TimeAuditable: types.TimeAuditable{
CreatedAt: timeRightNow,
UpdatedAt: timeRightNow,
},
},
{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
OrgID: orgID,
Filter: string(logsJSON),
Signal: SignalLogs,
TimeAuditable: types.TimeAuditable{
CreatedAt: timeRightNow,
UpdatedAt: timeRightNow,
},
},
{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
OrgID: orgID,
Filter: string(apiMonitoringJSON),
Signal: SignalApiMonitoring,
TimeAuditable: types.TimeAuditable{
CreatedAt: timeRightNow,
UpdatedAt: timeRightNow,
},
},
{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
OrgID: orgID,
Filter: string(exceptionsJSON),
Signal: SignalExceptions,
TimeAuditable: types.TimeAuditable{
CreatedAt: timeRightNow,
UpdatedAt: timeRightNow,
},
},
{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
OrgID: orgID,
Filter: string(meterJSON),
Signal: SignalMeter,
TimeAuditable: types.TimeAuditable{
CreatedAt: timeRightNow,
UpdatedAt: timeRightNow,
},
},
{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
OrgID: orgID,
Filter: string(aiObservabilityJSON),
Signal: SignalAiObservability,
TimeAuditable: types.TimeAuditable{
CreatedAt: timeRightNow,
UpdatedAt: timeRightNow,
},
},
}, nil
return storableQuickFilters, nil
}

View File

@@ -1,5 +0,0 @@
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:01:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:02:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:03:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:04:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:05:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}

View File

@@ -1,58 +0,0 @@
{
"alert": "promql_subquery_no_step",
"ruleType": "promql_rule",
"alertType": "METRIC_BASED_ALERT",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 10,
"matchType": "at_least_once",
"op": "above",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "promql",
"panelType": "graph",
"queries": [
{
"type": "promql",
"spec": {
"name": "A",
"query": "max_over_time({\"cpu_percent_promql_subquery_no_step\"}[2m:])"
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -1,93 +0,0 @@
import json
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import (
update_rule_channel_name,
verify_webhook_alert_expectation,
)
from fixtures.fs import get_testdata_file_path
TEST_CASE = types.AlertTestCase(
name="promql_subquery_no_step",
rule_path="alerts/test_scenarios/promql_subquery_no_step/rule.json",
alert_data=[
types.AlertData(
type="metrics",
data_path="alerts/test_scenarios/promql_subquery_no_step/alert_data.jsonl",
),
],
alert_expectation=types.AlertExpectation(
should_alert=True,
wait_time_seconds=30,
expected_alerts=[
types.FiringAlert(
labels={
"alertname": "promql_subquery_no_step",
"threshold.name": "critical",
}
),
],
),
)
def test_promql_rule_subquery_without_step(
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_webhook_notification_channel: Callable[[str, str, dict, bool], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
):
"""
A promql rule with a step-less subquery ([2m:]) must evaluate and fire.
A nil NoStepSubqueryIntervalFn segfaults the process on first evaluation.
"""
notification_channel_name = str(uuid.uuid4())
webhook_endpoint_path = f"/alert/{notification_channel_name}"
notification_url = notification_channel.container_configs["8080"].get(webhook_endpoint_path)
make_http_mocks(
notification_channel,
[
Mapping(
request=MappingRequest(
method=HttpMethods.POST,
url=webhook_endpoint_path,
),
response=MappingResponse(
status=200,
json_body={},
),
persistent=False,
)
],
)
create_webhook_notification_channel(
channel_name=notification_channel_name,
webhook_url=notification_url,
http_config={},
send_resolved=False,
)
insert_alert_data(
TEST_CASE.alert_data,
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
)
rule_path = get_testdata_file_path(TEST_CASE.rule_path)
with open(rule_path, encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, notification_channel_name)
create_alert_rule(rule_data)
verify_webhook_alert_expectation(
notification_channel,
notification_channel_name,
TEST_CASE.alert_expectation,
)

View File

@@ -1,192 +0,0 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from sqlalchemy import sql
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.dashboards import DASHBOARDS_BASE_URL, MAX_LIST_LIMIT
from fixtures.types import Operation, SigNoz
SYSTEM_BASE_URL = "/api/v2/dashboards/system"
# Provisioned for every org by the reconciler; the path segment is the bare
# definition name, the stored name carries the reserved prefix.
SYSTEM_DASHBOARD_NAME = "ai-o11y-overview"
SYSTEM_DASHBOARD_PREFIX = "signoz---"
def test_get_system_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{SYSTEM_BASE_URL}/{SYSTEM_DASHBOARD_NAME}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
dashboard = response.json()["data"]
assert dashboard["name"] == SYSTEM_DASHBOARD_PREFIX + SYSTEM_DASHBOARD_NAME
assert dashboard["source"] == "system"
assert dashboard["createdBy"] == "signoz"
assert dashboard["schemaVersion"] == "v6"
assert "id" not in dashboard
def test_get_system_dashboard_rejects_prefixed_name(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{SYSTEM_BASE_URL}/{SYSTEM_DASHBOARD_PREFIX}{SYSTEM_DASHBOARD_NAME}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "must not carry" in response.json()["error"]["message"]
def test_get_missing_system_dashboard_returns_not_found(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{SYSTEM_BASE_URL}/no-such-dashboard"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
def test_system_dashboard_hidden_from_list_but_gettable_by_id(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# The API never exposes a system dashboard's id; read it from the state row.
with signoz.sqlstore.conn.connect() as conn:
dashboard_id = conn.execute(
sql.text("SELECT dashboard_id FROM system_dashboard WHERE name = :name"),
{"name": SYSTEM_DASHBOARD_PREFIX + SYSTEM_DASHBOARD_NAME},
).scalar_one()
response = requests.get(
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}?limit={MAX_LIST_LIMIT}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
listed = response.json()["data"]["dashboards"] or []
assert all(dashboard["source"] != "system" for dashboard in listed)
assert all(dashboard["id"] != dashboard_id for dashboard in listed)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}/{dashboard_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["source"] == "system"
def test_system_dashboard_is_immutable(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(f"{SYSTEM_BASE_URL}/{SYSTEM_DASHBOARD_NAME}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
dashboard = response.json()["data"]
with signoz.sqlstore.conn.connect() as conn:
dashboard_id = conn.execute(
sql.text("SELECT dashboard_id FROM system_dashboard WHERE name = :name"),
{"name": SYSTEM_DASHBOARD_PREFIX + SYSTEM_DASHBOARD_NAME},
).scalar_one()
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}/{dashboard_id}"),
json={
"schemaVersion": dashboard["schemaVersion"],
"name": dashboard["name"],
"tags": [],
"spec": dashboard["spec"],
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json()["error"]["code"] == "dashboard_immutable"
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}/{dashboard_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json()["error"]["code"] == "dashboard_immutable"
response = requests.put(
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}/{dashboard_id}/lock"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json()["error"]["code"] == "dashboard_immutable"
response = requests.post(
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}/{dashboard_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert response.json()["error"]["code"] == "dashboard_immutable"
def test_create_rejects_reserved_prefix_name(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(DASHBOARDS_BASE_URL),
json={
"schemaVersion": "v6",
"name": f"{SYSTEM_DASHBOARD_PREFIX}custom",
"tags": [],
"spec": {
"display": {"name": "Custom"},
"variables": [],
"panels": {},
"layouts": [],
},
},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
assert "reserved for system dashboards" in response.json()["error"]["message"]

View File

@@ -1,65 +0,0 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from uuid import uuid4
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.metrics import Metrics
from fixtures.querier import get_all_series, make_query_request
MINUTE_MS = 60_000
LEGS: list[tuple[str, dict | None]] = [
("default", None),
("clickhousev2", {"X-SigNoz-PromQL-Provider": "clickhousev2"}),
]
def test_promql_subquery_without_step_evaluates(
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 subquery that omits its step, e.g. `metric[5m:]`, is valid PromQL: the
engine fills in its default resolution. A nil NoStepSubqueryIntervalFn
segfaults the whole process on the first such query.
"""
end_ms = (int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000) // MINUTE_MS) * MINUTE_MS
start_ms = end_ms - 30 * MINUTE_MS
metric = f"no_step_subquery_gauge_{uuid4().hex[:8]}"
insert_metrics(
[
Metrics(
metric_name=metric,
labels={"host": "server-01"},
timestamp=datetime.fromtimestamp(ts_ms / 1000, tz=UTC),
value=42.0,
)
for ts_ms in range(start_ms, end_ms + 1, MINUTE_MS)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
for leg, headers in LEGS:
query = {"type": "promql", "spec": {"name": "A", "query": f"max_over_time({metric}[5m:])"}}
response = make_query_request(signoz, token, start_ms, end_ms, [query], headers=headers)
assert response.status_code == HTTPStatus.OK, f"{leg}: {response.text[:300]}"
series = get_all_series(response.json(), "A")
assert series, f"{leg}: the subquery must return the inserted series"
values = {point["value"] for entry in series for point in entry.get("values") or []}
assert values == {42.0}, f"{leg}: {sorted(values)[:5]}"
# A plain follow-up query proves the process survived the subquery legs.
response = make_query_request(
signoz,
token,
start_ms,
end_ms,
[{"type": "promql", "spec": {"name": "A", "query": metric}}],
)
assert response.status_code == HTTPStatus.OK, response.text[:300]

View File

@@ -0,0 +1,269 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from sqlalchemy import sql
from fixtures import types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
ALL_SIGNALS = {
"traces",
"logs",
"api_monitoring",
"exceptions",
"meter",
"ai_observability",
}
def test_get_quick_filters_returns_defaults(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert {signal_filters["signal"] for signal_filters in data} == ALL_SIGNALS
for signal_filters in data:
assert len(signal_filters["filters"]) > 0
for field_key in signal_filters["filters"]:
assert field_key["name"] != ""
assert "fieldContext" in field_key
assert "fieldDataType" in field_key
assert "key" not in field_key
def test_v1_get_serves_legacy_shape(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/orgs/me/filters"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
data = response.json()["data"]
assert {signal_filters["signal"] for signal_filters in data} == ALL_SIGNALS
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/orgs/me/filters/traces"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
filters = response.json()["data"]["filters"]
assert filters[0]["key"] == "duration_nano"
assert filters[0]["type"] == "tag"
assert filters[0]["dataType"] == "float64"
assert all("name" not in legacy_filter for legacy_filter in filters)
def test_v1_update_round_trips_to_v2(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get("/api/v1/orgs/me/filters"),
json={
"signal": "exceptions",
"filters": [
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "http.method", "dataType": "string", "type": "tag"},
{"key": "code_line", "dataType": "int64", "type": "tag"},
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters/exceptions"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
filters = response.json()["data"]["filters"]
assert [(field_key["name"], field_key["fieldContext"]) for field_key in filters] == [
("service.name", "resource"),
("http.method", "attribute"),
("code_line", "attribute"),
]
assert filters[2]["fieldDataType"] == "number"
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/orgs/me/filters/exceptions"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
filters = response.json()["data"]["filters"]
assert [(legacy_filter["key"], legacy_filter["type"]) for legacy_filter in filters] == [
("service.name", "resource"),
("http.method", "tag"),
("code_line", "tag"),
]
response = requests.put(
signoz.self.host_configs["8080"].get("/api/v1/orgs/me/filters"),
json={
"signal": "meter",
"filters": [{"key": "host.name", "dataType": "string", "type": ""}],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters/meter"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
assert [(field_key["name"], field_key["signal"]) for field_key in response.json()["data"]["filters"]] == [("host.name", "metrics")]
def test_update_quick_filters_round_trip(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters"),
json={
"signal": "logs",
"filters": [
{
"name": "k8s.pod.name",
"fieldContext": "resource",
"fieldDataType": "string",
},
{
"name": "body.status",
"fieldContext": "body",
"fieldDataType": "string",
},
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters/logs"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
filters = response.json()["data"]["filters"]
assert [field_key["name"] for field_key in filters] == [
"k8s.pod.name",
"body.status",
]
assert filters[0]["fieldContext"] == "resource"
assert filters[1]["fieldContext"] == "body"
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v1/orgs/me/filters/logs"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
assert [(legacy_filter["key"], legacy_filter["type"]) for legacy_filter in response.json()["data"]["filters"]] == [
("k8s.pod.name", "resource"),
("body.status", ""),
]
def test_update_quick_filters_creates_row_for_signal_without_one(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
with signoz.sqlstore.conn.connect() as conn:
conn.execute(
sql.text("DELETE FROM quick_filter WHERE signal = :signal"),
{"signal": "api_monitoring"},
)
conn.commit()
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters/api_monitoring"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"] == {"signal": "api_monitoring", "filters": []}
response = requests.put(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters"),
json={
"signal": "api_monitoring",
"filters": [
{
"name": "service.name",
"fieldContext": "resource",
"fieldDataType": "string",
},
],
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters/api_monitoring"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
assert [field_key["name"] for field_key in response.json()["data"]["filters"]] == ["service.name"]
def test_update_quick_filters_rejects_invalid_input(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
for invalid_body in [
{
"signal": "traces",
"filters": [{"key": "service.name", "dataType": "string", "type": "resource"}],
},
{"signal": "invalid", "filters": []},
]:
response = requests.put(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters"),
json=invalid_body,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text

View File

@@ -0,0 +1,92 @@
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
USERS_BASE,
create_active_user,
)
EDITOR_EMAIL = "editor+quickfilter@integration.test"
VIEWER_EMAIL = "viewer+quickfilter@integration.test"
NON_ADMIN_PASSWORD = "password123Z$"
def test_create_non_admin_users(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get(USERS_BASE),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
existing_emails = {user["email"] for user in response.json()["data"]}
for email, role, name in [
(EDITOR_EMAIL, "signoz-editor", "quickfilter-editor"),
(VIEWER_EMAIL, "signoz-viewer", "quickfilter-viewer"),
]:
if email not in existing_emails:
create_active_user(
signoz,
admin_token,
email=email,
role=role,
password=NON_ADMIN_PASSWORD,
name=name,
)
@pytest.mark.parametrize("email", [EDITOR_EMAIL, VIEWER_EMAIL], ids=["editor", "viewer"])
def test_non_admin_can_read_quick_filters(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
email: str,
):
token = get_token(email, NON_ADMIN_PASSWORD)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters"),
headers={"Authorization": f"Bearer {token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters/traces"),
headers={"Authorization": f"Bearer {token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
@pytest.mark.parametrize("email", [EDITOR_EMAIL, VIEWER_EMAIL], ids=["editor", "viewer"])
def test_non_admin_cannot_update_quick_filters(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
email: str,
):
token = get_token(email, NON_ADMIN_PASSWORD)
response = requests.put(
signoz.self.host_configs["8080"].get("/api/v2/quick_filters"),
json={
"signal": "traces",
"filters": [{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"}],
},
headers={"Authorization": f"Bearer {token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text