Compare commits

..

3 Commits

Author SHA1 Message Date
therealpandey
b2a6e1b786 chore: remove query progress tracking
The websocket client for query progress was removed from the frontend in
tokenizer/sso (#9183), leaving /api/v3/query_progress and /ws/query_progress
with no consumers. Livetail streams over SSE, so no websocket endpoint
remains once these go.

Removes the query_progress tracker package, the GetQueryProgressUpdates
handler and its websocket upgrader, the reader hooks that reported progress
to clickhouse, and the X-SIGNOZ-QUERY-ID / Sec-WebSocket-Protocol plumbing
that only existed to feed them.
2026-09-07 18:19:08 +05:30
Naman Verma
861380dc65 feat: add create v2 api for notification channels (#12638)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This PR adds one endpoint, `POST /api/v2/notification_channels`, whose
typed `{name, displayName, config:{kind, spec}}` body replaces v1's
pass-through Alertmanager receiver JSON. v1 routes are deliberately
untouched, so the diff is near-purely additive.
List/get/update/delete/test come in the next PR.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/296

<!--Anything reviewers should keep in mind while reviewing -->

Eventually references (rules, routing policies) migrate onto
internal_name, freeing name to become a user-editable display name. But
that will happen post rules migration so that all rules are on v2

<!--Please delete paragraphs that you did not use before submitting.-->
2026-09-07 11:36:12 +00:00
Gaurav Tewari
391f685e57 feat(llm-observability): AI explorer QB and builder_ai_query wiring (#12683)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Adds the `builder_ai_query` envelope type, seeded by the AI explorer
via `initialQueryAIWithType`.
- AI queries pull filter-bar keys from
`/api/v1/ai_observability/fields/keys` and values from
`/api/v1/ai_observability/fields/values`.
- `QuerySection` drops the builder's custom order-by (`ExplorerOrderBy`
via `renderOrderBy`) for the list and trace panels, along with
`showTraceOperator`. Ordering for those views is the list's own concern,
not the builder's — the trace view supplies its own control in #12688.
`limit` and `having` are now rendered disabled on every panel instead of
hidden on list.
- Hides the span-scope selector for AI queries — "Root / Entrypoint
Spans" ANDs badly with the gate, since GenAI attributes sit on nested
spans. Derived from the query itself, so no new prop on the shared
builder.
- `createNewBuilderQuery` inherits `builderQueryType` from the first
query, the way it already inherits `source`.
- Response conversion reads aggregation metadata from both builder
envelope types, not just `builder_query`.

TDD -
https://app.notion.com/p/signoz/Query-Builder-Changes-3b4fcc6bcd1980889c7aed782665e78d

#### Issues closed by this PR

Closes -
https://github.com/orgs/SigNoz/projects/39/views/20?pane=issue&itemId=226748421&issue=SigNoz%7Cengineering-pod%7C5889

#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/985cb178-ffb0-405c-b0a6-82ec242c3d68

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-07 07:29:31 +00:00
85 changed files with 4660 additions and 920 deletions

View File

@@ -397,7 +397,6 @@ identn:
# headers to use for tokenizer identN resolver
headers:
- Authorization
- Sec-WebSocket-Protocol
apikey:
# toggle apikey identN
enabled: true

View File

@@ -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

View File

@@ -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)

View File

@@ -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));
};

View File

@@ -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;
/**

View File

@@ -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).

View File

@@ -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');
});
});

View File

@@ -365,7 +365,7 @@ export function convertBuilderQueriesToV5(
}
return {
type: 'builder_query' as QueryType,
type: queryData.builderQueryType ?? 'builder_query',
spec,
};
},

View File

@@ -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,
],
);

View File

@@ -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);
});
});

View File

@@ -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>;
};

View File

@@ -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(() => {

View File

@@ -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),

View File

@@ -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],

View File

@@ -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],
);

View File

@@ -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
/>
);

View File

@@ -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],
);

View File

@@ -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;
};

View File

@@ -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)) {

View File

@@ -8,7 +8,7 @@ import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panel
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import ConfigActions from './ConfigActions/ConfigActions';
import SectionSlot from './SectionSlot/SectionSlot';

View File

@@ -5,18 +5,8 @@ import PanelTypeSwitcher from '../PanelTypeSwitcher';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
{ kind: 'signoz/ListPanel', displayName: 'List' },
].map((option) => ({ ...option, icon: (): null => null })),
}));
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;

View File

@@ -2,8 +2,8 @@ import { useMemo } from 'react';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import { PANEL_OPTIONS } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
import { getPanelTypeDisabledReason } from './utils';
@@ -27,17 +27,17 @@ export function usePanelTypeSelectItems({
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
return useMemo(
() =>
PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => {
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
const disabledReason = getPanelTypeDisabledReason({
kind,
kind: panelKind,
queryType,
signal,
label: displayName,
label,
});
return {
value: kind,
label: displayName,
value: panelKind,
label,
icon: <Icon size={14} />,
disabled: !!disabledReason,
tooltip: disabledReason,

View File

@@ -5,7 +5,7 @@ import { Input } from 'antd';
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import { Virtuoso } from 'react-virtuoso';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../../../utils/legendSeries';
import LegendColorRow from './LegendColorRow';
import {
clearSeriesColor,

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../../../../utils/legendSeries';
import LegendColors from '../LegendColors';
const SERIES: LegendSeries[] = [

View File

@@ -1,4 +1,4 @@
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../../../../utils/legendSeries';
import {
clearSeriesColor,
filterLegendSeries,

View File

@@ -1,6 +1,6 @@
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../../../utils/legendSeries';
/** Case-insensitive substring filter over series labels. Empty query → all series. */
export function filterLegendSeries(

View File

@@ -1,7 +1,7 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { PanelKind } from '../../Panels/types/panelKind';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import type { LegendSeries } from '../utils/legendSeries';
import type { TableColumnOption } from '../hooks/useTableColumns';
import { EQueryType } from 'types/common/dashboard';

View File

@@ -11,10 +11,6 @@ jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
})),
PANEL_OPTIONS: [
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
{ kind: 'signoz/TablePanel', displayName: 'Table' },
].map((option) => ({ ...option, icon: (): null => null })),
}));
// Open the antd Select by clicking its selector, then pick the option by label.

View File

@@ -1,27 +1,36 @@
import { useMemo } from 'react';
import { useIsDarkMode } from 'hooks/useDarkMode';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
import { getSectionControls } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getSectionControls';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import {
type LegendSeries,
resolvePieLegendSeries,
resolveTimeSeriesLegendSeries,
} from '../utils/legendSeries';
/**
* Resolves the panel's rendered series into `{ label, defaultColor }` pairs so the
* legend-colors control can key overrides by the exact labels the chart draws, using
* the resolver the kind declares as its `colors` control.
* legend-colors control can key overrides by the exact labels the chart draws. Only the
* kinds that expose a colors control resolve series (Pie from its scalar slices, Time
* Series from its flat series); every other kind returns none.
*/
export function useLegendSeries(
panel: DashboardtypesPanelDTO,
data: PanelQueryData,
): LegendSeries[] {
const isDarkMode = useIsDarkMode();
const kind = panel.spec.plugin.kind;
return useMemo(() => {
const resolve = getSectionControls(kind, SectionKind.Legend)?.colors;
return resolve
? resolve({ queries: panel.spec.queries, data, isDarkMode })
: [];
}, [kind, panel.spec.queries, data, isDarkMode]);
switch (panel.spec.plugin.kind) {
case 'signoz/PieChartPanel':
return resolvePieLegendSeries(data, isDarkMode);
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
case 'signoz/HistogramPanel':
return resolveTimeSeriesLegendSeries(panel.spec.queries, data, isDarkMode);
default:
return [];
}
}, [panel.spec.plugin.kind, panel.spec.queries, data, isDarkMode]);
}

View File

@@ -2,9 +2,9 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { themeColors } from 'constants/theme';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { preparePieData } from '../kinds/PieChartPanel/prepareData';
import { getBuilderQueries } from './getBuilderQueries';
import { resolveSeriesLabelV5 } from './resolveSeriesLabel';
import { preparePieData } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/PieChartPanel/prepareData';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import { prepareScalarTables } from 'pages/DashboardPage/DashboardContainer/queryV5/prepareScalarTables';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import {
@@ -22,15 +22,6 @@ export interface LegendSeries {
type PanelQueries = DashboardtypesPanelDTO['spec']['queries'];
export interface LegendSeriesArgs {
queries: PanelQueries;
data: PanelQueryData;
isDarkMode: boolean;
}
/** Resolves a kind's output into the legend entries the colors control keys overrides by. */
export type LegendSeriesResolver = (args: LegendSeriesArgs) => LegendSeries[];
/**
* Dedupes `labels` (first-seen order, empties dropped) into `{ label, defaultColor }`
* pairs, resolving each unique label's color lazily via `colorFor` so a repeated
@@ -57,10 +48,10 @@ function buildLegendSeries(
* draws (without overrides, so their colors are the defaults) so the color control keys
* overrides by the same labels the chart does.
*/
export function resolvePieLegendSeries({
data,
isDarkMode,
}: LegendSeriesArgs): LegendSeries[] {
export function resolvePieLegendSeries(
data: PanelQueryData,
isDarkMode: boolean,
): LegendSeries[] {
const slices = preparePieData({
tables: prepareScalarTables({
results: getScalarResults(data.response),
@@ -79,11 +70,11 @@ export function resolvePieLegendSeries({
* Time-series kinds: resolve each flattened series' label the way the renderer does
* (`getLabelName` `resolveSeriesLabelV5`) and color it with `generateColor`.
*/
export function resolveTimeSeriesLegendSeries({
queries,
data,
isDarkMode,
}: LegendSeriesArgs): LegendSeries[] {
export function resolveTimeSeriesLegendSeries(
queries: PanelQueries,
data: PanelQueryData,
isDarkMode: boolean,
): LegendSeries[] {
const palette = isDarkMode
? themeColors.chartcolors
: themeColors.lightModeColor;

View File

@@ -1,5 +1,3 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -12,7 +10,6 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
icon: BarChart,
Renderer,
sections,
supportedSignals: [

View File

@@ -1,4 +1,3 @@
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
import {
SectionKind,
ThresholdVariant,
@@ -14,10 +13,7 @@ export const sections: SectionConfig[] = [
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
},
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },

View File

@@ -1,5 +1,3 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -12,7 +10,6 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
icon: BarChart,
Renderer,
sections,
supportedSignals: [

View File

@@ -1,4 +1,3 @@
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { SectionKind, type SectionConfig } from '../../types/sections';
@@ -10,7 +9,7 @@ export const sections: SectionConfig[] = [
},
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
controls: { position: true, colors: true },
// Merging all queries collapses to one distribution with no legend.
isHidden: (spec): boolean =>
Boolean(

View File

@@ -1,5 +1,3 @@
import { List } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -13,7 +11,6 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
displayName: 'List',
icon: List,
Renderer,
// Raw records come from logs and traces; metrics don't produce row data.
supportedSignals: [

View File

@@ -1,5 +1,3 @@
import { Hash } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -12,7 +10,6 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
icon: Hash,
Renderer,
sections,
supportedSignals: [

View File

@@ -1,5 +1,3 @@
import { ChartPie } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -12,7 +10,6 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
icon: ChartPie,
Renderer,
sections,
supportedSignals: [

View File

@@ -1,4 +1,3 @@
import { resolvePieLegendSeries } from '../../utils/legendSeries';
import { SectionKind, type SectionConfig } from '../../types/sections';
// Pie has no axes, thresholds, or stacking — just value formatting and a legend
@@ -9,9 +8,6 @@ export const sections: SectionConfig[] = [
controls: { switchPanelKind: true, timePreference: true },
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolvePieLegendSeries },
},
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{ kind: SectionKind.ContextLinks },
];

View File

@@ -1,5 +1,3 @@
import { Table } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -12,7 +10,6 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
displayName: 'Table',
icon: Table,
Renderer,
sections,
supportedSignals: [

View File

@@ -1,5 +1,3 @@
import { ChartLine } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -12,7 +10,6 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
icon: ChartLine,
Renderer,
sections,
supportedSignals: [

View File

@@ -1,4 +1,3 @@
import { resolveTimeSeriesLegendSeries } from '../../utils/legendSeries';
import {
SectionKind,
ThresholdVariant,
@@ -12,10 +11,7 @@ export const sections: SectionConfig[] = [
},
{ kind: SectionKind.Formatting, controls: { unit: true, decimals: true } },
{ kind: SectionKind.Axes, controls: { minMax: true, logScale: true } },
{
kind: SectionKind.Legend,
controls: { position: true, colors: resolveTimeSeriesLegendSeries },
},
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
{
kind: SectionKind.ChartAppearance,
controls: {

View File

@@ -1,5 +1,4 @@
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { TriangleAlert } from '@signozhq/icons';
import {
NO_PANEL_ACTIONS,
@@ -19,8 +18,6 @@ import Renderer from './Renderer';
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
displayName: 'Unsupported panel',
// Never offered in the UI — the kind lists come from the registry, which omits this.
icon: TriangleAlert,
Renderer,
sections: [],
supportedSignals: [],

View File

@@ -7,33 +7,22 @@ import { definition as Table } from './kinds/TablePanel/definition';
import { definition as List } from './kinds/ListPanel/definition';
import { UNSUPPORTED_PANEL } from './kinds/UnsupportedPanel/definition';
import type {
PanelDefinition,
PanelRegistry,
RenderablePanelDefinition,
} from './types/panelDefinition';
import { PanelKind } from './types/panelKind';
// Each kind owns its PanelDefinition; registering a new panel is one entry here.
// Declaration order is the order kinds are offered in the UI.
export const PANELS: PanelRegistry = {
[TimeSeries.kind]: TimeSeries,
[NumberValue.kind]: NumberValue,
[Table.kind]: Table,
[BarChart.kind]: BarChart,
[PieChart.kind]: PieChart,
[Histogram.kind]: Histogram,
[NumberValue.kind]: NumberValue,
[PieChart.kind]: PieChart,
[Table.kind]: Table,
[List.kind]: List,
};
export type PanelOption = Pick<
PanelDefinition,
'kind' | 'displayName' | 'icon'
>;
// Backs both the new-panel picker and the editor's kind switcher; derived from PANELS
// so a registered kind can't end up unreachable from the UI.
export const PANEL_OPTIONS: PanelOption[] = Object.values(PANELS);
/**
* Whether this build can render the kind. `PanelKind` spans every kind the API declares,
* but a dashboard spec written by a newer SigNoz can name one this client has never heard

View File

@@ -1,6 +1,5 @@
import type { ComponentType } from 'react';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { ChartLine } from '@signozhq/icons';
import type { EQueryType } from 'types/common/dashboard';
import type { SectionConfig } from './sections';
@@ -61,14 +60,9 @@ export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
drilldown: false,
};
// Derived from an icon component so the props stay exact (size is a constrained
// IconSize union) and ForwardRef-compatible.
export type PanelIcon = typeof ChartLine;
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
icon: PanelIcon;
Renderer: ComponentType<PanelRendererProps<K>>;
sections: SectionConfig[];
/** Signals this kind can visualize. */

View File

@@ -13,7 +13,6 @@ import type {
DashboardtypesTimeSeriesChartAppearanceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { LegendSeriesResolver } from '../utils/legendSeries';
import {
Antenna,
BarChart,
@@ -106,12 +105,7 @@ export interface SectionControls {
columnUnits?: boolean;
};
[SectionKind.Axes]: { minMax?: boolean; logScale?: boolean }; // minMax → softMin/softMax
[SectionKind.Legend]: {
position?: boolean;
// colors → customColors; the resolver supplies the labels overrides are keyed by,
// so a kind can't offer color overrides with nothing to color
colors?: LegendSeriesResolver;
};
[SectionKind.Legend]: { position?: boolean; colors?: boolean }; // colors → customColors
[SectionKind.ChartAppearance]: {
lineStyle?: boolean;
lineInterpolation?: boolean;

View File

@@ -79,7 +79,7 @@ describe('buildPluginSpec', () => {
it('omits the key entirely when a seed produces an empty slice (never key: undefined)', () => {
const result = buildPluginSpec([
{ kind: SectionKind.Legend, controls: { colors: (): [] => [] } },
{ kind: SectionKind.Legend, controls: { colors: true } },
]);
expect(result).toStrictEqual({});
@@ -129,7 +129,7 @@ describe('buildPluginSpec', () => {
it('seeds neither when their defaulting controls are absent', () => {
const sections: SectionConfig[] = [
{ kind: SectionKind.Visualization, controls: { switchPanelKind: true } },
{ kind: SectionKind.Legend, controls: { colors: (): [] => [] } },
{ kind: SectionKind.Legend, controls: { colors: true } },
];
expect(buildPluginSpec(sections)).toStrictEqual({});
});
@@ -180,10 +180,7 @@ describe('buildPluginSpec', () => {
it('carries old legend position but never customColors', () => {
const sections: SectionConfig[] = [
{
kind: SectionKind.Legend,
controls: { position: true, colors: (): [] => [] },
},
{ kind: SectionKind.Legend, controls: { position: true, colors: true } },
];
const oldSpec = oldSpecWith({
legend: {

View File

@@ -1,46 +0,0 @@
import { SectionKind, ThresholdVariant } from '../../types/sections';
import { getSectionControls } from '../getSectionControls';
describe('getSectionControls', () => {
it('returns the controls a kind declares for a section', () => {
expect(
getSectionControls('signoz/TimeSeriesPanel', SectionKind.Formatting),
).toStrictEqual({ unit: true, decimals: true });
});
it('distinguishes kinds that key units per column from kinds with a panel unit', () => {
expect(
getSectionControls('signoz/TablePanel', SectionKind.Formatting)?.unit,
).toBeUndefined();
expect(
getSectionControls('signoz/TablePanel', SectionKind.Formatting)?.columnUnits,
).toBe(true);
});
it('reports the threshold variant each kind edits', () => {
expect(
getSectionControls('signoz/NumberPanel', SectionKind.Thresholds)?.variant,
).toBe(ThresholdVariant.COMPARISON);
expect(
getSectionControls('signoz/BarChartPanel', SectionKind.Thresholds)?.variant,
).toBe(ThresholdVariant.LABEL);
});
it('returns undefined when the kind does not expose the section', () => {
expect(
getSectionControls('signoz/ListPanel', SectionKind.Formatting),
).toBeUndefined();
expect(
getSectionControls('signoz/HistogramPanel', SectionKind.Thresholds),
).toBeUndefined();
});
it('returns undefined for an unregistered kind', () => {
expect(
getSectionControls(
'signoz/FuturePanel' as Parameters<typeof getSectionControls>[0],
SectionKind.Formatting,
),
).toBeUndefined();
});
});

View File

@@ -1,22 +0,0 @@
import { getPanelDefinition } from '../registry';
import type { PanelKind } from '../types/panelKind';
import type { ControlledSectionKind, SectionControls } from '../types/sections';
/**
* The controls a kind declares for one section, or `undefined` when it doesn't expose
* that section — so callers read `kinds/<Kind>/sections.ts` instead of switching on kind.
*/
export function getSectionControls<K extends ControlledSectionKind>(
kind: PanelKind,
sectionKind: K,
): SectionControls[K] | undefined {
const section = getPanelDefinition(kind).sections.find(
(candidate) => candidate.kind === sectionKind,
);
if (!section || !('controls' in section)) {
return undefined;
}
// `find` can't correlate the matched member's `controls` with `sectionKind`; the
// SectionConfig union guarantees it.
return section.controls as SectionControls[K];
}

View File

@@ -4,8 +4,8 @@ import { DialogWrapper } from '@signozhq/ui/dialog';
import cx from 'classnames';
import { useDashboardSections } from '../../../hooks/useDashboardSections';
import { PANEL_OPTIONS } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from './constants';
import PanelTypeSelectionModalFooter from './PanelTypeSelectionModalFooter';
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
import styles from './PanelTypeSelectionModal.module.scss';
@@ -91,19 +91,19 @@ function PanelTypeSelectionModal({
<span className={styles.pickerLabel}>Select panel type</span>
)}
<div className={styles.grid}>
{PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => (
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<button
key={kind}
key={panelKind}
type="button"
className={cx(styles.panelTypeCard, {
[styles.panelTypeCardSelected]: kind === selectedPanelKind,
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
})}
data-testid={`panel-type-${kind}`}
aria-pressed={kind === selectedPanelKind}
onClick={(): void => handleTileClick(kind)}
data-testid={`panel-type-${panelKind}`}
aria-pressed={panelKind === selectedPanelKind}
onClick={(): void => handleTileClick(panelKind)}
>
<Icon size={24} color={Color.BG_ROBIN_400} />
{displayName}
{label}
</button>
))}
</div>

View File

@@ -0,0 +1,24 @@
import {
BarChart,
ChartLine,
ChartPie,
Hash,
List,
Table,
} from '@signozhq/icons';
import type { PanelType } from './types';
export const PANEL_TYPES: PanelType[] = [
{
panelKind: 'signoz/TimeSeriesPanel',
label: 'Time Series',
Icon: ChartLine,
},
{ panelKind: 'signoz/NumberPanel', label: 'Number', Icon: Hash },
{ panelKind: 'signoz/TablePanel', label: 'Table', Icon: Table },
{ panelKind: 'signoz/BarChartPanel', label: 'Bar Chart', Icon: BarChart },
{ panelKind: 'signoz/PieChartPanel', label: 'Pie Chart', Icon: ChartPie },
{ panelKind: 'signoz/HistogramPanel', label: 'Histogram', Icon: BarChart },
{ panelKind: 'signoz/ListPanel', label: 'List', Icon: List },
];

View File

@@ -1,11 +1,20 @@
import type { IconSize } from '@signozhq/icons';
import type { ComponentType, SVGProps } from 'react';
import type { PanelKind } from '../../../Panels/types/panelKind';
type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
size?: number | IconSize;
strokeWidth?: number;
};
export interface PanelType {
panelKind: PanelKind;
label: string;
/** Icon component — the consumer renders it and controls size/color/etc. */
Icon: ComponentType<IconProps>;
}
export interface SectionOption {
/** The section's `layoutIndex`, stringified for the Select value. */
value: string;

View File

@@ -8,24 +8,24 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
SectionKind,
type PanelFormattingSlice,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getSectionControls } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getSectionControls';
import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { deriveAlertPrefill, PanelAlertPrefill } from './deriveAlertPrefill';
/** The panel's configured y-axis unit, for the kinds that declare one. */
/** The panel's configured y-axis unit, for the kinds that carry one. */
export function readPanelUnit(
plugin: DashboardtypesPanelPluginDTO,
): string | undefined {
if (!getSectionControls(plugin.kind, SectionKind.Formatting)?.unit) {
return undefined;
switch (plugin.kind) {
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
case 'signoz/NumberPanel':
case 'signoz/PieChartPanel':
return plugin.spec.formatting?.unit;
default:
return undefined;
}
return (plugin.spec as { formatting?: PanelFormattingSlice }).formatting?.unit;
}
/**

View File

@@ -11,15 +11,7 @@ import {
AlertThresholdOperator,
Threshold,
} from 'container/CreateAlertV2/context/types';
import {
SectionKind,
ThresholdVariant,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import {
THRESHOLD_COLOR_DANGER_ORDER,
type ComparisonThresholdShape,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/threshold';
import { getSectionControls } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getSectionControls';
import { THRESHOLD_COLOR_DANGER_ORDER } from 'pages/DashboardPage/DashboardContainer/Panels/types/threshold';
import type { MetricAggregation } from 'types/api/v5/queryRange';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { ReduceOperators } from 'types/common/queryBuilder';
@@ -72,35 +64,27 @@ export function uniformReduceTo(query: Query): ReduceOperators | undefined {
: undefined;
}
/**
* The panel's thresholds, normalized for alert prefill, read through the variant the
* kind declares. A `table` variant contributes nothing: per-column thresholds have no
* meaning for a panel-wide alert condition.
*/
function readPanelThresholds(
plugin: DashboardtypesPanelPluginDTO,
): NormalizedPanelThreshold[] {
const variant = getSectionControls(
plugin.kind,
SectionKind.Thresholds,
)?.variant;
if (
variant !== ThresholdVariant.LABEL &&
variant !== ThresholdVariant.COMPARISON
) {
return [];
switch (plugin.kind) {
case 'signoz/TimeSeriesPanel':
case 'signoz/BarChartPanel':
return (plugin.spec.thresholds ?? []).map((t) => ({
color: t.color,
value: t.value,
unit: t.unit,
}));
case 'signoz/NumberPanel':
return (plugin.spec.thresholds ?? []).map((t) => ({
color: t.color,
value: t.value,
unit: t.unit,
operator: t.operator,
}));
default:
return [];
}
const thresholds =
(plugin.spec as { thresholds?: ComparisonThresholdShape[] }).thresholds ?? [];
return thresholds.map((threshold) => ({
color: threshold.color,
value: threshold.value,
unit: threshold.unit,
// Only comparison thresholds carry an operator.
...(variant === ThresholdVariant.COMPARISON && {
operator: threshold.operator,
}),
}));
}
// Match case-insensitively (picker emits lowercase hex); unknown colors sort last.

View File

@@ -12,7 +12,6 @@ import {
buildPluginSpec,
type SeededPluginSpec,
} from '../DashboardContainer/Panels/utils/buildPluginSpec';
import { getSectionControls } from '../DashboardContainer/Panels/utils/getSectionControls';
import { toPerses } from '../DashboardContainer/queryV5/persesQueryAdapters';
interface NewPanelSeed {
@@ -22,6 +21,15 @@ interface NewPanelSeed {
pluginSpec: SeededPluginSpec;
}
function kindSupportsUnit(kind: PanelKind): boolean {
return getPanelDefinition(kind).sections.some(
(section) =>
section.kind === SectionKind.Formatting &&
'controls' in section &&
section.controls.unit === true,
);
}
/** Kind to fall back to for a query language a builder-only kind (List) can't hold. */
const FALLBACK_KIND_BY_QUERY_TYPE: Partial<Record<EQueryType, PanelKind>> = {
[EQueryType.PROM]: 'signoz/TimeSeriesPanel',
@@ -66,10 +74,7 @@ export function buildNewPanelSeed(
const queries = converted.length > 0 ? converted : buildDefaultQueries(kind);
// Explorers put the single `unit` on the query itself, not the panel spec.
if (
compositeQuery.unit &&
getSectionControls(kind, SectionKind.Formatting)?.unit
) {
if (compositeQuery.unit && kindSupportsUnit(kind)) {
return {
kind,
queries,

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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 {

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View 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)
}

View File

@@ -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
}

View File

@@ -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"},

View File

@@ -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,

View File

@@ -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())
}

View File

@@ -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{},
}
}

View File

@@ -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)
}

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View 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"`
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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
}

View 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)
})
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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"))
}

View File

@@ -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,

View File

@@ -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"] == ""