Compare commits

..

1 Commits

Author SHA1 Message Date
Ashwin Bhatkal
32a7342d52 chore(ci): unset a tenant's staging image via a staging:<tenant>:unset label 2026-10-09 14:48:38 +05:30
88 changed files with 720 additions and 5408 deletions

View File

@@ -25,6 +25,7 @@ jobs:
time: ${{ steps.build-info.outputs.time }}
branch: ${{ steps.build-info.outputs.branch }}
deployment: ${{ steps.build-info.outputs.deployment }}
unset: ${{ steps.build-info.outputs.unset }}
steps:
- name: self-checkout
uses: actions/checkout@v4
@@ -59,7 +60,14 @@ jobs:
echo "error: not able to determine deployment - please verify the PR label or the branch"
exit 1
fi
unset=false
if [[ "${deployment}" == *":unset" ]]; then
deployment=${deployment%":unset"}
unset=true
fi
echo "deployment=${deployment}" >> $GITHUB_OUTPUT
echo "unset=${unset}" >> $GITHUB_OUTPUT
- name: create-dotenv
run: |
mkdir -p frontend
@@ -76,6 +84,7 @@ jobs:
path: frontend/.env
key: staging-dotenv-${{ github.sha }}
js-build:
if: ${{ needs.prepare.outputs.unset != 'true' }}
uses: signoz/primus.workflows/.github/workflows/js-build.yaml@main
needs: prepare
secrets: inherit
@@ -126,3 +135,15 @@ jobs:
GITHUB_REPOSITORY_NAME: charts-saas-v3-staging
GITHUB_EVENT_NAME: releaser
GITHUB_EVENT_PAYLOAD: '{"deployment": "${{ needs.prepare.outputs.deployment }}", "signoz_version": "${{ needs.prepare.outputs.version }}"}'
staging-unset:
if: ${{ needs.prepare.outputs.unset == 'true' }}
uses: signoz/primus.workflows/.github/workflows/github-trigger.yaml@main
secrets: inherit
needs: prepare
with:
PRIMUS_REF: main
GITHUB_ENVIRONMENT: staging
GITHUB_SILENT: true
GITHUB_REPOSITORY_NAME: charts-saas-v3-staging
GITHUB_EVENT_NAME: releaser
GITHUB_EVENT_PAYLOAD: '{"deployment": "${{ needs.prepare.outputs.deployment }}", "signoz_version": "unset"}'

View File

@@ -7756,8 +7756,6 @@ components:
type: object
PromotetypesPromotePath:
properties:
context:
type: string
indexes:
items:
$ref: '#/components/schemas/PromotetypesWrappedIndex'
@@ -7766,12 +7764,6 @@ components:
type: string
promote:
type: boolean
signal:
type: string
required:
- signal
- context
- path
type: object
PromotetypesWrappedIndex:
properties:
@@ -13440,6 +13432,110 @@ paths:
tags:
- llmpricingrules
x-signoz-stability: alpha
/api/v1/logs/promote_paths:
get:
deprecated: false
description: This endpoints promotes and indexes paths
operationId: ListPromotedAndIndexedPaths
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Promote and index paths
tags:
- logs
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoints promotes and indexes paths
operationId: HandlePromoteAndIndexPaths
requestBody:
content:
application/json:
schema:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
responses:
"201":
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Promote and index paths
tags:
- logs
x-signoz-stability: alpha
/api/v1/org/preferences:
get:
deprecated: false
@@ -13611,152 +13707,6 @@ paths:
tags:
- preferences
x-signoz-stability: alpha
/api/v1/promoted_paths:
get:
deprecated: false
description: This endpoint lists the promoted paths of every JSON column, each
annotated with its signal and context. The signal, context, promoted and indexes
query parameters filter the listing.
operationId: ListPromotedPaths
parameters:
- in: query
name: signal
schema:
type: string
- in: query
name: context
schema:
type: string
- in: query
name: promoted
schema:
nullable: true
type: boolean
- in: query
name: indexes
schema:
nullable: true
type: boolean
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List promoted paths
tags:
- promote
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoint promotes paths of JSON columns to their promoted
columns. Each path names its promotion target with its signal and context,
e.g. traces/attribute.
operationId: PromotePaths
requestBody:
content:
application/json:
examples:
logs_body:
summary: 'Logs body: promote a path and index it'
value:
- context: body
indexes:
- fieldDataType: string
granularity: 1
type: ngrambf_v1(4, 1024, 2, 0)
path: user.name
promote: true
signal: logs
traces_attribute:
summary: 'Traces attribute: promote a path'
value:
- context: attribute
path: http.method
promote: true
signal: traces
schema:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
responses:
"201":
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Promote paths
tags:
- promote
x-signoz-stability: alpha
/api/v1/roles:
get:
deprecated: false

View File

@@ -4,15 +4,23 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation } from 'react-query';
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
HandleExportRawDataPOSTParams,
ListPromotedAndIndexedPaths200,
PromotetypesPromotePathDTO,
Querybuildertypesv5QueryRangeRequestDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -20,6 +28,26 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoints allows complex query exporting raw data for traces and logs
* @summary Export raw data
@@ -121,3 +149,175 @@ export const useHandleExportRawDataPOST = <
> => {
return useMutation(getHandleExportRawDataPOSTMutationOptions(options));
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths
*/
export const listPromotedAndIndexedPaths = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListPromotedAndIndexedPaths200>({
url: `/api/v1/logs/promote_paths`,
method: 'GET',
signal,
});
};
export const getListPromotedAndIndexedPathsQueryKey = () => {
return [`/api/v1/logs/promote_paths`] as const;
};
export const getListPromotedAndIndexedPathsQueryOptions = <
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListPromotedAndIndexedPathsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
> = ({ signal }) => listPromotedAndIndexedPaths(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListPromotedAndIndexedPathsQueryResult = NonNullable<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
>;
export type ListPromotedAndIndexedPathsQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote and index paths
*/
export function useListPromotedAndIndexedPaths<
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListPromotedAndIndexedPathsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Promote and index paths
*/
export const invalidateListPromotedAndIndexedPaths = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListPromotedAndIndexedPathsQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths
*/
export const handlePromoteAndIndexPaths = (
promotetypesPromotePathDTONull?: BodyType<
PromotetypesPromotePathDTO[] | null
> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/logs/promote_paths`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPromotePathDTONull,
signal,
});
};
export const getHandlePromoteAndIndexPathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
const mutationKey = ['handlePromoteAndIndexPaths'];
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 handlePromoteAndIndexPaths>>,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
> = (props) => {
const { data } = props ?? {};
return handlePromoteAndIndexPaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type HandlePromoteAndIndexPathsMutationResult = NonNullable<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>
>;
export type HandlePromoteAndIndexPathsMutationBody =
| BodyType<PromotetypesPromotePathDTO[] | null>
| undefined;
export type HandlePromoteAndIndexPathsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote and index paths
*/
export const useHandlePromoteAndIndexPaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
return useMutation(getHandlePromoteAndIndexPathsMutationOptions(options));
};

View File

@@ -1,232 +0,0 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
ListPromotedPaths200,
ListPromotedPathsParams,
PromotetypesPromotePathDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint lists the promoted paths of every JSON column, each annotated with its signal and context. The signal, context, promoted and indexes query parameters filter the listing.
* @summary List promoted paths
*/
export const listPromotedPaths = (
params?: ListPromotedPathsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListPromotedPaths200>({
url: `/api/v1/promoted_paths`,
method: 'GET',
params,
signal,
});
};
export const getListPromotedPathsQueryKey = (
params?: ListPromotedPathsParams,
) => {
return [`/api/v1/promoted_paths`, ...(params ? [params] : [])] as const;
};
export const getListPromotedPathsQueryOptions = <
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListPromotedPathsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListPromotedPathsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listPromotedPaths>>
> = ({ signal }) => listPromotedPaths(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListPromotedPathsQueryResult = NonNullable<
Awaited<ReturnType<typeof listPromotedPaths>>
>;
export type ListPromotedPathsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List promoted paths
*/
export function useListPromotedPaths<
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListPromotedPathsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListPromotedPathsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary List promoted paths
*/
export const invalidateListPromotedPaths = async (
queryClient: QueryClient,
params?: ListPromotedPathsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListPromotedPathsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion target with its signal and context, e.g. traces/attribute.
* @summary Promote paths
*/
export const promotePaths = (
promotetypesPromotePathDTONull?: BodyType<
PromotetypesPromotePathDTO[] | null
> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/promoted_paths`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPromotePathDTONull,
signal,
});
};
export const getPromotePathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
const mutationKey = ['promotePaths'];
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 promotePaths>>,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
> = (props) => {
const { data } = props ?? {};
return promotePaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type PromotePathsMutationResult = NonNullable<
Awaited<ReturnType<typeof promotePaths>>
>;
export type PromotePathsMutationBody =
| BodyType<PromotetypesPromotePathDTO[] | null>
| undefined;
export type PromotePathsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote paths
*/
export const usePromotePaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
return useMutation(getPromotePathsMutationOptions(options));
};

View File

@@ -9519,10 +9519,6 @@ export interface PromotetypesWrappedIndexDTO {
}
export interface PromotetypesPromotePathDTO {
/**
* @type string
*/
context: string;
/**
* @type array
*/
@@ -9530,15 +9526,11 @@ export interface PromotetypesPromotePathDTO {
/**
* @type string
*/
path: string;
path?: string;
/**
* @type boolean
*/
promote?: boolean;
/**
* @type string
*/
signal: string;
}
export interface Querybuildertypesv5AggregationMetaDTO {
@@ -12842,6 +12834,17 @@ export type ListUnmappedLLMModels200 = {
status: string;
};
export type ListPromotedAndIndexedPaths200 = {
/**
* @type array,null
*/
data: PromotetypesPromotePathDTO[] | null;
/**
* @type string
*/
status: string;
};
export type ListOrgPreferences200 = {
/**
* @type array
@@ -12867,40 +12870,6 @@ export type GetOrgPreference200 = {
export type UpdateOrgPreferencePathParameters = {
name: string;
};
export type ListPromotedPathsParams = {
/**
* @type string
* @description undefined
*/
signal?: string;
/**
* @type string
* @description undefined
*/
context?: string;
/**
* @type boolean,null
* @description undefined
*/
promoted?: boolean | null;
/**
* @type boolean,null
* @description undefined
*/
indexes?: boolean | null;
};
export type ListPromotedPaths200 = {
/**
* @type array,null
*/
data: PromotetypesPromotePathDTO[] | null;
/**
* @type string
*/
status: string;
};
export type ListRoles200 = {
/**
* @type array

View File

@@ -36,7 +36,6 @@ import {
filterOptionsBySearch,
findOptionLabelText,
handleScrollToBottom,
findOptionLabel,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
} from './utils';
@@ -74,7 +73,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
onDropdownVisibleChange,
showIncompleteDataMessage = false,
showLabels = false,
preserveOptionOrder = false,
enableRegexOption = false,
isDynamicVariable = false,
showRetryButton = true,
@@ -313,7 +311,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
]);
} else {
setVisibleOptions(
selectedValues.length > 0 && isEmpty(searchText) && !preserveOptionOrder
selectedValues.length > 0 && isEmpty(searchText)
? prioritizeOrAddOptionForMultiSelect(filteredOptions, selectedValues)
: filteredOptions,
);
@@ -1477,7 +1475,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const shouldPrioritize =
selectedValues.length > 0 &&
isEmpty(searchText) &&
!preserveOptionOrder &&
!(hasSections && (allOptionShown || isAllSelected));
const processedOptions = shouldPrioritize
@@ -1808,7 +1805,6 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
isDynamicVariable,
showRetryButton,
waitingMessage,
preserveOptionOrder,
]);
// Custom handler for dropdown visibility changes
@@ -1896,7 +1892,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const { label: labelProp, value, closable, onClose } = props;
const label = showLabels
? findOptionLabel(options, value) || labelProp
? options.find((option) => option.value === value)?.label || labelProp
: labelProp;
// If the display value is the special ALL value, render the ALL tag

View File

@@ -1,70 +0,0 @@
import { findOptionLabel } from '../utils';
describe('findOptionLabel', () => {
const sectioned = [
{
label: 'Lifecycle',
options: [
{ label: 'Running', value: 'running' },
{ label: 'Pending', value: 'pending' },
],
},
{
label: 'Error status',
options: [{ label: 'CrashLoopBackOff', value: 'crashloopbackoff' }],
},
];
it('resolves a label nested inside a section', () => {
expect(findOptionLabel(sectioned, 'crashloopbackoff')).toBe(
'CrashLoopBackOff',
);
});
it('resolves a label on a flat option list', () => {
expect(findOptionLabel([{ label: 'Ready', value: 'ready' }], 'ready')).toBe(
'Ready',
);
});
it('returns undefined for a value no option declares', () => {
expect(findOptionLabel(sectioned, 'nope')).toBeUndefined();
});
});
describe('findOptionLabel for overflow tags', () => {
// The `+N` tooltip and the visible tag both resolve labels this way, so a
// sectioned list must not fall back to the raw enum value in either.
it('resolves every value in a sectioned list', () => {
const sections = [
{
label: 'Lifecycle',
options: [
{ label: 'Running', value: 'running' },
{ label: 'ContainerCreating', value: 'containercreating' },
],
},
{
label: 'Error status',
options: [
{ label: 'OOMKilled', value: 'oomkilled' },
{ label: 'NodeAffinity', value: 'nodeaffinity' },
],
},
];
const resolved = [
'running',
'containercreating',
'oomkilled',
'nodeaffinity',
].map((value) => findOptionLabel(sections, value) ?? value);
expect(resolved).toStrictEqual([
'Running',
'ContainerCreating',
'OOMKilled',
'NodeAffinity',
]);
});
});

View File

@@ -66,7 +66,6 @@ export interface CustomMultiSelectProps extends Omit<
maxTagTextLength?: number;
showIncompleteDataMessage?: boolean;
showLabels?: boolean;
preserveOptionOrder?: boolean;
enableRegexOption?: boolean;
isDynamicVariable?: boolean;
showRetryButton?: boolean;

View File

@@ -69,28 +69,6 @@ export const prioritizeOrAddOptionForSingleSelect = (
return [foundOption, ...filteredOptions];
};
/**
* Options may be grouped into sections, so resolving a value's label has to look
* one level down as well — a sectioned list has no label at the top level.
*/
export const findOptionLabel = (
options: OptionData[],
value: string,
): string | undefined => {
for (const option of options) {
if (option.value === value) {
return option.label;
}
const nested = option.options?.find((subOption) => subOption.value === value);
if (nested) {
return nested.label;
}
}
return undefined;
};
export const prioritizeOrAddOptionForMultiSelect = (
options: OptionData[],
values: string[], // Only supports multiple values (string[])

View File

@@ -1,7 +0,0 @@
.overflowValues {
max-width: 360px;
margin: 0;
padding-left: 14px;
list-style: disc outside;
overflow-wrap: anywhere;
}

View File

@@ -28,9 +28,6 @@ import {
useInfraMonitoringOrderBy,
useInfraMonitoringSelectedItemParams,
useInfraMonitoringStatusFilter,
useInfraMonitoringPodStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringContainerStatusFilter,
} from '../hooks';
import {
useInfraMonitoringFontSize,
@@ -100,10 +97,6 @@ export type K8sBaseListProps<
detailsQueryKeyPrefix: string;
};
function emptyToUndefined<T>(values: T[]): T[] | undefined {
return values.length > 0 ? values : undefined;
}
export function K8sBaseList<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
@@ -127,9 +120,6 @@ export function K8sBaseList<
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [statusFilter] = useInfraMonitoringStatusFilter();
const [podStatusFilter] = useInfraMonitoringPodStatusFilter();
const [nodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
const [containerStatusFilter] = useInfraMonitoringContainerStatusFilter();
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
@@ -181,9 +171,6 @@ export function K8sBaseList<
JSON.stringify(orderBy),
JSON.stringify(groupBy),
statusFilter,
podStatusFilter.join(),
nodeReadinessFilter.join(),
containerStatusFilter.join(),
...extraQueryKeyParts,
);
}, [
@@ -196,9 +183,6 @@ export function K8sBaseList<
orderBy,
groupBy,
statusFilter,
podStatusFilter,
nodeReadinessFilter,
containerStatusFilter,
extraQueryKeyParts,
]);
@@ -219,9 +203,6 @@ export function K8sBaseList<
statusFilter === 'active' || statusFilter === 'inactive'
? statusFilter
: undefined,
filterByPodStatus: emptyToUndefined(podStatusFilter),
filterByNodeReadiness: emptyToUndefined(nodeReadinessFilter),
filterByContainerStatus: emptyToUndefined(containerStatusFilter),
},
groupBy:
groupBy && groupBy.length > 0

View File

@@ -285,9 +285,6 @@ describe('K8sBaseList', () => {
expect(filters.filter).toStrictEqual({
expression: '',
filterByStatus: undefined,
filterByPodStatus: undefined,
filterByNodeReadiness: undefined,
filterByContainerStatus: undefined,
});
expect(filters.groupBy).toBeUndefined();
expect(filters.orderBy).toBeUndefined();
@@ -1671,61 +1668,3 @@ describe('K8sBaseList', () => {
});
});
});
describe('K8sBaseList status filters', () => {
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
function renderWithParams(queryParams: Record<string, string>): void {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({ data: [], total: 0, error: null });
renderComponent<TestItem>({
queryParams,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
}
it('sends the statuses named in the URL', async () => {
renderWithParams({ podStatus: 'running,crashloopbackoff' });
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toStrictEqual([
'running',
'crashloopbackoff',
]);
});
it('drops unknown statuses the API would reject', async () => {
renderWithParams({ podStatus: 'running,no_data,bogus' });
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toStrictEqual(['running']);
});
it('omits the filter entirely when no status is selected', async () => {
renderWithParams({});
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toBeUndefined();
});
});

View File

@@ -1,21 +0,0 @@
.statusFilterGroup {
display: flex;
align-items: center;
gap: 12px;
}
.statusFilter {
display: flex;
align-items: center;
gap: 8px;
}
.statusFilterLabel {
color: var(--muted-foreground);
font-size: var(--periscope-font-size-base);
white-space: nowrap;
}
.statusFilterControl {
min-width: 180px;
}

View File

@@ -1,121 +0,0 @@
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
FILTERABLE_CONTAINER_STATUSES,
FILTERABLE_NODE_CONDITIONS,
FILTERABLE_POD_STATUSES,
useInfraMonitoringCategory,
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringPodStatusFilter,
} from 'container/InfraMonitoringK8sV2/hooks';
import StatusMultiSelect from './StatusMultiSelect';
import { StatusFilterKind } from '../StatusFilterCells/useStatusFilter';
import {
CONTAINER_STATUS_FILTER_OPTIONS,
NODE_READINESS_FILTER_OPTIONS,
POD_STATUS_FILTER_OPTIONS,
} from './statusFilterOptions';
import styles from './EntityStatusFilter.module.scss';
interface StatusFilterSpec {
kind: StatusFilterKind;
/** Matches the entity's own status column header, so filter and column agree. */
label: string;
}
const POD_STATUS_SPEC: StatusFilterSpec = { kind: 'pod', label: 'Pod Status' };
const NODE_READINESS_SPEC: StatusFilterSpec = {
kind: 'node',
label: 'Node Readiness',
};
/**
* Which secondary status filters each list accepts — mirrors the filter structs in
* pkg/types/inframonitoringtypes. Volumes take none, so they are absent here.
*/
const STATUS_FILTERS_BY_ENTITY: Partial<
Record<InfraMonitoringEntity, StatusFilterSpec[]>
> = {
[InfraMonitoringEntity.PODS]: [{ kind: 'pod', label: 'Status' }],
[InfraMonitoringEntity.CONTAINERS]: [{ kind: 'container', label: 'Status' }],
[InfraMonitoringEntity.NODES]: [NODE_READINESS_SPEC, POD_STATUS_SPEC],
[InfraMonitoringEntity.CLUSTERS]: [NODE_READINESS_SPEC, POD_STATUS_SPEC],
[InfraMonitoringEntity.NAMESPACES]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.DEPLOYMENTS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.DAEMONSETS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.STATEFULSETS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.JOBS]: [POD_STATUS_SPEC],
};
const FILTER_KEY_BY_KIND: Record<StatusFilterKind, string> = {
pod: 'pod_status',
node: 'node_readiness',
container: 'container_status',
};
const OPTIONS_BY_KIND = {
pod: POD_STATUS_FILTER_OPTIONS,
node: NODE_READINESS_FILTER_OPTIONS,
container: CONTAINER_STATUS_FILTER_OPTIONS,
};
const ALL_VALUES_BY_KIND = {
pod: FILTERABLE_POD_STATUSES,
node: FILTERABLE_NODE_CONDITIONS,
container: FILTERABLE_CONTAINER_STATUSES,
};
function EntityStatusFilter(): JSX.Element | null {
const [category] = useInfraMonitoringCategory();
const [podStatus, setPodStatus] = useInfraMonitoringPodStatusFilter();
const [nodeReadiness, setNodeReadiness] =
useInfraMonitoringNodeReadinessFilter();
const [containerStatus, setContainerStatus] =
useInfraMonitoringContainerStatusFilter();
const specs = STATUS_FILTERS_BY_ENTITY[category as InfraMonitoringEntity];
if (!specs) {
return null;
}
const selectedByKind: Record<StatusFilterKind, string[]> = {
pod: podStatus,
node: nodeReadiness,
container: containerStatus,
};
const setterByKind: Record<StatusFilterKind, (next: string[]) => void> = {
pod: (next): void =>
void setPodStatus(next.length > 0 ? (next as typeof podStatus) : null),
node: (next): void =>
void setNodeReadiness(
next.length > 0 ? (next as typeof nodeReadiness) : null,
),
container: (next): void =>
void setContainerStatus(
next.length > 0 ? (next as typeof containerStatus) : null,
),
};
return (
<div className={styles.statusFilterGroup}>
{specs.map((spec) => (
<StatusMultiSelect
key={spec.kind}
label={spec.label}
entity={category as InfraMonitoringEntity}
filterKey={FILTER_KEY_BY_KIND[spec.kind]}
options={OPTIONS_BY_KIND[spec.kind]}
allValues={ALL_VALUES_BY_KIND[spec.kind]}
selected={selectedByKind[spec.kind]}
onChange={setterByKind[spec.kind]}
testId={`${spec.kind}-status-filter`}
/>
))}
</div>
);
}
export default EntityStatusFilter;

View File

@@ -1,102 +0,0 @@
import { useMemo } from 'react';
import { CustomMultiSelect } from 'components/NewSelect';
import OverflowValuesTooltip from 'components/OverflowValuesTooltip/OverflowValuesTooltip';
import { OptionData } from 'components/NewSelect/types';
import { findOptionLabel } from 'components/NewSelect/utils';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { useInfraMonitoringPageListing } from 'container/InfraMonitoringK8sV2/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { logInfraFilterCustomizedEvent } from '../../events';
import styles from './EntityStatusFilter.module.scss';
interface StatusMultiSelectProps<T extends string> {
label: string;
entity: InfraMonitoringEntity;
/** Key reported to analytics for this control, e.g. `pod_status`. */
filterKey: string;
options: OptionData[];
allValues: T[];
selected: T[];
onChange: (next: T[]) => void;
testId: string;
}
function StatusMultiSelect<T extends string>({
label,
entity,
filterKey,
options,
allValues,
selected,
onChange,
testId,
}: StatusMultiSelectProps<T>): JSX.Element {
const { currentQuery } = useQueryBuilder();
// No selection means no filter, which is every status — so show ALL as checked
// rather than an empty control that reads as "nothing matches".
const value = useMemo(
() => (selected.length > 0 ? selected : allValues),
[selected, allValues],
);
const handleChange = (next: string[] | string | undefined): void => {
const values = (Array.isArray(next) ? next : [next])
.filter((item): item is string => Boolean(item))
.filter((item): item is T => allValues.includes(item as T));
// Selecting everything is the same request as selecting nothing; store the
// empty form so the param drops out of the URL.
const isAll = values.length === allValues.length;
onChange(isAll ? [] : values);
logInfraFilterCustomizedEvent(
entity,
'status_filter',
currentQuery.builder.queryData[0]?.filter?.expression || '',
isAll ? [] : [filterKey],
);
};
const [, setCurrentPage] = useInfraMonitoringPageListing();
return (
<div className={styles.statusFilter}>
<span className={styles.statusFilterLabel}>{label}</span>
<CustomMultiSelect
className={styles.statusFilterControl}
data-testid={testId}
options={options}
value={value}
showSearch
showLabels
preserveOptionOrder
maxTagCount={1}
maxTagTextLength={12}
maxTagPlaceholder={(omitted): JSX.Element => (
<OverflowValuesTooltip
values={omitted.map((item) => {
// The omitted items carry the raw value as their label, since
// sections put the real one a level down.
const value = String(item.value ?? '');
return findOptionLabel(options, value) ?? value;
})}
/>
)}
placeholder={label}
onChange={(next): void => {
handleChange(next);
void setCurrentPage(1);
}}
onClear={(): void => {
onChange([]);
void setCurrentPage(1);
}}
/>
</div>
);
}
export default StatusMultiSelect;

View File

@@ -1,64 +0,0 @@
import { BadgeColor } from '@signozhq/ui/badge';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { OptionData } from 'components/NewSelect/types';
import {
FILTERABLE_CONTAINER_STATUSES,
FILTERABLE_NODE_CONDITIONS,
FILTERABLE_POD_STATUSES,
} from '../../../hooks';
import { POD_STATUS_COLORS, POD_STATUS_LABELS } from '../../../commonUtils';
import {
CONTAINER_STATUS_COLORS,
CONTAINER_STATUS_LABELS,
} from '../../../Containers/utils';
import { NODE_CONDITION_LABELS } from '../../../Nodes/utils';
const ERROR_BADGE_COLOR: BadgeColor = 'cherry';
/**
* Splits statuses into the two sections the dropdown renders. The badge colour is
* already the severity signal in the table, so reusing it keeps the sections and
* the status column from drifting apart.
*/
function toSectionedOptions<T extends string>(
values: T[],
labels: Record<T, string>,
colors: Record<T, BadgeColor>,
): OptionData[] {
const toOption = (value: T): OptionData => ({
label: labels[value],
value,
});
const lifecycle = values.filter(
(value) => colors[value] !== ERROR_BADGE_COLOR,
);
const errors = values.filter((value) => colors[value] === ERROR_BADGE_COLOR);
return [
{ label: 'Lifecycle', options: lifecycle.map(toOption) },
{ label: 'Error status', options: errors.map(toOption) },
].filter((section) => section.options.length > 0);
}
export const POD_STATUS_FILTER_OPTIONS = toSectionedOptions(
FILTERABLE_POD_STATUSES as InframonitoringtypesPodStatusDTO[],
POD_STATUS_LABELS,
POD_STATUS_COLORS,
);
export const CONTAINER_STATUS_FILTER_OPTIONS = toSectionedOptions(
FILTERABLE_CONTAINER_STATUSES as InframonitoringtypesContainerStatusDTO[],
CONTAINER_STATUS_LABELS,
CONTAINER_STATUS_COLORS,
);
/** Only two values, so sections would add a heading per row. */
export const NODE_READINESS_FILTER_OPTIONS: OptionData[] = (
FILTERABLE_NODE_CONDITIONS as InframonitoringtypesNodeConditionDTO[]
).map((value) => ({ label: NODE_CONDITION_LABELS[value], value }));

View File

@@ -1,11 +0,0 @@
.statusBadgeButton {
display: inline-flex;
padding: 0;
background: none;
border: none;
cursor: pointer;
font: inherit;
// The badge sets its own cursor, so the button's never reaches it.
--badge-cursor: pointer;
}

View File

@@ -1,57 +0,0 @@
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { StatusFilterKind, useStatusFilter } from './useStatusFilter';
import styles from './ClickableStatusBadge.module.scss';
interface ClickableStatusBadgeProps {
color: BadgeColor;
label: string;
/** The status this badge stands for, applied as the filter when clicked. */
status: string;
kind: StatusFilterKind;
rowId: string;
}
function ClickableStatusBadge({
color,
label,
status,
kind,
rowId,
}: ClickableStatusBadgeProps): JSX.Element {
const { selected, setStatusFilter } = useStatusFilter(kind);
// Clicking the status the list is already narrowed to has nothing left to
// narrow, so it undoes the filter instead — but only when this status is the
// whole filter, else it would silently drop the other selected statuses.
const isWholeFilter = selected.length === 1 && selected[0] === status;
return (
<TanStackTable.HoverTooltip
rowId={rowId}
title={isWholeFilter ? `Clear ${label} filter` : `Filter by ${label}`}
arrow
align="start"
delayDuration={300}
>
<button
type="button"
className={styles.statusBadgeButton}
data-testid={`status-badge-${status}`}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
setStatusFilter(isWholeFilter ? [] : [status]);
}}
>
<Badge color={color} variant="outline">
{label}
</Badge>
</button>
</TanStackTable.HoverTooltip>
);
}
export default ClickableStatusBadge;

View File

@@ -1,33 +0,0 @@
import {
GroupedStatusCounts,
StatusCountItem,
} from 'container/InfraMonitoringK8sV2/components';
import { StatusFilterKind, useStatusFilter } from './useStatusFilter';
interface ClickableStatusCountsProps {
items: StatusCountItem[];
rowId: string;
kind: StatusFilterKind;
showZeroValues?: boolean;
}
function ClickableStatusCounts({
items,
rowId,
kind,
showZeroValues,
}: ClickableStatusCountsProps): JSX.Element {
const { setStatusFilter } = useStatusFilter(kind);
return (
<GroupedStatusCounts
items={items}
rowId={rowId}
showZeroValues={showZeroValues}
onSelectStatuses={setStatusFilter}
/>
);
}
export default ClickableStatusCounts;

View File

@@ -1,67 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
import ClickableStatusBadge from '../ClickableStatusBadge';
function renderBadge(
searchParams: Record<string, string>,
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
): void {
render(
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
<ClickableStatusBadge
color="cherry"
label="CrashLoopBackOff"
status="crashloopbackoff"
kind="pod"
rowId="row-1"
/>
</NuqsTestingAdapter>,
);
}
describe('ClickableStatusBadge', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
beforeEach(() => {
onUrlUpdate.mockClear();
});
it('filters by its status when nothing is filtered', async () => {
renderBadge({}, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus')).toBe(
'crashloopbackoff',
);
});
});
it('clears the filter when its status is the whole filter', async () => {
renderBadge({ podStatus: 'crashloopbackoff' }, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(
onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus'),
).toBeNull();
});
});
it('still filters when its status is only part of the filter', async () => {
// Clearing here would silently drop oomkilled, which the user also picked.
renderBadge({ podStatus: 'crashloopbackoff,oomkilled' }, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus')).toBe(
'crashloopbackoff',
);
});
});
});

View File

@@ -1,60 +0,0 @@
import { useCallback } from 'react';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringPageListing,
useInfraMonitoringPodStatusFilter,
} from '../../../hooks';
export type StatusFilterKind = 'pod' | 'node' | 'container';
interface StatusFilter {
selected: string[];
/**
* Replaces the filter rather than adding to it — a click on a status means
* "show me these", not "add these to what I already have". Empty clears it.
*/
setStatusFilter: (statuses: string[]) => void;
}
export function useStatusFilter(kind: StatusFilterKind): StatusFilter {
const [podStatus, setPodStatus] = useInfraMonitoringPodStatusFilter();
const [nodeStatus, setNodeStatus] = useInfraMonitoringNodeReadinessFilter();
const [containerStatus, setContainerStatus] =
useInfraMonitoringContainerStatusFilter();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const setStatusFilter = useCallback(
(statuses: string[]): void => {
const next = statuses.length > 0 ? statuses : null;
if (kind === 'pod') {
void setPodStatus(next as InframonitoringtypesPodStatusDTO[] | null);
} else if (kind === 'node') {
void setNodeStatus(next as InframonitoringtypesNodeConditionDTO[] | null);
} else {
void setContainerStatus(
next as InframonitoringtypesContainerStatusDTO[] | null,
);
}
void setCurrentPage(1);
},
[kind, setPodStatus, setNodeStatus, setContainerStatus, setCurrentPage],
);
let selected: string[] = containerStatus;
if (kind === 'pod') {
selected = podStatus;
} else if (kind === 'node') {
selected = nodeStatus;
}
return { selected, setStatusFilter };
}

View File

@@ -6,7 +6,7 @@ import { isCustomTimeRange } from 'store/globalTime';
export function logInfraFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
source: 'quick_filter' | 'search' | 'host_status_toggle' | 'status_filter',
source: 'quick_filter' | 'search' | 'host_status_toggle',
expression: string,
extraKeys?: string[],
): void {

View File

@@ -1,9 +1,4 @@
import { ReactNode } from 'react';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
CustomTimeType,
Time,
@@ -22,9 +17,6 @@ export type K8sBaseFilters = {
filter: {
expression: string;
filterByStatus?: 'active' | 'inactive' | '';
filterByPodStatus?: InframonitoringtypesPodStatusDTO[];
filterByNodeReadiness?: InframonitoringtypesNodeConditionDTO[];
filterByContainerStatus?: InframonitoringtypesContainerStatusDTO[];
};
groupBy?: Array<{ name: string }>;
offset?: number;

View File

@@ -38,11 +38,7 @@ async function fetchListData(
try {
const response = await listClusters(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
filterByNodeReadiness: filters.filter.filterByNodeReadiness,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,19 +1,19 @@
import { Color } from '@signozhq/design-tokens';
import { Boxes } from '@signozhq/icons';
import {
InframonitoringtypesClusterRecordDTO,
InframonitoringtypesNodeConditionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { InframonitoringtypesClusterRecordDTO } from 'api/generated/services/sigNoz.schemas';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
@@ -98,21 +98,18 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
}
return (
<ClickableStatusCounts
kind="node"
<GroupedStatusCounts
rowId={rowId}
items={[
{
value: row.nodeCountsByReadiness.ready,
label: 'Ready',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesNodeConditionDTO.ready],
},
{
value: row.nodeCountsByReadiness.notReady,
label: 'Not Ready',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesNodeConditionDTO.not_ready],
},
]}
/>
@@ -138,8 +135,7 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
rowId={rowId}
items={getPodStatusItems(row.podCountsByStatus)}
/>

View File

@@ -40,10 +40,7 @@ async function fetchListData(
try {
const response = await listContainers(
{
filter: {
expression: filters.filter.expression,
filterByContainerStatus: filters.filter.filterByContainerStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -9,8 +9,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
@@ -175,7 +173,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
width: { min: STATUS_COLUMN_WIDTH },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row, rowId }): React.ReactNode => {
cell: ({ row }): React.ReactNode => {
if (
!row.status ||
row.status === InframonitoringtypesContainerStatusDTO.no_data
@@ -184,13 +182,9 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
}
return (
<ClickableStatusBadge
color={CONTAINER_STATUS_COLORS[row.status]}
label={CONTAINER_STATUS_LABELS[row.status]}
status={row.status}
kind="container"
rowId={rowId}
/>
<Badge color={CONTAINER_STATUS_COLORS[row.status]} variant="outline">
{CONTAINER_STATUS_LABELS[row.status]}
</Badge>
);
},
},
@@ -211,8 +205,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="container"
<GroupedStatusCounts
items={getContainerStatusItems(row.containerCountsByStatus)}
rowId={rowId}
/>

View File

@@ -104,20 +104,6 @@ const CONTAINER_ERROR_STATUS_LABELS: Partial<
containerCannotRun: 'ContainerCannotRun',
};
const CONTAINER_STATUS_VALUES = new Set<string>(
Object.values(InframonitoringtypesContainerStatusDTO),
);
/** See countKeyToPodStatus in commonUtils — the counts DTO uses camelCase keys. */
function countKeyToContainerStatus(
key: string,
): InframonitoringtypesContainerStatusDTO | null {
const value = key.toLowerCase();
return CONTAINER_STATUS_VALUES.has(value)
? (value as InframonitoringtypesContainerStatusDTO)
: null;
}
export function getContainerStatusItems(
counts: InframonitoringtypesContainerCountsByStatusDTO,
): StatusCountItem[] {
@@ -125,67 +111,32 @@ export function getContainerStatusItems(
keyof typeof CONTAINER_ERROR_STATUS_LABELS
>;
const errorBreakdown = errorKeys.map((key) => ({
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
value: counts[key],
statuses: [countKeyToContainerStatus(key)].filter(
(status): status is InframonitoringtypesContainerStatusDTO =>
status !== null,
),
}));
return [
{
value: counts.running,
label: 'Running',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesContainerStatusDTO.running],
},
{
value: counts.completed,
label: 'Completed',
color: Color.BG_ROBIN_500,
statuses: [InframonitoringtypesContainerStatusDTO.completed],
},
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
{
value: counts.waiting + counts.containerCreating,
label: 'Waiting',
color: Color.BG_AMBER_500,
breakdown: [
{
label: 'Waiting',
value: counts.waiting,
statuses: [InframonitoringtypesContainerStatusDTO.waiting],
},
{
label: 'ContainerCreating',
value: counts.containerCreating,
statuses: [InframonitoringtypesContainerStatusDTO.containercreating],
},
],
statuses: [
InframonitoringtypesContainerStatusDTO.waiting,
InframonitoringtypesContainerStatusDTO.containercreating,
{ label: 'Waiting', value: counts.waiting },
{ label: 'ContainerCreating', value: counts.containerCreating },
],
},
{
value: counts.terminated,
label: 'Terminated',
color: Color.BG_SIENNA_500,
statuses: [InframonitoringtypesContainerStatusDTO.terminated],
},
{
value: counts.unknown,
label: 'Unknown',
color: Color.BG_SLATE_400,
statuses: [InframonitoringtypesContainerStatusDTO.unknown],
},
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
{
value: errorKeys.reduce((sum, key) => sum + counts[key], 0),
label: 'Error Status',
color: Color.BG_CHERRY_500,
breakdown: errorBreakdown,
statuses: errorBreakdown.flatMap((item) => item.statuses),
breakdown: errorKeys.map((key) => ({
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
value: counts[key],
})),
},
];
}

View File

@@ -42,10 +42,7 @@ async function fetchListData(
try {
const response = await listDaemonSets(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,7 +4,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -127,8 +126,7 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
rowId={rowId}
items={getPodStatusItems(podCountsByStatus)}
/>

View File

@@ -46,10 +46,7 @@ async function fetchListData(
try {
const response = await listDeployments(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,7 +4,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -125,8 +124,7 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
rowId={rowId}
items={getPodStatusItems(podCountsByStatus)}
/>

View File

@@ -18,7 +18,6 @@ import { DataSource } from 'types/common/queryBuilder';
import { CategoryIcon } from './Base/categoryIcons';
import { K8sDynamicList } from './Base/K8sDynamicList';
import EntityStatusFilter from './Base/components/EntityStatusFilter/EntityStatusFilter';
import {
GetClustersQuickFiltersConfig,
GetContainersQuickFiltersConfig,
@@ -37,12 +36,9 @@ import {
} from './constants';
import {
useInfraMonitoringCategory,
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringGroupBy,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringPodStatusFilter,
useInfraMonitoringSelectedItemParams,
} from './hooks';
@@ -64,9 +60,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [, setOrderBy] = useInfraMonitoringOrderBy();
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [, setPodStatusFilter] = useInfraMonitoringPodStatusFilter();
const [, setNodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
const [, setContainerStatusFilter] = useInfraMonitoringContainerStatusFilter();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -226,9 +219,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
void setPodStatusFilter(null);
void setNodeReadinessFilter(null);
void setContainerStatusFilter(null);
redirectWithQueryBuilderData({
...currentQuery,
builder: {
@@ -336,10 +326,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
showFilters ? styles.listContainerFiltersVisible : ''
}`}
>
<K8sDynamicList
controlListPrefix={showFiltersComp}
leftFilters={<EntityStatusFilter />}
/>
<K8sDynamicList controlListPrefix={showFiltersComp} />
</div>
</div>
</div>

View File

@@ -42,10 +42,7 @@ async function fetchListData(
try {
const response = await listJobs(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,7 +4,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -119,8 +118,7 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
items={getPodStatusItems(podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -44,10 +44,7 @@ async function fetchListData(
try {
const response = await listNamespaces(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,11 +4,14 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
@@ -116,8 +119,7 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -39,11 +39,7 @@ async function fetchListData(
try {
const response = await listNodes(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
filterByNodeReadiness: filters.filter.filterByNodeReadiness,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,21 +1,20 @@
import { Color } from '@signozhq/design-tokens';
import {
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesNodeRecordDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { InframonitoringtypesNodeRecordDTO } from 'api/generated/services/sigNoz.schemas';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import { InfraMonitoringEntity } from '../constants';
import { NODE_CONDITION_COLORS, NODE_CONDITION_LABELS } from './utils';
import { Workflow } from '@signozhq/icons';
export function getK8sNodeRowKey(
@@ -32,6 +31,18 @@ export function getK8sNodeItemKey(
return node.nodeName;
}
const NODE_CONDITION_COLORS: Record<string, BadgeColor> = {
ready: 'forest',
not_ready: 'amber',
no_data: 'secondary',
};
const NODE_CONDITION_LABEL_MAP: Record<string, string> = {
ready: 'Ready',
not_ready: 'Not Ready',
no_data: 'No Data',
};
export type NodeTableColumnConfig =
TableColumnDef<InframonitoringtypesNodeRecordDTO>;
export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
@@ -96,31 +107,24 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
}
return (
<ClickableStatusBadge
color={color}
label={NODE_CONDITION_LABELS[row.condition] || 'Unknown'}
status={row.condition}
kind="node"
rowId={rowId}
/>
<Badge color={color} variant="outline">
{NODE_CONDITION_LABEL_MAP[row.condition] || 'Unknown'}
</Badge>
);
}
return (
<ClickableStatusCounts
kind="node"
<GroupedStatusCounts
items={[
{
value: row.nodeCountsByReadiness?.ready ?? 0,
label: 'Ready',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesNodeConditionDTO.ready],
},
{
value: row.nodeCountsByReadiness?.notReady ?? 0,
label: 'Not Ready',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesNodeConditionDTO.not_ready],
},
]}
rowId={rowId}
@@ -145,8 +149,7 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -1,20 +0,0 @@
import { BadgeColor } from '@signozhq/ui/badge';
import { InframonitoringtypesNodeConditionDTO } from 'api/generated/services/sigNoz.schemas';
export const NODE_CONDITION_COLORS: Record<
InframonitoringtypesNodeConditionDTO,
BadgeColor
> = {
[InframonitoringtypesNodeConditionDTO.ready]: 'forest',
[InframonitoringtypesNodeConditionDTO.not_ready]: 'amber',
[InframonitoringtypesNodeConditionDTO.no_data]: 'secondary',
};
export const NODE_CONDITION_LABELS: Record<
InframonitoringtypesNodeConditionDTO,
string
> = {
[InframonitoringtypesNodeConditionDTO.ready]: 'Ready',
[InframonitoringtypesNodeConditionDTO.not_ready]: 'Not Ready',
[InframonitoringtypesNodeConditionDTO.no_data]: 'No Data',
};

View File

@@ -36,10 +36,7 @@ async function fetchListData(
try {
const response = await listPods(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,4 +1,5 @@
import { Container } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import {
InframonitoringtypesPodRecordDTO,
InframonitoringtypesPodStatusDTO,
@@ -9,17 +10,15 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import {
formatBytes,
getPodStatusItems,
POD_STATUS_COLORS,
POD_STATUS_LABELS,
} from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
@@ -101,7 +100,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
width: { min: 250 },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row, rowId }): React.ReactNode => {
cell: ({ row }): React.ReactNode => {
if (!row.podStatus) {
return <></>;
}
@@ -111,14 +110,11 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
}
const color = POD_STATUS_COLORS[row.podStatus] || POD_STATUS_COLORS.unknown;
const label = row.podStatus.charAt(0).toUpperCase() + row.podStatus.slice(1);
return (
<ClickableStatusBadge
color={color}
label={POD_STATUS_LABELS[row.podStatus]}
status={row.podStatus}
kind="pod"
rowId={rowId}
/>
<Badge color={color} variant="outline">
{label}
</Badge>
);
},
},
@@ -140,8 +136,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -42,10 +42,7 @@ async function fetchListData(
try {
const response = await listStatefulSets(
{
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,7 +4,6 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -128,8 +127,7 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
return <TextNoData type="tanstack" />;
}
return (
<ClickableStatusCounts
kind="pod"
<GroupedStatusCounts
items={getPodStatusItems(podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -69,42 +69,6 @@ function renderPage(
}
describe('InfraMonitoringK8s', () => {
describe('when the category changes with a status filter applied', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
beforeEach(async () => {
onUrlUpdateMock.mockClear();
renderPage(
{
category: K8sCategories.CLUSTERS,
podStatus: 'crashloopbackoff',
nodeReadiness: 'not_ready',
},
onUrlUpdateMock,
);
await screen.findByTestId(`category-${K8sCategories.CONTAINERS}`);
});
it('should drop the status filters the new category cannot apply', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.CONTAINERS}`));
// Containers filter on their own statuses, so a pod status carried over from
// clusters would sit in the toolbar looking applied while matching nothing.
await waitFor(() => {
const categorySwitch = onUrlUpdateMock.mock.calls.find(
(call) =>
call[0].searchParams.get('category') === K8sCategories.CONTAINERS,
);
expect(categorySwitch).toBeDefined();
expect(categorySwitch?.[0].searchParams.get('podStatus')).toBeNull();
expect(categorySwitch?.[0].searchParams.get('nodeReadiness')).toBeNull();
});
});
});
describe('when the category changes from a page other than the first', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();

View File

@@ -1,87 +0,0 @@
import {
InframonitoringtypesContainerCountsByStatusDTO,
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesPodCountsByStatusDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPodStatusItems } from '../commonUtils';
import { getContainerStatusItems } from '../Containers/utils';
const POD_COUNT_KEYS: Array<keyof InframonitoringtypesPodCountsByStatusDTO> = [
'running',
'completed',
'pending',
'unknown',
'failed',
'crashLoopBackOff',
'imagePullBackOff',
'errImagePull',
'createContainerConfigError',
'containerCreating',
'oomKilled',
'error',
'containerCannotRun',
'evicted',
'nodeAffinity',
'nodeLost',
'shutdown',
'unexpectedAdmissionError',
];
function podCounts(): InframonitoringtypesPodCountsByStatusDTO {
return POD_COUNT_KEYS.reduce(
(acc, key) => ({ ...acc, [key]: 1 }),
{} as InframonitoringtypesPodCountsByStatusDTO,
);
}
function containerCounts(): InframonitoringtypesContainerCountsByStatusDTO {
return {
running: 1,
waiting: 1,
terminated: 1,
completed: 1,
unknown: 1,
containerCreating: 1,
crashLoopBackOff: 1,
imagePullBackOff: 1,
errImagePull: 1,
createContainerConfigError: 1,
oomKilled: 1,
error: 1,
containerCannotRun: 1,
} as InframonitoringtypesContainerCountsByStatusDTO;
}
describe('status counts carry filterable statuses', () => {
it('maps every pod count bucket to a real status enum member', () => {
const items = getPodStatusItems(podCounts());
const statuses = items.flatMap((item) => item.statuses ?? []);
const valid = Object.values(InframonitoringtypesPodStatusDTO) as string[];
expect(statuses.length).toBeGreaterThan(0);
statuses.forEach((status) => expect(valid).toContain(status));
});
it('gives the pod Error Status chip every error bucket it sums', () => {
const items = getPodStatusItems(podCounts());
const errorItem = items.find((item) => item.label === 'Error Status');
// The chip's own value is the sum of its breakdown, so selecting it must
// select every status counted in that sum.
expect(errorItem?.value).toBe(errorItem?.breakdown?.length);
expect(errorItem?.statuses).toHaveLength(errorItem?.breakdown?.length ?? 0);
});
it('maps every container count bucket to a real status enum member', () => {
const items = getContainerStatusItems(containerCounts());
const statuses = items.flatMap((item) => item.statuses ?? []);
const valid = Object.values(
InframonitoringtypesContainerStatusDTO,
) as string[];
expect(statuses.length).toBeGreaterThan(0);
statuses.forEach((status) => expect(valid).toContain(status));
});
});

View File

@@ -1,68 +0,0 @@
import * as inframonitoring from 'api/generated/services/inframonitoring';
import { entityRegistry } from '../Base/entity.registry';
import { K8sCategories } from '../constants';
jest.mock('api/generated/services/inframonitoring');
/**
* Every list the status filter offers must forward the flag, or the control is a
* no-op on that section. Mirrors the filter structs in pkg/types/inframonitoringtypes.
*/
const EXPECTED_FLAGS: Record<string, string[]> = {
[K8sCategories.PODS]: ['filterByPodStatus'],
[K8sCategories.CONTAINERS]: ['filterByContainerStatus'],
[K8sCategories.NODES]: ['filterByPodStatus', 'filterByNodeReadiness'],
[K8sCategories.CLUSTERS]: ['filterByPodStatus', 'filterByNodeReadiness'],
[K8sCategories.NAMESPACES]: ['filterByPodStatus'],
[K8sCategories.DEPLOYMENTS]: ['filterByPodStatus'],
[K8sCategories.DAEMONSETS]: ['filterByPodStatus'],
[K8sCategories.STATEFULSETS]: ['filterByPodStatus'],
[K8sCategories.JOBS]: ['filterByPodStatus'],
};
const SENTINELS: Record<string, string[]> = {
filterByPodStatus: ['crashloopbackoff'],
filterByNodeReadiness: ['not_ready'],
filterByContainerStatus: ['oomkilled'],
};
describe('status filters reach the list APIs', () => {
beforeEach(() => {
jest.clearAllMocks();
Object.values(inframonitoring).forEach((exported) => {
if (jest.isMockFunction(exported)) {
exported.mockResolvedValue({
data: { type: 'list', records: [], total: 0 },
});
}
});
});
Object.entries(EXPECTED_FLAGS).forEach(([category, flags]) => {
it(`forwards ${flags.join(' + ')} for ${category}`, async () => {
const config = entityRegistry[category];
expect(config).toBeDefined();
await config.list.fetchListData({
filter: {
expression: '',
...Object.fromEntries(flags.map((flag) => [flag, SENTINELS[flag]])),
},
start: 1,
end: 2,
limit: 10,
offset: 0,
});
const call = Object.values(inframonitoring)
.filter(jest.isMockFunction)
.flatMap((fn) => (fn as jest.Mock).mock.calls)[0];
expect(call).toBeDefined();
flags.forEach((flag) => {
expect(call[0].filter[flag]).toStrictEqual(SENTINELS[flag]);
});
});
});
});

View File

@@ -51,34 +51,6 @@ export const POD_STATUS_COLORS: Record<
[InframonitoringtypesPodStatusDTO.unexpectedadmissionerror]: 'cherry',
};
/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */
export const POD_STATUS_LABELS: Record<
InframonitoringtypesPodStatusDTO,
string
> = {
[InframonitoringtypesPodStatusDTO.running]: 'Running',
[InframonitoringtypesPodStatusDTO.completed]: 'Completed',
[InframonitoringtypesPodStatusDTO.pending]: 'Pending',
[InframonitoringtypesPodStatusDTO.unknown]: 'Unknown',
[InframonitoringtypesPodStatusDTO.no_data]: 'No data',
[InframonitoringtypesPodStatusDTO.failed]: 'Failed',
[InframonitoringtypesPodStatusDTO.crashloopbackoff]: 'CrashLoopBackOff',
[InframonitoringtypesPodStatusDTO.imagepullbackoff]: 'ImagePullBackOff',
[InframonitoringtypesPodStatusDTO.errimagepull]: 'ErrImagePull',
[InframonitoringtypesPodStatusDTO.createcontainerconfigerror]:
'CreateContainerConfigError',
[InframonitoringtypesPodStatusDTO.containercreating]: 'ContainerCreating',
[InframonitoringtypesPodStatusDTO.oomkilled]: 'OOMKilled',
[InframonitoringtypesPodStatusDTO.error]: 'Error',
[InframonitoringtypesPodStatusDTO.containercannotrun]: 'ContainerCannotRun',
[InframonitoringtypesPodStatusDTO.evicted]: 'Evicted',
[InframonitoringtypesPodStatusDTO.nodeaffinity]: 'NodeAffinity',
[InframonitoringtypesPodStatusDTO.nodelost]: 'NodeLost',
[InframonitoringtypesPodStatusDTO.shutdown]: 'Shutdown',
[InframonitoringtypesPodStatusDTO.unexpectedadmissionerror]:
'UnexpectedAdmissionError',
};
type PodStatusCategory =
| 'running'
| 'completed'
@@ -133,24 +105,6 @@ const ERROR_STATUS_LABELS: Record<ErrorStatusKey, string> = {
unexpectedAdmissionError: 'UnexpectedAdmissionError',
};
const POD_STATUS_VALUES = new Set<string>(
Object.values(InframonitoringtypesPodStatusDTO),
);
/**
* The count DTO spells each status in camelCase (`crashLoopBackOff`) where the
* status enum spells it lowercase (`crashloopbackoff`); anything that does not
* land on a real enum member is dropped rather than sent to the API.
*/
function countKeyToPodStatus(
key: string,
): InframonitoringtypesPodStatusDTO | null {
const value = key.toLowerCase();
return POD_STATUS_VALUES.has(value)
? (value as InframonitoringtypesPodStatusDTO)
: null;
}
export function getPodStatusItems(
counts: InframonitoringtypesPodCountsByStatusDTO,
): StatusCountItem[] {
@@ -160,42 +114,18 @@ export function getPodStatusItems(
const errorBreakdown = errorKeys.map((key) => ({
label: ERROR_STATUS_LABELS[key],
value: counts[key],
statuses: [countKeyToPodStatus(key)].filter(
(status): status is InframonitoringtypesPodStatusDTO => status !== null,
),
}));
return [
{
value: counts.running,
label: 'Running',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesPodStatusDTO.running],
},
{
value: counts.completed,
label: 'Completed',
color: Color.BG_ROBIN_500,
statuses: [InframonitoringtypesPodStatusDTO.completed],
},
{
value: counts.pending,
label: 'Pending',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesPodStatusDTO.pending],
},
{
value: counts.unknown,
label: 'Unknown',
color: Color.BG_SLATE_400,
statuses: [InframonitoringtypesPodStatusDTO.unknown],
},
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
{ value: counts.pending, label: 'Pending', color: Color.BG_AMBER_500 },
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
{
value: errorTotal,
label: 'Error Status',
color: Color.BG_CHERRY_500,
breakdown: errorBreakdown,
statuses: errorBreakdown.flatMap((item) => item.statuses),
},
];
}

View File

@@ -45,25 +45,3 @@
.tooltipValue {
font-variant-numeric: tabular-nums;
}
.itemSelectable {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.tooltipRowSelectable {
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
text-align: left;
&:hover {
text-decoration: underline;
}
}

View File

@@ -8,8 +8,6 @@ import { MouseEventHandler } from 'react';
export interface StatusBreakdownItem {
label: string;
value: number;
/** The status values this row stands for, for click-to-filter. */
statuses?: string[];
}
export interface StatusCountItem {
@@ -17,22 +15,15 @@ export interface StatusCountItem {
label: string;
color: string;
breakdown?: StatusBreakdownItem[];
/** The status values this count stands for, for click-to-filter. */
statuses?: string[];
}
interface GroupedStatusCountsProps {
items: StatusCountItem[];
rowId: string;
showZeroValues?: boolean;
/** Omit to leave the counts inert, as on entities with no status filter. */
onSelectStatuses?: (statuses: string[]) => void;
}
function buildTooltipContent(
item: StatusCountItem,
onSelectStatuses?: (statuses: string[]) => void,
): React.ReactNode {
function buildTooltipContent(item: StatusCountItem): React.ReactNode {
const onClickHandle: MouseEventHandler = (e) => {
e.preventDefault();
e.stopPropagation();
@@ -66,33 +57,14 @@ function buildTooltipContent(
<Typography.Text className={styles.tooltipHeader}>
{item.label}
</Typography.Text>
{nonZeroBreakdown.map((b) =>
onSelectStatuses && b.statuses?.length ? (
<button
key={b.label}
type="button"
className={`${styles.tooltipRow} ${styles.tooltipRowSelectable}`}
data-testid={`status-breakdown-${b.statuses.join('-')}`}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
onSelectStatuses(b.statuses as string[]);
}}
>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</button>
) : (
<div key={b.label} className={styles.tooltipRow}>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</div>
),
)}
{nonZeroBreakdown.map((b) => (
<div key={b.label} className={styles.tooltipRow}>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</div>
))}
</div>
);
}
@@ -101,7 +73,6 @@ export function GroupedStatusCounts({
items,
rowId,
showZeroValues = true,
onSelectStatuses,
}: GroupedStatusCountsProps): JSX.Element {
const visibleItems =
showZeroValues === false ? items.filter((item) => item.value > 0) : items;
@@ -116,30 +87,14 @@ export function GroupedStatusCounts({
<TanStackTable.HoverTooltip
key={item.label}
rowId={rowId}
title={buildTooltipContent(item, onSelectStatuses)}
title={buildTooltipContent(item)}
arrow
align="start"
>
{item.value ? (
<TanStackTable.Text
className={`${styles.item} ${
onSelectStatuses && item.statuses?.length ? styles.itemSelectable : ''
}`}
className={styles.item}
style={{ '--gsc-color': item.color } as React.CSSProperties}
data-testid={
item.statuses?.length
? `status-count-${item.statuses.join('-')}`
: undefined
}
onClick={
onSelectStatuses && item.statuses?.length
? (e: React.MouseEvent): void => {
e.preventDefault();
e.stopPropagation();
onSelectStatuses(item.statuses as string[]);
}
: undefined
}
>
{item.value}
</TanStackTable.Text>

View File

@@ -971,9 +971,6 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
EVENTS_FILTERS: 'eventsFilters',
HOSTS_FILTERS: 'hostsFilters',
STATUS_FILTER: 'statusFilter',
POD_STATUS_FILTER: 'podStatus',
NODE_READINESS_FILTER: 'nodeReadiness',
CONTAINER_STATUS_FILTER: 'containerStatus',
CURRENT_PAGE: 'currentPage',
PAGE: 'page',
PAGE_SIZE: 'pageSize',

View File

@@ -1,20 +1,13 @@
import {
createParser,
Options,
parseAsArrayOf,
parseAsInteger,
parseAsJson,
parseAsString,
parseAsStringEnum,
useQueryState,
useQueryStates,
UseQueryStateReturn,
} from 'nuqs';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useCallback, useMemo } from 'react';
import {
IBuilderQuery,
@@ -232,58 +225,3 @@ export const useInfraMonitoringStatusFilter = (): UseQueryStateReturn<
INFRA_MONITORING_K8S_PARAMS_KEYS.STATUS_FILTER,
parseAsString.withDefault('').withOptions(defaultNuqsOptions),
);
/**
* The list APIs reject the `no_data` sentinel (see IsFilterable in
* pkg/types/inframonitoringtypes), so it is never offered as a filter value.
*/
function withoutNoData<T extends string>(values: T[]): T[] {
return values.filter((value) => value !== 'no_data');
}
export const FILTERABLE_POD_STATUSES = withoutNoData(
Object.values(InframonitoringtypesPodStatusDTO),
);
export const FILTERABLE_NODE_CONDITIONS = withoutNoData(
Object.values(InframonitoringtypesNodeConditionDTO),
);
export const FILTERABLE_CONTAINER_STATUSES = withoutNoData(
Object.values(InframonitoringtypesContainerStatusDTO),
);
const EMPTY_FILTER: [] = [];
export const useInfraMonitoringPodStatusFilter = (): UseQueryStateReturn<
InframonitoringtypesPodStatusDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.POD_STATUS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_POD_STATUSES))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringNodeReadinessFilter = (): UseQueryStateReturn<
InframonitoringtypesNodeConditionDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.NODE_READINESS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_NODE_CONDITIONS))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringContainerStatusFilter = (): UseQueryStateReturn<
InframonitoringtypesContainerStatusDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.CONTAINER_STATUS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_CONTAINER_STATUSES))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);

View File

@@ -67,6 +67,16 @@
vertical-align: bottom;
}
// The values behind a pill's `+N`, one bullet each, width-capped so a long value
// wraps instead of stretching the tooltip off-screen.
.overflowValues {
max-width: 360px;
margin: 0;
padding-left: 14px;
list-style: disc outside;
overflow-wrap: anywhere;
}
// Shared by the Text and value selectors: strips the antd control chrome so the
// selector blends into the variable pill.
.control {

View File

@@ -3,7 +3,7 @@ import TooltipScrollArea, {
TOOLTIP_SCROLL_CONTENT_CLASS,
} from 'components/TooltipScrollArea/TooltipScrollArea';
import styles from './OverflowValuesTooltip.module.scss';
import styles from '../../VariablesBar.module.scss';
interface OverflowValuesTooltipProps {
/** The selected values the pill hides behind this `+N`. */

View File

@@ -9,7 +9,7 @@ import type { VariableSelection } from '../../selectionTypes';
import { dynamicVariableOptions } from '../../utils/dynamicVariableOptions';
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
import { selectionFromCommittedValues } from '../../utils/selectionUtils';
import OverflowValuesTooltip from 'components/OverflowValuesTooltip/OverflowValuesTooltip';
import OverflowValuesTooltip from './OverflowValuesTooltip';
import styles from '../../VariablesBar.module.scss';
interface ValueSelectorProps {

View File

@@ -27,14 +27,31 @@ import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import MissingSpansBanner from './MissingSpansBanner';
import TraceOptionsMenu from './TraceOptionsMenu';
import { useTraceSummary } from './useTraceSummary';
import styles from './TraceDetailsHeader.module.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
interface FilterMetadata {
startTime: number;
endTime: number;
traceId: string;
}
export interface TraceMetadataForHeader {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
hasMissingSpans: boolean;
totalSpansCount: number;
}
interface TraceDetailsHeaderProps {
filterMetadata: FilterMetadata;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
showTraceDetailsHeaderOptions?: boolean;
isDataLoaded?: boolean;
traceMetadata?: TraceMetadataForHeader;
}
const SKELETON_COUNT = 3;
@@ -56,19 +73,20 @@ function DetailsLoader(): JSX.Element {
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
showTraceDetailsHeaderOptions,
isDataLoaded,
traceMetadata,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV3URLProps>();
const [showTraceDetails, setShowTraceDetails] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const { data: traceSummary } = useTraceSummary(traceID);
const previewFields = useTraceStore((s) => s.previewFields);
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
const logTraceEvent = useTraceDetailLogEvent('v3', traceID);
const logTraceEvent = useTraceDetailLogEvent('v3', traceID || '');
const handleToggleAnalytics = useCallback((): void => {
logTraceEvent(TraceDetailEvents.AnalyticsPanelToggled, {
@@ -98,11 +116,8 @@ function TraceDetailsHeader({
setShowTraceDetails((prev) => !prev);
}, []);
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
const durationMs = traceSummary
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
const durationMs = traceMetadata
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
: 0;
return (
@@ -122,12 +137,12 @@ function TraceDetailsHeader({
</Button>
<KeyValueLabel
badgeKey="Trace ID"
badgeValue={traceID}
badgeValue={traceID || ''}
maxCharacters={100}
/>
</div>
)}
{showTraceDetailsHeaderOptions && traceSummary && (
{isDataLoaded && (
<div
className={cx(
styles.filterSection,
@@ -155,10 +170,10 @@ function TraceDetailsHeader({
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
traceId={traceID}
startTime={startTime}
endTime={endTime}
totalSpansCount={traceSummary.totalSpansCount}
traceId={traceID || ''}
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
totalSpansCount={traceMetadata?.totalSpansCount || 0}
/>
</div>
</TooltipProvider>
@@ -168,9 +183,9 @@ function TraceDetailsHeader({
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
>
<Filters
startTime={startTime}
endTime={endTime}
traceID={traceID}
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
isExpanded={isFilterExpanded}
onExpand={(): void => setIsFilterExpanded(true)}
@@ -183,18 +198,18 @@ function TraceDetailsHeader({
{showTraceDetails && (
<div className={styles.subHeader}>
{traceSummary ? (
{traceMetadata ? (
<EntityMetadataRow
entity="trace"
service={{
name: traceSummary.rootServiceName,
entryPoint: traceSummary.rootServiceEntryPoint,
name: traceMetadata.rootServiceName,
entryPoint: traceMetadata.rootServiceEntryPoint,
}}
durationMs={durationMs}
timestamp={dayjs(traceSummary.startTimestampMillis).format(
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
statusCode={traceSummary.rootSpanStatusCode}
statusCode={traceMetadata.rootSpanStatusCode}
/>
) : (
<DetailsLoader />
@@ -202,7 +217,7 @@ function TraceDetailsHeader({
</div>
)}
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
<FieldsSelector
isOpen={isPreviewFieldsOpen}

View File

@@ -5,11 +5,6 @@ import ROUTES from 'constants/routes';
import { render } from 'tests/test-utils';
import TraceDetailsHeader from '../TraceDetailsHeader';
import { useTraceSummary } from '../useTraceSummary';
jest.mock('../useTraceSummary', () => ({
useTraceSummary: jest.fn(() => ({ data: undefined, isLoading: false })),
}));
const mockGoBack = jest.fn();
const mockPush = jest.fn();
@@ -56,19 +51,13 @@ jest.mock('components/FieldsSelector', () => ({
}));
const baseProps = {
filterMetadata: {
startTime: 0,
endTime: 1,
traceId: 'trace-123',
},
onFilteredSpansChange: jest.fn(),
showTraceDetailsHeaderOptions: false,
};
const SUMMARY = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000,
rootServiceName: 'frontend',
rootServiceEntryPoint: 'GET /checkout',
rootSpanStatusCode: '200',
hasMissingSpans: false,
totalSpansCount: 3,
totalErrorSpansCount: 0,
isDataLoaded: false,
};
describe('TraceDetailsHeader – back button', () => {
@@ -103,32 +92,10 @@ describe('TraceDetailsHeader – back button', () => {
describe('TraceDetailsHeader – action cluster', () => {
beforeEach(() => {
mockReplace.mockClear();
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: SUMMARY, isLoading: false });
});
afterEach(() => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: false });
});
it('does not render the action buttons until the summary loads', () => {
jest
.mocked(useTraceSummary)
.mockReturnValue({ data: undefined, isLoading: true });
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
});
it('does not render the action buttons while data is still loading', () => {
render(
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions={false} />,
);
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
@@ -139,7 +106,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
it('renders Analytics and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(
screen.getByRole('button', { name: /^analytics$/i }),
@@ -150,7 +117,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
const panel = screen.getByTestId('analytics-panel');
expect(panel).toHaveAttribute('data-open', 'false');
@@ -166,7 +133,7 @@ describe('TraceDetailsHeader – action cluster', () => {
});
describe('TraceDetailsHeader – trace metadata row', () => {
// useTraceSummary is mocked, so no API call is made.
// Plain prop, no API mock needed: traceMetadata is passed straight in.
const traceMetadata = {
startTimestampMillis: 1_700_000_000_000,
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
@@ -175,20 +142,16 @@ describe('TraceDetailsHeader – trace metadata row', () => {
rootSpanStatusCode: '404',
hasMissingSpans: false,
totalSpansCount: 42,
totalErrorSpansCount: 0,
};
const mockSummary = (data?: typeof traceMetadata): void => {
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
};
afterEach(() => {
mockSummary(undefined);
});
it('renders the metadata (service, entry point, duration, status) when provided', () => {
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
@@ -203,8 +166,13 @@ describe('TraceDetailsHeader – trace metadata row', () => {
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
const user = userEvent.setup({ delay: null });
mockSummary(traceMetadata);
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
render(
<TraceDetailsHeader
{...baseProps}
isDataLoaded
traceMetadata={traceMetadata}
/>,
);
// Visible by default (showTraceDetails defaults to true).
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
@@ -224,12 +192,9 @@ describe('TraceDetailsHeader – trace metadata row', () => {
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
});
it('shows skeletons instead of the metadata when the summary is absent', () => {
const { container } = render(
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />,
);
it('does not render the metadata row when traceMetadata is absent', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
});
});

View File

@@ -1,16 +0,0 @@
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
interface UseTraceSummaryResult {
data: SpantypesGettableTraceSummaryDTO | undefined;
isLoading: boolean;
}
export function useTraceSummary(traceId: string): UseTraceSummaryResult {
const { data, isLoading } = useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId, keepPreviousData: true } },
);
return { data: data?.data, isLoading };
}

View File

@@ -28,6 +28,7 @@ import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
@@ -322,6 +323,38 @@ function TraceDetailsV3(): JSX.Element {
[],
);
const filterMetadata = useMemo(
() => ({
startTime: (traceData?.payload?.startTimestampMillis || 0) / 1e3,
endTime: (traceData?.payload?.endTimestampMillis || 0) / 1e3,
traceId: traceId || '',
}),
[
traceData?.payload?.startTimestampMillis,
traceData?.payload?.endTimestampMillis,
traceId,
],
);
const traceMetadataForHeader = useMemo(():
| TraceMetadataForHeader
| undefined => {
const payload = traceData?.payload;
if (!payload) {
return undefined;
}
const rootSpan = payload.spans?.find((s) => s.level === 0);
return {
startTimestampMillis: payload.startTimestampMillis,
endTimestampMillis: payload.endTimestampMillis,
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
hasMissingSpans: payload.hasMissingSpans || false,
totalSpansCount: payload.totalSpansCount || 0,
};
}, [traceData?.payload]);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
@@ -360,10 +393,10 @@ function TraceDetailsV3(): JSX.Element {
<TraceStoreSync availableColorByFields={availableColorByFields}>
<div className={styles.root}>
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
showTraceDetailsHeaderOptions={
!!traceData?.payload?.spans?.length && !showNoData
}
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
traceMetadata={traceMetadataForHeader}
/>
{showNoData ? (

View File

@@ -44,7 +44,6 @@ import {
traceDetailFieldKeys,
traceDetailFieldValues,
traceFlamegraphResponse,
traceSummaryResponse,
traceWaterfallResponse,
} from './__story_mockdata__/traceDetails';
@@ -150,13 +149,6 @@ export const traceDetailsMocks = defineStoryMocks({
),
),
rest.get(
'http://localhost/api/v1/traces/:traceId/summary',
response.json(() =>
traceSummaryResponse({ ...trace, missingSpans: values.missingSpans }),
),
),
rest.post(
'http://localhost/api/v3/traces/:traceId/flamegraph',
response.json(() => traceFlamegraphResponse(trace)),

View File

@@ -6,7 +6,6 @@
import type {
GetFlamegraph200,
GetTraceAggregations200,
GetTraceSummary200,
GetWaterfallV4200,
SpantypesFlamegraphSpanDTO,
SpantypesSpanAggregationDTO,
@@ -304,27 +303,6 @@ export const traceWaterfallResponse = (
};
};
export const traceSummaryResponse = (
options: TraceOptions & { missingSpans: boolean },
): GetTraceSummary200 => {
const spans = buildSpans(options);
const root = spans[0];
return {
status: 'success',
data: {
startTimestampMillis: Math.round(options.traceStart),
endTimestampMillis: Math.round(options.traceStart + ROOT_DURATION_MS),
rootServiceName: root?.template.service ?? '',
rootServiceEntryPoint: root?.template.name ?? '',
rootSpanStatusCode: root?.hasError ? '503' : '200',
totalSpansCount: spans.length,
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
hasMissingSpans: options.missingSpans,
},
};
};
const flamegraphSpan = (span: BuiltSpan): SpantypesFlamegraphSpanDTO => ({
spanId: span.spanId,
parentSpanId: span.parentSpanId,

View File

@@ -10,42 +10,13 @@ import (
)
func (provider *provider) addPromoteRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/promoted_paths", handler.New(provider.authzMiddleware.EditAccess(provider.promoteHandler.PromotePaths), handler.OpenAPIDef{
ID: "PromotePaths",
Tags: []string{"promote"},
Summary: "Promote paths",
Description: "This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion target with its signal and context, e.g. traces/attribute.",
Request: new([]*promotetypes.PromotePath),
RequestContentType: "application/json",
RequestExamples: []handler.OpenAPIExample{
{
Name: "logs_body",
Summary: "Logs body: promote a path and index it",
Value: []map[string]any{
{
"signal": "logs",
"context": "body",
"path": "user.name",
"promote": true,
"indexes": []map[string]any{
{"fieldDataType": "string", "type": "ngrambf_v1(4, 1024, 2, 0)", "granularity": 1},
},
},
},
},
{
Name: "traces_attribute",
Summary: "Traces attribute: promote a path",
Value: []map[string]any{
{
"signal": "traces",
"context": "attribute",
"path": "http.method",
"promote": true,
},
},
},
},
if err := router.Handle("/api/v1/logs/promote_paths", handler.New(provider.authzMiddleware.EditAccess(provider.promoteHandler.HandlePromoteAndIndexPaths), handler.OpenAPIDef{
ID: "HandlePromoteAndIndexPaths",
Tags: []string{"logs"},
Summary: "Promote and index paths",
Description: "This endpoints promotes and indexes paths",
Request: new([]*promotetypes.PromotePath),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusCreated,
@@ -55,13 +26,12 @@ func (provider *provider) addPromoteRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/promoted_paths", handler.New(provider.authzMiddleware.ViewAccess(provider.promoteHandler.ListPromotedPaths), handler.OpenAPIDef{
ID: "ListPromotedPaths",
Tags: []string{"promote"},
Summary: "List promoted paths",
Description: "This endpoint lists the promoted paths of every JSON column, each annotated with its signal and context. The signal, context, promoted and indexes query parameters filter the listing.",
if err := router.Handle("/api/v1/logs/promote_paths", handler.New(provider.authzMiddleware.ViewAccess(provider.promoteHandler.ListPromotedAndIndexedPaths), handler.OpenAPIDef{
ID: "ListPromotedAndIndexedPaths",
Tags: []string{"logs"},
Summary: "Promote and index paths",
Description: "This endpoints promotes and indexes paths",
Request: nil,
RequestQuery: new(promotetypes.ListPromotedPathsFilters),
RequestContentType: "",
Response: new([]*promotetypes.PromotePath),
ResponseContentType: "",

View File

@@ -19,7 +19,7 @@ func NewHandler(module promote.Module) promote.Handler {
return &handler{module: module}
}
func (h *handler) PromotePaths(w http.ResponseWriter, r *http.Request) {
func (h *handler) HandlePromoteAndIndexPaths(w http.ResponseWriter, r *http.Request) {
// TODO(Nitya): Use in multi tenant setup
_, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
@@ -32,12 +32,8 @@ func (h *handler) PromotePaths(w http.ResponseWriter, r *http.Request) {
render.Error(w, err)
return
}
if err := promotetypes.ValidatePromotePaths(req); err != nil {
render.Error(w, err)
return
}
err = h.module.PromotePaths(r.Context(), req...)
err = h.module.PromoteAndIndexPaths(r.Context(), req...)
if err != nil {
render.Error(w, err)
return
@@ -46,7 +42,7 @@ func (h *handler) PromotePaths(w http.ResponseWriter, r *http.Request) {
render.Success(w, http.StatusCreated, nil)
}
func (h *handler) ListPromotedPaths(w http.ResponseWriter, r *http.Request) {
func (h *handler) ListPromotedAndIndexedPaths(w http.ResponseWriter, r *http.Request) {
// TODO(Nitya): Use in multi tenant setup
_, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
@@ -54,17 +50,7 @@ func (h *handler) ListPromotedPaths(w http.ResponseWriter, r *http.Request) {
return
}
var filters promotetypes.ListPromotedPathsFilters
if err := binding.Query.BindQuery(r.URL.Query(), &filters); err != nil {
render.Error(w, err)
return
}
if err := filters.Validate(); err != nil {
render.Error(w, err)
return
}
paths, err := h.module.ListPromotedPaths(r.Context(), filters)
paths, err := h.module.ListPromotedAndIndexedPaths(r.Context())
if err != nil {
render.Error(w, err)
return

View File

@@ -2,11 +2,14 @@ package implpromote
import (
"context"
"maps"
"slices"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
@@ -28,72 +31,46 @@ func NewModule(metadataStore telemetrytypes.MetadataStore, telemetrystore teleme
return &module{metadataStore: metadataStore, telemetryStore: telemetrystore}
}
func (m *module) ListPromotedPaths(ctx context.Context, filters promotetypes.ListPromotedPathsFilters) ([]promotetypes.PromotePath, error) {
response := make([]promotetypes.PromotePath, 0)
for _, target := range promotetypes.Targets() {
if !filters.MatchesTarget(target) {
continue
}
paths, err := m.listPromotedPaths(ctx, target)
if err != nil {
return nil, err
}
for _, path := range paths {
if filters.MatchesPath(path) {
response = append(response, path)
}
}
}
return response, nil
}
func (m *module) listPromotedPaths(ctx context.Context, target promotetypes.Target) ([]promotetypes.PromotePath, error) {
promotedPaths, err := m.metadataStore.GetPromotedPaths(ctx, target.Entry)
func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetypes.PromotePath, error) {
indexes, err := m.metadataStore.ListLogsJSONIndexes(ctx)
if err != nil {
return nil, err
}
response := make([]promotetypes.PromotePath, 0, len(promotedPaths))
for path := range promotedPaths {
response = append(response, promotetypes.PromotePath{
Signal: target.Entry.Signal.StringValue(),
Context: target.Entry.FieldContext.StringValue(),
Path: path,
Promote: true,
})
}
indexes, err := m.metadataStore.ListJSONIndexes(ctx, target.JSONIndexLookup())
if err != nil {
return nil, err
}
// aggr keys are full sub-column paths: index.BaseColumn carries the
// column prefix and index.Name the bare path.
aggr := map[string][]promotetypes.WrappedIndex{}
for _, index := range indexes {
fullPath := index.BaseColumn + index.Name
aggr[fullPath] = append(aggr[fullPath], promotetypes.WrappedIndex{
aggr[index.Name] = append(aggr[index.Name], promotetypes.WrappedIndex{
FieldDataType: index.FieldDataType,
Type: index.IndexType,
Granularity: index.Granularity,
})
}
for i := range response {
fullPath := target.PromotedColumnPrefix() + response[i].Path
if indexes, ok := aggr[fullPath]; ok {
response[i].Indexes = indexes
delete(aggr, fullPath)
}
promotedPaths, err := m.listPromotedPaths(ctx)
if err != nil {
return nil, err
}
for fullPath, indexes := range aggr {
path := strings.TrimPrefix(fullPath, target.BaseColumnPrefix())
path = strings.TrimPrefix(path, target.PromotedColumnPrefix())
response := []promotetypes.PromotePath{}
for _, path := range promotedPaths {
fullPath := logstelemetryschema.BodyPromotedColumnPrefix + path
path = telemetrytypes.BodyJSONStringSearchPrefix + path
item := promotetypes.PromotePath{
Path: path,
Promote: true,
}
indexes, ok := aggr[fullPath]
if ok {
item.Indexes = indexes
delete(aggr, fullPath)
}
response = append(response, item)
}
// add the paths that are not promoted but have indexes
for path, indexes := range aggr {
path := strings.TrimPrefix(path, logstelemetryschema.BodyV2ColumnPrefix)
path = telemetrytypes.BodyJSONStringSearchPrefix + path
response = append(response, promotetypes.PromotePath{
Signal: target.Entry.Signal.StringValue(),
Context: target.Entry.FieldContext.StringValue(),
Path: path,
Indexes: indexes,
})
@@ -101,32 +78,27 @@ func (m *module) listPromotedPaths(ctx context.Context, target promotetypes.Targ
return response, nil
}
func (m *module) PromotePaths(ctx context.Context, paths ...*promotetypes.PromotePath) error {
byTarget := map[promotetypes.Target][]*promotetypes.PromotePath{}
targets := []promotetypes.Target{}
for _, path := range paths {
target, err := path.Target()
if err != nil {
return err
}
if _, ok := byTarget[target]; !ok {
targets = append(targets, target)
}
byTarget[target] = append(byTarget[target], path)
func (m *module) listPromotedPaths(ctx context.Context) ([]string, error) {
paths, err := m.metadataStore.GetPromotedPaths(ctx)
if err != nil {
return nil, err
}
return slices.Collect(maps.Keys(paths)), nil
}
// PromotePaths inserts provided JSON paths into the promoted paths table for logs queries.
func (m *module) PromotePaths(ctx context.Context, paths []string) error {
if len(paths) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "paths cannot be empty")
}
for _, target := range targets {
if err := m.promotePaths(ctx, target, byTarget[target]...); err != nil {
return err
}
}
return nil
return m.metadataStore.PromotePaths(ctx, paths...)
}
// createIndexes creates string ngram + token filter indexes on JSON path subcolumns for LIKE queries.
func (m *module) createIndexes(ctx context.Context, target promotetypes.Target, indexes []schemamigrator.Index) error {
func (m *module) createIndexes(ctx context.Context, indexes []schemamigrator.Index) error {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: target.Entry.Signal.StringValue(),
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
instrumentationtypes.CodeNamespace: "promote",
instrumentationtypes.CodeFunctionName: "createIndexes",
})
@@ -136,8 +108,8 @@ func (m *module) createIndexes(ctx context.Context, target promotetypes.Target,
for _, index := range indexes {
alterStmt := schemamigrator.AlterTableAddIndex{
Database: target.DBName,
Table: target.LocalTableName,
Database: logstelemetryschema.DBName,
Table: logstelemetryschema.LogsV2LocalTableName,
Index: index,
}
op := alterStmt.OnCluster(m.telemetryStore.Cluster())
@@ -149,13 +121,25 @@ func (m *module) createIndexes(ctx context.Context, target promotetypes.Target,
return nil
}
func (m *module) promotePaths(ctx context.Context, target promotetypes.Target, paths ...*promotetypes.PromotePath) error {
// PromoteAndIndexPaths handles promoting paths and creating indexes in one call.
func (m *module) PromoteAndIndexPaths(
ctx context.Context,
paths ...*promotetypes.PromotePath,
) error {
if len(paths) == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "paths cannot be empty")
}
pathsStr := []string{}
// validate the paths
for _, path := range paths {
if err := path.ValidateAndSetDefaults(); err != nil {
return err
}
pathsStr = append(pathsStr, path.Path)
}
existingPromotedPaths, err := m.metadataStore.GetPromotedPaths(ctx, target.Entry, pathsStr...)
existingPromotedPaths, err := m.metadataStore.GetPromotedPaths(ctx, pathsStr...)
if err != nil {
return err
}
@@ -169,20 +153,27 @@ func (m *module) promotePaths(ctx context.Context, target promotetypes.Target, p
}
}
if len(it.Indexes) > 0 {
parentColumn := target.BaseColumn
parentColumn := logstelemetryschema.LogsV2BodyV2Column
// if the path is already promoted or is being promoted, add it to the promoted column
if _, promoted := existingPromotedPaths[it.Path]; promoted || it.Promote {
parentColumn = target.PromotedColumn()
parentColumn = logstelemetryschema.LogsV2BodyPromotedColumn
}
for _, index := range it.Indexes {
typeIndex, err := index.SkipIndexType()
if err != nil {
return err
var typeIndex schemamigrator.IndexType
switch {
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeNGramBF)):
typeIndex = schemamigrator.IndexTypeNGramBF
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeTokenBF)):
typeIndex = schemamigrator.IndexTypeTokenBF
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeMinMax)):
typeIndex = schemamigrator.IndexTypeMinMax
default:
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid index type: %s", index.Type)
}
indexes = append(indexes, schemamigrator.Index{
Name: schemamigrator.JSONSubColumnIndexName(parentColumn, it.Path, index.JSONDataType.StringValue(), typeIndex),
Expression: target.IndexExpression(parentColumn, it.Path, index.JSONDataType.StringValue()),
Expression: schemamigrator.JSONSubColumnIndexExpr(parentColumn, it.Path, index.JSONDataType.StringValue()),
Type: index.Type,
Granularity: index.Granularity,
})
@@ -191,14 +182,14 @@ func (m *module) promotePaths(ctx context.Context, target promotetypes.Target, p
}
if len(toInsert) > 0 {
err := m.metadataStore.PromotePaths(ctx, target.Entry, toInsert...)
err := m.PromotePaths(ctx, toInsert)
if err != nil {
return err
}
}
if len(indexes) > 0 {
if err := m.createIndexes(ctx, target, indexes); err != nil {
if err := m.createIndexes(ctx, indexes); err != nil {
return err
}
}

View File

@@ -1,410 +0,0 @@
package implpromote
import (
"context"
"regexp"
"testing"
sqlmock "github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/promotetypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
)
func TestPromotePaths(t *testing.T) {
ctx := context.Background()
testCases := []struct {
name string
paths []*promotetypes.PromotePath
promoteTwice bool
wantErr bool
wantPromoted map[telemetrytypes.Signal]map[string]bool
}{
{
name: "PromotesNewAttributes_Idempotent",
paths: []*promotetypes.PromotePath{
{Signal: "traces", Context: "attribute", Path: "http.method", Promote: true},
{Signal: "traces", Context: "attribute", Path: "span.operation", Promote: true},
},
promoteTwice: true,
wantPromoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalTraces: {"http.method": true, "span.operation": true}},
},
{
name: "MixedContexts_RecordedPerTarget",
paths: []*promotetypes.PromotePath{
{Signal: "traces", Context: "attribute", Path: "http.method", Promote: true},
{Signal: "logs", Context: "body", Path: "user.name", Promote: true},
},
wantPromoted: map[telemetrytypes.Signal]map[string]bool{
telemetrytypes.SignalTraces: {"http.method": true},
telemetrytypes.SignalLogs: {"user.name": true},
},
},
{
name: "NonPromoteEntries_NotRecorded",
paths: []*promotetypes.PromotePath{{Signal: "traces", Context: "attribute", Path: "http.method"}},
wantPromoted: map[telemetrytypes.Signal]map[string]bool{},
},
{
name: "InvalidSignal_Rejected",
paths: []*promotetypes.PromotePath{{Signal: "events", Context: "attribute", Path: "http.method", Promote: true}},
wantErr: true,
},
{
name: "UnsupportedTarget_Rejected",
paths: []*promotetypes.PromotePath{{Signal: "metrics", Context: "attribute", Path: "http.method", Promote: true}},
wantErr: true,
},
{
name: "PromotesBareBodyPath_AsIs",
paths: []*promotetypes.PromotePath{{Signal: "logs", Context: "body", Path: "user.name", Promote: true}},
wantPromoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalLogs: {"user.name": true}},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
store := telemetrytypestest.NewMockMetadataStore()
m := NewModule(store, nil)
err := m.PromotePaths(ctx, testCase.paths...)
if testCase.wantErr {
assert.Error(t, err)
assert.Empty(t, store.PromotedPathsMap)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.wantPromoted, store.PromotedPathsMap)
if testCase.promoteTwice {
require.NoError(t, m.PromotePaths(ctx, testCase.paths...))
assert.Equal(t, testCase.wantPromoted, store.PromotedPathsMap)
}
})
}
}
func TestPromotePathsCreatesIndexes(t *testing.T) {
ctx := context.Background()
testCases := []struct {
name string
promoted map[string]bool
path *promotetypes.PromotePath
wantDDLColumn string
}{
{
name: "LogsNewPromotion_IndexesPromotedColumn",
path: &promotetypes.PromotePath{
Signal: "logs",
Context: "body",
Path: "user.name",
Promote: true,
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "dynamicElement(body_promoted.user.name",
},
{
name: "LogsAlreadyPromoted_IndexesPromotedColumn",
promoted: map[string]bool{"user.name": true},
path: &promotetypes.PromotePath{
Signal: "logs",
Context: "body",
Path: "user.name",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "dynamicElement(body_promoted.user.name",
},
{
name: "LogsUnpromotedPath_IndexesBaseColumn",
path: &promotetypes.PromotePath{
Signal: "logs",
Context: "body",
Path: "user.name",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "dynamicElement(body_v2.user.name",
},
{
name: "TracesNewPromotion_IndexesPromotedColumn",
path: &promotetypes.PromotePath{
Signal: "traces",
Context: "attribute",
Path: "http.method",
Promote: true,
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`attributes_promoted.http.method_String_ngrambf_v1` attributes_promoted.`http.method`::String",
},
{
name: "TracesUnpromotedPath_IndexesBaseColumn",
path: &promotetypes.PromotePath{
Signal: "traces",
Context: "attribute",
Path: "http.method",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`attributes.http.method_String_ngrambf_v1` attributes.`http.method`::String",
},
{
name: "BloomFilterIndex_FullTypeInDDL",
path: &promotetypes.PromotePath{
Signal: "logs",
Context: "body",
Path: "user.name",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter(0.01)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`body_v2.user.name_String_bloom_filter` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE bloom_filter(0.01)",
},
{
name: "SetIndex_FullTypeInDDL",
path: &promotetypes.PromotePath{
Signal: "logs",
Context: "body",
Path: "user.name",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "set(100)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`body_v2.user.name_String_set` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE set(100)",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
ts := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
store := telemetrytypestest.NewMockMetadataStore()
store.PromotedPathsMap[telemetrytypes.SignalLogs] = testCase.promoted
m := NewModule(store, ts)
ts.Mock().ExpectExec("ADD INDEX (.+)" + regexp.QuoteMeta(testCase.wantDDLColumn)).WillReturnError(nil)
require.NoError(t, m.PromotePaths(ctx, testCase.path))
assert.NoError(t, ts.Mock().ExpectationsWereMet())
})
}
}
func TestListPromotedPaths(t *testing.T) {
ctx := context.Background()
trueValue := true
falseValue := false
testCases := []struct {
name string
filters promotetypes.ListPromotedPathsFilters
promoted map[telemetrytypes.Signal]map[string]bool
indexes []telemetrytypes.TelemetryFieldKeySkipIndex
wantPaths []promotetypes.PromotePath
}{
{
name: "PromotedPaths_ListedPerTarget",
promoted: map[telemetrytypes.Signal]map[string]bool{
telemetrytypes.SignalLogs: {"user.name": true},
telemetrytypes.SignalTraces: {"http.method": true},
},
wantPaths: []promotetypes.PromotePath{
{Signal: "logs", Context: "body", Path: "user.name", Promote: true},
{Signal: "traces", Context: "attribute", Path: "http.method", Promote: true},
},
},
{
name: "SignalFilter_SkipsOtherContexts",
filters: promotetypes.ListPromotedPathsFilters{Signal: "traces"},
promoted: map[telemetrytypes.Signal]map[string]bool{
telemetrytypes.SignalLogs: {"user.name": true},
telemetrytypes.SignalTraces: {"http.method": true},
},
wantPaths: []promotetypes.PromotePath{{Signal: "traces", Context: "attribute", Path: "http.method", Promote: true}},
},
{
name: "ContextFilter_SkipsOtherContexts",
filters: promotetypes.ListPromotedPathsFilters{Context: "body"},
promoted: map[telemetrytypes.Signal]map[string]bool{
telemetrytypes.SignalLogs: {"user.name": true},
telemetrytypes.SignalTraces: {"http.method": true},
},
wantPaths: []promotetypes.PromotePath{{Signal: "logs", Context: "body", Path: "user.name", Promote: true}},
},
{
name: "ContextAliasFilter_MatchesTarget",
filters: promotetypes.ListPromotedPathsFilters{Signal: "traces", Context: "tag"},
promoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalTraces: {"http.method": true}},
wantPaths: []promotetypes.PromotePath{{Signal: "traces", Context: "attribute", Path: "http.method", Promote: true}},
},
{
name: "LogsIndexes_MergedWithLogsPaths",
promoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalLogs: {"user.name": true}},
indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
{
Name: "user.name",
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: telemetrytypes.FieldDataTypeString,
BaseColumn: "body_promoted.",
IndexType: "ngrambf_v1(4, 1024, 2, 0)",
Granularity: 1,
},
{
Name: "request.duration",
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
BaseColumn: "body_v2.",
IndexType: "minmax",
Granularity: 1,
},
},
wantPaths: []promotetypes.PromotePath{
{
Signal: "logs",
Context: "body",
Path: "user.name",
Promote: true,
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
},
},
{
Signal: "logs",
Context: "body",
Path: "request.duration",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeFloat64, Type: "minmax", Granularity: 1},
},
},
},
},
{
name: "PromotedFalseFilter_IndexOnlyPaths",
filters: promotetypes.ListPromotedPathsFilters{Promoted: &falseValue},
promoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalLogs: {"user.name": true}},
indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
{
Name: "request.duration",
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
BaseColumn: "body_v2.",
IndexType: "minmax",
Granularity: 1,
},
},
wantPaths: []promotetypes.PromotePath{
{
Signal: "logs",
Context: "body",
Path: "request.duration",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeFloat64, Type: "minmax", Granularity: 1},
},
},
},
},
{
name: "IndexesTrueFilter_PathsWithIndexes",
filters: promotetypes.ListPromotedPathsFilters{Indexes: &trueValue},
promoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalLogs: {"user.name": true}},
indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
{
Name: "user.name",
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: telemetrytypes.FieldDataTypeString,
BaseColumn: "body_promoted.",
IndexType: "ngrambf_v1(4, 1024, 2, 0)",
Granularity: 1,
},
},
wantPaths: []promotetypes.PromotePath{
{
Signal: "logs",
Context: "body",
Path: "user.name",
Promote: true,
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
},
},
},
},
{
name: "TracesIndexes_MergedWithTracesPaths",
promoted: map[telemetrytypes.Signal]map[string]bool{telemetrytypes.SignalTraces: {"http.method": true}},
indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{
{
Name: "http.method",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
BaseColumn: "attributes_promoted.",
IndexType: "ngrambf_v1(4, 1024, 2, 0)",
Granularity: 1,
},
{
Name: "http.status_code",
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
BaseColumn: "attributes.",
IndexType: "minmax",
Granularity: 1,
},
},
wantPaths: []promotetypes.PromotePath{
{
Signal: "traces",
Context: "attribute",
Path: "http.method",
Promote: true,
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
},
},
{
Signal: "traces",
Context: "attribute",
Path: "http.status_code",
Indexes: []promotetypes.WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeFloat64, Type: "minmax", Granularity: 1},
},
},
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
store := telemetrytypestest.NewMockMetadataStore()
store.PromotedPathsMap = testCase.promoted
store.LogsJSONIndexes = testCase.indexes
m := NewModule(store, nil)
paths, err := m.ListPromotedPaths(ctx, testCase.filters)
require.NoError(t, err)
require.Len(t, paths, len(testCase.wantPaths))
byContextPath := map[string]promotetypes.PromotePath{}
for _, path := range paths {
byContextPath[path.Signal+"/"+path.Context+"/"+path.Path] = path
}
for _, want := range testCase.wantPaths {
key := want.Signal + "/" + want.Context + "/" + want.Path
require.Contains(t, byContextPath, key)
assert.Equal(t, want, byContextPath[key])
}
})
}
}

View File

@@ -8,11 +8,11 @@ import (
)
type Module interface {
ListPromotedPaths(ctx context.Context, filters promotetypes.ListPromotedPathsFilters) ([]promotetypes.PromotePath, error)
PromotePaths(ctx context.Context, paths ...*promotetypes.PromotePath) error
ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetypes.PromotePath, error)
PromoteAndIndexPaths(ctx context.Context, paths ...*promotetypes.PromotePath) error
}
type Handler interface {
PromotePaths(w http.ResponseWriter, r *http.Request)
ListPromotedPaths(w http.ResponseWriter, r *http.Request)
HandlePromoteAndIndexPaths(w http.ResponseWriter, r *http.Request)
ListPromotedAndIndexedPaths(w http.ResponseWriter, r *http.Request)
}

View File

@@ -5,18 +5,17 @@ import (
"fmt"
"log/slog"
"reflect"
"regexp"
"strings"
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz-otel-collector/constants"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
"github.com/SigNoz/signoz/pkg/types/promotetypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
)
@@ -34,26 +33,6 @@ var (
CodeFailedToAppendPath = errors.MustNewCode("failed_to_append_path_promoted_paths")
)
var logsBodyPromotedEntry = promotetypes.NewLogsBodyTarget().Entry
var logsBodyIndexLookup = promotetypes.NewLogsBodyTarget().JSONIndexLookup()
// ClickHouse stores a `col.path::Type` skip index expression as CAST(col.path, 'Type').
var simpleJSONSubColumnIndexExprRe = regexp.MustCompile(`^CAST\((?P<expr>[^()]+), '(?P<type>.+)'\)$`)
// unfoldJSONSubColumnIndexExpr accepts both the folded (lower/assumeNotNull)
// and the bare type-cast expression forms.
func unfoldJSONSubColumnIndexExpr(expr string) (string, string, error) {
if columnExpr, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(expr); err == nil {
return columnExpr, columnType, nil
}
matches := simpleJSONSubColumnIndexExprRe.FindStringSubmatch(expr)
if matches == nil {
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid expression: %s", expr)
}
return matches[1], matches[2], nil
}
// enrichJSONKeys enriches body-context keys with promoted path info, indexes,
// and JSON access plans. parentTypeCache contains parent array types (ArrayJSON/ArrayDynamic)
// pre-fetched in the main UNION query.
@@ -88,7 +67,7 @@ func (t *telemetryMetaStore) enrichJSONKeys(ctx context.Context, selectors []*te
}
// fetch promoted paths
promoted, err := t.GetPromotedPaths(ctx, logsBodyPromotedEntry, paths...)
promoted, err := t.GetPromotedPaths(ctx, paths...)
if err != nil {
return err
}
@@ -141,7 +120,7 @@ func (t *telemetryMetaStore) getJSONPathIndexes(ctx context.Context, paths ...st
}
// list indexes for the paths
indexes, err := t.ListJSONIndexes(ctx, logsBodyIndexLookup, filteredPaths...)
indexes, err := t.ListLogsJSONIndexes(ctx, filteredPaths...)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to list JSON path indexes")
}
@@ -155,16 +134,16 @@ func (t *telemetryMetaStore) getJSONPathIndexes(ctx context.Context, paths ...st
return fieldPathToIndexes, nil
}
func buildListJSONIndexesQuery(cluster string, lookup telemetrytypes.JSONIndexLookup, filters ...string) (string, []any) {
func buildListLogsJSONIndexesQuery(cluster string, filters ...string) (string, []any) {
sb := sqlbuilder.Select(
"name", "type_full", "expr", "granularity",
).From(fmt.Sprintf("clusterAllReplicas('%s', %s)", cluster, SkipIndexTableName))
sb.Where(sb.Equal("database", lookup.DBName))
sb.Where(sb.Equal("table", lookup.LocalTableName))
sb.Where(sb.Equal("database", logstelemetryschema.DBName))
sb.Where(sb.Equal("table", logstelemetryschema.LogsV2LocalTableName))
sb.Where(sb.Or(
sb.ILike("expr", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(lookup.BaseColumnPrefix))),
sb.ILike("expr", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(lookup.PromotedColumnPrefix))),
sb.ILike("expr", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(constants.BodyV2ColumnPrefix))),
sb.ILike("expr", fmt.Sprintf("%%%s%%", querybuilder.FormatValueForContains(constants.BodyPromotedColumnPrefix))),
))
filterExprs := []string{}
@@ -177,9 +156,9 @@ func buildListJSONIndexesQuery(cluster string, lookup telemetrytypes.JSONIndexLo
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
func (t *telemetryMetaStore) ListJSONIndexes(ctx context.Context, lookup telemetrytypes.JSONIndexLookup, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
ctx = withTelemetryContext(ctx, lookup.Signal, "ListJSONIndexes")
query, args := buildListJSONIndexesQuery(t.telemetrystore.Cluster(), lookup, filters...)
func (t *telemetryMetaStore) ListLogsJSONIndexes(ctx context.Context, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
ctx = withTelemetryContext(ctx, "ListLogsJSONIndexes")
query, args := buildListLogsJSONIndexesQuery(t.telemetrystore.Cluster(), filters...)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to load string indexed columns")
@@ -196,7 +175,7 @@ func (t *telemetryMetaStore) ListJSONIndexes(ctx context.Context, lookup telemet
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to scan string indexed column")
}
columnExpr, columnType, err := unfoldJSONSubColumnIndexExpr(expr)
columnExpr, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(expr)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to unfold JSON sub column index expression: %s", expr)
}
@@ -210,18 +189,18 @@ func (t *telemetryMetaStore) ListJSONIndexes(ctx context.Context, lookup telemet
baseColumn := ""
fieldName := ""
switch {
case strings.HasPrefix(columnExpr, lookup.BaseColumnPrefix):
baseColumn = lookup.BaseColumnPrefix
fieldName = strings.TrimPrefix(columnExpr, lookup.BaseColumnPrefix)
case strings.HasPrefix(columnExpr, lookup.PromotedColumnPrefix):
baseColumn = lookup.PromotedColumnPrefix
fieldName = strings.TrimPrefix(columnExpr, lookup.PromotedColumnPrefix)
case strings.HasPrefix(columnExpr, logstelemetryschema.BodyV2ColumnPrefix):
baseColumn = logstelemetryschema.BodyV2ColumnPrefix
fieldName = strings.TrimPrefix(columnExpr, logstelemetryschema.BodyV2ColumnPrefix)
case strings.HasPrefix(columnExpr, logstelemetryschema.BodyPromotedColumnPrefix):
baseColumn = logstelemetryschema.BodyPromotedColumnPrefix
fieldName = strings.TrimPrefix(columnExpr, logstelemetryschema.BodyPromotedColumnPrefix)
}
fieldName = strings.ReplaceAll(fieldName, "`", "")
indexes = append(indexes, telemetrytypes.TelemetryFieldKeySkipIndex{
Name: fieldName,
FieldContext: lookup.FieldContext,
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: fdt,
BaseColumn: baseColumn,
IndexName: name,
@@ -236,14 +215,14 @@ func (t *telemetryMetaStore) ListJSONIndexes(ctx context.Context, lookup telemet
// TODO(Piyush): Remove this if not used in future.
func (t *telemetryMetaStore) ListJSONValues(ctx context.Context, path string, limit int) (*telemetrytypes.TelemetryFieldValues, bool, error) {
ctx = withTelemetryContext(ctx, telemetrytypes.SignalLogs, "ListJSONValues")
ctx = withTelemetryContext(ctx, "ListJSONValues")
path = CleanPathPrefixes(path)
if strings.Contains(path, telemetrytypes.ArraySep) || strings.Contains(path, telemetrytypes.ArrayAnyIndex) {
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput, "array paths are not supported")
}
promoted, err := t.isPathPromoted(ctx, logsBodyPromotedEntry, path)
promoted, err := t.IsPathPromoted(ctx, path)
if err != nil {
return nil, false, err
}
@@ -397,12 +376,13 @@ func derefValue(v any) any {
return val.Interface()
}
func (t *telemetryMetaStore) isPathPromoted(ctx context.Context, entry telemetrytypes.EvolutionEntry, path string) (bool, error) {
ctx = withTelemetryContext(ctx, entry.Signal, "isPathPromoted")
// IsPathPromoted checks if a specific path is promoted (Column Evolution table: field_name for logs body).
func (t *telemetryMetaStore) IsPathPromoted(ctx context.Context, path string) (bool, error) {
ctx = withTelemetryContext(ctx, "IsPathPromoted")
split := strings.Split(path, telemetrytypes.ArraySep)
pathSegment := split[0]
query := fmt.Sprintf("SELECT 1 FROM %s.%s WHERE signal = ? AND column_name = ? AND field_context = ? AND field_name = ? LIMIT 1", DBName, PromotedPathsTableName)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, entry.Signal, entry.ColumnName, entry.FieldContext, pathSegment)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, telemetrytypes.SignalLogs, logstelemetryschema.LogsV2BodyPromotedColumn, telemetrytypes.FieldContextBody, pathSegment)
if err != nil {
return false, errors.WrapInternalf(err, CodeFailCheckPathPromoted, "failed to check if path %s is promoted", path)
}
@@ -411,13 +391,14 @@ func (t *telemetryMetaStore) isPathPromoted(ctx context.Context, entry telemetry
return rows.Next(), nil
}
func (t *telemetryMetaStore) GetPromotedPaths(ctx context.Context, entry telemetrytypes.EvolutionEntry, paths ...string) (map[string]bool, error) {
ctx = withTelemetryContext(ctx, entry.Signal, "GetPromotedPaths")
// GetPromotedPaths returns promoted paths from the Column Evolution table (field_name for logs body).
func (t *telemetryMetaStore) GetPromotedPaths(ctx context.Context, paths ...string) (map[string]bool, error) {
ctx = withTelemetryContext(ctx, "GetPromotedPaths")
sb := sqlbuilder.Select("field_name").From(fmt.Sprintf("%s.%s", DBName, PromotedPathsTableName))
conditions := []string{
sb.Equal("signal", entry.Signal),
sb.Equal("column_name", entry.ColumnName),
sb.Equal("field_context", entry.FieldContext),
sb.Equal("signal", telemetrytypes.SignalLogs),
sb.Equal("column_name", logstelemetryschema.LogsV2BodyPromotedColumn),
sb.Equal("field_context", telemetrytypes.FieldContextBody),
sb.NotEqual("field_name", "__all__"),
}
if len(paths) > 0 {
@@ -457,8 +438,9 @@ func CleanPathPrefixes(path string) string {
return path
}
func (t *telemetryMetaStore) PromotePaths(ctx context.Context, entry telemetrytypes.EvolutionEntry, paths ...string) error {
ctx = withTelemetryContext(ctx, entry.Signal, "PromotePaths")
// PromotePaths inserts promoted paths into the Column Evolution table (same schema as signoz-otel-collector metadata_migrations).
func (t *telemetryMetaStore) PromotePaths(ctx context.Context, paths ...string) error {
ctx = withTelemetryContext(ctx, "PromotePaths")
batch, err := t.telemetrystore.ClickhouseDB().PrepareBatch(ctx,
fmt.Sprintf("INSERT INTO %s.%s (signal, column_name, column_type, field_context, field_name, version, release_time) VALUES", DBName,
PromotedPathsTableName))
@@ -472,7 +454,7 @@ func (t *telemetryMetaStore) PromotePaths(ctx context.Context, entry telemetryty
if trimmed == "" {
continue
}
if err := batch.Append(entry.Signal, entry.ColumnName, entry.ColumnType, entry.FieldContext, trimmed, entry.Version, releaseTime); err != nil {
if err := batch.Append(telemetrytypes.SignalLogs, logstelemetryschema.LogsV2BodyPromotedColumn, "JSON()", telemetrytypes.FieldContextBody, trimmed, 0, releaseTime); err != nil {
_ = batch.Abort()
return errors.WrapInternalf(err, CodeFailedToAppendPath, "failed to append path")
}
@@ -484,9 +466,9 @@ func (t *telemetryMetaStore) PromotePaths(ctx context.Context, entry telemetryty
return nil
}
func withTelemetryContext(ctx context.Context, signal telemetrytypes.Signal, functionName string) context.Context {
func withTelemetryContext(ctx context.Context, functionName string) context.Context {
return ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: signal.StringValue(),
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
instrumentationtypes.CodeNamespace: "metadata",
instrumentationtypes.CodeFunctionName: functionName,
})

View File

@@ -2,13 +2,11 @@ package telemetrymetadata
import (
"fmt"
"strings"
"testing"
"github.com/SigNoz/signoz-otel-collector/constants"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/types/promotetypes"
"github.com/stretchr/testify/require"
)
@@ -52,106 +50,10 @@ func TestBuildListLogsJSONIndexesQuery(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
query, args := buildListJSONIndexesQuery(tc.cluster, logsBodyIndexLookup, tc.filters...)
query, args := buildListLogsJSONIndexesQuery(tc.cluster, tc.filters...)
require.Equal(t, tc.expectedSQL, query)
require.Equal(t, tc.expectedArgs, args)
})
}
}
func TestUnfoldJSONSubColumnIndexExpr(t *testing.T) {
testCases := []struct {
name string
expr string
wantColumn string
wantType string
wantErr bool
}{
{
name: "LogsFoldedLower",
expr: "lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String')))",
wantColumn: "body_v2.user.name",
wantType: "String",
},
{
name: "LogsAssumeNotNullOnly",
expr: "assumeNotNull(dynamicElement(body_v2.request.duration, 'Float64'))",
wantColumn: "body_v2.request.duration",
wantType: "Float64",
},
{
name: "TracesCastDottedKey",
expr: "CAST(attributes.`http.method`, 'String')",
wantColumn: "attributes.`http.method`",
wantType: "String",
},
{
name: "TracesCastPlainKey",
expr: "CAST(attributes_promoted.status, 'Int64')",
wantColumn: "attributes_promoted.status",
wantType: "Int64",
},
{
name: "UnrecognizedForm",
expr: "toString(attributes.status)",
wantErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
columnExpr, columnType, err := unfoldJSONSubColumnIndexExpr(tc.expr)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tc.wantColumn, columnExpr)
require.Equal(t, tc.wantType, columnType)
})
}
}
// ClickHouse stores a generated col.`path`::Type trace index expression as
// CAST(col.`path`, 'Type'), dropping quoting an identifier does not need; the
// listing must parse that stored form back into the original path.
func TestUnfoldJSONSubColumnIndexExprTracesRoundTrip(t *testing.T) {
testCases := []struct {
name string
column string
path string
jsonDataType string
wantExpr string
storedExpr string
}{
{
name: "DottedKey_BackticksPreservedInStorage",
column: "attributes",
path: "http.method",
jsonDataType: "String",
wantExpr: "attributes.`http.method`::String",
storedExpr: "CAST(attributes.`http.method`, 'String')",
},
{
name: "PlainKey_UnquotedInStorage",
column: "attributes_promoted",
path: "status",
jsonDataType: "Int64",
wantExpr: "attributes_promoted.`status`::Int64",
storedExpr: "CAST(attributes_promoted.status, 'Int64')",
},
}
target := promotetypes.NewTracesAttributesTarget()
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.wantExpr, target.IndexExpression(tc.column, tc.path, tc.jsonDataType))
columnExpr, columnType, err := unfoldJSONSubColumnIndexExpr(tc.storedExpr)
require.NoError(t, err)
require.Equal(t, tc.column+"."+tc.path, strings.ReplaceAll(columnExpr, "`", ""))
require.Equal(t, tc.jsonDataType, columnType)
})
}
}

View File

@@ -1,146 +0,0 @@
package promotetypes
import (
"fmt"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/clickhousesql"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// Target identifies a promotion context.
type Target struct {
Entry telemetrytypes.EvolutionEntry // evolution row template; FieldName and ReleaseTime are set per write
DBName string // index DDL database
LocalTableName string // index DDL local table
BaseColumn string // column holding every path; indexes for unpromoted paths are created on it
}
func (t Target) PromotedColumn() string { return t.Entry.ColumnName }
func (t Target) BaseColumnPrefix() string { return t.BaseColumn + "." }
func (t Target) PromotedColumnPrefix() string { return t.PromotedColumn() + "." }
// IndexExpression folds logs strings to lower case over assumeNotNull for
// case-insensitive LIKE searches; traces indexes are a bare type cast.
func (t Target) IndexExpression(column, path, jsonDataType string) string {
switch t.Entry.Signal {
case telemetrytypes.SignalLogs:
return schemamigrator.JSONSubColumnIndexExpr(column, path, jsonDataType)
default:
return simpleJSONSubColumnIndexExpr(column, path, jsonDataType)
}
}
func (t Target) JSONIndexLookup() telemetrytypes.JSONIndexLookup {
return telemetrytypes.JSONIndexLookup{
Signal: t.Entry.Signal,
FieldContext: t.Entry.FieldContext,
DBName: t.DBName,
LocalTableName: t.LocalTableName,
BaseColumnPrefix: t.BaseColumnPrefix(),
PromotedColumnPrefix: t.PromotedColumnPrefix(),
}
}
func NewTarget(entry telemetrytypes.EvolutionEntry, dbName, localTableName, baseColumn string) Target {
return Target{
Entry: entry,
DBName: dbName,
LocalTableName: localTableName,
BaseColumn: baseColumn,
}
}
// NewLogsBodyTarget returns the logs body context (body_v2 -> body_promoted).
func NewLogsBodyTarget() Target {
return NewTarget(
telemetrytypes.EvolutionEntry{
Signal: telemetrytypes.SignalLogs,
ColumnName: logstelemetryschema.LogsV2BodyPromotedColumn,
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextBody,
},
logstelemetryschema.DBName,
logstelemetryschema.LogsV2LocalTableName,
logstelemetryschema.LogsV2BodyV2Column,
)
}
// NewTracesAttributesTarget returns the spans attributes context (attributes
// -> attributes_promoted).
func NewTracesAttributesTarget() Target {
return NewTarget(
telemetrytypes.EvolutionEntry{
Signal: telemetrytypes.SignalTraces,
ColumnName: tracestelemetryschema.SpanAttributesPromotedColumn,
ColumnType: "JSON()",
FieldContext: telemetrytypes.FieldContextAttribute,
},
tracestelemetryschema.DBName,
tracestelemetryschema.SpanIndexV3LocalTableName,
tracestelemetryschema.SpanAttributesColumn,
)
}
func NewTargetFromText(signal, context string) (Target, error) {
parsedSignal, ok := telemetrytypes.SignalFromText(signal)
if !ok {
return Target{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid signal: %s", signal)
}
parsedContext, ok := telemetrytypes.FieldContextFromText(context)
if !ok {
return Target{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid context: %s", context)
}
target, ok := TargetFor(parsedSignal, parsedContext)
if !ok {
return Target{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "promotion is not supported for %s %s", parsedSignal.StringValue(), parsedContext.StringValue())
}
return target, nil
}
func Targets() []Target {
return []Target{
NewLogsBodyTarget(),
NewTracesAttributesTarget(),
}
}
func TargetFor(signal telemetrytypes.Signal, context telemetrytypes.FieldContext) (Target, bool) {
for _, target := range Targets() {
if target.Entry.Signal.StringValue() == signal.StringValue() &&
target.Entry.FieldContext.StringValue() == context.StringValue() {
return target, true
}
}
return Target{}, false
}
// simpleJSONSubColumnIndexExpr renders column.`path`::Type with the path
// quoted as one identifier, the form trace queries read the sub-column with;
// the cast unwraps the Nullable the sub-column access returns, which bloom
// filter indexes reject.
func simpleJSONSubColumnIndexExpr(column, path, jsonDataType string) string {
return fmt.Sprintf("%s.%s::%s", column, clickhousesql.Identifier(path), jsonDataType)
}
// reservedPathPrefix returns the target prefix path carries, if any: the base
// or promoted column prefix, or the logs body search alias, which is stripped
// from queries before metadata lookup and can never match.
func (t Target) reservedPathPrefix(path string) (string, bool) {
prefixes := []string{t.BaseColumnPrefix(), t.PromotedColumnPrefix()}
if t.Entry.Signal == telemetrytypes.SignalLogs {
prefixes = append(prefixes, telemetrytypes.BodyJSONStringSearchPrefix)
}
for _, prefix := range prefixes {
if strings.HasPrefix(path, prefix) {
return prefix, true
}
}
return "", false
}

View File

@@ -1,57 +0,0 @@
package promotetypes
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIndexExpression(t *testing.T) {
testCases := []struct {
name string
target Target
column string
path string
jsonDataType string
want string
}{
{
name: "LogsString_LoweredOverAssumeNotNull",
target: NewLogsBodyTarget(),
column: "body_promoted",
path: "user.name",
jsonDataType: "String",
want: "lower(assumeNotNull(dynamicElement(body_promoted.user.name, 'String')))",
},
{
name: "LogsNumber_AssumeNotNullOnly",
target: NewLogsBodyTarget(),
column: "body_v2",
path: "request.duration",
jsonDataType: "Float64",
want: "assumeNotNull(dynamicElement(body_v2.request.duration, 'Float64'))",
},
{
name: "TracesString_TypeCastOnly",
target: NewTracesAttributesTarget(),
column: "attributes_promoted",
path: "http.method",
jsonDataType: "String",
want: "attributes_promoted.`http.method`::String",
},
{
name: "TracesPathNeedingBackticks_Backticked",
target: NewTracesAttributesTarget(),
column: "attributes",
path: "user-name",
jsonDataType: "String",
want: "attributes.`user-name`::String",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, testCase.target.IndexExpression(testCase.column, testCase.path, testCase.jsonDataType))
})
}
}

View File

@@ -1,30 +1,14 @@
package promotetypes
import (
"regexp"
"strconv"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz-otel-collector/constants"
"github.com/SigNoz/signoz-otel-collector/pkg/keycheck"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// maxPromotePathsPerRequest caps a promote batch so one request cannot flood
// the cluster with index DDL.
const maxPromotePathsPerRequest = 100
const (
maxIndexNGramLength = 64
maxIndexBloomFilterBytes = 1 << 20
maxIndexHashFunctions = 64
)
// indexTypeRe anchors the whole index type string, so only a whitelisted type
// with bounded numeric parameters can reach the index DDL.
var indexTypeRe = regexp.MustCompile(`^(?:minmax|set\(\s*(\d{1,7})\s*\)|bloom_filter(?:\(\s*(\d+(?:\.\d+)?|\.\d+)\s*\))?|tokenbf_v1\(\s*(\d{1,7})\s*,\s*(\d{1,2})\s*,\s*(\d{1,10})\s*\)|ngrambf_v1\(\s*(\d{1,2})\s*,\s*(\d{1,7})\s*,\s*(\d{1,2})\s*,\s*(\d{1,10})\s*\))$`)
type WrappedIndex struct {
JSONDataType telemetrytypes.JSONDataType `json:"-"`
FieldDataType telemetrytypes.FieldDataType `json:"fieldDataType"`
@@ -33,66 +17,13 @@ type WrappedIndex struct {
}
type PromotePath struct {
Signal string `json:"signal" required:"true"`
Context string `json:"context" required:"true"`
Path string `json:"path" required:"true"`
Path string `json:"path"`
Promote bool `json:"promote,omitempty"`
Indexes []WrappedIndex `json:"indexes,omitempty"`
}
func (i *PromotePath) Target() (Target, error) {
return NewTargetFromText(i.Signal, i.Context)
}
type ListPromotedPathsFilters struct {
Signal string `query:"signal" json:"signal"`
Context string `query:"context" json:"context"`
Promoted *bool `query:"promoted" json:"promoted"`
Indexes *bool `query:"indexes" json:"indexes"`
}
// Validate checks the signal and context words are known; the pair need not
// name a supported target.
func (f *ListPromotedPathsFilters) Validate() error {
if f.Signal != "" {
if _, ok := telemetrytypes.SignalFromText(f.Signal); !ok {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid signal: %s", f.Signal)
}
}
if f.Context != "" {
if _, ok := telemetrytypes.FieldContextFromText(f.Context); !ok {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid context: %s", f.Context)
}
}
return nil
}
func (f *ListPromotedPathsFilters) MatchesTarget(target Target) bool {
if f.Signal != "" {
if signal, ok := telemetrytypes.SignalFromText(f.Signal); !ok || signal != target.Entry.Signal {
return false
}
}
if f.Context != "" {
if fieldContext, ok := telemetrytypes.FieldContextFromText(f.Context); !ok || fieldContext != target.Entry.FieldContext {
return false
}
}
return true
}
func (f *ListPromotedPathsFilters) MatchesPath(path PromotePath) bool {
if f.Promoted != nil && *f.Promoted != path.Promote {
return false
}
if f.Indexes != nil && *f.Indexes != (len(path.Indexes) > 0) {
return false
}
return true
}
func (i *PromotePath) ValidateAndSetDefaults(target Target) error {
func (i *PromotePath) ValidateAndSetDefaults() error {
if i.Path == "" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "path is required")
}
@@ -105,10 +36,17 @@ func (i *PromotePath) ValidateAndSetDefaults(target Target) error {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "array paths can not be promoted or indexed")
}
if prefix, ok := target.reservedPathPrefix(i.Path); ok {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "path must be a bare attribute name, without the `%s` prefix", prefix)
if strings.HasPrefix(i.Path, constants.BodyV2ColumnPrefix) || strings.HasPrefix(i.Path, constants.BodyPromotedColumnPrefix) {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "`%s`, `%s` don't add these prefixes to the path", constants.BodyV2ColumnPrefix, constants.BodyPromotedColumnPrefix)
}
if !strings.HasPrefix(i.Path, telemetrytypes.BodyJSONStringSearchPrefix) {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "path must start with `body.`")
}
// remove the "body." prefix from the path
i.Path = strings.TrimPrefix(i.Path, telemetrytypes.BodyJSONStringSearchPrefix)
isCardinal := keycheck.IsCardinal(i.Path)
if isCardinal {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cardinal paths can not be promoted or indexed")
@@ -118,9 +56,6 @@ func (i *PromotePath) ValidateAndSetDefaults(target Target) error {
if index.Type == "" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index type is required")
}
if err := validateIndexType(index.Type); err != nil {
return err
}
if index.Granularity <= 0 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index granularity must be greater than 0")
}
@@ -138,87 +73,3 @@ func (i *PromotePath) ValidateAndSetDefaults(target Target) error {
return nil
}
func (index WrappedIndex) SkipIndexType() (schemamigrator.IndexType, error) {
switch {
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeNGramBF)):
return schemamigrator.IndexTypeNGramBF, nil
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeTokenBF)):
return schemamigrator.IndexTypeTokenBF, nil
case strings.HasPrefix(index.Type, string(schemamigrator.IndexTypeMinMax)):
return schemamigrator.IndexTypeMinMax, nil
case strings.HasPrefix(index.Type, "bloom_filter"):
return "bloom_filter", nil
case strings.HasPrefix(index.Type, "set"):
return "set", nil
default:
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid index type: %s", index.Type)
}
}
func ValidatePromotePaths(paths []*PromotePath) error {
if len(paths) == 0 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "paths cannot be empty")
}
if len(paths) > maxPromotePathsPerRequest {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot promote more than %d paths in one request", maxPromotePathsPerRequest)
}
for _, path := range paths {
if path == nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "path cannot be null")
}
target, err := path.Target()
if err != nil {
return err
}
if err := path.ValidateAndSetDefaults(target); err != nil {
return err
}
}
return nil
}
func validateIndexType(indexType string) error {
matches := indexTypeRe.FindStringSubmatch(indexType)
if matches == nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid index type: %s", indexType)
}
switch {
case indexType == "minmax" || strings.HasPrefix(indexType, "set"):
return nil
case strings.HasPrefix(indexType, "bloom_filter"):
if matches[2] == "" {
return nil
}
falsePositive, _ := strconv.ParseFloat(matches[2], 64)
if falsePositive <= 0 || falsePositive >= 1 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid false positive rate in index type: %s", indexType)
}
return nil
}
params := matches[3:6]
if strings.HasPrefix(indexType, "ngrambf_v1") {
params = matches[6:10]
}
values := make([]uint64, len(params))
for idx, param := range params {
values[idx], _ = strconv.ParseUint(param, 10, 64)
}
bloomBytes, hashes := 0, 1
if len(values) == 4 {
if values[0] < 1 || values[0] > maxIndexNGramLength {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid n-gram length in index type: %s", indexType)
}
bloomBytes, hashes = 1, 2
}
if values[bloomBytes] < 1 || values[bloomBytes] > maxIndexBloomFilterBytes {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid bloom filter size in index type: %s", indexType)
}
if values[hashes] < 1 || values[hashes] > maxIndexHashFunctions {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid hash function count in index type: %s", indexType)
}
return nil
}

View File

@@ -1,478 +0,0 @@
package promotetypes
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func TestPromotePathTarget(t *testing.T) {
testCases := []struct {
name string
path *PromotePath
want Target
wantErr bool
}{
{
name: "LogsBody_Resolved",
path: &PromotePath{Signal: "logs", Context: "body", Path: "body.user.name"},
want: NewLogsBodyTarget(),
},
{
name: "TracesAttribute_Resolved",
path: &PromotePath{Signal: "traces", Context: "attribute", Path: "http.method"},
want: NewTracesAttributesTarget(),
},
{
name: "InvalidSignal_Rejected",
path: &PromotePath{Signal: "events", Context: "attribute", Path: "http.method"},
wantErr: true,
},
{
name: "InvalidContext_Rejected",
path: &PromotePath{Signal: "logs", Context: "span", Path: "user.name"},
wantErr: true,
},
{
name: "UnsupportedTarget_Rejected",
path: &PromotePath{Signal: "metrics", Context: "attribute", Path: "http.method"},
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
target, err := testCase.path.Target()
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.want, target)
})
}
}
func TestValidateAndSetDefaultsLogsBody(t *testing.T) {
target := NewLogsBodyTarget()
testCases := []struct {
name string
path *PromotePath
wantErr bool
wantPath string
wantJSONDataType telemetrytypes.JSONDataType
}{
{
name: "BarePath_KeptAsIs",
path: &PromotePath{Path: "user.name", Promote: true},
wantPath: "user.name",
},
{
name: "BodyPrefixedPath_Rejected",
path: &PromotePath{Path: "body.user.name", Promote: true},
wantErr: true,
},
{
name: "BodyV2PrefixedPath_Rejected",
path: &PromotePath{Path: "body_v2.user.name", Promote: true},
wantErr: true,
},
{
name: "BodyPromotedPrefixedPath_Rejected",
path: &PromotePath{Path: "body_promoted.user.name", Promote: true},
wantErr: true,
},
{
name: "EmptyPath_Rejected",
path: &PromotePath{Path: "", Promote: true},
wantErr: true,
},
{
name: "SpacedPath_Rejected",
path: &PromotePath{Path: "body.my path", Promote: true},
wantErr: true,
},
{
name: "ArrayIndexPath_Rejected",
path: &PromotePath{Path: "body.users[].id", Promote: true},
wantErr: true,
},
{
name: "ArrayWildcardPath_Rejected",
path: &PromotePath{Path: "body.users[*].id", Promote: true},
wantErr: true,
},
{
name: "CardinalPath_Rejected",
path: &PromotePath{Path: "body.request.550e8400-e29b-41d4-a716-446655440000", Promote: true},
wantErr: true,
},
{
name: "ValidIndex_JSONDataTypeDefaulted",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
},
},
wantPath: "user.name",
wantJSONDataType: telemetrytypes.String,
},
{
name: "ValidTokenBFIndex_JSONDataTypeDefaulted",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "tokenbf_v1(1024, 2, 0)", Granularity: 1},
},
},
wantPath: "user.name",
wantJSONDataType: telemetrytypes.String,
},
{
name: "IndexTypeWithAlterAction_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4,1024,2,0)\tGRANULARITY\t1,\tDROP\tINDEX\tidx\t--\t", Granularity: 1}},
},
wantErr: true,
},
{
name: "ValidBloomFilterIndex_JSONDataTypeDefaulted",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter(0.01)", Granularity: 1},
},
},
wantPath: "user.name",
wantJSONDataType: telemetrytypes.String,
},
{
name: "ValidSetIndex_JSONDataTypeDefaulted",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "set(100)", Granularity: 1},
},
},
wantPath: "user.name",
wantJSONDataType: telemetrytypes.String,
},
{
name: "IndexTypeFalsePositiveOutOfRange_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter(1)", Granularity: 1}},
},
wantErr: true,
},
{
name: "UnsupportedIndexType_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "inverted(2)", Granularity: 1}},
},
wantErr: true,
},
{
name: "IndexTypeWrongArity_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024)", Granularity: 1}},
},
wantErr: true,
},
{
name: "IndexTypeOversizedBloomFilter_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 9999999, 2, 0)", Granularity: 1}},
},
wantErr: true,
},
{
name: "UnsupportedColumnTypeIndex_Rejected",
path: &PromotePath{
Path: "user.active",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeBool, Type: "minmax", Granularity: 1}},
},
wantErr: true,
},
{
name: "IndexWithoutType_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Granularity: 1}},
},
wantErr: true,
},
{
name: "UnknownIndexType_Rejected",
path: &PromotePath{
Path: "body.user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "unsupported", Granularity: 1}},
},
wantErr: true,
},
{
name: "IndexWithoutGranularity_Rejected",
path: &PromotePath{
Path: "user.name",
Indexes: []WrappedIndex{{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "minmax"}},
},
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := testCase.path.ValidateAndSetDefaults(target)
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.wantPath, testCase.path.Path)
if testCase.wantJSONDataType != (telemetrytypes.JSONDataType{}) {
require.Len(t, testCase.path.Indexes, 1)
assert.Equal(t, testCase.wantJSONDataType, testCase.path.Indexes[0].JSONDataType)
}
})
}
}
func TestValidateAndSetDefaultsTracesAttributes(t *testing.T) {
target := NewTracesAttributesTarget()
testCases := []struct {
name string
path *PromotePath
wantErr bool
wantPath string
wantJSONDataType telemetrytypes.JSONDataType
}{
{
name: "BareAttributeName_KeptAsIs",
path: &PromotePath{Path: "http.method", Promote: true},
wantPath: "http.method",
},
{
name: "ValidIndex_JSONDataTypeDefaulted",
path: &PromotePath{
Path: "http.method",
Indexes: []WrappedIndex{
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1},
},
},
wantPath: "http.method",
wantJSONDataType: telemetrytypes.String,
},
{
name: "AttributesPrefixedPath_Rejected",
path: &PromotePath{Path: "attributes.http.method", Promote: true},
wantErr: true,
},
{
name: "AttributesPromotedPrefixedPath_Rejected",
path: &PromotePath{Path: "attributes_promoted.http.method", Promote: true},
wantErr: true,
},
{
name: "EmptyPath_Rejected",
path: &PromotePath{Path: "", Promote: true},
wantErr: true,
},
{
name: "SpacedPath_Rejected",
path: &PromotePath{Path: "my attr", Promote: true},
wantErr: true,
},
{
name: "ArrayIndexPath_Rejected",
path: &PromotePath{Path: "tags[].id", Promote: true},
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := testCase.path.ValidateAndSetDefaults(target)
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.wantPath, testCase.path.Path)
if testCase.wantJSONDataType != (telemetrytypes.JSONDataType{}) {
require.Len(t, testCase.path.Indexes, 1)
assert.Equal(t, testCase.wantJSONDataType, testCase.path.Indexes[0].JSONDataType)
}
})
}
}
func TestListPromotedPathsFiltersValidate(t *testing.T) {
testCases := []struct {
name string
filters ListPromotedPathsFilters
wantErr bool
}{
{
name: "EmptyFilters_Valid",
filters: ListPromotedPathsFilters{},
},
{
name: "KnownSignalAndContext_Valid",
filters: ListPromotedPathsFilters{Signal: "traces", Context: "attribute"},
},
{
name: "InvalidSignal_Rejected",
filters: ListPromotedPathsFilters{Signal: "events"},
wantErr: true,
},
{
name: "InvalidContext_Rejected",
filters: ListPromotedPathsFilters{Context: "json"},
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := testCase.filters.Validate()
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestListPromotedPathsFiltersMatch(t *testing.T) {
trueValue := true
falseValue := false
testCases := []struct {
name string
filters ListPromotedPathsFilters
target Target
path PromotePath
wantTarget bool
wantPath bool
}{
{
name: "EmptyFilters_MatchEverything",
filters: ListPromotedPathsFilters{},
target: NewTracesAttributesTarget(),
path: PromotePath{Path: "http.method", Promote: true},
wantTarget: true,
wantPath: true,
},
{
name: "SignalFilter_MatchesSameSignal",
filters: ListPromotedPathsFilters{Signal: "traces"},
target: NewTracesAttributesTarget(),
wantTarget: true,
},
{
name: "SignalFilter_SkipsOtherSignals",
filters: ListPromotedPathsFilters{Signal: "traces"},
target: NewLogsBodyTarget(),
wantTarget: false,
},
{
name: "ContextAliasFilter_MatchesResolvedContext",
filters: ListPromotedPathsFilters{Context: "tag"},
target: NewTracesAttributesTarget(),
wantTarget: true,
},
{
name: "UppercaseContextFilter_MatchesResolvedContext",
filters: ListPromotedPathsFilters{Context: "ATTRIBUTE"},
target: NewTracesAttributesTarget(),
wantTarget: true,
},
{
name: "ContextFilter_SkipsOtherContexts",
filters: ListPromotedPathsFilters{Context: "body"},
target: NewTracesAttributesTarget(),
wantTarget: false,
},
{
name: "PromotedFalseFilter_MatchesUnpromotedPath",
filters: ListPromotedPathsFilters{Promoted: &falseValue},
path: PromotePath{Path: "request.duration"},
wantPath: true,
},
{
name: "PromotedFalseFilter_SkipsPromotedPath",
filters: ListPromotedPathsFilters{Promoted: &falseValue},
path: PromotePath{Path: "http.method", Promote: true},
wantPath: false,
},
{
name: "IndexesTrueFilter_MatchesIndexedPath",
filters: ListPromotedPathsFilters{Indexes: &trueValue},
path: PromotePath{Path: "user.name", Indexes: []WrappedIndex{{Type: "minmax"}}},
wantPath: true,
},
{
name: "IndexesTrueFilter_SkipsUnindexedPath",
filters: ListPromotedPathsFilters{Indexes: &trueValue},
path: PromotePath{Path: "http.method", Promote: true},
wantPath: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
if testCase.target.Entry.Signal.StringValue() != "" {
assert.Equal(t, testCase.wantTarget, testCase.filters.MatchesTarget(testCase.target))
}
if testCase.path.Path != "" {
assert.Equal(t, testCase.wantPath, testCase.filters.MatchesPath(testCase.path))
}
})
}
}
func TestValidatePromotePaths(t *testing.T) {
validPaths := func(n int) []*PromotePath {
paths := make([]*PromotePath, n)
for idx := range paths {
paths[idx] = &PromotePath{Signal: "logs", Context: "body", Path: "user.name"}
}
return paths
}
testCases := []struct {
name string
paths []*PromotePath
wantErr bool
}{
{name: "Empty_Rejected", paths: nil, wantErr: true},
{name: "NullPath_Rejected", paths: []*PromotePath{nil}, wantErr: true},
{name: "OverCap_Rejected", paths: validPaths(maxPromotePathsPerRequest + 1), wantErr: true},
{name: "PrefixedPath_Rejected", paths: []*PromotePath{{Signal: "logs", Context: "body", Path: "body.user.name"}}, wantErr: true},
{name: "UnsupportedSignal_Rejected", paths: []*PromotePath{{Signal: "metrics", Context: "body", Path: "user.name"}}, wantErr: true},
{name: "AtCap_Valid", paths: validPaths(maxPromotePathsPerRequest)},
{name: "SinglePath_Valid", paths: validPaths(1)},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
err := ValidatePromotePaths(testCase.paths)
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
})
}
}

View File

@@ -398,19 +398,6 @@ func NewTelemetryFieldKey(name string, fieldContext FieldContext, fieldDataType
}
}
// JSONIndexLookup locates one domain's JSON sub-column indexes in
// system.data_skipping_indices: the table to read, the base and promoted
// column prefixes to match index expressions against, and the signal and
// context to stamp on the results.
type JSONIndexLookup struct {
Signal Signal
FieldContext FieldContext
DBName string
LocalTableName string
BaseColumnPrefix string
PromotedColumnPrefix string
}
type TelemetryFieldKeySkipIndex struct {
Name string `json:"name"` // Name is TelemetryFieldKey.Name not IndexName from ClickHouse
FieldContext FieldContext `json:"fieldContext,omitzero"`

View File

@@ -22,18 +22,3 @@ func (Signal) Enum() []any {
SignalUnspecified,
}
}
// SignalFromText resolves a signal word to its Signal; ok is false for an
// unknown word.
func SignalFromText(text string) (Signal, bool) {
s := Signal{valuer.NewString(text)}
switch s {
case SignalTraces:
return SignalTraces, true
case SignalLogs:
return SignalLogs, true
case SignalMetrics:
return SignalMetrics, true
}
return Signal{}, false
}

View File

@@ -34,16 +34,14 @@ type MetadataStore interface {
FetchTemporalityAndTypeMulti(ctx context.Context, orgID valuer.UUID, queryTimeRangeStartTs, queryTimeRangeEndTs uint64, metricNames ...string) (map[string]metrictypes.Temporality, map[string]metrictypes.Type, map[string]bool, error)
// ListJSONIndexes lists the per-path JSON skip indexes of the given source.
ListJSONIndexes(ctx context.Context, lookup JSONIndexLookup, filters ...string) ([]TelemetryFieldKeySkipIndex, error)
// ListLogsJSONIndexes lists the JSON indexes for the logs table.
ListLogsJSONIndexes(ctx context.Context, filters ...string) ([]TelemetryFieldKeySkipIndex, error)
// GetPromotedPaths lists the promoted paths recorded in the column
// evolution table for the entry's signal, column and field context.
GetPromotedPaths(ctx context.Context, entry EvolutionEntry, paths ...string) (map[string]bool, error)
// ListPromotedPaths lists the promoted paths.
GetPromotedPaths(ctx context.Context, paths ...string) (map[string]bool, error)
// PromotePaths records promoted paths in the column evolution table as
// rows templated by entry; FieldName and ReleaseTime are set per path.
PromotePaths(ctx context.Context, entry EvolutionEntry, paths ...string) error
// PromotePaths promotes the paths.
PromotePaths(ctx context.Context, paths ...string) error
// GetFirstSeenFromMetricMetadata gets the first seen timestamp for a metric metadata lookup key.
GetFirstSeenFromMetricMetadata(ctx context.Context, lookupKeys []MetricMetadataLookupKey) (map[MetricMetadataLookupKey]int64, error)

View File

@@ -18,7 +18,7 @@ type MockMetadataStore struct {
TemporalityMap map[string]metrictypes.Temporality
TypeMap map[string]metrictypes.Type
ReducedMap map[string]bool
PromotedPathsMap map[telemetrytypes.Signal]map[string]bool
PromotedPathsMap map[string]bool
LogsJSONIndexes []telemetrytypes.TelemetryFieldKeySkipIndex
ColumnEvolutionMetadataMap map[string][]*telemetrytypes.EvolutionEntry
LookupKeysMap map[telemetrytypes.MetricMetadataLookupKey]int64
@@ -34,7 +34,7 @@ func NewMockMetadataStore() *MockMetadataStore {
AllValuesMap: make(map[string]*telemetrytypes.TelemetryFieldValues),
TemporalityMap: make(map[string]metrictypes.Temporality),
TypeMap: make(map[string]metrictypes.Type),
PromotedPathsMap: make(map[telemetrytypes.Signal]map[string]bool),
PromotedPathsMap: make(map[string]bool),
LogsJSONIndexes: []telemetrytypes.TelemetryFieldKeySkipIndex{},
ColumnEvolutionMetadataMap: make(map[string][]*telemetrytypes.EvolutionEntry),
LookupKeysMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
@@ -361,30 +361,21 @@ func (m *MockMetadataStore) SetTemporality(metricName string, temporality metric
}
// PromotePaths promotes the paths.
func (m *MockMetadataStore) PromotePaths(_ context.Context, entry telemetrytypes.EvolutionEntry, paths ...string) error {
if m.PromotedPathsMap[entry.Signal] == nil {
m.PromotedPathsMap[entry.Signal] = make(map[string]bool)
}
func (m *MockMetadataStore) PromotePaths(ctx context.Context, paths ...string) error {
for _, path := range paths {
m.PromotedPathsMap[entry.Signal][path] = true
m.PromotedPathsMap[path] = true
}
return nil
}
// GetPromotedPaths returns the promoted paths.
func (m *MockMetadataStore) GetPromotedPaths(_ context.Context, entry telemetrytypes.EvolutionEntry, _ ...string) (map[string]bool, error) {
return m.PromotedPathsMap[entry.Signal], nil
func (m *MockMetadataStore) GetPromotedPaths(ctx context.Context, paths ...string) (map[string]bool, error) {
return m.PromotedPathsMap, nil
}
// ListJSONIndexes narrows the stored indexes to the lookup's field context like the real query.
func (m *MockMetadataStore) ListJSONIndexes(ctx context.Context, lookup telemetrytypes.JSONIndexLookup, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
indexes := []telemetrytypes.TelemetryFieldKeySkipIndex{}
for _, index := range m.LogsJSONIndexes {
if index.FieldContext.StringValue() == lookup.FieldContext.StringValue() {
indexes = append(indexes, index)
}
}
return indexes, nil
// ListLogsJSONIndexes lists the JSON indexes for the logs table.
func (m *MockMetadataStore) ListLogsJSONIndexes(ctx context.Context, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
return m.LogsJSONIndexes, nil
}
func (m *MockMetadataStore) updateColumnEvolutionMetadataForKeys(_ context.Context, keysToUpdate []*telemetrytypes.TelemetryFieldKey) map[string][]*telemetrytypes.EvolutionEntry {

View File

@@ -392,40 +392,20 @@ def verify_webhook_notification_expectation(
notification_channel: types.TestContainerDocker,
validation_data: dict,
) -> bool:
"""Check that wiremock received the expected request(s) at the given path.
validation_data supports (all optional except one of path/path_pattern):
- path: request url path (matched as urlPath, so query strings are ignored)
- path_pattern: url path regex instead of path, for paths that embed a
dynamic segment (e.g. a group-hash alias)
- json_body: expected JSON subset of the request body
- count: exact number of requests required at the path
- min_count: minimum number of requests required (e.g. retries)
The body constraint must be satisfied by a single request; count constraints
apply to the total at the path."""
path = validation_data.get("path")
json_body = validation_data.get("json_body")
# urlPath ignores query strings; real webhook urls may carry their own (e.g. key/token).
matcher = {"method": "POST", "urlPath": path} if path is not None else {"method": "POST", "urlPathPattern": validation_data["path_pattern"]}
"""Check if wiremock received a request at the given path
whose JSON body is a superset of the expected json_body."""
path = validation_data["path"]
json_body = validation_data["json_body"]
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
try:
res = requests.post(url, json=matcher, timeout=10)
res = requests.post(url, json={"method": "POST", "url": path}, timeout=10)
except requests.exceptions.RequestException:
return False
if res.status_code != HTTPStatus.OK:
return False
reqs = res.json()["requests"]
if "count" in validation_data and len(reqs) != validation_data["count"]:
return False
if "min_count" in validation_data and len(reqs) < validation_data["min_count"]:
return False
if json_body is None:
return True
for req in reqs:
for req in res.json()["requests"]:
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
if _is_json_subset(json_body, body):
return True
@@ -488,10 +468,8 @@ def _received_notifications(
if validation.destination_type != "webhook":
continue
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
path = validation.validation_data.get("path")
matcher = {"method": "POST", "urlPath": path} if path is not None else {"method": "POST", "urlPathPattern": validation.validation_data["path_pattern"]}
try:
res = requests.post(url, json=matcher, timeout=10)
res = requests.post(url, json={"method": "POST", "url": validation.validation_data["path"]}, timeout=10)
webhook_bodies.extend(json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8")) for req in res.json()["requests"])
except requests.exceptions.RequestException as exc:
webhook_bodies.append(f"<failed to fetch wiremock journal: {exc}>")
@@ -520,8 +498,6 @@ def update_raw_channel_config(
"webhook_configs": "url",
"pagerduty_configs": "url",
"opsgenie_configs": "api_url",
"googlechat_configs": "webhook_url",
"incidentio_configs": "url",
}
for config_key, url_field in url_field_map.items():
@@ -532,7 +508,4 @@ def update_raw_channel_config(
path = urlparse(original_url).path
entry[url_field] = notification_channel.container_configs["8080"].get(path)
for entry in config.get("jira_configs", []):
entry["site"] = notification_channel.container_configs["8080"].base()
return config

View File

@@ -1,12 +1,9 @@
# pylint: disable=line-too-long
import hashlib
import json
import re
import time
import uuid
from collections.abc import Callable
from http import HTTPStatus
from pathlib import Path
import docker
import docker.errors
@@ -14,28 +11,15 @@ import pytest
import requests
from sqlalchemy import sql
from testcontainers.core.container import Network
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
from wiremock.testing.testcontainer import WireMockContainer
from fixtures import reuse, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.maildev import MAILDEV_INCOMING_PASS, SMTP_TEST_FROM
from fixtures.tls import CA_ID_LABEL, KEYSTORE_PASSWORD, ca_id, issue_server_keystore
logger = setup_logger(__name__)
# The JSM Ops and Jira service-account gateways are hardcoded in signoz, so the
# WireMock container joins the network under this alias and serves HTTPS on 443
# with a certificate issued by the integration CA that signoz trusts.
ATLASSIAN_API_HOST = "api.atlassian.com"
TLS_HOSTS = [ATLASSIAN_API_HOST]
# A reused container serving a cert without a newly added host (or missing its
# network alias) fails TLS opaquely; this label records the hosts it was built
# for so stale() recreates it when the list changes.
TLS_HOSTS_LABEL = "signoz.integration.tls-hosts"
EMAIL_TRANSPORT_KEYS = [
"from",
@@ -170,364 +154,9 @@ email_default_config = {
}
def googlechat_config(space: str) -> dict:
"""Google Chat channel config for a per-test WireMock space path. Title/text are
omitted so the backend applies its default templates. The host is injected at
runtime by update_raw_channel_config."""
return {
"googlechat_configs": [
{
"webhook_url": f"/v1/spaces/{space}/messages", # host set on runtime
}
],
}
def googlechat_ok_mappings(path: str) -> list[Mapping]:
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=200, json_body={"name": "spaces/x/messages/x"}),
)
]
def googlechat_retry_mappings(path: str) -> list[Mapping]:
"""429 on the first call then 200, via a wiremock scenario transition."""
scenario = f"gc-retry-{path}"
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=429, json_body={"error": {"code": 429, "status": "RESOURCE_EXHAUSTED"}}),
scenario_name=scenario,
required_scenario_state="Started",
new_scenario_state="ok",
),
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=200, json_body={"name": "spaces/x/messages/x"}),
scenario_name=scenario,
required_scenario_state="ok",
),
]
def googlechat_card_subset(alertname: str, buttons: list[tuple[str, str]]) -> dict:
"""A cardsV2 subset asserting title, firing banner, rendered body, and each
button's text AND deep-link url (as a regex), so a broken link is caught too.
buttons: list of (text, url_regex)."""
return {
"text": f"[FIRING:1] {alertname}",
"cardsV2": [
{
"cardId": "signoz-alert",
"card": {
"header": {"title": f"[FIRING:1] {alertname}"},
"sections": [
# firing banner
{"widgets": [{"textParagraph": {"text": re.compile("FIRING")}}]},
# rendered alert body mentions the alertname
{"widgets": [{"textParagraph": {"text": re.compile(re.escape(alertname))}}]},
]
+ [{"widgets": [{"buttonList": {"buttons": [{"text": text, "onClick": {"openLink": {"url": re.compile(url)}}}]}}]} for text, url in buttons],
},
}
],
}
INCIDENTIO_TEST_TOKEN = "incidentio-test-token" # noqa: S105
def incidentio_path(source_id: str) -> str:
return f"/v2/alert_events/http/{source_id}"
def incidentio_config(source_id: str) -> dict:
"""incident.io channel config for a per-test alert source id. Title/description
are omitted so the backend applies its default templates. The host is injected
at runtime by update_raw_channel_config."""
return {
"incidentio_configs": [
{
"url": incidentio_path(source_id), # host set on runtime
"token": INCIDENTIO_TEST_TOKEN,
}
],
}
# recorded incident.io Alert Events V2 responses: 202 accepted-for-processing
# echoing the dedup key; errors are {type, status, errors: [{code, message}]}
def incidentio_ok_mappings(path: str) -> list[Mapping]:
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=202, json_body={"status": "accepted", "message": "Event accepted for processing", "deduplication_key": "x"}),
)
]
def incidentio_retry_mappings(path: str) -> list[Mapping]:
"""429 on the first call then 202, via a wiremock scenario transition."""
scenario = f"incidentio-retry-{path}"
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=429, json_body={"type": "rate_limit_error", "status": 429}),
scenario_name=scenario,
required_scenario_state="Started",
new_scenario_state="ok",
),
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=202, json_body={"status": "accepted", "message": "Event accepted for processing", "deduplication_key": "x"}),
scenario_name=scenario,
required_scenario_state="ok",
),
]
def incidentio_event_subset(alertname: str, links: list[tuple[str, str]]) -> dict:
"""An alert-event subset asserting title, firing status, dedup key, SigNoz
source_url, metadata labels, and each markdown link's text AND url (as a
regex), so a broken link is caught too. links: (text, url_regex) pairs in
default-template order (View in SigNoz -> related logs -> related traces)."""
description = "(?s)" + re.escape(f"**Alert:** {alertname}")
for text, url in links:
description += rf".*\[{re.escape(text)}\]\([^)]*{url}"
return {
"title": f"[FIRING:1] {alertname}",
"status": "firing",
"deduplication_key": re.compile(r".+"),
"source_url": re.compile(r"/alerts/overview\?ruleId="),
"description": re.compile(description),
"metadata": {"alertname": alertname},
}
JIRA_TEST_EMAIL = "user@acme.io"
JIRA_SA_EMAIL = "svc@serviceaccount.atlassian.com"
JIRA_TEST_TOKEN = "jira-test-token" # noqa: S105
JIRA_API_BASE = "/rest/api/3"
def jira_config(**overrides) -> dict:
"""Jira channel config with personal API token auth. Summary/description are
omitted so the backend applies its default templates; overrides lay extra
receiver fields on top. The site is injected at runtime by
update_raw_channel_config."""
return {
"jira_configs": [
{
"site": "", # set on runtime
"project": "OPS",
"issue_type": "Task",
"http_config": {"basic_auth": {"username": JIRA_TEST_EMAIL, "password": JIRA_TEST_TOKEN}},
**overrides,
}
],
}
def jira_search_issue(key: str, done: bool, labels: list[str]) -> dict:
"""One issue as returned by the /search/jql stub, with the fields the
notifier requests (status category + labels)."""
return {
"key": key,
"fields": {"status": {"statusCategory": {"key": "done" if done else "indeterminate"}}, "labels": labels},
}
# Jira flows span several endpoints; each mapping helper stubs one, on any base
# (site host for personal tokens, /ex/jira/<cloud_id> gateway for service accounts).
def jira_search_mapping(issues: list[dict], base: str = JIRA_API_BASE) -> Mapping:
return Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{base}/search/jql"),
response=MappingResponse(status=200, json_body={"issues": issues}),
)
def jira_create_mapping(key: str = "OPS-1", base: str = JIRA_API_BASE) -> Mapping:
return Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{base}/issue"),
response=MappingResponse(status=201, json_body={"id": "10001", "key": key}),
)
def jira_update_mapping(key: str, base: str = JIRA_API_BASE) -> Mapping:
return Mapping(
request=MappingRequest(method=HttpMethods.PUT, url_path=f"{base}/issue/{key}"),
response=MappingResponse(status=204),
)
def jira_transitions_mapping(key: str, transitions: list[dict], base: str = JIRA_API_BASE) -> Mapping:
return Mapping(
request=MappingRequest(method=HttpMethods.GET, url_path=f"{base}/issue/{key}/transitions"),
response=MappingResponse(status=200, json_body={"transitions": transitions}),
)
def jira_transition_post_mapping(key: str, base: str = JIRA_API_BASE) -> Mapping:
return Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{base}/issue/{key}/transitions"),
response=MappingResponse(status=204),
)
def jira_comment_mapping(key: str, base: str = JIRA_API_BASE) -> Mapping:
return Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{base}/issue/{key}/comment"),
response=MappingResponse(status=201, json_body={"id": "1"}),
)
def jira_retry_search_mappings() -> list[Mapping]:
"""429 on the first search then 200-empty, via a wiremock scenario transition."""
scenario = "jira-retry-search"
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JIRA_API_BASE}/search/jql"),
response=MappingResponse(status=429, json_body={"errorMessages": ["Rate limit exceeded"]}),
scenario_name=scenario,
required_scenario_state="Started",
new_scenario_state="ok",
),
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JIRA_API_BASE}/search/jql"),
response=MappingResponse(status=200, json_body={"issues": []}),
scenario_name=scenario,
required_scenario_state="ok",
),
]
def find_requests(notification_channel: types.TestContainerDocker, method: str, path: str | None = None, path_pattern: str | None = None) -> list[dict]:
"""The wiremock journal entries for method+path (query strings ignored);
path_pattern matches the path as a regex instead, for paths that embed a
dynamic segment like the group-hash alias."""
matcher = {"method": method, "urlPath": path} if path is not None else {"method": method, "urlPathPattern": path_pattern}
find = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/find"),
json=matcher,
timeout=10,
)
return find.json()["requests"]
JSMOPS_TEST_API_KEY = "jsmops-test-api-key" # noqa: S105
# The JSM Ops gateway lives on api.atlassian.com (already aliased for Jira
# service accounts); the notifier appends v2/alerts... to this base.
JSMOPS_API_BASE = "/jsm/ops/integration"
JSMOPS_NOTES_PATH_PATTERN = f"{JSMOPS_API_BASE}/v2/alerts/[a-f0-9]+/notes"
def jsmops_config(**overrides) -> dict:
"""JSM Ops channel config. Message/description/tags are omitted so the
backend applies its defaults; overrides lay extra receiver fields on top."""
return {
"jsmops_configs": [
{
"api_key": JSMOPS_TEST_API_KEY,
**overrides,
}
],
}
def jsmops_create_mapping() -> Mapping:
return Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JSMOPS_API_BASE}/v2/alerts"),
response=MappingResponse(status=202, json_body={"result": "Request will be processed", "took": 0.005, "requestId": "1b1f0000-0000-4000-8000-000000000001"}),
)
def jsmops_notes_mapping(status: int = 202, body: dict | None = None) -> Mapping:
return Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path_pattern=JSMOPS_NOTES_PATH_PATTERN),
response=MappingResponse(status=status, json_body=body or {"result": "Request will be processed", "took": 0.002, "requestId": "1b1f0000-0000-4000-8000-000000000002"}),
)
def jsmops_retry_create_mappings() -> list[Mapping]:
"""429 on the first create then 202, via a wiremock scenario transition."""
scenario = "jsmops-retry-create"
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JSMOPS_API_BASE}/v2/alerts"),
response=MappingResponse(status=429, json_body={"message": "You are making too many requests!", "took": 0.001, "requestId": "x"}),
scenario_name=scenario,
required_scenario_state="Started",
new_scenario_state="ok",
),
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JSMOPS_API_BASE}/v2/alerts"),
response=MappingResponse(status=202, json_body={"result": "Request will be processed", "took": 0.005, "requestId": "x"}),
scenario_name=scenario,
required_scenario_state="ok",
),
]
def jsmops_alert_subset(alertname: str, links: list[tuple[str, str]]) -> dict:
"""A created-alert subset asserting message, alias, source, default tags,
details labels, and the HTML description: the rendered bold Alert run plus
each link's anchor (href as a regex), so a broken link is caught too.
links: (text, url_regex) pairs in default-template order."""
description = "(?s)" + re.escape("<strong>Alert:</strong>")
for text, url in links:
description += rf'.*<a href="[^"]*{url}[^"]*"[^>]*>{re.escape(text)}</a>'
return {
"alias": re.compile(r".+"),
"message": f"[FIRING:1] {alertname}",
"source": "SigNoz",
"tags": ["signoz"],
"details": {"alertname": alertname},
"description": re.compile(description),
}
def jira_issue_subset(alertname: str, links: list[tuple[str, str]]) -> dict:
"""A created-issue subset asserting summary, group labels, ADF status panel,
the rendered alert text, and each deep-link's text AND url (as a regex), so
a broken link is caught too. links: (text, url_regex) pairs."""
# the ADF renderer splits text nodes at underscores, so the alertname never
# sits in one node; the summary pins it exactly, the body asserts the
# rendered "Alert:" strong run followed by the name's first fragment
description_content = [
{"type": "panel", "attrs": {"panelType": "error"}},
{
"type": "paragraph",
"content": [
{"type": "text", "text": "Alert:", "marks": [{"type": "strong"}]},
{"type": "text", "text": re.compile(re.escape(alertname.split("_", maxsplit=1)[0]))},
],
},
]
if links:
description_content.append(
{
"type": "paragraph",
"content": [{"type": "text", "text": text, "marks": [{"type": "link", "attrs": {"href": re.compile(url)}}]} for text, url in links],
}
)
return {
"fields": {
"project": {"key": "OPS"},
"issuetype": {"name": "Task"},
"summary": f"[FIRING:1] {alertname}",
"labels": ["signoz-alert", re.compile(r"ALERT\{")],
"description": {"type": "doc", "version": 1, "content": description_content},
},
}
@pytest.fixture(name="notification_channel", scope="package")
def notification_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments
def notification_channel(
network: Network,
tls: types.TLS,
tmpfs: Callable[[str], Path],
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.TestContainerDocker:
@@ -536,25 +165,9 @@ def notification_channel( # pylint: disable=too-many-arguments,too-many-positio
"""
def create() -> types.TestContainerDocker:
# http:8080 for admin API + plain webhook delivery; https:443 aliased as
# api.atlassian.com with a CA-issued cert so the hardcoded Atlassian
# gateways route here over real TLS (signoz trusts the integration CA).
keystore_path = issue_server_keystore(tls, tmpfs("notification-channel-certs"), *TLS_HOSTS)
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
container.with_volume_mapping(str(keystore_path.parent), "/certs", "ro")
container.with_network(network)
container.with_network_aliases(*TLS_HOSTS)
container.with_kwargs(labels={CA_ID_LABEL: ca_id(tls), TLS_HOSTS_LABEL: ",".join(TLS_HOSTS)})
try:
container.start(f"--port 8080 --https-port 443 --https-keystore /certs/keystore.p12 --keystore-type PKCS12 --keystore-password {KEYSTORE_PASSWORD}")
except Exception:
# Ryuk is disabled: a started-but-unready container would survive and
# keep squatting on the api.atlassian.com alias, poisoning DNS for
# any replacement on the shared network.
container.stop()
raise
container.start()
return types.TestContainerDocker(
id=container.get_wrapped_container().id,
@@ -565,9 +178,7 @@ def notification_channel( # pylint: disable=too-many-arguments,too-many-positio
container.get_exposed_port(8080),
)
},
container_configs={
"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080),
},
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
)
def delete(container: types.TestContainerDocker):
@@ -584,16 +195,6 @@ def notification_channel( # pylint: disable=too-many-arguments,too-many-positio
def restore(cache: dict) -> types.TestContainerDocker:
return types.TestContainerDocker.from_cache(cache)
def stale(container: types.TestContainerDocker) -> bool:
# A container built against a rotated/absent CA can't serve a cert signoz
# trusts; recreate it instead of failing TLS opaquely.
client = docker.from_env()
try:
labels = client.containers.get(container_id=container.id).attrs["Config"]["Labels"]
except docker.errors.NotFound:
return True
return labels.get(CA_ID_LABEL) != ca_id(tls) or labels.get(TLS_HOSTS_LABEL) != ",".join(TLS_HOSTS)
return reuse.wrap(
request,
pytestconfig,
@@ -602,7 +203,6 @@ def notification_channel( # pylint: disable=too-many-arguments,too-many-positio
create,
delete,
restore,
stale=stale,
)
@@ -704,31 +304,6 @@ def create_webhook_notification_channel(
return _create_webhook_notification_channel
def wait_for_alertmanager_sync(signoz: types.SigNoz, token: str, notification_channel: types.TestContainerDocker, wait_seconds: int = 60) -> None:
"""Polls until the org's alertmanager server is registered (one poll tick).
channels/test 404s until then, before reaching any notifier. The sentinel
receiver posts to its own unstubbed wiremock path, so request journals
asserted by tests stay clean."""
sentinel = {
"name": str(uuid.uuid4()),
"webhook_configs": [{"url": notification_channel.container_configs["8080"].get("/org-registration-sentinel")}],
}
deadline = time.time() + wait_seconds
last = None
while time.time() < deadline:
last = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=sentinel,
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if last.status_code != HTTPStatus.NOT_FOUND:
return
time.sleep(2)
raise AssertionError(f"org alertmanager did not register within {wait_seconds}s, last response: {last.status_code} {last.text}")
def send_test_notification(signoz: types.SigNoz, token: str, receiver: dict, wait_seconds: int = 90) -> None:
deadline = time.time() + wait_seconds
last = None

13
tests/fixtures/tls.py vendored
View File

@@ -107,11 +107,10 @@ def tls(
)
def issue_server_keystore(tls: types.TLS, directory: Path, *hostnames: str) -> Path:
def issue_server_keystore(tls: types.TLS, directory: Path, hostname: str) -> Path:
"""Write a PKCS12 keystore (keystore.p12, password KEYSTORE_PASSWORD) into
directory, holding a certificate for the hostnames (SANs, CN is the first)
issued by the integration CA. Mount it into a mock container that must
serve TLS as those hostnames."""
directory, holding a certificate for hostname issued by the integration CA.
Mount it into a mock container that must serve TLS as hostname."""
ca_cert = x509.load_pem_x509_certificate(Path(tls.ca_cert_path).read_bytes())
ca_key = serialization.load_pem_private_key(Path(tls.ca_key_path).read_bytes(), password=None)
@@ -119,13 +118,13 @@ def issue_server_keystore(tls: types.TLS, directory: Path, *hostnames: str) -> P
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
leaf_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostnames[0])]))
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]))
.issuer_name(ca_cert.subject)
.public_key(leaf_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=3650))
.add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname) for hostname in hostnames]), critical=False)
.add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False)
.add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
.sign(ca_key, hashes.SHA256())
)
@@ -133,7 +132,7 @@ def issue_server_keystore(tls: types.TLS, directory: Path, *hostnames: str) -> P
keystore_path = directory / "keystore.p12"
keystore_path.write_bytes(
pkcs12.serialize_key_and_certificates(
name=hostnames[0].encode(),
name=hostname.encode(),
key=leaf_key,
cert=leaf_cert,
cas=[ca_cert],

View File

@@ -1,185 +0,0 @@
import json
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import pytest
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.alerts import (
get_testdata_file_path,
update_raw_channel_config,
update_rule_channel_name,
verify_notification_expectation,
)
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.notification_channel import (
googlechat_card_subset,
googlechat_config,
googlechat_ok_mappings,
googlechat_retry_mappings,
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
LOGS_RULE = "ruler/test_scenarios/threshold_below_at_least_once/rule.json"
TRACES_DATA = "ruler/test_scenarios/threshold_above_average/alert_data.jsonl"
TRACES_RULE = "ruler/test_scenarios/threshold_above_average/rule.json"
GOOGLECHAT_CASES = [
types.AlertManagerNotificationTestCase(
name="googlechat_default_metrics_firing",
rule_path=METRICS_RULE,
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
channel_config=googlechat_config("gc-metrics"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/v1/spaces/gc-metrics/messages",
"count": 1,
"json_body": googlechat_card_subset("threshold_above_at_least_once", [("Open in SigNoz", r"/alerts/overview\?ruleId=")]),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="googlechat_rich_card_logs",
rule_path=LOGS_RULE,
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
channel_config=googlechat_config("gc-logs"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/v1/spaces/gc-logs/messages",
"count": 1,
"json_body": googlechat_card_subset(
"threshold_below_at_least_once",
[("View Related Logs", r"/logs/logs-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="googlechat_rich_card_traces",
rule_path=TRACES_RULE,
alert_data=[types.AlertData(type="traces", data_path=TRACES_DATA)],
channel_config=googlechat_config("gc-traces"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": "/v1/spaces/gc-traces/messages",
"count": 1,
"json_body": googlechat_card_subset(
"threshold_above_average",
[("View Related Traces", r"traces-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
),
},
),
],
),
),
]
@pytest.mark.parametrize(
"gc_test_case",
GOOGLECHAT_CASES,
ids=lambda c: c.name,
)
def test_googlechat_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
gc_test_case: types.AlertManagerNotificationTestCase,
) -> None:
channel_name = str(uuid.uuid4())
path = gc_test_case.notification_expectation.notification_validations[0].validation_data["path"]
channel_config = update_raw_channel_config(gc_test_case.channel_config, channel_name, notification_channel)
make_http_mocks(notification_channel, googlechat_ok_mappings(path))
create_notification_channel(channel_config)
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
insert_alert_data(gc_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(gc_test_case.rule_path), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(notification_channel, maildev, gc_test_case.notification_expectation)
def test_googlechat_retry_429_then_200( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
) -> None:
channel_name = str(uuid.uuid4())
path = "/v1/spaces/gc-retry/messages"
channel_config = update_raw_channel_config(googlechat_config("gc-retry"), channel_name, notification_channel)
make_http_mocks(notification_channel, googlechat_retry_mappings(path))
create_notification_channel(channel_config)
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
insert_alert_data([types.AlertData(type="metrics", data_path=METRICS_DATA)], base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(METRICS_RULE), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(
notification_channel,
maildev,
types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
# a retryable 429 is followed by a successful re-POST => >=2 hits
"path": path,
"min_count": 2,
"json_body": {"cardsV2": [{"cardId": "signoz-alert"}]},
},
),
],
),
)

View File

@@ -1,111 +0,0 @@
import base64
import json
import re
import time
import uuid
from collections.abc import Callable
from http import HTTPStatus
from typing import NamedTuple
import pytest
import requests
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import update_raw_channel_config
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.notification_channel import googlechat_config
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
# with a hardcoded test alert and no retry — the deterministic place to assert
# permanent-failure behaviour. Rich cards + retry are covered in alertmanager/09_googlechat.py.
class TestChannelCase(NamedTuple):
__test__ = False
name: str
space: str
status: int # stub status
body: dict # stub body
expect_delivered: bool # expect channels/test 204
TEST_CHANNEL_CASES = [
TestChannelCase("success", "gc-tc-ok", 200, {"name": "spaces/x/messages/x"}, True),
TestChannelCase("permanent_400", "gc-tc-400", 400, {"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "Message cannot be empty."}}, False),
TestChannelCase("permission_403", "gc-tc-403", 403, {"error": {"code": 403, "status": "PERMISSION_DENIED", "message": "Method doesn't allow unregistered callers"}}, False),
]
@pytest.mark.parametrize(
"case",
TEST_CHANNEL_CASES,
ids=lambda c: c.name,
)
def test_googlechat_test_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
case: TestChannelCase,
) -> None:
path = f"/v1/spaces/{case.space}/messages"
make_http_mocks(
notification_channel,
[
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=case.status, json_body=case.body),
)
],
)
channel_name = str(uuid.uuid4())
receiver = update_raw_channel_config(googlechat_config(case.space), channel_name, notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# channels/test 404s until the org's alertmanager registers (one poll tick),
# without reaching the notifier — so the first non-404 response is the single
# authoritative delivery attempt and the count == 1 assertion below holds
deadline = time.time() + 60
while True:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
if response.status_code != HTTPStatus.NOT_FOUND or time.time() > deadline:
break
time.sleep(2)
if case.expect_delivered:
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
else:
# a downstream 400/403 surfaces as a 500 (untyped notify error) whose body
# carries the real downstream status code; pin it to distinguish 400 vs 403
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"expected 500, got {response.status_code}: {response.text}"
assert f"unexpected status code {case.status}" in response.text, f"expected downstream {case.status} in error body: {response.text}"
# exactly one delivery attempt either way (testChannel never retries)
count = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/count"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
assert count.json()["count"] == 1, f"expected exactly 1 request (no retry), got {count.text}"
if case.expect_delivered:
find = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/find"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
req = find.json()["requests"][0]
# the configured webhook url is posted verbatim, nothing appended
assert req["url"] == path, f"expected webhook url {path} posted verbatim, got {req['url']}"
# cardsV2 shape with the hardcoded test alert
card = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
assert card["cardsV2"][0]["cardId"] == "signoz-alert"
assert re.search(r"Test Alert \(", card["cardsV2"][0]["card"]["header"]["title"])

View File

@@ -1,161 +0,0 @@
import json
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import pytest
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.alerts import (
get_testdata_file_path,
update_raw_channel_config,
update_rule_channel_name,
verify_notification_expectation,
)
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.notification_channel import (
incidentio_config,
incidentio_event_subset,
incidentio_ok_mappings,
incidentio_path,
incidentio_retry_mappings,
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
LOGS_RULE = "ruler/test_scenarios/threshold_below_at_least_once/rule.json"
INCIDENTIO_CASES = [
types.AlertManagerNotificationTestCase(
name="incidentio_default_metrics_firing",
rule_path=METRICS_RULE,
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
channel_config=incidentio_config("inc-metrics"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": incidentio_path("inc-metrics"),
"count": 1,
"json_body": incidentio_event_subset("threshold_above_at_least_once", [("View in SigNoz", r"/alerts/overview\?ruleId=")]),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="incidentio_rich_event_logs",
rule_path=LOGS_RULE,
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
channel_config=incidentio_config("inc-logs"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": incidentio_path("inc-logs"),
"count": 1,
"json_body": incidentio_event_subset(
"threshold_below_at_least_once",
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View related logs", r"/logs/logs-explorer\?")],
),
},
),
],
),
),
]
@pytest.mark.parametrize(
"incidentio_test_case",
INCIDENTIO_CASES,
ids=lambda c: c.name,
)
def test_incidentio_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
incidentio_test_case: types.AlertManagerNotificationTestCase,
) -> None:
channel_name = str(uuid.uuid4())
path = incidentio_test_case.notification_expectation.notification_validations[0].validation_data["path"]
channel_config = update_raw_channel_config(incidentio_test_case.channel_config, channel_name, notification_channel)
make_http_mocks(notification_channel, incidentio_ok_mappings(path))
create_notification_channel(channel_config)
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
insert_alert_data(incidentio_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(incidentio_test_case.rule_path), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(notification_channel, maildev, incidentio_test_case.notification_expectation)
def test_incidentio_retry_429_then_202( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
) -> None:
channel_name = str(uuid.uuid4())
path = incidentio_path("inc-retry")
channel_config = update_raw_channel_config(incidentio_config("inc-retry"), channel_name, notification_channel)
make_http_mocks(notification_channel, incidentio_retry_mappings(path))
create_notification_channel(channel_config)
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
insert_alert_data([types.AlertData(type="metrics", data_path=METRICS_DATA)], base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(METRICS_RULE), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(
notification_channel,
maildev,
types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
# a retryable 429 is followed by a successful re-POST => >=2 hits
"path": path,
"min_count": 2,
"json_body": {"status": "firing"},
},
),
],
),
)

View File

@@ -1,145 +0,0 @@
import base64
import json
import re
import time
import uuid
from collections.abc import Callable
from http import HTTPStatus
from typing import NamedTuple
import pytest
import requests
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import update_raw_channel_config
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.notification_channel import INCIDENTIO_TEST_TOKEN, incidentio_config, incidentio_path
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
# with a hardcoded test alert and no retry — the deterministic place to assert
# permanent-failure behaviour. Rich events + retry are covered in alertmanager/11_incidentio.py.
# Stub bodies are the recorded incident.io Alert Events V2 responses.
class TestChannelCase(NamedTuple):
__test__ = False
name: str
source: str
status: int # stub status
body: dict # stub body
expect_delivered: bool # expect channels/test 204
TEST_CHANNEL_CASES = [
TestChannelCase("success", "inc-tc-ok", 202, {"status": "accepted", "message": "Event accepted for processing", "deduplication_key": "x"}, True),
TestChannelCase("permanent_401", "inc-tc-401", 401, {"type": "authentication_error", "status": 401, "errors": [{"code": "invalid_authentication_material", "message": "Secret token not valid"}]}, False),
TestChannelCase("permanent_422", "inc-tc-422", 422, {"type": "validation_error", "status": 422, "errors": [{"code": "missing_field", "message": '"title" is missing from body'}]}, False),
]
@pytest.mark.parametrize(
"case",
TEST_CHANNEL_CASES,
ids=lambda c: c.name,
)
def test_incidentio_test_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
case: TestChannelCase,
) -> None:
path = incidentio_path(case.source)
make_http_mocks(
notification_channel,
[
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=case.status, json_body=case.body),
)
],
)
channel_name = str(uuid.uuid4())
receiver = update_raw_channel_config(incidentio_config(case.source), channel_name, notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# channels/test 404s until the org's alertmanager registers (one poll tick),
# without reaching the notifier — so the first non-404 response is the single
# authoritative delivery attempt and the count == 1 assertion below holds
deadline = time.time() + 60
while True:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
if response.status_code != HTTPStatus.NOT_FOUND or time.time() > deadline:
break
time.sleep(2)
if case.expect_delivered:
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
else:
# a downstream 401/422 surfaces as a 500 (untyped notify error) whose body
# carries the real downstream status code; pin it to distinguish 401 vs 422
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"expected 500, got {response.status_code}: {response.text}"
assert f"unexpected status code {case.status}" in response.text, f"expected downstream {case.status} in error body: {response.text}"
# exactly one delivery attempt either way (testChannel never retries)
count = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/count"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
assert count.json()["count"] == 1, f"expected exactly 1 request (no retry), got {count.text}"
find = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/find"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
req = find.json()["requests"][0]
# the configured url is posted verbatim, nothing appended, and the token is
# sent with a single Bearer prefix (header name lowercased on the wire by h2)
assert req["url"] == path, f"expected alert events url {path} posted verbatim, got {req['url']}"
headers = {name.lower(): value for name, value in req["headers"].items()}
assert headers.get("authorization") == f"Bearer {INCIDENTIO_TEST_TOKEN}", f"expected single Bearer prefix, got {headers.get('authorization')}"
if case.expect_delivered:
# alert event shape with the hardcoded test alert
event = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
assert re.search(r"\[FIRING:1\] Test Alert \(", event["title"]), f"unexpected title: {event['title']}"
assert event["status"] == "firing"
assert event["deduplication_key"], "expected a non-empty deduplication_key"
def test_incidentio_test_channel_rejects_bearer_prefix(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
) -> None:
path = incidentio_path("inc-tc-bearer")
receiver = update_raw_channel_config(incidentio_config("inc-tc-bearer"), str(uuid.uuid4()), notification_channel)
receiver["incidentio_configs"][0]["token"] = f"Bearer {INCIDENTIO_TEST_TOKEN}"
# rejected while parsing the receiver, before the org's alertmanager is looked up
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)}"},
timeout=30,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, f"expected 400, got {response.status_code}: {response.text}"
assert "without the Bearer prefix" in response.text, response.text
count = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/count"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
assert count.json()["count"] == 0, f"expected no delivery attempt, got {count.text}"

View File

@@ -1,165 +0,0 @@
import json
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import pytest
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.alerts import (
get_testdata_file_path,
update_raw_channel_config,
update_rule_channel_name,
verify_notification_expectation,
)
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.notification_channel import (
JIRA_API_BASE,
jira_config,
jira_create_mapping,
jira_issue_subset,
jira_retry_search_mappings,
jira_search_mapping,
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
LOGS_RULE = "ruler/test_scenarios/threshold_below_at_least_once/rule.json"
JIRA_CASES = [
types.AlertManagerNotificationTestCase(
name="jira_default_metrics_firing",
rule_path=METRICS_RULE,
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
channel_config=jira_config(),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": f"{JIRA_API_BASE}/issue",
"count": 1,
"json_body": jira_issue_subset("threshold_above_at_least_once", [("Open in SigNoz", r"/alerts/overview\?ruleId=")]),
},
),
types.NotificationValidation(
destination_type="webhook",
validation_data={"path": f"{JIRA_API_BASE}/search/jql", "count": 1},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="jira_rich_issue_logs",
rule_path=LOGS_RULE,
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
channel_config=jira_config(),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": f"{JIRA_API_BASE}/issue",
"count": 1,
"json_body": jira_issue_subset(
"threshold_below_at_least_once",
[("Open in SigNoz", r"/alerts/overview\?ruleId="), ("View Related Logs", r"/logs/logs-explorer\?")],
),
},
),
],
),
),
]
@pytest.mark.parametrize(
"jira_test_case",
JIRA_CASES,
ids=lambda c: c.name,
)
def test_jira_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
jira_test_case: types.AlertManagerNotificationTestCase,
) -> None:
channel_name = str(uuid.uuid4())
channel_config = update_raw_channel_config(jira_test_case.channel_config, channel_name, notification_channel)
make_http_mocks(notification_channel, [jira_search_mapping([]), jira_create_mapping()])
create_notification_channel(channel_config)
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
insert_alert_data(jira_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(jira_test_case.rule_path), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(notification_channel, maildev, jira_test_case.notification_expectation)
def test_jira_retry_429_then_200( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
) -> None:
channel_name = str(uuid.uuid4())
channel_config = update_raw_channel_config(jira_config(), channel_name, notification_channel)
make_http_mocks(notification_channel, [*jira_retry_search_mappings(), jira_create_mapping()])
create_notification_channel(channel_config)
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
insert_alert_data([types.AlertData(type="metrics", data_path=METRICS_DATA)], base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(METRICS_RULE), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(
notification_channel,
maildev,
types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
# a retryable 429 on the search re-runs the whole notify => >=2 searches
validation_data={"path": f"{JIRA_API_BASE}/search/jql", "min_count": 2},
),
types.NotificationValidation(
destination_type="webhook",
# but the issue is still only created once
validation_data={"path": f"{JIRA_API_BASE}/issue", "count": 1},
),
],
),
)

View File

@@ -1,322 +0,0 @@
import base64
import json
import re
import uuid
from collections.abc import Callable
from http import HTTPStatus
from typing import NamedTuple
import pytest
import requests
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import update_raw_channel_config
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.notification_channel import (
JIRA_API_BASE,
JIRA_SA_EMAIL,
JIRA_TEST_EMAIL,
JIRA_TEST_TOKEN,
find_requests,
jira_comment_mapping,
jira_config,
jira_create_mapping,
jira_search_issue,
jira_search_mapping,
jira_transition_post_mapping,
jira_transitions_mapping,
jira_update_mapping,
wait_for_alertmanager_sync,
)
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
# with a hardcoded firing test alert and no retry. The search stub decides which
# branch runs (create / update / reopen), so the whole issue lifecycle is
# deterministic here; default-template events + retry are in alertmanager/13_jira.py.
BASIC_AUTH = "Basic " + base64.b64encode(f"{JIRA_TEST_EMAIL}:{JIRA_TEST_TOKEN}".encode()).decode()
def test_jira_create_issue(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
) -> None:
make_http_mocks(notification_channel, [jira_search_mapping([]), jira_create_mapping()])
receiver = update_raw_channel_config(jira_config(), str(uuid.uuid4()), notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
searches = find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/search/jql")
assert len(searches) == 1
# basic auth on every call (header name lowercased on the wire by h2)
headers = {name.lower(): value for name, value in searches[0]["headers"].items()}
assert headers.get("authorization") == BASIC_AUTH, f"expected basic auth, got {headers.get('authorization')}"
jql = json.loads(base64.b64decode(searches[0]["bodyAsBase64"]).decode("utf-8"))["jql"]
assert 'project="OPS"' in jql, jql
assert 'labels="ALERT{' in jql, jql
# default reopen_duration (72h) becomes the firing reopen window
assert "resolutiondate >= -4320m" in jql, jql
creates = find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue")
assert len(creates) == 1
fields = json.loads(base64.b64decode(creates[0]["bodyAsBase64"]).decode("utf-8"))["fields"]
assert fields["project"] == {"key": "OPS"}
assert fields["issuetype"] == {"name": "Task"}
assert re.search(r"\[FIRING:1\] Test Alert \(", fields["summary"]), fields["summary"]
assert "signoz-alert" in fields["labels"]
assert any(label.startswith("ALERT{") for label in fields["labels"]), fields["labels"]
# ADF body leads with the firing status panel
panel = fields["description"]["content"][0]
assert panel["attrs"] == {"panelType": "error"}
assert panel["content"][0]["content"][0]["text"] == "🔴 FIRING"
def test_jira_wont_fix_resolution_in_search_jql(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
) -> None:
make_http_mocks(notification_channel, [jira_search_mapping([]), jira_create_mapping()])
receiver = update_raw_channel_config(jira_config(wont_fix_resolution="Won't Do"), str(uuid.uuid4()), notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
searches = find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/search/jql")
assert len(searches) == 1
jql = json.loads(base64.b64decode(searches[0]["bodyAsBase64"]).decode("utf-8"))["jql"]
# issues resolved as won't-fix stay closed: the search skips them so a
# refire creates a fresh issue instead of reopening
assert '(resolution is EMPTY or resolution != "Won\'t Do")' in jql, jql
def test_jira_updates_existing_open_issue(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
) -> None:
make_http_mocks(
notification_channel,
[
jira_search_mapping([jira_search_issue("OPS-7", done=False, labels=["user-added", "signoz-alert"])]),
jira_update_mapping("OPS-7"),
jira_comment_mapping("OPS-7"),
],
)
receiver = update_raw_channel_config(jira_config(), str(uuid.uuid4()), notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
# still-firing group with an open issue: refresh + comment, no create, no transition
updates = find_requests(notification_channel, "PUT", f"{JIRA_API_BASE}/issue/OPS-7")
assert len(updates) == 1
fields = json.loads(base64.b64decode(updates[0]["bodyAsBase64"]).decode("utf-8"))["fields"]
assert "user-added" in fields["labels"], f"user-added labels must survive the update: {fields['labels']}"
assert "signoz-alert" in fields["labels"]
assert "project" not in fields and "issuetype" not in fields, "create-only fields must not be sent on update"
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue")) == 0
assert len(find_requests(notification_channel, "GET", f"{JIRA_API_BASE}/issue/OPS-7/transitions")) == 0
comments = find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue/OPS-7/comment")
assert len(comments) == 1
body = json.loads(base64.b64decode(comments[0]["bodyAsBase64"]).decode("utf-8"))["body"]
assert body["content"][0]["attrs"] == {"panelType": "error"}, "comment carries the same ADF snapshot"
def test_jira_reopens_done_issue(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
) -> None:
make_http_mocks(
notification_channel,
[
jira_search_mapping([jira_search_issue("OPS-7", done=True, labels=["signoz-alert"])]),
jira_update_mapping("OPS-7"),
jira_transitions_mapping(
"OPS-7",
[
{"id": "31", "name": "Done", "to": {"statusCategory": {"key": "done"}}},
{"id": "11", "name": "To Do", "to": {"statusCategory": {"key": "new"}}},
],
),
jira_transition_post_mapping("OPS-7"),
jira_comment_mapping("OPS-7"),
],
)
receiver = update_raw_channel_config(jira_config(), str(uuid.uuid4()), notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
# firing group whose issue is done: update, then transition out of done, then comment
assert len(find_requests(notification_channel, "PUT", f"{JIRA_API_BASE}/issue/OPS-7")) == 1
transitions = find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue/OPS-7/transitions")
assert len(transitions) == 1
body = json.loads(base64.b64decode(transitions[0]["bodyAsBase64"]).decode("utf-8"))
assert body == {"transition": {"id": "11"}}, f"expected the not-done transition to be applied: {body}"
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue/OPS-7/comment")) == 1
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue")) == 0
class PermanentErrorCase(NamedTuple):
__test__ = False
name: str
mappings: list[Mapping]
downstream_status: int
search_count: int
create_count: int
PERMANENT_ERROR_CASES = [
PermanentErrorCase(
name="create_400",
mappings=[
jira_search_mapping([]),
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JIRA_API_BASE}/issue"),
response=MappingResponse(status=400, json_body={"errorMessages": [], "errors": {"issuetype": "The issue type selected is invalid."}}),
),
],
downstream_status=400,
search_count=1,
create_count=1,
),
PermanentErrorCase(
name="search_401",
mappings=[
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JIRA_API_BASE}/search/jql"),
response=MappingResponse(status=401, json_body={"errorMessages": ["Client must be authenticated to access this resource."]}),
),
],
downstream_status=401,
search_count=1,
create_count=0,
),
]
@pytest.mark.parametrize(
"case",
PERMANENT_ERROR_CASES,
ids=lambda c: c.name,
)
def test_jira_permanent_error( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
case: PermanentErrorCase,
) -> None:
make_http_mocks(notification_channel, case.mappings)
receiver = update_raw_channel_config(jira_config(), str(uuid.uuid4()), notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
# a downstream 4xx surfaces as a 500 (untyped notify error) whose body
# carries the real downstream status code; testChannel never retries
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"expected 500, got {response.status_code}: {response.text}"
assert f"unexpected status code {case.downstream_status}" in response.text, response.text
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/search/jql")) == case.search_count
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue")) == case.create_count
def test_jira_service_account_uses_gateway(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
) -> None:
cloud_id = "b8e7c297-4c56-4d39-9e1a-000000000001"
gateway_base = f"/ex/jira/{cloud_id}/rest/api/3"
make_http_mocks(
notification_channel,
[
Mapping(
request=MappingRequest(method=HttpMethods.GET, url_path="/_edge/tenant_info"),
response=MappingResponse(status=200, json_body={"cloudId": cloud_id}),
),
jira_search_mapping([], base=gateway_base),
jira_create_mapping(base=gateway_base),
],
)
receiver = update_raw_channel_config(
jira_config(http_config={"basic_auth": {"username": JIRA_SA_EMAIL, "password": JIRA_TEST_TOKEN}}),
str(uuid.uuid4()),
notification_channel,
)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
# cloud id resolved from the site's tenant_info, then every API call goes
# through the api.atlassian.com gateway instead of the site host
assert len(find_requests(notification_channel, "GET", "/_edge/tenant_info")) == 1
assert len(find_requests(notification_channel, "POST", f"{gateway_base}/search/jql")) == 1
assert len(find_requests(notification_channel, "POST", f"{gateway_base}/issue")) == 1
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/search/jql")) == 0

View File

@@ -1,167 +0,0 @@
import json
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import pytest
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.alerts import (
get_testdata_file_path,
update_raw_channel_config,
update_rule_channel_name,
verify_notification_expectation,
)
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.notification_channel import (
JSMOPS_API_BASE,
JSMOPS_NOTES_PATH_PATTERN,
jsmops_alert_subset,
jsmops_config,
jsmops_create_mapping,
jsmops_notes_mapping,
jsmops_retry_create_mappings,
wait_for_alertmanager_sync,
)
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
LOGS_RULE = "ruler/test_scenarios/threshold_below_at_least_once/rule.json"
JSMOPS_CASES = [
types.AlertManagerNotificationTestCase(
name="jsmops_default_metrics_firing",
rule_path=METRICS_RULE,
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
channel_config=jsmops_config(),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": f"{JSMOPS_API_BASE}/v2/alerts",
"count": 1,
"json_body": jsmops_alert_subset("threshold_above_at_least_once", [("View in SigNoz", r"/alerts/overview\?ruleId=")]),
},
),
types.NotificationValidation(
destination_type="webhook",
# every fire appends a timeline note
validation_data={"path_pattern": JSMOPS_NOTES_PATH_PATTERN, "count": 1},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="jsmops_rich_alert_logs",
rule_path=LOGS_RULE,
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
channel_config=jsmops_config(),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": f"{JSMOPS_API_BASE}/v2/alerts",
"count": 1,
"json_body": jsmops_alert_subset(
"threshold_below_at_least_once",
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View related logs", r"/logs/logs-explorer\?")],
),
},
),
],
),
),
]
@pytest.mark.parametrize(
"jsmops_test_case",
JSMOPS_CASES,
ids=lambda c: c.name,
)
def test_jsmops_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
jsmops_test_case: types.AlertManagerNotificationTestCase,
) -> None:
channel_name = str(uuid.uuid4())
channel_config = update_raw_channel_config(jsmops_test_case.channel_config, channel_name, notification_channel)
make_http_mocks(notification_channel, [jsmops_create_mapping(), jsmops_notes_mapping()])
create_notification_channel(channel_config)
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
insert_alert_data(jsmops_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(jsmops_test_case.rule_path), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(notification_channel, maildev, jsmops_test_case.notification_expectation)
def test_jsmops_retry_429_then_202( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
) -> None:
channel_name = str(uuid.uuid4())
channel_config = update_raw_channel_config(jsmops_config(), channel_name, notification_channel)
make_http_mocks(notification_channel, [*jsmops_retry_create_mappings(), jsmops_notes_mapping()])
create_notification_channel(channel_config)
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
insert_alert_data([types.AlertData(type="metrics", data_path=METRICS_DATA)], base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(METRICS_RULE), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(
notification_channel,
maildev,
types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=60,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
# a retryable 429 on the create re-runs the whole notify => >=2 creates
validation_data={"path": f"{JSMOPS_API_BASE}/v2/alerts", "min_count": 2},
),
types.NotificationValidation(
destination_type="webhook",
# the note only goes out after the create succeeded
validation_data={"path_pattern": JSMOPS_NOTES_PATH_PATTERN, "count": 1},
),
],
),
)

View File

@@ -1,163 +0,0 @@
import base64
import json
import re
import uuid
from collections.abc import Callable
from http import HTTPStatus
from typing import NamedTuple
import pytest
import requests
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import update_raw_channel_config
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.notification_channel import (
JSMOPS_API_BASE,
JSMOPS_NOTES_PATH_PATTERN,
JSMOPS_TEST_API_KEY,
find_requests,
jsmops_config,
jsmops_create_mapping,
jsmops_notes_mapping,
wait_for_alertmanager_sync,
)
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
# with a hardcoded firing test alert and no retry: create alert on the JSM Ops
# gateway, then append a timeline note. Default-template events + retry are in
# alertmanager/15_jsmops.py.
def test_jsmops_create_alert_with_note(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
) -> None:
make_http_mocks(notification_channel, [jsmops_create_mapping(), jsmops_notes_mapping()])
receiver = update_raw_channel_config(jsmops_config(), str(uuid.uuid4()), notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
creates = find_requests(notification_channel, "POST", f"{JSMOPS_API_BASE}/v2/alerts")
assert len(creates) == 1
# GenieKey auth on every call (header name lowercased on the wire by h2)
headers = {name.lower(): value for name, value in creates[0]["headers"].items()}
assert headers.get("authorization") == f"GenieKey {JSMOPS_TEST_API_KEY}", f"expected GenieKey auth, got {headers.get('authorization')}"
alert = json.loads(base64.b64decode(creates[0]["bodyAsBase64"]).decode("utf-8"))
assert alert["alias"], "alias carries the group hash for dedup/close"
assert re.search(r"\[FIRING:1\] Test Alert \(", alert["message"]), alert["message"]
assert alert["source"] == "SigNoz"
assert alert["tags"] == ["signoz"]
# advanced treatment renders the default body as HTML
assert "<div>" in alert["description"], alert["description"]
notes = find_requests(notification_channel, "POST", path_pattern=JSMOPS_NOTES_PATH_PATTERN)
assert len(notes) == 1
assert notes[0]["queryParams"]["identifierType"]["values"] == ["alias"]
note = json.loads(base64.b64decode(notes[0]["bodyAsBase64"]).decode("utf-8"))
assert note["source"] == "SigNoz"
assert note["note"].strip(), "the timeline note carries the plain-text snapshot"
def test_jsmops_failed_note_does_not_fail_delivery(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
) -> None:
# notes are enrichment: a permanent note failure (e.g. the first-fire note
# racing JSM's async alert create) is dropped and the delivery still succeeds
make_http_mocks(
notification_channel,
[
jsmops_create_mapping(),
jsmops_notes_mapping(status=404, body={"message": "Alert with id/alias does not exist", "took": 0.001, "requestId": "x"}),
],
)
receiver = update_raw_channel_config(jsmops_config(), str(uuid.uuid4()), notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204 despite the failed note, got {response.status_code}: {response.text}"
assert len(find_requests(notification_channel, "POST", f"{JSMOPS_API_BASE}/v2/alerts")) == 1
assert len(find_requests(notification_channel, "POST", path_pattern=JSMOPS_NOTES_PATH_PATTERN)) == 1
class PermanentErrorCase(NamedTuple):
__test__ = False
name: str
status: int
body: dict
PERMANENT_ERROR_CASES = [
PermanentErrorCase("create_422", 422, {"message": "Message can not be empty.", "took": 0.001, "requestId": "x"}),
PermanentErrorCase("create_401", 401, {"message": "Could not authenticate.", "took": 0.001, "requestId": "x"}),
]
@pytest.mark.parametrize(
"case",
PERMANENT_ERROR_CASES,
ids=lambda c: c.name,
)
def test_jsmops_permanent_error( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
case: PermanentErrorCase,
) -> None:
make_http_mocks(
notification_channel,
[
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JSMOPS_API_BASE}/v2/alerts"),
response=MappingResponse(status=case.status, json_body=case.body),
),
jsmops_notes_mapping(),
],
)
receiver = update_raw_channel_config(jsmops_config(), str(uuid.uuid4()), notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
# a downstream 4xx on the create surfaces as a 500 (untyped notify error)
# whose body carries the real downstream status code; testChannel never retries
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"expected 500, got {response.status_code}: {response.text}"
assert f"unexpected status code {case.status}" in response.text, response.text
assert len(find_requests(notification_channel, "POST", f"{JSMOPS_API_BASE}/v2/alerts")) == 1
# the request loop stops at the failed create, so the note is never attempted
assert len(find_requests(notification_channel, "POST", path_pattern=JSMOPS_NOTES_PATH_PATTERN)) == 0

View File

@@ -13,7 +13,6 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
tls: types.TLS,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
maildev: types.TestContainerDocker,
@@ -25,7 +24,6 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
tls=tls,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz_alertmanager",