mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-08 04:20:41 +01:00
Compare commits
3 Commits
feat/text-
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2a6e1b786 | ||
|
|
861380dc65 | ||
|
|
391f685e57 |
@@ -397,7 +397,6 @@ identn:
|
||||
# headers to use for tokenizer identN resolver
|
||||
headers:
|
||||
- Authorization
|
||||
- Sec-WebSocket-Protocol
|
||||
apikey:
|
||||
# toggle apikey identN
|
||||
enabled: true
|
||||
|
||||
@@ -25,6 +25,379 @@ components:
|
||||
- data
|
||||
- orgId
|
||||
type: object
|
||||
AlertmanagertypesChannelConfig:
|
||||
discriminator:
|
||||
mapping:
|
||||
email: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
|
||||
googlechat: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
|
||||
incidentio: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
|
||||
jira: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
|
||||
jsmops: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
|
||||
msteams: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
|
||||
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
|
||||
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
|
||||
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
|
||||
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- email
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelEmailConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- googlechat
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelGoogleChatConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- incidentio
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelIncidentIOConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- jsmops
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelJSMOpsConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- jira
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelJiraConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- msteams
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelMSTeamsConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- opsgenie
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelOpsgenieConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- pagerduty
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelPagerdutyConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- slack
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelSlackConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- webhook
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelWebhookConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelEmailConfig:
|
||||
properties:
|
||||
headers:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
html:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
to:
|
||||
type: string
|
||||
required:
|
||||
- to
|
||||
type: object
|
||||
AlertmanagertypesChannelGoogleChatConfig:
|
||||
properties:
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
webhookUrl:
|
||||
type: string
|
||||
required:
|
||||
- webhookUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelIncidentIOConfig:
|
||||
properties:
|
||||
description:
|
||||
type: string
|
||||
metadata:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
title:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
- token
|
||||
type: object
|
||||
AlertmanagertypesChannelJSMOpsConfig:
|
||||
properties:
|
||||
apiKey:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
tags:
|
||||
type: string
|
||||
required:
|
||||
- apiKey
|
||||
type: object
|
||||
AlertmanagertypesChannelJiraConfig:
|
||||
properties:
|
||||
apiToken:
|
||||
type: string
|
||||
customFields:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
issueType:
|
||||
type: string
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
priority:
|
||||
type: string
|
||||
project:
|
||||
type: string
|
||||
reopenDuration:
|
||||
type: string
|
||||
reopenTransition:
|
||||
type: string
|
||||
resolveTransition:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
site:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
wontFixResolution:
|
||||
type: string
|
||||
required:
|
||||
- site
|
||||
- project
|
||||
- issueType
|
||||
- email
|
||||
- apiToken
|
||||
type: object
|
||||
AlertmanagertypesChannelKind:
|
||||
enum:
|
||||
- slack
|
||||
- email
|
||||
- webhook
|
||||
- pagerduty
|
||||
- opsgenie
|
||||
- msteams
|
||||
- googlechat
|
||||
- jira
|
||||
- jsmops
|
||||
- incidentio
|
||||
type: string
|
||||
AlertmanagertypesChannelMSTeamsConfig:
|
||||
properties:
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
webhookUrl:
|
||||
type: string
|
||||
required:
|
||||
- webhookUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelOpsgenieConfig:
|
||||
properties:
|
||||
apiKey:
|
||||
type: string
|
||||
apiUrl:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
message:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
source:
|
||||
type: string
|
||||
required:
|
||||
- apiKey
|
||||
type: object
|
||||
AlertmanagertypesChannelPagerdutyConfig:
|
||||
properties:
|
||||
class:
|
||||
type: string
|
||||
client:
|
||||
type: string
|
||||
clientUrl:
|
||||
type: string
|
||||
component:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
details:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
group:
|
||||
type: string
|
||||
routingKey:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
severity:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
required:
|
||||
- routingKey
|
||||
type: object
|
||||
AlertmanagertypesChannelSlackConfig:
|
||||
properties:
|
||||
apiUrl:
|
||||
type: string
|
||||
channel:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
text:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
- apiUrl
|
||||
type: object
|
||||
AlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
bearerToken:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
url:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
required:
|
||||
- url
|
||||
type: object
|
||||
AlertmanagertypesDeprecatedGettableAlert:
|
||||
properties:
|
||||
annotations:
|
||||
@@ -54,6 +427,30 @@ components:
|
||||
- rule
|
||||
- policy
|
||||
type: string
|
||||
AlertmanagertypesGettableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- displayName
|
||||
- config
|
||||
- id
|
||||
- createdAt
|
||||
- updatedAt
|
||||
type: object
|
||||
AlertmanagertypesGettableRoutePolicy:
|
||||
properties:
|
||||
channels:
|
||||
@@ -356,6 +753,19 @@ components:
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
AlertmanagertypesPostableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
displayName:
|
||||
type: string
|
||||
generateName:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
AlertmanagertypesPostablePlannedMaintenance:
|
||||
properties:
|
||||
alertIds:
|
||||
@@ -19309,6 +19719,69 @@ paths:
|
||||
summary: Get metrics treemap
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/notification_channels:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a notification channel
|
||||
operationId: CreateNotificationChannel
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesPostableNotificationChannel'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
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
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- notification-channel:create
|
||||
- tokenizer:
|
||||
- notification-channel:create
|
||||
summary: Create notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/orgs/me:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -184,7 +184,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
apiHandler.RegisterIntegrationRoutes(r, am)
|
||||
apiHandler.RegisterQueryRangeV3Routes(r, am)
|
||||
apiHandler.RegisterQueryRangeV4Routes(r, am)
|
||||
apiHandler.RegisterWebSocketPaths(r, am)
|
||||
apiHandler.RegisterMessagingQueuesRoutes(r, am)
|
||||
apiHandler.RegisterThirdPartyApiRoutes(r, am)
|
||||
apiHandler.RegisterTraceFunnelsRoutes(r, am)
|
||||
@@ -197,7 +196,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
|
||||
})
|
||||
|
||||
handler := c.Handler(r)
|
||||
|
||||
@@ -19,8 +19,10 @@ import type {
|
||||
|
||||
import type {
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
AlertmanagertypesPostableNotificationChannelDTO,
|
||||
AlertmanagertypesReceiverDTO,
|
||||
CreateChannel201,
|
||||
CreateNotificationChannel201,
|
||||
DeleteChannelByIDPathParameters,
|
||||
GetChannelByID200,
|
||||
GetChannelByIDPathParameters,
|
||||
@@ -647,3 +649,87 @@ export const useTestChannelDeprecated = <
|
||||
> => {
|
||||
return useMutation(getTestChannelDeprecatedMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint creates a notification channel
|
||||
* @summary Create notification channel
|
||||
*/
|
||||
export const createNotificationChannel = (
|
||||
alertmanagertypesPostableNotificationChannelDTO?: BodyType<AlertmanagertypesPostableNotificationChannelDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateNotificationChannel201>({
|
||||
url: `/api/v2/notification_channels`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: alertmanagertypesPostableNotificationChannelDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createNotificationChannel'];
|
||||
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 createNotificationChannel>>,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createNotificationChannel(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>
|
||||
>;
|
||||
export type CreateNotificationChannelMutationBody =
|
||||
| BodyType<AlertmanagertypesPostableNotificationChannelDTO>
|
||||
| undefined;
|
||||
export type CreateNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create notification channel
|
||||
*/
|
||||
export const useCreateNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesPostableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateNotificationChannelMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -37,6 +37,476 @@ export interface AlertmanagertypesChannelDTO {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind {
|
||||
slack = 'slack',
|
||||
}
|
||||
export interface AlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiUrl: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
channel?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO {
|
||||
/**
|
||||
* @enum slack
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelSlackConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind {
|
||||
email = 'email',
|
||||
}
|
||||
export type AlertmanagertypesChannelEmailConfigDTOHeaders = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelEmailConfigDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
headers?: AlertmanagertypesChannelEmailConfigDTOHeaders;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
html?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO {
|
||||
/**
|
||||
* @enum email
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelEmailConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind {
|
||||
webhook = 'webhook',
|
||||
}
|
||||
export interface AlertmanagertypesChannelWebhookConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
bearerToken?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO {
|
||||
/**
|
||||
* @enum webhook
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelWebhookConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind {
|
||||
pagerduty = 'pagerduty',
|
||||
}
|
||||
export type AlertmanagertypesChannelPagerdutyConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
client?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
clientUrl?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
component?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelPagerdutyConfigDTODetails;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
group?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
routingKey: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
severity?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO {
|
||||
/**
|
||||
* @enum pagerduty
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelPagerdutyConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind {
|
||||
opsgenie = 'opsgenie',
|
||||
}
|
||||
export type AlertmanagertypesChannelOpsgenieConfigDTODetails = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiKey: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiUrl?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
details?: AlertmanagertypesChannelOpsgenieConfigDTODetails;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO {
|
||||
/**
|
||||
* @enum opsgenie
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelOpsgenieConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind {
|
||||
msteams = 'msteams',
|
||||
}
|
||||
export interface AlertmanagertypesChannelMSTeamsConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
webhookUrl: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO {
|
||||
/**
|
||||
* @enum msteams
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelMSTeamsConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind {
|
||||
googlechat = 'googlechat',
|
||||
}
|
||||
export interface AlertmanagertypesChannelGoogleChatConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
webhookUrl: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO {
|
||||
/**
|
||||
* @enum googlechat
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelGoogleChatConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind {
|
||||
jira = 'jira',
|
||||
}
|
||||
export type AlertmanagertypesChannelJiraConfigDTOCustomFields = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiToken: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
customFields?: AlertmanagertypesChannelJiraConfigDTOCustomFields;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
email: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issueType: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopenDuration?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopenTransition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolveTransition?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
site: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
summary?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wontFixResolution?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO {
|
||||
/**
|
||||
* @enum jira
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelJiraConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind {
|
||||
jsmops = 'jsmops',
|
||||
}
|
||||
export interface AlertmanagertypesChannelJSMOpsConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
apiKey: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO {
|
||||
/**
|
||||
* @enum jsmops
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelJSMOpsConfigDTO;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind {
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export type AlertmanagertypesChannelIncidentIOConfigDTOMetadata = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesChannelIncidentIOConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
metadata?: AlertmanagertypesChannelIncidentIOConfigDTOMetadata;
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
sendResolved?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
title?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
token: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO {
|
||||
/**
|
||||
* @enum incidentio
|
||||
* @type string
|
||||
*/
|
||||
kind: AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTOKind;
|
||||
spec: AlertmanagertypesChannelIncidentIOConfigDTO;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesChannelConfigDTO =
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelMSTeamsConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelGoogleChatConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfigDTO
|
||||
| AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfigDTO;
|
||||
|
||||
export enum AlertmanagertypesChannelKindDTO {
|
||||
slack = 'slack',
|
||||
email = 'email',
|
||||
webhook = 'webhook',
|
||||
pagerduty = 'pagerduty',
|
||||
opsgenie = 'opsgenie',
|
||||
msteams = 'msteams',
|
||||
googlechat = 'googlechat',
|
||||
jira = 'jira',
|
||||
jsmops = 'jsmops',
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export interface ModelLabelSetDTO {
|
||||
[key: string]: string;
|
||||
}
|
||||
@@ -88,6 +558,32 @@ export enum AlertmanagertypesExpressionKindDTO {
|
||||
rule = 'rule',
|
||||
policy = 'policy',
|
||||
}
|
||||
export interface AlertmanagertypesGettableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesGettableRoutePolicyDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -1748,6 +2244,22 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
|
||||
wechat_configs?: ConfigWechatConfigDTO[];
|
||||
};
|
||||
|
||||
export interface AlertmanagertypesPostableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
generateName?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -12650,6 +13162,14 @@ export type GetMetricsTreemap200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateNotificationChannel201 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMyOrganization200 = {
|
||||
data: TypesOrganizationDTO;
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,10 @@ import { cloneDeep, isEmpty } from 'lodash-es';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import {
|
||||
BuilderQuery,
|
||||
DistributionData,
|
||||
MetricRangePayloadV5,
|
||||
QueryEnvelope,
|
||||
QueryRangeRequestV5,
|
||||
RawData,
|
||||
ScalarData,
|
||||
@@ -11,6 +13,11 @@ import {
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
const isBuilderQueryEnvelope = (
|
||||
envelope: QueryEnvelope,
|
||||
): envelope is QueryEnvelope & { spec: BuilderQuery } =>
|
||||
envelope.type === 'builder_query' || envelope.type === 'builder_ai_query';
|
||||
|
||||
function getColName(
|
||||
col: ScalarData['columns'][number],
|
||||
legendMap: Record<string, string>,
|
||||
@@ -409,21 +416,19 @@ export function convertV5ResponseToLegacy(
|
||||
const v5Data = payload?.data;
|
||||
|
||||
const aggregationPerQuery =
|
||||
params?.compositeQuery?.queries
|
||||
?.filter((query) => query.type === 'builder_query')
|
||||
.reduce(
|
||||
(acc, query) => {
|
||||
if (
|
||||
query.type === 'builder_query' &&
|
||||
'aggregations' in query.spec &&
|
||||
query.spec.name
|
||||
) {
|
||||
acc[query.spec.name] = query.spec.aggregations;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
) || {};
|
||||
params?.compositeQuery?.queries?.filter(isBuilderQueryEnvelope).reduce(
|
||||
(acc, query) => {
|
||||
if (
|
||||
isBuilderQueryEnvelope(query) &&
|
||||
'aggregations' in query.spec &&
|
||||
query.spec.name
|
||||
) {
|
||||
acc[query.spec.name] = query.spec.aggregations;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
) || {};
|
||||
|
||||
// clickhouse_sql queries have no aggregation metadata; their value columns
|
||||
// are named/keyed by the real SQL alias the response carries (see getColId).
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
QueryBuilderFormula as V5QueryBuilderFormula,
|
||||
QueryEnvelope,
|
||||
QueryRangePayloadV5,
|
||||
RequestType,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -935,3 +936,41 @@ describe('convertBuilderQueriesToV5 having normalization', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertBuilderQueriesToV5 builder query type', () => {
|
||||
const buildEnvelope = (
|
||||
builderQueryType: IBuilderQuery['builderQueryType'],
|
||||
requestType: RequestType,
|
||||
): QueryEnvelope => {
|
||||
const [envelope] = convertBuilderQueriesToV5(
|
||||
{
|
||||
A: {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
builderQueryType,
|
||||
} as unknown as IBuilderQuery,
|
||||
},
|
||||
requestType,
|
||||
);
|
||||
return envelope;
|
||||
};
|
||||
|
||||
it.each<[RequestType]>([
|
||||
['trace'],
|
||||
['raw'],
|
||||
['time_series'],
|
||||
['scalar'],
|
||||
['distribution'],
|
||||
])('sends builder_ai_query for the %s request type', (requestType) => {
|
||||
expect(buildEnvelope('builder_ai_query', requestType).type).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it.each<[string, IBuilderQuery['builderQueryType']]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('sends builder_query for %s', (_label, builderQueryType) => {
|
||||
expect(buildEnvelope(builderQueryType, 'trace').type).toBe('builder_query');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -365,7 +365,7 @@ export function convertBuilderQueriesToV5(
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'builder_query' as QueryType,
|
||||
type: queryData.builderQueryType ?? 'builder_query',
|
||||
spec,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -16,8 +16,6 @@ import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
|
||||
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
negationQueryOperatorSuggestions,
|
||||
@@ -54,6 +52,12 @@ import {
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import {
|
||||
fetchFieldKeysForQuery,
|
||||
fetchFieldValuesForQuery,
|
||||
SuggestedFieldKey,
|
||||
SuggestedFieldKeysByName,
|
||||
} from './fieldSuggestions';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -261,10 +265,8 @@ function QuerySearch({
|
||||
const dashboardDynamicVariables = useDynamicVariableSuggestions();
|
||||
|
||||
// Add back the generateOptions function and useEffect
|
||||
const generateOptions = (keys: {
|
||||
[key: string]: QueryKeyDataSuggestionsProps[];
|
||||
}): any[] =>
|
||||
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
|
||||
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
|
||||
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
|
||||
items.map(({ name, fieldDataType, fieldContext }) => ({
|
||||
label: name,
|
||||
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
|
||||
@@ -317,8 +319,9 @@ function QuerySearch({
|
||||
|
||||
lastFetchedKeyRef.current = searchText || '';
|
||||
|
||||
const response = await getKeySuggestions({
|
||||
signal: dataSource,
|
||||
const response = await fetchFieldKeysForQuery({
|
||||
builderQueryType: queryData.builderQueryType,
|
||||
dataSource,
|
||||
searchText: searchText || '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
@@ -360,6 +363,7 @@ function QuerySearch({
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
metricNamespace,
|
||||
queryData.builderQueryType,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -493,10 +497,11 @@ function QuerySearch({
|
||||
try {
|
||||
const values = valueSuggestionsOverride
|
||||
? await valueSuggestionsOverride(key, sanitizedSearchText)
|
||||
: await getValueSuggestions({
|
||||
: await fetchFieldValuesForQuery({
|
||||
builderQueryType: queryData.builderQueryType,
|
||||
dataSource,
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
}).then((response) => {
|
||||
@@ -601,6 +606,7 @@ function QuerySearch({
|
||||
signalSource,
|
||||
toggleSuggestions,
|
||||
valueSuggestionsOverride,
|
||||
queryData.builderQueryType,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
getAIObservabilityFieldsKeys,
|
||||
getAIObservabilityFieldsValues,
|
||||
} from 'api/generated/services/ai-observability';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
fetchFieldKeysForQuery,
|
||||
fetchFieldValuesForQuery,
|
||||
} from '../fieldSuggestions';
|
||||
|
||||
jest.mock('api/generated/services/ai-observability', () => ({
|
||||
getAIObservabilityFieldsKeys: jest.fn(),
|
||||
getAIObservabilityFieldsValues: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
|
||||
typeof getAIObservabilityFieldsKeys
|
||||
>;
|
||||
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
>;
|
||||
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
|
||||
typeof getAIObservabilityFieldsValues
|
||||
>;
|
||||
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
|
||||
typeof getValueSuggestions
|
||||
>;
|
||||
|
||||
const aiValuesResponse = (
|
||||
values: { stringValues?: string[]; numberValues?: number[] } | null,
|
||||
complete = true,
|
||||
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
|
||||
({
|
||||
status: 'success',
|
||||
data: { complete, values },
|
||||
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
|
||||
|
||||
describe('fetchFieldKeysForQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
mockedAIKeys.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
|
||||
},
|
||||
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
|
||||
|
||||
const keys = await fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: 'llm',
|
||||
});
|
||||
|
||||
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
|
||||
expect(mockedGenericKeys).not.toHaveBeenCalled();
|
||||
expect(keys.data.data).toStrictEqual({
|
||||
complete: true,
|
||||
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, 'builder_query' | undefined]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
|
||||
mockedGenericKeys.mockResolvedValue({
|
||||
data: { status: 'success', data: { complete: true, keys: {} } },
|
||||
} as Awaited<ReturnType<typeof getKeySuggestions>>);
|
||||
|
||||
await fetchFieldKeysForQuery({
|
||||
builderQueryType,
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: 'svc',
|
||||
});
|
||||
|
||||
expect(mockedAIKeys).not.toHaveBeenCalled();
|
||||
expect(mockedGenericKeys).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a null ai_observability keys payload to an empty map', async () => {
|
||||
mockedAIKeys.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { complete: false, keys: null },
|
||||
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
|
||||
|
||||
const response = await fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: '',
|
||||
});
|
||||
|
||||
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
|
||||
});
|
||||
|
||||
it('passes the generic response through untouched', async () => {
|
||||
const genericResponse = {
|
||||
data: { status: 'success', data: { complete: true, keys: {} } },
|
||||
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
|
||||
mockedGenericKeys.mockResolvedValue(genericResponse);
|
||||
|
||||
await expect(
|
||||
fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: '',
|
||||
}),
|
||||
).resolves.toBe(genericResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFieldValuesForQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
mockedAIValues.mockResolvedValue(
|
||||
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
|
||||
);
|
||||
|
||||
const response = await fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'gen_ai.request.model',
|
||||
searchText: 'gpt',
|
||||
});
|
||||
|
||||
expect(mockedGenericValues).not.toHaveBeenCalled();
|
||||
expect(response).toStrictEqual({
|
||||
data: {
|
||||
data: {
|
||||
complete: true,
|
||||
values: { stringValues: ['gpt-4o'], numberValues: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the key as the name the endpoint expects', async () => {
|
||||
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
|
||||
|
||||
await fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'total_tokens',
|
||||
searchText: '',
|
||||
});
|
||||
|
||||
expect(mockedAIValues).toHaveBeenCalledWith({
|
||||
name: 'total_tokens',
|
||||
searchText: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
|
||||
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
|
||||
|
||||
await expect(
|
||||
fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'llm_call_count',
|
||||
searchText: '',
|
||||
}),
|
||||
).resolves.toStrictEqual({
|
||||
data: { data: { complete: false, values: null } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, 'builder_query' | undefined]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
|
||||
const genericResponse = {
|
||||
data: {
|
||||
data: { complete: false, values: { stringValues: ['frontend'] } },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
|
||||
mockedGenericValues.mockResolvedValue(genericResponse);
|
||||
|
||||
const response = await fetchFieldValuesForQuery({
|
||||
builderQueryType,
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'service.name',
|
||||
searchText: 'front',
|
||||
});
|
||||
|
||||
expect(mockedAIValues).not.toHaveBeenCalled();
|
||||
expect(mockedGenericValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signal: DataSource.TRACES,
|
||||
key: 'service.name',
|
||||
searchText: 'front',
|
||||
}),
|
||||
);
|
||||
expect(response).toBe(genericResponse);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
getAIObservabilityFieldsKeys,
|
||||
getAIObservabilityFieldsValues,
|
||||
} from 'api/generated/services/ai-observability';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export interface SuggestedFieldKey {
|
||||
name: string;
|
||||
fieldContext?: string;
|
||||
fieldDataType?: string;
|
||||
}
|
||||
|
||||
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
|
||||
|
||||
export interface SuggestedFieldKeysPayload {
|
||||
complete: boolean;
|
||||
keys: SuggestedFieldKeysByName;
|
||||
}
|
||||
|
||||
export interface SuggestedFieldKeysResponse {
|
||||
data: { data?: SuggestedFieldKeysPayload };
|
||||
}
|
||||
|
||||
export interface SuggestedFieldValuesPayload {
|
||||
complete?: boolean;
|
||||
values?: {
|
||||
stringValues?: string[] | null;
|
||||
numberValues?: number[] | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SuggestedFieldValuesResponse {
|
||||
data: { data?: SuggestedFieldValuesPayload };
|
||||
}
|
||||
|
||||
interface FetchFieldKeysParams {
|
||||
builderQueryType: IBuilderQuery['builderQueryType'];
|
||||
dataSource: DataSource;
|
||||
searchText: string;
|
||||
metricName?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
metricNamespace?: string;
|
||||
}
|
||||
|
||||
interface FetchFieldValuesParams {
|
||||
builderQueryType: IBuilderQuery['builderQueryType'];
|
||||
dataSource: DataSource;
|
||||
key: string;
|
||||
searchText: string;
|
||||
metricName?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
}
|
||||
|
||||
export const fetchFieldKeysForQuery = async ({
|
||||
builderQueryType,
|
||||
dataSource,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
metricNamespace,
|
||||
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
|
||||
if (builderQueryType === 'builder_ai_query') {
|
||||
const response = await getAIObservabilityFieldsKeys({ searchText });
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: response.data
|
||||
? { complete: response.data.complete, keys: response.data.keys ?? {} }
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
metricNamespace,
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchFieldValuesForQuery = async ({
|
||||
builderQueryType,
|
||||
dataSource,
|
||||
key,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
|
||||
if (builderQueryType === 'builder_ai_query') {
|
||||
const response = await getAIObservabilityFieldsValues({
|
||||
name: key,
|
||||
searchText,
|
||||
});
|
||||
|
||||
return { data: { data: response.data } };
|
||||
}
|
||||
|
||||
// getValueSuggestions' declared response type does not match what the endpoint returns.
|
||||
return getValueSuggestions({
|
||||
signal: dataSource,
|
||||
key,
|
||||
searchText,
|
||||
signalSource,
|
||||
metricName,
|
||||
}) as unknown as Promise<SuggestedFieldValuesResponse>;
|
||||
};
|
||||
@@ -54,7 +54,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
const { dataSource } = query;
|
||||
const { dataSource, builderQueryType } = query;
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
@@ -94,8 +94,9 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
);
|
||||
|
||||
const showSpanScopeSelector = useMemo(
|
||||
() => dataSource === DataSource.TRACES,
|
||||
[dataSource],
|
||||
() =>
|
||||
dataSource === DataSource.TRACES && builderQueryType !== 'builder_ai_query',
|
||||
[dataSource, builderQueryType],
|
||||
);
|
||||
|
||||
const showInlineQuerySearch = useMemo(() => {
|
||||
|
||||
@@ -348,6 +348,19 @@ export const initialQueryMeterWithType: Query = {
|
||||
},
|
||||
};
|
||||
|
||||
export const initialQueryAIWithType: Query = {
|
||||
...initialQueryWithType,
|
||||
builder: {
|
||||
...initialQueryWithType.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
builderQueryType: 'builder_ai_query',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const operatorsByTypes: Record<LocalDataType, string[]> = {
|
||||
string: Object.values(StringOperators),
|
||||
number: Object.values(NumberOperators),
|
||||
|
||||
@@ -11,17 +11,13 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import {
|
||||
getExportQueryData,
|
||||
getQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
@@ -52,6 +48,7 @@ import {
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { TOOLBAR_VIEWS } from './constants';
|
||||
import { getExportQueryData, getQueryByPanelType } from './explorerUtils';
|
||||
import ListView from './ListView/ListView';
|
||||
import { defaultSelectedColumns } from './ListView/configs';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
@@ -118,7 +115,7 @@ function Explorer(): JSX.Element {
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.traces,
|
||||
initialQueryAIWithType,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
@@ -185,7 +182,7 @@ function Explorer(): JSX.Element {
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
getQueryByPanelType(
|
||||
stagedQuery || initialQueriesMap.traces,
|
||||
stagedQuery || initialQueryAIWithType,
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
),
|
||||
[stagedQuery, panelType],
|
||||
|
||||
@@ -17,12 +17,11 @@ import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import {
|
||||
getTraceLink,
|
||||
transformSpanRows,
|
||||
@@ -43,6 +42,7 @@ import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
@@ -94,7 +94,7 @@ function ListView({
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -10,33 +8,16 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => ({
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: isList, isDisabled: true },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps) => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
limit: { isHidden: false, isDisabled: true },
|
||||
having: { isHidden: false, isDisabled: true },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
|
||||
const shouldRenderCustomOrderBy =
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
|
||||
|
||||
return {
|
||||
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
|
||||
};
|
||||
}, [panelTypes, renderOrderBy]);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
@@ -45,14 +26,10 @@ function QuerySection(): JSX.Element {
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isListViewPanel={isListViewPanel}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
showOnlyWhereClause={isListViewPanel}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,10 +14,9 @@ import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
@@ -31,6 +30,7 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import { getListViewQuery } from '../explorerUtils';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
@@ -60,7 +60,7 @@ function TracesView({
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { cloneDeep, set } from 'lodash-es';
|
||||
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const getListViewQuery = (
|
||||
stagedQuery: Query,
|
||||
orderBy?: string,
|
||||
): Query => {
|
||||
const query = stagedQuery
|
||||
? cloneDeep(stagedQuery)
|
||||
: cloneDeep(initialQueriesMap.traces);
|
||||
|
||||
const orderByPayload: OrderByPayload[] = orderBy
|
||||
? [
|
||||
{
|
||||
columnName: orderBy.split(':')[0],
|
||||
order: orderBy.split(':')[1] as 'asc' | 'desc',
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
for (let i = 0; i < query.builder.queryData.length; i++) {
|
||||
const queryData = query.builder.queryData[i];
|
||||
queryData.groupBy = [];
|
||||
queryData.having = {
|
||||
expression: '',
|
||||
};
|
||||
queryData.orderBy = orderByPayload;
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const getQueryByPanelType = (
|
||||
stagedQuery: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE) {
|
||||
return getListViewQuery(stagedQuery);
|
||||
}
|
||||
return stagedQuery;
|
||||
};
|
||||
|
||||
export const getExportQueryData = (
|
||||
query: Query,
|
||||
panelType: PANEL_TYPES,
|
||||
options: OptionsQuery,
|
||||
): Query => {
|
||||
if (panelType === PANEL_TYPES.LIST) {
|
||||
const updatedQuery = cloneDeep(query);
|
||||
set(
|
||||
updatedQuery,
|
||||
'builder.queryData[0].selectColumns',
|
||||
options.selectColumns,
|
||||
);
|
||||
|
||||
return updatedQuery;
|
||||
}
|
||||
return query;
|
||||
};
|
||||
@@ -108,7 +108,6 @@ function LogsExplorerViewsContainer({
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [logs, setLogs] = useState<ILog[]>([]);
|
||||
const [requestData, setRequestData] = useState<Query | null>(null);
|
||||
const [queryId, setQueryId] = useState<string>(v4());
|
||||
const [listChartQuery, setListChartQuery] = useState<Query | null>(null);
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
@@ -180,12 +179,7 @@ function LogsExplorerViewsContainer({
|
||||
},
|
||||
undefined,
|
||||
listQueryKeyRef,
|
||||
{
|
||||
...(!isEmpty(queryId) &&
|
||||
selectedPanelType !== PANEL_TYPES.LIST && {
|
||||
'X-SIGNOZ-QUERY-ID': queryId,
|
||||
}),
|
||||
},
|
||||
undefined,
|
||||
// custom selected time interval to prevent recalculating the start and end timestamps before fetching next pages
|
||||
'custom',
|
||||
);
|
||||
@@ -250,10 +244,6 @@ function LogsExplorerViewsContainer({
|
||||
setRequestData(newRequestData);
|
||||
}, [isLimit, logs, listQuery, pageSize, stagedQuery, getRequestData, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setQueryId(v4());
|
||||
}, [data]);
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current && !isUndefined(data?.payload)) {
|
||||
|
||||
@@ -475,6 +475,7 @@ export function QueryBuilderProvider({
|
||||
const newQuery: IBuilderQuery = {
|
||||
...initialBuilderQuery,
|
||||
source: queries?.[0]?.source || '',
|
||||
builderQueryType: queries?.[0]?.builderQueryType,
|
||||
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
|
||||
expression: createNewBuilderItemName({
|
||||
existNames,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryAIWithType,
|
||||
} from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
const renderQueryBuilder = (
|
||||
initialQuery: Query,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useQueryBuilder>, unknown>
|
||||
> => {
|
||||
const hook = renderHook(() => useQueryBuilder(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
hook.result.current.initQueryBuilderData(initialQuery);
|
||||
});
|
||||
|
||||
return hook;
|
||||
};
|
||||
|
||||
describe('createNewBuilderQuery builderQueryType propagation', () => {
|
||||
it('carries builderQueryType from the first query onto an added query', () => {
|
||||
const { result } = renderQueryBuilder(initialQueryAIWithType);
|
||||
|
||||
expect(
|
||||
result.current.currentQuery.builder.queryData[0].builderQueryType,
|
||||
).toBe('builder_ai_query');
|
||||
|
||||
act(() => {
|
||||
result.current.addNewBuilderQuery();
|
||||
});
|
||||
|
||||
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
|
||||
expect(
|
||||
result.current.currentQuery.builder.queryData[1].builderQueryType,
|
||||
).toBe('builder_ai_query');
|
||||
});
|
||||
|
||||
it('leaves builderQueryType unset when the first query has none', () => {
|
||||
const { result } = renderQueryBuilder(initialQueriesMap.traces);
|
||||
|
||||
act(() => {
|
||||
result.current.addNewBuilderQuery();
|
||||
});
|
||||
|
||||
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
|
||||
expect(
|
||||
result.current.currentQuery.builder.queryData[1].builderQueryType,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
BuilderQueryType,
|
||||
Filter,
|
||||
Having as HavingV5,
|
||||
LogAggregation,
|
||||
@@ -90,6 +91,7 @@ export type IBuilderQuery = {
|
||||
offset?: number;
|
||||
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
|
||||
source?: 'meter' | '';
|
||||
builderQueryType?: BuilderQueryType;
|
||||
};
|
||||
|
||||
export interface IClickHouseQuery {
|
||||
|
||||
@@ -16,6 +16,7 @@ export type RequestType =
|
||||
|
||||
export type QueryType =
|
||||
| 'builder_query'
|
||||
| 'builder_ai_query'
|
||||
| 'builder_trace_operator'
|
||||
| 'builder_formula'
|
||||
| 'builder_sub_query'
|
||||
@@ -23,6 +24,11 @@ export type QueryType =
|
||||
| 'clickhouse_sql'
|
||||
| 'promql';
|
||||
|
||||
export type BuilderQueryType = Extract<
|
||||
QueryType,
|
||||
'builder_query' | 'builder_ai_query'
|
||||
>;
|
||||
|
||||
export type OrderDirection = 'asc' | 'desc';
|
||||
|
||||
export type JoinType = 'inner' | 'left' | 'right' | 'full' | 'cross';
|
||||
|
||||
2
go.mod
2
go.mod
@@ -31,7 +31,6 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674
|
||||
github.com/huandu/go-sqlbuilder v1.39.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12
|
||||
@@ -134,6 +133,7 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
||||
github.com/huandu/go-clone v1.7.3 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
|
||||
@@ -46,6 +46,10 @@ type Alertmanager interface {
|
||||
// CreateChannel creates a channel for the organization.
|
||||
CreateChannel(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)
|
||||
|
||||
// CreateNotificationChannel takes the postable rather than a receiver, because
|
||||
// a receiver carries only the display name.
|
||||
CreateNotificationChannel(context.Context, string, *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)
|
||||
|
||||
// DeleteChannelByID deletes a channel for the organization.
|
||||
DeleteChannelByID(context.Context, string, valuer.UUID) error
|
||||
|
||||
|
||||
@@ -155,8 +155,8 @@ func (_c *MockAlertmanager_Config_Call) RunAndReturn(run func() alertmanagerserv
|
||||
}
|
||||
|
||||
// CreateChannel provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
|
||||
ret := _mock.Called(context1, s, v)
|
||||
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, receiver *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
|
||||
ret := _mock.Called(context1, s, receiver)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CreateChannel")
|
||||
@@ -165,17 +165,17 @@ func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string,
|
||||
var r0 *alertmanagertypes.Channel
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)); ok {
|
||||
return returnFunc(context1, s, v)
|
||||
return returnFunc(context1, s, receiver)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) *alertmanagertypes.Channel); ok {
|
||||
r0 = returnFunc(context1, s, v)
|
||||
r0 = returnFunc(context1, s, receiver)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*alertmanagertypes.Channel)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
|
||||
r1 = returnFunc(context1, s, v)
|
||||
r1 = returnFunc(context1, s, receiver)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
@@ -190,12 +190,12 @@ type MockAlertmanager_CreateChannel_Call struct {
|
||||
// CreateChannel is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - v *alertmanagertypes.Receiver
|
||||
func (_e *MockAlertmanager_Expecter) CreateChannel(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_CreateChannel_Call {
|
||||
return &MockAlertmanager_CreateChannel_Call{Call: _e.mock.On("CreateChannel", context1, s, v)}
|
||||
// - receiver *alertmanagertypes.Receiver
|
||||
func (_e *MockAlertmanager_Expecter) CreateChannel(context1 interface{}, s interface{}, receiver interface{}) *MockAlertmanager_CreateChannel_Call {
|
||||
return &MockAlertmanager_CreateChannel_Call{Call: _e.mock.On("CreateChannel", context1, s, receiver)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
@@ -223,7 +223,7 @@ func (_c *MockAlertmanager_CreateChannel_Call) Return(channel *alertmanagertypes
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -291,6 +291,80 @@ func (_c *MockAlertmanager_CreateInhibitRules_Call) RunAndReturn(run func(ctx co
|
||||
return _c
|
||||
}
|
||||
|
||||
// CreateNotificationChannel provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) CreateNotificationChannel(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
ret := _mock.Called(context1, s, postableNotificationChannel)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for CreateNotificationChannel")
|
||||
}
|
||||
|
||||
var r0 *alertmanagertypes.Channel
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)); ok {
|
||||
return returnFunc(context1, s, postableNotificationChannel)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) *alertmanagertypes.Channel); ok {
|
||||
r0 = returnFunc(context1, s, postableNotificationChannel)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*alertmanagertypes.Channel)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) error); ok {
|
||||
r1 = returnFunc(context1, s, postableNotificationChannel)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// MockAlertmanager_CreateNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateNotificationChannel'
|
||||
type MockAlertmanager_CreateNotificationChannel_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// CreateNotificationChannel is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - postableNotificationChannel *alertmanagertypes.PostableNotificationChannel
|
||||
func (_e *MockAlertmanager_Expecter) CreateNotificationChannel(context1 interface{}, s interface{}, postableNotificationChannel interface{}) *MockAlertmanager_CreateNotificationChannel_Call {
|
||||
return &MockAlertmanager_CreateNotificationChannel_Call{Call: _e.mock.On("CreateNotificationChannel", context1, s, postableNotificationChannel)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_CreateNotificationChannel_Call) Run(run func(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel)) *MockAlertmanager_CreateNotificationChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(context.Context)
|
||||
}
|
||||
var arg1 string
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 *alertmanagertypes.PostableNotificationChannel
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(*alertmanagertypes.PostableNotificationChannel)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
arg2,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_CreateNotificationChannel_Call) Return(channel *alertmanagertypes.Channel, err error) *MockAlertmanager_CreateNotificationChannel_Call {
|
||||
_c.Call.Return(channel, err)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_CreateNotificationChannel_Call) RunAndReturn(run func(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateNotificationChannel_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// CreateRoutePolicies provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) CreateRoutePolicies(ctx context.Context, routeRequests []*alertmanagertypes.PostableRoutePolicy) ([]*alertmanagertypes.GettableRoutePolicy, error) {
|
||||
ret := _mock.Called(ctx, routeRequests)
|
||||
@@ -1624,8 +1698,8 @@ func (_c *MockAlertmanager_TestAlert_Call) RunAndReturn(run func(ctx context.Con
|
||||
}
|
||||
|
||||
// TestReceiver provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, v *alertmanagertypes.Receiver) error {
|
||||
ret := _mock.Called(context1, s, v)
|
||||
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, receiver *alertmanagertypes.Receiver) error {
|
||||
ret := _mock.Called(context1, s, receiver)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for TestReceiver")
|
||||
@@ -1633,7 +1707,7 @@ func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string,
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
|
||||
r0 = returnFunc(context1, s, v)
|
||||
r0 = returnFunc(context1, s, receiver)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
@@ -1648,12 +1722,12 @@ type MockAlertmanager_TestReceiver_Call struct {
|
||||
// TestReceiver is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - v *alertmanagertypes.Receiver
|
||||
func (_e *MockAlertmanager_Expecter) TestReceiver(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_TestReceiver_Call {
|
||||
return &MockAlertmanager_TestReceiver_Call{Call: _e.mock.On("TestReceiver", context1, s, v)}
|
||||
// - receiver *alertmanagertypes.Receiver
|
||||
func (_e *MockAlertmanager_Expecter) TestReceiver(context1 interface{}, s interface{}, receiver interface{}) *MockAlertmanager_TestReceiver_Call {
|
||||
return &MockAlertmanager_TestReceiver_Call{Call: _e.mock.On("TestReceiver", context1, s, receiver)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
@@ -1681,7 +1755,7 @@ func (_c *MockAlertmanager_TestReceiver_Call) Return(err error) *MockAlertmanage
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1750,8 +1824,8 @@ func (_c *MockAlertmanager_UpdateAllRoutePoliciesByRuleId_Call) RunAndReturn(run
|
||||
}
|
||||
|
||||
// UpdateChannelByReceiverAndID provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error {
|
||||
ret := _mock.Called(context1, s, v, uUID)
|
||||
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, receiver *alertmanagertypes.Receiver, uUID valuer.UUID) error {
|
||||
ret := _mock.Called(context1, s, receiver, uUID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for UpdateChannelByReceiverAndID")
|
||||
@@ -1759,7 +1833,7 @@ func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Con
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver, valuer.UUID) error); ok {
|
||||
r0 = returnFunc(context1, s, v, uUID)
|
||||
r0 = returnFunc(context1, s, receiver, uUID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
@@ -1774,13 +1848,13 @@ type MockAlertmanager_UpdateChannelByReceiverAndID_Call struct {
|
||||
// UpdateChannelByReceiverAndID is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - v *alertmanagertypes.Receiver
|
||||
// - receiver *alertmanagertypes.Receiver
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockAlertmanager_Expecter) UpdateChannelByReceiverAndID(context1 interface{}, s interface{}, v interface{}, uUID interface{}) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
return &MockAlertmanager_UpdateChannelByReceiverAndID_Call{Call: _e.mock.On("UpdateChannelByReceiverAndID", context1, s, v, uUID)}
|
||||
func (_e *MockAlertmanager_Expecter) UpdateChannelByReceiverAndID(context1 interface{}, s interface{}, receiver interface{}, uUID interface{}) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
return &MockAlertmanager_UpdateChannelByReceiverAndID_Call{Call: _e.mock.On("UpdateChannelByReceiverAndID", context1, s, receiver, uUID)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
@@ -1813,7 +1887,7 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Return(err error)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, receiver *alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1965,6 +2039,52 @@ func (_c *MockHandler_CreateChannel_Call) RunAndReturn(run func(responseWriter h
|
||||
return _c
|
||||
}
|
||||
|
||||
// CreateNotificationChannel provides a mock function for the type MockHandler
|
||||
func (_mock *MockHandler) CreateNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
_mock.Called(responseWriter, request)
|
||||
return
|
||||
}
|
||||
|
||||
// MockHandler_CreateNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateNotificationChannel'
|
||||
type MockHandler_CreateNotificationChannel_Call struct {
|
||||
*mock.Call
|
||||
}
|
||||
|
||||
// CreateNotificationChannel is a helper method to define mock.On call
|
||||
// - responseWriter http.ResponseWriter
|
||||
// - request *http.Request
|
||||
func (_e *MockHandler_Expecter) CreateNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_CreateNotificationChannel_Call {
|
||||
return &MockHandler_CreateNotificationChannel_Call{Call: _e.mock.On("CreateNotificationChannel", responseWriter, request)}
|
||||
}
|
||||
|
||||
func (_c *MockHandler_CreateNotificationChannel_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_CreateNotificationChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 http.ResponseWriter
|
||||
if args[0] != nil {
|
||||
arg0 = args[0].(http.ResponseWriter)
|
||||
}
|
||||
var arg1 *http.Request
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(*http.Request)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
arg1,
|
||||
)
|
||||
})
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_CreateNotificationChannel_Call) Return() *MockHandler_CreateNotificationChannel_Call {
|
||||
_c.Call.Return()
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockHandler_CreateNotificationChannel_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_CreateNotificationChannel_Call {
|
||||
_c.Run(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
// CreateRoutePolicy provides a mock function for the type MockHandler
|
||||
func (_mock *MockHandler) CreateRoutePolicy(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
_mock.Called(responseWriter, request)
|
||||
|
||||
@@ -19,6 +19,8 @@ type Handler interface {
|
||||
|
||||
DeleteChannelByID(http.ResponseWriter, *http.Request)
|
||||
|
||||
CreateNotificationChannel(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetAllRoutePolicies(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetRoutePolicyByID(http.ResponseWriter, *http.Request)
|
||||
|
||||
43
pkg/alertmanager/signozalertmanager/handler_channel.go
Normal file
43
pkg/alertmanager/signozalertmanager/handler_channel.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package signozalertmanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
)
|
||||
|
||||
func (handler *handler) CreateNotificationChannel(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
postable := new(alertmanagertypes.PostableNotificationChannel)
|
||||
if err := binding.JSON.BindBody(req.Body, postable); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
channel, err := handler.alertmanager.CreateNotificationChannel(ctx, claims.OrgID, postable)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
gettable, err := channel.ToGettableNotificationChannel()
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, gettable)
|
||||
}
|
||||
@@ -244,6 +244,40 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID string, postable *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
|
||||
receiver, err := postable.ToReceiver()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := provider.configStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := config.SetGlobalConfig(provider.config.Signoz.Global); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := config.CreateReceiverV2(receiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
channel, err := alertmanagertypes.NewChannelFromReceiverWithName(receiver, postable.Name, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = provider.configStore.CreateChannel(ctx, channel, alertmanagertypes.WithCb(func(ctx context.Context) error {
|
||||
return provider.configStore.Set(ctx, config)
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
func (provider *provider) Config() alertmanagerserver.Config {
|
||||
return provider.config.Signoz.Config
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -129,6 +131,33 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/notification_channels", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.CreateNotificationChannel, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateNotificationChannel",
|
||||
Tags: []string{"channels"},
|
||||
Summary: "Create notification channel",
|
||||
Description: "This endpoint creates a notification channel",
|
||||
Request: new(alertmanagertypes.PostableNotificationChannel),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.GettableNotificationChannel),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceNotificationChannel,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/route_policies", handler.New(provider.authzMiddleware.ViewAccess(provider.alertmanagerHandler.GetAllRoutePolicies), handler.OpenAPIDef{
|
||||
ID: "GetAllRoutePolicies",
|
||||
Tags: []string{"routepolicies"},
|
||||
|
||||
@@ -45,7 +45,7 @@ func newConfig() factory.Config {
|
||||
return &Config{
|
||||
Tokenizer: TokenizerConfig{
|
||||
Enabled: true,
|
||||
Headers: []string{"Authorization", "Sec-WebSocket-Protocol"},
|
||||
Headers: []string{"Authorization"},
|
||||
},
|
||||
APIKeyConfig: APIKeyConfig{
|
||||
Enabled: true,
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
package queryprogress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/exp/maps"
|
||||
)
|
||||
|
||||
// tracks progress and manages subscriptions for all queries
|
||||
type inMemoryQueryProgressTracker struct {
|
||||
logger *slog.Logger
|
||||
queries map[string]*queryTracker
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) ReportQueryStarted(
|
||||
queryId string,
|
||||
) (postQueryCleanup func(), apiErr *model.ApiError) {
|
||||
tracker.lock.Lock()
|
||||
defer tracker.lock.Unlock()
|
||||
|
||||
_, exists := tracker.queries[queryId]
|
||||
if exists {
|
||||
return nil, model.BadRequest(fmt.Errorf(
|
||||
"query %s already started", queryId,
|
||||
))
|
||||
}
|
||||
|
||||
tracker.queries[queryId] = newQueryTracker(tracker.logger, queryId)
|
||||
|
||||
return func() {
|
||||
tracker.onQueryFinished(queryId)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) ReportQueryProgress(
|
||||
queryId string, chProgress *clickhouse.Progress,
|
||||
) *model.ApiError {
|
||||
queryTracker, err := tracker.getQueryTracker(queryId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
queryTracker.handleProgressUpdate(chProgress)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) SubscribeToQueryProgress(
|
||||
queryId string,
|
||||
) (<-chan model.QueryProgress, func(), *model.ApiError) {
|
||||
queryTracker, err := tracker.getQueryTracker(queryId)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return queryTracker.subscribe()
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) onQueryFinished(
|
||||
queryId string,
|
||||
) {
|
||||
tracker.lock.Lock()
|
||||
queryTracker := tracker.queries[queryId]
|
||||
if queryTracker != nil {
|
||||
delete(tracker.queries, queryId)
|
||||
}
|
||||
tracker.lock.Unlock()
|
||||
|
||||
if queryTracker != nil {
|
||||
queryTracker.onFinished()
|
||||
}
|
||||
}
|
||||
|
||||
func (tracker *inMemoryQueryProgressTracker) getQueryTracker(
|
||||
queryId string,
|
||||
) (*queryTracker, *model.ApiError) {
|
||||
tracker.lock.RLock()
|
||||
defer tracker.lock.RUnlock()
|
||||
|
||||
queryTracker := tracker.queries[queryId]
|
||||
if queryTracker == nil {
|
||||
return nil, model.NotFoundError(fmt.Errorf(
|
||||
"query %s doesn't exist", queryId,
|
||||
))
|
||||
}
|
||||
|
||||
return queryTracker, nil
|
||||
}
|
||||
|
||||
// Tracks progress and manages subscriptions for a single query
|
||||
type queryTracker struct {
|
||||
logger *slog.Logger
|
||||
queryId string
|
||||
isFinished bool
|
||||
|
||||
progress *model.QueryProgress
|
||||
subscriptions map[string]*queryProgressSubscription
|
||||
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func newQueryTracker(logger *slog.Logger, queryId string) *queryTracker {
|
||||
return &queryTracker{
|
||||
logger: logger,
|
||||
queryId: queryId,
|
||||
subscriptions: map[string]*queryProgressSubscription{},
|
||||
}
|
||||
}
|
||||
|
||||
func (qt *queryTracker) handleProgressUpdate(p *clickhouse.Progress) {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
qt.logger.Warn("received clickhouse progress update for finished query", "queryId", qt.queryId, "progress", p)
|
||||
return
|
||||
}
|
||||
|
||||
if qt.progress == nil {
|
||||
// This is the first update
|
||||
qt.progress = &model.QueryProgress{}
|
||||
}
|
||||
updateQueryProgress(qt.progress, p)
|
||||
|
||||
// broadcast latest state to all subscribers.
|
||||
for _, sub := range maps.Values(qt.subscriptions) {
|
||||
sub.send(*qt.progress)
|
||||
}
|
||||
}
|
||||
|
||||
func (qt *queryTracker) subscribe() (
|
||||
<-chan model.QueryProgress, func(), *model.ApiError,
|
||||
) {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
return nil, nil, model.NotFoundError(fmt.Errorf(
|
||||
"query %s already finished", qt.queryId,
|
||||
))
|
||||
}
|
||||
|
||||
subscriberId := uuid.NewString()
|
||||
subscription := newQueryProgressSubscription(qt.logger)
|
||||
qt.subscriptions[subscriberId] = subscription
|
||||
|
||||
if qt.progress != nil {
|
||||
subscription.send(*qt.progress)
|
||||
}
|
||||
|
||||
return subscription.ch, func() {
|
||||
qt.unsubscribe(subscriberId)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (qt *queryTracker) unsubscribe(subscriberId string) {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
qt.logger.Debug("received unsubscribe request after query finished", "subscriber", subscriberId, "queryId", qt.queryId)
|
||||
return
|
||||
}
|
||||
|
||||
subscription := qt.subscriptions[subscriberId]
|
||||
if subscription != nil {
|
||||
subscription.close()
|
||||
delete(qt.subscriptions, subscriberId)
|
||||
}
|
||||
}
|
||||
|
||||
func (qt *queryTracker) onFinished() {
|
||||
qt.lock.Lock()
|
||||
defer qt.lock.Unlock()
|
||||
|
||||
if qt.isFinished {
|
||||
qt.logger.Warn("receiver query finish report after query finished", "queryId", qt.queryId)
|
||||
return
|
||||
}
|
||||
|
||||
for subId, sub := range qt.subscriptions {
|
||||
sub.close()
|
||||
delete(qt.subscriptions, subId)
|
||||
}
|
||||
|
||||
qt.isFinished = true
|
||||
}
|
||||
|
||||
type queryProgressSubscription struct {
|
||||
logger *slog.Logger
|
||||
ch chan model.QueryProgress
|
||||
isClosed bool
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func newQueryProgressSubscription(logger *slog.Logger) *queryProgressSubscription {
|
||||
ch := make(chan model.QueryProgress, 1000)
|
||||
return &queryProgressSubscription{
|
||||
logger: logger,
|
||||
ch: ch,
|
||||
}
|
||||
}
|
||||
|
||||
// Must not block or panic in any scenario
|
||||
func (ch *queryProgressSubscription) send(progress model.QueryProgress) {
|
||||
ch.lock.Lock()
|
||||
defer ch.lock.Unlock()
|
||||
|
||||
if ch.isClosed {
|
||||
ch.logger.Error("can't send query progress: channel already closed.", "progress", progress)
|
||||
return
|
||||
}
|
||||
|
||||
// subscription channels are expected to have big enough buffers to ensure
|
||||
// blocking while sending doesn't happen in the happy path
|
||||
select {
|
||||
case ch.ch <- progress:
|
||||
ch.logger.Debug("published query progress", "progress", progress)
|
||||
default:
|
||||
ch.logger.Error("couldn't publish query progress. dropping update.", "progress", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func (ch *queryProgressSubscription) close() {
|
||||
ch.lock.Lock()
|
||||
defer ch.lock.Unlock()
|
||||
|
||||
if !ch.isClosed {
|
||||
close(ch.ch)
|
||||
ch.isClosed = true
|
||||
}
|
||||
}
|
||||
|
||||
func updateQueryProgress(qp *model.QueryProgress, chProgress *clickhouse.Progress) {
|
||||
qp.ReadRows += chProgress.Rows
|
||||
qp.ReadBytes += chProgress.Bytes
|
||||
qp.ElapsedMs += uint64(chProgress.Elapsed.Milliseconds())
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package queryprogress
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
)
|
||||
|
||||
type QueryProgressTracker interface {
|
||||
// Tells the tracker that query with id `queryId` has started.
|
||||
// Progress can only be reported for and tracked for a query that is in progress.
|
||||
// Returns a cleanup function that must be called after the query finishes.
|
||||
ReportQueryStarted(queryId string) (postQueryCleanup func(), apiErr *model.ApiError)
|
||||
|
||||
// Report progress stats received from clickhouse for `queryId`
|
||||
ReportQueryProgress(queryId string, chProgress *clickhouse.Progress) *model.ApiError
|
||||
|
||||
// Subscribe to progress updates for `queryId`
|
||||
// The returned channel will produce `QueryProgress` instances representing
|
||||
// the latest state of query progress stats. Also returns a function that
|
||||
// can be called to unsubscribe before the query finishes, if needed.
|
||||
SubscribeToQueryProgress(queryId string) (ch <-chan model.QueryProgress, unsubscribe func(), apiErr *model.ApiError)
|
||||
}
|
||||
|
||||
func NewQueryProgressTracker(logger *slog.Logger) QueryProgressTracker {
|
||||
// InMemory tracker is useful only for single replica query service setups.
|
||||
// Multi replica setups must use a centralized store for tracking and subscribing to query progress
|
||||
return &inMemoryQueryProgressTracker{
|
||||
logger: logger,
|
||||
queries: map[string]*queryTracker{},
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package queryprogress
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestQueryProgressTracking(t *testing.T) {
|
||||
require := require.New(t)
|
||||
|
||||
tracker := NewQueryProgressTracker(slog.Default())
|
||||
|
||||
testQueryId := "test-query"
|
||||
|
||||
testProgress := &clickhouse.Progress{}
|
||||
err := tracker.ReportQueryProgress(testQueryId, testProgress)
|
||||
require.NotNil(err, "shouldn't be able to report query progress before query has been started")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
|
||||
ch, unsubscribe, err := tracker.SubscribeToQueryProgress(testQueryId)
|
||||
require.NotNil(err, "shouldn't be able to subscribe for progress updates before query has been started")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
require.Nil(ch)
|
||||
require.Nil(unsubscribe)
|
||||
|
||||
reportQueryFinished, err := tracker.ReportQueryStarted(testQueryId)
|
||||
require.Nil(err, "should be able to report start of a query to be tracked")
|
||||
|
||||
testProgress1 := &clickhouse.Progress{
|
||||
Rows: 10,
|
||||
Bytes: 20,
|
||||
TotalRows: 100,
|
||||
Elapsed: 20 * time.Millisecond,
|
||||
}
|
||||
err = tracker.ReportQueryProgress(testQueryId, testProgress1)
|
||||
require.Nil(err, "should be able to report progress after query has started")
|
||||
|
||||
ch, unsubscribe, err = tracker.SubscribeToQueryProgress(testQueryId)
|
||||
require.Nil(err, "should be able to subscribe to query progress updates after query started")
|
||||
require.NotNil(ch)
|
||||
require.NotNil(unsubscribe)
|
||||
|
||||
expectedProgress := model.QueryProgress{}
|
||||
updateQueryProgress(&expectedProgress, testProgress1)
|
||||
require.Equal(expectedProgress.ReadRows, testProgress1.Rows)
|
||||
select {
|
||||
case qp := <-ch:
|
||||
require.Equal(qp, expectedProgress)
|
||||
default:
|
||||
require.Fail("should receive latest query progress state immediately after subscription")
|
||||
}
|
||||
select {
|
||||
case _ = <-ch:
|
||||
require.Fail("should have had only one pending update at this point")
|
||||
default:
|
||||
}
|
||||
|
||||
testProgress2 := &clickhouse.Progress{
|
||||
Rows: 20,
|
||||
Bytes: 40,
|
||||
TotalRows: 100,
|
||||
Elapsed: 40 * time.Millisecond,
|
||||
}
|
||||
err = tracker.ReportQueryProgress(testQueryId, testProgress2)
|
||||
require.Nil(err, "should be able to report progress multiple times while query is in progress")
|
||||
|
||||
updateQueryProgress(&expectedProgress, testProgress2)
|
||||
select {
|
||||
case qp := <-ch:
|
||||
require.Equal(qp, expectedProgress)
|
||||
default:
|
||||
require.Fail("should receive updates whenever new progress updates get reported to tracker")
|
||||
}
|
||||
select {
|
||||
case _ = <-ch:
|
||||
require.Fail("should have had only one pending update at this point")
|
||||
default:
|
||||
}
|
||||
|
||||
reportQueryFinished()
|
||||
select {
|
||||
case _, isSubscriptionChannelOpen := <-ch:
|
||||
require.False(isSubscriptionChannelOpen, "subscription channels should get closed after query finishes")
|
||||
default:
|
||||
require.Fail("subscription channels should get closed after query finishes")
|
||||
}
|
||||
|
||||
err = tracker.ReportQueryProgress(testQueryId, testProgress)
|
||||
require.NotNil(err, "shouldn't be able to report query progress after query has finished")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
|
||||
ch, unsubscribe, err = tracker.SubscribeToQueryProgress(testQueryId)
|
||||
require.NotNil(err, "shouldn't be able to subscribe for progress updates after query has finished")
|
||||
require.Equal(err.Type(), model.ErrorNotFound)
|
||||
require.Nil(ch)
|
||||
require.Nil(unsubscribe)
|
||||
}
|
||||
@@ -44,7 +44,6 @@ import (
|
||||
|
||||
"log/slog"
|
||||
|
||||
queryprogress "github.com/SigNoz/signoz/pkg/query-service/app/clickhouseReader/query_progress"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/resource"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/services"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/traces/smart"
|
||||
@@ -145,7 +144,6 @@ type ClickHouseReader struct {
|
||||
logsResourceKeys string
|
||||
logsTagAttributeTableV2 string
|
||||
logger *slog.Logger
|
||||
queryProgressTracker queryprogress.QueryProgressTracker
|
||||
|
||||
logsTableV2 string
|
||||
logsLocalTableV2 string
|
||||
@@ -214,7 +212,6 @@ func NewReader(
|
||||
logsTagAttributeTableV2: options.primary.LogsTagAttributeTableV2,
|
||||
liveTailRefreshSeconds: options.primary.LiveTailRefreshSeconds,
|
||||
cluster: cluster,
|
||||
queryProgressTracker: queryprogress.NewQueryProgressTracker(logger),
|
||||
logsTableV2: options.primary.LogsTableV2,
|
||||
logsLocalTableV2: options.primary.LogsLocalTableV2,
|
||||
logsResourceTableV2: options.primary.LogsResourceTableV2,
|
||||
@@ -4024,27 +4021,6 @@ func (r *ClickHouseReader) GetTimeSeriesResultV3(ctx context.Context, query stri
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetTimeSeriesResultV3",
|
||||
})
|
||||
// Hook up query progress reporting if requested.
|
||||
queryId := ctx.Value("queryId")
|
||||
if queryId != nil {
|
||||
qid, ok := queryId.(string)
|
||||
if !ok {
|
||||
r.logger.Error("GetTimeSeriesResultV3: queryId in ctx not a string as expected", "queryId", queryId)
|
||||
|
||||
} else {
|
||||
ctx = clickhouse.Context(ctx, clickhouse.WithProgress(
|
||||
func(p *clickhouse.Progress) {
|
||||
go func() {
|
||||
err := r.queryProgressTracker.ReportQueryProgress(qid, p)
|
||||
if err != nil {
|
||||
r.logger.Error("Couldn't report query progress", "queryId", qid, errorsV2.Attr(err))
|
||||
}
|
||||
}()
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
|
||||
if err != nil {
|
||||
@@ -5005,18 +4981,6 @@ func (r *ClickHouseReader) GetMinAndMaxTimestampForTraceID(ctx context.Context,
|
||||
return minTime.UnixNano(), maxTime.UnixNano(), nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) ReportQueryStartForProgressTracking(
|
||||
queryId string,
|
||||
) (func(), *model.ApiError) {
|
||||
return r.queryProgressTracker.ReportQueryStarted(queryId)
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) SubscribeToQueryProgress(
|
||||
queryId string,
|
||||
) (<-chan model.QueryProgress, func(), *model.ApiError) {
|
||||
return r.queryProgressTracker.SubscribeToQueryProgress(queryId)
|
||||
}
|
||||
|
||||
func (r *ClickHouseReader) UpdateMetricsMetadata(ctx context.Context, orgID valuer.UUID, req *model.UpdateMetricsMetadata) *model.ApiError {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
|
||||
|
||||
@@ -37,7 +37,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
@@ -116,9 +115,6 @@ type APIHandler struct {
|
||||
// is registers.
|
||||
SetupCompleted bool
|
||||
|
||||
// Websocket connection upgrader
|
||||
Upgrader *websocket.Upgrader
|
||||
|
||||
LicensingAPI licensing.API
|
||||
|
||||
QueryParserAPI *queryparser.API
|
||||
@@ -213,12 +209,6 @@ func NewAPIHandler(opts APIHandlerOpts, config signoz.Config) (*APIHandler, erro
|
||||
aH.SetupCompleted = true
|
||||
}
|
||||
|
||||
aH.Upgrader = &websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
return aH, nil
|
||||
}
|
||||
|
||||
@@ -360,18 +350,10 @@ func (aH *APIHandler) RegisterQueryRangeV3Routes(router *mux.Router, am *middlew
|
||||
|
||||
subRouter.HandleFunc("/filter_suggestions", am.ViewAccess(aH.getQueryBuilderSuggestions)).Methods(http.MethodGet)
|
||||
|
||||
// TODO(Raj): Remove this handler after /ws based path has been completely rolled out.
|
||||
subRouter.HandleFunc("/query_progress", am.ViewAccess(aH.GetQueryProgressUpdates)).Methods(http.MethodGet)
|
||||
|
||||
// live logs
|
||||
subRouter.HandleFunc("/logs/livetail", am.ViewAccess(aH.Signoz.Handlers.QuerierHandler.QueryRawStream)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) RegisterWebSocketPaths(router *mux.Router, am *middleware.AuthZ) {
|
||||
subRouter := router.PathPrefix("/ws").Subrouter()
|
||||
subRouter.HandleFunc("/query_progress", am.ViewAccess(aH.GetQueryProgressUpdates)).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) RegisterQueryRangeV4Routes(router *mux.Router, am *middleware.AuthZ) {
|
||||
subRouter := router.PathPrefix("/api/v4").Subrouter()
|
||||
subRouter.HandleFunc("/query_range", am.ViewAccess(aH.QueryRangeV4)).Methods(http.MethodPost)
|
||||
@@ -3551,27 +3533,6 @@ func (aH *APIHandler) queryRangeV3(ctx context.Context, queryRangeParams *v3.Que
|
||||
}
|
||||
}
|
||||
|
||||
// Hook up query progress tracking if requested
|
||||
queryIdHeader := r.Header.Get("X-SIGNOZ-QUERY-ID")
|
||||
if len(queryIdHeader) > 0 {
|
||||
onQueryFinished, apiErr := aH.reader.ReportQueryStartForProgressTracking(queryIdHeader)
|
||||
|
||||
if apiErr != nil {
|
||||
aH.logger.ErrorContext(ctx, "failed to report query start for progress tracking",
|
||||
"query_id", queryIdHeader, errors.Attr(apiErr),
|
||||
)
|
||||
|
||||
} else {
|
||||
// Adding queryId to the context signals clickhouse queries to report progress
|
||||
//lint:ignore SA1029 ignore for now
|
||||
ctx = context.WithValue(ctx, "queryId", queryIdHeader)
|
||||
|
||||
defer func() {
|
||||
go onQueryFinished()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "app",
|
||||
instrumentationtypes.CodeFunctionName: "QueryRange",
|
||||
@@ -3753,73 +3714,6 @@ func (aH *APIHandler) QueryRangeV3(w http.ResponseWriter, r *http.Request) {
|
||||
aH.queryRangeV3(r.Context(), queryRangeParams, w, r)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) GetQueryProgressUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
// Upgrade connection to websocket, sending back the requested protocol
|
||||
// value for sec-websocket-protocol
|
||||
//
|
||||
// Since js websocket API doesn't allow setting headers, this header is often
|
||||
// used for passing auth tokens. As per websocket spec the connection will only
|
||||
// succeed if the requested `Sec-Websocket-Protocol` is sent back as a header
|
||||
// in the upgrade response (signifying that the protocol is supported by the server).
|
||||
upgradeResponseHeaders := http.Header{}
|
||||
requestedProtocol := r.Header.Get("Sec-WebSocket-Protocol")
|
||||
if len(requestedProtocol) > 0 {
|
||||
upgradeResponseHeaders.Add("Sec-WebSocket-Protocol", requestedProtocol)
|
||||
}
|
||||
|
||||
c, err := aH.Upgrader.Upgrade(w, r, upgradeResponseHeaders)
|
||||
if err != nil {
|
||||
RespondError(w, model.InternalError(fmt.Errorf(
|
||||
"couldn't upgrade connection: %w", err,
|
||||
)), nil)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
// Websocket upgrade complete. Subscribe to query progress and send updates to client
|
||||
//
|
||||
// Note: we handle any subscription problems (queryId query param missing or query already complete etc)
|
||||
// after the websocket connection upgrade by closing the channel.
|
||||
// The other option would be to handle the errors before websocket upgrade by sending an
|
||||
// error response instead of the upgrade response, but that leads to a generic websocket
|
||||
// connection failure on the client.
|
||||
|
||||
queryId := r.URL.Query().Get("q")
|
||||
|
||||
progressCh, unsubscribe, apiErr := aH.reader.SubscribeToQueryProgress(queryId)
|
||||
if apiErr != nil {
|
||||
// Shouldn't happen unless query progress requested after query finished
|
||||
aH.logger.WarnContext(r.Context(), "failed to subscribe to query progress",
|
||||
"query_id", queryId, errors.Attr(apiErr),
|
||||
)
|
||||
return
|
||||
}
|
||||
defer func() { go unsubscribe() }()
|
||||
|
||||
for queryProgress := range progressCh {
|
||||
msg, err := json.Marshal(queryProgress)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "failed to serialize progress message",
|
||||
"query_id", queryId, "progress", queryProgress, errors.Attr(err),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
err = c.WriteMessage(websocket.TextMessage, msg)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "failed to write progress message to websocket",
|
||||
"query_id", queryId, "msg", string(msg), errors.Attr(err),
|
||||
)
|
||||
break
|
||||
|
||||
} else {
|
||||
aH.logger.DebugContext(r.Context(), "wrote progress message to websocket",
|
||||
"query_id", queryId, "msg", string(msg),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getMetricMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -166,7 +166,6 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
api.RegisterLogsRoutes(r, am)
|
||||
api.RegisterIntegrationRoutes(r, am)
|
||||
api.RegisterQueryRangeV3Routes(r, am)
|
||||
api.RegisterWebSocketPaths(r, am)
|
||||
api.RegisterQueryRangeV4Routes(r, am)
|
||||
api.RegisterMessagingQueuesRoutes(r, am)
|
||||
api.RegisterThirdPartyApiRoutes(r, am)
|
||||
@@ -180,7 +179,7 @@ func (s *Server) createPublicServer(api *APIHandler, web web.Web) (*http.Server,
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control", "X-SIGNOZ-QUERY-ID", "Sec-WebSocket-Protocol"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "cache-control"},
|
||||
})
|
||||
|
||||
handler := c.Handler(r)
|
||||
|
||||
@@ -91,10 +91,6 @@ type Reader interface {
|
||||
|
||||
GetMinAndMaxTimestampForTraceID(ctx context.Context, traceID []string) (int64, int64, error)
|
||||
|
||||
// Query Progress tracking helpers.
|
||||
ReportQueryStartForProgressTracking(queryId string) (reportQueryFinished func(), apiErr *model.ApiError)
|
||||
SubscribeToQueryProgress(queryId string) (<-chan model.QueryProgress, func(), *model.ApiError)
|
||||
|
||||
//trace
|
||||
GetTraceFields(ctx context.Context) (*model.GetFieldsResponse, *model.ApiError)
|
||||
UpdateTraceField(ctx context.Context, field *model.UpdateField) *model.ApiError
|
||||
|
||||
@@ -7,14 +7,6 @@ import (
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
)
|
||||
|
||||
type QueryProgress struct {
|
||||
ReadRows uint64 `json:"read_rows"`
|
||||
|
||||
ReadBytes uint64 `json:"read_bytes"`
|
||||
|
||||
ElapsedMs uint64 `json:"elapsed_ms"`
|
||||
}
|
||||
|
||||
func GetLogFieldsV3(ctx context.Context, queryRangeParams *v3.QueryRangeParamsV3, fields *GetFieldsResponse) map[string]v3.AttributeKey {
|
||||
data := map[string]v3.AttributeKey{}
|
||||
for _, query := range queryRangeParams.CompositeQuery.BuilderQueries {
|
||||
|
||||
106
pkg/types/alertmanagertypes/channelapi.go
Normal file
106
pkg/types/alertmanagertypes/channelapi.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Postable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Name is the immutable DNS1123 identity references will point at; DisplayName is the
|
||||
// free-text label.
|
||||
type PostableNotificationChannel struct {
|
||||
Name string `json:"name"`
|
||||
GenerateName bool `json:"generateName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Config ChannelConfig `json:"config" required:"true"`
|
||||
}
|
||||
|
||||
func (p *PostableNotificationChannel) UnmarshalJSON(data []byte) error {
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
type alias PostableNotificationChannel
|
||||
var tmp alias
|
||||
if err := dec.Decode(&tmp); err != nil {
|
||||
if errors.Ast(err, errors.TypeInvalidInput) {
|
||||
return err
|
||||
}
|
||||
return errors.WrapInvalidInputf(err, ErrCodeAlertmanagerChannelInvalid, "%s", err.Error())
|
||||
}
|
||||
|
||||
*p = PostableNotificationChannel(tmp)
|
||||
|
||||
if !p.GenerateName && p.DisplayName == "" {
|
||||
p.DisplayName = p.Name
|
||||
}
|
||||
|
||||
if err := p.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if p.GenerateName {
|
||||
p.Name = generateChannelName(p.DisplayName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PostableNotificationChannel) Validate() error {
|
||||
if err := p.validateName(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if p.DisplayName == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "displayName is required")
|
||||
}
|
||||
|
||||
if p.Name == DefaultReceiverName || p.DisplayName == DefaultReceiverName {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "name %q is reserved", DefaultReceiverName)
|
||||
}
|
||||
|
||||
return p.Config.Validate()
|
||||
}
|
||||
|
||||
func (p *PostableNotificationChannel) validateName() error {
|
||||
if p.GenerateName {
|
||||
if p.Name != "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "name must be empty when generateName is true, got %q", p.Name)
|
||||
}
|
||||
if p.DisplayName == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "displayName is required when generateName is true")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if p.Name == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "name is required")
|
||||
}
|
||||
|
||||
if errs := validation.IsDNS1123Label(p.Name); len(errs) > 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "name %q is invalid: %s", p.Name, strings.Join(errs, "; "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Gettable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type GettableNotificationChannel struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
DisplayName string `json:"displayName" required:"true"`
|
||||
Config ChannelConfig `json:"config" required:"true"`
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
CreatedAt time.Time `json:"createdAt" required:"true"`
|
||||
UpdatedAt time.Time `json:"updatedAt" required:"true"`
|
||||
}
|
||||
138
pkg/types/alertmanagertypes/channelapi_test.go
Normal file
138
pkg/types/alertmanagertypes/channelapi_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
func TestPostableChannelUnmarshalJSONRejectsBadInput(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
description: "empty kind",
|
||||
body: `{"name":"x","config":{"spec":{"to":"a@b.c"}}}`,
|
||||
},
|
||||
{
|
||||
description: "missing spec",
|
||||
body: `{"name":"x","config":{"kind":"slack"}}`,
|
||||
},
|
||||
{
|
||||
// A custom UnmarshalJSON receives raw bytes, so the request body's own
|
||||
// DisallowUnknownFields never reaches inside config.
|
||||
description: "unknown field alongside kind and spec",
|
||||
body: `{"name":"x","config":{"kind":"slack","bogus":1,"spec":{"apiUrl":"https://a","channel":"#c","title":"slack title","text":"slack text"}}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
var postable PostableNotificationChannel
|
||||
assert.Error(t, json.Unmarshal([]byte(testCase.body), &postable))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostableChannelValidate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
postable PostableNotificationChannel
|
||||
}{
|
||||
{
|
||||
description: "webhook password without username",
|
||||
postable: PostableNotificationChannel{
|
||||
Name: "hook",
|
||||
DisplayName: "hook",
|
||||
Config: ChannelConfig{Kind: ChannelKindWebhook, Spec: &ChannelWebhookConfig{URL: "https://a", Password: "p"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "jira with an unparseable reopen duration",
|
||||
postable: PostableNotificationChannel{
|
||||
Name: "jira",
|
||||
DisplayName: "jira",
|
||||
Config: ChannelConfig{Kind: ChannelKindJira, Spec: &ChannelJiraConfig{
|
||||
Site: "https://acme.atlassian.net", Project: "OPS", IssueType: "Bug",
|
||||
Email: "oncall@acme.com", APIToken: "api-token",
|
||||
Summary: valuer.MustNewUnsetOrNonEmptyString("jira summary"), Description: valuer.MustNewUnsetOrNonEmptyString("jira description"), ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("three days"),
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "nil spec",
|
||||
postable: PostableNotificationChannel{
|
||||
Name: "oncall",
|
||||
DisplayName: "oncall",
|
||||
Config: ChannelConfig{Kind: ChannelKindSlack},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
assert.Error(t, testCase.postable.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A display name that slugifies to nothing still has to yield a DNS1123 label,
|
||||
// because the generated name is what every other resource references.
|
||||
func TestPostableChannelUnmarshalJSONGeneratesANameFromAnUnslugifiableDisplayName(t *testing.T) {
|
||||
var postable PostableNotificationChannel
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"generateName":true,"displayName":"###","config":{"kind":"slack","spec":{"apiUrl":"https://a","channel":"#c","title":"slack title","text":"slack text"}}}`), &postable))
|
||||
|
||||
assert.Equal(t, "###", postable.DisplayName)
|
||||
assert.Empty(t, validation.IsDNS1123Label(postable.Name))
|
||||
}
|
||||
|
||||
// A GettableNotificationChannel must marshal to the PostableNotificationChannel shape plus the
|
||||
// server-owned fields, so a client can read one and post it back.
|
||||
func TestGettableChannelMarshalsAsPostablePlusServerFields(t *testing.T) {
|
||||
gettable := GettableNotificationChannel{
|
||||
Name: "oncall",
|
||||
DisplayName: "#oncall",
|
||||
Config: ChannelConfig{Kind: ChannelKindSlack, Spec: &ChannelSlackConfig{APIURL: "https://a", Channel: "#c", Title: valuer.MustNewUnsetOrNonEmptyString("slack title"), Text: valuer.MustNewUnsetOrNonEmptyString("slack text")}},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(gettable)
|
||||
require.NoError(t, err)
|
||||
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
|
||||
assert.ElementsMatch(t, []string{"name", "displayName", "config", "id", "createdAt", "updatedAt"}, channelKeysOf(decoded))
|
||||
|
||||
config, ok := decoded["config"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.ElementsMatch(t, []string{"kind", "spec"}, channelKeysOf(config))
|
||||
assert.Equal(t, "slack", config["kind"])
|
||||
}
|
||||
|
||||
// Only a caller assembling the struct can pair a kind with another kind's spec;
|
||||
// a decoded config builds the spec from the kind. Left unchecked the conversion
|
||||
// to a receiver, which switches on the spec's type, would write a channel of the
|
||||
// spec's kind under the declared one.
|
||||
func TestChannelConfigValidateRejectsSpecOfAnotherKind(t *testing.T) {
|
||||
config := ChannelConfig{
|
||||
Kind: ChannelKindSlack,
|
||||
Spec: &ChannelEmailConfig{To: "team@example.com", HTML: valuer.MustNewUnsetOrNonEmptyString("<p>body</p>")},
|
||||
}
|
||||
|
||||
err := config.Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "does not match kind")
|
||||
}
|
||||
|
||||
func channelKeysOf(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
1193
pkg/types/alertmanagertypes/channelconfig.go
Normal file
1193
pkg/types/alertmanagertypes/channelconfig.go
Normal file
File diff suppressed because it is too large
Load Diff
117
pkg/types/alertmanagertypes/channelconvertors.go
Normal file
117
pkg/types/alertmanagertypes/channelconvertors.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// API -> storage
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ToReceiver hands the assembled receiver to newDefaultedReceiver, which is the
|
||||
// only place upstream applies a notifier's defaults and validation — several
|
||||
// integrations panic without them.
|
||||
func (p *PostableNotificationChannel) ToReceiver() (*Receiver, error) {
|
||||
spec, ok := p.Config.Spec.(ChannelSpec)
|
||||
if !ok {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "config.spec was not decoded into a known type")
|
||||
}
|
||||
|
||||
receiver, err := spec.toUndefaultedReceiver(p.DisplayName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newDefaultedReceiver(receiver)
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Storage -> API
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// toPostableNotificationChannel derives the kind from the config the receiver
|
||||
// actually carries rather than from Channel.Type, so a row written with several
|
||||
// notifier kinds is rejected instead of reported under whichever one
|
||||
// receiverChannelType happened to pick.
|
||||
func (c *Channel) toPostableNotificationChannel() (*PostableNotificationChannel, error) {
|
||||
receiver := &Receiver{Receiver: &config.Receiver{}}
|
||||
if err := json.Unmarshal([]byte(c.Data), receiver); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal channel %q", c.DisplayName)
|
||||
}
|
||||
|
||||
if total := countNotifierConfigs(receiver); total > 1 {
|
||||
return nil, errors.NewInvalidInputf(
|
||||
ErrCodeAlertmanagerChannelInvalid,
|
||||
"channel %q carries %d notifier configurations; only one per channel is supported", c.DisplayName, total,
|
||||
)
|
||||
}
|
||||
|
||||
for _, channelKind := range channelKinds {
|
||||
if channelKind.countConfigs(receiver) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
spec, err := channelKind.extractSpec(c.DisplayName, receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PostableNotificationChannel{
|
||||
Name: c.Name,
|
||||
DisplayName: c.DisplayName,
|
||||
Config: ChannelConfig{Kind: channelKind.kind, Spec: spec},
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, errors.NewNotFoundf(
|
||||
ErrCodeChannelUnsupportedKind,
|
||||
"channel %q carries no supported notifier configuration", c.DisplayName,
|
||||
)
|
||||
}
|
||||
|
||||
// countNotifierConfigs totals every *_configs entry on the receiver, including
|
||||
// notifier kinds no ChannelSpec models, so a row mixing a modelled kind with
|
||||
// an unmodelled one is not mistaken for a single-notifier channel.
|
||||
func countNotifierConfigs(receiver *Receiver) int {
|
||||
return countConfigsFields(reflect.ValueOf(*receiver)) +
|
||||
countConfigsFields(reflect.ValueOf(*receiver.Receiver))
|
||||
}
|
||||
|
||||
func countConfigsFields(v reflect.Value) int {
|
||||
t := v.Type()
|
||||
total := 0
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
fieldVal := v.Field(i)
|
||||
if fieldVal.Kind() != reflect.Slice || fieldVal.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if !receiverTypeRegex.MatchString(t.Field(i).Tag.Get("yaml")) {
|
||||
continue
|
||||
}
|
||||
|
||||
total += fieldVal.Len()
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
func (c *Channel) ToGettableNotificationChannel() (*GettableNotificationChannel, error) {
|
||||
postable, err := c.toPostableNotificationChannel()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &GettableNotificationChannel{
|
||||
Name: postable.Name,
|
||||
DisplayName: postable.DisplayName,
|
||||
Config: postable.Config,
|
||||
ID: c.ID,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
544
pkg/types/alertmanagertypes/channelconvertors_test.go
Normal file
544
pkg/types/alertmanagertypes/channelconvertors_test.go
Normal file
@@ -0,0 +1,544 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The spec types and the upstream configs they translate through carry the same
|
||||
// field sets, and nothing but this couples them: a field missing from either
|
||||
// direction of the mapping is silently dropped. Every field is set so no default
|
||||
// can fill the gap and hide it, and the whole spec is compared so a dropped
|
||||
// field fails rather than going unasserted. Webhook is covered by
|
||||
// TestPostableChannelToReceiverRoundTripsWebhookAuthModes, whose auth modes are
|
||||
// mutually exclusive and so cannot all be set at once.
|
||||
func TestChannelToPostableChannelRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
sendResolved := true
|
||||
|
||||
testCases := []struct {
|
||||
description string
|
||||
kind ChannelKind
|
||||
spec any
|
||||
expectedRoundTrip any
|
||||
}{
|
||||
{
|
||||
description: "slack",
|
||||
kind: ChannelKindSlack,
|
||||
spec: &ChannelSlackConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIURL: "https://hooks.slack.com/services/T/B/X",
|
||||
Channel: "#alerts",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("slack title"),
|
||||
Text: valuer.MustNewUnsetOrNonEmptyString("slack text"),
|
||||
},
|
||||
expectedRoundTrip: &ChannelSlackConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIURL: "https://hooks.slack.com/services/T/B/X",
|
||||
Channel: "#alerts",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("slack title"),
|
||||
Text: valuer.MustNewUnsetOrNonEmptyString("slack text"),
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "email",
|
||||
kind: ChannelKindEmail,
|
||||
spec: &ChannelEmailConfig{
|
||||
SendResolved: &sendResolved,
|
||||
To: "team@example.com",
|
||||
HTML: valuer.MustNewUnsetOrNonEmptyString("<p>email body</p>"),
|
||||
Headers: map[string]string{"Subject": "email subject"},
|
||||
},
|
||||
expectedRoundTrip: &ChannelEmailConfig{
|
||||
SendResolved: &sendResolved,
|
||||
To: "team@example.com",
|
||||
HTML: valuer.MustNewUnsetOrNonEmptyString("<p>email body</p>"),
|
||||
Headers: map[string]string{"Subject": "email subject"},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "pagerduty",
|
||||
kind: ChannelKindPagerduty,
|
||||
spec: &ChannelPagerdutyConfig{
|
||||
SendResolved: &sendResolved,
|
||||
RoutingKey: "routing-key",
|
||||
URL: "https://events.example.com/v2/enqueue",
|
||||
Source: valuer.MustNewUnsetOrNonEmptyString("pagerduty source"),
|
||||
Client: valuer.MustNewUnsetOrNonEmptyString("pagerduty client"),
|
||||
ClientURL: valuer.MustNewUnsetOrNonEmptyString("https://client.example.com"),
|
||||
Description: valuer.MustNewUnsetOrNonEmptyString("pagerduty description"),
|
||||
Severity: "critical",
|
||||
Component: "api",
|
||||
Group: "platform",
|
||||
Class: "deploy",
|
||||
Details: map[string]string{"env": "prod"},
|
||||
},
|
||||
// Map-valued fields are merged with the notifier's defaults rather
|
||||
// than replaced, so a read cannot tell the caller's entries from
|
||||
// upstream's. Clients that diff a read against their own input
|
||||
// (Terraform) see the extra keys.
|
||||
expectedRoundTrip: &ChannelPagerdutyConfig{
|
||||
SendResolved: &sendResolved,
|
||||
RoutingKey: "routing-key",
|
||||
URL: "https://events.example.com/v2/enqueue",
|
||||
Source: valuer.MustNewUnsetOrNonEmptyString("pagerduty source"),
|
||||
Client: valuer.MustNewUnsetOrNonEmptyString("pagerduty client"),
|
||||
ClientURL: valuer.MustNewUnsetOrNonEmptyString("https://client.example.com"),
|
||||
Description: valuer.MustNewUnsetOrNonEmptyString("pagerduty description"),
|
||||
Severity: "critical",
|
||||
Component: "api",
|
||||
Group: "platform",
|
||||
Class: "deploy",
|
||||
Details: map[string]string{
|
||||
"env": "prod",
|
||||
"firing": "{{ .Alerts.Firing | toJson }}",
|
||||
"num_firing": "{{ .Alerts.Firing | len }}",
|
||||
"num_resolved": "{{ .Alerts.Resolved | len }}",
|
||||
"resolved": "{{ .Alerts.Resolved | toJson }}",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "opsgenie",
|
||||
kind: ChannelKindOpsgenie,
|
||||
spec: &ChannelOpsgenieConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIKey: "api-key",
|
||||
APIURL: "https://api.eu.opsgenie.com",
|
||||
Message: valuer.MustNewUnsetOrNonEmptyString("opsgenie message"),
|
||||
Description: valuer.MustNewUnsetOrNonEmptyString("opsgenie description"),
|
||||
Source: valuer.MustNewUnsetOrNonEmptyString("opsgenie source"),
|
||||
Priority: "P1",
|
||||
Details: map[string]string{"env": "prod"},
|
||||
},
|
||||
expectedRoundTrip: &ChannelOpsgenieConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIKey: "api-key",
|
||||
APIURL: "https://api.eu.opsgenie.com",
|
||||
Message: valuer.MustNewUnsetOrNonEmptyString("opsgenie message"),
|
||||
Description: valuer.MustNewUnsetOrNonEmptyString("opsgenie description"),
|
||||
Source: valuer.MustNewUnsetOrNonEmptyString("opsgenie source"),
|
||||
Priority: "P1",
|
||||
Details: map[string]string{"env": "prod"},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "msteams",
|
||||
kind: ChannelKindMSTeams,
|
||||
spec: &ChannelMSTeamsConfig{
|
||||
SendResolved: &sendResolved,
|
||||
WebhookURL: "https://teams.example.com/hook",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("msteams title"),
|
||||
Text: valuer.MustNewUnsetOrNonEmptyString("msteams text"),
|
||||
},
|
||||
expectedRoundTrip: &ChannelMSTeamsConfig{
|
||||
SendResolved: &sendResolved,
|
||||
WebhookURL: "https://teams.example.com/hook",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("msteams title"),
|
||||
Text: valuer.MustNewUnsetOrNonEmptyString("msteams text"),
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "googlechat",
|
||||
kind: ChannelKindGoogleChat,
|
||||
spec: &ChannelGoogleChatConfig{
|
||||
SendResolved: &sendResolved,
|
||||
WebhookURL: "https://chat.googleapis.com/v1/spaces/s/messages",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("googlechat title"),
|
||||
Text: valuer.MustNewUnsetOrNonEmptyString("googlechat text"),
|
||||
},
|
||||
expectedRoundTrip: &ChannelGoogleChatConfig{
|
||||
SendResolved: &sendResolved,
|
||||
WebhookURL: "https://chat.googleapis.com/v1/spaces/s/messages",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("googlechat title"),
|
||||
Text: valuer.MustNewUnsetOrNonEmptyString("googlechat text"),
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "jira",
|
||||
kind: ChannelKindJira,
|
||||
spec: &ChannelJiraConfig{
|
||||
SendResolved: &sendResolved,
|
||||
Site: "https://acme.atlassian.net",
|
||||
Project: "OPS",
|
||||
IssueType: "Bug",
|
||||
Summary: valuer.MustNewUnsetOrNonEmptyString("jira summary"),
|
||||
Description: valuer.MustNewUnsetOrNonEmptyString("jira description"),
|
||||
Priority: "High",
|
||||
Labels: []string{"signoz", "alert"},
|
||||
ResolveTransition: "Done",
|
||||
ReopenTransition: "Reopen",
|
||||
ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("3d"),
|
||||
WontFixResolution: "Won't Do",
|
||||
CustomFields: map[string]any{"customfield_10010": "Ops"},
|
||||
Email: "oncall@acme.com",
|
||||
APIToken: "api-token",
|
||||
},
|
||||
expectedRoundTrip: &ChannelJiraConfig{
|
||||
SendResolved: &sendResolved,
|
||||
Site: "https://acme.atlassian.net",
|
||||
Project: "OPS",
|
||||
IssueType: "Bug",
|
||||
Summary: valuer.MustNewUnsetOrNonEmptyString("jira summary"),
|
||||
Description: valuer.MustNewUnsetOrNonEmptyString("jira description"),
|
||||
Priority: "High",
|
||||
Labels: []string{"signoz", "alert"},
|
||||
ResolveTransition: "Done",
|
||||
ReopenTransition: "Reopen",
|
||||
ReopenDuration: valuer.MustNewUnsetOrNonEmptyString("3d"),
|
||||
WontFixResolution: "Won't Do",
|
||||
CustomFields: map[string]any{"customfield_10010": "Ops"},
|
||||
Email: "oncall@acme.com",
|
||||
APIToken: "api-token",
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "jsmops",
|
||||
kind: ChannelKindJSMOps,
|
||||
spec: &ChannelJSMOpsConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIKey: "api-key",
|
||||
Message: valuer.MustNewUnsetOrNonEmptyString("jsmops message"),
|
||||
Description: valuer.MustNewUnsetOrNonEmptyString("jsmops description"),
|
||||
Priority: "P1",
|
||||
Tags: valuer.MustNewUnsetOrNonEmptyString("signoz,oncall"),
|
||||
},
|
||||
expectedRoundTrip: &ChannelJSMOpsConfig{
|
||||
SendResolved: &sendResolved,
|
||||
APIKey: "api-key",
|
||||
Message: valuer.MustNewUnsetOrNonEmptyString("jsmops message"),
|
||||
Description: valuer.MustNewUnsetOrNonEmptyString("jsmops description"),
|
||||
Priority: "P1",
|
||||
Tags: valuer.MustNewUnsetOrNonEmptyString("signoz,oncall"),
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "incidentio",
|
||||
kind: ChannelKindIncidentIO,
|
||||
spec: &ChannelIncidentIOConfig{
|
||||
SendResolved: &sendResolved,
|
||||
URL: "https://api.incident.io/v2/alert_events/http/01ABC",
|
||||
Token: "token",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("incidentio title"),
|
||||
Description: valuer.MustNewUnsetOrNonEmptyString("incidentio description"),
|
||||
Metadata: map[string]string{"team": "platform"},
|
||||
},
|
||||
expectedRoundTrip: &ChannelIncidentIOConfig{
|
||||
SendResolved: &sendResolved,
|
||||
URL: "https://api.incident.io/v2/alert_events/http/01ABC",
|
||||
Token: "token",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("incidentio title"),
|
||||
Description: valuer.MustNewUnsetOrNonEmptyString("incidentio description"),
|
||||
Metadata: map[string]string{"team": "platform"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
postable := PostableNotificationChannel{
|
||||
Name: "channel",
|
||||
DisplayName: "channel",
|
||||
Config: ChannelConfig{Kind: testCase.kind, Spec: testCase.spec},
|
||||
}
|
||||
require.NoError(t, postable.Validate())
|
||||
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
roundTripped, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, postable.Name, roundTripped.Name)
|
||||
assert.Equal(t, testCase.kind, roundTripped.Config.Kind)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, roundTripped.Config.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Email transport is not representable in the channel spec, and no credential
|
||||
// may reach storage. stripEmailTransport blanks Smarthost rather than dropping
|
||||
// it, so the key survives as an empty string.
|
||||
func TestPostableChannelToReceiverOmitsEmailTransportCredentials(t *testing.T) {
|
||||
postable := PostableNotificationChannel{
|
||||
Name: "team",
|
||||
DisplayName: "team",
|
||||
Config: ChannelConfig{
|
||||
Kind: ChannelKindEmail,
|
||||
Spec: &ChannelEmailConfig{To: "team@example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, credentialKey := range []string{"auth_username", "auth_password", "auth_secret", "tls_config"} {
|
||||
assert.NotContains(t, channel.Data, credentialKey)
|
||||
}
|
||||
|
||||
assert.Contains(t, channel.Data, `"smarthost":""`)
|
||||
}
|
||||
|
||||
// The UI offers basic auth and bearer token for webhooks, so both must survive a
|
||||
// round trip. The legacy API overloaded one password field for both.
|
||||
func TestPostableChannelToReceiverRoundTripsWebhookAuthModes(t *testing.T) {
|
||||
// The webhook notifier defaults send_resolved to true, so a spec that omits
|
||||
// it reads back with that default rather than as unset.
|
||||
sendResolved := config.DefaultWebhookConfig.VSendResolved
|
||||
|
||||
testCases := []struct {
|
||||
description string
|
||||
spec ChannelWebhookConfig
|
||||
expectedInData string
|
||||
expectedRoundTrip *ChannelWebhookConfig
|
||||
}{
|
||||
{
|
||||
description: "basic auth",
|
||||
spec: ChannelWebhookConfig{URL: "https://example.com/hook", Username: "u", Password: "p"},
|
||||
expectedInData: `"basic_auth"`,
|
||||
expectedRoundTrip: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://example.com/hook", Username: "u", Password: "p"},
|
||||
},
|
||||
{
|
||||
description: "bearer token",
|
||||
spec: ChannelWebhookConfig{URL: "https://example.com/hook", BearerToken: "tok"},
|
||||
expectedInData: `"authorization"`,
|
||||
expectedRoundTrip: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://example.com/hook", BearerToken: "tok"},
|
||||
},
|
||||
{
|
||||
description: "no auth",
|
||||
spec: ChannelWebhookConfig{URL: "https://example.com/hook"},
|
||||
expectedInData: `"url"`,
|
||||
expectedRoundTrip: &ChannelWebhookConfig{SendResolved: &sendResolved, URL: "https://example.com/hook"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
postable := PostableNotificationChannel{
|
||||
Name: "hook",
|
||||
DisplayName: "hook",
|
||||
Config: ChannelConfig{Kind: ChannelKindWebhook, Spec: &testCase.spec},
|
||||
}
|
||||
require.NoError(t, postable.Validate())
|
||||
|
||||
receiver, err := postable.ToReceiver()
|
||||
require.NoError(t, err)
|
||||
|
||||
channel, err := NewChannelFromReceiverWithName(receiver, postable.Name, "org-1")
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, channel.Data, testCase.expectedInData)
|
||||
|
||||
roundTripped, err := channel.toPostableNotificationChannel()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCase.expectedRoundTrip, roundTripped.Config.Spec)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The SigNoz notifiers validate in their UnmarshalYAML, which ToReceiver reaches
|
||||
// only through the defaulting round-trip. A spec that passes Validate can still
|
||||
// be rejected there, and the request has to fail as invalid input rather than as
|
||||
// an internal error.
|
||||
func TestPostableChannelToReceiverReportsNotifierValidationAsInvalidInput(t *testing.T) {
|
||||
postable := PostableNotificationChannel{
|
||||
Name: "channel",
|
||||
DisplayName: "channel",
|
||||
Config: ChannelConfig{Kind: ChannelKindIncidentIO, Spec: &ChannelIncidentIOConfig{
|
||||
URL: "https://api.incident.io/v2/incidents", Token: "token",
|
||||
Title: valuer.MustNewUnsetOrNonEmptyString("incidentio title"), Description: valuer.MustNewUnsetOrNonEmptyString("incidentio description"),
|
||||
}},
|
||||
}
|
||||
require.NoError(t, postable.Validate())
|
||||
|
||||
_, err := postable.ToReceiver()
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "got %v", err)
|
||||
}
|
||||
|
||||
// rejectUnsupportedHTTPConfig enumerates the fields it rejects, so one added
|
||||
// upstream would pass unnoticed and be dropped on read. Pinning the counts turns
|
||||
// a dependency bump into a failing test rather than silent data loss.
|
||||
func TestRejectUnrepresentableHTTPConfigCoversEveryUpstreamMember(t *testing.T) {
|
||||
assert.Equal(t, 10, reflect.TypeFor[commoncfg.HTTPClientConfig]().NumField())
|
||||
assert.Equal(t, 5, reflect.TypeFor[commoncfg.ProxyConfig]().NumField())
|
||||
}
|
||||
|
||||
func TestChannelToPostableChannelRejectsUnrepresentableChannels(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
channel Channel
|
||||
}{
|
||||
{
|
||||
description: "two notifier kinds in one channel",
|
||||
channel: Channel{
|
||||
DisplayName: "mixed",
|
||||
Data: `{"name":"mixed","slack_configs":[{"channel":"#a"}],"email_configs":[{"to":"a@b.c"}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Only the first would survive the read, and the second would be
|
||||
// dropped on the next write.
|
||||
description: "two configs of the same notifier kind",
|
||||
channel: Channel{
|
||||
DisplayName: "two-slacks",
|
||||
Data: `{"name":"two-slacks","slack_configs":[{"channel":"#a"},{"channel":"#b"}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "no notifier configuration",
|
||||
channel: Channel{
|
||||
DisplayName: "empty",
|
||||
Data: `{"name":"empty"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "notifier kind outside the supported set",
|
||||
channel: Channel{
|
||||
DisplayName: "tg",
|
||||
Data: `{"name":"tg","telegram_configs":[{"chat_id":1}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "legacy msteams v1 configs",
|
||||
channel: Channel{
|
||||
DisplayName: "old-teams",
|
||||
Data: `{"name":"old-teams","msteams_configs":[{"webhook_url":"https://a"}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Dropping these on read would unauthenticate the channel on the
|
||||
// next write, so the read fails instead.
|
||||
description: "webhook http_config beyond basic auth and bearer token",
|
||||
channel: Channel{
|
||||
DisplayName: "proxied",
|
||||
Data: `{"name":"proxied","webhook_configs":[{"url":"https://a","http_config":{"proxy_url":"https://proxy","tls_config":{"insecure_skip_verify":true}}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "a modelled notifier kind alongside an unmodelled one",
|
||||
channel: Channel{
|
||||
DisplayName: "slack-and-telegram",
|
||||
Data: `{"name":"slack-and-telegram","slack_configs":[{"api_url":"https://a","channel":"#a"}],"telegram_configs":[{"chat_id":1,"bot_token":"t"}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
// The spec models one config per kind, so the second would be lost.
|
||||
description: "two configs of one notifier kind",
|
||||
channel: Channel{
|
||||
DisplayName: "two-slacks",
|
||||
Data: `{"name":"two-slacks","slack_configs":[{"api_url":"https://a","channel":"#a"},{"api_url":"https://b","channel":"#b"}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
// The spec carries the credentials but not the scheme, so any other
|
||||
// scheme would be rewritten as Bearer on the next write.
|
||||
description: "webhook authorization scheme other than bearer",
|
||||
channel: Channel{
|
||||
DisplayName: "token-auth",
|
||||
Data: `{"name":"token-auth","webhook_configs":[{"url":"https://a","http_config":{"authorization":{"type":"Token","credentials":"abc"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "webhook credentials sourced from a file",
|
||||
channel: Channel{
|
||||
DisplayName: "file-auth",
|
||||
Data: `{"name":"file-auth","webhook_configs":[{"url":"https://a","http_config":{"authorization":{"type":"Bearer","credentials_file":"/run/token"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "webhook basic auth password sourced from a file",
|
||||
channel: Channel{
|
||||
DisplayName: "file-password",
|
||||
Data: `{"name":"file-password","webhook_configs":[{"url":"https://a","http_config":{"basic_auth":{"username":"u","password_file":"/run/pass"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "webhook inline tls material",
|
||||
channel: Channel{
|
||||
DisplayName: "inline-tls",
|
||||
Data: `{"name":"inline-tls","webhook_configs":[{"url":"https://a","http_config":{"tls_config":{"ca":"---PEM---","min_version":"TLS12"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
// The upstream kinds lift nothing out of http_config, so any credential
|
||||
// or transport setting stored there would be dropped on the next write.
|
||||
description: "slack basic auth",
|
||||
channel: Channel{
|
||||
DisplayName: "slack-basic",
|
||||
Data: `{"name":"slack-basic","slack_configs":[{"api_url":"https://a","channel":"#a","http_config":{"basic_auth":{"username":"u","password":"p"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "opsgenie proxy",
|
||||
channel: Channel{
|
||||
DisplayName: "og-proxy",
|
||||
Data: `{"name":"og-proxy","opsgenie_configs":[{"api_key":"k","http_config":{"proxy_url":"https://proxy","follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "pagerduty authorization header",
|
||||
channel: Channel{
|
||||
DisplayName: "pd-bearer",
|
||||
Data: `{"name":"pd-bearer","pagerduty_configs":[{"routing_key":"k","http_config":{"authorization":{"type":"Bearer","credentials":"tok"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "msteams inline tls material",
|
||||
channel: Channel{
|
||||
DisplayName: "teams-tls",
|
||||
Data: `{"name":"teams-tls","msteamsv2_configs":[{"webhook_url":"https://a","http_config":{"tls_config":{"ca":"---PEM---"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "googlechat basic auth",
|
||||
channel: Channel{
|
||||
DisplayName: "chat-basic",
|
||||
Data: `{"name":"chat-basic","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/A/messages","http_config":{"basic_auth":{"username":"u","password":"p"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
// ChannelJiraConfig lifts only basic auth out of http_config, because
|
||||
// that is all Jira Cloud accepts.
|
||||
description: "jira authorization header",
|
||||
channel: Channel{
|
||||
DisplayName: "jira-bearer",
|
||||
Data: `{"name":"jira-bearer","jira_configs":[{"site":"https://acme.atlassian.net","project":"OPS","issue_type":"Bug","http_config":{"authorization":{"type":"Bearer","credentials":"tok"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
// JSM Ops and incident.io authenticate through their own spec fields,
|
||||
// so their specs model no http_config credentials at all.
|
||||
description: "jsmops basic auth",
|
||||
channel: Channel{
|
||||
DisplayName: "jsm-basic",
|
||||
Data: `{"name":"jsm-basic","jsmops_configs":[{"api_key":"key","http_config":{"basic_auth":{"username":"u","password":"p"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "incidentio authorization header",
|
||||
channel: Channel{
|
||||
DisplayName: "io-bearer",
|
||||
Data: `{"name":"io-bearer","incidentio_configs":[{"url":"https://api.incident.io/v2/alert_events/http/01ABC","token":"t","http_config":{"authorization":{"type":"Bearer","credentials":"tok"},"follow_redirects":true,"enable_http2":true}}]}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
_, err := testCase.channel.toPostableNotificationChannel()
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -334,12 +334,32 @@ func cloneReceiver(receiver *Receiver) (*Receiver, error) {
|
||||
|
||||
func (c *Config) CreateReceiver(receiver *Receiver) error {
|
||||
// check that receiver name is not already used
|
||||
for _, existingReceiver := range c.alertmanagerConfig.Receivers {
|
||||
if existingReceiver.Name == receiver.Name {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeAlertmanagerConfigConflict, "the receiver name has to be unique, please choose a different name")
|
||||
}
|
||||
if c.hasReceiver(receiver.Name) {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeAlertmanagerConfigConflict, "the receiver name has to be unique, please choose a different name")
|
||||
}
|
||||
|
||||
return c.createReceiver(receiver)
|
||||
}
|
||||
|
||||
// CreateReceiverV2 differs from CreateReceiver only in reporting a name already in
|
||||
// use as a conflict rather than as invalid input. The v2 create path is the sole
|
||||
// caller: v1 create, NewConfigFromChannels and TestReceiver stay on CreateReceiver
|
||||
// so their responses keep the status code clients already see.
|
||||
func (c *Config) CreateReceiverV2(receiver *Receiver) error {
|
||||
if c.hasReceiver(receiver.Name) {
|
||||
return errors.Newf(errors.TypeAlreadyExists, ErrCodeAlertmanagerChannelAlreadyExists, "channel with display name %q already exists", receiver.Name)
|
||||
}
|
||||
|
||||
return c.createReceiver(receiver)
|
||||
}
|
||||
|
||||
func (c *Config) hasReceiver(name string) bool {
|
||||
return slices.ContainsFunc(c.alertmanagerConfig.Receivers, func(existing config.Receiver) bool {
|
||||
return existing.Name == name
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Config) createReceiver(receiver *Receiver) error {
|
||||
owned, err := cloneReceiver(receiver)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -41,6 +41,10 @@ func NewReceiver(input string) (*Receiver, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newDefaultedReceiver(receiver)
|
||||
}
|
||||
|
||||
func newDefaultedReceiver(receiver *Receiver) (*Receiver, error) {
|
||||
withDefaults, err := defaultedBaseReceiver(receiver.Receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -33,6 +33,13 @@ func MustNewUnsetOrNonEmptyString(val string) UnsetOrNonEmptyString {
|
||||
return nonEmptyString
|
||||
}
|
||||
|
||||
// UnsetIfEmpty reads a value back from a store, where an empty string is how
|
||||
// unset is spelled. It is the only way to reach the zero value from a string, so
|
||||
// it must never be used on caller input, which has to reject "" instead.
|
||||
func UnsetIfEmpty(val string) UnsetOrNonEmptyString {
|
||||
return UnsetOrNonEmptyString{val: val}
|
||||
}
|
||||
|
||||
func (enum UnsetOrNonEmptyString) IsZero() bool {
|
||||
return enum.val == ""
|
||||
}
|
||||
@@ -73,16 +80,18 @@ func (enum *UnsetOrNonEmptyString) Scan(val any) error {
|
||||
return errors.Newf(errors.TypeInternal, ErrCodeUnknownValuerScan, "unset_or_non_empty_string: (nil \"%T\")", enum)
|
||||
}
|
||||
|
||||
if val == nil {
|
||||
*enum = UnsetOrNonEmptyString{}
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := val.(string)
|
||||
if !ok {
|
||||
return errors.Newf(errors.TypeInternal, ErrCodeUnknownValuerScan, "unset_or_non_empty_string: (non-string \"%T\")", val)
|
||||
}
|
||||
|
||||
var err error
|
||||
*enum, err = NewUnsetOrNonEmptyString(str)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// scan is run when reading stored data where we can assume "" means unset, so no errors on seeing "".
|
||||
*enum = UnsetIfEmpty(str)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -63,14 +63,22 @@ func TestUnsetOrNonEmptyStringMarshalJSON(t *testing.T) {
|
||||
assert.JSONEq(t, `"Alert"`, string(raw))
|
||||
}
|
||||
|
||||
func TestUnsetOrNonEmptyStringScanRejectsAnEmptyString(t *testing.T) {
|
||||
var nonEmptyString UnsetOrNonEmptyString
|
||||
// A store spells unset as an empty or null column, so scanning one is the unset
|
||||
// case rather than a failure. Only a non-string column is an error.
|
||||
func TestUnsetOrNonEmptyStringScanReadsAnEmptyColumnAsUnset(t *testing.T) {
|
||||
var unsetOrNonEmpty UnsetOrNonEmptyString
|
||||
|
||||
require.NoError(t, nonEmptyString.Scan("oncall"))
|
||||
assert.Equal(t, "oncall", nonEmptyString.StringValue())
|
||||
require.NoError(t, unsetOrNonEmpty.Scan("oncall"))
|
||||
assert.Equal(t, "oncall", unsetOrNonEmpty.StringValue())
|
||||
|
||||
assert.Error(t, nonEmptyString.Scan(""))
|
||||
assert.Error(t, nonEmptyString.Scan(nil))
|
||||
require.NoError(t, unsetOrNonEmpty.Scan(""))
|
||||
assert.True(t, unsetOrNonEmpty.IsZero())
|
||||
|
||||
require.NoError(t, unsetOrNonEmpty.Scan("oncall"))
|
||||
require.NoError(t, unsetOrNonEmpty.Scan(nil))
|
||||
assert.True(t, unsetOrNonEmpty.IsZero())
|
||||
|
||||
assert.Error(t, unsetOrNonEmpty.Scan(42))
|
||||
}
|
||||
|
||||
func TestUnsetOrNonEmptyStringUnmarshalTextRejectsAnEmptyString(t *testing.T) {
|
||||
@@ -90,3 +98,8 @@ func TestUnsetOrNonEmptyStringUnmarshalParamRejectsAnEmptyString(t *testing.T) {
|
||||
|
||||
assert.Error(t, nonEmptyString.UnmarshalParam(""))
|
||||
}
|
||||
|
||||
func TestUnsetIfEmpty(t *testing.T) {
|
||||
assert.True(t, UnsetIfEmpty("").IsZero())
|
||||
assert.Equal(t, MustNewUnsetOrNonEmptyString("oncall"), UnsetIfEmpty("oncall"))
|
||||
}
|
||||
|
||||
26
tests/fixtures/notification_channel.py
vendored
26
tests/fixtures/notification_channel.py
vendored
@@ -210,6 +210,32 @@ def create_notification_channel(
|
||||
logger.error("Failed to delete channel: %s", {"channel_id": channel_id, "status": response.status_code, "response": response.text})
|
||||
|
||||
|
||||
@pytest.fixture(name="cleanup_notification_channels", scope="function")
|
||||
def cleanup_notification_channels(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
) -> Callable[[], list]:
|
||||
"""Yields a list to append channel IDs to; each is deleted on teardown.
|
||||
|
||||
Deletion goes through v1, which owns the same rows as v2.
|
||||
"""
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
channel_ids = []
|
||||
|
||||
yield channel_ids
|
||||
|
||||
for channel_id in channel_ids:
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v1/channels/{channel_id}"),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
if response.status_code != HTTPStatus.NO_CONTENT:
|
||||
logger.error("Failed to delete channel: %s", {"channel_id": channel_id, "status": response.status_code, "response": response.text})
|
||||
|
||||
|
||||
@pytest.fixture(name="create_webhook_notification_channel", scope="function")
|
||||
def create_webhook_notification_channel(
|
||||
signoz: types.SigNoz,
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import (
|
||||
USER_ADMIN_EMAIL,
|
||||
USER_ADMIN_PASSWORD,
|
||||
create_active_user,
|
||||
)
|
||||
|
||||
TIMEOUT = 10
|
||||
|
||||
V2_BASE_URL = "/api/v2/notification_channels"
|
||||
|
||||
DNS1123_LABEL = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$")
|
||||
|
||||
_EDITOR_EMAIL = "editor+channelsv2@integration.test"
|
||||
_VIEWER_EMAIL = "viewer+channelsv2@integration.test"
|
||||
_PASSWORD = "password123Z$"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,spec,assert_field,assert_value",
|
||||
[
|
||||
pytest.param("slack", {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#alerts", "title": "Alert", "text": "{{ .CommonLabels.alertname }}"}, "channel", "#alerts", id="slack"),
|
||||
pytest.param("email", {"to": "oncall@integration.test", "html": "<p>{{ .CommonLabels.alertname }}</p>"}, "to", "oncall@integration.test", id="email"),
|
||||
pytest.param("webhook", {"url": "https://webhook.test/hook", "username": "bob", "password": "s3cret"}, "username", "bob", id="webhook"),
|
||||
pytest.param("pagerduty", {"routingKey": "pd-routing-key", "severity": "critical", "class": "db", "description": "{{ .CommonLabels.alertname }}"}, "severity", "critical", id="pagerduty"),
|
||||
pytest.param("opsgenie", {"apiKey": "og-api-key", "message": "{{ .CommonLabels.alertname }}", "description": "{{ .CommonLabels.alertname }}", "priority": "P2"}, "priority", "P2", id="opsgenie"),
|
||||
pytest.param("msteams", {"webhookUrl": "https://teams.test/webhook/abc", "title": "Alert", "text": "{{ .CommonLabels.alertname }}"}, "title", "Alert", id="msteams"),
|
||||
# The google chat notifier only accepts https URLs on chat.googleapis.com.
|
||||
pytest.param("googlechat", {"webhookUrl": "https://chat.googleapis.com/v1/spaces/A/messages?key=k&token=t", "title": "Alert", "text": "{{ .CommonLabels.alertname }}"}, "title", "Alert", id="googlechat"),
|
||||
# The jira notifier only accepts Jira Cloud sites and basic auth.
|
||||
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "oncall@integration.test", "apiToken": "jira-api-token", "summary": "Alert", "description": "{{ .CommonLabels.alertname }}", "customFields": {"customfield_10010": "Ops"}}, "project", "OPS", id="jira"),
|
||||
pytest.param("jsmops", {"apiKey": "jsm-api-key", "message": "Alert", "description": "{{ .CommonLabels.alertname }}", "priority": "P2"}, "priority", "P2", id="jsmops"),
|
||||
# The incident.io notifier only accepts an alert source's events URL.
|
||||
pytest.param("incidentio", {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF", "token": "incidentio-token", "title": "Alert", "description": "{{ .CommonLabels.alertname }}"}, "title", "Alert", id="incidentio"),
|
||||
],
|
||||
)
|
||||
def test_create_returns_the_channel_for_every_kind( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
kind: str,
|
||||
spec: dict,
|
||||
assert_field: str,
|
||||
assert_value: str,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"v2-{kind}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "displayName": f"Display {name}", "config": {"kind": kind, "spec": spec}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
created = response.json()["data"]
|
||||
cleanup_notification_channels.append(created["id"])
|
||||
|
||||
assert created["name"] == name
|
||||
assert created["displayName"] == f"Display {name}"
|
||||
assert created["config"]["kind"] == kind
|
||||
assert created["config"]["spec"][assert_field] == assert_value
|
||||
assert created["createdAt"]
|
||||
assert created["updatedAt"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,spec,expected_send_resolved",
|
||||
[
|
||||
pytest.param("slack", {"apiUrl": "https://hooks.slack.test/services/T/B/X", "title": "Alert", "text": "body"}, False, id="slack"),
|
||||
pytest.param("email", {"to": "oncall@integration.test", "html": "<p>body</p>"}, False, id="email"),
|
||||
pytest.param("webhook", {"url": "https://webhook.test/hook"}, True, id="webhook"),
|
||||
pytest.param("pagerduty", {"routingKey": "pd-routing-key", "description": "body"}, True, id="pagerduty"),
|
||||
pytest.param("opsgenie", {"apiKey": "og-api-key", "message": "subject", "description": "body", "priority": "P2"}, True, id="opsgenie"),
|
||||
pytest.param("msteams", {"webhookUrl": "https://teams.test/webhook/abc", "title": "Alert", "text": "body"}, True, id="msteams"),
|
||||
pytest.param("googlechat", {"webhookUrl": "https://chat.googleapis.com/v1/spaces/A/messages?key=k&token=t", "title": "Alert", "text": "body"}, False, id="googlechat"),
|
||||
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "oncall@integration.test", "apiToken": "jira-api-token", "summary": "Alert", "description": "body"}, False, id="jira"),
|
||||
pytest.param("jsmops", {"apiKey": "jsm-api-key", "message": "Alert", "description": "body"}, False, id="jsmops"),
|
||||
pytest.param("incidentio", {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF", "token": "incidentio-token", "title": "Alert", "description": "body"}, False, id="incidentio"),
|
||||
],
|
||||
)
|
||||
def test_create_without_send_resolved_returns_the_notifier_default( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
kind: str,
|
||||
spec: dict,
|
||||
expected_send_resolved: bool,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"v2-sendresolved-{kind}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": kind, "spec": spec}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
created = response.json()["data"]
|
||||
cleanup_notification_channels.append(created["id"])
|
||||
|
||||
assert created["config"]["spec"]["sendResolved"] is expected_send_resolved
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,spec,template_fields",
|
||||
[
|
||||
pytest.param("slack", {"apiUrl": "https://hooks.slack.test/services/T/B/X"}, ["title", "text"], id="slack"),
|
||||
pytest.param("email", {"to": "oncall@integration.test"}, ["html"], id="email"),
|
||||
pytest.param("pagerduty", {"routingKey": "pd-routing-key"}, ["description"], id="pagerduty"),
|
||||
pytest.param("opsgenie", {"apiKey": "og-api-key"}, ["message", "description"], id="opsgenie"),
|
||||
pytest.param("msteams", {"webhookUrl": "https://teams.test/webhook/abc"}, ["title", "text"], id="msteams"),
|
||||
pytest.param("googlechat", {"webhookUrl": "https://chat.googleapis.com/v1/spaces/A/messages?key=k&token=t"}, ["title", "text"], id="googlechat"),
|
||||
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "oncall@integration.test", "apiToken": "jira-api-token"}, ["summary", "description"], id="jira"),
|
||||
pytest.param("jsmops", {"apiKey": "jsm-api-key"}, ["message", "description"], id="jsmops"),
|
||||
pytest.param("incidentio", {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF", "token": "incidentio-token"}, ["title", "description"], id="incidentio"),
|
||||
],
|
||||
)
|
||||
def test_create_without_templates_returns_the_notifier_defaults( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
kind: str,
|
||||
spec: dict,
|
||||
template_fields: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"v2-templates-{kind}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": kind, "spec": spec}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
created = response.json()["data"]
|
||||
cleanup_notification_channels.append(created["id"])
|
||||
|
||||
for field in template_fields:
|
||||
assert "{{" in created["config"]["spec"][field], f"{field} should come back carrying the notifier's default template"
|
||||
|
||||
|
||||
def test_create_defaults_display_name_to_name(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"v2-nodisplay-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "config": {"kind": "email", "spec": {"to": "nodisplay@integration.test", "html": "<p>body</p>"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
created = response.json()["data"]
|
||||
cleanup_notification_channels.append(created["id"])
|
||||
|
||||
assert created["name"] == name
|
||||
assert created["displayName"] == name
|
||||
|
||||
|
||||
def test_create_with_generate_name_derives_a_dns1123_name(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
display_name = f"On Call Escalation {uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={
|
||||
"generateName": True,
|
||||
"displayName": display_name,
|
||||
"config": {"kind": "email", "spec": {"to": "generated@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
created = response.json()["data"]
|
||||
cleanup_notification_channels.append(created["id"])
|
||||
|
||||
assert created["displayName"] == display_name
|
||||
assert DNS1123_LABEL.match(created["name"]), created["name"]
|
||||
assert created["name"].startswith("on-call-escalation-")
|
||||
assert created["name"] != display_name
|
||||
|
||||
|
||||
def test_create_generates_a_distinct_name_for_the_same_display_name(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
display_name = f"Duplicate Display {uuid.uuid4().hex[:8]}"
|
||||
names = []
|
||||
|
||||
for suffix in ("a", "b"):
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={
|
||||
"generateName": True,
|
||||
# The display name has to differ, because it is still the
|
||||
# receiver name in the alertmanager config and must be unique.
|
||||
"displayName": f"{display_name} {suffix}",
|
||||
"config": {"kind": "email", "spec": {"to": f"{suffix}@integration.test", "html": "<p>body</p>"}},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
created = response.json()["data"]
|
||||
cleanup_notification_channels.append(created["id"])
|
||||
names.append(created["name"])
|
||||
|
||||
assert names[0] != names[1]
|
||||
|
||||
|
||||
def test_create_rejects_a_duplicate_name_with_conflict(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
name = f"v2-dupname-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "displayName": f"{name} first", "config": {"kind": "email", "spec": {"to": "first@integration.test", "html": "<p>body</p>"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
cleanup_notification_channels.append(response.json()["data"]["id"])
|
||||
|
||||
# Same name, different display name: only the unique index on
|
||||
# (org_id, name) can catch this one.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": name, "displayName": f"{name} second", "config": {"kind": "email", "spec": {"to": "second@integration.test", "html": "<p>body</p>"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CONFLICT, response.text
|
||||
|
||||
|
||||
def test_create_rejects_a_duplicate_display_name(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
display_name = f"v2-dupdisplay-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": f"{display_name}-one", "displayName": display_name, "config": {"kind": "email", "spec": {"to": "one@integration.test", "html": "<p>body</p>"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
cleanup_notification_channels.append(response.json()["data"]["id"])
|
||||
|
||||
# The display name is the receiver name in the alertmanager config, which
|
||||
# rejects duplicates before the row is ever written.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": f"{display_name}-two", "displayName": display_name, "config": {"kind": "email", "spec": {"to": "two@integration.test", "html": "<p>body</p>"}}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CONFLICT, response.text
|
||||
# Both v2 conflicts share a status and an error code, so only the message
|
||||
# separates a clashing display name from a clashing name.
|
||||
assert "display name" in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
pytest.param({"name": "Not_A_Label", "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="name_not_dns1123_label"),
|
||||
pytest.param({"config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="no_name_and_no_generate_name"),
|
||||
pytest.param({"name": "explicit", "generateName": True, "displayName": "Explicit", "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="name_with_generate_name"),
|
||||
pytest.param({"generateName": True, "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="generate_name_without_display_name"),
|
||||
pytest.param({"name": "default-receiver", "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="reserved_receiver_name"),
|
||||
pytest.param({"name": "no-config"}, id="no_config"),
|
||||
pytest.param({"name": "telegram-kind", "config": {"kind": "telegram", "spec": {"chatId": 1}}}, id="unmodelled_kind"),
|
||||
pytest.param({"name": "slack-unknown-field", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "channel": "#a", "text": "body", "iconEmoji": ":tada:"}}}, id="unknown_spec_field"),
|
||||
pytest.param({"name": "slack-with-email-spec", "config": {"kind": "slack", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}}, id="spec_of_another_kind"),
|
||||
pytest.param({"name": "extra-field", "config": {"kind": "email", "spec": {"to": "a@integration.test", "html": "<p>body</p>"}}, "type": "email"}, id="unknown_envelope_field"),
|
||||
pytest.param({"name": "webhook-both-auth", "config": {"kind": "webhook", "spec": {"url": "https://webhook.test/hook", "username": "u", "password": "p", "bearerToken": "t"}}}, id="webhook_basic_auth_with_bearer_token"),
|
||||
pytest.param({"name": "webhook-half-auth", "config": {"kind": "webhook", "spec": {"url": "https://webhook.test/hook", "username": "u"}}}, id="webhook_basic_auth_without_password"),
|
||||
# The last three reach the notifier's own validation rather than the
|
||||
# spec's, so they assert it still surfaces as a 400 through v2.
|
||||
pytest.param({"name": "jira-server-site", "config": {"kind": "jira", "spec": {"site": "https://jira.acme.com", "project": "OPS", "issueType": "Bug", "email": "a@integration.test", "apiToken": "t", "summary": "Alert", "description": "body"}}}, id="jira_site_not_jira_cloud"),
|
||||
pytest.param(
|
||||
{"name": "jira-short-reopen", "config": {"kind": "jira", "spec": {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "a@integration.test", "apiToken": "t", "summary": "Alert", "description": "body", "reopenDuration": "30s"}}}, id="jira_reopen_duration_below_a_minute"
|
||||
),
|
||||
pytest.param({"name": "incidentio-bearer", "config": {"kind": "incidentio", "spec": {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF", "token": "Bearer incidentio-token", "title": "Alert", "description": "body"}}}, id="incidentio_token_with_bearer_prefix"),
|
||||
pytest.param({"name": "slack-empty-title", "config": {"kind": "slack", "spec": {"apiUrl": "https://hooks.slack.test/services/T/B/X", "title": ""}}}, id="empty_string_on_a_defaulted_field"),
|
||||
pytest.param({"name": "jsmops-empty-tags", "config": {"kind": "jsmops", "spec": {"apiKey": "jsm-api-key", "tags": ""}}}, id="empty_string_on_a_defaulted_signoz_field"),
|
||||
pytest.param({"name": "jira-noncanonical-reopen", "config": {"kind": "jira", "spec": {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "a@integration.test", "apiToken": "t", "reopenDuration": "72h"}}}, id="jira_reopen_duration_not_as_reported"),
|
||||
pytest.param({"name": "email-lowercase-header", "config": {"kind": "email", "spec": {"to": "a@integration.test", "headers": {"subject": "Alert"}}}}, id="email_header_name_not_as_reported"),
|
||||
],
|
||||
)
|
||||
def test_create_rejects_invalid_bodies(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
body: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,spec",
|
||||
[
|
||||
pytest.param("slack", {"channel": "#alerts", "title": "Alert", "text": "body"}, id="slack_without_api_url"),
|
||||
pytest.param("email", {"html": "<p>body</p>"}, id="email_without_to"),
|
||||
pytest.param("webhook", {}, id="webhook_without_url"),
|
||||
pytest.param("pagerduty", {"description": "body"}, id="pagerduty_without_routing_key"),
|
||||
pytest.param("opsgenie", {"message": "subject", "description": "body"}, id="opsgenie_without_api_key"),
|
||||
pytest.param("msteams", {"title": "Alert", "text": "body"}, id="msteams_without_webhook_url"),
|
||||
pytest.param("googlechat", {"title": "Alert", "text": "body"}, id="googlechat_without_webhook_url"),
|
||||
pytest.param("jira", {"project": "OPS", "issueType": "Bug", "email": "oncall@integration.test", "apiToken": "jira-api-token"}, id="jira_without_site"),
|
||||
pytest.param("jira", {"site": "https://acme.atlassian.net", "issueType": "Bug", "email": "oncall@integration.test", "apiToken": "jira-api-token"}, id="jira_without_project"),
|
||||
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "email": "oncall@integration.test", "apiToken": "jira-api-token"}, id="jira_without_issue_type"),
|
||||
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "apiToken": "jira-api-token"}, id="jira_without_email"),
|
||||
pytest.param("jira", {"site": "https://acme.atlassian.net", "project": "OPS", "issueType": "Bug", "email": "oncall@integration.test"}, id="jira_without_api_token"),
|
||||
pytest.param("jsmops", {}, id="jsmops_without_api_key"),
|
||||
pytest.param("incidentio", {"token": "incidentio-token"}, id="incidentio_without_url"),
|
||||
pytest.param("incidentio", {"url": "https://api.incident.io/v2/alert_events/http/01ABCDEF"}, id="incidentio_without_token"),
|
||||
],
|
||||
)
|
||||
def test_create_rejects_a_spec_missing_a_required_field(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
kind: str,
|
||||
spec: dict,
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={"name": f"v2-missing-{uuid.uuid4().hex[:8]}", "config": {"kind": kind, "spec": spec}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
|
||||
|
||||
def test_create_accepts_an_opsgenie_channel_without_a_priority(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Nothing seeds priority, so v1 channels created without one hold an empty
|
||||
# value; requiring it here would make those rows unsaveable through v2.
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={
|
||||
"name": f"v2-og-nopriority-{uuid.uuid4().hex[:8]}",
|
||||
"config": {"kind": "opsgenie", "spec": {"apiKey": "og-api-key", "message": "subject", "description": "body"}},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
created = response.json()["data"]
|
||||
cleanup_notification_channels.append(created["id"])
|
||||
|
||||
assert created["config"]["spec"]["priority"] == ""
|
||||
|
||||
|
||||
def test_create_echoes_an_empty_value_on_a_field_with_no_default(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
cleanup_notification_channels: list[str],
|
||||
) -> None:
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(V2_BASE_URL),
|
||||
json={
|
||||
"name": f"v2-pd-empty-{uuid.uuid4().hex[:8]}",
|
||||
"config": {"kind": "pagerduty", "spec": {"routingKey": "pd-routing-key", "severity": "", "class": ""}},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.CREATED, response.text
|
||||
|
||||
created = response.json()["data"]
|
||||
cleanup_notification_channels.append(created["id"])
|
||||
|
||||
assert created["config"]["spec"]["severity"] == ""
|
||||
assert created["config"]["spec"]["class"] == ""
|
||||
Reference in New Issue
Block a user