Compare commits

..

5 Commits

Author SHA1 Message Date
srikanthccv
2c721113bf chore: some more updates 2026-02-19 02:21:26 +05:30
srikanthccv
8c4a0ad512 chore: add list metrics 2026-02-19 01:56:38 +05:30
Srikanth Chekuri
5c86b80682 chore: add OpenAPI spec for /v5/query_range (#10239)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-18 20:21:37 +00:00
Piyush Singariya
75512a81c6 fix: json qb array expression (#10162)
* fix: json qb array expression

* fix: comment

* fix: unnecessary casting of float64

* revert: changes

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2026-02-18 23:31:23 +05:30
Karan Balani
6aaea79b73 chore: add tests for unique index on email and org_id in users table (#10331)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: add tests for unique index on email and org_id in users table

* chore: choose one status code

* chore: choose one status code

* chore: move unique index test into passwordauthn folder

* chore: moved to existing suite, remove register

* chore: better name for file and vars

* chore: fix var name

* chore: fix var name

* chore: fix var name
2026-02-18 19:05:50 +05:30
68 changed files with 5870 additions and 5625 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import (
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/http/middleware"
querierAPI "github.com/SigNoz/signoz/pkg/querier"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
@@ -106,7 +107,10 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v4/query_range", am.ViewAccess(ah.queryRangeV4)).Methods(http.MethodPost)
// v5
router.HandleFunc("/api/v5/query_range", am.ViewAccess(ah.queryRangeV5)).Methods(http.MethodPost)
router.Handle("/api/v5/query_range", handler.New(
am.ViewAccess(ah.queryRangeV5),
querierAPI.QueryRangeV5OpenAPIDef,
)).Methods(http.MethodPost)
router.HandleFunc("/api/v5/substitute_vars", am.ViewAccess(ah.QuerierAPI.ReplaceVariables)).Methods(http.MethodPost)

View File

@@ -58,7 +58,6 @@
"@signozhq/radio-group": "0.0.2",
"@signozhq/resizable": "0.0.0",
"@signozhq/sonner": "0.1.0",
"@signozhq/switch": "0.0.2",
"@signozhq/table": "0.3.7",
"@signozhq/tooltip": "0.0.2",
"@tanstack/react-table": "8.20.6",

View File

@@ -20,17 +20,20 @@ import { useMutation, useQuery } from 'react-query';
import { GeneratedAPIInstance } from '../../../index';
import type {
GetMetricAlerts200,
GetMetricAlertsParams,
GetMetricAlertsPathParameters,
GetMetricAttributes200,
GetMetricAttributesParams,
GetMetricAttributesPathParameters,
GetMetricDashboards200,
GetMetricDashboardsParams,
GetMetricDashboardsPathParameters,
GetMetricHighlights200,
GetMetricHighlightsParams,
GetMetricHighlightsPathParameters,
GetMetricMetadata200,
GetMetricMetadataParams,
GetMetricMetadataPathParameters,
GetMetricsStats200,
GetMetricsTreemap200,
MetricsexplorertypesMetricAttributesRequestDTO,
ListMetrics200,
ListMetricsParams,
MetricsexplorertypesStatsRequestDTO,
MetricsexplorertypesTreemapRequestDTO,
MetricsexplorertypesUpdateMetricMetadataRequestDTO,
@@ -43,30 +46,128 @@ type AwaitedInput<T> = PromiseLike<T> | T;
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
/**
* This endpoint returns associated alerts for a specified metric
* @summary Get metric alerts
* This endpoint returns a list of distinct metric names within the specified time range
* @summary List metric names
*/
export const getMetricAlerts = (
params: GetMetricAlertsParams,
export const listMetrics = (
params?: ListMetricsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAlerts200>({
url: `/api/v2/metric/alerts`,
return GeneratedAPIInstance<ListMetrics200>({
url: `/api/v2/metrics`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricAlertsQueryKey = (params?: GetMetricAlertsParams) => {
return ['getMetricAlerts', ...(params ? [params] : [])] as const;
export const getListMetricsQueryKey = (params?: ListMetricsParams) => {
return ['listMetrics', ...(params ? [params] : [])] as const;
};
export const getListMetricsQueryOptions = <
TData = Awaited<ReturnType<typeof listMetrics>>,
TError = RenderErrorResponseDTO
>(
params?: ListMetricsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetrics>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListMetricsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listMetrics>>> = ({
signal,
}) => listMetrics(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listMetrics>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListMetricsQueryResult = NonNullable<
Awaited<ReturnType<typeof listMetrics>>
>;
export type ListMetricsQueryError = RenderErrorResponseDTO;
/**
* @summary List metric names
*/
export function useListMetrics<
TData = Awaited<ReturnType<typeof listMetrics>>,
TError = RenderErrorResponseDTO
>(
params?: ListMetricsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listMetrics>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListMetricsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary List metric names
*/
export const invalidateListMetrics = async (
queryClient: QueryClient,
params?: ListMetricsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListMetricsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns associated alerts for a specified metric
* @summary Get metric alerts
*/
export const getMetricAlerts = (
{ metricName }: GetMetricAlertsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAlerts200>({
url: `/api/v2/metrics/${metricName}/alerts`,
method: 'GET',
signal,
});
};
export const getGetMetricAlertsQueryKey = ({
metricName,
}: GetMetricAlertsPathParameters) => {
return ['getMetricAlerts'] as const;
};
export const getGetMetricAlertsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricAlertsParams,
{ metricName }: GetMetricAlertsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -77,13 +178,19 @@ export const getGetMetricAlertsQueryOptions = <
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetMetricAlertsQueryKey(params);
const queryKey =
queryOptions?.queryKey ?? getGetMetricAlertsQueryKey({ metricName });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getMetricAlerts>>> = ({
signal,
}) => getMetricAlerts(params, signal);
}) => getMetricAlerts({ metricName }, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
TError,
TData
@@ -103,7 +210,7 @@ export function useGetMetricAlerts<
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricAlertsParams,
{ metricName }: GetMetricAlertsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -112,7 +219,7 @@ export function useGetMetricAlerts<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricAlertsQueryOptions(params, options);
const queryOptions = getGetMetricAlertsQueryOptions({ metricName }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -128,11 +235,126 @@ export function useGetMetricAlerts<
*/
export const invalidateGetMetricAlerts = async (
queryClient: QueryClient,
params: GetMetricAlertsParams,
{ metricName }: GetMetricAlertsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricAlertsQueryKey(params) },
{ queryKey: getGetMetricAlertsQueryKey({ metricName }) },
options,
);
return queryClient;
};
/**
* This endpoint returns attribute keys and their unique values for a specified metric
* @summary Get metric attributes
*/
export const getMetricAttributes = (
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAttributes200>({
url: `/api/v2/metrics/${metricName}/attributes`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricAttributesQueryKey = (
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
) => {
return ['getMetricAttributes', ...(params ? [params] : [])] as const;
};
export const getGetMetricAttributesQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ??
getGetMetricAttributesQueryKey({ metricName }, params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricAttributes>>
> = ({ signal }) => getMetricAttributes({ metricName }, params, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricAttributesQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricAttributes>>
>;
export type GetMetricAttributesQueryError = RenderErrorResponseDTO;
/**
* @summary Get metric attributes
*/
export function useGetMetricAttributes<
TData = Awaited<ReturnType<typeof getMetricAttributes>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricAttributesQueryOptions(
{ metricName },
params,
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get metric attributes
*/
export const invalidateGetMetricAttributes = async (
queryClient: QueryClient,
{ metricName }: GetMetricAttributesPathParameters,
params?: GetMetricAttributesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricAttributesQueryKey({ metricName }, params) },
options,
);
@@ -144,28 +366,27 @@ export const invalidateGetMetricAlerts = async (
* @summary Get metric dashboards
*/
export const getMetricDashboards = (
params: GetMetricDashboardsParams,
{ metricName }: GetMetricDashboardsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboards200>({
url: `/api/v2/metric/dashboards`,
url: `/api/v2/metrics/${metricName}/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricDashboardsQueryKey = (
params?: GetMetricDashboardsParams,
) => {
return ['getMetricDashboards', ...(params ? [params] : [])] as const;
export const getGetMetricDashboardsQueryKey = ({
metricName,
}: GetMetricDashboardsPathParameters) => {
return ['getMetricDashboards'] as const;
};
export const getGetMetricDashboardsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricDashboardsParams,
{ metricName }: GetMetricDashboardsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -177,13 +398,18 @@ export const getGetMetricDashboardsQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey(params);
queryOptions?.queryKey ?? getGetMetricDashboardsQueryKey({ metricName });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricDashboards>>
> = ({ signal }) => getMetricDashboards(params, signal);
> = ({ signal }) => getMetricDashboards({ metricName }, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
TError,
TData
@@ -203,7 +429,7 @@ export function useGetMetricDashboards<
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricDashboardsParams,
{ metricName }: GetMetricDashboardsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -212,7 +438,10 @@ export function useGetMetricDashboards<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricDashboardsQueryOptions(params, options);
const queryOptions = getGetMetricDashboardsQueryOptions(
{ metricName },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -228,11 +457,11 @@ export function useGetMetricDashboards<
*/
export const invalidateGetMetricDashboards = async (
queryClient: QueryClient,
params: GetMetricDashboardsParams,
{ metricName }: GetMetricDashboardsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricDashboardsQueryKey(params) },
{ queryKey: getGetMetricDashboardsQueryKey({ metricName }) },
options,
);
@@ -244,28 +473,27 @@ export const invalidateGetMetricDashboards = async (
* @summary Get metric highlights
*/
export const getMetricHighlights = (
params: GetMetricHighlightsParams,
{ metricName }: GetMetricHighlightsPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricHighlights200>({
url: `/api/v2/metric/highlights`,
url: `/api/v2/metrics/${metricName}/highlights`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricHighlightsQueryKey = (
params?: GetMetricHighlightsParams,
) => {
return ['getMetricHighlights', ...(params ? [params] : [])] as const;
export const getGetMetricHighlightsQueryKey = ({
metricName,
}: GetMetricHighlightsPathParameters) => {
return ['getMetricHighlights'] as const;
};
export const getGetMetricHighlightsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricHighlightsParams,
{ metricName }: GetMetricHighlightsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -277,13 +505,18 @@ export const getGetMetricHighlightsQueryOptions = <
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey(params);
queryOptions?.queryKey ?? getGetMetricHighlightsQueryKey({ metricName });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricHighlights>>
> = ({ signal }) => getMetricHighlights(params, signal);
> = ({ signal }) => getMetricHighlights({ metricName }, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
TError,
TData
@@ -303,7 +536,7 @@ export function useGetMetricHighlights<
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricHighlightsParams,
{ metricName }: GetMetricHighlightsPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -312,7 +545,10 @@ export function useGetMetricHighlights<
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricHighlightsQueryOptions(params, options);
const queryOptions = getGetMetricHighlightsQueryOptions(
{ metricName },
options,
);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -328,11 +564,115 @@ export function useGetMetricHighlights<
*/
export const invalidateGetMetricHighlights = async (
queryClient: QueryClient,
params: GetMetricHighlightsParams,
{ metricName }: GetMetricHighlightsPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricHighlightsQueryKey(params) },
{ queryKey: getGetMetricHighlightsQueryKey({ metricName }) },
options,
);
return queryClient;
};
/**
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Get metric metadata
*/
export const getMetricMetadata = (
{ metricName }: GetMetricMetadataPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
url: `/api/v2/metrics/${metricName}/metadata`,
method: 'GET',
signal,
});
};
export const getGetMetricMetadataQueryKey = ({
metricName,
}: GetMetricMetadataPathParameters) => {
return ['getMetricMetadata'] as const;
};
export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey({ metricName });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricMetadata>>
> = ({ signal }) => getMetricMetadata({ metricName }, signal);
return {
queryKey,
queryFn,
enabled: !!metricName,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricMetadata>>
>;
export type GetMetricMetadataQueryError = RenderErrorResponseDTO;
/**
* @summary Get metric metadata
*/
export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
{ metricName }: GetMetricMetadataPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricMetadataQueryOptions({ metricName }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get metric metadata
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
{ metricName }: GetMetricMetadataPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricMetadataQueryKey({ metricName }) },
options,
);
@@ -438,189 +778,6 @@ export const useUpdateMetricMetadata = <
return useMutation(mutationOptions);
};
/**
* This endpoint returns attribute keys and their unique values for a specified metric
* @summary Get metric attributes
*/
export const getMetricAttributes = (
metricsexplorertypesMetricAttributesRequestDTO: MetricsexplorertypesMetricAttributesRequestDTO,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAttributes200>({
url: `/api/v2/metrics/attributes`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: metricsexplorertypesMetricAttributesRequestDTO,
signal,
});
};
export const getGetMetricAttributesMutationOptions = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
> => {
const mutationKey = ['getMetricAttributes'];
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 getMetricAttributes>>,
{ data: MetricsexplorertypesMetricAttributesRequestDTO }
> = (props) => {
const { data } = props ?? {};
return getMetricAttributes(data);
};
return { mutationFn, ...mutationOptions };
};
export type GetMetricAttributesMutationResult = NonNullable<
Awaited<ReturnType<typeof getMetricAttributes>>
>;
export type GetMetricAttributesMutationBody = MetricsexplorertypesMetricAttributesRequestDTO;
export type GetMetricAttributesMutationError = RenderErrorResponseDTO;
/**
* @summary Get metric attributes
*/
export const useGetMetricAttributes = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getMetricAttributes>>,
TError,
{ data: MetricsexplorertypesMetricAttributesRequestDTO },
TContext
> => {
const mutationOptions = getGetMetricAttributesMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric
* @summary Get metric metadata
*/
export const getMetricMetadata = (
params: GetMetricMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
url: `/api/v2/metrics/metadata`,
method: 'GET',
params,
signal,
});
};
export const getGetMetricMetadataQueryKey = (
params?: GetMetricMetadataParams,
) => {
return ['getMetricMetadata', ...(params ? [params] : [])] as const;
};
export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetMetricMetadataQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getMetricMetadata>>
> = ({ signal }) => getMetricMetadata(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetMetricMetadataQueryResult = NonNullable<
Awaited<ReturnType<typeof getMetricMetadata>>
>;
export type GetMetricMetadataQueryError = RenderErrorResponseDTO;
/**
* @summary Get metric metadata
*/
export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetMetricMetadataQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get metric metadata
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
params: GetMetricMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetMetricMetadataQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint provides list of metrics with their number of samples and timeseries for the given time range
* @summary Get metrics statistics

View File

@@ -0,0 +1,107 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
MutationFunction,
UseMutationOptions,
UseMutationResult,
} from 'react-query';
import { useMutation } from 'react-query';
import { GeneratedAPIInstance } from '../../../index';
import type {
Querybuildertypesv5QueryRangeRequestDTO,
QueryRangeV5200,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
type AwaitedInput<T> = PromiseLike<T> | T;
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
/**
* Execute a composite query over a time range. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL.
* @summary Query range
*/
export const queryRangeV5 = (
querybuildertypesv5QueryRangeRequestDTO: Querybuildertypesv5QueryRangeRequestDTO,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<QueryRangeV5200>({
url: `/api/v5/query_range`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: querybuildertypesv5QueryRangeRequestDTO,
signal,
});
};
export const getQueryRangeV5MutationOptions = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
> => {
const mutationKey = ['queryRangeV5'];
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 queryRangeV5>>,
{ data: Querybuildertypesv5QueryRangeRequestDTO }
> = (props) => {
const { data } = props ?? {};
return queryRangeV5(data);
};
return { mutationFn, ...mutationOptions };
};
export type QueryRangeV5MutationResult = NonNullable<
Awaited<ReturnType<typeof queryRangeV5>>
>;
export type QueryRangeV5MutationBody = Querybuildertypesv5QueryRangeRequestDTO;
export type QueryRangeV5MutationError = RenderErrorResponseDTO;
/**
* @summary Query range
*/
export const useQueryRangeV5 = <
TError = RenderErrorResponseDTO,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof queryRangeV5>>,
TError,
{ data: Querybuildertypesv5QueryRangeRequestDTO },
TContext
> => {
const mutationOptions = getQueryRangeV5MutationOptions(options);
return useMutation(mutationOptions);
};

File diff suppressed because it is too large Load Diff

View File

@@ -11,13 +11,10 @@ export const getMetricMetadata = async (
): Promise<SuccessResponseV2<MetricMetadataResponse> | ErrorResponseV2> => {
try {
const encodedMetricName = encodeURIComponent(metricName);
const response = await axios.get(
`/metrics/metadata?metricName=${encodedMetricName}`,
{
signal,
headers,
},
);
const response = await axios.get(`/metrics/${encodedMetricName}/metadata`, {
signal,
headers,
});
return {
httpStatusCode: response.status,

View File

@@ -0,0 +1,19 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
const deleteDomain = async (id: string): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete<null>(`/domains/${id}`);
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteDomain;

View File

@@ -0,0 +1,25 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { UpdatableAuthDomain } from 'types/api/v1/domains/put';
const put = async (
props: UpdatableAuthDomain,
): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put<RawSuccessResponse<null>>(
`/domains/${props.id}`,
{ config: props.config },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default put;

View File

@@ -0,0 +1,24 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
const listAllDomain = async (): Promise<
SuccessResponseV2<GettableAuthDomain[]>
> => {
try {
const response = await axios.get<RawSuccessResponse<GettableAuthDomain[]>>(
`/domains`,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default listAllDomain;

View File

@@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
import { PostableAuthDomain } from 'types/api/v1/domains/post';
const post = async (
props: PostableAuthDomain,
): Promise<SuccessResponseV2<GettableAuthDomain>> => {
try {
const response = await axios.post<RawSuccessResponse<GettableAuthDomain>>(
`/domains`,
props,
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default post;

View File

@@ -24,6 +24,5 @@ import '@signozhq/popover';
import '@signozhq/radio-group';
import '@signozhq/resizable';
import '@signozhq/sonner';
import '@signozhq/switch';
import '@signozhq/table';
import '@signozhq/tooltip';

View File

@@ -7,12 +7,6 @@
display: flex;
align-items: center;
justify-content: space-between;
.auth-domain-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
}
}
@@ -21,29 +15,5 @@
display: flex;
flex-direction: row;
gap: 24px;
.auth-domain-list-action-link {
cursor: pointer;
color: var(--bg-robin-500);
transition: color 0.3s;
border: none;
background: none;
padding: 0;
font-size: inherit;
&:hover {
opacity: 0.8;
text-decoration: underline;
}
&.delete {
color: var(--bg-cherry-500);
}
}
}
.auth-domain-list-na {
padding-left: 6px;
color: var(--text-secondary);
}
}

View File

@@ -1,37 +1,28 @@
import { useCallback, useState } from 'react';
import { useState } from 'react';
import { Button, Form, Modal } from 'antd';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import {
useCreateAuthDomain,
useUpdateAuthDomain,
} from 'api/generated/services/authdomains';
import {
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesRoleMappingDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import put from 'api/v1/domains/id/put';
import post from 'api/v1/domains/post';
import { FeatureKeys } from 'constants/features';
import { useNotifications } from 'hooks/useNotifications';
import { defaultTo } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
import { GettableAuthDomain } from 'types/api/v1/domains/list';
import { PostableAuthDomain } from 'types/api/v1/domains/post';
import AuthnProviderSelector from './AuthnProviderSelector';
import {
convertDomainMappingsToRecord,
convertGroupMappingsToRecord,
FormValues,
prepareInitialValues,
} from './CreateEdit.utils';
import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
import ConfigureOIDCAuthnProvider from './Providers/AuthnOIDC';
import ConfigureSAMLAuthnProvider from './Providers/AuthnSAML';
import './CreateEdit.styles.scss';
interface CreateOrEditProps {
isCreate: boolean;
onClose: () => void;
record?: GettableAuthDomain;
}
function configureAuthnProvider(
authnProvider: string,
isCreate: boolean,
@@ -48,193 +39,64 @@ function configureAuthnProvider(
}
}
interface CreateOrEditProps {
isCreate: boolean;
onClose: () => void;
record?: AuthtypesGettableAuthDomainDTO;
}
function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const { isCreate, record, onClose } = props;
const [form] = Form.useForm<FormValues>();
const [form] = Form.useForm<PostableAuthDomain>();
const [authnProvider, setAuthnProvider] = useState<string>(
record?.ssoType || '',
);
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const { featureFlags } = useAppContext();
const handleError = useCallback(
(error: AxiosError<RenderErrorResponseDTO>): void => {
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
},
[showErrorModal],
);
const samlEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false;
const {
mutate: createAuthDomain,
isLoading: isCreating,
} = useCreateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
const {
mutate: updateAuthDomain,
isLoading: isUpdating,
} = useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
/**
* Prepares Google Auth config for API payload
*/
const getGoogleAuthConfig = useCallback(():
| AuthtypesGoogleConfigDTO
| undefined => {
const config = form.getFieldValue('googleAuthConfig');
if (!config) {
return undefined;
}
const { domainToAdminEmailList, ...rest } = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
...(domainToAdminEmail && { domainToAdminEmail }),
};
}, [form]);
/**
* Prepares role mapping for API payload
*/
const getRoleMapping = useCallback((): AuthtypesRoleMappingDTO | undefined => {
const roleMapping = form.getFieldValue('roleMapping');
if (!roleMapping) {
return undefined;
}
const { groupMappingsList, ...rest } = roleMapping;
const groupMappings = convertGroupMappingsToRecord(groupMappingsList);
// Only return roleMapping if there's meaningful content
const hasDefaultRole = !!rest.defaultRole;
const hasUseRoleAttribute = rest.useRoleAttribute === true;
const hasGroupMappings =
groupMappings && Object.keys(groupMappings).length > 0;
if (!hasDefaultRole && !hasUseRoleAttribute && !hasGroupMappings) {
return undefined;
}
return {
...rest,
...(groupMappings && { groupMappings }),
};
}, [form]);
const onSubmitHandler = useCallback(async (): Promise<void> => {
try {
await form.validateFields();
} catch {
return;
}
const onSubmitHandler = async (): Promise<void> => {
const name = form.getFieldValue('name');
const googleAuthConfig = getGoogleAuthConfig();
const googleAuthConfig = form.getFieldValue('googleAuthConfig');
const samlConfig = form.getFieldValue('samlConfig');
const oidcConfig = form.getFieldValue('oidcConfig');
const roleMapping = getRoleMapping();
if (isCreate) {
createAuthDomain(
{
data: {
name,
config: {
ssoEnabled: true,
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
try {
if (isCreate) {
await post({
name,
config: {
ssoEnabled: true,
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
},
},
{
onSuccess: () => {
notifications.success({
message: 'Domain created successfully',
});
onClose();
});
} else {
await put({
id: record?.id || '',
config: {
ssoEnabled: form.getFieldValue('ssoEnabled'),
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
},
onError: handleError,
},
);
} else {
if (!record?.id) {
return;
});
}
updateAuthDomain(
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: form.getFieldValue('ssoEnabled'),
ssoType: authnProvider,
googleAuthConfig,
samlConfig,
oidcConfig,
roleMapping,
},
},
},
{
onSuccess: () => {
notifications.success({
message: 'Domain updated successfully',
});
onClose();
},
onError: handleError,
},
);
onClose();
} catch (error) {
showErrorModal(error as APIError);
}
}, [
authnProvider,
createAuthDomain,
form,
getGoogleAuthConfig,
getRoleMapping,
handleError,
isCreate,
notifications,
onClose,
record,
updateAuthDomain,
]);
};
const onBackHandler = useCallback((): void => {
form.resetFields();
const onBackHandler = (): void => {
setAuthnProvider('');
}, [form]);
};
return (
<Modal
open
footer={null}
onCancel={onClose}
width={authnProvider ? 980 : undefined}
>
<Modal open footer={null} onCancel={onClose}>
<Form
name="auth-domain"
initialValues={defaultTo(prepareInitialValues(record), {
initialValues={defaultTo(record, {
name: '',
ssoEnabled: false,
ssoType: '',
@@ -254,11 +116,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
<section className="action-buttons">
{isCreate && <Button onClick={onBackHandler}>Back</Button>}
{!isCreate && <Button onClick={onClose}>Cancel</Button>}
<Button
onClick={onSubmitHandler}
type="primary"
loading={isCreating || isUpdating}
>
<Button onClick={onSubmitHandler} type="primary">
Save Changes
</Button>
</section>

View File

@@ -1,134 +0,0 @@
import {
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesOIDCConfigDTO,
AuthtypesRoleMappingDTO,
AuthtypesSamlConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
// Form values interface for internal use (includes array-based fields for UI)
export interface FormValues {
name?: string;
ssoEnabled?: boolean;
ssoType?: string;
googleAuthConfig?: AuthtypesGoogleConfigDTO & {
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>;
};
samlConfig?: AuthtypesSamlConfigDTO;
oidcConfig?: AuthtypesOIDCConfigDTO;
roleMapping?: AuthtypesRoleMappingDTO & {
groupMappingsList?: Array<{ groupName?: string; role?: string }>;
};
}
/**
* Converts groupMappingsList array to groupMappings Record for API
*/
export function convertGroupMappingsToRecord(
groupMappingsList?: Array<{ groupName?: string; role?: string }>,
): Record<string, string> | undefined {
if (!Array.isArray(groupMappingsList) || groupMappingsList.length === 0) {
return undefined;
}
const groupMappings: Record<string, string> = {};
groupMappingsList.forEach((item) => {
if (item.groupName && item.role) {
groupMappings[item.groupName] = item.role;
}
});
return Object.keys(groupMappings).length > 0 ? groupMappings : undefined;
}
/**
* Converts groupMappings Record to groupMappingsList array for form
*/
export function convertGroupMappingsToList(
groupMappings?: Record<string, string> | null,
): Array<{ groupName: string; role: string }> {
if (!groupMappings) {
return [];
}
return Object.entries(groupMappings).map(([groupName, role]) => ({
groupName,
role,
}));
}
/**
* Converts domainToAdminEmailList array to domainToAdminEmail Record for API
*/
export function convertDomainMappingsToRecord(
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>,
): Record<string, string> | undefined {
if (
!Array.isArray(domainToAdminEmailList) ||
domainToAdminEmailList.length === 0
) {
return undefined;
}
const domainToAdminEmail: Record<string, string> = {};
domainToAdminEmailList.forEach((item) => {
if (item.domain && item.adminEmail) {
domainToAdminEmail[item.domain] = item.adminEmail;
}
});
return Object.keys(domainToAdminEmail).length > 0
? domainToAdminEmail
: undefined;
}
/**
* Converts domainToAdminEmail Record to domainToAdminEmailList array for form
*/
export function convertDomainMappingsToList(
domainToAdminEmail?: Record<string, string>,
): Array<{ domain: string; adminEmail: string }> {
if (!domainToAdminEmail) {
return [];
}
return Object.entries(domainToAdminEmail).map(([domain, adminEmail]) => ({
domain,
adminEmail,
}));
}
/**
* Prepares initial form values from API record
*/
export function prepareInitialValues(
record?: AuthtypesGettableAuthDomainDTO,
): FormValues {
if (!record) {
return {
name: '',
ssoEnabled: false,
ssoType: '',
};
}
return {
...record,
googleAuthConfig: record.googleAuthConfig
? {
...record.googleAuthConfig,
domainToAdminEmailList: convertDomainMappingsToList(
record.googleAuthConfig.domainToAdminEmail,
),
}
: undefined,
roleMapping: record.roleMapping
? {
...record.roleMapping,
groupMappingsList: convertGroupMappingsToList(
record.roleMapping.groupMappings,
),
}
: undefined,
};
}

View File

@@ -1,67 +1,20 @@
import { useCallback, useState } from 'react';
import { Callout } from '@signozhq/callout';
import { Checkbox } from '@signozhq/checkbox';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
import DomainMappingList from './components/DomainMappingList';
import EmailTagInput from './components/EmailTagInput';
import RoleMappingSection from './components/RoleMappingSection';
import { Form, Input, Typography } from 'antd';
import './Providers.styles.scss';
type ExpandedSection = 'workspace-groups' | 'role-mapping' | null;
function ConfigureGoogleAuthAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
const form = Form.useFormInstance();
const fetchGroups = Form.useWatch(['googleAuthConfig', 'fetchGroups'], form);
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
const handleWorkspaceGroupsChange = useCallback(
(keys: string | string[]): void => {
const isExpanding = Array.isArray(keys) ? keys.length > 0 : !!keys;
setExpandedSection(isExpanding ? 'workspace-groups' : null);
},
[],
);
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'role-mapping' : null);
}, []);
const {
hasErrors: hasWorkspaceGroupsErrors,
errorMessages: workspaceGroupsErrorMessages,
} = useCollapseSectionErrors(
['googleAuthConfig'],
[
['googleAuthConfig', 'fetchGroups'],
['googleAuthConfig', 'serviceAccountJson'],
['googleAuthConfig', 'domainToAdminEmailList'],
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
['googleAuthConfig', 'allowedGroups'],
],
);
return (
<div className="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit Google Authentication</h3>
<p className="authn-provider__description">
<div className="google-auth">
<section className="header">
<Typography.Text className="title">
Edit Google Authentication
</Typography.Text>
<Typography.Paragraph className="description">
Enter OAuth 2.0 credentials obtained from the Google API Console below.
Read the{' '}
<a
@@ -72,247 +25,50 @@ function ConfigureGoogleAuthAuthnProvider({
docs
</a>{' '}
for more information.
</p>
</Typography.Paragraph>
</section>
<div className="authn-provider__columns">
{/* Left Column - Core OAuth Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="google-domain">
Domain
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input id="google-domain" disabled={!isCreate} />
</Form.Item>
</div>
<Form.Item
label="Domain"
name="name"
className="field"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="google-client-id">
Client ID
<Tooltip title="ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'clientId']}
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Client ID is required', whitespace: true },
]}
>
<Input id="google-client-id" />
</Form.Item>
</div>
<Form.Item
label="Client ID"
name={['googleAuthConfig', 'clientId']}
className="field"
tooltip={{
title: `ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="google-client-secret">
Client Secret
<Tooltip title="It is the application's secret.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'clientSecret']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'Client Secret is required',
whitespace: true,
},
]}
>
<Input id="google-client-secret" />
</Form.Item>
</div>
<Form.Item
label="Client Secret"
name={['googleAuthConfig', 'clientSecret']}
className="field"
tooltip={{
title: `It is the application's secret.`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['googleAuthConfig', 'insecureSkipEmailVerified']}
valuePropName="checked"
noStyle
>
<Checkbox
id="google-skip-email-verification"
labelName="Skip Email Verification"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'insecureSkipEmailVerified'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title='Whether to skip email verification. Defaults to "false"'>
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
<Callout
type="warning"
size="small"
showIcon
description="Google OAuth2 won't be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
{/* Right Column - Google Workspace Groups (Advanced) */}
<div className="authn-provider__right">
<Collapse
bordered={false}
activeKey={
expandedSection === 'workspace-groups' ? ['workspace-groups'] : []
}
onChange={handleWorkspaceGroupsChange}
className="authn-provider__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="workspace-groups"
header={
<div className="authn-provider__collapse-header">
{expandedSection !== 'workspace-groups' ? (
<ChevronRight size={16} />
) : (
<ChevronDown size={16} />
)}
<div className="authn-provider__collapse-header-text">
<h4 className="authn-provider__section-title">
Google Workspace Groups (Advanced)
</h4>
<p className="authn-provider__section-description">
Enable group fetching to retrieve user groups from Google Workspace.
Requires a Service Account with domain-wide delegation.
</p>
</div>
{expandedSection !== 'workspace-groups' && hasWorkspaceGroupsErrors && (
<Tooltip
title={
<div>
{workspaceGroupsErrorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div className="authn-provider__group-content">
<div className="authn-provider__checkbox-row">
<Form.Item
name={['googleAuthConfig', 'fetchGroups']}
valuePropName="checked"
noStyle
>
<Checkbox
id="google-fetch-groups"
labelName="Fetch Groups"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(['googleAuthConfig', 'fetchGroups'], checked);
}}
/>
</Form.Item>
<Tooltip title="Enable fetching Google Workspace groups for the user. Requires service account configuration.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
{fetchGroups && (
<div className="authn-provider__group-fields">
<div className="authn-provider__field-group">
<label
className="authn-provider__label"
htmlFor="google-service-account-json"
>
Service Account JSON
<Tooltip title="The JSON content of the Google Service Account credentials file. Required for group fetching.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'serviceAccountJson']}
className="authn-provider__form-item"
>
<TextArea
id="google-service-account-json"
rows={3}
placeholder="Paste service account JSON"
className="authn-provider__textarea"
/>
</Form.Item>
</div>
<DomainMappingList
fieldNamePrefix={['googleAuthConfig', 'domainToAdminEmailList']}
/>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['googleAuthConfig', 'fetchTransitiveGroupMembership']}
valuePropName="checked"
noStyle
>
<Checkbox
id="google-transitive-membership"
labelName="Fetch Transitive Group Membership"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title="If enabled, recursively fetch groups that contain other groups (transitive membership).">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
<div className="authn-provider__field-group">
<label
className="authn-provider__label"
htmlFor="google-allowed-groups"
>
Allowed Groups
<Tooltip title="Optional list of allowed groups. If configured, only users belonging to one of these groups will be allowed to login.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'allowedGroups']}
className="authn-provider__form-item"
>
<EmailTagInput placeholder="Type a group email and press Enter" />
</Form.Item>
</div>
</div>
)}
</div>
</Collapse.Panel>
</Collapse>
<RoleMappingSection
fieldNamePrefix={['roleMapping']}
isExpanded={expandedSection === 'role-mapping'}
onExpandChange={handleRoleMappingChange}
/>
</div>
</div>
<Callout
type="warning"
size="small"
showIcon
description="Google OAuth2 wont be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
);
}

View File

@@ -1,211 +1,110 @@
import { useCallback, useState } from 'react';
import { Callout } from '@signozhq/callout';
import { Checkbox } from '@signozhq/checkbox';
import { CircleHelp } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form, Tooltip } from 'antd';
import ClaimMappingSection from './components/ClaimMappingSection';
import RoleMappingSection from './components/RoleMappingSection';
import { Checkbox, Form, Input, Typography } from 'antd';
import './Providers.styles.scss';
type ExpandedSection = 'claim-mapping' | 'role-mapping' | null;
function ConfigureOIDCAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
const form = Form.useFormInstance();
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
const handleClaimMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'claim-mapping' : null);
}, []);
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'role-mapping' : null);
}, []);
return (
<div className="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit OIDC Authentication</h3>
<p className="authn-provider__description">
Configure OpenID Connect Single Sign-On with your Identity Provider. Read
the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
target="_blank"
rel="noreferrer"
>
docs
</a>{' '}
for more information.
</p>
<div className="saml">
<section className="header">
<Typography.Text className="title">
Edit OIDC Authentication
</Typography.Text>
</section>
<div className="authn-provider__columns">
{/* Left Column - Core OIDC Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-domain">
Domain
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input id="oidc-domain" disabled={!isCreate} />
</Form.Item>
</div>
<Form.Item
label="Domain"
name="name"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-issuer">
Issuer URL
<Tooltip title='The URL identifier for the OIDC provider. For example: "https://accounts.google.com" or "https://login.salesforce.com".'>
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'issuer']}
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Issuer URL is required', whitespace: true },
]}
>
<Input id="oidc-issuer" />
</Form.Item>
</div>
<Form.Item
label="Issuer URL"
name={['oidcConfig', 'issuer']}
tooltip={{
title: `It is the URL identifier for the service. For example: "https://accounts.google.com" or "https://login.salesforce.com".`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-issuer-alias">
Issuer Alias
<Tooltip title="Optional: Override the issuer URL from .well-known/openid-configuration for providers like Azure or Oracle IDCS.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'issuerAlias']}
className="authn-provider__form-item"
>
<Input id="oidc-issuer-alias" />
</Form.Item>
</div>
<Form.Item
label="Issuer Alias"
name={['oidcConfig', 'issuerAlias']}
tooltip={{
title: `Some offspec providers like Azure, Oracle IDCS have oidc discovery url different from issuer url which causes issuerValidation to fail.
This provides a way to override the Issuer url from the .well-known/openid-configuration issuer`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-client-id">
Client ID
<Tooltip title="The application's client ID from your OIDC provider.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'clientId']}
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Client ID is required', whitespace: true },
]}
>
<Input id="oidc-client-id" />
</Form.Item>
</div>
<Form.Item
label="Client ID"
name={['oidcConfig', 'clientId']}
tooltip={{ title: `It is the application's ID.` }}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-client-secret">
Client Secret
<Tooltip title="The application's client secret from your OIDC provider.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'clientSecret']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'Client Secret is required',
whitespace: true,
},
]}
>
<Input id="oidc-client-secret" />
</Form.Item>
</div>
<Form.Item
label="Client Secret"
name={['oidcConfig', 'clientSecret']}
tooltip={{ title: `It is the application's secret.` }}
>
<Input />
</Form.Item>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['oidcConfig', 'insecureSkipEmailVerified']}
valuePropName="checked"
noStyle
>
<Checkbox
id="oidc-skip-email-verification"
labelName="Skip Email Verification"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['oidcConfig', 'insecureSkipEmailVerified'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title='Whether to skip email verification. Defaults to "false"'>
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
<Form.Item
label="Email Claim Mapping"
name={['oidcConfig', 'claimMapping', 'email']}
tooltip={{
title: `Mapping of email claims to the corresponding email field in the token.`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['oidcConfig', 'getUserInfo']}
valuePropName="checked"
noStyle
>
<Checkbox
id="oidc-get-user-info"
labelName="Get User Info"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(['oidcConfig', 'getUserInfo'], checked);
}}
/>
</Form.Item>
<Tooltip title="Use the userinfo endpoint to get additional claims. Useful when providers return thin ID tokens.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
<Form.Item
label="Skip Email Verification"
name={['oidcConfig', 'insecureSkipEmailVerified']}
valuePropName="checked"
className="field"
tooltip={{
title: `Whether to skip email verification. Defaults to "false"`,
}}
>
<Checkbox />
</Form.Item>
<Callout
type="warning"
size="small"
showIcon
description="OIDC won't be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
<Form.Item
label="Get User Info"
name={['oidcConfig', 'getUserInfo']}
valuePropName="checked"
className="field"
tooltip={{
title: `Uses the userinfo endpoint to get additional claims for the token. This is especially useful where upstreams return "thin" id tokens`,
}}
>
<Checkbox />
</Form.Item>
{/* Right Column - Advanced Settings */}
<div className="authn-provider__right">
<ClaimMappingSection
fieldNamePrefix={['oidcConfig', 'claimMapping']}
isExpanded={expandedSection === 'claim-mapping'}
onExpandChange={handleClaimMappingChange}
/>
<RoleMappingSection
fieldNamePrefix={['roleMapping']}
isExpanded={expandedSection === 'role-mapping'}
onExpandChange={handleRoleMappingChange}
/>
</div>
</div>
<Callout
type="warning"
size="small"
showIcon
description="OIDC wont be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
);
}

View File

@@ -1,190 +1,82 @@
import { useCallback, useState } from 'react';
import { Callout } from '@signozhq/callout';
import { Checkbox } from '@signozhq/checkbox';
import { CircleHelp } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form, Tooltip } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import AttributeMappingSection from './components/AttributeMappingSection';
import RoleMappingSection from './components/RoleMappingSection';
import { Checkbox, Form, Input, Typography } from 'antd';
import './Providers.styles.scss';
type ExpandedSection = 'attribute-mapping' | 'role-mapping' | null;
function ConfigureSAMLAuthnProvider({
isCreate,
}: {
isCreate: boolean;
}): JSX.Element {
const form = Form.useFormInstance();
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
const handleAttributeMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'attribute-mapping' : null);
}, []);
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
setExpandedSection(expanded ? 'role-mapping' : null);
}, []);
return (
<div className="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit SAML Authentication</h3>
<p className="authn-provider__description">
Configure SAML 2.0 Single Sign-On with your Identity Provider. Read the{' '}
<a
href="https://signoz.io/docs/userguide/sso-authentication"
target="_blank"
rel="noreferrer"
>
docs
</a>{' '}
for more information.
</p>
<div className="saml">
<section className="header">
<Typography.Text className="title">
Edit SAML Authentication
</Typography.Text>
</section>
<div className="authn-provider__columns">
{/* Left Column - Core SAML Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-domain">
Domain
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__form-item"
rules={[
{ required: true, message: 'Domain is required', whitespace: true },
]}
>
<Input id="saml-domain" disabled={!isCreate} />
</Form.Item>
</div>
<Form.Item
label="Domain"
name="name"
tooltip={{
title:
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
}}
>
<Input disabled={!isCreate} />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-acs-url">
SAML ACS URL
<Tooltip title="The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlIdp']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'SAML ACS URL is required',
whitespace: true,
},
]}
>
<Input id="saml-acs-url" />
</Form.Item>
</div>
<Form.Item
label="SAML ACS URL"
name={['samlConfig', 'samlIdp']}
tooltip={{
title: `The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-entity-id">
SAML Entity ID
<Tooltip title="The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlEntity']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'SAML Entity ID is required',
whitespace: true,
},
]}
>
<Input id="saml-entity-id" />
</Form.Item>
</div>
<Form.Item
label="SAML Entity ID"
name={['samlConfig', 'samlEntity']}
tooltip={{
title: `The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">`,
}}
>
<Input />
</Form.Item>
<div className="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="saml-certificate">
SAML X.509 Certificate
<Tooltip title="The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlCert']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'SAML Certificate is required',
whitespace: true,
},
]}
>
<TextArea
id="saml-certificate"
rows={3}
placeholder="Paste X.509 certificate"
className="authn-provider__textarea"
/>
</Form.Item>
</div>
<Form.Item
label="SAML X.509 Certificate"
name={['samlConfig', 'samlCert']}
tooltip={{
title: `The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{samlCert}</ds:X509Certificate></ds:X509Certificate>`,
}}
>
<Input.TextArea rows={4} />
</Form.Item>
<div className="authn-provider__checkbox-row">
<Form.Item
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
valuePropName="checked"
noStyle
>
<Checkbox
id="saml-skip-signing"
labelName="Skip Signing AuthN Requests"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue(
['samlConfig', 'insecureSkipAuthNRequestsSigned'],
checked,
);
}}
/>
</Form.Item>
<Tooltip title="Whether to skip signing the SAML requests. For providers like JumpCloud, this should be enabled.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</div>
<Form.Item
label="Skip Signing AuthN Requests"
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
valuePropName="checked"
className="field"
tooltip={{
title: `Whether to skip signing the SAML requests. It can typically be found in the WantAuthnRequestsSigned attribute of the IDPSSODescriptor element in the SAML metadata of the identity provider. Example: <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
For providers like jumpcloud, this should be set to true.Note: This is the reverse of WantAuthnRequestsSigned. If WantAuthnRequestsSigned is false, then InsecureSkipAuthNRequestsSigned should be true.`,
}}
>
<Checkbox />
</Form.Item>
<Callout
type="warning"
size="small"
showIcon
description="SAML won't be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
{/* Right Column - Advanced Settings */}
<div className="authn-provider__right">
<AttributeMappingSection
fieldNamePrefix={['samlConfig', 'attributeMapping']}
isExpanded={expandedSection === 'attribute-mapping'}
onExpandChange={handleAttributeMappingChange}
/>
<RoleMappingSection
fieldNamePrefix={['roleMapping']}
isExpanded={expandedSection === 'role-mapping'}
onExpandChange={handleRoleMappingChange}
/>
</div>
</div>
<Callout
type="warning"
size="small"
showIcon
description="SAML wont be enabled unless you enter all the attributes above"
className="callout"
/>
</div>
);
}

View File

@@ -1,246 +1,24 @@
.authn-provider {
.google-auth {
display: flex;
flex-direction: column;
&__header {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 16px;
.ant-form-item {
margin-bottom: 12px !important;
}
&__title {
margin: 0;
color: var(--l1-foreground);
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
&__description {
margin: 0;
color: var(--l2-foreground);
font-family: 'Inter', sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 20px;
a {
color: var(--accent-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
&__columns {
display: grid;
grid-template-columns: 0.9fr 1fr;
gap: 24px;
}
&__left {
display: flex;
flex-direction: column;
}
&__right {
border-left: 1px solid var(--l3-border);
padding-left: 24px;
display: flex;
flex-direction: column;
}
&__field-group {
.header {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__label-icon {
color: var(--l3-foreground);
cursor: help;
flex-shrink: 0;
}
&__form-item {
margin-bottom: 0 !important;
}
&__checkbox-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
input,
textarea {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
.title {
font-weight: bold;
}
&:hover {
border-color: var(--l3-border) !important;
.description {
margin-bottom: 0px !important;
}
&:focus,
&:focus-visible {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
outline: none;
}
}
textarea {
height: auto;
}
&__textarea {
min-height: 60px !important;
max-height: 200px;
resize: vertical;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
font-family: 'SF Mono', monospace;
font-size: 12px;
line-height: 18px;
&::placeholder {
color: var(--l3-foreground) !important;
font-family: Inter, sans-serif;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
outline: none;
}
}
button[role='checkbox'] {
border: 1px solid var(--l2-foreground) !important;
border-radius: 2px;
&[data-state='checked'] {
background-color: var(--bg-robin-500) !important;
border-color: var(--bg-robin-500) !important;
}
}
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__group-content {
display: flex;
flex-direction: column;
gap: 12px;
}
&__group-fields {
display: flex;
flex-direction: column;
gap: 24px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
.authn-provider__field-group,
.authn-provider__checkbox-row {
margin-bottom: 0;
}
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
.callout {

View File

@@ -1,134 +0,0 @@
.attribute-mapping-section {
display: flex;
flex-direction: column;
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
// Thin scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
&__field-group {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__label-icon {
color: var(--l3-foreground);
cursor: help;
flex-shrink: 0;
}
&__form-item {
margin-bottom: 0 !important;
}
// todo: https://github.com/SigNoz/components/issues/116
input {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
outline: none;
}
}
}

View File

@@ -1,190 +0,0 @@
import { useCallback, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
import './AttributeMappingSection.styles.scss';
interface AttributeMappingSectionProps {
/** The form field name prefix for the attribute mapping configuration */
fieldNamePrefix: string[];
/** Whether the section is expanded (controlled mode) */
isExpanded?: boolean;
/** Callback when expand/collapse is toggled */
onExpandChange?: (expanded: boolean) => void;
}
function AttributeMappingSection({
fieldNamePrefix,
isExpanded,
onExpandChange,
}: AttributeMappingSectionProps): JSX.Element {
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
const isControlled = isExpanded !== undefined;
const expanded = isControlled ? isExpanded : internalExpanded;
const handleCollapseChange = useCallback(
(keys: string | string[]): void => {
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
if (isControlled && onExpandChange) {
onExpandChange(newExpanded);
} else {
setInternalExpanded(newExpanded);
}
},
[isControlled, onExpandChange],
);
const collapseActiveKey = expanded ? ['attribute-mapping'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
return (
<div className="attribute-mapping-section">
<Collapse
bordered={false}
activeKey={collapseActiveKey}
onChange={handleCollapseChange}
className="attribute-mapping-section__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="attribute-mapping"
header={
<div
className="attribute-mapping-section__collapse-header"
role="button"
aria-expanded={expanded}
aria-controls="attribute-mapping-content"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="attribute-mapping-section__collapse-header-text">
<h4 className="attribute-mapping-section__section-title">
Attribute Mapping (Advanced)
</h4>
<p className="attribute-mapping-section__section-description">
Configure how SAML assertion attributes from your Identity Provider map
to SigNoz user attributes. Leave empty to use default values.
</p>
</div>
{!expanded && hasErrors && (
<Tooltip
title={
<div>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div
id="attribute-mapping-content"
className="attribute-mapping-section__content"
>
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="email-attribute"
>
Email Attribute
<Tooltip title="The SAML attribute key that contains the user's email. Default: 'email'">
<CircleHelp
size={14}
className="attribute-mapping-section__label-icon"
/>
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'email']}
className="attribute-mapping-section__form-item"
>
<Input id="email-attribute" placeholder="Email" />
</Form.Item>
</div>
{/* Name Attribute */}
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="name-attribute"
>
Name Attribute
<Tooltip title="The SAML attribute key that contains the user's display name. Default: 'name'">
<CircleHelp
size={14}
className="attribute-mapping-section__label-icon"
/>
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'name']}
className="attribute-mapping-section__form-item"
>
<Input id="name-attribute" placeholder="Name" />
</Form.Item>
</div>
{/* Groups Attribute */}
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="groups-attribute"
>
Groups Attribute
<Tooltip title="The SAML attribute key that contains the user's group memberships. Used for role mapping. Default: 'groups'">
<CircleHelp
size={14}
className="attribute-mapping-section__label-icon"
/>
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'groups']}
className="attribute-mapping-section__form-item"
>
<Input id="groups-attribute" placeholder="Groups" />
</Form.Item>
</div>
{/* Role Attribute */}
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="role-attribute"
>
Role Attribute
<Tooltip title="The SAML attribute key that contains the user's role directly from the IDP. Default: 'role'">
<CircleHelp
size={14}
className="attribute-mapping-section__label-icon"
/>
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'role']}
className="attribute-mapping-section__form-item"
>
<Input id="role-attribute" placeholder="Role" />
</Form.Item>
</div>
</div>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default AttributeMappingSection;

View File

@@ -1,134 +0,0 @@
.claim-mapping-section {
display: flex;
flex-direction: column;
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
// Thin scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
&__field-group {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__label-icon {
color: var(--l3-foreground);
cursor: help;
flex-shrink: 0;
}
&__form-item {
margin-bottom: 0 !important;
}
// todo: https://github.com/SigNoz/components/issues/116
input {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
outline: none;
}
}
}

View File

@@ -1,164 +0,0 @@
import { useCallback, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
import './ClaimMappingSection.styles.scss';
interface ClaimMappingSectionProps {
/** The form field name prefix for the claim mapping configuration */
fieldNamePrefix: string[];
/** Whether the section is expanded (controlled mode) */
isExpanded?: boolean;
/** Callback when expand/collapse is toggled */
onExpandChange?: (expanded: boolean) => void;
}
function ClaimMappingSection({
fieldNamePrefix,
isExpanded,
onExpandChange,
}: ClaimMappingSectionProps): JSX.Element {
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
const isControlled = isExpanded !== undefined;
const expanded = isControlled ? isExpanded : internalExpanded;
const handleCollapseChange = useCallback(
(keys: string | string[]): void => {
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
if (isControlled && onExpandChange) {
onExpandChange(newExpanded);
} else {
setInternalExpanded(newExpanded);
}
},
[isControlled, onExpandChange],
);
const collapseActiveKey = expanded ? ['claim-mapping'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
return (
<div className="claim-mapping-section">
<Collapse
bordered={false}
activeKey={collapseActiveKey}
onChange={handleCollapseChange}
className="claim-mapping-section__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="claim-mapping"
header={
<div
className="claim-mapping-section__collapse-header"
role="button"
aria-expanded={expanded}
aria-controls="claim-mapping-content"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="claim-mapping-section__collapse-header-text">
<h4 className="claim-mapping-section__section-title">
Claim Mapping (Advanced)
</h4>
<p className="claim-mapping-section__section-description">
Configure how claims from your Identity Provider map to SigNoz user
attributes. Leave empty to use default values.
</p>
</div>
{!expanded && hasErrors && (
<Tooltip
title={
<div>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div id="claim-mapping-content" className="claim-mapping-section__content">
{/* Email Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="email-claim">
Email Claim
<Tooltip title="The claim key that contains the user's email address. Default: 'email'">
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'email']}
className="claim-mapping-section__form-item"
>
<Input id="email-claim" placeholder="Email" />
</Form.Item>
</div>
{/* Name Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="name-claim">
Name Claim
<Tooltip title="The claim key that contains the user's display name. Default: 'name'">
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'name']}
className="claim-mapping-section__form-item"
>
<Input id="name-claim" placeholder="Name" />
</Form.Item>
</div>
{/* Groups Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="groups-claim">
Groups Claim
<Tooltip title="The claim key that contains the user's group memberships. Used for role mapping. Default: 'groups'">
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'groups']}
className="claim-mapping-section__form-item"
>
<Input id="groups-claim" placeholder="Groups" />
</Form.Item>
</div>
{/* Role Claim */}
<div className="claim-mapping-section__field-group">
<label className="claim-mapping-section__label" htmlFor="role-claim">
Role Claim
<Tooltip title="The claim key that contains the user's role directly from the IDP. Default: 'role'">
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'role']}
className="claim-mapping-section__form-item"
>
<Input id="role-claim" placeholder="Role" />
</Form.Item>
</div>
</div>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default ClaimMappingSection;

View File

@@ -1,103 +0,0 @@
.domain-mapping-list {
display: flex;
flex-direction: column;
gap: 8px;
&__header {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
&__title {
margin: 0;
color: var(--l1-foreground);
}
&__description {
margin: 0;
color: var(--l3-foreground);
}
&__items {
display: flex;
flex-direction: column;
gap: 8px;
}
&__row {
display: flex;
align-items: flex-start;
gap: 8px;
.ant-form-item {
margin-bottom: 0;
}
}
&__field {
flex: 1;
}
&__remove-btn {
flex-shrink: 0;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 0 !important;
border: none !important;
border-radius: 2px !important;
background: transparent !important;
color: var(--bg-cherry-500) !important;
opacity: 0.6 !important;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
box-shadow: none !important;
display: flex;
align-items: center;
justify-content: center;
svg {
color: var(--bg-cherry-500) !important;
width: 12px !important;
height: 12px !important;
}
&:hover {
background: rgba(229, 72, 77, 0.1) !important;
opacity: 0.9 !important;
color: var(--bg-cherry-500) !important;
svg {
color: var(--bg-cherry-500) !important;
}
}
&:active {
opacity: 0.7 !important;
background: rgba(229, 72, 77, 0.15) !important;
}
}
&__add-btn {
width: 100%;
// Ensure icon is visible
svg,
[class*='icon'] {
color: var(--l2-foreground) !important;
display: inline-block !important;
opacity: 1 !important;
}
&:hover {
color: var(--l1-foreground);
svg,
[class*='icon'] {
color: var(--l1-foreground) !important;
}
}
}
}

View File

@@ -1,88 +0,0 @@
import { Button } from '@signozhq/button';
import { Plus, Trash2 } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form } from 'antd';
import './DomainMappingList.styles.scss';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const validateEmail = (_: unknown, value: string): Promise<void> => {
if (!value) {
return Promise.reject(new Error('Admin email is required'));
}
if (!EMAIL_REGEX.test(value)) {
return Promise.reject(new Error('Please enter a valid email'));
}
return Promise.resolve();
};
interface DomainMappingListProps {
/** The form field name prefix for the domain mapping list */
fieldNamePrefix: string[];
}
function DomainMappingList({
fieldNamePrefix,
}: DomainMappingListProps): JSX.Element {
return (
<div className="domain-mapping-list">
<div className="domain-mapping-list__header">
<span className="domain-mapping-list__title">
Domain to Admin Email Mapping
</span>
<p className="domain-mapping-list__description">
Map workspace domains to admin emails for service account impersonation.
Use &quot;*&quot; as a wildcard for any domain.
</p>
</div>
<Form.List name={fieldNamePrefix}>
{(fields, { add, remove }): JSX.Element => (
<div className="domain-mapping-list__items">
{fields.map((field) => (
<div key={field.key} className="domain-mapping-list__row">
<Form.Item
name={[field.name, 'domain']}
className="domain-mapping-list__field"
rules={[{ required: true, message: 'Domain is required' }]}
>
<Input placeholder="Domain (e.g., example.com or *)" />
</Form.Item>
<Form.Item
name={[field.name, 'adminEmail']}
className="domain-mapping-list__field"
rules={[{ validator: validateEmail }]}
>
<Input placeholder="Admin Email" />
</Form.Item>
<Button
variant="ghost"
color="secondary"
className="domain-mapping-list__remove-btn"
onClick={(): void => remove(field.name)}
aria-label="Remove mapping"
>
<Trash2 size={12} />
</Button>
</div>
))}
<Button
variant="dashed"
onClick={(): void => add({ domain: '', adminEmail: '' })}
prefixIcon={<Plus size={14} />}
className="domain-mapping-list__add-btn"
>
Add Domain Mapping
</Button>
</div>
)}
</Form.List>
</div>
);
}
export default DomainMappingList;

View File

@@ -1,28 +0,0 @@
.email-tag-input {
display: flex;
flex-direction: column;
gap: 4px;
&__select {
width: 100%;
.ant-select-selector {
.ant-select-selection-search {
input {
height: 32px !important;
border: none !important;
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
box-shadow: none !important;
font-family: inherit !important;
}
}
}
}
&__error {
margin: 0;
color: var(--bg-cherry-500);
}
}

View File

@@ -1,61 +0,0 @@
import { useCallback, useState } from 'react';
import { Select, Tooltip } from 'antd';
import './EmailTagInput.styles.scss';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
interface EmailTagInputProps {
/** Current value (array of email strings) */
value?: string[];
/** Change handler */
onChange?: (value: string[]) => void;
/** Placeholder text */
placeholder?: string;
}
function EmailTagInput({
value = [],
onChange,
placeholder = 'Type an email and press Enter',
}: EmailTagInputProps): JSX.Element {
const [validationError, setValidationError] = useState('');
const handleChange = useCallback(
(newValues: string[]): void => {
const addedValues = newValues.filter((v) => !value.includes(v));
const invalidEmail = addedValues.find((v) => !EMAIL_REGEX.test(v));
if (invalidEmail) {
setValidationError(`"${invalidEmail}" is not a valid email`);
return;
}
setValidationError('');
onChange?.(newValues);
},
[onChange, value],
);
return (
<div className="email-tag-input">
<Tooltip
title={validationError}
open={!!validationError}
placement="topRight"
>
<Select
mode="tags"
value={value}
onChange={handleChange}
placeholder={placeholder}
tokenSeparators={[',', ' ']}
className="email-tag-input__select"
allowClear
status={validationError ? 'error' : undefined}
/>
</Tooltip>
</div>
);
}
export default EmailTagInput;

View File

@@ -1,294 +0,0 @@
.role-mapping-section {
display: flex;
flex-direction: column;
margin-top: 24px;
&__collapse {
background: transparent !important;
.ant-collapse-item {
border: none !important;
}
.ant-collapse-header {
padding: 0 !important;
}
.ant-collapse-content {
border-top: none !important;
background: transparent !important;
}
.ant-collapse-content-box {
padding: 12px 0 0 24px !important;
}
}
&__collapse-header {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
position: relative;
width: 100%;
svg {
margin-top: 2px;
color: var(--l3-foreground);
flex-shrink: 0;
}
}
&__collapse-header-text {
display: flex;
flex-direction: column;
gap: 4px;
}
&__section-title {
margin: 0;
color: var(--l1-foreground);
}
&__section-description {
margin: 0;
color: var(--l3-foreground);
}
&__content {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 45vh;
overflow-y: auto;
padding-right: 4px;
// Thin scrollbar
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-foreground);
border-radius: 4px;
&:hover {
background: var(--l2-foreground);
}
}
scrollbar-width: thin;
scrollbar-color: var(--l3-foreground) transparent;
}
&__field-group {
display: flex;
flex-direction: column;
gap: 4px;
}
&__label {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--l1-foreground);
}
&__label-icon {
color: var(--l3-foreground);
cursor: help;
flex-shrink: 0;
}
&__form-item {
margin-bottom: 0 !important;
}
&__checkbox-row {
display: flex;
align-items: center;
gap: 6px;
}
&__select {
width: 100%;
&.ant-select {
.ant-select-selector {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
.ant-select-selection-item {
color: var(--l1-foreground) !important;
}
}
&:hover .ant-select-selector {
border-color: var(--l3-border) !important;
}
&.ant-select-focused .ant-select-selector {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
}
.ant-select-arrow {
color: var(--l3-foreground);
}
}
}
&__group-mappings {
display: flex;
flex-direction: column;
gap: 8px;
}
&__group-header {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
&__group-title {
margin: 0;
color: var(--l1-foreground);
}
&__group-description {
margin: 0;
color: var(--l3-foreground);
}
&__items {
display: flex;
flex-direction: column;
gap: 8px;
}
&__row {
display: flex;
align-items: flex-start;
gap: 8px;
.ant-form-item {
margin-bottom: 0;
}
}
&__field {
&--group {
flex: 2;
}
&--role {
flex: 1;
min-width: 120px;
}
}
&__remove-btn {
flex-shrink: 0;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
padding: 0 !important;
border: none !important;
border-radius: 2px !important;
background: transparent !important;
color: var(--bg-cherry-500) !important;
opacity: 0.6 !important;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
box-shadow: none !important;
display: flex;
align-items: center;
justify-content: center;
svg {
color: var(--bg-cherry-500) !important;
width: 12px !important;
height: 12px !important;
}
&:hover {
background: rgba(229, 72, 77, 0.1) !important;
opacity: 0.9 !important;
color: var(--bg-cherry-500) !important;
svg {
color: var(--bg-cherry-500) !important;
}
}
&:active {
opacity: 0.7 !important;
background: rgba(229, 72, 77, 0.15) !important;
}
}
&__add-btn {
width: 100%;
// Ensure icon is visible
svg,
[class*='icon'] {
color: var(--l2-foreground) !important;
display: inline-block !important;
opacity: 1 !important;
}
&:hover {
color: var(--l1-foreground);
svg,
[class*='icon'] {
color: var(--l1-foreground) !important;
}
}
}
// --- Checkbox border visibility ---
button[role='checkbox'] {
border: 1px solid var(--l2-foreground) !important;
border-radius: 2px;
&[data-state='checked'] {
background-color: var(--bg-robin-500) !important;
border-color: var(--bg-robin-500) !important;
}
}
// todo: https://github.com/SigNoz/components/issues/116
input {
height: 32px;
background: var(--l3-background) !important;
border: 1px solid var(--l3-border) !important;
border-radius: 2px;
color: var(--l1-foreground) !important;
&::placeholder {
color: var(--l3-foreground) !important;
opacity: 1;
}
&:hover {
border-color: var(--l3-border) !important;
}
&:focus,
&:focus-visible {
border-color: var(--bg-robin-500) !important;
box-shadow: none !important;
outline: none;
}
}
}

View File

@@ -1,222 +0,0 @@
import { useCallback, useState } from 'react';
import { Button } from '@signozhq/button';
import { Checkbox } from '@signozhq/checkbox';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
Plus,
Trash2,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Select, Tooltip } from 'antd';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
import './RoleMappingSection.styles.scss';
const ROLE_OPTIONS = [
{ value: 'VIEWER', label: 'VIEWER' },
{ value: 'EDITOR', label: 'EDITOR' },
{ value: 'ADMIN', label: 'ADMIN' },
];
interface RoleMappingSectionProps {
/** The form field name prefix for the role mapping configuration */
fieldNamePrefix: string[];
/** Whether the section is expanded (controlled mode) */
isExpanded?: boolean;
/** Callback when expand/collapse is toggled */
onExpandChange?: (expanded: boolean) => void;
}
function RoleMappingSection({
fieldNamePrefix,
isExpanded,
onExpandChange,
}: RoleMappingSectionProps): JSX.Element {
const form = Form.useFormInstance();
const useRoleAttribute = Form.useWatch(
[...fieldNamePrefix, 'useRoleAttribute'],
form,
);
// Support both controlled and uncontrolled modes
const [internalExpanded, setInternalExpanded] = useState(false);
const isControlled = isExpanded !== undefined;
const expanded = isControlled ? isExpanded : internalExpanded;
const handleCollapseChange = useCallback(
(keys: string | string[]): void => {
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
if (isControlled && onExpandChange) {
onExpandChange(newExpanded);
} else {
setInternalExpanded(newExpanded);
}
},
[isControlled, onExpandChange],
);
const collapseActiveKey = expanded ? ['role-mapping'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
return (
<div className="role-mapping-section">
<Collapse
bordered={false}
activeKey={collapseActiveKey}
onChange={handleCollapseChange}
className="role-mapping-section__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="role-mapping"
header={
<div
className="role-mapping-section__collapse-header"
role="button"
aria-expanded={expanded}
aria-controls="role-mapping-content"
>
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
<div className="role-mapping-section__collapse-header-text">
<h4 className="role-mapping-section__section-title">
Role Mapping (Advanced)
</h4>
<p className="role-mapping-section__section-description">
Configure how user roles are determined from your Identity Provider.
You can either use a direct role attribute or map IDP groups to SigNoz
roles.
</p>
</div>
{!expanded && hasErrors && (
<Tooltip
title={
<div>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div id="role-mapping-content" className="role-mapping-section__content">
{/* Default Role */}
<div className="role-mapping-section__field-group">
<label className="role-mapping-section__label" htmlFor="default-role">
Default Role
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "VIEWER"'>
<CircleHelp size={14} className="role-mapping-section__label-icon" />
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'defaultRole']}
className="role-mapping-section__form-item"
initialValue="VIEWER"
>
<Select
id="default-role"
options={ROLE_OPTIONS}
className="role-mapping-section__select"
/>
</Form.Item>
</div>
{/* Use Role Attribute */}
<div className="role-mapping-section__checkbox-row">
<Form.Item
name={[...fieldNamePrefix, 'useRoleAttribute']}
valuePropName="checked"
noStyle
>
<Checkbox
id="use-role-attribute"
labelName="Use Role Attribute Directly"
onCheckedChange={(checked: boolean): void => {
form.setFieldValue([...fieldNamePrefix, 'useRoleAttribute'], checked);
}}
/>
</Form.Item>
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role (VIEWER, EDITOR, or ADMIN).">
<CircleHelp size={14} className="role-mapping-section__label-icon" />
</Tooltip>
</div>
{/* Group to Role Mappings - only show when useRoleAttribute is false */}
{!useRoleAttribute && (
<div className="role-mapping-section__group-mappings">
<div className="role-mapping-section__group-header">
<span className="role-mapping-section__group-title">
Group to Role Mappings
</span>
<p className="role-mapping-section__group-description">
Map IDP group names to SigNoz roles. If a user belongs to multiple
groups, the highest privilege role will be assigned.
</p>
</div>
<Form.List name={[...fieldNamePrefix, 'groupMappingsList']}>
{(fields, { add, remove }): JSX.Element => (
<div className="role-mapping-section__items">
{fields.map((field) => (
<div key={field.key} className="role-mapping-section__row">
<Form.Item
name={[field.name, 'groupName']}
className="role-mapping-section__field role-mapping-section__field--group"
rules={[{ required: true, message: 'Group name is required' }]}
>
<Input placeholder="IDP Group Name" />
</Form.Item>
<Form.Item
name={[field.name, 'role']}
className="role-mapping-section__field role-mapping-section__field--role"
rules={[{ required: true, message: 'Role is required' }]}
initialValue="VIEWER"
>
<Select
options={ROLE_OPTIONS}
className="role-mapping-section__select"
/>
</Form.Item>
<Button
variant="ghost"
color="secondary"
className="role-mapping-section__remove-btn"
onClick={(): void => remove(field.name)}
aria-label="Remove mapping"
>
<Trash2 size={12} />
</Button>
</div>
))}
<Button
variant="dashed"
onClick={(): void => add({ groupName: '', role: 'VIEWER' })}
prefixIcon={<Plus size={14} />}
className="role-mapping-section__add-btn"
>
Add Group Mapping
</Button>
</div>
)}
</Form.List>
</div>
)}
</div>
</Collapse.Panel>
</Collapse>
</div>
);
}
export default RoleMappingSection;

View File

@@ -1,74 +1,45 @@
import { useEffect, useState } from 'react';
import { Switch } from '@signozhq/switch';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { useUpdateAuthDomain } from 'api/generated/services/authdomains';
import {
AuthtypesGettableAuthDomainDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { useState } from 'react';
import { Switch } from 'antd';
import put from 'api/v1/domains/id/put';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
interface ToggleProps {
isDefaultChecked: boolean;
record: AuthtypesGettableAuthDomainDTO;
}
import { GettableAuthDomain } from 'types/api/v1/domains/list';
function Toggle({ isDefaultChecked, record }: ToggleProps): JSX.Element {
const [isChecked, setIsChecked] = useState<boolean>(isDefaultChecked);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { showErrorModal } = useErrorModal();
useEffect(() => {
setIsChecked(isDefaultChecked);
}, [isDefaultChecked]);
const onChangeHandler = async (checked: boolean): Promise<void> => {
setIsLoading(true);
const { mutate: updateAuthDomain, isLoading } = useUpdateAuthDomain<
AxiosError<RenderErrorResponseDTO>
>();
const onChangeHandler = (checked: boolean): void => {
if (!record.id) {
return;
try {
await put({
id: record.id,
config: {
ssoEnabled: checked,
ssoType: record.ssoType,
googleAuthConfig: record.googleAuthConfig,
oidcConfig: record.oidcConfig,
samlConfig: record.samlConfig,
},
});
setIsChecked(checked);
} catch (error) {
showErrorModal(error as APIError);
}
setIsChecked(checked);
updateAuthDomain(
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: checked,
ssoType: record.ssoType,
googleAuthConfig: record.googleAuthConfig,
oidcConfig: record.oidcConfig,
samlConfig: record.samlConfig,
roleMapping: record.roleMapping,
},
},
},
{
onError: (error) => {
setIsChecked(!checked);
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
},
},
);
setIsLoading(false);
};
return (
<Switch
disabled={isLoading}
checked={isChecked}
onCheckedChange={onChangeHandler}
/>
<Switch loading={isLoading} checked={isChecked} onChange={onChangeHandler} />
);
}
interface ToggleProps {
isDefaultChecked: boolean;
record: GettableAuthDomain;
}
export default Toggle;

View File

@@ -1,142 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import AuthDomain from '../index';
import {
AUTH_DOMAINS_LIST_ENDPOINT,
mockDomainsListResponse,
mockEmptyDomainsResponse,
mockErrorResponse,
} from './mocks';
const successNotification = jest.fn();
jest.mock('hooks/useNotifications', () => ({
__esModule: true,
useNotifications: jest.fn(() => ({
notifications: {
success: successNotification,
error: jest.fn(),
},
})),
}));
describe('AuthDomain', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
server.resetHandlers();
});
describe('List View', () => {
it('renders page header and add button', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
),
);
render(<AuthDomain />);
expect(screen.getByText(/authenticated domains/i)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /add domain/i }),
).toBeInTheDocument();
});
it('renders list of auth domains successfully', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(screen.getByText('signoz.io')).toBeInTheDocument();
expect(screen.getByText('example.com')).toBeInTheDocument();
expect(screen.getByText('corp.io')).toBeInTheDocument();
});
});
it('renders empty state when no domains exist', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(screen.getByText(/no data/i)).toBeInTheDocument();
});
});
it('displays error content when API fails', async () => {
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json(mockErrorResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(
screen.getByText(/failed to perform operation/i),
).toBeInTheDocument();
});
});
});
describe('Add Domain', () => {
it('opens create modal when Add Domain button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
),
);
render(<AuthDomain />);
const addButton = await screen.findByRole('button', { name: /add domain/i });
await user.click(addButton);
await waitFor(() => {
expect(
screen.getByText(/configure authentication method/i),
).toBeInTheDocument();
});
});
});
describe('Configure Domain', () => {
it('opens edit modal when configure action is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
),
);
render(<AuthDomain />);
await waitFor(() => {
expect(screen.getByText('signoz.io')).toBeInTheDocument();
});
const configureLinks = await screen.findAllByText(/configure google auth/i);
await user.click(configureLinks[0]);
await waitFor(() => {
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
});
});
});
});

View File

@@ -1,354 +0,0 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
mockDomainWithRoleMapping,
mockGoogleAuthDomain,
mockGoogleAuthWithWorkspaceGroups,
mockOidcAuthDomain,
mockOidcWithClaimMapping,
mockSamlAuthDomain,
mockSamlWithAttributeMapping,
} from './mocks';
const mockOnClose = jest.fn();
describe('CreateEdit Modal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Provider Selection (Create Mode)', () => {
it('renders provider selection when creating new domain', () => {
render(<CreateEdit isCreate onClose={mockOnClose} />);
expect(
screen.getByText(/configure authentication method/i),
).toBeInTheDocument();
expect(screen.getByText(/google apps authentication/i)).toBeInTheDocument();
expect(screen.getByText(/saml authentication/i)).toBeInTheDocument();
expect(screen.getByText(/oidc authentication/i)).toBeInTheDocument();
});
it('returns to provider selection when back button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
await waitFor(() => {
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
});
const backButton = screen.getByRole('button', { name: /back/i });
await user.click(backButton);
await waitFor(() => {
expect(
screen.getByText(/configure authentication method/i),
).toBeInTheDocument();
});
});
});
describe('Edit Mode', () => {
it('shows provider form directly when editing existing domain', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
expect(
screen.queryByText(/configure authentication method/i),
).not.toBeInTheDocument();
});
it('pre-fills form with existing domain values', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByDisplayValue('signoz.io')).toBeInTheDocument();
expect(screen.getByDisplayValue('test-client-id')).toBeInTheDocument();
});
it('disables domain field when editing', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
const domainInput = screen.getByDisplayValue('signoz.io');
expect(domainInput).toBeDisabled();
});
it('shows cancel button instead of back when editing', () => {
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /back/i }),
).not.toBeInTheDocument();
});
});
describe('Form Validation', () => {
it('shows validation error when submitting without required fields', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
const saveButton = await screen.findByRole('button', {
name: /save changes/i,
});
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByText(/domain is required/i)).toBeInTheDocument();
});
});
});
describe('Google Auth Provider', () => {
it('shows Google Auth form fields', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
await waitFor(() => {
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
expect(screen.getByLabelText(/domain/i)).toBeInTheDocument();
expect(screen.getByLabelText(/client id/i)).toBeInTheDocument();
expect(screen.getByLabelText(/client secret/i)).toBeInTheDocument();
expect(screen.getByText(/skip email verification/i)).toBeInTheDocument();
});
});
it('shows workspace groups section when expanded', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthWithWorkspaceGroups}
onClose={mockOnClose}
/>,
);
const workspaceHeader = screen.getByText(/google workspace groups/i);
await user.click(workspaceHeader);
await waitFor(() => {
expect(screen.getByText(/fetch groups/i)).toBeInTheDocument();
expect(screen.getByText(/service account json/i)).toBeInTheDocument();
});
});
});
describe('SAML Provider', () => {
it('shows SAML-specific fields when editing SAML domain', () => {
render(
<CreateEdit
isCreate={false}
record={mockSamlAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/edit saml authentication/i)).toBeInTheDocument();
expect(
screen.getByDisplayValue('https://idp.example.com/sso'),
).toBeInTheDocument();
expect(screen.getByDisplayValue('urn:example:idp')).toBeInTheDocument();
});
it('shows attribute mapping section for SAML', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockSamlWithAttributeMapping}
onClose={mockOnClose}
/>,
);
expect(
screen.getByText(/attribute mapping \(advanced\)/i),
).toBeInTheDocument();
const attributeHeader = screen.getByText(/attribute mapping \(advanced\)/i);
await user.click(attributeHeader);
await waitFor(() => {
expect(screen.getByLabelText(/name attribute/i)).toBeInTheDocument();
expect(screen.getByLabelText(/groups attribute/i)).toBeInTheDocument();
expect(screen.getByLabelText(/role attribute/i)).toBeInTheDocument();
});
});
});
describe('OIDC Provider', () => {
it('shows OIDC-specific fields when editing OIDC domain', () => {
render(
<CreateEdit
isCreate={false}
record={mockOidcAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/edit oidc authentication/i)).toBeInTheDocument();
expect(screen.getByDisplayValue('https://oidc.corp.io')).toBeInTheDocument();
expect(screen.getByDisplayValue('oidc-client-id')).toBeInTheDocument();
});
it('shows claim mapping section for OIDC', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockOidcWithClaimMapping}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/claim mapping \(advanced\)/i)).toBeInTheDocument();
const claimHeader = screen.getByText(/claim mapping \(advanced\)/i);
await user.click(claimHeader);
await waitFor(() => {
expect(screen.getByLabelText(/email claim/i)).toBeInTheDocument();
expect(screen.getByLabelText(/name claim/i)).toBeInTheDocument();
expect(screen.getByLabelText(/groups claim/i)).toBeInTheDocument();
expect(screen.getByLabelText(/role claim/i)).toBeInTheDocument();
});
});
it('shows OIDC options checkboxes', () => {
render(
<CreateEdit
isCreate={false}
record={mockOidcAuthDomain}
onClose={mockOnClose}
/>,
);
expect(screen.getByText(/skip email verification/i)).toBeInTheDocument();
expect(screen.getByText(/get user info/i)).toBeInTheDocument();
});
});
describe('Role Mapping', () => {
it('shows role mapping section in provider forms', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CreateEdit isCreate onClose={mockOnClose} />);
const configureButtons = await screen.findAllByRole('button', {
name: /configure/i,
});
await user.click(configureButtons[0]);
await waitFor(() => {
expect(screen.getByText(/role mapping \(advanced\)/i)).toBeInTheDocument();
});
});
it('expands role mapping section to show default role selector', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockDomainWithRoleMapping}
onClose={mockOnClose}
/>,
);
const roleMappingHeader = screen.getByText(/role mapping \(advanced\)/i);
await user.click(roleMappingHeader);
await waitFor(() => {
expect(screen.getByText(/default role/i)).toBeInTheDocument();
expect(
screen.getByText(/use role attribute directly/i),
).toBeInTheDocument();
});
});
it('shows group mappings section when useRoleAttribute is false', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockDomainWithRoleMapping}
onClose={mockOnClose}
/>,
);
const roleMappingHeader = screen.getByText(/role mapping \(advanced\)/i);
await user.click(roleMappingHeader);
await waitFor(() => {
expect(screen.getByText(/group to role mappings/i)).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /add group mapping/i }),
).toBeInTheDocument();
});
});
});
describe('Modal Actions', () => {
it('calls onClose when cancel button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<CreateEdit
isCreate={false}
record={mockGoogleAuthDomain}
onClose={mockOnClose}
/>,
);
const cancelButton = screen.getByRole('button', { name: /cancel/i });
await user.click(cancelButton);
expect(mockOnClose).toHaveBeenCalled();
});
});
});

View File

@@ -1,115 +0,0 @@
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import Toggle from '../Toggle';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockErrorResponse,
mockGoogleAuthDomain,
mockUpdateSuccessResponse,
} from './mocks';
describe('Toggle', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
server.resetHandlers();
});
it('renders switch with correct initial state', () => {
render(<Toggle isDefaultChecked={true} record={mockGoogleAuthDomain} />);
const switchElement = screen.getByRole('switch');
expect(switchElement).toBeChecked();
});
it('renders unchecked switch when SSO is disabled', () => {
render(
<Toggle
isDefaultChecked={false}
record={{ ...mockGoogleAuthDomain, ssoEnabled: false }}
/>,
);
const switchElement = screen.getByRole('switch');
expect(switchElement).not.toBeChecked();
});
it('calls update API when toggle is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockUpdateAPI = jest.fn();
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
const body = await req.json();
mockUpdateAPI(body);
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(<Toggle isDefaultChecked={true} record={mockGoogleAuthDomain} />);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
await waitFor(() => {
expect(switchElement).not.toBeChecked();
});
expect(mockUpdateAPI).toHaveBeenCalledTimes(1);
expect(mockUpdateAPI).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
ssoEnabled: false,
}),
}),
);
});
it('shows error modal when update fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json(mockErrorResponse)),
),
);
render(<Toggle isDefaultChecked={true} record={mockGoogleAuthDomain} />);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
await waitFor(() => {
expect(screen.getByText(/failed to perform operation/i)).toBeInTheDocument();
});
});
it('does not call API when record has no id', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let apiCalled = false;
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, (_, res, ctx) => {
apiCalled = true;
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(
<Toggle
isDefaultChecked={true}
record={{ ...mockGoogleAuthDomain, id: undefined }}
/>,
);
const switchElement = screen.getByRole('switch');
await user.click(switchElement);
// Wait a bit to ensure no API call was made
await new Promise((resolve) => setTimeout(resolve, 100));
expect(apiCalled).toBe(false);
});
});

View File

@@ -1,220 +0,0 @@
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
// API Endpoints
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_CREATE_ENDPOINT = '*/api/v1/domains';
export const AUTH_DOMAINS_UPDATE_ENDPOINT = '*/api/v1/domains/:id';
export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
// Mock Auth Domain with Google Auth
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-1',
name: 'signoz.io',
ssoEnabled: true,
ssoType: 'google_auth',
googleAuthConfig: {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-1',
},
};
// Mock Auth Domain with SAML
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-2',
name: 'example.com',
ssoEnabled: false,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.example.com/sso',
samlEntity: 'urn:example:idp',
samlCert: 'MOCK_CERTIFICATE',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-2',
},
};
// Mock Auth Domain with OIDC
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
id: 'domain-3',
name: 'corp.io',
ssoEnabled: true,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.corp.io',
clientId: 'oidc-client-id',
clientSecret: 'oidc-client-secret',
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-3',
},
};
// Mock Auth Domain with Role Mapping
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-4',
name: 'enterprise.com',
ssoEnabled: true,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.enterprise.com/sso',
samlEntity: 'urn:enterprise:idp',
samlCert: 'MOCK_CERTIFICATE',
},
roleMapping: {
defaultRole: 'EDITOR',
useRoleAttribute: false,
groupMappings: {
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-4',
},
};
// Mock Auth Domain with useRoleAttribute enabled
export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO = {
id: 'domain-5',
name: 'direct-role.com',
ssoEnabled: true,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.direct-role.com',
clientId: 'direct-role-client-id',
clientSecret: 'direct-role-client-secret',
},
roleMapping: {
defaultRole: 'VIEWER',
useRoleAttribute: true,
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-5',
},
};
// Mock OIDC domain with claim mapping
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-6',
name: 'oidc-claims.com',
ssoEnabled: true,
ssoType: 'oidc',
oidcConfig: {
issuer: 'https://oidc.claims.com',
issuerAlias: 'https://alias.claims.com',
clientId: 'claims-client-id',
clientSecret: 'claims-client-secret',
insecureSkipEmailVerified: true,
getUserInfo: true,
claimMapping: {
email: 'user_email',
name: 'display_name',
groups: 'user_groups',
role: 'user_role',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-6',
},
};
// Mock SAML domain with attribute mapping
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
id: 'domain-7',
name: 'saml-attrs.com',
ssoEnabled: true,
ssoType: 'saml',
samlConfig: {
samlIdp: 'https://idp.saml-attrs.com/sso',
samlEntity: 'urn:saml-attrs:idp',
samlCert: 'MOCK_CERTIFICATE_ATTRS',
insecureSkipAuthNRequestsSigned: true,
attributeMapping: {
name: 'user_display_name',
groups: 'member_of',
role: 'signoz_role',
},
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-7',
},
};
// Mock Google Auth with workspace groups
export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO = {
id: 'domain-8',
name: 'google-groups.com',
ssoEnabled: true,
ssoType: 'google_auth',
googleAuthConfig: {
clientId: 'google-groups-client-id',
clientSecret: 'google-groups-client-secret',
insecureSkipEmailVerified: false,
fetchGroups: true,
serviceAccountJson: '{"type": "service_account"}',
domainToAdminEmail: {
'google-groups.com': 'admin@google-groups.com',
},
fetchTransitiveGroupMembership: true,
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
},
authNProviderInfo: {
relayStatePath: 'api/v1/sso/relay/domain-8',
},
};
// Mock empty list response
export const mockEmptyDomainsResponse = {
status: 'success',
data: [],
};
// Mock list response with domains
export const mockDomainsListResponse = {
status: 'success',
data: [mockGoogleAuthDomain, mockSamlAuthDomain, mockOidcAuthDomain],
};
// Mock single domain list response
export const mockSingleDomainResponse = {
status: 'success',
data: [mockGoogleAuthDomain],
};
// Mock success responses
export const mockCreateSuccessResponse = {
status: 'success',
data: mockGoogleAuthDomain,
};
export const mockUpdateSuccessResponse = {
status: 'success',
data: { ...mockGoogleAuthDomain, ssoEnabled: false },
};
export const mockDeleteSuccessResponse = {
status: 'success',
data: 'Domain deleted successfully',
};
// Mock error responses
export const mockErrorResponse = {
error: {
code: 'internal_error',
message: 'Failed to perform operation',
url: '',
},
};
export const mockValidationErrorResponse = {
error: {
code: 'invalid_input',
message: 'Domain name is required',
url: '',
},
};

View File

@@ -1,214 +1,151 @@
import { useCallback, useMemo, useState } from 'react';
import { useState } from 'react';
import { useQuery } from 'react-query';
import { PlusOutlined } from '@ant-design/icons';
import { Button } from '@signozhq/button';
import { Trash2, X } from '@signozhq/icons';
import { Modal, Typography } from 'antd';
import { Table } from 'antd';
import { Button, Table, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import {
useDeleteAuthDomain,
useListAuthDomains,
} from 'api/generated/services/authdomains';
import {
AuthtypesGettableAuthDomainDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import deleteDomain from 'api/v1/domains/id/delete';
import listAllDomain from 'api/v1/domains/list';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { useNotifications } from 'hooks/useNotifications';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
import { GettableAuthDomain, SSOType } from 'types/api/v1/domains/list';
import CreateEdit from './CreateEdit/CreateEdit';
import Toggle from './Toggle';
import './AuthDomain.styles.scss';
import '../../IngestionSettings/IngestionSettings.styles.scss';
export const SSOType = new Map<string, string>([
['google_auth', 'Google Auth'],
['saml', 'SAML'],
['email_password', 'Email Password'],
['oidc', 'OIDC'],
]);
const columns: ColumnsType<GettableAuthDomain> = [
{
title: 'Domain',
dataIndex: 'name',
key: 'name',
width: 100,
render: (val): JSX.Element => <Typography.Text>{val}</Typography.Text>,
},
{
title: 'Enforce SSO',
dataIndex: 'ssoEnabled',
key: 'ssoEnabled',
width: 80,
render: (value: boolean, record: GettableAuthDomain): JSX.Element => (
<Toggle isDefaultChecked={value} record={record} />
),
},
{
title: 'IDP Initiated SSO URL',
dataIndex: 'relayState',
key: 'relayState',
width: 80,
render: (_, record: GettableAuthDomain): JSX.Element => {
const relayPath = record.authNProviderInfo.relayStatePath;
if (!relayPath) {
return (
<Typography.Text style={{ paddingLeft: '6px' }}>N/A</Typography.Text>
);
}
const href = `${window.location.origin}/${relayPath}`;
return <CopyToClipboard textToCopy={href} />;
},
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
width: 100,
render: (_, record: GettableAuthDomain): JSX.Element => (
<section className="auth-domain-list-column-action">
<Typography.Link data-column-action="configure">
Configure {SSOType.get(record.ssoType)}
</Typography.Link>
<Typography.Link type="danger" data-column-action="delete">
Delete
</Typography.Link>
</section>
),
},
];
async function deleteDomainById(
id: string,
showErrorModal: (error: APIError) => void,
refetchAuthDomainListResponse: () => void,
): Promise<void> {
try {
await deleteDomain(id);
refetchAuthDomainListResponse();
} catch (error) {
showErrorModal(error as APIError);
}
}
function AuthDomain(): JSX.Element {
const [record, setRecord] = useState<AuthtypesGettableAuthDomainDTO>();
const [record, setRecord] = useState<GettableAuthDomain>();
const [addDomain, setAddDomain] = useState<boolean>(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [
activeDomain,
setActiveDomain,
] = useState<AuthtypesGettableAuthDomainDTO | null>(null);
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const {
data: authDomainListResponse,
isLoading: isLoadingAuthDomainListResponse,
isFetching: isFetchingAuthDomainListResponse,
error: errorFetchingAuthDomainListResponse,
refetch: refetchAuthDomainListResponse,
} = useListAuthDomains();
const { mutate: deleteAuthDomain, isLoading } = useDeleteAuthDomain<
AxiosError<RenderErrorResponseDTO>
>();
const showDeleteModal = useCallback(
(domain: AuthtypesGettableAuthDomainDTO): void => {
setActiveDomain(domain);
setIsDeleteModalOpen(true);
},
[],
);
const hideDeleteModal = useCallback((): void => {
setIsDeleteModalOpen(false);
setActiveDomain(null);
}, []);
const handleDeleteDomain = useCallback((): void => {
if (!activeDomain?.id) {
return;
}
deleteAuthDomain(
{ pathParams: { id: activeDomain.id } },
{
onSuccess: () => {
notifications.success({
message: 'Domain deleted successfully',
});
refetchAuthDomainListResponse();
hideDeleteModal();
},
onError: (error) => {
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
},
},
);
}, [
activeDomain,
deleteAuthDomain,
hideDeleteModal,
notifications,
refetchAuthDomainListResponse,
showErrorModal,
]);
const formattedError = useMemo(() => {
if (!errorFetchingAuthDomainListResponse) {
return null;
}
let errorResult: APIError | null = null;
try {
ErrorResponseHandlerV2(
errorFetchingAuthDomainListResponse as AxiosError<ErrorV2Resp>,
);
} catch (error) {
errorResult = error as APIError;
}
return errorResult;
}, [errorFetchingAuthDomainListResponse]);
const columns: ColumnsType<AuthtypesGettableAuthDomainDTO> = useMemo(
() => [
{
title: 'Domain',
dataIndex: 'name',
key: 'name',
width: 100,
render: (val): JSX.Element => <span>{val}</span>,
},
{
title: 'Enforce SSO',
dataIndex: 'ssoEnabled',
key: 'ssoEnabled',
width: 80,
render: (
value: boolean,
record: AuthtypesGettableAuthDomainDTO,
): JSX.Element => <Toggle isDefaultChecked={value} record={record} />,
},
{
title: 'IDP Initiated SSO URL',
dataIndex: 'relayState',
key: 'relayState',
width: 80,
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => {
const relayPath = record.authNProviderInfo?.relayStatePath;
if (!relayPath) {
return <span className="auth-domain-list-na">N/A</span>;
}
const href = `${window.location.origin}/${relayPath}`;
return <CopyToClipboard textToCopy={href} />;
},
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
width: 100,
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => (
<section className="auth-domain-list-column-action">
<Button
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
>
Delete
</Button>
</section>
),
},
],
[showDeleteModal],
);
} = useQuery({
queryFn: listAllDomain,
queryKey: ['/api/v1/domains', 'list'],
enabled: true,
});
return (
<div className="auth-domain">
<section className="auth-domain-header">
<h3 className="auth-domain-title">Authenticated Domains</h3>
<Typography.Title level={3}>Authenticated Domains</Typography.Title>
<Button
prefixIcon={<PlusOutlined />}
type="primary"
icon={<PlusOutlined />}
onClick={(): void => {
setAddDomain(true);
}}
variant="solid"
size="sm"
color="primary"
className="button"
>
Add Domain
</Button>
</section>
{formattedError && <ErrorContent error={formattedError} />}
{!errorFetchingAuthDomainListResponse && (
{(errorFetchingAuthDomainListResponse as APIError) && (
<ErrorContent error={errorFetchingAuthDomainListResponse as APIError} />
)}
{!(errorFetchingAuthDomainListResponse as APIError) && (
<Table
columns={columns}
dataSource={authDomainListResponse?.data?.data}
onRow={undefined}
dataSource={authDomainListResponse?.data}
onRow={(record): any => ({
onClick: (
event: React.SyntheticEvent<HTMLLinkElement, MouseEvent>,
): void => {
const target = event.target as HTMLLinkElement;
const { columnAction } = target.dataset;
switch (columnAction) {
case 'configure':
setRecord(record);
break;
case 'delete':
deleteDomainById(
record.id,
showErrorModal,
refetchAuthDomainListResponse,
);
break;
default:
console.error('Unknown action:', columnAction);
}
},
})}
loading={
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
}
className="auth-domain-list"
rowKey="id"
/>
)}
{(addDomain || record) && (
@@ -222,39 +159,6 @@ function AuthDomain(): JSX.Element {
}}
/>
)}
<Modal
className="delete-ingestion-key-modal"
title={<span className="title">Delete Domain</span>}
open={isDeleteModalOpen}
closable
onCancel={hideDeleteModal}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={hideDeleteModal}
className="cancel-btn"
prefixIcon={<X size={16} />}
>
Cancel
</Button>,
<Button
key="submit"
prefixIcon={<Trash2 size={16} />}
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
>
Delete Domain
</Button>,
]}
>
<Typography.Text className="delete-text">
Are you sure you want to delete the domain{' '}
<strong>{activeDomain?.name}</strong>? This action cannot be undone.
</Typography.Text>
</Modal>
</div>
);
}

View File

@@ -1,61 +0,0 @@
import { useMemo } from 'react';
import { Form } from 'antd';
export interface CollapseSectionErrors {
hasErrors: boolean;
errorMessages: string[];
}
/**
* Detects validation errors in a form section
* @param fieldNamePrefix - Field path prefix for the section
* @param specificFields - Optional specific field prefixes to check (uses prefix matching)
*/
export function useCollapseSectionErrors(
fieldNamePrefix: string[],
specificFields?: string[][],
): CollapseSectionErrors {
const form = Form.useFormInstance();
// Refer: https://github.com/SigNoz/signoz/pull/10276#discussion_r2819372174
Form.useWatch([], form);
// eslint-disable-next-line sonarjs/cognitive-complexity
return useMemo(() => {
const fieldErrors = form.getFieldsError();
const messages: string[] = [];
if (specificFields?.length) {
fieldErrors.forEach((field) => {
const fieldPath = Array.isArray(field.name) ? field.name : [field.name];
const isMatch = specificFields.some((specificField) => {
if (fieldPath.length < specificField.length) {
return false;
}
return specificField.every((part, idx) => fieldPath[idx] === part);
});
if (isMatch && field.errors.length > 0) {
field.errors.forEach((error) => messages.push(error));
}
});
} else {
const prefixPath = fieldNamePrefix.join('.');
fieldErrors.forEach((field) => {
const fieldPath = Array.isArray(field.name)
? field.name.join('.')
: String(field.name);
if (fieldPath.startsWith(prefixPath) && field.errors.length > 0) {
field.errors.forEach((error) => messages.push(error));
}
});
}
return {
hasErrors: messages.length > 0,
errorMessages: messages,
};
}, [form, fieldNamePrefix, specificFields]);
}

View File

@@ -4479,19 +4479,6 @@
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-switch@^1.1.4":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-use-size" "1.1.1"
"@radix-ui/react-tabs@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
@@ -5177,20 +5164,6 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/switch@0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@signozhq/switch/-/switch-0.0.2.tgz#58003ce9c0cd1f2ad8266a7045182607ce51df76"
integrity sha512-3B3Y5dzIyepO6EQJ7agx97bPmwg1dcOY46q2lqviHnMxNk3Sv079nSNCaztjQlo0VR0qu2JgVXhWi5Lw9WBN8A==
dependencies:
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
"@radix-ui/react-switch" "^1.1.4"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/table@0.3.7":
version "0.3.7"
resolved "https://registry.yarnpkg.com/@signozhq/table/-/table-0.3.7.tgz#895b710c02af124dfb5117e02bbc6d80ce062063"

View File

@@ -10,6 +10,26 @@ import (
)
func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/metrics", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.ListMetrics),
handler.OpenAPIDef{
ID: "ListMetrics",
Tags: []string{"metrics"},
Summary: "List metric names",
Description: "This endpoint returns a list of distinct metric names within the specified time range",
Request: nil,
RequestQuery: new(metricsexplorertypes.ListMetricsParams),
RequestContentType: "",
Response: new(metricsexplorertypes.ListMetricsResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/stats", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetStats),
handler.OpenAPIDef{
@@ -48,26 +68,27 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metrics/attributes", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/attributes", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetMetricAttributes),
handler.OpenAPIDef{
ID: "GetMetricAttributes",
Tags: []string{"metrics"},
Summary: "Get metric attributes",
Description: "This endpoint returns attribute keys and their unique values for a specified metric",
Request: new(metricsexplorertypes.MetricAttributesRequest),
RequestContentType: "application/json",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricAttributesRequest),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricAttributesResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/metadata", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetMetricMetadata),
handler.OpenAPIDef{
ID: "GetMetricMetadata",
@@ -75,7 +96,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric metadata",
Description: "This endpoint returns metadata information like metric description, unit, type, temporality, monotonicity for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameParams),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricMetadata),
ResponseContentType: "application/json",
@@ -106,7 +126,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metric/highlights", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/highlights", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetMetricHighlights),
handler.OpenAPIDef{
ID: "GetMetricHighlights",
@@ -114,7 +134,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric highlights",
Description: "This endpoint returns highlights like number of datapoints, totaltimeseries, active time series, last received time for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameParams),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricHighlightsResponse),
ResponseContentType: "application/json",
@@ -126,7 +145,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metric/alerts", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/alerts", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetMetricAlerts),
handler.OpenAPIDef{
ID: "GetMetricAlerts",
@@ -134,7 +153,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric alerts",
Description: "This endpoint returns associated alerts for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameParams),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricAlertsResponse),
ResponseContentType: "application/json",
@@ -146,7 +164,7 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/metric/dashboards", handler.New(
if err := router.Handle("/api/v2/metrics/{metric_name}/dashboards", handler.New(
provider.authZ.ViewAccess(provider.metricsExplorerHandler.GetMetricDashboards),
handler.OpenAPIDef{
ID: "GetMetricDashboards",
@@ -154,7 +172,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Summary: "Get metric dashboards",
Description: "This endpoint returns associated dashboards for a specified metric",
Request: nil,
RequestQuery: new(metricsexplorertypes.MetricNameParams),
RequestContentType: "",
Response: new(metricsexplorertypes.MetricDashboardsResponse),
ResponseContentType: "application/json",

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/swaggest/openapi-go"
"github.com/swaggest/openapi-go/openapi3"
)
type ServeOpenAPIFunc func(openapi.OperationContext)
@@ -59,7 +60,39 @@ func (handler *handler) ServeOpenAPI(opCtx openapi.OperationContext) {
}
// Add request structure
opCtx.AddReqStructure(handler.openAPIDef.Request, openapi.WithContentType(handler.openAPIDef.RequestContentType))
reqOpts := []openapi.ContentOption{openapi.WithContentType(handler.openAPIDef.RequestContentType)}
if len(handler.openAPIDef.RequestExamples) > 0 {
reqOpts = append(reqOpts, openapi.WithCustomize(func(cor openapi.ContentOrReference) {
rbOrRef, ok := cor.(*openapi3.RequestBodyOrRef)
if !ok || rbOrRef.RequestBody == nil {
return
}
ct := handler.openAPIDef.RequestContentType
if ct == "" {
ct = "application/json"
}
mt, exists := rbOrRef.RequestBody.Content[ct]
if !exists {
return
}
if mt.Examples == nil {
mt.Examples = make(map[string]openapi3.ExampleOrRef)
}
for _, ex := range handler.openAPIDef.RequestExamples {
val := ex.Value
oaExample := openapi3.Example{Value: &val}
if ex.Summary != "" {
oaExample.WithSummary(ex.Summary)
}
if ex.Description != "" {
oaExample.WithDescription(ex.Description)
}
mt.Examples[ex.Name] = openapi3.ExampleOrRef{Example: &oaExample}
}
rbOrRef.RequestBody.Content[ct] = mt
}))
}
opCtx.AddReqStructure(handler.openAPIDef.Request, reqOpts...)
// Add request query structure
opCtx.AddReqStructure(handler.openAPIDef.RequestQuery)

View File

@@ -9,6 +9,14 @@ import (
"github.com/swaggest/rest/openapi"
)
// OpenAPIExample is a named example for an OpenAPI operation.
type OpenAPIExample struct {
Name string
Summary string
Description string
Value any
}
// Def is the definition of an OpenAPI operation
type OpenAPIDef struct {
ID string
@@ -18,6 +26,7 @@ type OpenAPIDef struct {
Request any
RequestQuery any
RequestContentType string
RequestExamples []OpenAPIExample
Response any
ResponseContentType string
SuccessStatusCode int

View File

@@ -13,6 +13,14 @@ import (
"github.com/gorilla/mux"
)
func extractMetricName(req *http.Request) (string, error) {
metricName := mux.Vars(req)["metric_name"]
if metricName == "" {
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "metric_name is required in URL path")
}
return metricName, nil
}
type handler struct {
module metricsexplorer.Module
}
@@ -24,6 +32,34 @@ func NewHandler(m metricsexplorer.Module) metricsexplorer.Handler {
}
}
func (h *handler) ListMetrics(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
var params metricsexplorertypes.ListMetricsParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
render.Error(rw, err)
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
out, err := h.module.ListMetrics(req.Context(), orgID, &params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (h *handler) GetStats(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
@@ -114,28 +150,23 @@ func (h *handler) GetMetricMetadata(rw http.ResponseWriter, req *http.Request) {
return
}
var params metricsexplorertypes.MetricNameParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
render.Error(rw, err)
return
}
if params.MetricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName query parameter is required"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
metadataMap, err := h.module.GetMetricMetadataMulti(req.Context(), orgID, []string{params.MetricName})
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
metadata, ok := metadataMap[params.MetricName]
orgID := valuer.MustNewUUID(claims.OrgID)
metadataMap, err := h.module.GetMetricMetadataMulti(req.Context(), orgID, []string{metricName})
if err != nil {
render.Error(rw, err)
return
}
metadata, ok := metadataMap[metricName]
if !ok || metadata == nil {
render.Error(rw, errors.NewNotFoundf(errors.CodeNotFound, "metadata not found for metric %q", params.MetricName))
render.Error(rw, errors.NewNotFoundf(errors.CodeNotFound, "metadata not found for metric %q", metricName))
return
}
@@ -149,19 +180,14 @@ func (h *handler) GetMetricAlerts(rw http.ResponseWriter, req *http.Request) {
return
}
var params metricsexplorertypes.MetricNameParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
if params.MetricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName query parameter is required"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
out, err := h.module.GetMetricAlerts(req.Context(), orgID, params.MetricName)
out, err := h.module.GetMetricAlerts(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
@@ -176,19 +202,14 @@ func (h *handler) GetMetricDashboards(rw http.ResponseWriter, req *http.Request)
return
}
var params metricsexplorertypes.MetricNameParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
if params.MetricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName query parameter is required"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
out, err := h.module.GetMetricDashboards(req.Context(), orgID, params.MetricName)
out, err := h.module.GetMetricDashboards(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
@@ -203,19 +224,14 @@ func (h *handler) GetMetricHighlights(rw http.ResponseWriter, req *http.Request)
return
}
var params metricsexplorertypes.MetricNameParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
if params.MetricName == "" {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "metricName query parameter is required"))
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
highlights, err := h.module.GetMetricHighlights(req.Context(), orgID, params.MetricName)
highlights, err := h.module.GetMetricHighlights(req.Context(), orgID, metricName)
if err != nil {
render.Error(rw, err)
return
@@ -230,8 +246,21 @@ func (h *handler) GetMetricAttributes(rw http.ResponseWriter, req *http.Request)
return
}
metricName, err := extractMetricName(req)
if err != nil {
render.Error(rw, err)
return
}
var in metricsexplorertypes.MetricAttributesRequest
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
if err := binding.Query.BindQuery(req.URL.Query(), &in); err != nil {
render.Error(rw, err)
return
}
in.MetricName = metricName
if err := in.Validate(); err != nil {
render.Error(rw, err)
return
}

View File

@@ -56,6 +56,88 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
}
}
func (m *module) ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error) {
if err := params.Validate(); err != nil {
return nil, err
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select("DISTINCT metric_name")
if params.Start != nil && params.End != nil {
start, end, distributedTsTable, _ := telemetrymetrics.WhichTSTableToUse(uint64(*params.Start), uint64(*params.End), nil)
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, distributedTsTable))
sb.Where(sb.Between("unix_milli", start, end))
} else {
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV41weekTableName))
}
sb.Where(sb.E("__normalized", false))
if params.Search != "" {
searchLower := strings.ToLower(params.Search)
searchLower = strings.ReplaceAll(searchLower, "%", "\\%")
searchLower = strings.ReplaceAll(searchLower, "_", "\\_")
sb.Where(sb.Like("lower(metric_name)", fmt.Sprintf("%%%s%%", searchLower)))
}
sb.OrderBy("metric_name ASC")
sb.Limit(params.Limit)
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to list metrics")
}
defer rows.Close()
metricNames := make([]string, 0)
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to scan metric name")
}
metricNames = append(metricNames, name)
}
if err := rows.Err(); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error iterating metric names")
}
if len(metricNames) == 0 {
return &metricsexplorertypes.ListMetricsResponse{
Metrics: []metricsexplorertypes.ListMetric{},
}, nil
}
metadata, err := m.GetMetricMetadataMulti(ctx, orgID, metricNames)
if err != nil {
return nil, err
}
metrics := make([]metricsexplorertypes.ListMetric, 0, len(metricNames))
for _, name := range metricNames {
metric := metricsexplorertypes.ListMetric{
MetricName: name,
}
if meta, ok := metadata[name]; ok && meta != nil {
metric.Description = meta.Description
metric.MetricType = meta.MetricType
metric.MetricUnit = meta.MetricUnit
metric.Temporality = meta.Temporality
metric.IsMonotonic = meta.IsMonotonic
}
metrics = append(metrics, metric)
}
return &metricsexplorertypes.ListMetricsResponse{
Metrics: metrics,
}, nil
}
func (m *module) GetStats(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.StatsRequest) (*metricsexplorertypes.StatsResponse, error) {
if err := req.Validate(); err != nil {
return nil, err

View File

@@ -10,6 +10,7 @@ import (
// Handler exposes HTTP handlers for the metrics module.
type Handler interface {
ListMetrics(http.ResponseWriter, *http.Request)
GetStats(http.ResponseWriter, *http.Request)
GetTreemap(http.ResponseWriter, *http.Request)
GetMetricMetadata(http.ResponseWriter, *http.Request)
@@ -22,6 +23,7 @@ type Handler interface {
// Module represents the metrics module interface.
type Module interface {
ListMetrics(ctx context.Context, orgID valuer.UUID, params *metricsexplorertypes.ListMetricsParams) (*metricsexplorertypes.ListMetricsResponse, error)
GetStats(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.StatsRequest) (*metricsexplorertypes.StatsResponse, error)
GetTreemap(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.TreemapRequest) (*metricsexplorertypes.TreemapResponse, error)
GetMetricMetadataMulti(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string]*metricsexplorertypes.MetricMetadata, error)

454
pkg/querier/openapi.go Normal file
View File

@@ -0,0 +1,454 @@
package querier
import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
)
// QueryRangeV5OpenAPIDef is the OpenAPI definition for the /api/v5/query_range endpoint.
var QueryRangeV5OpenAPIDef = handler.OpenAPIDef{
ID: "QueryRangeV5",
Tags: []string{"query"},
Summary: "Query range",
Description: "Execute a composite query over a time range. Supports builder queries (traces, logs, metrics), formulas, trace operators, PromQL, and ClickHouse SQL.",
Request: new(qbtypes.QueryRangeRequest),
RequestContentType: "application/json",
RequestExamples: queryRangeV5Examples,
Response: new(qbtypes.QueryRangeResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: []handler.OpenAPISecurityScheme{
{Name: ctxtypes.AuthTypeAPIKey.StringValue(), Scopes: []string{"VIEWER"}},
{Name: ctxtypes.AuthTypeTokenizer.StringValue(), Scopes: []string{"VIEWER"}},
},
}
var queryRangeV5Examples = []handler.OpenAPIExample{
{
Name: "traces_time_series",
Summary: "Time series: count spans grouped by service",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{
map[string]any{"expression": "count()", "alias": "span_count"},
},
"stepInterval": "60s",
"filter": map[string]any{"expression": "service.name = 'frontend'"},
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"order": []any{map[string]any{"key": map[string]any{"name": "span_count"}, "direction": "desc"}},
"limit": 10,
},
},
},
},
},
},
{
Name: "logs_time_series",
Summary: "Time series: count error logs grouped by service",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"aggregations": []any{
map[string]any{"expression": "count()", "alias": "log_count"},
},
"stepInterval": "60s",
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
"order": []any{map[string]any{"key": map[string]any{"name": "log_count"}, "direction": "desc"}},
"limit": 10,
},
},
},
},
},
},
{
Name: "metrics_gauge_time_series",
Summary: "Time series: latest gauge value averaged across series",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "system.cpu.utilization", "timeAggregation": "latest", "spaceAggregation": "avg"},
},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "host.name", "fieldContext": "resource"}},
},
},
},
},
},
},
{
Name: "metrics_rate_time_series",
Summary: "Time series: rate of cumulative counter",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum"},
},
"stepInterval": 120,
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
},
},
},
},
},
},
{
Name: "metrics_histogram_time_series",
Summary: "Time series: p99 latency from histogram",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.bucket", "spaceAggregation": "p99"},
},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
},
},
},
},
},
},
{
Name: "logs_raw",
Summary: "Raw: fetch raw log records",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "raw",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
"selectFields": []any{
map[string]any{"name": "body", "fieldContext": "log"},
map[string]any{"name": "service.name", "fieldContext": "resource"},
},
"order": []any{
map[string]any{"key": map[string]any{"name": "timestamp", "fieldContext": "log"}, "direction": "desc"},
map[string]any{"key": map[string]any{"name": "id"}, "direction": "desc"},
},
"limit": 50,
"offset": 0,
},
},
},
},
},
},
{
Name: "traces_raw",
Summary: "Raw: fetch raw span records",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "raw",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"filter": map[string]any{"expression": "service.name = 'frontend' AND has_error = true"},
"selectFields": []any{
map[string]any{"name": "name", "fieldContext": "span"},
map[string]any{"name": "duration_nano", "fieldContext": "span"},
},
"order": []any{
map[string]any{"key": map[string]any{"name": "timestamp", "fieldContext": "span"}, "direction": "desc"},
},
"limit": 100,
},
},
},
},
},
},
{
Name: "traces_scalar",
Summary: "Scalar: total span count",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "scalar",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{
map[string]any{"expression": "count()", "alias": "span_count"},
},
"filter": map[string]any{"expression": "service.name = 'frontend'"},
},
},
},
},
},
},
{
Name: "logs_scalar",
Summary: "Scalar: total error log count",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "scalar",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"aggregations": []any{
map[string]any{"expression": "count()", "alias": "error_count"},
},
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
},
},
},
},
},
},
{
Name: "metrics_scalar",
Summary: "Scalar: single reduced metric value",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "scalar",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{
map[string]any{"metricName": "http.server.duration.count", "timeAggregation": "rate", "spaceAggregation": "sum", "reduceTo": "sum"},
},
"stepInterval": "60s",
},
},
},
},
},
},
{
Name: "formula",
Summary: "Formula: error rate from two trace queries",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "countIf(has_error = true)"}},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
},
},
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "B",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()"}},
"stepInterval": "60s",
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource"}},
},
},
map[string]any{
"type": "builder_formula",
"spec": map[string]any{
"name": "error_rate",
"expression": "A / B * 100",
},
},
},
},
},
},
{
Name: "promql",
Summary: "PromQL: request rate with UTF-8 metric name",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "promql",
"spec": map[string]any{
"name": "request_rate",
"query": "sum(rate({\"http.server.duration.count\"}[5m])) by (\"service.name\")",
"step": 60,
},
},
},
},
},
},
{
Name: "clickhouse_sql_traces_time_series",
Summary: "ClickHouse SQL: traces time series with resource filter",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "time_series",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "clickhouse_sql",
"spec": map[string]any{
"name": "span_rate",
"query": "WITH __resource_filter AS (" +
" SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource" +
" WHERE seen_at_ts_bucket_start >= $start_timestamp - 1800 AND seen_at_ts_bucket_start <= $end_timestamp" +
" ) SELECT toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts, count() AS value" +
" FROM signoz_traces.distributed_signoz_index_v3" +
" WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)" +
" AND timestamp >= $start_datetime AND timestamp <= $end_datetime" +
" AND ts_bucket_start >= $start_timestamp - 1800 AND ts_bucket_start <= $end_timestamp" +
" GROUP BY ts ORDER BY ts",
},
},
},
},
},
},
{
Name: "clickhouse_sql_logs_raw",
Summary: "ClickHouse SQL: raw logs with resource filter",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "raw",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "clickhouse_sql",
"spec": map[string]any{
"name": "recent_errors",
"query": "WITH __resource_filter AS (" +
" SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource" +
" WHERE seen_at_ts_bucket_start >= $start_timestamp - 1800 AND seen_at_ts_bucket_start <= $end_timestamp" +
" ) SELECT timestamp, body" +
" FROM signoz_logs.distributed_logs_v2" +
" WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)" +
" AND timestamp >= $start_timestamp_nano AND timestamp <= $end_timestamp_nano" +
" AND ts_bucket_start >= $start_timestamp - 1800 AND ts_bucket_start <= $end_timestamp" +
" AND severity_text = 'ERROR'" +
" ORDER BY timestamp DESC LIMIT 100",
},
},
},
},
},
},
{
Name: "clickhouse_sql_traces_scalar",
Summary: "ClickHouse SQL: scalar aggregate with resource filter",
Value: map[string]any{
"schemaVersion": "v1",
"start": 1640995200000,
"end": 1640998800000,
"requestType": "scalar",
"compositeQuery": map[string]any{
"queries": []any{
map[string]any{
"type": "clickhouse_sql",
"spec": map[string]any{
"name": "total_spans",
"query": "WITH __resource_filter AS (" +
" SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource" +
" WHERE seen_at_ts_bucket_start >= $start_timestamp - 1800 AND seen_at_ts_bucket_start <= $end_timestamp" +
" ) SELECT count() AS value" +
" FROM signoz_traces.distributed_signoz_index_v3" +
" WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)" +
" AND timestamp >= $start_datetime AND timestamp <= $end_datetime" +
" AND ts_bucket_start >= $start_timestamp - 1800 AND ts_bucket_start <= $end_timestamp",
},
},
},
},
},
},
}

View File

@@ -1,7 +1,10 @@
package signoz
import (
"bytes"
"context"
"encoding/json"
"net/http"
"os"
"reflect"
@@ -22,10 +25,13 @@ import (
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/modules/session"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/gorilla/mux"
"github.com/swaggest/jsonschema-go"
"github.com/swaggest/openapi-go"
"github.com/swaggest/openapi-go/openapi3"
"gopkg.in/yaml.v2"
)
type OpenAPI struct {
@@ -57,6 +63,10 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
return nil, err
}
// Register routes that live outside the APIServer modules
// so they are discovered by the OpenAPI walker.
registerQueryRoutes(apiserver.Router())
reflector := openapi3.NewReflector()
reflector.JSONSchemaReflector().DefaultOptions = append(reflector.JSONSchemaReflector().DefaultOptions, jsonschema.InterceptDefName(func(t reflect.Type, defaultDefName string) string {
if defaultDefName == "RenderSuccessResponse" {
@@ -90,10 +100,67 @@ func (openapi *OpenAPI) CreateAndWrite(path string) error {
return err
}
spec, err := openapi.reflector.Spec.MarshalYAML()
// The library's MarshalYAML does a JSON round-trip that converts all numbers
// to float64, causing large integers (e.g. epoch millisecond timestamps) to
// render in scientific notation (1.6409952e+12).
jsonData, err := openapi.reflector.Spec.MarshalJSON()
if err != nil {
return err
}
dec := json.NewDecoder(bytes.NewReader(jsonData))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return err
}
convertJSONNumbers(v)
spec, err := yaml.Marshal(v)
if err != nil {
return err
}
return os.WriteFile(path, spec, 0o600)
}
// convertJSONNumbers recursively walks a decoded JSON structure and converts
// json.Number values to int64 (preferred) or float64 so that YAML marshaling
// renders them as plain numbers instead of quoted strings.
func convertJSONNumbers(v interface{}) {
switch val := v.(type) {
case map[string]interface{}:
for k, elem := range val {
if n, ok := elem.(json.Number); ok {
if i, err := n.Int64(); err == nil {
val[k] = i
} else if f, err := n.Float64(); err == nil {
val[k] = f
}
} else {
convertJSONNumbers(elem)
}
}
case []interface{}:
for i, elem := range val {
if n, ok := elem.(json.Number); ok {
if i64, err := n.Int64(); err == nil {
val[i] = i64
} else if f, err := n.Float64(); err == nil {
val[i] = f
}
} else {
convertJSONNumbers(elem)
}
}
}
}
func registerQueryRoutes(router *mux.Router) {
router.Handle("/api/v5/query_range", handler.New(
func(http.ResponseWriter, *http.Request) {},
querier.QueryRangeV5OpenAPIDef,
)).Methods(http.MethodPost)
}

View File

@@ -61,13 +61,11 @@ var (
}
)
type fieldMapper struct {
}
type fieldMapper struct {}
func NewFieldMapper() qbtypes.FieldMapper {
return &fieldMapper{}
}
func (m *fieldMapper) getColumn(_ context.Context, key *telemetrytypes.TelemetryFieldKey) (*schema.Column, error) {
switch key.FieldContext {
case telemetrytypes.FieldContextResource:
@@ -254,12 +252,27 @@ func (m *fieldMapper) buildFieldForJSON(key *telemetrytypes.TelemetryFieldKey) (
"plan length is less than 2 for promoted path: %s", key.Name)
}
// promoted column first then body_json column
// TODO(Piyush): Change this in future for better performance
expr = fmt.Sprintf("coalesce(%s, %s)",
fmt.Sprintf("dynamicElement(%s, '%s')", plan[1].FieldPath(), plan[1].TerminalConfig.ElemType.StringValue()),
expr,
node := plan[1]
promotedExpr := fmt.Sprintf(
"dynamicElement(%s, '%s')",
node.FieldPath(),
node.TerminalConfig.ElemType.StringValue(),
)
// dynamicElement returns NULL for scalar types or an empty array for array types.
if node.TerminalConfig.ElemType.IsArray {
expr = fmt.Sprintf(
"if(length(%s) > 0, %s, %s)",
promotedExpr,
promotedExpr,
expr,
)
} else {
// promoted column first then body_json column
// TODO(Piyush): Change this in future for better performance
expr = fmt.Sprintf("coalesce(%s, %s)", promotedExpr, expr)
}
}
return expr, nil
@@ -281,8 +294,7 @@ func (m *fieldMapper) buildArrayConcat(plan telemetrytypes.JSONAccessPlan) (stri
}
// Build arrayMap expressions for ALL available branches at the root level.
// Iterate branches in deterministic order (JSON then Dynamic) so generated SQL
// is stable across environments; map iteration order is random in Go.
// Iterate branches in deterministic order (JSON then Dynamic)
var arrayMapExpressions []string
for _, node := range plan {
for _, branchType := range node.BranchesInOrder() {

View File

@@ -32,7 +32,6 @@ func NewJSONConditionBuilder(key *telemetrytypes.TelemetryFieldKey, valueType te
// BuildCondition builds the full WHERE condition for body_json JSON paths
func (c *jsonConditionBuilder) buildJSONCondition(operator qbtypes.FilterOperator, value any, sb *sqlbuilder.SelectBuilder) (string, error) {
conditions := []string{}
for _, node := range c.key.JSONPlan {
condition, err := c.emitPlannedCondition(node, operator, value, sb)
@@ -73,9 +72,9 @@ func (c *jsonConditionBuilder) buildTerminalCondition(node *telemetrytypes.JSONA
// switch operator for array membership checks
switch operator {
case qbtypes.FilterOperatorContains, qbtypes.FilterOperatorIn:
case qbtypes.FilterOperatorContains:
operator = qbtypes.FilterOperatorEqual
case qbtypes.FilterOperatorNotContains, qbtypes.FilterOperatorNotIn:
case qbtypes.FilterOperatorNotContains:
operator = qbtypes.FilterOperatorNotEqual
}
}
@@ -191,13 +190,14 @@ func (c *jsonConditionBuilder) buildArrayMembershipCondition(node *telemetrytype
arrayExpr = typedArrayExpr()
}
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, "x", operator)
key := "x"
fieldExpr, value := querybuilder.DataTypeCollisionHandledFieldName(&localKeyCopy, value, key, operator)
op, err := c.applyOperator(sb, fieldExpr, operator, value)
if err != nil {
return "", err
}
return fmt.Sprintf("arrayExists(%s -> %s, %s)", fieldExpr, op, arrayExpr), nil
return fmt.Sprintf("arrayExists(%s -> %s, %s)", key, op, arrayExpr), nil
}
// recurseArrayHops recursively builds array traversal conditions
@@ -279,27 +279,31 @@ func (c *jsonConditionBuilder) applyOperator(sb *sqlbuilder.SelectBuilder, field
case qbtypes.FilterOperatorNotContains:
return sb.NotILike(fieldExpr, fmt.Sprintf("%%%v%%", value)), nil
case qbtypes.FilterOperatorIn, qbtypes.FilterOperatorNotIn:
// emulate IN/NOT IN using OR/AND over equals to leverage indexes consistently
values, ok := value.([]any)
if !ok {
values = []any{value}
}
conds := []string{}
for _, v := range values {
if operator == qbtypes.FilterOperatorIn {
conds = append(conds, sb.E(fieldExpr, v))
} else {
conds = append(conds, sb.NE(fieldExpr, v))
}
}
if operator == qbtypes.FilterOperatorIn {
return sb.Or(conds...), nil
return sb.In(fieldExpr, values...), nil
}
return sb.And(conds...), nil
return sb.NotIn(fieldExpr, values...), nil
case qbtypes.FilterOperatorExists:
return fmt.Sprintf("%s IS NOT NULL", fieldExpr), nil
case qbtypes.FilterOperatorNotExists:
return fmt.Sprintf("%s IS NULL", fieldExpr), nil
// between and not between
case qbtypes.FilterOperatorBetween, qbtypes.FilterOperatorNotBetween:
values, ok := value.([]any)
if !ok {
return "", qbtypes.ErrBetweenValues
}
if len(values) != 2 {
return "", qbtypes.ErrBetweenValues
}
if operator == qbtypes.FilterOperatorBetween {
return sb.Between(fieldExpr, values[0], values[1]), nil
}
return sb.NotBetween(fieldExpr, values[0], values[1]), nil
default:
return "", qbtypes.ErrUnsupportedOperator
}

View File

@@ -316,7 +316,7 @@ func TestStatementBuilderListQueryBody(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%1.65%", 1.65, "%1.65%", 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
},
@@ -345,7 +345,7 @@ func TestStatementBuilderListQueryBody(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%true%", true, "%true%", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
},
@@ -360,12 +360,55 @@ func TestStatementBuilderListQueryBody(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toString(x) -> toString(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toString(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%passed%", "passed", "%passed%", "passed", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
},
expectedErr: nil,
},
{
name: "Dynamic array IN Operator",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "body.education[].parameters IN [1.65, 1.99]"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> toFloat64(x) IN (?, ?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> x IN (?, ?), arrayMap(x->dynamicElement(x, 'Array(Nullable(Float64))'), arrayFilter(x->(dynamicType(x) = 'Array(Nullable(Float64))'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), 1.65, 1.99, 1.65, 1.99, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,jsondatatype=Array(Dynamic)]."},
},
expectedErr: nil,
},
{
name: "Integer BETWEEN Operator",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "education[].awards[].semester BETWEEN 2 AND 4"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (arrayExists(`body_json.education`-> (arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) BETWEEN ? AND ?, dynamicElement(`body_json.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) BETWEEN ? AND ?, arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_json.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (arrayExists(`body_json.education`-> (arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) BETWEEN ? AND ?, dynamicElement(`body_json.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) BETWEEN ? AND ?, arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_json.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{float64(2), float64(4), float64(2), float64(4), uint64(1747945619), uint64(1747983448), float64(2), float64(4), float64(2), float64(4), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "Integer IN Operator",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "education[].awards[].semester IN [2, 4]"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (arrayExists(`body_json.education`-> (arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) IN (?, ?), dynamicElement(`body_json.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) IN (?, ?), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_json.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (arrayExists(`body_json.education`-> (arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) IN (?, ?), dynamicElement(`body_json.education`.`awards`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')) OR arrayExists(`body_json.education[].awards`-> toFloat64(dynamicElement(`body_json.education[].awards`.`semester`, 'Int64')) IN (?, ?), arrayMap(x->dynamicElement(x, 'JSON'), arrayFilter(x->(dynamicType(x) = 'JSON'), dynamicElement(`body_json.education`.`awards`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{float64(2), float64(4), float64(2), float64(4), uint64(1747945619), uint64(1747983448), float64(2), float64(4), float64(2), float64(4), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "Equals to 'sports' inside array of awards",
requestType: qbtypes.RequestTypeRaw,
@@ -389,7 +432,7 @@ func TestStatementBuilderListQueryBody(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))')) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))')) OR arrayExists(toFloat64OrNull(x) -> toFloat64OrNull(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))')) OR arrayExists(x -> toFloat64OrNull(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%4%", float64(4), "%4%", float64(4), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `interests[].entities[].reviews[].entries[].metadata[].positions[].ratings` is ambiguous, found 2 different combinations of field context / data type: [name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]int64,jsondatatype=Array(Nullable(Int64)) name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]string,jsondatatype=Array(Nullable(String))]."},
},
@@ -404,7 +447,7 @@ func TestStatementBuilderListQueryBody(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))')) OR arrayExists(toString(x) -> toString(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))')) OR arrayExists(x -> toString(x) = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(Int64))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))'))) OR (arrayExists(`body_json.interests`-> arrayExists(`body_json.interests[].entities`-> arrayExists(`body_json.interests[].entities[].reviews`-> arrayExists(`body_json.interests[].entities[].reviews[].entries`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata`-> arrayExists(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata[].positions`.`ratings`, 'Array(Nullable(String))'))), dynamicElement(`body_json.interests[].entities[].reviews[].entries[].metadata`.`positions`, 'Array(JSON(max_dynamic_types=0, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews[].entries`.`metadata`, 'Array(JSON(max_dynamic_types=1, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities[].reviews`.`entries`, 'Array(JSON(max_dynamic_types=2, max_dynamic_paths=0))')), dynamicElement(`body_json.interests[].entities`.`reviews`, 'Array(JSON(max_dynamic_types=4, max_dynamic_paths=0))')), dynamicElement(`body_json.interests`.`entities`, 'Array(JSON(max_dynamic_types=8, max_dynamic_paths=0))')), dynamicElement(body_json.`interests`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%Good%", "Good", "%Good%", "Good", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `interests[].entities[].reviews[].entries[].metadata[].positions[].ratings` is ambiguous, found 2 different combinations of field context / data type: [name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]int64,jsondatatype=Array(Nullable(Int64)) name=interests[].entities[].reviews[].entries[].metadata[].positions[].ratings,context=body,datatype=[]string,jsondatatype=Array(Nullable(String))]."},
},
@@ -492,7 +535,7 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
disableBodyJSONQuery(t)
}()
statementBuilder := buildJSONTestStatementBuilder(t, "education")
statementBuilder := buildJSONTestStatementBuilder(t, "education", "tags")
cases := []struct {
name string
requestType qbtypes.RequestType
@@ -500,6 +543,20 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
expected qbtypes.Statement
expectedErr error
}{
{
name: "Has Array promoted uses body fallback",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "has(body.tags, 'production')"},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND has(if(length(dynamicElement(body_json_promoted.`tags`, 'Array(Nullable(String))')) > 0, dynamicElement(body_json_promoted.`tags`, 'Array(Nullable(String))'), dynamicElement(body_json.`tags`, 'Array(Nullable(String))')), ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "production", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "Key inside Array(JSON) exists",
requestType: qbtypes.RequestTypeRaw,
@@ -551,7 +608,7 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(toFloat64(x) -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toFloat64(x) = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> toFloat64(x) = ?, arrayMap(x->dynamicElement(x, 'Float64'), arrayFilter(x->(dynamicType(x) = 'Float64'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%1.65%", 1.65, "%1.65%", 1.65, "%1.65%", 1.65, "%1.65%", 1.65, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
},
@@ -580,7 +637,7 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> x = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'Bool'), arrayFilter(x->(dynamicType(x) = 'Bool'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%true%", true, "%true%", true, "%true%", true, "%true%", true, "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
},
@@ -595,7 +652,7 @@ func TestStatementBuilderListQueryBodyPromoted(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toString(x) -> toString(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(toString(x) -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(toString(x) -> toString(x) = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, body_json, body_json_promoted, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toString(x) = ?, dynamicElement(`body_json.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(toString(x)) LIKE LOWER(?), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))')) OR arrayExists(x -> toString(x) = ?, dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Nullable(Float64))'))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))'))) OR (arrayExists(`body_json.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=0))')) OR arrayExists(`body_json_promoted.education`-> (arrayExists(x -> LOWER(x) LIKE LOWER(?), arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)')))) OR arrayExists(x -> x = ?, arrayMap(x->dynamicElement(x, 'String'), arrayFilter(x->(dynamicType(x) = 'String'), dynamicElement(`body_json_promoted.education`.`parameters`, 'Array(Dynamic)'))))), dynamicElement(body_json_promoted.`education`, 'Array(JSON(max_dynamic_types=16, max_dynamic_paths=256))')))) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "%passed%", "passed", "%passed%", "passed", "%passed%", "passed", "%passed%", "passed", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
Warnings: []string{"Key `education[].parameters` is ambiguous, found 2 different combinations of field context / data type: [name=education[].parameters,context=body,datatype=[]float64,materialized=true,jsondatatype=Array(Nullable(Float64)) name=education[].parameters,context=body,datatype=[]dynamic,materialized=true,jsondatatype=Array(Dynamic)]."},
},

View File

@@ -3,12 +3,12 @@ package telemetrylogs
import (
"context"
"fmt"
"reflect"
"strconv"
"strings"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func parseStrValue(valueStr string, operator qbtypes.FilterOperator) (telemetrytypes.FieldDataType, any) {
@@ -41,31 +41,55 @@ func parseStrValue(valueStr string, operator qbtypes.FilterOperator) (telemetryt
}
func InferDataType(value any, operator qbtypes.FilterOperator, key *telemetrytypes.TelemetryFieldKey) (telemetrytypes.FieldDataType, any) {
// check if the value is a int, float, string, bool
valueType := telemetrytypes.FieldDataTypeUnspecified
switch v := value.(type) {
case []any:
// take the first element and infer the type
if len(v) > 0 {
valueType, _ = InferDataType(v[0], operator, key)
if operator.IsArrayOperator() && reflect.ValueOf(value).Kind() != reflect.Slice {
value = []any{value}
}
// closure to calculate the data type of the value
var closure func(value any, key *telemetrytypes.TelemetryFieldKey) (telemetrytypes.FieldDataType, any)
closure = func(value any, key *telemetrytypes.TelemetryFieldKey) (telemetrytypes.FieldDataType, any) {
// check if the value is a int, float, string, bool
valueType := telemetrytypes.FieldDataTypeUnspecified
switch v := value.(type) {
case []any:
// take the first element and infer the type
var scalerType telemetrytypes.FieldDataType
if len(v) > 0 {
// Note: [[...]] Slices inside Slices are not handled yet
if reflect.ValueOf(v[0]).Kind() == reflect.Slice {
return telemetrytypes.FieldDataTypeUnspecified, value
}
scalerType, _ = closure(v[0], key)
}
arrayType := telemetrytypes.ScalerFieldTypeToArrayFieldType[scalerType]
switch {
// decide on the field data type based on the key
case key.FieldDataType.IsArray():
return arrayType, v
default:
// TODO(Piyush): backward compatibility for the old String based JSON QB queries
if strings.HasSuffix(key.Name, telemetrytypes.ArrayAnyIndexSuffix) {
return arrayType, v
}
return scalerType, v
}
case uint8, uint16, uint32, uint64, int, int8, int16, int32, int64:
valueType = telemetrytypes.FieldDataTypeInt64
case float32, float64:
valueType = telemetrytypes.FieldDataTypeFloat64
case string:
valueType, value = parseStrValue(v, operator)
case bool:
valueType = telemetrytypes.FieldDataTypeBool
}
return valueType, v
case uint8, uint16, uint32, uint64, int, int8, int16, int32, int64:
valueType = telemetrytypes.FieldDataTypeInt64
case float32, float64:
valueType = telemetrytypes.FieldDataTypeFloat64
case string:
valueType, value = parseStrValue(v, operator)
case bool:
valueType = telemetrytypes.FieldDataTypeBool
return valueType, value
}
// check if it is array
if strings.HasSuffix(key.Name, "[*]") || strings.HasSuffix(key.Name, "[]") {
valueType = telemetrytypes.FieldDataType{String: valuer.NewString(fmt.Sprintf("[]%s", valueType.StringValue()))}
}
return valueType, value
// calculate the data type of the value
return closure(value, key)
}
func getBodyJSONPath(key *telemetrytypes.TelemetryFieldKey) string {

View File

@@ -421,6 +421,38 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
},
expectedErr: nil,
},
{
name: "IN operator with json search",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{
Expression: "body.user_names[*] IN 'john_doe'",
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND ((JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(String)') = ?) AND JSON_EXISTS(body, '$.\"user_names\"[*]')) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "has with json search",
requestType: qbtypes.RequestTypeRaw,
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{
Expression: "has(body.user_names[*], 'john_doe')",
},
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE true AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND has(JSONExtract(JSON_QUERY(body, '$.\"user_names\"[*]'), 'Array(String)'), ?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{uint64(1747945619), uint64(1747983448), "john_doe", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
},
expectedErr: nil,
},
}
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()

View File

@@ -112,7 +112,8 @@ func (t *telemetryMetaStore) buildBodyJSONPaths(ctx context.Context,
}
for _, fieldKey := range fieldKeys {
fieldKey.Materialized = promoted.Contains(fieldKey.Name)
promotedKey := strings.Split(fieldKey.Name, telemetrytypes.ArraySep)[0]
fieldKey.Materialized = promoted.Contains(promotedKey)
fieldKey.Indexes = indexes[fieldKey.Name]
}
@@ -501,7 +502,8 @@ func (t *telemetryMetaStore) GetPromotedPaths(ctx context.Context, paths ...stri
sb := sqlbuilder.Select("path").From(fmt.Sprintf("%s.%s", DBName, PromotedPathsTableName))
pathConditions := []string{}
for _, path := range paths {
pathConditions = append(pathConditions, sb.Equal("path", path))
split := strings.Split(path, telemetrytypes.ArraySep)
pathConditions = append(pathConditions, sb.Equal("path", split[0]))
}
sb.Where(sb.Or(pathConditions...))

View File

@@ -260,11 +260,11 @@ type MetricHighlightsResponse struct {
ActiveTimeSeries uint64 `json:"activeTimeSeries" required:"true"`
}
// MetricAttributesRequest represents the payload for the metric attributes endpoint.
// MetricAttributesRequest represents the query parameters for the metric attributes endpoint.
type MetricAttributesRequest struct {
MetricName string `json:"metricName" required:"true"`
Start *int64 `json:"start,omitempty"`
End *int64 `json:"end,omitempty"`
MetricName string `json:"-"`
Start *int64 `query:"start"`
End *int64 `query:"end"`
}
// Validate ensures MetricAttributesRequest contains acceptable values.
@@ -273,10 +273,6 @@ func (req *MetricAttributesRequest) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if req.MetricName == "" {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric_name is required")
}
if req.Start != nil && req.End != nil {
if *req.Start >= *req.End {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start (%d) must be less than end (%d)", *req.Start, *req.End)
@@ -286,17 +282,6 @@ func (req *MetricAttributesRequest) Validate() error {
return nil
}
// UnmarshalJSON validates input immediately after decoding.
func (req *MetricAttributesRequest) UnmarshalJSON(data []byte) error {
type raw MetricAttributesRequest
var decoded raw
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*req = MetricAttributesRequest(decoded)
return req.Validate()
}
// MetricAttribute represents a single attribute with its values and count.
type MetricAttribute struct {
Key string `json:"key" required:"true"`
@@ -310,6 +295,48 @@ type MetricAttributesResponse struct {
TotalKeys int64 `json:"totalKeys" required:"true"`
}
type MetricNameParams struct {
MetricName string `query:"metricName" required:"true"`
// ListMetricsParams represents the query parameters for the list metrics endpoint.
type ListMetricsParams struct {
Start *int64 `query:"start"`
End *int64 `query:"end"`
Limit int `query:"limit"`
Search string `query:"searchText"`
}
// Validate ensures ListMetricsParams contains acceptable values.
func (p *ListMetricsParams) Validate() error {
if p.Start != nil && *p.Start <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start must be greater than 0")
}
if p.End != nil && *p.End <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "end must be greater than 0")
}
if p.Start != nil && p.End != nil && *p.Start >= *p.End {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "start (%d) must be less than end (%d)", *p.Start, *p.End)
}
if p.Limit < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit cannot be negative")
}
if p.Limit == 0 {
p.Limit = 100
}
if p.Limit > 5000 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must not exceed 5000")
}
return nil
}
// ListMetric represents a single metric with its metadata in the list metrics response.
type ListMetric struct {
MetricName string `json:"metricName" required:"true"`
Description string `json:"description" required:"true"`
MetricType metrictypes.Type `json:"type" required:"true"`
MetricUnit string `json:"unit" required:"true"`
Temporality metrictypes.Temporality `json:"temporality" required:"true"`
IsMonotonic bool `json:"isMonotonic" required:"true"`
}
// ListMetricsResponse represents the response for the list metrics endpoint.
type ListMetricsResponse struct {
Metrics []ListMetric `json:"metrics" required:"true" nullable:"true"`
}

View File

@@ -172,7 +172,6 @@ var (
func (TimeAggregation) Enum() []any {
return []any{
TimeAggregationUnspecified,
TimeAggregationLatest,
TimeAggregationSum,
TimeAggregationAvg,
@@ -205,7 +204,6 @@ var (
func (SpaceAggregation) Enum() []any {
return []any{
SpaceAggregationUnspecified,
SpaceAggregationSum,
SpaceAggregationAvg,
SpaceAggregationMin,

View File

@@ -10,10 +10,35 @@ import (
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
)
type Step struct{ time.Duration }
var _ jsonschema.Exposer = Step{}
// JSONSchema returns a custom schema for Step that accepts either a duration string or a number (seconds).
func (Step) JSONSchema() (jsonschema.Schema, error) {
s := jsonschema.Schema{}
s.WithDescription("Step interval. Accepts a Go duration string (e.g., \"60s\", \"1m\", \"1h\") or a number representing seconds (e.g., 60).")
strSchema := jsonschema.Schema{}
strSchema.WithType(jsonschema.String.Type())
strSchema.WithExamples("60s", "5m", "1h")
strSchema.WithDescription("Duration string (e.g., \"60s\", \"5m\", \"1h\").")
numSchema := jsonschema.Schema{}
numSchema.WithType(jsonschema.Number.Type())
numSchema.WithExamples(60, 300, 3600)
numSchema.WithDescription("Duration in seconds.")
s.OneOf = []jsonschema.SchemaOrBool{
strSchema.ToSchemaOrBool(),
numSchema.ToSchemaOrBool(),
}
return s, nil
}
func (s *Step) UnmarshalJSON(b []byte) error {
if len(b) == 0 {
return nil
@@ -161,6 +186,17 @@ func (f FilterOperator) IsStringSearchOperator() bool {
}
}
// IsArrayOperator returns true if the operator works with array values only
func (f FilterOperator) IsArrayOperator() bool {
switch f {
case FilterOperatorIn, FilterOperatorNotIn,
FilterOperatorBetween, FilterOperatorNotBetween:
return true
default:
return false
}
}
type OrderDirection struct {
valuer.String
}
@@ -170,6 +206,14 @@ var (
OrderDirectionDesc = OrderDirection{valuer.NewString("desc")}
)
// Enum returns the acceptable values for OrderDirection.
func (OrderDirection) Enum() []any {
return []any{
OrderDirectionAsc,
OrderDirectionDesc,
}
}
var (
OrderDirectionMap = map[string]OrderDirection{
"asc": OrderDirectionAsc,
@@ -192,6 +236,19 @@ var (
ReduceToMedian = ReduceTo{valuer.NewString("median")}
)
// Enum returns the acceptable values for ReduceTo.
func (ReduceTo) Enum() []any {
return []any{
ReduceToSum,
ReduceToCount,
ReduceToAvg,
ReduceToMin,
ReduceToMax,
ReduceToLast,
ReduceToMedian,
}
}
// FunctionReduceTo applies the reduceTo operator to a time series and returns a new series with the reduced value
// reduceTo can be one of: last, sum, avg, min, max, count, median
// if reduceTo is not recognized, the function returns the original series

View File

@@ -36,6 +36,30 @@ var (
FunctionNameFillZero = FunctionName{valuer.NewString("fillZero")}
)
// Enum returns the acceptable values for FunctionName.
func (FunctionName) Enum() []any {
return []any{
FunctionNameCutOffMin,
FunctionNameCutOffMax,
FunctionNameClampMin,
FunctionNameClampMax,
FunctionNameAbsolute,
FunctionNameRunningDiff,
FunctionNameLog2,
FunctionNameLog10,
FunctionNameCumulativeSum,
FunctionNameEWMA3,
FunctionNameEWMA5,
FunctionNameEWMA7,
FunctionNameMedian3,
FunctionNameMedian5,
FunctionNameMedian7,
FunctionNameTimeShift,
FunctionNameAnomaly,
FunctionNameFillZero,
}
}
// Validate checks if the FunctionName is valid and one of the known types
func (fn FunctionName) Validate() error {
validFunctions := []FunctionName{

View File

@@ -16,6 +16,17 @@ var (
JoinTypeCross = JoinType{valuer.NewString("cross")}
)
// Enum returns the acceptable values for JoinType.
func (JoinType) Enum() []any {
return []any{
JoinTypeInner,
JoinTypeLeft,
JoinTypeRight,
JoinTypeFull,
JoinTypeCross,
}
}
type QueryRef struct {
Name string `json:"name"`
}

View File

@@ -2,6 +2,8 @@ package querybuildertypesv5
import (
"context"
"github.com/swaggest/jsonschema-go"
)
type Query interface {
@@ -29,4 +31,12 @@ type ExecStats struct {
StepIntervals map[string]uint64 `json:"stepIntervals,omitempty"`
}
var _ jsonschema.Preparer = &ExecStats{}
// PrepareJSONSchema adds description to the ExecStats schema.
func (e *ExecStats) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Execution statistics for the query, including rows scanned, bytes scanned, and duration.")
return nil
}
type TimeRange struct{ From, To uint64 } // ms since epoch

View File

@@ -16,3 +16,17 @@ var (
QueryTypeClickHouseSQL = QueryType{valuer.NewString("clickhouse_sql")}
QueryTypePromQL = QueryType{valuer.NewString("promql")}
)
// Enum returns the acceptable values for QueryType.
func (QueryType) Enum() []any {
return []any{
QueryTypeBuilder,
QueryTypeFormula,
// Not yet supported.
// QueryTypeSubQuery,
// QueryTypeJoin,
QueryTypeTraceOperator,
QueryTypeClickHouseSQL,
QueryTypePromQL,
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
)
type QueryEnvelope struct {
@@ -18,6 +19,71 @@ type QueryEnvelope struct {
Spec any `json:"spec"`
}
// queryEnvelopeBuilderTrace is the OpenAPI schema for a QueryEnvelope with type=builder_query and signal=traces.
type queryEnvelopeBuilderTrace struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec QueryBuilderQuery[TraceAggregation] `json:"spec" description:"The trace builder query specification."`
}
// queryEnvelopeBuilderLog is the OpenAPI schema for a QueryEnvelope with type=builder_query and signal=logs.
type queryEnvelopeBuilderLog struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec QueryBuilderQuery[LogAggregation] `json:"spec" description:"The log builder query specification."`
}
// queryEnvelopeBuilderMetric is the OpenAPI schema for a QueryEnvelope with type=builder_query and signal=metrics.
type queryEnvelopeBuilderMetric struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec QueryBuilderQuery[MetricAggregation] `json:"spec" description:"The metric builder query specification."`
}
// queryEnvelopeFormula is the OpenAPI schema for a QueryEnvelope with type=builder_formula.
type queryEnvelopeFormula struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec QueryBuilderFormula `json:"spec" description:"The formula specification."`
}
// queryEnvelopeJoin is the OpenAPI schema for a QueryEnvelope with type=builder_join.
// type queryEnvelopeJoin struct {
// Type QueryType `json:"type" description:"The type of the query."`
// Spec QueryBuilderJoin `json:"spec" description:"The join specification."`
// }
// queryEnvelopeTraceOperator is the OpenAPI schema for a QueryEnvelope with type=builder_trace_operator.
type queryEnvelopeTraceOperator struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec QueryBuilderTraceOperator `json:"spec" description:"The trace operator specification."`
}
// queryEnvelopePromQL is the OpenAPI schema for a QueryEnvelope with type=promql.
type queryEnvelopePromQL struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec PromQuery `json:"spec" description:"The PromQL query specification."`
}
// queryEnvelopeClickHouseSQL is the OpenAPI schema for a QueryEnvelope with type=clickhouse_sql.
type queryEnvelopeClickHouseSQL struct {
Type QueryType `json:"type" description:"The type of the query."`
Spec ClickHouseQuery `json:"spec" description:"The ClickHouse SQL query specification."`
}
var _ jsonschema.OneOfExposer = QueryEnvelope{}
// JSONSchemaOneOf returns the oneOf variants for the QueryEnvelope discriminated union.
// Each variant represents a different query type with its corresponding spec schema.
func (QueryEnvelope) JSONSchemaOneOf() []any {
return []any{
queryEnvelopeBuilderTrace{},
queryEnvelopeBuilderLog{},
queryEnvelopeBuilderMetric{},
queryEnvelopeFormula{},
// queryEnvelopeJoin{},
queryEnvelopeTraceOperator{},
queryEnvelopePromQL{},
queryEnvelopeClickHouseSQL{},
}
}
// implement custom json unmarshaler for the QueryEnvelope
func (q *QueryEnvelope) UnmarshalJSON(data []byte) error {
var shadow struct {
@@ -130,6 +196,12 @@ type CompositeQuery struct {
Queries []QueryEnvelope `json:"queries"`
}
// PrepareJSONSchema adds description to the CompositeQuery schema.
func (c *CompositeQuery) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.")
return nil
}
// UnmarshalJSON implements custom JSON unmarshaling to provide better error messages
func (c *CompositeQuery) UnmarshalJSON(data []byte) error {
type Alias CompositeQuery
@@ -192,6 +264,16 @@ var (
TextBoxVariableType = VariableType{valuer.NewString("text")}
)
// Enum returns the acceptable values for VariableType.
func (VariableType) Enum() []any {
return []any{
QueryVariableType,
DynamicVariableType,
CustomVariableType,
TextBoxVariableType,
}
}
type VariableItem struct {
Type VariableType `json:"type"`
Value any `json:"value"`
@@ -217,6 +299,12 @@ type QueryRangeRequest struct {
FormatOptions *FormatOptions `json:"formatOptions,omitempty"`
}
// PrepareJSONSchema adds description to the QueryRangeRequest schema.
func (q *QueryRangeRequest) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Request body for the v5 query range endpoint. Supports builder queries (traces, logs, metrics), formulas, joins, trace operators, PromQL, and ClickHouse SQL queries.")
return nil
}
func (r *QueryRangeRequest) StepIntervalForQuery(name string) int64 {
stepsMap := make(map[string]int64)
for _, query := range r.CompositeQuery.Queries {

View File

@@ -30,3 +30,15 @@ var (
func (r RequestType) IsAggregation() bool {
return r == RequestTypeTimeSeries || r == RequestTypeScalar || r == RequestTypeDistribution
}
// Enum implements jsonschema.Enum; returns the acceptable values for RequestType.
func (RequestType) Enum() []any {
return []any{
RequestTypeScalar,
RequestTypeTimeSeries,
RequestTypeRaw,
RequestTypeRawStream,
RequestTypeTrace,
// RequestTypeDistribution,
}
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
)
type QBEvent struct {
@@ -42,6 +43,17 @@ type QueryData struct {
Results []any `json:"results"`
}
var _ jsonschema.OneOfExposer = QueryData{}
// JSONSchemaOneOf documents the polymorphic result types in QueryData.Results.
func (QueryData) JSONSchemaOneOf() []any {
return []any{
TimeSeriesData{},
ScalarData{},
RawData{},
}
}
type QueryRangeResponse struct {
Type RequestType `json:"type"`
Data QueryData `json:"data"`
@@ -52,6 +64,14 @@ type QueryRangeResponse struct {
QBEvent *QBEvent `json:"-"`
}
var _ jsonschema.Preparer = &QueryRangeResponse{}
// PrepareJSONSchema adds description to the QueryRangeResponse schema.
func (q *QueryRangeResponse) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Response from the v5 query range endpoint. The data.results array contains typed results depending on the requestType: TimeSeriesData for time_series, ScalarData for scalar, or RawData for raw requests.")
return nil
}
type TimeSeriesData struct {
QueryName string `json:"queryName"`
Aggregations []*AggregationBucket `json:"aggregations"`
@@ -159,6 +179,14 @@ var (
ColumnTypeAggregation = ColumnType{valuer.NewString("aggregation")}
)
// Enum returns the acceptable values for ColumnType.
func (ColumnType) Enum() []any {
return []any{
ColumnTypeGroup,
ColumnTypeAggregation,
}
}
type ColumnDescriptor struct {
telemetrytypes.TelemetryFieldKey
QueryName string `json:"queryName"`

View File

@@ -23,8 +23,10 @@ const (
// e.g., "body.status" where "body." is the prefix
BodyJSONStringSearchPrefix = "body."
ArraySep = jsontypeexporter.ArraySeparator
ArraySepSuffix = "[]"
// TODO(Piyush): Remove once we've migrated to the new array syntax
ArrayAnyIndex = "[*]."
ArrayAnyIndex = "[*]."
ArrayAnyIndexSuffix = "[*]"
)
type TelemetryFieldKey struct {

View File

@@ -172,3 +172,18 @@ func isContextValidForSignal(ctx FieldContext, signal Signal) bool {
}
return true
}
// Enum returns the acceptable values for FieldContext.
func (FieldContext) Enum() []any {
return []any{
FieldContextMetric,
FieldContextLog,
FieldContextSpan,
// FieldContextTrace,
FieldContextResource,
// FieldContextScope,
FieldContextAttribute,
// FieldContextEvent,
FieldContextBody,
}
}

View File

@@ -93,6 +93,14 @@ var (
FieldDataTypeArrayFloat64: "Array(Float64)",
FieldDataTypeArrayBool: "Array(Bool)",
}
ScalerFieldTypeToArrayFieldType = map[FieldDataType]FieldDataType{
FieldDataTypeString: FieldDataTypeArrayString,
FieldDataTypeBool: FieldDataTypeArrayBool,
FieldDataTypeNumber: FieldDataTypeArrayNumber,
FieldDataTypeInt64: FieldDataTypeArrayInt64,
FieldDataTypeFloat64: FieldDataTypeArrayFloat64,
}
)
func (f FieldDataType) CHDataType() string {
@@ -169,3 +177,19 @@ func (f FieldDataType) TagDataType() string {
return "string"
}
}
// Enum returns the acceptable values for FieldDataType.
func (FieldDataType) Enum() []any {
return []any{
FieldDataTypeString,
FieldDataTypeBool,
FieldDataTypeFloat64,
FieldDataTypeInt64,
FieldDataTypeNumber,
// FieldDataTypeArrayString,
// FieldDataTypeArrayFloat64,
// FieldDataTypeArrayBool,
// FieldDataTypeArrayInt64,
// FieldDataTypeArrayNumber,
}
}

View File

@@ -12,3 +12,12 @@ var (
SignalMetrics = Signal{valuer.NewString("metrics")}
SignalUnspecified = Signal{valuer.NewString("")}
)
// Enum returns the acceptable values for Signal.
func (Signal) Enum() []any {
return []any{
SignalTraces,
SignalLogs,
SignalMetrics,
}
}

View File

@@ -10,3 +10,10 @@ var (
SourceMeter = Source{valuer.NewString("meter")}
SourceUnspecified = Source{valuer.NewString("")}
)
// Enum returns the acceptable values for Source.
func (Source) Enum() []any {
return []any{
SourceMeter,
}
}

View File

@@ -65,6 +65,7 @@ func TestJSONTypeSet() (map[string][]JSONDataType, MetadataStore) {
"interests[].entities[].reviews[].entries[].metadata[].positions[].unit": {String},
"interests[].entities[].reviews[].entries[].metadata[].positions[].ratings": {ArrayInt64, ArrayString},
"message": {String},
"tags": {ArrayString},
}
return types, nil

View File

@@ -0,0 +1,62 @@
from http import HTTPStatus
from typing import Callable
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import SigNoz
DUPLICATE_USER_EMAIL = "duplicate@integration.test"
def test_duplicate_user_invite_rejected(
signoz: SigNoz,
get_token: Callable[[str, str], str],
):
"""
Verify that the unique index on (email, org_id) in the users table prevents
creating duplicate users. This invites a new user, accepts the invite, then
tries to invite and accept the same email again expecting a failure.
"""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Step 1: Invite a new user.
initial_invite_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "EDITOR"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert initial_invite_response.status_code == HTTPStatus.CREATED
initial_invite_token = initial_invite_response.json()["data"]["token"]
# Step 2: Accept the invite to create the user.
initial_accept_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={"token": initial_invite_token, "password": "password123Z$"},
timeout=2,
)
assert initial_accept_response.status_code == HTTPStatus.CREATED
# Step 3: Invite the same email again.
duplicate_invite_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite"),
json={"email": DUPLICATE_USER_EMAIL, "role": "VIEWER"},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
# The invite creation itself may be rejected if the app checks for existing users.
if duplicate_invite_response.status_code != HTTPStatus.CREATED:
assert duplicate_invite_response.status_code == HTTPStatus.CONFLICT
return
duplicate_invite_token = duplicate_invite_response.json()["data"]["token"]
# Step 4: Accept the duplicate invite — should fail due to unique constraint.
duplicate_accept_response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/invite/accept"),
json={"token": duplicate_invite_token, "password": "password123Z$"},
timeout=2,
)
assert duplicate_accept_response.status_code == HTTPStatus.CONFLICT