mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-10 04:01:04 +01:00
Compare commits
1 Commits
main
...
chore/stag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32a7342d52 |
21
.github/workflows/build-staging.yaml
vendored
21
.github/workflows/build-staging.yaml
vendored
@@ -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"}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -66,7 +66,6 @@ export interface CustomMultiSelectProps extends Omit<
|
||||
maxTagTextLength?: number;
|
||||
showIncompleteDataMessage?: boolean;
|
||||
showLabels?: boolean;
|
||||
preserveOptionOrder?: boolean;
|
||||
enableRegexOption?: boolean;
|
||||
isDynamicVariable?: boolean;
|
||||
showRetryButton?: boolean;
|
||||
|
||||
@@ -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[])
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.overflowValues {
|
||||
max-width: 360px;
|
||||
margin: 0;
|
||||
padding-left: 14px;
|
||||
list-style: disc outside;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 }));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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],
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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]>();
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`. */
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
41
tests/fixtures/alerts.py
vendored
41
tests/fixtures/alerts.py
vendored
@@ -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
|
||||
|
||||
431
tests/fixtures/notification_channel.py
vendored
431
tests/fixtures/notification_channel.py
vendored
@@ -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
13
tests/fixtures/tls.py
vendored
@@ -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],
|
||||
|
||||
@@ -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"}]},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -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"])
|
||||
@@ -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"},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -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},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user