diff --git a/docs/api/openapi.yml b/docs/api/openapi.yml index a7b713179ce..973f40ae10d 100644 --- a/docs/api/openapi.yml +++ b/docs/api/openapi.yml @@ -109,6 +109,57 @@ components: webhook_url: $ref: '#/components/schemas/ConfigSecretURL' type: object + AlertmanagertypesJSMOpsReceiverConfig: + properties: + api_key: + type: string + description: + type: string + http_config: + $ref: '#/components/schemas/ConfigHTTPClientConfig' + message: + type: string + priority: + type: string + send_resolved: + type: boolean + tags: + type: string + type: object + AlertmanagertypesJiraReceiverConfig: + properties: + custom_fields: + additionalProperties: {} + type: object + description: + type: string + http_config: + $ref: '#/components/schemas/ConfigHTTPClientConfig' + issue_type: + type: string + labels: + items: + type: string + type: array + priority: + type: string + project: + type: string + reopen_duration: + $ref: '#/components/schemas/ModelDuration' + reopen_transition: + type: string + resolve_transition: + type: string + send_resolved: + type: boolean + site: + type: string + summary: + type: string + wont_fix_resolution: + type: string + type: object AlertmanagertypesMaintenanceKind: enum: - fixed @@ -162,6 +213,10 @@ components: oneOf: - required: - googlechat_configs + - required: + - jira_configs + - required: + - jsmops_configs - required: - discord_configs - required: @@ -192,8 +247,6 @@ components: - msteams_configs - required: - msteamsv2_configs - - required: - - jira_configs - required: - rocketchat_configs - required: @@ -217,7 +270,11 @@ components: type: array jira_configs: items: - $ref: '#/components/schemas/ConfigJiraConfig' + $ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig' + type: array + jsmops_configs: + items: + $ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig' type: array mattermost_configs: items: @@ -344,7 +401,11 @@ components: type: array jira_configs: items: - $ref: '#/components/schemas/ConfigJiraConfig' + $ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig' + type: array + jsmops_configs: + items: + $ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig' type: array mattermost_configs: items: diff --git a/frontend/src/api/generated/services/sigNoz.schemas.ts b/frontend/src/api/generated/services/sigNoz.schemas.ts index 2a86ee28f69..0822eed1142 100644 --- a/frontend/src/api/generated/services/sigNoz.schemas.ts +++ b/frontend/src/api/generated/services/sigNoz.schemas.ts @@ -385,6 +385,93 @@ export interface AlertmanagertypesGoogleChatReceiverConfigDTO { webhook_url?: ConfigSecretURLDTO; } +export interface AlertmanagertypesJSMOpsReceiverConfigDTO { + /** + * @type string + */ + api_key?: string; + /** + * @type string + */ + description?: string; + http_config?: ConfigHTTPClientConfigDTO; + /** + * @type string + */ + message?: string; + /** + * @type string + */ + priority?: string; + /** + * @type boolean + */ + send_resolved?: boolean; + /** + * @type string + */ + tags?: string; +} + +export type AlertmanagertypesJiraReceiverConfigDTOCustomFields = { + [key: string]: unknown; +}; + +export type ModelDurationDTO = number; + +export interface AlertmanagertypesJiraReceiverConfigDTO { + /** + * @type object + */ + custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields; + /** + * @type string + */ + description?: string; + http_config?: ConfigHTTPClientConfigDTO; + /** + * @type string + */ + issue_type?: string; + /** + * @type array + */ + labels?: string[]; + /** + * @type string + */ + priority?: string; + /** + * @type string + */ + project?: string; + reopen_duration?: ModelDurationDTO; + /** + * @type string + */ + reopen_transition?: string; + /** + * @type string + */ + resolve_transition?: string; + /** + * @type boolean + */ + send_resolved?: boolean; + /** + * @type string + */ + site?: string; + /** + * @type string + */ + summary?: string; + /** + * @type string + */ + wont_fix_resolution?: string; +} + export enum AlertmanagertypesMaintenanceKindDTO { fixed = 'fixed', recurring = 'recurring', @@ -631,69 +718,6 @@ export interface ConfigIncidentioConfigDTO { url_file?: string; } -export interface ConfigJiraFieldConfigDTO { - /** - * @type boolean,null - */ - enable_update?: boolean | null; - /** - * @type string - */ - template?: string; -} - -export type ModelDurationDTO = number; - -export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown }; - -export interface ConfigJiraConfigDTO { - /** - * @type string - */ - api_type?: string; - api_url?: ConfigURLType2DTO; - /** - * @type object - */ - custom_fields?: ConfigJiraConfigDTOCustomFields; - description?: ConfigJiraFieldConfigDTO; - http_config?: ConfigHTTPClientConfigDTO; - /** - * @type string - */ - issue_type?: string; - /** - * @type array - */ - labels?: string[]; - /** - * @type string - */ - priority?: string; - /** - * @type string - */ - project?: string; - reopen_duration?: ModelDurationDTO; - /** - * @type string - */ - reopen_transition?: string; - /** - * @type string - */ - resolve_transition?: string; - /** - * @type boolean - */ - send_resolved?: boolean; - summary?: ConfigJiraFieldConfigDTO; - /** - * @type string - */ - wont_fix_resolution?: string; -} - export interface ConfigMattermostFieldDTO { /** * @type boolean,null @@ -1652,7 +1676,11 @@ export type AlertmanagertypesPostableChannelDTO = unknown & { /** * @type array */ - jira_configs?: ConfigJiraConfigDTO[]; + jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[]; + /** + * @type array + */ + jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[]; /** * @type array */ @@ -1779,7 +1807,11 @@ export interface AlertmanagertypesReceiverDTO { /** * @type array */ - jira_configs?: ConfigJiraConfigDTO[]; + jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[]; + /** + * @type array + */ + jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[]; /** * @type array */ @@ -3268,6 +3300,67 @@ export interface CommonJSONRefDTO { $ref?: string; } +export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown }; + +export interface ConfigJiraFieldConfigDTO { + /** + * @type boolean,null + */ + enable_update?: boolean | null; + /** + * @type string + */ + template?: string; +} + +export interface ConfigJiraConfigDTO { + /** + * @type string + */ + api_type?: string; + api_url?: ConfigURLType2DTO; + /** + * @type object + */ + custom_fields?: ConfigJiraConfigDTOCustomFields; + description?: ConfigJiraFieldConfigDTO; + http_config?: ConfigHTTPClientConfigDTO; + /** + * @type string + */ + issue_type?: string; + /** + * @type array + */ + labels?: string[]; + /** + * @type string + */ + priority?: string; + /** + * @type string + */ + project?: string; + reopen_duration?: ModelDurationDTO; + /** + * @type string + */ + reopen_transition?: string; + /** + * @type string + */ + resolve_transition?: string; + /** + * @type boolean + */ + send_resolved?: boolean; + summary?: ConfigJiraFieldConfigDTO; + /** + * @type string + */ + wont_fix_resolution?: string; +} + export interface DashboardGridItemDTO { content?: CommonJSONRefDTO; /** diff --git a/frontend/src/constants/events.ts b/frontend/src/constants/events.ts index baa4c573af1..5f5a21feed1 100644 --- a/frontend/src/constants/events.ts +++ b/frontend/src/constants/events.ts @@ -29,6 +29,7 @@ export enum InfraMonitoringEvents { MetricsView = 'metrics', Total = 'total', Cluster = 'cluster', + Container = 'container', DaemonSet = 'daemonSet', Deployment = 'deployment', Job = 'job', diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/ColumnHeader.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/ColumnHeader.tsx index d0a97a9e3ea..379254eb7e9 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/ColumnHeader.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/ColumnHeader.tsx @@ -4,8 +4,9 @@ import { TooltipSimple } from '@signozhq/ui/tooltip'; import styles from './ColumnHeader.module.scss'; import cx from 'classnames'; import { MouseEventHandler } from 'react'; +import { DOCS_BASE_URL } from 'constants/app'; -const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`; +const DOCS_ROOT = `${DOCS_BASE_URL}/docs`; interface ColumnHeaderProps { children?: React.ReactNode; @@ -43,7 +44,7 @@ function ColumnHeader({
{tooltipTitle}{' '} {tooltipTitle}{' '} e.stopPropagation()} diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.tsx index 04b68accb3a..5323ce3e8f4 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.tsx @@ -1,12 +1,10 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useQuery } from 'react-query'; -import { useCopyToClipboard } from 'react-use'; -import { Copy, X } from '@signozhq/icons'; +import { X } from '@signozhq/icons'; import { Divider } from '@signozhq/ui/divider'; import { Button } from '@signozhq/ui/button'; import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer'; import { toast } from '@signozhq/ui/sonner'; -import { TooltipSimple } from '@signozhq/ui/tooltip'; import { Typography } from '@signozhq/ui/typography'; import logEvent from 'api/common/logEvent'; import ErrorContent from 'components/ErrorModal/components/ErrorContent'; @@ -20,6 +18,7 @@ import { import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants'; import { useInfraMonitoringSelectedItemParams } from '../hooks'; +import CopyButton from 'periscope/components/CopyButton/CopyButton'; import LoadingContainer from '../LoadingContainer'; import K8sBaseDetailsContent from './K8sBaseDetailsContent'; @@ -95,12 +94,14 @@ export default function K8sBaseDetails({ selectedItem, selectedItemParams.clusterName, selectedItemParams.namespaceName, + selectedItemParams.containerName, ), [ queryKeyPrefix, selectedItem, selectedItemParams.clusterName, selectedItemParams.namespaceName, + selectedItemParams.containerName, selectedTime, getAutoRefreshQueryKey, ], @@ -170,14 +171,9 @@ export default function K8sBaseDetails({ [handleClose], ); - const [, copyToClipboard] = useCopyToClipboard(); - const handleCopyId = useCallback((): void => { - if (selectedItem) { - copyToClipboard(selectedItem); - toast.success('ID copied to clipboard', { position: 'bottom-left' }); - } - }, [copyToClipboard, selectedItem]); + toast.success('ID copied to clipboard', { position: 'bottom-left' }); + }, []); const entityName = entity ? getEntityName(entity) : ''; @@ -211,17 +207,13 @@ export default function K8sBaseDetails({ (isEntityLoading && 'Loading...') || '-'} - - - + ) as unknown as string; diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetailsContent.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetailsContent.tsx index 89743fd8885..1d56bb24b68 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetailsContent.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetailsContent.tsx @@ -9,7 +9,6 @@ import { import { Button } from '@signozhq/ui/button'; import { ToggleGroupSimple } from '@signozhq/ui/toggle-group'; import { TooltipSimple } from '@signozhq/ui/tooltip'; -import { Typography } from '@signozhq/ui/typography'; import logEvent from 'api/common/logEvent'; import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils'; import { InfraMonitoringEvents } from 'constants/events'; @@ -42,6 +41,7 @@ import { } from '../hooks'; import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection'; +import { EntityMetadataItem } from './components/EntityMetadataItem/EntityMetadataItem'; import { K8sBaseDetailsContentProps } from './types'; import { getDrawerDurationMs } from './useDrawerLifecycleStore'; @@ -239,41 +239,18 @@ export default function K8sBaseDetailsContent({ <>
-
- {metadataConfig.map((config) => ( - - {config.label} - - ))} -
- -
- {metadataConfig.map((config) => { - const value = config.getValue(entity); + {metadataConfig.map((config) => { + const value = config.getValue(entity); - if (config.render) { - return config.render(value, entity); - } - - const displayValue = String(value); - return ( - - {displayValue} - - ); - })} -
+ return ( + + ); + })}
{countsConfig && diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx index 82e65cdc19d..5fd324bc37f 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx @@ -296,6 +296,7 @@ export function K8sBaseList< params.selectedItem, params.clusterName, params.namespaceName, + params.containerName, ); queryClient.setQueryData(detailQueryKey, { data: record }); } @@ -348,6 +349,12 @@ export function K8sBaseList< params.namespaceName, ); } + if (params.containerName) { + url.searchParams.set( + INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME, + params.containerName, + ); + } } else { url.searchParams.set( INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM, diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss index b118df1e251..f65f0eabee3 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.module.scss @@ -19,6 +19,12 @@ & [data-hide-expanded='true'] { display: none; } + + // The icon slot is rendered even when the icon inside it is hidden, and the + // header's flex gap then indents the title past the values below it. + & [data-slot='icon']:has([data-hide-expanded='true']) { + display: none; + } } .expandedTableFooter { diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx index 728a1f5d8e0..3c327ccbd3b 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/K8sExpandedRow.tsx @@ -214,6 +214,7 @@ export function K8sExpandedRow< params.selectedItem, params.clusterName, params.namespaceName, + params.containerName, ); queryClient.setQueryData(detailQueryKey, { data: row }); } diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.module.scss b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.module.scss new file mode 100644 index 00000000000..012a5718f6a --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.module.scss @@ -0,0 +1,44 @@ +.metadataItem { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + min-width: 0; +} + +.valueRow { + display: flex; + align-items: center; + gap: var(--spacing-1); + min-width: 0; +} + +.label { + letter-spacing: 0.44px; + text-transform: uppercase; +} + +// Single-line ellipsis rather than Typography's `truncate`, which line-clamps: +// clamping still wraps the text, so a value breaking at a hyphen ends its line +// early and leaves a gap between the ellipsis and the copy button. +// +// This has to be a block: overflow and text-overflow do not apply to inline +// boxes, and Typography.Text renders an inline span. +.value { + display: block; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.valueText { + font-family: var(--periscope-font-family-mono); +} + +// Sized to the icon rather than the default 2rem tap target, so it sits flush +// against the value instead of floating in its own block of padding. +.copyButton { + --button-padding: 2px; + --button-height: auto; + --button-width: auto; +} diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.tsx new file mode 100644 index 00000000000..0993b419f36 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/EntityMetadataItem.tsx @@ -0,0 +1,63 @@ +import { ReactNode, useCallback } from 'react'; +import { toast } from '@signozhq/ui/sonner'; +import { TooltipSimple } from '@signozhq/ui/tooltip'; +import { Typography } from '@signozhq/ui/typography'; +import CopyButton from 'periscope/components/CopyButton/CopyButton'; + +import styles from './EntityMetadataItem.module.scss'; + +export interface EntityMetadataItemProps { + label: string; + value: string; + /** An entity-supplied renderer, which opts out of clamping, tooltip and copy. */ + renderedValue?: ReactNode; +} + +export function EntityMetadataItem({ + label, + value, + renderedValue, +}: EntityMetadataItemProps): JSX.Element { + const handleCopy = useCallback((): void => { + toast.success(`${label} copied to clipboard`, { position: 'bottom-left' }); + }, [label]); + + return ( +
+ + {label} + + + {renderedValue ?? ( +
+ + + + {value} + + + + {!!value && ( + + )} +
+ )} +
+ ); +} diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/__tests__/EntityMetadataItem.test.tsx b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/__tests__/EntityMetadataItem.test.tsx new file mode 100644 index 00000000000..cdfb0a19480 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/components/EntityMetadataItem/__tests__/EntityMetadataItem.test.tsx @@ -0,0 +1,110 @@ +import { render, screen, userEvent, waitFor } from 'tests/test-utils'; + +import { EntityMetadataItem } from '../EntityMetadataItem'; + +const mockCopyToClipboard = jest.fn(); + +jest.mock('react-use', () => ({ + __esModule: true, + useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard], +})); + +const mockToastSuccess = jest.fn(); + +jest.mock('@signozhq/ui/sonner', () => ({ + ...jest.requireActual('@signozhq/ui/sonner'), + toast: { + success: (...args: unknown[]): unknown => mockToastSuccess(...args), + }, +})); + +describe('EntityMetadataItem', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the label and its value', () => { + render(); + + expect(screen.getByText('Cluster Name')).toBeInTheDocument(); + expect(screen.getByText('prod-cluster')).toBeInTheDocument(); + }); + + it('copies the full value and confirms which field was copied', async () => { + render( + , + ); + + await userEvent.click(screen.getByTestId('copy-metadata-image:tag')); + + await waitFor(() => { + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'ghcr.io/open-telemetry/demo:1.12.0-loadgenerator', + ); + }); + expect(mockToastSuccess).toHaveBeenCalledWith( + 'Image:Tag copied to clipboard', + expect.anything(), + ); + }); + + it('offers no copy control when the value is empty', () => { + render(); + + expect(screen.queryByTestId('copy-metadata-node')).not.toBeInTheDocument(); + }); + + it('exposes the full value on hover', async () => { + render( + , + ); + + await userEvent.hover( + screen.getByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t'), + ); + + await waitFor(() => { + expect( + screen.getAllByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t').length, + ).toBeGreaterThan(1); + }); + }); + + it('never presents the value as clickable', () => { + render(); + + const valueEl = screen.getByText('a-very-long-node-name'); + expect(valueEl).not.toHaveAttribute('data-interactive'); + expect(valueEl).not.toHaveAttribute('data-truncate'); + }); + + it('triggers the tooltip from the wrapper, never from the text itself', () => { + render(); + + // Radix merges its handlers onto the trigger, and Typography styles + // itself interactive off any merged onClick — so the trigger has to stay + // off the text. + const textEl = screen.getByText('a-very-long-node-name'); + expect(textEl).not.toHaveAttribute('data-slot', 'tooltip-trigger'); + expect(textEl.parentElement).toHaveAttribute('data-slot', 'tooltip-trigger'); + }); + + it('leaves an entity-supplied renderer alone', () => { + render( + custom node} + />, + ); + + expect(screen.getByTestId('custom')).toBeInTheDocument(); + expect(screen.queryByTestId('copy-metadata-status')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts b/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts index 9783aa3c150..a5cbeb1846e 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts +++ b/frontend/src/container/InfraMonitoringK8sV2/Base/entity.registry.ts @@ -11,6 +11,7 @@ import { jobEntityConfig } from '../Jobs/entity.config'; import { daemonSetEntityConfig } from '../DaemonSets/entity.config'; import { statefulSetEntityConfig } from '../StatefulSets/entity.config'; import { volumeEntityConfig } from '../Volumes/entity.config'; +import { containerEntityConfig } from '../Containers/entity.config'; type AnyEntityConfig = K8sEntityConfig< K8sEntityData, @@ -34,6 +35,7 @@ export const entityRegistry: Record = { [K8sCategories.DAEMONSETS]: registerConfig(daemonSetEntityConfig), [K8sCategories.STATEFULSETS]: registerConfig(statefulSetEntityConfig), [K8sCategories.VOLUMES]: registerConfig(volumeEntityConfig), + [K8sCategories.CONTAINERS]: registerConfig(containerEntityConfig), }; export function getEntityConfig(category: string): AnyEntityConfig | undefined { diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/__tests__/containers.test.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/__tests__/containers.test.ts new file mode 100644 index 00000000000..e11e344bfa9 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/__tests__/containers.test.ts @@ -0,0 +1,142 @@ +import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas'; +import { + k8sContainerGetSelectedItemExpression, + k8sContainerInitialEventsExpression, + k8sContainerInitialLogTracesExpression, +} from 'container/InfraMonitoringK8sV2/Containers/constants'; +import { getContainerMetricsQueryPayload } from 'container/InfraMonitoringK8sV2/Containers/metrics'; +import { + getK8sContainerItemKey, + getK8sContainerRowKey, +} from 'container/InfraMonitoringK8sV2/Containers/table.config'; +import { getContainerImageWithTag } from 'container/InfraMonitoringK8sV2/Containers/utils'; + +function makeContainer( + overrides: Partial = {}, +): InframonitoringtypesContainerRecordDTO { + return { + containerName: 'nginx', + podUID: 'pod-uid-1', + meta: { + 'k8s.container.name': 'nginx', + 'k8s.pod.uid': 'pod-uid-1', + 'k8s.pod.name': 'web-0', + 'k8s.namespace.name': 'production', + 'k8s.cluster.name': 'prod-cluster', + 'container.image.name': 'nginx', + 'container.image.tag': '1.27', + }, + ...overrides, + } as InframonitoringtypesContainerRecordDTO; +} + +describe('container identity', () => { + it('keys a row by the (pod UID, container name) pair', () => { + expect(getK8sContainerRowKey(makeContainer())).toBe('pod-uid-1/nginx'); + }); + + it('carries the container name alongside the pod UID into the drawer params', () => { + expect(getK8sContainerItemKey(makeContainer())).toStrictEqual({ + selectedItem: 'pod-uid-1', + containerName: 'nginx', + clusterName: null, + namespaceName: null, + }); + }); + + it('falls back to meta when the record fields are empty', () => { + const container = makeContainer({ containerName: '', podUID: '' }); + + expect(getK8sContainerItemKey(container)).toStrictEqual({ + selectedItem: 'pod-uid-1', + containerName: 'nginx', + clusterName: null, + namespaceName: null, + }); + }); + + it('scopes the details fetch to both halves of the identity', () => { + expect( + k8sContainerGetSelectedItemExpression({ + selectedItem: 'pod-uid-1', + containerName: 'nginx', + }), + ).toBe("k8s.pod.uid = 'pod-uid-1' AND k8s.container.name = 'nginx'"); + }); +}); + +describe('getContainerImageWithTag', () => { + it('renders name and tag together', () => { + expect(getContainerImageWithTag(makeContainer())).toBe('nginx:1.27'); + }); + + it('drops the tag when the image is not pinned', () => { + const container = makeContainer({ + meta: { 'container.image.name': 'nginx' }, + }); + + expect(getContainerImageWithTag(container)).toBe('nginx'); + }); + + it('renders nothing when the image name is missing', () => { + expect(getContainerImageWithTag(makeContainer({ meta: {} }))).toBe(''); + }); +}); + +describe('container drawer expressions', () => { + it('scopes logs and traces to the container within its pod', () => { + expect(k8sContainerInitialLogTracesExpression(makeContainer())).toBe( + "k8s.pod.uid = 'pod-uid-1' AND k8s.cluster.name = 'prod-cluster' AND k8s.namespace.name = 'production' AND k8s.container.name = 'nginx'", + ); + }); + + it('scopes events to the pod, since k8s emits events per pod', () => { + expect(k8sContainerInitialEventsExpression(makeContainer())).toBe( + "k8s.object.kind = 'Pod' AND k8s.object.name = 'web-0' AND k8s.cluster.name = 'prod-cluster' AND attribute.k8s.namespace.name = 'production'", + ); + }); +}); + +describe('getContainerMetricsQueryPayload', () => { + const payloads = getContainerMetricsQueryPayload(makeContainer(), 1000, 2000); + + it('returns one payload per documented chart', () => { + expect(payloads).toHaveLength(10); + }); + + it('scopes every query to the selected container', () => { + payloads.forEach((payload) => { + payload.query.builder.queryData.forEach((query) => { + expect(query.filters?.items).toStrictEqual([ + expect.objectContaining({ + key: expect.objectContaining({ key: 'k8s.pod.uid' }), + op: '=', + value: 'pod-uid-1', + }), + expect.objectContaining({ + key: expect.objectContaining({ key: 'k8s.container.name' }), + op: '=', + value: 'nginx', + }), + ]); + }); + }); + }); + + it('derives cache memory from the working set and RSS queries', () => { + const memoryByState = payloads[4]; + + expect( + memoryByState.query.builder.queryData.map((query) => [ + query.queryName, + query.aggregateAttribute?.key, + ]), + ).toStrictEqual([ + ['A', 'container.memory.rss'], + ['B', 'container.memory.working_set'], + ]); + expect(memoryByState.query.builder.queryFormulas).toStrictEqual([ + expect.objectContaining({ expression: 'B - A', legend: 'Cache Memory' }), + ]); + }); +}); diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/constants.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/constants.ts new file mode 100644 index 00000000000..f98440c48a4 --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/constants.ts @@ -0,0 +1,170 @@ +import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas'; +import { formatValueForExpression } from 'components/QueryBuilderV2/utils'; + +import { + buildEventsExpression, + buildLogsTracesExpression, +} from '../Base/utils'; +import { K8sDetailsMetadataConfig, K8sDetailsWidgetInfo } from '../Base/types'; +import { INFRA_MONITORING_ATTR_KEYS } from '../constants'; +import { SelectedItemParams } from '../hooks'; +import { + CONTAINERS_DOC_PATH, + getContainerImageWithTag, + getContainerName, + getContainerPodUID, +} from './utils'; + +/** A container row is identified by the (pod UID, container name) pair. */ +export const k8sContainerGetSelectedItemExpression = ( + params: SelectedItemParams, +): string => + [ + `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID} = ${formatValueForExpression( + params.selectedItem ?? '', + )}`, + `${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME} = ${formatValueForExpression( + params.containerName ?? '', + )}`, + ].join(' AND '); + +export const k8sContainerGetEntityName = getContainerName; + +export const k8sContainerDetailsMetadataConfig: K8sDetailsMetadataConfig[] = + [ + { + label: 'Pod', + getValue: (c): string => + c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '', + }, + { + label: 'NAMESPACE', + getValue: (c): string => + c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '', + }, + { + label: 'Node', + getValue: (c): string => + c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '', + }, + { + label: 'Cluster Name', + getValue: (c): string => + c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '', + }, + { + label: 'Image:Tag', + getValue: getContainerImageWithTag, + }, + ]; + +export const k8sContainerInitialLogTracesExpression = ( + container: InframonitoringtypesContainerRecordDTO, +): string => { + const base = buildLogsTracesExpression({ + mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID, + mainAttributeValue: getContainerPodUID(container), + clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME], + namespaceName: + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME], + }); + + const containerName = getContainerName(container); + if (!containerName) { + return base; + } + + const containerClause = `${ + INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME + } = ${formatValueForExpression(containerName)}`; + + return base ? `${base} AND ${containerClause}` : containerClause; +}; + +/** + * Kubernetes emits events against the pod, not the container, so the events tab + * is scoped to the container's pod. + */ +export const k8sContainerInitialEventsExpression = ( + container: InframonitoringtypesContainerRecordDTO, +): string => + buildEventsExpression({ + objectKind: 'Pod', + objectName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '', + clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME], + namespaceName: + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME], + }); + +export const containerWidgetInfo: K8sDetailsWidgetInfo[] = [ + { + title: 'CPU Usage (cores)', + yAxisUnit: '', + docPath: `${CONTAINERS_DOC_PATH}#cpu-usage-cores-1`, + description: + 'Avg, max and min CPU consumption of the container in cores, showing how bursty it is.', + }, + { + title: 'CPU Request, Limit Utilization', + yAxisUnit: 'percentunit', + docPath: `${CONTAINERS_DOC_PATH}#cpu-request-limit-utilization`, + description: + 'Container CPU usage as a fraction of its own CPU request and limit; limit lines near 100% mean throttling.', + }, + { + title: 'Memory Usage (bytes)', + yAxisUnit: 'bytes', + docPath: `${CONTAINERS_DOC_PATH}#memory-usage-bytes`, + description: + 'Total memory charged to the container, including reclaimable page cache, against the headroom left before its limit.', + }, + { + title: 'Memory Request, Limit Utilization', + yAxisUnit: 'percentunit', + docPath: `${CONTAINERS_DOC_PATH}#memory-request-limit-utilization`, + description: + 'Container memory usage as a fraction of its own memory request and limit; limit lines near 100% risk an OOMKill.', + }, + { + title: 'Memory by State', + yAxisUnit: 'bytes', + docPath: `${CONTAINERS_DOC_PATH}#memory-by-state`, + description: + 'RSS, working set and cache memory of the container, separating heap growth from file cache.', + }, + { + title: 'Memory Major Page Faults', + yAxisUnit: '', + docPath: `${CONTAINERS_DOC_PATH}#memory-major-page-faults`, + description: + 'Major page fault rate of the container; sustained values mean the working set is paging to disk.', + }, + { + title: 'File System (bytes)', + yAxisUnit: 'bytes', + docPath: `${CONTAINERS_DOC_PATH}#file-system-bytes`, + description: + 'Capacity, available and used bytes of the container filesystem.', + }, + { + title: 'Container Uptime', + yAxisUnit: 's', + docPath: `${CONTAINERS_DOC_PATH}#container-uptime`, + description: + 'Time since the container last started; a sawtooth of resets means it is restarting repeatedly.', + }, + { + title: 'Node CPU Utilization by Container', + yAxisUnit: 'percentunit', + docPath: `${CONTAINERS_DOC_PATH}#node-cpu-utilization-by-container`, + description: + "The container's CPU usage as a fraction of the whole node's capacity, to spot noisy neighbours.", + }, + { + title: 'Node Memory Utilization by Container', + yAxisUnit: 'percentunit', + docPath: `${CONTAINERS_DOC_PATH}#node-memory-utilization-by-container`, + description: + "The container's memory usage as a fraction of the whole node's capacity.", + }, +]; diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/entity.config.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/entity.config.ts new file mode 100644 index 00000000000..95f33ca214c --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/entity.config.ts @@ -0,0 +1,140 @@ +import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs'; +import { listContainers } from 'api/generated/services/inframonitoring'; +import { + InframonitoringtypesContainerRecordDTO, + InframonitoringtypesResponseTypeDTO, + Querybuildertypesv5OrderDirectionDTO, + RenderErrorResponseDTO, +} from 'api/generated/services/sigNoz.schemas'; +import { AxiosError } from 'axios'; +import { InfraMonitoringEvents } from 'constants/events'; + +import { K8sEntityConfig } from '../Base/entity.config.types'; +import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types'; +import { InfraMonitoringEntity } from '../constants'; +import { SelectedItemParams } from '../hooks'; +import { + containerWidgetInfo, + k8sContainerDetailsMetadataConfig, + k8sContainerGetEntityName, + k8sContainerGetSelectedItemExpression, + k8sContainerInitialEventsExpression, + k8sContainerInitialLogTracesExpression, +} from './constants'; +import { getContainerMetricsQueryPayload } from './metrics'; +import { + getK8sContainerItemKey, + getK8sContainerRowKey, + k8sContainerColumnsConfig, +} from './table.config'; + +async function fetchListData( + filters: K8sBaseFilters, + signal?: AbortSignal, +): ReturnType< + K8sEntityConfig< + InframonitoringtypesContainerRecordDTO, + SelectedItemParams + >['list']['fetchListData'] +> { + try { + const response = await listContainers( + { + filter: { expression: filters.filter.expression }, + groupBy: filters.groupBy?.map((g) => ({ name: g.name })), + offset: filters.offset, + limit: filters.limit ?? 10, + start: filters.start, + end: filters.end, + orderBy: filters.orderBy + ? { + key: { name: filters.orderBy.key.name }, + direction: + filters.orderBy.direction === 'asc' + ? Querybuildertypesv5OrderDirectionDTO.asc + : Querybuildertypesv5OrderDirectionDTO.desc, + } + : undefined, + }, + signal, + ); + + const data = response.data; + return { + type: + data.type === InframonitoringtypesResponseTypeDTO.grouped_list + ? ('grouped_list' as const) + : ('list' as const), + records: data.records, + total: data.total, + endTimeBeforeRetention: data.endTimeBeforeRetention, + warning: data.warning, + }; + } catch (error) { + return { + type: 'list' as const, + records: [] as InframonitoringtypesContainerRecordDTO[], + total: 0, + error: + convertToApiError(error as AxiosError) ?? null, + }; + } +} + +async function fetchEntityData( + filters: K8sDetailsFilters, + signal?: AbortSignal, +): ReturnType< + K8sEntityConfig['details']['fetchEntityData'] +> { + try { + const response = await listContainers( + { + filter: { expression: filters.filter.expression }, + start: filters.start, + end: filters.end, + limit: 1, + offset: 0, + }, + signal, + ); + + return { + data: response.data.records.length > 0 ? response.data.records[0] : null, + }; + } catch (error) { + return { + data: null, + error: + convertToApiError(error as AxiosError) ?? null, + }; + } +} + +export const containerEntityConfig: K8sEntityConfig< + InframonitoringtypesContainerRecordDTO, + SelectedItemParams +> = { + list: { + entity: InfraMonitoringEntity.CONTAINERS, + eventCategory: InfraMonitoringEvents.Container, + tableColumns: k8sContainerColumnsConfig, + fetchListData, + getRowKey: getK8sContainerRowKey, + getItemKey: getK8sContainerItemKey, + detailsQueryKeyPrefix: 'container', + }, + details: { + category: InfraMonitoringEntity.CONTAINERS, + eventCategory: InfraMonitoringEvents.Container, + queryKeyPrefix: 'container', + getSelectedItemExpression: k8sContainerGetSelectedItemExpression, + fetchEntityData, + getEntityName: k8sContainerGetEntityName, + getInitialLogTracesExpression: k8sContainerInitialLogTracesExpression, + getInitialEventsExpression: k8sContainerInitialEventsExpression, + metadataConfig: k8sContainerDetailsMetadataConfig, + entityWidgetInfo: containerWidgetInfo, + getEntityQueryPayload: getContainerMetricsQueryPayload, + }, +}; diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/metrics.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/metrics.ts new file mode 100644 index 00000000000..6fd9cb24dab --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/metrics.ts @@ -0,0 +1,275 @@ +import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas'; +import { PANEL_TYPES } from 'constants/queryBuilder'; +import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults'; +import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; +import { TagFilter } from 'types/api/queryBuilder/queryBuilderData'; +import { EQueryType } from 'types/common/dashboard'; +import { DataSource, ReduceOperators } from 'types/common/queryBuilder'; +import { v4 } from 'uuid'; + +import { INFRA_MONITORING_ATTR_KEYS } from '../constants'; +import { getContainerName, getContainerPodUID } from './utils'; + +const QUERY_NAMES = ['A', 'B', 'C', 'D', 'E', 'F']; +const STEP_INTERVAL = 60; + +type TimeAggregation = 'avg' | 'max' | 'min' | 'latest'; +type SpaceAggregation = 'sum' | 'avg' | 'max'; + +interface SeriesSpec { + metricKey: string; + legend: string; + timeAggregation: TimeAggregation; + spaceAggregation: SpaceAggregation; +} + +interface FormulaSpec { + expression: string; + legend: string; +} + +/** + * Every panel is scoped to a single container by the (k8s.pod.uid, + * k8s.container.name) pair that identifies its row in the list. + */ +function buildScopeFilters( + container: InframonitoringtypesContainerRecordDTO, +): TagFilter { + return { + items: [ + { + id: 'pod-uid', + key: { + dataType: DataTypes.String, + id: `k8s_pod_uid--string--tag--false`, + key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID, + type: 'tag', + }, + op: '=', + value: getContainerPodUID(container), + }, + { + id: 'container-name', + key: { + dataType: DataTypes.String, + id: `k8s_container_name--string--tag--false`, + key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME, + type: 'tag', + }, + op: '=', + value: getContainerName(container), + }, + ], + op: 'AND', + }; +} + +function buildQuery( + container: InframonitoringtypesContainerRecordDTO, + start: number, + end: number, + series: SeriesSpec[], + formulas: FormulaSpec[] = [], +): GetQueryResultsProps { + const filters = buildScopeFilters(container); + + return { + selectedTime: 'GLOBAL_TIME', + graphType: PANEL_TYPES.TIME_SERIES, + query: { + builder: { + queryData: series.map((spec, index) => ({ + aggregateAttribute: { + dataType: DataTypes.Float64, + id: `${spec.metricKey.replace(/\./g, '_')}--float64--Gauge--true`, + key: spec.metricKey, + type: 'Gauge', + }, + aggregateOperator: spec.timeAggregation, + dataSource: DataSource.METRICS, + disabled: false, + expression: QUERY_NAMES[index], + filters, + functions: [], + groupBy: [], + having: [], + legend: spec.legend, + limit: null, + orderBy: [], + queryName: QUERY_NAMES[index], + reduceTo: ReduceOperators.AVG, + spaceAggregation: spec.spaceAggregation, + stepInterval: STEP_INTERVAL, + timeAggregation: spec.timeAggregation, + })), + queryFormulas: formulas.map((formula, index) => ({ + disabled: false, + expression: formula.expression, + legend: formula.legend, + queryName: `F${index + 1}`, + })), + queryTraceOperator: [], + }, + clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }], + id: v4(), + promql: [{ disabled: false, legend: '', name: 'A', query: '' }], + queryType: EQueryType.QUERY_BUILDER, + }, + variables: {}, + formatForWeb: false, + start, + end, + }; +} + +/** Absolute usage metrics are summed across series, ratios are averaged. */ +function usageSeries(metricKey: string, legendPrefix = ''): SeriesSpec[] { + const prefix = legendPrefix ? `${legendPrefix} - ` : ''; + return [ + { + metricKey, + legend: `${prefix}Avg`, + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + { + metricKey, + legend: `${prefix}Max`, + timeAggregation: 'max', + spaceAggregation: 'sum', + }, + { + metricKey, + legend: `${prefix}Min`, + timeAggregation: 'min', + spaceAggregation: 'sum', + }, + ]; +} + +function utilizationSeries( + metricKey: string, + legendPrefix: string, +): SeriesSpec[] { + return (['avg', 'max', 'min'] as TimeAggregation[]).map((timeAggregation) => ({ + metricKey, + legend: `${legendPrefix} - ${ + timeAggregation.charAt(0).toUpperCase() + timeAggregation.slice(1) + }`, + timeAggregation, + spaceAggregation: 'avg' as const, + })); +} + +export const getContainerMetricsQueryPayload = ( + container: InframonitoringtypesContainerRecordDTO, + start: number, + end: number, +): GetQueryResultsProps[] => { + const query = ( + series: SeriesSpec[], + formulas?: FormulaSpec[], + ): GetQueryResultsProps => buildQuery(container, start, end, series, formulas); + + return [ + query(usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE)), + query([ + ...utilizationSeries( + INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_REQUEST_UTILIZATION, + 'Request util %', + ), + ...utilizationSeries( + INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_LIMIT_UTILIZATION, + 'Limit util %', + ), + ]), + query([ + ...usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_USAGE, 'Usage'), + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_AVAILABLE, + legend: 'Available', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + ]), + query([ + ...utilizationSeries( + INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION, + 'Request util %', + ), + ...utilizationSeries( + INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION, + 'Limit util %', + ), + ]), + query( + [ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_RSS, + legend: 'RSS Memory', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_WORKING_SET, + legend: 'Working Set Memory', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + ], + [{ expression: 'B - A', legend: 'Cache Memory' }], + ), + query([ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_MAJOR_PAGE_FAULTS, + legend: 'Major Page Faults', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + ]), + query([ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_CAPACITY, + legend: 'Capacity', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_AVAILABLE, + legend: 'Available', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_USAGE, + legend: 'Usage', + timeAggregation: 'avg', + spaceAggregation: 'sum', + }, + ]), + query([ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_UPTIME, + legend: 'Uptime', + timeAggregation: 'latest', + spaceAggregation: 'sum', + }, + ]), + query([ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_NODE_UTILIZATION, + legend: 'Node CPU Utilization', + timeAggregation: 'avg', + spaceAggregation: 'avg', + }, + ]), + query([ + { + metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_NODE_UTILIZATION, + legend: 'Node Memory Utilization', + timeAggregation: 'avg', + spaceAggregation: 'avg', + }, + ]), + ]; +}; diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/table.config.tsx b/frontend/src/container/InfraMonitoringK8sV2/Containers/table.config.tsx new file mode 100644 index 00000000000..9b4c1ecc7bb --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/table.config.tsx @@ -0,0 +1,470 @@ +import { Container } from '@signozhq/icons'; +import { Badge } from '@signozhq/ui/badge'; +import { + InframonitoringtypesContainerReadyDTO, + InframonitoringtypesContainerRecordDTO, + InframonitoringtypesContainerStatusDTO, +} from 'api/generated/services/sigNoz.schemas'; +import TanStackTable, { TableColumnDef } from 'components/TanStackTableView'; +import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components'; + +import ColumnHeader from '../Base/ColumnHeader'; +import EntityGroupHeader from '../Base/EntityGroupHeader'; +import K8sGroupCell from '../Base/K8sGroupCell'; +import { formatBytes } from '../commonUtils'; +import { + EntityProgressBar, + EntityProgressThresholds, + GroupedStatusCounts, + TextNoData, + ValidateColumnValueWrapper, +} from '../components'; +import { + INFRA_MONITORING_ATTR_KEYS, + InfraMonitoringEntity, +} from '../constants'; +import { SelectedItemParams } from '../hooks'; +import { + CONTAINER_READY_COLORS, + CONTAINER_READY_LABELS, + CONTAINER_STATUS_COLORS, + CONTAINER_STATUS_LABELS, + CONTAINERS_DOC_PATH, + getContainerImageWithTag, + getContainerReadyItems, + getContainerStatusItems, +} from './utils'; + +export function getK8sContainerRowKey( + container: InframonitoringtypesContainerRecordDTO, +): string { + return ( + [container.podUID, container.containerName].filter(Boolean).join('/') || + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] || + '' + ); +} + +export function getK8sContainerItemKey( + container: InframonitoringtypesContainerRecordDTO, +): SelectedItemParams { + return { + selectedItem: + container.podUID || + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] || + null, + containerName: + container.containerName || + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] || + null, + clusterName: null, + namespaceName: null, + }; +} + +export type ContainerTableColumnConfig = + TableColumnDef; + +/** + * The grouped table and its nested rows are separate tables, so a column and the + * one that replaces it while grouped share a width to keep the two aligned. + */ +const NAME_COLUMN_WIDTH = 220; +const STATUS_COLUMN_WIDTH = 250; + +export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [ + { + id: 'containerGroup', + header: (): React.ReactNode => , + accessorFn: (row): string => row.containerName || '', + width: { min: NAME_COLUMN_WIDTH }, + enableSort: false, + enableRemove: false, + enableMove: false, + pin: 'left', + visibilityBehavior: 'hidden-on-collapse', + cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => ( + + + + ), + }, + { + id: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME, + header: (): React.ReactNode => ( + } + docPath={`${CONTAINERS_DOC_PATH}#container-name`} + /> + ), + accessorFn: (row): string => + row.containerName || + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] || + '', + width: { min: NAME_COLUMN_WIDTH }, + enableSort: true, + enableRemove: false, + enableMove: false, + pin: 'left', + visibilityBehavior: 'hidden-on-expand', + cell: ({ value }): React.ReactNode => ( + {value as string} + ), + }, + { + id: 'podName', + header: (): React.ReactNode => ( + + Pod Name + + ), + accessorFn: (row): string => + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '', + width: { min: 260 }, + enableSort: false, + cell: ({ value }): React.ReactNode => { + const podName = value as string; + if (!podName) { + return ; + } + return {podName}; + }, + }, + { + id: 'namespace', + header: (): React.ReactNode => ( + + Namespace + + ), + accessorFn: (row): string => + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '', + width: { min: 160 }, + enableSort: false, + cell: ({ value }): React.ReactNode => ( + {value as string} + ), + }, + { + id: 'image', + header: (): React.ReactNode => ( + + Image:Tag + + ), + accessorFn: (row): string => getContainerImageWithTag(row), + width: { min: 240 }, + enableSort: false, + cell: ({ value }): React.ReactNode => { + const image = value as string; + if (!image) { + return ; + } + return {image}; + }, + }, + { + id: 'containerStatus', + header: (): React.ReactNode => ( + Status + ), + accessorFn: (row): string => row.status, + width: { min: STATUS_COLUMN_WIDTH }, + enableSort: false, + visibilityBehavior: 'hidden-on-expand', + cell: ({ row }): React.ReactNode => { + if ( + !row.status || + row.status === InframonitoringtypesContainerStatusDTO.no_data + ) { + return ; + } + + return ( + + {CONTAINER_STATUS_LABELS[row.status]} + + ); + }, + }, + { + id: 'containerCountsByStatus', + header: (): React.ReactNode => ( + Status + ), + accessorFn: ( + row, + ): InframonitoringtypesContainerRecordDTO['containerCountsByStatus'] => + row.containerCountsByStatus, + width: { min: STATUS_COLUMN_WIDTH }, + enableSort: false, + visibilityBehavior: 'hidden-on-collapse', + cell: ({ row, rowId }): React.ReactNode => { + if (!row.containerCountsByStatus) { + return ; + } + return ( + + ); + }, + }, + { + id: 'containerReady', + header: (): React.ReactNode => ( + Ready + ), + accessorFn: (row): string => row.ready, + width: { min: 130 }, + enableSort: false, + visibilityBehavior: 'hidden-on-expand', + cell: ({ row }): React.ReactNode => { + if ( + !row.ready || + row.ready === InframonitoringtypesContainerReadyDTO.no_data + ) { + return ; + } + + return ( + + {CONTAINER_READY_LABELS[row.ready]} + + ); + }, + }, + { + id: 'containerCountsByReady', + header: (): React.ReactNode => ( + Ready + ), + accessorFn: ( + row, + ): InframonitoringtypesContainerRecordDTO['containerCountsByReady'] => + row.containerCountsByReady, + width: { min: 130 }, + enableSort: false, + visibilityBehavior: 'hidden-on-collapse', + cell: ({ row, rowId }): React.ReactNode => { + if (!row.containerCountsByReady) { + return ; + } + return ( + + ); + }, + }, + { + id: 'containerRestarts', + header: (): React.ReactNode => ( + + Restarts + + ), + accessorFn: (row): number => row.restarts, + width: { min: 130 }, + enableSort: false, + cell: ({ value, rowId }): React.ReactNode => ( + + {value as number} + + ), + }, + { + id: 'cpu_request', + header: (): React.ReactNode => ( + } + > + CPU Request Usage (%) + + ), + accessorFn: (row): number => row.cpuRequestUtilization, + width: { min: 210 }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + + + ), + }, + { + id: 'cpu_limit', + header: (): React.ReactNode => ( + } + > + CPU Limit Usage (%) + + ), + accessorFn: (row): number => row.cpuLimitUtilization, + width: { min: 220 }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + + + ), + }, + { + id: 'cpu', + header: (): React.ReactNode => ( + + CPU Usage (cores) + + ), + accessorFn: (row): number => row.cpu, + width: { min: 160 }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + {Number(value).toFixed(2)} + + ), + }, + { + id: 'memory_request', + header: (): React.ReactNode => ( + } + > + Memory Request Usage (%) + + ), + accessorFn: (row): number => row.memoryRequestUtilization, + width: { min: 210 }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + + + ), + }, + { + id: 'memory_limit', + header: (): React.ReactNode => ( + } + > + Memory Limit Usage (%) + + ), + accessorFn: (row): number => row.memoryLimitUtilization, + width: { min: 220 }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + + + ), + }, + { + id: 'memory', + header: (): React.ReactNode => ( + + Memory Usage (WSS) + + ), + accessorFn: (row): number => row.memory, + width: { min: 210, default: '100%' }, + enableSort: true, + cell: ({ value, rowId }): React.ReactNode => ( + + {formatBytes(value as number)} + + ), + }, + { + id: 'node', + header: (): React.ReactNode => ( + + Node + + ), + accessorFn: (row): string => + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '', + width: { default: 100 }, + enableSort: false, + defaultVisibility: false, + cell: ({ value }): React.ReactNode => ( + {value as string} + ), + }, + { + id: 'cluster', + header: (): React.ReactNode => ( + + Cluster + + ), + accessorFn: (row): string => + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '', + width: { default: 100 }, + enableSort: false, + defaultVisibility: false, + cell: ({ value }): React.ReactNode => ( + {value as string} + ), + }, + { + id: 'deployment', + header: (): React.ReactNode => ( + + Deployment + + ), + accessorFn: (row): string => + row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '', + width: { default: 100 }, + enableSort: false, + defaultVisibility: false, + cell: ({ value }): React.ReactNode => ( + {value as string} + ), + }, +]; diff --git a/frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts b/frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts new file mode 100644 index 00000000000..ce2dfa0cd6d --- /dev/null +++ b/frontend/src/container/InfraMonitoringK8sV2/Containers/utils.ts @@ -0,0 +1,169 @@ +import { Color } from '@signozhq/design-tokens'; +import { BadgeColor } from '@signozhq/ui/badge'; +import { + InframonitoringtypesContainerCountsByReadyDTO, + InframonitoringtypesContainerCountsByStatusDTO, + InframonitoringtypesContainerReadyDTO, + InframonitoringtypesContainerRecordDTO, + InframonitoringtypesContainerStatusDTO, +} from 'api/generated/services/sigNoz.schemas'; + +import { StatusCountItem } from '../components/GroupedStatusCounts'; +import { INFRA_MONITORING_ATTR_KEYS } from '../constants'; + +export const CONTAINERS_DOC_PATH = + '/infrastructure-monitoring/kubernetes/containers'; + +/** Renders as `name:tag`; the tag is dropped when the image is not pinned. */ +export function getContainerImageWithTag( + container: InframonitoringtypesContainerRecordDTO, +): string { + const name = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME]; + const tag = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_TAG]; + + if (!name) { + return ''; + } + + return tag ? `${name}:${tag}` : name; +} + +export function getContainerName( + container: InframonitoringtypesContainerRecordDTO, +): string { + return ( + container.containerName || + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] || + '' + ); +} + +export function getContainerPodUID( + container: InframonitoringtypesContainerRecordDTO, +): string { + return ( + container.podUID || + container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] || + '' + ); +} + +export const CONTAINER_STATUS_COLORS: Record< + InframonitoringtypesContainerStatusDTO, + BadgeColor +> = { + [InframonitoringtypesContainerStatusDTO.running]: 'forest', + [InframonitoringtypesContainerStatusDTO.completed]: 'robin', + [InframonitoringtypesContainerStatusDTO.waiting]: 'amber', + [InframonitoringtypesContainerStatusDTO.containercreating]: 'amber', + [InframonitoringtypesContainerStatusDTO.terminated]: 'sienna', + [InframonitoringtypesContainerStatusDTO.unknown]: 'vanilla', + [InframonitoringtypesContainerStatusDTO.no_data]: 'vanilla', + [InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'cherry', + [InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'cherry', + [InframonitoringtypesContainerStatusDTO.errimagepull]: 'cherry', + [InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]: 'cherry', + [InframonitoringtypesContainerStatusDTO.oomkilled]: 'cherry', + [InframonitoringtypesContainerStatusDTO.error]: 'cherry', + [InframonitoringtypesContainerStatusDTO.containercannotrun]: 'cherry', +}; + +/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */ +export const CONTAINER_STATUS_LABELS: Record< + InframonitoringtypesContainerStatusDTO, + string +> = { + [InframonitoringtypesContainerStatusDTO.running]: 'Running', + [InframonitoringtypesContainerStatusDTO.completed]: 'Completed', + [InframonitoringtypesContainerStatusDTO.waiting]: 'Waiting', + [InframonitoringtypesContainerStatusDTO.containercreating]: + 'ContainerCreating', + [InframonitoringtypesContainerStatusDTO.terminated]: 'Terminated', + [InframonitoringtypesContainerStatusDTO.unknown]: 'Unknown', + [InframonitoringtypesContainerStatusDTO.no_data]: 'No data', + [InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'CrashLoopBackOff', + [InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'ImagePullBackOff', + [InframonitoringtypesContainerStatusDTO.errimagepull]: 'ErrImagePull', + [InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]: + 'CreateContainerConfigError', + [InframonitoringtypesContainerStatusDTO.oomkilled]: 'OOMKilled', + [InframonitoringtypesContainerStatusDTO.error]: 'Error', + [InframonitoringtypesContainerStatusDTO.containercannotrun]: + 'ContainerCannotRun', +}; + +const CONTAINER_ERROR_STATUS_LABELS: Partial< + Record +> = { + crashLoopBackOff: 'CrashLoopBackOff', + imagePullBackOff: 'ImagePullBackOff', + errImagePull: 'ErrImagePull', + createContainerConfigError: 'CreateContainerConfigError', + oomKilled: 'OOMKilled', + error: 'Error', + containerCannotRun: 'ContainerCannotRun', +}; + +export function getContainerStatusItems( + counts: InframonitoringtypesContainerCountsByStatusDTO, +): StatusCountItem[] { + const errorKeys = Object.keys(CONTAINER_ERROR_STATUS_LABELS) as Array< + keyof typeof CONTAINER_ERROR_STATUS_LABELS + >; + + return [ + { value: counts.running, label: 'Running', color: Color.BG_FOREST_500 }, + { value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 }, + { + value: counts.waiting + counts.containerCreating, + label: 'Waiting', + color: Color.BG_AMBER_500, + breakdown: [ + { label: 'Waiting', value: counts.waiting }, + { label: 'ContainerCreating', value: counts.containerCreating }, + ], + }, + { + value: counts.terminated, + label: 'Terminated', + color: Color.BG_SIENNA_500, + }, + { value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 }, + { + value: errorKeys.reduce((sum, key) => sum + counts[key], 0), + label: 'Error Status', + color: Color.BG_CHERRY_500, + breakdown: errorKeys.map((key) => ({ + label: CONTAINER_ERROR_STATUS_LABELS[key] as string, + value: counts[key], + })), + }, + ]; +} + +export const CONTAINER_READY_COLORS: Record< + InframonitoringtypesContainerReadyDTO, + BadgeColor +> = { + [InframonitoringtypesContainerReadyDTO.ready]: 'forest', + [InframonitoringtypesContainerReadyDTO.not_ready]: 'cherry', + [InframonitoringtypesContainerReadyDTO.no_data]: 'vanilla', +}; + +export const CONTAINER_READY_LABELS: Record< + InframonitoringtypesContainerReadyDTO, + string +> = { + [InframonitoringtypesContainerReadyDTO.ready]: 'Ready', + [InframonitoringtypesContainerReadyDTO.not_ready]: 'Not Ready', + [InframonitoringtypesContainerReadyDTO.no_data]: 'No data', +}; + +export function getContainerReadyItems( + counts: InframonitoringtypesContainerCountsByReadyDTO, +): StatusCountItem[] { + return [ + { value: counts.ready, label: 'Ready', color: Color.BG_FOREST_500 }, + { value: counts.notReady, label: 'Not Ready', color: Color.BG_CHERRY_500 }, + ]; +} diff --git a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx index acb38f19029..4ede4924307 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/EntityMetrics/ChartHeader.tsx @@ -3,8 +3,9 @@ import { Compass, Info } from '@signozhq/icons'; import { TooltipSimple } from '@signozhq/ui/tooltip'; import styles from './ChartHeader.module.scss'; +import { DOCS_BASE_URL } from 'constants/app'; -const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`; +const DOCS_ROOT = `${DOCS_BASE_URL}/docs`; interface ChartHeaderProps { title: string; @@ -33,7 +34,7 @@ function ChartHeader({ <> {tooltipTitle}{' '}
e.stopPropagation()} diff --git a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss index be86b269387..355ccf47a23 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss +++ b/frontend/src/container/InfraMonitoringK8sV2/EntityDetailsUtils/entityDetails.module.scss @@ -37,38 +37,28 @@ .title { font-family: var(--periscope-font-family-mono); - --typography-margin: 0px var(--spacing-4) 0px 0px; + --typography-margin: 0px var(--spacing-1) 0px 0px; } -.entityDetailsEntity { - display: flex; - flex-direction: column; +// Sized to the icon rather than the default tap target, so it sits beside the +// entity name instead of a gap away from it. +.copyIdButton { + --button-padding: 2px; + --button-height: auto; + --button-width: auto; } -.entityDetailsGrid { +.entityDetailsEntity { display: flex; flex-direction: column; } -.labelsRow, -.valuesRow { +// Tracks size to the entity's field count instead of a fixed four, so entities +// with more fields stay on one row rather than spilling a near-empty second one. +.entityDetailsGrid { display: grid; - grid-template-columns: 1.5fr 1.5fr 1.5fr 1.5fr; - gap: 30px; - align-items: center; -} - -.labelsRow { - margin-bottom: var(--spacing-4); -} - -.entityDetailsMetadataLabel { - letter-spacing: 0.44px; - text-transform: uppercase; -} - -.entityDetailsMetadataValue { - font-family: var(--periscope-font-family-mono); + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: var(--spacing-4) var(--spacing-6); } .viewsTabsContainer { diff --git a/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx b/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx index 94e3b9fada1..4d402d53610 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx +++ b/frontend/src/container/InfraMonitoringK8sV2/InfraMonitoringK8s.tsx @@ -16,6 +16,7 @@ import { ArrowUpDown, ArrowUpToLine, Bolt, + Box, Boxes, Computer, Container, @@ -31,6 +32,7 @@ import { DataSource } from 'types/common/queryBuilder'; import { K8sDynamicList } from './Base/K8sDynamicList'; import { GetClustersQuickFiltersConfig, + GetContainersQuickFiltersConfig, GetDaemonsetsQuickFiltersConfig, GetDeploymentsQuickFiltersConfig, GetJobsQuickFiltersConfig, @@ -152,6 +154,12 @@ export default function InfraMonitoringK8s(): JSX.Element { const categories = useMemo( () => [ + { + key: K8sCategories.CONTAINERS, + label: 'Containers', + icon: , + config: GetContainersQuickFiltersConfig(), + }, { key: K8sCategories.PODS, label: 'Pods', diff --git a/frontend/src/container/InfraMonitoringK8sV2/constants.ts b/frontend/src/container/InfraMonitoringK8sV2/constants.ts index f4c4a5236cd..d3aae6489dc 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/constants.ts +++ b/frontend/src/container/InfraMonitoringK8sV2/constants.ts @@ -61,10 +61,26 @@ export const INFRA_MONITORING_ATTR_KEYS = { K8S_CONTAINER_CPU_LIMIT: 'k8s.container.cpu_limit', K8S_CONTAINER_MEMORY_REQUEST: 'k8s.container.memory_request', K8S_CONTAINER_MEMORY_LIMIT: 'k8s.container.memory_limit', + K8S_CONTAINER_CPU_REQUEST_UTILIZATION: 'k8s.container.cpu_request_utilization', + K8S_CONTAINER_CPU_LIMIT_UTILIZATION: 'k8s.container.cpu_limit_utilization', + K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION: + 'k8s.container.memory_request_utilization', + K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION: + 'k8s.container.memory_limit_utilization', + K8S_CONTAINER_CPU_NODE_UTILIZATION: 'k8s.container.cpu.node.utilization', + K8S_CONTAINER_MEMORY_NODE_UTILIZATION: 'k8s.container.memory.node.utilization', CONTAINER_CPU_USAGE: 'container.cpu.usage', CONTAINER_MEMORY_USAGE: 'container.memory.usage', + CONTAINER_MEMORY_AVAILABLE: 'container.memory.available', CONTAINER_MEMORY_WORKING_SET: 'container.memory.working_set', CONTAINER_MEMORY_RSS: 'container.memory.rss', + CONTAINER_MEMORY_MAJOR_PAGE_FAULTS: 'container.memory.major_page_faults', + CONTAINER_FILESYSTEM_AVAILABLE: 'container.filesystem.available', + CONTAINER_FILESYSTEM_CAPACITY: 'container.filesystem.capacity', + CONTAINER_FILESYSTEM_USAGE: 'container.filesystem.usage', + CONTAINER_UPTIME: 'container.uptime', + CONTAINER_IMAGE_NAME: 'container.image.name', + CONTAINER_IMAGE_TAG: 'container.image.tag', // Deployment K8S_DEPLOYMENT_NAME: 'k8s.deployment.name', @@ -165,6 +181,9 @@ export const K8sCategories = { VOLUMES: 'volumes', }; +/** The section the Kubernetes view opens on when a link names none. */ +export const DEFAULT_K8S_CATEGORY = K8sCategories.CONTAINERS; + const dotMap = { [InfraMonitoringEntity.HOSTS]: INFRA_MONITORING_ATTR_KEYS.SYSTEM_CPU_LOAD_AVERAGE_15M, @@ -181,7 +200,7 @@ const dotMap = { [InfraMonitoringEntity.DAEMONSETS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE, [InfraMonitoringEntity.CONTAINERS]: - INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE, + INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, [InfraMonitoringEntity.JOBS]: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_DESIRED_SUCCESSFUL_PODS, [InfraMonitoringEntity.VOLUMES]: @@ -321,6 +340,161 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] { ]; } +export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] { + return [ + { + type: FiltersType.CHECKBOX, + title: 'Container', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'Pod', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: true, + }, + { + type: FiltersType.CHECKBOX, + title: 'Namespace', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Node', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Cluster', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Image', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Deployment', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Statefulset', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'DaemonSet', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Job', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME, + dataType: DataTypes.String, + type: 'resource', + id: `${INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME}--string--resource--false`, + }, + aggregateOperator: 'noop', + aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE, + dataSource: DataSource.METRICS, + defaultOpen: false, + }, + { + type: FiltersType.CHECKBOX, + title: 'Environment', + attributeKey: { + key: INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT, + dataType: DataTypes.String, + type: 'resource', + }, + defaultOpen: true, + }, + ]; +} + export function GetNodesQuickFiltersConfig(): IQuickFiltersConfig[] { return [ { @@ -767,6 +941,7 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = { SELECTED_ITEM: 'selectedItem', SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName', SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName', + SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName', DETAIL_RELATIVE_TIME: 'detailRelativeTime', DETAIL_START_TIME: 'detailStartTime', DETAIL_END_TIME: 'detailEndTime', @@ -783,7 +958,7 @@ export const METRIC_NAMESPACE_BY_ENTITY: Record = [InfraMonitoringEntity.DEPLOYMENTS]: 'k8s.', [InfraMonitoringEntity.STATEFULSETS]: 'k8s.', [InfraMonitoringEntity.DAEMONSETS]: 'k8s.', - [InfraMonitoringEntity.CONTAINERS]: 'k8s.pod.', + [InfraMonitoringEntity.CONTAINERS]: 'k8s.container.', [InfraMonitoringEntity.JOBS]: 'k8s.', [InfraMonitoringEntity.VOLUMES]: 'k8s.volume.', }; diff --git a/frontend/src/container/InfraMonitoringK8sV2/hooks.ts b/frontend/src/container/InfraMonitoringK8sV2/hooks.ts index 33e4bbd0ba1..80ad35c3023 100644 --- a/frontend/src/container/InfraMonitoringK8sV2/hooks.ts +++ b/frontend/src/container/InfraMonitoringK8sV2/hooks.ts @@ -16,8 +16,8 @@ import { import { parseAsJsonNoValidate } from 'utils/nuqsParsers'; import { + DEFAULT_K8S_CATEGORY, INFRA_MONITORING_K8S_PARAMS_KEYS, - K8sCategories, VIEWS, } from './constants'; import { orderBySchema, OrderBySchemaType } from './schemas'; @@ -130,19 +130,23 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn< > => useQueryState( INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, - parseAsString.withDefault(K8sCategories.PODS).withOptions(defaultNuqsOptions), + parseAsString + .withDefault(DEFAULT_K8S_CATEGORY) + .withOptions({ ...defaultNuqsOptions, clearOnDefault: false }), ); export interface SelectedItemParams { selectedItem: string | null; clusterName?: string | null; namespaceName?: string | null; + containerName?: string | null; } const selectedItemParamsParsers = { [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString, [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString, [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString, + [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString, }; export type UseSelectedItemParamsReturn = [ @@ -167,6 +171,9 @@ export const useInfraMonitoringSelectedItemParams = namespaceName: rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME] ?? null, + containerName: + rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME] ?? + null, }), [rawParams], ); @@ -178,6 +185,7 @@ export const useInfraMonitoringSelectedItemParams = [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null, [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null, [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null, + [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null, }); return; } @@ -189,6 +197,8 @@ export const useInfraMonitoringSelectedItemParams = newParams.clusterName ?? null, [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: newParams.namespaceName ?? null, + [INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: + newParams.containerName ?? null, }); }, [setRawParams], diff --git a/frontend/src/pages/InfrastructureMonitoring/constants.tsx b/frontend/src/pages/InfrastructureMonitoring/constants.tsx index 82c098a5d8f..dfb5efafa5c 100644 --- a/frontend/src/pages/InfrastructureMonitoring/constants.tsx +++ b/frontend/src/pages/InfrastructureMonitoring/constants.tsx @@ -2,6 +2,10 @@ import { TabRoutes } from 'components/RouteTab/types'; import ROUTES from 'constants/routes'; import InfraMonitoringHostsV2 from 'container/InfraMonitoringHostsV2'; import InfraMonitoringK8sV2 from 'container/InfraMonitoringK8sV2'; +import { + DEFAULT_K8S_CATEGORY, + INFRA_MONITORING_K8S_PARAMS_KEYS, +} from 'container/InfraMonitoringK8sV2/constants'; import { Inbox } from '@signozhq/icons'; function HostsContainer(): JSX.Element { @@ -30,6 +34,6 @@ export const Kubernetes: TabRoutes = { Kubernetes
), - route: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES, + route: `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=${DEFAULT_K8S_CATEGORY}`, key: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES, }; diff --git a/pkg/alertmanager/alertmanagernotify/jira/jira.go b/pkg/alertmanager/alertmanagernotify/jira/jira.go new file mode 100644 index 00000000000..638dfacb4f7 --- /dev/null +++ b/pkg/alertmanager/alertmanagernotify/jira/jira.go @@ -0,0 +1,557 @@ +// Copyright (c) 2026 SigNoz, Inc. +// Copyright 2023 Prometheus Team +// SPDX-License-Identifier: Apache-2.0 + +package jira + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "sort" + "strings" + "time" + "unicode/utf16" + + "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate" + "github.com/SigNoz/signoz/pkg/errors" + "github.com/SigNoz/signoz/pkg/templating/markdownrenderer/adf" + "github.com/SigNoz/signoz/pkg/types/alertmanagertypes" + "github.com/SigNoz/signoz/pkg/types/ruletypes" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" +) + +const Integration = "jira" + +const ( + maxSummaryLenRunes = 255 + maxDescriptionLenRunes = 32767 +) + +// Notifier implements notify.Notifier for Jira. +type Notifier struct { + conf *alertmanagertypes.JiraReceiverConfig + logger *slog.Logger + client *http.Client + retrier *notify.Retrier + templater alertmanagertypes.Templater +} + +func New(conf *alertmanagertypes.JiraReceiverConfig, _ *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) (*Notifier, error) { + if conf.HTTPConfig == nil { + return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira http_config is nil") + } + client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration) + if err != nil { + return nil, err + } + return &Notifier{ + conf: conf, + logger: l, + client: client, + retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}}, + templater: templater, + }, nil +} + +func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { + key, err := notify.ExtractGroupKey(ctx) + if err != nil { + return false, err + } + groupID := key.Hash() + firing := types.Alerts(as...).HasFiring() + n.logger.DebugContext(ctx, "sending jira notification", slog.String("group_key", key.String()), slog.Bool("firing", firing)) + + customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as) + result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{ + TitleTemplate: customTitle, + BodyTemplate: customBody, + DefaultTitleTemplate: n.conf.Summary, + DefaultBodyTemplate: n.conf.Description, + }, as) + if err != nil { + return false, err + } + summary := truncateRunes(result.Title, maxSummaryLenRunes) + + var parts []string + for _, body := range result.Body { + if body != "" { + parts = append(parts, body) + } + } + // custom body templates render per alert; join them under ADF rule dividers. + // The default body is a single combined part, so the join is a no-op there. + descText := truncateRunes(strings.Join(parts, "\n\n---\n\n"), maxDescriptionLenRunes) + + baseURL, retry, err := n.resolveAPIBaseURL(ctx) + if err != nil { + return retry, err + } + + existing, retry, err := n.searchIssue(ctx, baseURL, groupID, firing) + if err != nil { + return retry, err + } + + fields := n.buildFields(groupID, summary, descText, as, firing) + + // No existing issue: create for firing groups; never create for resolved-only. + if existing == nil { + if !firing { + return false, nil + } + return n.createIssue(ctx, baseURL, fields) + } + + // Existing issue: refresh it, then transition + comment based on the new state. + if retry, err := n.updateIssue(ctx, baseURL, existing, fields); err != nil { + return retry, err + } + + // Each state-change comment carries the same rich snapshot as the description + // (panel + details + deep-links), so the comment timeline mirrors the card + // Google Chat re-posts on every notification. + switch { + case firing && existing.isDone(): // re-fired after resolution → reopen + if retry, err := n.applyTransition(ctx, baseURL, existing.Key, false, n.conf.ReopenTransition); err != nil { + return retry, err + } + case !firing: // resolved (search returns only open issues, so this one is open) + if retry, err := n.applyTransition(ctx, baseURL, existing.Key, true, n.conf.ResolveTransition); err != nil { + return retry, err + } + } + // firing && !isDone (still firing) needs no transition. + return n.addComment(ctx, baseURL, existing.Key, fields.Description) +} + +func (n *Notifier) buildFields(groupID, summary, descText string, alerts []*types.Alert, firing bool) *issueFields { + f := &issueFields{ + Project: &idKey{Key: n.conf.Project}, + Issuetype: &idName{Name: n.conf.IssueType}, + Summary: summary, + Labels: n.labels(groupID), + Description: n.buildBoundedDescription(descText, alerts, firing), + } + if n.conf.Priority != "" { + f.Priority = &idName{Name: n.conf.Priority} + } + return f +} + +// buildBoundedDescription builds the ADF issue body and keeps it within Jira's +// description limit, which counts text characters plus per-node overhead — so a +// text-only markdown cap is not enough. Over-limit bodies are shrunk at the +// markdown level and rebuilt; the panel and deep-links are part of the measured +// document, so the result always fits. +func (n *Notifier) buildBoundedDescription(descText string, alerts []*types.Alert, firing bool) map[string]any { + doc := n.buildDescription(descText, alerts, firing) + for range 4 { + size := adfDocLen(doc) + if size <= maxDescriptionLenRunes { + return doc + } + runes := []rune(descText) + keep := len(runes) * maxDescriptionLenRunes / size * 9 / 10 + if keep >= len(runes) { + keep = len(runes) - 1 + } + if keep <= 0 { + break + } + descText = string(runes[:keep]) + "…" + doc = n.buildDescription(descText, alerts, firing) + } + if adfDocLen(doc) <= maxDescriptionLenRunes { + return doc + } + // still over after shrinking: keep just the panel and deep-links + return n.buildDescription("", alerts, firing) +} + +// adfDocLen approximates how Jira measures an ADF document against the 32767 +// limit: text length in UTF-16 code units, plus per-node overhead (block +// boundaries count like newlines), plus link targets. Deliberately counts on +// the high side so a passing measurement never 400s. +func adfDocLen(node any) int { + m, ok := node.(map[string]any) + if !ok { + return 0 + } + size := 2 + if text, ok := m["text"].(string); ok { + for _, r := range text { + size += utf16.RuneLen(r) + } + } + if marks, ok := m["marks"].([]any); ok { + for _, mark := range marks { + if mm, ok := mark.(map[string]any); ok { + if attrs, ok := mm["attrs"].(map[string]any); ok { + if href, ok := attrs["href"].(string); ok { + size += len(href) + } + } + } + } + } + if content, ok := m["content"].([]any); ok { + for _, child := range content { + size += adfDocLen(child) + } + } + return size +} + +// buildDescription assembles the ADF issue body: a firing/resolved status panel, +// the rendered markdown body, and SigNoz deep-links. +func (n *Notifier) buildDescription(descText string, alerts []*types.Alert, firing bool) map[string]any { + content := []any{statusPanel(firing)} + content = append(content, adf.Render(descText)...) + if links := deepLinks(alerts); links != nil { + content = append(content, links) + } + return map[string]any{"type": "doc", "version": 1, "content": content} +} + +func statusPanel(firing bool) map[string]any { + panelType, label := "success", "🟢 RESOLVED" + if firing { + panelType, label = "error", "🔴 FIRING" + } + return map[string]any{ + "type": "panel", + "attrs": map[string]any{"panelType": panelType}, + "content": []any{map[string]any{ + "type": "paragraph", + "content": []any{map[string]any{"type": "text", "text": label, "marks": []any{map[string]any{"type": "strong"}}}}, + }}, + } +} + +// deepLinks builds a paragraph of SigNoz links from the per-rule ruleSource label +// and the related-logs/traces annotations. Returns nil when none are present. +func deepLinks(alerts []*types.Alert) map[string]any { + if len(alerts) == 0 { + return nil + } + a := alerts[0] + var parts []any + add := func(label, url string) { + if url == "" { + return + } + if len(parts) > 0 { + parts = append(parts, map[string]any{"type": "text", "text": " · "}) + } + parts = append(parts, map[string]any{ + "type": "text", + "text": label, + "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": url}}}, + }) + } + add("Open in SigNoz", string(a.Labels[ruletypes.LabelRuleSource])) + add("View Related Logs", string(a.Annotations[ruletypes.AnnotationRelatedLogs])) + add("View Related Traces", string(a.Annotations[ruletypes.AnnotationRelatedTraces])) + if len(parts) == 0 { + return nil + } + return map[string]any{"type": "paragraph", "content": parts} +} + +func (n *Notifier) labels(groupID string) []string { + out := append([]string{}, n.conf.Labels...) + out = append(out, "signoz-alert", fmt.Sprintf("ALERT{%s}", groupID)) + sort.Strings(out) + return out +} + +func (n *Notifier) searchIssue(ctx context.Context, baseURL, groupID string, firing bool) (*issue, bool, error) { + var jql strings.Builder + if n.conf.WontFixResolution != "" { + // != alone also drops unresolved (EMPTY) issues, so keep those explicitly. + fmt.Fprintf(&jql, `(resolution is EMPTY or resolution != %q) and `, n.conf.WontFixResolution) + } + if reopenMin := int64(time.Duration(n.conf.ReopenDuration).Minutes()); firing && reopenMin > 0 { + fmt.Fprintf(&jql, `(resolutiondate is EMPTY OR resolutiondate >= -%dm) and `, reopenMin) + } else { + jql.WriteString(`statusCategory != Done and `) + } + fmt.Fprintf(&jql, `project=%q and labels=%q order by status ASC, resolutiondate DESC`, n.conf.Project, fmt.Sprintf("ALERT{%s}", groupID)) + + body, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/search/jql", searchRequest{ + JQL: jql.String(), MaxResults: 2, Fields: []string{"status", "labels"}, + }) + if err != nil { + return nil, retry, err + } + var res searchResult + if err := json.Unmarshal(body, &res); err != nil { + return nil, false, err + } + if len(res.Issues) == 0 { + return nil, false, nil + } + // the JQL order is not category-aware, so prefer an open issue over a done + // one; all done falls back to the most recently resolved (resolutiondate DESC) + for i := range res.Issues { + if !res.Issues[i].isDone() { + return &res.Issues[i], false, nil + } + } + return &res.Issues[0], false, nil +} + +func (n *Notifier) createIssue(ctx context.Context, baseURL string, fields *issueFields) (bool, error) { + _, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/issue", issue{Fields: fields}) + return retry, err +} + +func (n *Notifier) updateIssue(ctx context.Context, baseURL string, existing *issue, fields *issueFields) (bool, error) { + // project and issue type are set at creation and cannot be edited. + upd := *fields + upd.Project = nil + upd.Issuetype = nil + // Jira replaces the labels array wholesale, so union in the labels already + // on the issue to keep user-added ones. + if existing.Fields != nil { + upd.Labels = mergeLabels(existing.Fields.Labels, fields.Labels) + } + _, retry, err := n.callAPI(ctx, http.MethodPut, n.issueURL(baseURL, existing.Key, ""), issue{Fields: &upd}) + return retry, err +} + +func mergeLabels(existing, ours []string) []string { + seen := make(map[string]bool, len(existing)+len(ours)) + var merged []string + for _, label := range append(append([]string{}, existing...), ours...) { + if !seen[label] { + seen[label] = true + merged = append(merged, label) + } + } + sort.Strings(merged) + return merged +} + +// applyTransition moves the issue into (toDone) or out of (!toDone) the "done" +// status category, preferring the named override, else the first matching +// transition, else skipping without error when none is available. +func (n *Notifier) applyTransition(ctx context.Context, baseURL, key string, toDone bool, override string) (bool, error) { + transitions, retry, err := n.getTransitions(ctx, baseURL, key) + if err != nil { + return retry, err + } + id := selectTransition(transitions, toDone, override) + if id == "" { + n.logger.WarnContext(ctx, "jira: no matching transition, leaving issue as-is", slog.String("issue", key), slog.Bool("to_done", toDone)) + return false, nil + } + _, retry, err = n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "transitions"), issue{Transition: &idName{ID: id}}) + return retry, err +} + +func (n *Notifier) getTransitions(ctx context.Context, baseURL, key string) ([]jiraTransition, bool, error) { + body, retry, err := n.callAPI(ctx, http.MethodGet, n.issueURL(baseURL, key, "transitions"), nil) + if err != nil { + return nil, retry, err + } + var tr transitionsResponse + if err := json.Unmarshal(body, &tr); err != nil { + return nil, false, err + } + return tr.Transitions, false, nil +} + +func (n *Notifier) addComment(ctx context.Context, baseURL, key string, body any) (bool, error) { + _, retry, err := n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "comment"), comment{Body: body}) + return retry, err +} + +func (n *Notifier) issueURL(baseURL, key, sub string) string { + u := baseURL + "/issue/" + key + if sub != "" { + u += "/" + sub + } + return u +} + +func (n *Notifier) callAPI(ctx context.Context, method, url string, reqBody any) ([]byte, bool, error) { + var body io.Reader + if reqBody != nil { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(reqBody); err != nil { + return nil, false, err + } + body = &buf + } + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, false, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := n.client.Do(req) //nolint:bodyclose // notify.Drain closes the body + if err != nil { + return nil, true, notify.RedactURL(err) + } + defer notify.Drain(resp) + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, false, err + } + shouldRetry, err := n.retrier.Check(resp.StatusCode, bytes.NewReader(respBody)) + if err != nil { + return respBody, shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) + } + return respBody, false, nil +} + +// resolveAPIBaseURL resolves the service-account cloud id per notification (it +// is never persisted); personal API tokens use the site host directly. +func (n *Notifier) resolveAPIBaseURL(ctx context.Context) (string, bool, error) { + if !n.conf.IsServiceAccount() { + return n.conf.APIBaseURL(""), false, nil + } + cloudID, retry, err := n.resolveCloudID(ctx) + if err != nil { + return "", retry, err + } + return n.conf.APIBaseURL(cloudID), false, nil +} + +// resolveCloudID fetches the site's cloud id from its unauthenticated +// tenant_info endpoint; transport failures are retryable, bad responses are not. +func (n *Notifier) resolveCloudID(ctx context.Context) (string, bool, error) { + url := strings.TrimRight(n.conf.Site, "/") + "/_edge/tenant_info" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", false, err + } + req.Header.Set("Accept", "application/json") + + resp, err := n.client.Do(req) + if err != nil { + return "", true, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch jira cloud id") + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", true, err + } + if resp.StatusCode != http.StatusOK { + return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to resolve jira cloud id from %s: status %d", url, resp.StatusCode) + } + + var out struct { + CloudID string `json:"cloudId"` + } + if err := json.Unmarshal(body, &out); err != nil { + return "", false, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse jira tenant_info response") + } + if out.CloudID == "" { + return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira tenant_info returned an empty cloud id for %s", n.conf.Site) + } + return out.CloudID, false, nil +} + +// selectTransition returns the id of the transition whose target status category +// matches toDone, preferring one named override when present. +func selectTransition(transitions []jiraTransition, toDone bool, override string) string { + if override != "" { + for _, t := range transitions { + if t.Name == override { + return t.ID + } + } + } + for _, t := range transitions { + if (t.To.StatusCategory.Key == "done") == toDone { + return t.ID + } + } + return "" +} + +// Jira API types. +type issue struct { + Key string `json:"key,omitempty"` + Fields *issueFields `json:"fields,omitempty"` + Transition *idName `json:"transition,omitempty"` +} + +type issueFields struct { + Project *idKey `json:"project,omitempty"` + Issuetype *idName `json:"issuetype,omitempty"` + Summary string `json:"summary,omitempty"` + Labels []string `json:"labels,omitempty"` + Priority *idName `json:"priority,omitempty"` + Description any `json:"description,omitempty"` + Status *issueStatus `json:"status,omitempty"` +} + +type idKey struct { + Key string `json:"key"` +} + +type idName struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + +type issueStatus struct { + StatusCategory struct { + Key string `json:"key"` + } `json:"statusCategory"` +} + +func (i *issue) isDone() bool { + return i.Fields != nil && i.Fields.Status != nil && i.Fields.Status.StatusCategory.Key == "done" +} + +type searchRequest struct { + JQL string `json:"jql"` + MaxResults int `json:"maxResults"` + Fields []string `json:"fields"` +} + +type searchResult struct { + Issues []issue `json:"issues"` +} + +type transitionsResponse struct { + Transitions []jiraTransition `json:"transitions"` +} + +type jiraTransition struct { + ID string `json:"id"` + Name string `json:"name"` + To struct { + StatusCategory struct { + Key string `json:"key"` + } `json:"statusCategory"` + } `json:"to"` +} + +type comment struct { + Body any `json:"body"` +} + +func truncateRunes(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) +} diff --git a/pkg/alertmanager/alertmanagernotify/jira/jira_test.go b/pkg/alertmanager/alertmanagernotify/jira/jira_test.go new file mode 100644 index 00000000000..042efbbdd34 --- /dev/null +++ b/pkg/alertmanager/alertmanagernotify/jira/jira_test.go @@ -0,0 +1,489 @@ +package jira + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate" + "github.com/SigNoz/signoz/pkg/types/alertmanagertypes" + "github.com/SigNoz/signoz/pkg/types/ruletypes" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/notify/test" + "github.com/prometheus/alertmanager/types" + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockReq struct { + method string + path string + body map[string]any +} + +type mockJira struct { + srv *httptest.Server + mu sync.Mutex + reqs []mockReq + searchIssues []issue + transitions []jiraTransition + createStatus int +} + +func newMockJira(t *testing.T) *mockJira { + t.Helper() + m := &mockJira{} + m.srv = httptest.NewServer(http.HandlerFunc(m.handle)) + t.Cleanup(m.srv.Close) + return m +} + +func (m *mockJira) handle(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + m.mu.Lock() + m.reqs = append(m.reqs, mockReq{r.Method, r.URL.Path, body}) + m.mu.Unlock() + + p := r.URL.Path + switch { + case strings.HasSuffix(p, "/search/jql"): + _ = json.NewEncoder(w).Encode(searchResult{Issues: m.searchIssues}) + case strings.HasSuffix(p, "/transitions") && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(transitionsResponse{Transitions: m.transitions}) + case strings.HasSuffix(p, "/transitions"): + w.WriteHeader(http.StatusNoContent) + case strings.HasSuffix(p, "/comment"): + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"1"}`)) + case strings.HasSuffix(p, "/issue") && r.Method == http.MethodPost: + st := m.createStatus + if st == 0 { + st = http.StatusCreated + } + w.WriteHeader(st) + _, _ = w.Write([]byte(`{"key":"KAN-1"}`)) + case r.Method == http.MethodPut: + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func (m *mockJira) countPost(suffix string) int { + m.mu.Lock() + defer m.mu.Unlock() + c := 0 + for _, r := range m.reqs { + if r.method == http.MethodPost && strings.HasSuffix(r.path, suffix) { + c++ + } + } + return c +} + +func (m *mockJira) countPuts() int { + m.mu.Lock() + defer m.mu.Unlock() + c := 0 + for _, r := range m.reqs { + if r.method == http.MethodPut { + c++ + } + } + return c +} + +func newNotifier(t *testing.T, m *mockJira) *Notifier { + t.Helper() + tmpl := test.CreateTmpl(t) + n, err := New(&alertmanagertypes.JiraReceiverConfig{ + Site: m.srv.URL, + Project: "KAN", + IssueType: "Task", + Summary: alertmanagertypes.DefaultJiraSummaryTemplate, + Description: alertmanagertypes.DefaultJiraDescriptionTemplate, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + ReopenDuration: model.Duration(3 * 24 * time.Hour), + }, tmpl, slog.New(slog.DiscardHandler), alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler))) + require.NoError(t, err) + return n +} + +func alert(firing bool) *types.Alert { + a := &types.Alert{Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "HighCPU", "severity": "critical"}, + Annotations: model.LabelSet{"summary": "cpu high"}, + StartsAt: time.Now().Add(-time.Minute), + }} + if firing { + a.EndsAt = time.Now().Add(time.Hour) + } else { + a.EndsAt = time.Now().Add(-time.Minute) + } + return a +} + +func ctx() context.Context { + return notify.WithGroupKey(context.Background(), "test-jira") +} + +func doneIssue() issue { + i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}} + i.Fields.Status.StatusCategory.Key = "done" + return i +} + +func openIssue() issue { + i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}} + i.Fields.Status.StatusCategory.Key = "new" + return i +} + +func transition(id, name, category string) jiraTransition { + tr := jiraTransition{ID: id, Name: name} + tr.To.StatusCategory.Key = category + return tr +} + +func TestNotifyCreatesWhenNoExistingIssue(t *testing.T) { + m := newMockJira(t) + retry, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 1, m.countPost("/issue")) + assert.Equal(t, 0, m.countPost("/comment")) // no comment on create + assert.Equal(t, 0, m.countPuts()) // no update +} + +func TestNotifyResolvedOnlyWithNoIssueIsNoop(t *testing.T) { + m := newMockJira(t) + retry, err := newNotifier(t, m).Notify(ctx(), alert(false)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 1, m.countPost("/search/jql")) + assert.Equal(t, 0, m.countPost("/issue")) +} + +func TestNotifyStillFiringUpdatesAndComments(t *testing.T) { + m := newMockJira(t) + m.searchIssues = []issue{openIssue()} + retry, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 0, m.countPost("/issue")) // no create + assert.Equal(t, 1, m.countPuts()) // update + assert.Equal(t, 1, m.countPost("/comment")) + assert.Equal(t, 0, m.countPost("/transitions")) // still open, no transition + + // comment carries the full rich snapshot (panel + labeled body), not a one-liner. + cjs, err := json.Marshal(m.lastBody(t, "/comment")) + require.NoError(t, err) + assert.Contains(t, string(cjs), `"panel"`) + assert.Contains(t, string(cjs), "Summary:") +} + +func TestNotifyResolveTransitionsToDoneAndComments(t *testing.T) { + m := newMockJira(t) + m.searchIssues = []issue{openIssue()} + m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")} + retry, err := newNotifier(t, m).Notify(ctx(), alert(false)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 1, m.countPuts()) // update + assert.Equal(t, 1, m.countPost("/transitions")) // resolve transition + assert.Equal(t, 1, m.countPost("/comment")) +} + +func TestNotifyReopensDoneIssue(t *testing.T) { + m := newMockJira(t) + m.searchIssues = []issue{doneIssue()} + m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")} + retry, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 1, m.countPost("/transitions")) // reopen transition + assert.Equal(t, 1, m.countPost("/comment")) +} + +func TestNotifySafeSkipsWhenNoMatchingTransition(t *testing.T) { + m := newMockJira(t) + m.searchIssues = []issue{openIssue()} + m.transitions = []jiraTransition{transition("11", "To Do", "new")} // no done-category transition + retry, err := newNotifier(t, m).Notify(ctx(), alert(false)) + require.NoError(t, err) // must not error + assert.False(t, retry) + assert.Equal(t, 0, m.countPost("/transitions")) // skipped + assert.Equal(t, 1, m.countPost("/comment")) // comment still posted +} + +func TestNotifyPrefersOpenIssueOverRecentlyDone(t *testing.T) { + m := newMockJira(t) + open := openIssue() + open.Key = "KAN-2" + // the JQL order can put a recently-done issue first; the open one must win + m.searchIssues = []issue{doneIssue(), open} + + retry, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + assert.False(t, retry) + assert.Equal(t, 0, m.countPost("/issue")) // no duplicate create + assert.Equal(t, 0, m.countPost("/transitions")) // open issue → no reopen + assert.Equal(t, 1, m.countPuts()) + assert.Equal(t, 1, m.countPost("/comment")) + + m.mu.Lock() + defer m.mu.Unlock() + for _, r := range m.reqs { + if r.method == http.MethodPut || strings.HasSuffix(r.path, "/comment") { + assert.Contains(t, r.path, "KAN-2") + } + } +} + +func TestNotifyRetriesOn429(t *testing.T) { + m := newMockJira(t) + m.createStatus = http.StatusTooManyRequests + retry, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.Error(t, err) + assert.True(t, retry) +} + +func (m *mockJira) lastBody(t *testing.T, suffix string) map[string]any { + t.Helper() + m.mu.Lock() + defer m.mu.Unlock() + for i := len(m.reqs) - 1; i >= 0; i-- { + if m.reqs[i].method == http.MethodPost && strings.HasSuffix(m.reqs[i].path, suffix) { + return m.reqs[i].body + } + } + t.Fatalf("no POST request to %s", suffix) + return nil +} + +func TestNotifyRichDescriptionPanelAndLinks(t *testing.T) { + m := newMockJira(t) + a := alert(true) + a.Labels[ruletypes.LabelRuleSource] = model.LabelValue("https://app.signoz.io/alerts?ruleId=1") + a.Annotations[ruletypes.AnnotationRelatedLogs] = model.LabelValue("https://app.signoz.io/logs") + + _, err := newNotifier(t, m).Notify(ctx(), a) + require.NoError(t, err) + + body := m.lastBody(t, "/issue") + js, err := json.Marshal(body) + require.NoError(t, err) + s := string(js) + assert.Contains(t, s, `"panel"`) // status panel present + assert.Contains(t, s, `"error"`) // firing → error panel + assert.Contains(t, s, "Open in SigNoz") // rule deep-link + assert.Contains(t, s, "https://app.signoz.io/alerts?ruleId=1") // rule url + assert.Contains(t, s, "View Related Logs") // related-logs deep-link + assert.Contains(t, s, "Summary:") // labeled body section + assert.Contains(t, s, "cpu high") // rendered annotation +} + +func TestNotifyCustomTemplateAnnotationsOverrideDefaults(t *testing.T) { + m := newMockJira(t) + a1 := alert(true) + a1.Labels["service"] = "payment" + a1.Labels["namespace"] = "ns-one" + a1.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service" + a1.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace" + a2 := alert(true) + a2.Labels["service"] = "payment" + a2.Labels["namespace"] = "ns-two" + a2.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service" + a2.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace" + + _, err := newNotifier(t, m).Notify(ctx(), a1, a2) + require.NoError(t, err) + + body := m.lastBody(t, "/issue") + fields, ok := body["fields"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "High throughput for payment", fields["summary"]) + + js, err := json.Marshal(fields["description"]) + require.NoError(t, err) + s := string(js) + assert.Contains(t, s, "Firing in NS: ns-one") + assert.Contains(t, s, "Firing in NS: ns-two") + // per-alert custom bodies are separated by an ADF rule divider + assert.Contains(t, s, `"rule"`) + assert.NotContains(t, s, "Summary:") // default body template not used +} + +// Jira replaces labels wholesale on PUT, so the update must union in the +// labels already on the issue or user-added ones get wiped. +func TestNotifyUpdatePreservesUserAddedLabels(t *testing.T) { + m := newMockJira(t) + existing := openIssue() + existing.Fields.Labels = []string{"user-added-label", "signoz-alert"} + m.searchIssues = []issue{existing} + + _, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + + search := m.lastBody(t, "/search/jql") + assert.Contains(t, search["fields"], "labels") + + m.mu.Lock() + var putLabels []any + for _, r := range m.reqs { + if r.method == http.MethodPut { + putLabels, _ = r.body["fields"].(map[string]any)["labels"].([]any) + } + } + m.mu.Unlock() + assert.Contains(t, putLabels, "user-added-label") + assert.Contains(t, putLabels, "signoz-alert") + assert.Equal(t, 1, strings.Count(fmt.Sprint(putLabels), "signoz-alert")) // no duplicates + // the dedup label is re-asserted + found := false + for _, l := range putLabels { + if s, ok := l.(string); ok && strings.HasPrefix(s, "ALERT{") { + found = true + } + } + assert.True(t, found) +} + +func TestADFDocLen(t *testing.T) { + text := func(s string) map[string]any { return map[string]any{"type": "text", "text": s} } + para := func(children ...any) map[string]any { + return map[string]any{"type": "paragraph", "content": children} + } + cases := []struct { + name string + node any + want int + }{ + {"text node", text("hello"), 7}, // 5 utf16 + 2 overhead + {"emoji counts utf16", text("🔴"), 4}, // 2 utf16 units + 2 overhead + {"paragraph wraps text", para(text("hi")), 6}, // 2 + (2+2) + {"link href counted", map[string]any{"type": "text", "text": "a", "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": "https://x"}}}}, 12}, // 1 + 9 href + 2 + {"non-map is zero", "junk", 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, adfDocLen(c.node)) + }) + } +} + +// 30 fat custom bodies overflow Jira's description accounting (text + per-node +// overhead); the built doc must be shrunk under the limit, never rejected. +func TestNotifyDescriptionShrunkUnderJiraLimit(t *testing.T) { + m := newMockJira(t) + filler := strings.Repeat("This is a long runbook detail line used to inflate the alert body. ", 25) + alerts := make([]*types.Alert, 0, 30) + for i := range 30 { + a := alert(true) + a.Labels["service"] = model.LabelValue(strings.Repeat("s", 3) + string(rune('a'+i%26))) + a.Annotations[ruletypes.AnnotationTitleTemplate] = "overflow probe" + a.Annotations[ruletypes.AnnotationBodyTemplate] = model.LabelValue("**Alert in service** $labels.service\n\n" + filler) + alerts = append(alerts, a) + } + + _, err := newNotifier(t, m).Notify(ctx(), alerts...) + require.NoError(t, err) + + body := m.lastBody(t, "/issue") + fields, ok := body["fields"].(map[string]any) + require.True(t, ok) + desc := fields["description"] + assert.LessOrEqual(t, adfDocLen(desc), maxDescriptionLenRunes) + + js, err := json.Marshal(desc) + require.NoError(t, err) + assert.Contains(t, string(js), "FIRING") // status panel survives the shrink + assert.Contains(t, string(js), "…") // body ends with the shrink marker +} + +func TestFiringSearchJQLHasReopenWindow(t *testing.T) { + m := newMockJira(t) + _, err := newNotifier(t, m).Notify(ctx(), alert(true)) + require.NoError(t, err) + + body := m.lastBody(t, "/search/jql") + jql, ok := body["jql"].(string) + require.True(t, ok) + // newNotifier uses a 3d window → 4320 minutes. + assert.Contains(t, jql, "resolutiondate >= -4320m") +} + +func TestSelectTransition(t *testing.T) { + ts := []jiraTransition{ + transition("41", "Done", "done"), + transition("51", "Won't Do", "done"), + transition("11", "To Do", "new"), + } + assert.Equal(t, "41", selectTransition(ts, true, "")) // first done-category + assert.Equal(t, "51", selectTransition(ts, true, "Won't Do")) // named override + assert.Equal(t, "11", selectTransition(ts, false, "")) // first non-done + assert.Equal(t, "41", selectTransition(ts, true, "Nonexistent")) // bad override → fallback + assert.Equal(t, "", selectTransition([]jiraTransition{transition("11", "To Do", "new")}, true, "")) // none → skip +} + +func TestResolveCloudID(t *testing.T) { + cases := []struct { + name string + handler http.HandlerFunc + want string + wantErr bool + wantRetry bool + }{ + { + name: "success", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/_edge/tenant_info", r.URL.Path) + _, _ = w.Write([]byte(`{"cloudId":"abc-123"}`)) + }, + want: "abc-123", + }, + { + name: "non-200 is not retryable", + handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) }, + wantErr: true, + }, + { + name: "empty cloud id", + handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"cloudId":""}`)) }, + wantErr: true, + }, + { + name: "bad json", + handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`not json`)) }, + wantErr: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + srv := httptest.NewServer(c.handler) + defer srv.Close() + + n, err := New(&alertmanagertypes.JiraReceiverConfig{Site: srv.URL, HTTPConfig: &commoncfg.HTTPClientConfig{}}, nil, slog.New(slog.DiscardHandler), nil) + require.NoError(t, err) + + got, retry, err := n.resolveCloudID(context.Background()) + if c.wantErr { + assert.Error(t, err) + assert.Equal(t, c.wantRetry, retry) + return + } + require.NoError(t, err) + assert.Equal(t, c.want, got) + }) + } +} diff --git a/pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go new file mode 100644 index 00000000000..2b39fa8edf4 --- /dev/null +++ b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 SigNoz, Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package jsmops delivers Jira Service Management Ops alerts by reusing the +// Opsgenie notifier: JSM Ops is the ex-Opsgenie alert API, so we map the JSM +// config onto config.OpsGenieConfig with APIURL pinned to the JSM native +// integration-events gateway. +package jsmops + +import ( + "log/slog" + "net/url" + + "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie" + "github.com/SigNoz/signoz/pkg/types/alertmanagertypes" + "github.com/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/template" + commoncfg "github.com/prometheus/common/config" +) + +const ( + Integration = "jsmops" + source = "SigNoz" +) + +// New builds an Opsgenie notifier pointed at the JSM native endpoint. +// advancedFeatures enables the rich treatment: HTML body and a note timeline +// (per fire and on resolve). +func New(c *alertmanagertypes.JSMOpsReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool) (*opsgenie.Notifier, error) { + conf, err := toOpsGenieConfig(c) + if err != nil { + return nil, err + } + return opsgenie.New(conf, t, l, templater, advancedFeatures) +} + +// toOpsGenieConfig maps the JSM config onto config.OpsGenieConfig with APIURL +// pinned to the JSM native gateway. +func toOpsGenieConfig(c *alertmanagertypes.JSMOpsReceiverConfig) (*config.OpsGenieConfig, error) { + apiURL, err := url.Parse(alertmanagertypes.JSMOpsAPIBaseURL) + if err != nil { + return nil, err + } + + httpConfig := c.HTTPConfig + if httpConfig == nil { + httpConfig = &commoncfg.HTTPClientConfig{} + } + + return &config.OpsGenieConfig{ + NotifierConfig: c.NotifierConfig, + HTTPConfig: httpConfig, + APIKey: c.APIKey, + APIURL: &config.URL{URL: apiURL}, + Message: c.Message, + Description: c.Description, + Priority: c.Priority, + Tags: c.Tags, + Source: source, + }, nil +} diff --git a/pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go new file mode 100644 index 00000000000..757240ff193 --- /dev/null +++ b/pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go @@ -0,0 +1,41 @@ +package jsmops + +import ( + "testing" + + "github.com/SigNoz/signoz/pkg/types/alertmanagertypes" + commoncfg "github.com/prometheus/common/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToOpsGenieConfig(t *testing.T) { + c := &alertmanagertypes.JSMOpsReceiverConfig{ + APIKey: "key-123", + Message: "msg", + Description: "desc", + Priority: "P1", + Tags: "signoz", + HTTPConfig: &commoncfg.HTTPClientConfig{}, + } + + og, err := toOpsGenieConfig(c) + require.NoError(t, err) + + // Trailing slash is required: the Opsgenie notifier appends "v2/alerts..." + // with no separator, yielding /jsm/ops/integration/v2/alerts. + assert.Equal(t, "https://api.atlassian.com/jsm/ops/integration/", og.APIURL.String()) + assert.Equal(t, "key-123", string(og.APIKey)) + assert.Equal(t, "msg", og.Message) + assert.Equal(t, "desc", og.Description) + assert.Equal(t, "P1", og.Priority) + assert.Equal(t, "signoz", og.Tags) + assert.Equal(t, source, og.Source) + assert.Same(t, c.HTTPConfig, og.HTTPConfig) +} + +func TestToOpsGenieConfigNilHTTPConfig(t *testing.T) { + og, err := toOpsGenieConfig(&alertmanagertypes.JSMOpsReceiverConfig{APIKey: "k"}) + require.NoError(t, err) + assert.NotNil(t, og.HTTPConfig) +} diff --git a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go index 96bfc34675f..7a2703896a5 100644 --- a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go +++ b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie.go @@ -14,6 +14,7 @@ import ( "net/http" "os" "strings" + "unicode/utf8" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate" "github.com/SigNoz/signoz/pkg/errors" @@ -32,8 +33,13 @@ const ( Integration = "opsgenie" ) -// https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes. -const maxMessageLenRunes = 130 +// https://support.atlassian.com/opsgenie/docs/alert-fields/ - message 130, +// description 15000, note 25000 runes. +const ( + maxMessageLenRunes = 130 + maxDescriptionLenRunes = 15000 + maxNoteLenRunes = 25000 +) // Notifier implements a Notifier for OpsGenie notifications. type Notifier struct { @@ -43,21 +49,29 @@ type Notifier struct { client *http.Client retrier *notify.Retrier templater alertmanagertypes.Templater + // advancedFeatures bundles the JSM Ops enrichments: render the default body as + // HTML (markdown -> HTML), and post a note per fire and on resolve to build an + // immutable timeline. Off for plain OpsGenie. The alert-refresh-on-refire part + // rides on the upstream UpdateAlerts config flag, set alongside this. + advancedFeatures bool } -// New returns a new OpsGenie notifier. -func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { +// New returns a new OpsGenie notifier. advancedFeatures enables the JSM Ops +// enrichments (HTML default body + a note timeline per fire and on resolve); +// pass false for plain OpsGenie. +func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...) if err != nil { return nil, err } return &Notifier{ - conf: c, - tmpl: t, - logger: l, - client: client, - retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}}, - templater: templater, + conf: c, + tmpl: t, + logger: l, + client: client, + retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}}, + templater: templater, + advancedFeatures: advancedFeatures, }, nil } @@ -94,6 +108,30 @@ type opsGenieUpdateDescriptionMessage struct { Description string `json:"description,omitempty"` } +type opsGenieAddNoteMessage struct { + Note string `json:"note"` + Source string `json:"source"` +} + +// noteRequest builds a POST to the alert's notes endpoint (append-only timeline). +func (n *Notifier) noteRequest(ctx context.Context, alias, note, source string) (*http.Request, error) { + noteEndpointURL := n.conf.APIURL.Copy() + noteEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/notes", alias) + q := noteEndpointURL.Query() + q.Set("identifierType", "alias") + noteEndpointURL.RawQuery = q.Encode() + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(&opsGenieAddNoteMessage{Note: note, Source: source}); err != nil { + return nil, err + } + req, err := http.NewRequest("POST", noteEndpointURL.String(), &buf) + if err != nil { + return nil, err + } + return req.WithContext(ctx), nil +} + // Notify implements the Notifier interface. func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { requests, retry, err := n.createRequests(ctx, as...) @@ -110,12 +148,24 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) notify.Drain(resp) if err != nil { + // notes are enrichment; a permanently-failed note (e.g. the first-fire + // note racing JSM's async alert create) must not fail the notification + if !shouldRetry && isNoteRequest(req) { + n.logger.WarnContext(ctx, "dropping failed note", slog.Int("status_code", resp.StatusCode), errors.Attr(err)) + continue + } return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) } } return true, nil } +// isNoteRequest reports whether req targets the notes endpoint, the only one +// built by noteRequest. +func isNoteRequest(req *http.Request) bool { + return strings.HasSuffix(req.URL.Path, "/notes") +} + // Like Split but filter out empty strings. func safeSplit(s, sep string) []string { a := strings.Split(strings.TrimSpace(s), sep) @@ -145,28 +195,13 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s } var description string - if result.IsDefaultBody { + if result.IsDefaultBody && !n.advancedFeatures { description = strings.Join(result.Body, "\n") } else { - var b strings.Builder - first := true - for _, part := range result.Body { - if part == "" { - continue - } - rendered, renderErr := markdownrenderer.RenderHTML(part) - if renderErr != nil { - return "", "", renderErr - } - if !first { - b.WriteString("
") - } - b.WriteString("
") - b.WriteString(rendered) - b.WriteString("
") - first = false + description, err = buildHTMLDescription(result.Body, maxDescriptionLenRunes) + if err != nil { + return "", "", err } - description = b.String() } title, truncated := notify.TruncateInRunes(result.Title, maxMessageLenRunes) @@ -174,9 +209,141 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes)) } + // The API silently truncates over-limit descriptions, which would drop the + // trailing SigNoz link; cap here with an ellipsis instead. The HTML path is + // pre-fitted above, so this only ever cuts the plain-text default body. + description, descTruncated := notify.TruncateInRunes(description, maxDescriptionLenRunes) + if descTruncated { + n.logger.WarnContext(ctx, "Truncated description", slog.Int("max_runes", maxDescriptionLenRunes)) + } + return title, description, nil } +const ( + // room reserved for the "+N more" trailer appended when parts are dropped. + descriptionTrailerReserveRunes = 80 + // below this rendering budget a shrunk part carries no signal; drop it instead. + minShrinkBudgetRunes = 64 +) + +// buildHTMLDescription renders each markdown part to HTML (
-wrapped, +//
-joined) while keeping the total within budget runes. An over-budget part +// is shrunk at the markdown level and re-rendered so the HTML stays well-formed; +// fully dropped parts are summarized by a "+N more" trailer. +func buildHTMLDescription(parts []string, budget int) (string, error) { + rendering := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + rendering = append(rendering, part) + } + } + + budget -= descriptionTrailerReserveRunes + var b strings.Builder + used, included := 0, 0 + for _, part := range rendering { + rendered, err := markdownrenderer.RenderHTML(part) + if err != nil { + return "", err + } + overhead := len("
") + if included > 0 { + overhead += len("
") + } + if used+overhead+utf8.RuneCountInString(rendered) > budget { + rendered, err = shrinkMarkdownToFit(part, budget-used-overhead) + if err != nil { + return "", err + } + if rendered == "" { + break + } + } + if included > 0 { + b.WriteString("
") + } + b.WriteString("
") + b.WriteString(rendered) + b.WriteString("
") + used += overhead + utf8.RuneCountInString(rendered) + included++ + } + if dropped := len(rendering) - included; dropped > 0 { + fmt.Fprintf(&b, "
…and %d more alerts. Open in SigNoz for the full list.
", dropped) + } + return b.String(), nil +} + +// shrinkMarkdownToFit cuts markdown until its rendered HTML fits within budget +// runes, returning "" when the budget is too small to carry anything useful. +// Only the markdown is ever cut, never the rendered HTML, so goldmark always +// emits balanced markup. +func shrinkMarkdownToFit(md string, budget int) (string, error) { + if budget < minShrinkBudgetRunes { + return "", nil + } + for range 4 { + rendered, err := markdownrenderer.RenderHTML(md) + if err != nil { + return "", err + } + renderedLen := utf8.RuneCountInString(rendered) + if renderedLen <= budget { + return rendered, nil + } + runes := []rune(md) + keep := len(runes) * budget / renderedLen * 9 / 10 + if keep >= len(runes) { + keep = len(runes) - 1 + } + if keep < minShrinkBudgetRunes { + return "", nil + } + md = string(runes[:keep]) + "…" + } + return "", nil +} + +// prepareNote renders the same body template as plain text for a timeline note. +// JSM Ops notes render neither HTML nor markdown, so links flatten to +// "text (url)" and all markers are stripped. +func (n *Notifier) prepareNote(ctx context.Context, alerts []*types.Alert) (string, error) { + customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts) + result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{ + TitleTemplate: customTitle, + BodyTemplate: customBody, + DefaultTitleTemplate: n.conf.Message, + DefaultBodyTemplate: n.conf.Description, + }, alerts) + if err != nil { + return "", err + } + + var b strings.Builder + first := true + for _, part := range result.Body { + text, renderErr := markdownrenderer.RenderPlainText(part) + if renderErr != nil { + return "", renderErr + } + if text = strings.TrimSpace(text); text == "" { + continue + } + if !first { + b.WriteString("\n\n") + } + b.WriteString(text) + first = false + } + + note, truncated := notify.TruncateInRunes(b.String(), maxNoteLenRunes) + if truncated { + n.logger.WarnContext(ctx, "Truncated note", slog.Int("max_runes", maxNoteLenRunes)) + } + return note, nil +} + // Create requests for a list of alerts. func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) { key, err := notify.ExtractGroupKey(ctx) @@ -206,6 +373,21 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h ) switch alerts.Status() { case model.AlertResolved: + // Post the resolved snapshot to the timeline before closing (closed alerts + // reject notes), so the note lands first. + if n.advancedFeatures { + note, err := n.prepareNote(ctx, as) + if err != nil { + n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err)) + return nil, false, err + } + noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source)) + if err != nil { + return nil, true, err + } + requests = append(requests, noteReq) + } + resolvedEndpointURL := n.conf.APIURL.Copy() resolvedEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/close", alias) q := resolvedEndpointURL.Query() @@ -322,6 +504,21 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h } requests = append(requests, req.WithContext(ctx)) } + + // Append this fire's snapshot to the timeline (every fire, including the + // first, so no datapoint is lost when the description is overwritten). + // Notes are plain text, so this uses the plain-text render, not the HTML body. + if n.advancedFeatures { + note, err := n.prepareNote(ctx, as) + if err != nil { + return nil, false, err + } + noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source)) + if err != nil { + return nil, true, err + } + requests = append(requests, noteReq) + } } var apiKey string diff --git a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go index 6b935fc4455..87c6ed9013d 100644 --- a/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go +++ b/pkg/alertmanager/alertmanagernotify/opsgenie/opsgenie_test.go @@ -6,14 +6,18 @@ package opsgenie import ( "context" + "encoding/json" "fmt" "io" "log/slog" "net/http" + "net/http/httptest" "net/url" "os" + "strings" "testing" "time" + "unicode/utf8" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate" "github.com/SigNoz/signoz/pkg/types/alertmanagertypes" @@ -44,6 +48,7 @@ func TestOpsGenieRetry(t *testing.T) { tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), + false, ) require.NoError(t, err) @@ -69,6 +74,7 @@ func TestOpsGenieRedactedURL(t *testing.T) { tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), + false, ) require.NoError(t, err) @@ -96,6 +102,7 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) { tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), + false, ) require.NoError(t, err) @@ -216,7 +223,7 @@ func TestOpsGenie(t *testing.T) { }, } { t.Run(tc.title, func(t *testing.T) { - notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl)) + notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl), false) require.NoError(t, err) ctx := context.Background() @@ -292,7 +299,7 @@ func TestOpsGenieWithUpdate(t *testing.T) { APIURL: &config.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, } - notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl)) + notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false) alert := &types.Alert{ Alert: model.Alert{ StartsAt: time.Now(), @@ -324,6 +331,111 @@ func TestOpsGenieWithUpdate(t *testing.T) { assert.JSONEq(t, `{"description":"new description"}`, body2) } +func TestOpsGenieAdvancedFeatures(t *testing.T) { + u, err := url.Parse("https://test-opsgenie-url") + require.NoError(t, err) + tmpl := test.CreateTmpl(t) + ctx := notify.WithGroupKey(context.Background(), "1") + key, _ := notify.ExtractGroupKey(ctx) + alias := key.Hash() + + cfg := &config.OpsGenieConfig{ + NotifierConfig: config.NotifierConfig{VSendResolved: true}, + Message: `{{ .CommonLabels.Message }}`, + Description: `{{ .CommonLabels.Description }}`, + UpdateAlerts: true, + APIKey: "k", + APIURL: &config.URL{URL: u}, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + } + notifier, err := New(cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true) + require.NoError(t, err) + + firing := &types.Alert{Alert: model.Alert{ + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + Labels: model.LabelSet{"Message": "m", "Description": "**Alert:** d [View](https://s.io/a)"}, + }} + + // Fire: create + update message + update description + a timeline note. + reqs, _, err := notifier.createRequests(ctx, firing) + require.NoError(t, err) + require.Len(t, reqs, 4) + assert.Equal(t, "https://test-opsgenie-url/v2/alerts", reqs[0].URL.String()) + assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[3].URL.String()) + assert.Equal(t, http.MethodPost, reqs[3].Method) + + // the note body is the plain-text render: markers stripped, link flattened + var noteMsg opsGenieAddNoteMessage + require.NoError(t, json.Unmarshal([]byte(readBody(t, reqs[3])), ¬eMsg)) + assert.Equal(t, "Alert: d View (https://s.io/a)", noteMsg.Note) + + // Resolve: note posted before the close. + resolved := &types.Alert{Alert: model.Alert{ + StartsAt: time.Now().Add(-time.Hour), + EndsAt: time.Now().Add(-time.Minute), + Labels: model.LabelSet{"Message": "m", "Description": "d"}, + }} + reqs, _, err = notifier.createRequests(ctx, resolved) + require.NoError(t, err) + require.Len(t, reqs, 2) + assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[0].URL.String()) + assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/close?identifierType=alias", alias), reqs[1].URL.String()) +} + +func TestOpsGenieNotifyBestEffortNote(t *testing.T) { + tmpl := test.CreateTmpl(t) + ctx := notify.WithGroupKey(context.Background(), "1") + + firing := &types.Alert{Alert: model.Alert{ + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + Labels: model.LabelSet{"Message": "m", "Description": "d"}, + }} + + for _, tc := range []struct { + name string + createStatus int + noteStatus int + wantErr bool + wantRetry bool + }{ + {name: "note_404_is_dropped", createStatus: http.StatusAccepted, noteStatus: http.StatusNotFound, wantErr: false, wantRetry: true}, + {name: "note_429_still_retries", createStatus: http.StatusAccepted, noteStatus: http.StatusTooManyRequests, wantErr: true, wantRetry: true}, + {name: "create_404_still_fails", createStatus: http.StatusNotFound, noteStatus: http.StatusAccepted, wantErr: true, wantRetry: false}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/notes") { + w.WriteHeader(tc.noteStatus) + return + } + w.WriteHeader(tc.createStatus) + })) + defer srv.Close() + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + notifier, err := New(&config.OpsGenieConfig{ + Message: `{{ .CommonLabels.Message }}`, + Description: `{{ .CommonLabels.Description }}`, + APIKey: "k", + APIURL: &config.URL{URL: u}, + HTTPConfig: &commoncfg.HTTPClientConfig{}, + }, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true) + require.NoError(t, err) + + retry, err := notifier.Notify(ctx, firing) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + assert.Equal(t, tc.wantRetry, retry) + }) + } +} + func TestOpsGenieApiKeyFile(t *testing.T) { u, err := url.Parse("https://test-opsgenie-url") require.NoError(t, err) @@ -335,7 +447,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) { APIURL: &config.URL{URL: u}, HTTPConfig: &commoncfg.HTTPClientConfig{}, } - notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl)) + notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false) require.NoError(t, err) requests, _, err := notifierWithUpdate.createRequests(ctx) @@ -437,6 +549,109 @@ func TestPrepareContent(t *testing.T) { }) } +func TestShrinkMarkdownToFit(t *testing.T) { + cases := []struct { + name string + md string + budget int + wantEmpty bool + }{ + {"fits untouched", "**bold** text", 1000, false}, + {"shrinks to fit", strings.Repeat("lorem ipsum ", 500), 1000, false}, + {"budget too small", strings.Repeat("lorem ipsum ", 500), 10, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := shrinkMarkdownToFit(c.md, c.budget) + require.NoError(t, err) + if c.wantEmpty { + assert.Empty(t, got) + return + } + assert.NotEmpty(t, got) + assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget) + assert.Equal(t, strings.Count(got, "

"), strings.Count(got, "

")) + }) + } +} + +func TestBuildHTMLDescriptionOverflow(t *testing.T) { + bigPart := strings.Repeat("alpha beta gamma ", 100) + cases := []struct { + name string + parts []string + budget int + wantTrailer bool + }{ + {"all parts fit", []string{"**a**", "**b**"}, maxDescriptionLenRunes, false}, + {"empty parts skipped", []string{"", "hello", ""}, maxDescriptionLenRunes, false}, + {"overflow drops parts with trailer", repeatParts(bigPart, 12), maxDescriptionLenRunes, true}, + {"single huge part shrunk without trailer", []string{strings.Repeat(bigPart, 20)}, maxDescriptionLenRunes, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := buildHTMLDescription(c.parts, c.budget) + require.NoError(t, err) + assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget) + assert.Equal(t, strings.Count(got, "
"), strings.Count(got, "
")) + assert.True(t, strings.HasSuffix(got, "
")) + if c.wantTrailer { + assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, got) + } else { + assert.NotContains(t, got, "more alerts") + } + }) + } +} + +// prepareContent end-to-end: 40 custom-template alerts overflow the description +// budget yet the posted HTML stays within limits and well-formed. +func TestPrepareContentDescriptionOverflow(t *testing.T) { + tmpl := test.CreateTmpl(t) + notifier := &Notifier{ + conf: &config.OpsGenieConfig{ + Message: `{{ .CommonLabels.alertname }}`, + Description: `{{ .CommonLabels.alertname }}`, + }, + tmpl: tmpl, + logger: promslog.NewNopLogger(), + templater: newTestTemplater(tmpl), + advancedFeatures: true, + } + + bodyTemplate := "**Alert in** $labels.namespace\n\n" + strings.Repeat("detail line for the runbook ", 30) + alerts := make([]*types.Alert, 0, 40) + for i := range 40 { + alerts = append(alerts, &types.Alert{ + Alert: model.Alert{ + Labels: model.LabelSet{ + "alertname": "overflow", + "namespace": model.LabelValue(fmt.Sprintf("ns-%d", i)), + }, + Annotations: model.LabelSet{ + ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate), + }, + StartsAt: time.Now(), + EndsAt: time.Now().Add(time.Hour), + }, + }) + } + + _, desc, err := notifier.prepareContent(notify.WithGroupKey(context.Background(), "1"), alerts) + require.NoError(t, err) + assert.LessOrEqual(t, utf8.RuneCountInString(desc), maxDescriptionLenRunes) + assert.Equal(t, strings.Count(desc, "
"), strings.Count(desc, "
")) + assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, desc) +} + +func repeatParts(part string, n int) []string { + parts := make([]string, n) + for i := range parts { + parts[i] = part + } + return parts +} + func readBody(t *testing.T, r *http.Request) string { t.Helper() body, err := io.ReadAll(r.Body) diff --git a/pkg/alertmanager/alertmanagernotify/receiver.go b/pkg/alertmanager/alertmanagernotify/receiver.go index ac904873cea..bdc3d14c4ca 100644 --- a/pkg/alertmanager/alertmanagernotify/receiver.go +++ b/pkg/alertmanager/alertmanagernotify/receiver.go @@ -6,6 +6,8 @@ import ( "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/googlechat" + "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jira" + "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jsmops" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie" "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty" @@ -26,6 +28,8 @@ var customNotifierIntegrations = []string{ slack.Integration, msteamsv2.Integration, googlechat.Integration, + jira.Integration, + jsmops.Integration, } func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) { @@ -66,7 +70,7 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, templater) }) } for i, c := range nc.OpsGenieConfigs { - add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater) }) + add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater, false) }) } for i, c := range nc.SlackConfigs { add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, templater) }) @@ -81,6 +85,16 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp return googlechat.New(c, tmpl, l, templater) }) } + for i, c := range nc.JiraConfigs { + add(jira.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { + return jira.New(c, tmpl, l, templater) + }) + } + for i, c := range nc.JSMOpsConfigs { + add(jsmops.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { + return jsmops.New(c, tmpl, l, templater, true) + }) + } if errs.Len() > 0 { return nil, &errs diff --git a/pkg/query-service/rules/manager.go b/pkg/query-service/rules/manager.go index 402148e7195..4ab5a34c961 100644 --- a/pkg/query-service/rules/manager.go +++ b/pkg/query-service/rules/manager.go @@ -544,7 +544,7 @@ func (m *Manager) deleteTask(taskName string) { } // CreateRule stores rule def into db and also -// starts an executor for the rule +// starts an executor for the rule, unless the rule is disabled func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) { claims, err := authtypes.ClaimsFromContext(ctx) if err != nil { @@ -611,7 +611,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge } taskName := prepareTaskName(id.StringValue()) - if err = m.addTask(ctx, orgID, &parsedRule, taskName); err != nil { + if err = m.syncRuleStateWithTask(ctx, orgID, taskName, &parsedRule); err != nil { return err } diff --git a/pkg/sqlmigration/030_create_quick_filters.go b/pkg/sqlmigration/030_create_quick_filters.go index 2a7016e0ec8..5c451ed9087 100644 --- a/pkg/sqlmigration/030_create_quick_filters.go +++ b/pkg/sqlmigration/030_create_quick_filters.go @@ -3,12 +3,13 @@ package sqlmigration import ( "context" "database/sql" + "encoding/json" + "time" "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/sqlstore" "github.com/SigNoz/signoz/pkg/types" - "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" "github.com/SigNoz/signoz/pkg/valuer" "github.com/uptrace/bun" "github.com/uptrace/bun/migrate" @@ -39,6 +40,52 @@ func (m *createQuickFilters) Register(migrations *migrate.Migrations) error { } func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error { + // Frozen copy of the defaults as this migration shipped (hence the old + // camelCase keys); migrations must not read live types. 031 replaces these rows. + defaultFilters := []struct { + signal string + filters []map[string]any + }{ + {"traces", []map[string]any{ + {"key": "duration_nano", "dataType": "float64", "type": "tag"}, + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "hasError", "dataType": "bool", "type": "tag"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "name", "dataType": "string", "type": "resource"}, + {"key": "rpcMethod", "dataType": "string", "type": "tag"}, + {"key": "responseStatusCode", "dataType": "string", "type": "resource"}, + {"key": "httpHost", "dataType": "string", "type": "tag"}, + {"key": "httpMethod", "dataType": "string", "type": "tag"}, + {"key": "httpRoute", "dataType": "string", "type": "tag"}, + {"key": "httpUrl", "dataType": "string", "type": "tag"}, + {"key": "traceID", "dataType": "string", "type": "tag"}, + }}, + {"logs", []map[string]any{ + {"key": "severity_text", "dataType": "string", "type": "resource"}, + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "host.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.cluster.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.deployment.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.namespace.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.pod.name", "dataType": "string", "type": "resource"}, + }}, + {"api_monitoring", []map[string]any{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "rpcMethod", "dataType": "string", "type": "tag"}, + }}, + {"exceptions", []map[string]any{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "serviceName", "dataType": "string", "type": "tag"}, + {"key": "host.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.cluster.name", "dataType": "string", "type": "tag"}, + {"key": "k8s.deployment.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.namespace.name", "dataType": "string", "type": "tag"}, + {"key": "k8s.pod.name", "dataType": "string", "type": "tag"}, + }}, + } + tx, err := db.BeginTx(ctx, nil) if err != nil { return err @@ -72,15 +119,31 @@ func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error { return err } - // Get the default quick filters - storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(defaultOrg) - if err != nil { - return err + now := time.Now() + quickFilters := make([]*quickFilter, 0, len(defaultFilters)) + for _, defaultFilter := range defaultFilters { + filterJSON, err := json.Marshal(defaultFilter.filters) + if err != nil { + return err + } + + quickFilters = append(quickFilters, &quickFilter{ + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + OrgID: defaultOrg.StringValue(), + Filter: string(filterJSON), + Signal: defaultFilter.signal, + TimeAuditable: types.TimeAuditable{ + CreatedAt: now, + UpdatedAt: now, + }, + }) } // Insert all filters at once _, err = tx.NewInsert(). - Model(&storableQuickFilters). + Model(&quickFilters). Exec(ctx) if err != nil { diff --git a/pkg/sqlmigration/031_update_quick_filters.go b/pkg/sqlmigration/031_update_quick_filters.go index 15241011a86..ca8621be975 100644 --- a/pkg/sqlmigration/031_update_quick_filters.go +++ b/pkg/sqlmigration/031_update_quick_filters.go @@ -3,11 +3,13 @@ package sqlmigration import ( "context" "database/sql" + "encoding/json" + "time" "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/sqlstore" - "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" + "github.com/SigNoz/signoz/pkg/types" "github.com/SigNoz/signoz/pkg/valuer" "github.com/uptrace/bun" "github.com/uptrace/bun/migrate" @@ -38,6 +40,61 @@ func (migration *updateQuickFilters) Register(migrations *migrate.Migrations) er } func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error { + // Frozen copy of the defaults as this migration shipped; migrations must not + // read live types. api_monitoring's service.name is "tag" here — 035 fixes it. + defaultFilters := []struct { + signal string + filters []map[string]any + }{ + {"traces", []map[string]any{ + {"key": "duration_nano", "dataType": "float64", "type": "tag"}, + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "hasError", "dataType": "bool", "type": "tag"}, + {"key": "service.name", "dataType": "string", "type": "resource"}, + {"key": "name", "dataType": "string", "type": "tag"}, + {"key": "rpc.method", "dataType": "string", "type": "tag"}, + {"key": "response_status_code", "dataType": "string", "type": "tag"}, + {"key": "http_host", "dataType": "string", "type": "tag"}, + {"key": "http.method", "dataType": "string", "type": "tag"}, + {"key": "http.route", "dataType": "string", "type": "tag"}, + {"key": "http_url", "dataType": "string", "type": "tag"}, + {"key": "trace_id", "dataType": "string", "type": "tag"}, + }}, + {"logs", []map[string]any{ + {"key": "severity_text", "dataType": "string", "type": "resource"}, + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "service.name", "dataType": "string", "type": "resource"}, + {"key": "host.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.cluster.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.deployment.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.namespace.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.pod.name", "dataType": "string", "type": "resource"}, + }}, + {"api_monitoring", []map[string]any{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "service.name", "dataType": "string", "type": "tag"}, + {"key": "rpc.method", "dataType": "string", "type": "tag"}, + }}, + {"exceptions", []map[string]any{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "service.name", "dataType": "string", "type": "resource"}, + {"key": "host.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.cluster.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.deployment.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.namespace.name", "dataType": "string", "type": "resource"}, + {"key": "k8s.pod.name", "dataType": "string", "type": "resource"}, + }}, + } + + signalFilters := make([]struct{ signal, filter string }, 0, len(defaultFilters)) + for _, defaultFilter := range defaultFilters { + filterJSON, err := json.Marshal(defaultFilter.filters) + if err != nil { + return err + } + signalFilters = append(signalFilters, struct{ signal, filter string }{defaultFilter.signal, string(filterJSON)}) + } + tx, err := db.BeginTx(ctx, nil) if err != nil { return err @@ -73,17 +130,28 @@ func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error { return err } - // For each organization, create new quick filters with the updated NewDefaultQuickFilter function + // For each organization, create new quick filters with the updated defaults for _, orgID := range orgIDs { - // Get the updated default quick filters - storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID)) - if err != nil { - return err + now := time.Now() + quickFilters := make([]*quickFilter, 0, len(signalFilters)) + for _, signalFilter := range signalFilters { + quickFilters = append(quickFilters, &quickFilter{ + Identifiable: types.Identifiable{ + ID: valuer.GenerateUUID(), + }, + OrgID: orgID, + Filter: signalFilter.filter, + Signal: signalFilter.signal, + TimeAuditable: types.TimeAuditable{ + CreatedAt: now, + UpdatedAt: now, + }, + }) } // Insert all filters for this organization _, err = tx.NewInsert(). - Model(&storableQuickFilters). + Model(&quickFilters). Exec(ctx) if err != nil { diff --git a/pkg/sqlmigration/035_update_api_monitoring_filters.go b/pkg/sqlmigration/035_update_api_monitoring_filters.go index a1efc607667..6de816ef267 100644 --- a/pkg/sqlmigration/035_update_api_monitoring_filters.go +++ b/pkg/sqlmigration/035_update_api_monitoring_filters.go @@ -2,20 +2,16 @@ package sqlmigration import ( "context" - "database/sql" + "encoding/json" "time" "github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/sqlstore" - "github.com/SigNoz/signoz/pkg/types/quickfiltertypes" - "github.com/SigNoz/signoz/pkg/valuer" "github.com/uptrace/bun" "github.com/uptrace/bun/migrate" ) -type updateApiMonitoringFilters struct { - store sqlstore.SQLStore -} +type updateApiMonitoringFilters struct{} func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] { return factory.NewProviderFactory(factory.MustNewName("update_api_monitoring_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) { @@ -23,10 +19,8 @@ func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.Provi }) } -func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) { - return &updateApiMonitoringFilters{ - store: store, - }, nil +func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, _ sqlstore.SQLStore) (SQLMigration, error) { + return &updateApiMonitoringFilters{}, nil } func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrations) error { @@ -38,63 +32,29 @@ func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrat } func (migration *updateApiMonitoringFilters) Up(ctx context.Context, db *bun.DB) error { - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return err + // Frozen copy of the api_monitoring defaults as this migration shipped; the + // change over 031 is service.name moving from "tag" to "resource". + apiMonitoringFilters := []map[string]any{ + {"key": "deployment.environment", "dataType": "string", "type": "resource"}, + {"key": "service.name", "dataType": "string", "type": "resource"}, + {"key": "rpc.method", "dataType": "string", "type": "tag"}, } - defer func() { - _ = tx.Rollback() - }() - - // Get all organization IDs as strings - var orgIDs []string - err = tx.NewSelect(). - Table("organizations"). - Column("id"). - Scan(ctx, &orgIDs) + apiMonitoringFilterJSON, err := json.Marshal(apiMonitoringFilters) if err != nil { - if err == sql.ErrNoRows { - if err := tx.Commit(); err != nil { - return err - } - return nil - } return err } - for _, orgID := range orgIDs { - // Get the updated default quick filters which includes the new API monitoring filters - storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID)) - if err != nil { - return err - } - - // Find the API monitoring filter from the storable quick filters - var apiMonitoringFilterJSON string - for _, filter := range storableQuickFilters { - if filter.Signal == quickfiltertypes.SignalApiMonitoring { - apiMonitoringFilterJSON = filter.Filter - break - } - } - - if apiMonitoringFilterJSON != "" { - _, err = tx.NewUpdate(). - Table("quick_filter"). - Set("filter = ?, updated_at = ?", apiMonitoringFilterJSON, time.Now()). - Where("signal = ? AND org_id = ?", quickfiltertypes.SignalApiMonitoring, orgID). - Exec(ctx) - - if err != nil { - return err - } - } - } - - if err := tx.Commit(); err != nil { + // The filter JSON is org-independent, so one update covers every org's row. + _, err = db.NewUpdate(). + Table("quick_filter"). + Set("filter = ?, updated_at = ?", string(apiMonitoringFilterJSON), time.Now()). + Where("signal = ?", "api_monitoring"). + Exec(ctx) + if err != nil { return err } + return nil } diff --git a/pkg/templating/markdownrenderer/adf/adf.go b/pkg/templating/markdownrenderer/adf/adf.go new file mode 100644 index 00000000000..b87bf7bfd0d --- /dev/null +++ b/pkg/templating/markdownrenderer/adf/adf.go @@ -0,0 +1,173 @@ +// Package adf converts Markdown into Atlassian Document Format (ADF) nodes, +// the JSON rich-text format used by Jira Cloud's v3 API. +package adf + +import ( + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" +) + +// parser is stateless across Parse calls and safe for concurrent use; only +// goldmark's renderers hold per-document state (which we don't use). Strikethrough +// is included for the strike mark; linkify is deliberately omitted since it +// fragments plain text into word tokens while scanning for bare URLs. +var parser = goldmark.New(goldmark.WithExtensions(extension.Strikethrough)).Parser() + +// Render returns the ADF block nodes for markdown (without the doc wrapper), +// so callers can embed them alongside their own nodes (panels, links, …). +func Render(markdown string) []any { + src := []byte(markdown) + return blockChildren(parser.Parse(text.NewReader(src)), src) +} + +func blockChildren(parent ast.Node, src []byte) []any { + var out []any + for c := parent.FirstChild(); c != nil; c = c.NextSibling() { + if b := block(c, src); b != nil { + out = append(out, b) + } + } + return out +} + +func block(n ast.Node, src []byte) any { + switch node := n.(type) { + case *ast.Heading: + return map[string]any{"type": "heading", "attrs": map[string]any{"level": node.Level}, "content": inlineChildren(node, src, nil)} + case *ast.Paragraph: + return paragraph(inlineChildren(node, src, nil)) + case *ast.TextBlock: + return paragraph(inlineChildren(node, src, nil)) + case *ast.List: + typ := "bulletList" + if node.IsOrdered() { + typ = "orderedList" + } + return map[string]any{"type": typ, "content": blockChildren(node, src)} + case *ast.ListItem: + return map[string]any{"type": "listItem", "content": blockChildren(node, src)} + case *ast.Blockquote: + return map[string]any{"type": "blockquote", "content": blockChildren(node, src)} + case *ast.FencedCodeBlock: + return codeBlock(codeText(node, src), string(node.Language(src))) + case *ast.CodeBlock: + return codeBlock(codeText(node, src), "") + case *ast.ThematicBreak: + return map[string]any{"type": "rule"} + default: + return nil + } +} + +func paragraph(content []any) map[string]any { + p := map[string]any{"type": "paragraph"} + if len(content) > 0 { + p["content"] = content + } + return p +} + +func codeBlock(code, lang string) map[string]any { + cb := map[string]any{"type": "codeBlock"} + if lang != "" { + cb["attrs"] = map[string]any{"language": lang} + } + if code = strings.TrimRight(code, "\n"); code != "" { + cb["content"] = []any{map[string]any{"type": "text", "text": code}} + } + return cb +} + +// inlineChildren flattens an inline subtree into ADF text nodes, carrying the +// active marks (strong/em/code/strike/link) down the tree. +func inlineChildren(parent ast.Node, src []byte, marks []any) []any { + var out []any + for c := parent.FirstChild(); c != nil; c = c.NextSibling() { + switch node := c.(type) { + case *ast.Text: + if t := string(node.Segment.Value(src)); t != "" { + out = append(out, textNode(t, marks)) + } + if node.HardLineBreak() { + out = append(out, map[string]any{"type": "hardBreak"}) + } else if node.SoftLineBreak() { + out = append(out, textNode(" ", marks)) + } + case *ast.String: + if len(node.Value) > 0 { + out = append(out, textNode(string(node.Value), marks)) + } + case *ast.CodeSpan: + if t := rawText(node, src); t != "" { + out = append(out, textNode(t, withMark(marks, mark("code")))) + } + case *ast.Emphasis: + m := "em" + if node.Level == 2 { + m = "strong" + } + out = append(out, inlineChildren(node, src, withMark(marks, mark(m)))...) + case *extast.Strikethrough: + out = append(out, inlineChildren(node, src, withMark(marks, mark("strike")))...) + case *ast.Link: + out = append(out, inlineChildren(node, src, withMark(marks, linkMark(string(node.Destination))))...) + case *ast.AutoLink: + if u := string(node.URL(src)); u != "" { + out = append(out, textNode(u, withMark(marks, linkMark(u)))) + } + default: + out = append(out, inlineChildren(c, src, marks)...) + } + } + return out +} + +func textNode(s string, marks []any) map[string]any { + tn := map[string]any{"type": "text", "text": s} + if len(marks) > 0 { + tn["marks"] = marks + } + return tn +} + +func mark(typ string) any { return map[string]any{"type": typ} } + +func linkMark(href string) any { + return map[string]any{"type": "link", "attrs": map[string]any{"href": href}} +} + +func withMark(marks []any, m any) []any { + out := make([]any, 0, len(marks)+1) + out = append(out, marks...) + return append(out, m) +} + +func rawText(n ast.Node, src []byte) string { + var b strings.Builder + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + switch t := c.(type) { + case *ast.Text: + b.Write(t.Segment.Value(src)) + case *ast.String: + b.Write(t.Value) + default: + b.WriteString(rawText(c, src)) + } + } + return b.String() +} + +func codeText(n ast.Node, src []byte) string { + var b strings.Builder + lines := n.Lines() + for i := 0; i < lines.Len(); i++ { + seg := lines.At(i) + b.Write(seg.Value(src)) + } + return b.String() +} diff --git a/pkg/templating/markdownrenderer/adf/adf_test.go b/pkg/templating/markdownrenderer/adf/adf_test.go new file mode 100644 index 00000000000..9bd3c58c269 --- /dev/null +++ b/pkg/templating/markdownrenderer/adf/adf_test.go @@ -0,0 +1,85 @@ +package adf + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func toJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return string(b) +} + +func TestRenderInlineMarks(t *testing.T) { + js := toJSON(t, Render("**bold** and *em* and `code` and [txt](https://x.io)")) + assert.Contains(t, js, `"type":"strong"`) + assert.Contains(t, js, `"type":"em"`) + assert.Contains(t, js, `"type":"code"`) + assert.Contains(t, js, `"type":"link"`) + assert.Contains(t, js, `"href":"https://x.io"`) + assert.Contains(t, js, `"text":"bold"`) +} + +func TestRenderHeadingAndList(t *testing.T) { + js := toJSON(t, Render("# Title\n\n- a\n- b")) + assert.Contains(t, js, `"type":"heading"`) + assert.Contains(t, js, `"level":1`) + assert.Contains(t, js, `"type":"bulletList"`) + assert.Contains(t, js, `"type":"listItem"`) +} + +func TestRenderOrderedList(t *testing.T) { + js := toJSON(t, Render("1. one\n2. two")) + assert.Contains(t, js, `"type":"orderedList"`) +} + +func TestRenderCodeBlock(t *testing.T) { + js := toJSON(t, Render("```go\nx := 1\n```")) + assert.Contains(t, js, `"type":"codeBlock"`) + assert.Contains(t, js, `"language":"go"`) + assert.Contains(t, js, `x := 1`) +} + +func TestRenderStrikethrough(t *testing.T) { + js := toJSON(t, Render("~~gone~~")) + assert.Contains(t, js, `"type":"strike"`) + assert.Contains(t, js, `"text":"gone"`) +} + +func TestRenderBlockquote(t *testing.T) { + js := toJSON(t, Render("> quoted")) + assert.Contains(t, js, `"type":"blockquote"`) + assert.Contains(t, js, `"text":"quoted"`) +} + +func TestRenderAutoLink(t *testing.T) { + js := toJSON(t, Render("see ")) + assert.Contains(t, js, `"type":"link"`) + assert.Contains(t, js, `"href":"https://signoz.io"`) + assert.Contains(t, js, `"text":"https://signoz.io"`) +} + +func TestRenderLineBreaks(t *testing.T) { + js := toJSON(t, Render("one \ntwo")) + assert.Contains(t, js, `"type":"hardBreak"`) + + // a soft break renders as a space, keeping the paragraph intact + js = toJSON(t, Render("one\ntwo")) + assert.NotContains(t, js, `"type":"hardBreak"`) + assert.Contains(t, js, `"text":" "`) +} + +func TestRenderPlainText(t *testing.T) { + js := toJSON(t, Render("just text")) + assert.Contains(t, js, `"type":"paragraph"`) + assert.Contains(t, js, `"text":"just text"`) +} + +func TestRenderEmptyIsEmpty(t *testing.T) { + assert.Empty(t, Render("")) +} diff --git a/pkg/templating/markdownrenderer/markdownrenderer.go b/pkg/templating/markdownrenderer/markdownrenderer.go index 5705193df27..84960c01bc5 100644 --- a/pkg/templating/markdownrenderer/markdownrenderer.go +++ b/pkg/templating/markdownrenderer/markdownrenderer.go @@ -7,6 +7,7 @@ import ( "github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit" "github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn" + "github.com/SigNoz/signoz/pkg/templating/markdownrenderer/plaintext" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" ) @@ -32,6 +33,11 @@ var ( return goldmark.New(goldmark.WithExtensions(mrkdwn.Extender)) }, } + plaintextPool = sync.Pool{ + New: func() any { + return goldmark.New(goldmark.WithExtensions(plaintext.Extender)) + }, + } ) // RenderHTML converts markdown to HTML. @@ -53,6 +59,14 @@ func RenderSlackMrkdwn(markdown string) (string, error) { return render(md, markdown, "Slack mrkdwn") } +// RenderPlainText converts markdown to plain text: no markers, links flattened +// to "text (url)". +func RenderPlainText(markdown string) (string, error) { + md := plaintextPool.Get().(goldmark.Markdown) + defer plaintextPool.Put(md) + return render(md, markdown, "plain text") +} + func render(md goldmark.Markdown, markdown string, format string) (string, error) { var buf bytes.Buffer if err := md.Convert([]byte(markdown), &buf); err != nil { diff --git a/pkg/templating/markdownrenderer/plaintext/plaintext.go b/pkg/templating/markdownrenderer/plaintext/plaintext.go new file mode 100644 index 00000000000..cdf0f1c580d --- /dev/null +++ b/pkg/templating/markdownrenderer/plaintext/plaintext.go @@ -0,0 +1,301 @@ +// Package plaintext provides a goldmark node renderer that emits plain text: +// no markdown or HTML markers, and links flattened to "text (url)". It is used +// for JSM Ops timeline notes, which render neither HTML nor markdown. +package plaintext + +import ( + "bytes" + "fmt" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + extensionast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" +) + +// Extender registers the plain-text node renderer plus the GFM extensions it +// handles (tables, strikethrough). +var Extender goldmark.Extender = &extender{} + +type extender struct{} + +func (e *extender) Extend(m goldmark.Markdown) { + extension.Table.Extend(m) + extension.Strikethrough.Extend(m) + m.Renderer().AddOptions( + renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)), + ) +} + +// nodeRenderer holds per-document nesting prefixes, so it is not safe for +// concurrent Convert calls; callers pool one instance per goroutine. +type nodeRenderer struct { + prefixes []string +} + +func newRenderer() renderer.NodeRenderer { + return &nodeRenderer{} +} + +func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + // Blocks + reg.Register(ast.KindDocument, r.renderDocument) + reg.Register(ast.KindHeading, r.renderBlock) + reg.Register(ast.KindBlockquote, r.renderBlock) + reg.Register(ast.KindCodeBlock, r.renderCodeBlock) + reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock) + reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock) + reg.Register(ast.KindList, r.renderList) + reg.Register(ast.KindListItem, r.renderListItem) + reg.Register(ast.KindParagraph, r.renderBlock) + reg.Register(ast.KindTextBlock, r.renderTextBlock) + reg.Register(ast.KindThematicBreak, r.renderThematicBreak) + + // Inlines + reg.Register(ast.KindAutoLink, r.renderAutoLink) + reg.Register(ast.KindCodeSpan, r.renderCodeSpan) + reg.Register(ast.KindEmphasis, r.renderPassthrough) + reg.Register(ast.KindImage, r.renderLink) + reg.Register(ast.KindLink, r.renderLink) + reg.Register(ast.KindText, r.renderText) + reg.Register(ast.KindString, r.renderString) + reg.Register(ast.KindRawHTML, r.renderRawHTML) + + // Extensions + reg.Register(extensionast.KindStrikethrough, r.renderPassthrough) + reg.Register(extensionast.KindTable, r.renderTable) +} + +func (r *nodeRenderer) writePrefix(w util.BufWriter) { + for _, p := range r.prefixes { + _, _ = w.WriteString(p) + } +} + +func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) { + _ = w.WriteByte('\n') + r.writePrefix(w) +} + +// writeBlockSeparator writes a blank line between block-level elements. +func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) { + r.writeLineSeparator(w) + r.writeLineSeparator(w) +} + +func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) { + if n.PreviousSibling() != nil { + r.writeBlockSeparator(w) + } +} + +func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + // The renderer is pooled; wipe any prefix stack left over from a prior + // document (e.g. one that errored mid-walk) before starting fresh. + r.prefixes = r.prefixes[:0] + } + return ast.WalkContinue, nil +} + +// renderBlock separates block-level nodes (paragraph, heading, blockquote) from +// their previous sibling with a blank line, emitting no markers of their own. +func (r *nodeRenderer) renderBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + r.separateFromPrevious(w, node) + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + r.separateFromPrevious(w, n) + l := n.Lines().Len() + for i := 0; i < l; i++ { + line := n.Lines().At(i) + _, _ = w.Write(line.Value(source)) + } + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && node.PreviousSibling() != nil { + r.writeLineSeparator(w) + if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem { + r.writeLineSeparator(w) + } + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + if n.PreviousSibling() != nil { + r.writeLineSeparator(w) + } + parent := n.Parent().(*ast.List) + var prefixStr string + if parent.IsOrdered() { + index := parent.Start + for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() { + index++ + } + prefixStr = fmt.Sprintf("%d. ", index) + } else { + prefixStr = "- " + } + _, _ = w.WriteString(prefixStr) + r.prefixes = append(r.prefixes, " ") // indent wrapped/nested lines + } else { + r.prefixes = r.prefixes[:len(r.prefixes)-1] + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && n.PreviousSibling() != nil { + r.writeLineSeparator(w) + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + r.separateFromPrevious(w, n) + _, _ = w.WriteString("---") + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + n := node.(*ast.AutoLink) + url := string(n.URL(source)) + if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") { + url = "mailto:" + url + } + _, _ = w.WriteString(url) + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + segment := c.(*ast.Text).Segment + value := segment.Value(source) + if bytes.HasSuffix(value, []byte("\n")) { + _, _ = w.Write(value[:len(value)-1]) + _ = w.WriteByte(' ') + } else { + _, _ = w.Write(value) + } + } + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil +} + +// renderPassthrough emits no markers; the node's children render as plain text +// (used for emphasis/strong and strikethrough). +func (r *nodeRenderer) renderPassthrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + return ast.WalkContinue, nil +} + +// renderLink flattens links and images to "text (url)": children render the +// label, then the destination is appended in parentheses on exit. +func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + var dest []byte + switch n := node.(type) { + case *ast.Link: + dest = n.Destination + case *ast.Image: + dest = n.Destination + } + if !entering && len(dest) > 0 { + _, _ = fmt.Fprintf(w, " (%s)", dest) + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + n := node.(*ast.Text) + _, _ = w.Write(n.Segment.Value(source)) + if n.HardLineBreak() || n.SoftLineBreak() { + r.writeLineSeparator(w) + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.Write(node.(*ast.String).Value) + } + return ast.WalkContinue, nil +} + +func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + // Drop inline raw HTML tags; a plain-text note should never carry markup. + return ast.WalkSkipChildren, nil +} + +func (r *nodeRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + // Drop block-level raw HTML for the same reason as inline raw HTML. + return ast.WalkSkipChildren, nil +} + +func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + r.separateFromPrevious(w, node) + + first := true + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + if c.Kind() != extensionast.KindTableHeader && c.Kind() != extensionast.KindTableRow { + continue + } + if !first { + r.writeLineSeparator(w) + } + first = false + cellFirst := true + for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() { + if cc.Kind() != extensionast.KindTableCell { + continue + } + if !cellFirst { + _, _ = w.WriteString(" | ") + } + cellFirst = false + _, _ = w.WriteString(extractPlainText(cc, source)) + } + } + return ast.WalkSkipChildren, nil +} + +// extractPlainText collects the text content of a node. +func extractPlainText(n ast.Node, source []byte) string { + var buf bytes.Buffer + _ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch t := node.(type) { + case *ast.Text: + buf.Write(t.Segment.Value(source)) + case *ast.String: + buf.Write(t.Value) + } + return ast.WalkContinue, nil + }) + return strings.TrimSpace(buf.String()) +} diff --git a/pkg/templating/markdownrenderer/plaintext/plaintext_test.go b/pkg/templating/markdownrenderer/plaintext/plaintext_test.go new file mode 100644 index 00000000000..0230f03a005 --- /dev/null +++ b/pkg/templating/markdownrenderer/plaintext/plaintext_test.go @@ -0,0 +1,55 @@ +package plaintext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yuin/goldmark" +) + +func render(t *testing.T, md string) string { + t.Helper() + var b []byte + buf := bytesBuffer{&b} + g := goldmark.New(goldmark.WithExtensions(Extender)) + require.NoError(t, g.Convert([]byte(md), &buf)) + return string(b) +} + +// bytesBuffer is a tiny io.Writer so the test needs no extra imports. +type bytesBuffer struct{ b *[]byte } + +func (w bytesBuffer) Write(p []byte) (int, error) { + *w.b = append(*w.b, p...) + return len(p), nil +} + +func TestPlainText(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"strips bold and italic", "**bold** and *italic*", "bold and italic"}, + {"link becomes text (url)", "[View in SigNoz](https://signoz.io/alert)", "View in SigNoz (https://signoz.io/alert)"}, + {"bold label kept, marker dropped", "**Alert:** name (critical)", "Alert: name (critical)"}, + {"strikethrough stripped", "~~gone~~", "gone"}, + {"inline code unwrapped", "run `foo bar`", "run foo bar"}, + {"paragraphs separated by blank line", "one\n\ntwo", "one\n\ntwo"}, + {"unordered list", "- a\n- b", "- a\n- b"}, + {"ordered list keeps numbering", "1. a\n2. b", "1. a\n2. b"}, + {"nested list indents under parent", "- a\n - b", "- a\n - b"}, + {"fenced code block unwrapped", "```go\nx := 1\n```", "x := 1\n"}, + {"table flattens to pipe-separated rows", "| h1 | h2 |\n|---|---|\n| a | b |\n| c | d |", "h1 | h2\na | b\nc | d"}, + {"autolink kept as bare url", "see ", "see https://signoz.io"}, + {"inline raw html dropped", "a bold word", "a bold word"}, + {"html block dropped", "before\n\n
markup
\n\nafter", "before\n\nafter"}, + {"hard break becomes newline", "one \ntwo", "one\ntwo"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, render(t, c.in)) + }) + } +} diff --git a/pkg/types/alertmanagertypes/channel.go b/pkg/types/alertmanagertypes/channel.go index 3e2e3386c09..c3d9eca5a77 100644 --- a/pkg/types/alertmanagertypes/channel.go +++ b/pkg/types/alertmanagertypes/channel.go @@ -216,13 +216,20 @@ func (PostableChannel) JSONSchema() (jsonschema.Schema, error) { schema.WithRequired("name") var oneOf []jsonschema.SchemaOrBool - // Walk both halves: native fields on Receiver, upstream on the embed. + seen := map[string]struct{}{} + // Walk both halves: native fields on Receiver, upstream on the embed. A native + // field can shadow an upstream one with the same tag (e.g. jira_configs), so + // dedupe to avoid emitting two identical oneOf branches. collect := func(t reflect.Type) { for i := 0; i < t.NumField(); i++ { jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0] if !strings.HasSuffix(jsonTag, "_configs") { continue } + if _, ok := seen[jsonTag]; ok { + continue + } + seen[jsonTag] = struct{}{} branch := (&jsonschema.Schema{}).WithRequired(jsonTag) oneOf = append(oneOf, branch.ToSchemaOrBool()) } diff --git a/pkg/types/alertmanagertypes/config.go b/pkg/types/alertmanagertypes/config.go index 70b67030eea..7505cfea236 100644 --- a/pkg/types/alertmanagertypes/config.go +++ b/pkg/types/alertmanagertypes/config.go @@ -70,15 +70,19 @@ type Config struct { // on Receiver, and extensions to customConfigsOf + isEmpty. type customReceiverConfigs struct { GoogleChat []*GoogleChatReceiverConfig + Jira []*JiraReceiverConfig + JSMOps []*JSMOpsReceiverConfig } func (c customReceiverConfigs) isEmpty() bool { - return len(c.GoogleChat) == 0 + return len(c.GoogleChat) == 0 && len(c.Jira) == 0 && len(c.JSMOps) == 0 } func customConfigsOf(receiver *Receiver) customReceiverConfigs { return customReceiverConfigs{ GoogleChat: receiver.GoogleChatConfigs, + Jira: receiver.JiraConfigs, + JSMOps: receiver.JSMOpsConfigs, } } @@ -187,6 +191,8 @@ func extendedReceivers(c *config.Config, customConfigs map[string]customReceiver receivers[i] = &Receiver{ Receiver: &base, GoogleChatConfigs: custom.GoogleChat, + JiraConfigs: custom.Jira, + JSMOpsConfigs: custom.JSMOps, } } @@ -362,6 +368,8 @@ func (c *Config) GetReceiver(name string) (*Receiver, error) { return &Receiver{ Receiver: &base, GoogleChatConfigs: custom.GoogleChat, + JiraConfigs: custom.Jira, + JSMOpsConfigs: custom.JSMOps, }, nil } } @@ -441,6 +449,16 @@ func (c *Config) applyNativeDefaults() { gc.HTTPConfig = httpDefault } } + for _, jc := range custom.Jira { + if jc.HTTPConfig == nil { + jc.HTTPConfig = httpDefault + } + } + for _, jc := range custom.JSMOps { + if jc.HTTPConfig == nil { + jc.HTTPConfig = httpDefault + } + } } } diff --git a/pkg/types/alertmanagertypes/jira.go b/pkg/types/alertmanagertypes/jira.go new file mode 100644 index 00000000000..08d9e558c1e --- /dev/null +++ b/pkg/types/alertmanagertypes/jira.go @@ -0,0 +1,119 @@ +package alertmanagertypes + +import ( + "fmt" + "net/url" + "strings" + "time" + + "github.com/SigNoz/signoz/pkg/errors" + "github.com/prometheus/alertmanager/config" + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" +) + +const defaultJiraReopenDuration = model.Duration(3 * 24 * time.Hour) + +// Service accounts authenticate against the api.atlassian.com gateway (keyed by +// cloud id) instead of the site host; they are identified by their email domain. +const ( + jiraCloudHostSuffix = ".atlassian.net" + jiraServiceAccountEmailDomain = "@serviceaccount.atlassian.com" + jiraGatewayBaseURL = "https://api.atlassian.com/ex/jira/" +) + +// Default templates for the issue title and body. The body is rendered to +// markdown and then wrapped in the ADF status panel + deep-links by the notifier. +const ( + DefaultJiraSummaryTemplate = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}` + + DefaultJiraDescriptionTemplate = `{{ range .Alerts -}} +**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }} +{{ if .Annotations.summary }} +**Summary:** {{ .Annotations.summary }} +{{ end }}{{ if .Annotations.description }} +**Description:** {{ .Annotations.description }} +{{ end }} +{{ end }}` +) + +// JiraReceiverConfig is the SigNoz Jira receiver. Fields are declared explicitly +// instead of embedding upstream config.JiraConfig because that type's own +// UnmarshalYAML would reset our defaults and drop sibling fields on the yaml +// round-trip. Only Jira Cloud (v3/ADF) is supported, so api_url is derived from Site. +type JiraReceiverConfig struct { + config.NotifierConfig `yaml:",inline"` + + Site string `json:"site,omitempty" yaml:"site,omitempty"` + Project string `json:"project,omitempty" yaml:"project,omitempty"` + IssueType string `json:"issue_type,omitempty" yaml:"issue_type,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Priority string `json:"priority,omitempty" yaml:"priority,omitempty"` + Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"` + ResolveTransition string `json:"resolve_transition,omitempty" yaml:"resolve_transition,omitempty"` + ReopenTransition string `json:"reopen_transition,omitempty" yaml:"reopen_transition,omitempty"` + ReopenDuration model.Duration `json:"reopen_duration" yaml:"reopen_duration"` + WontFixResolution string `json:"wont_fix_resolution,omitempty" yaml:"wont_fix_resolution,omitempty"` + CustomFields map[string]any `json:"custom_fields,omitempty" yaml:"custom_fields,omitempty"` + HTTPConfig *commoncfg.HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty"` +} + +func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error { + type plain JiraReceiverConfig + if err := unmarshal((*plain)(c)); err != nil { + return err + } + + if c.ReopenDuration <= 0 { + c.ReopenDuration = defaultJiraReopenDuration + } + // sub-minute windows truncate to 0 in the reopen JQL and silently disable + // reopening, so reject them. + if c.ReopenDuration < model.Duration(time.Minute) { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira reopen_duration must be at least 1m") + } + if c.Summary == "" { + c.Summary = DefaultJiraSummaryTemplate + } + if c.Description == "" { + c.Description = DefaultJiraDescriptionTemplate + } + + site := strings.TrimRight(strings.TrimSpace(c.Site), "/") + u, err := url.Parse(site) + if site == "" || err != nil || u.Scheme != "https" || !strings.HasSuffix(strings.ToLower(u.Hostname()), jiraCloudHostSuffix) { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("jira site must be a Jira Cloud URL (https://%s)", jiraCloudHostSuffix)) + } + c.Site = site + + if c.Project == "" { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira project is required") + } + if c.IssueType == "" { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira issue_type is required") + } + if c.HTTPConfig == nil || c.HTTPConfig.BasicAuth == nil { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira requires basic auth (email + API token)") + } + return nil +} + +// IsServiceAccount reports whether the basic-auth user is an Atlassian service +// account, identified by its email domain. Service accounts must go through the +// api.atlassian.com gateway; personal API tokens use the site host directly. +func (c *JiraReceiverConfig) IsServiceAccount() bool { + if c.HTTPConfig == nil || c.HTTPConfig.BasicAuth == nil { + return false + } + return strings.HasSuffix(strings.ToLower(c.HTTPConfig.BasicAuth.Username), jiraServiceAccountEmailDomain) +} + +// APIBaseURL returns the Jira Cloud REST v3 base URL: the api.atlassian.com +// gateway when a cloud id is given (service accounts), else the site host. +func (c *JiraReceiverConfig) APIBaseURL(cloudID string) string { + if cloudID != "" { + return fmt.Sprintf("%s%s/rest/api/3", jiraGatewayBaseURL, cloudID) + } + return fmt.Sprintf("%s/rest/api/3", strings.TrimRight(c.Site, "/")) +} diff --git a/pkg/types/alertmanagertypes/jira_test.go b/pkg/types/alertmanagertypes/jira_test.go new file mode 100644 index 00000000000..d3cf95b065b --- /dev/null +++ b/pkg/types/alertmanagertypes/jira_test.go @@ -0,0 +1,119 @@ +package alertmanagertypes + +import ( + "fmt" + "testing" + "time" + + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func jiraReceiverJSON(site, project, issueType string, withAuth bool) string { + auth := "" + if withAuth { + auth = `,"http_config":{"basic_auth":{"username":"me@acme.com","password":"token"}}` + } + return fmt.Sprintf( + `{"name":"jira","jira_configs":[{"site":%q,"project":%q,"issue_type":%q%s}]}`, + site, project, issueType, auth, + ) +} + +func TestJiraReceiverConfigDefaults(t *testing.T) { + r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", true)) + require.NoError(t, err) + require.Len(t, r.JiraConfigs, 1) + + jc := r.JiraConfigs[0] + assert.Equal(t, "https://acme.atlassian.net", jc.Site) + assert.Equal(t, "https://acme.atlassian.net/rest/api/3", jc.APIBaseURL("")) + assert.False(t, jc.SendResolved()) // default off when omitted, like other channels + assert.Equal(t, defaultJiraReopenDuration, jc.ReopenDuration) + assert.Equal(t, DefaultJiraSummaryTemplate, jc.Summary) + assert.Equal(t, DefaultJiraDescriptionTemplate, jc.Description) + + ch, err := NewChannelFromReceiver(r, "org-1") + require.NoError(t, err) + assert.Equal(t, "jira", ch.Type) +} + +func TestJiraReceiverConfigSendResolved(t *testing.T) { + withSendResolved := func(v bool) string { + return fmt.Sprintf( + `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","send_resolved":%t,"http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`, + v, + ) + } + on, err := NewReceiver(withSendResolved(true)) + require.NoError(t, err) + assert.True(t, on.JiraConfigs[0].SendResolved()) + + off, err := NewReceiver(withSendResolved(false)) + require.NoError(t, err) + assert.False(t, off.JiraConfigs[0].SendResolved()) +} + +func TestJiraReceiverConfigReopenDurationMinimum(t *testing.T) { + withReopen := func(v string) string { + return fmt.Sprintf( + `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":%q,"http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`, + v, + ) + } + r, err := NewReceiver(withReopen("1m")) + require.NoError(t, err) + assert.Equal(t, model.Duration(time.Minute), r.JiraConfigs[0].ReopenDuration) + + _, err = NewReceiver(withReopen("30s")) + assert.Error(t, err) +} + +func TestJiraAPIBaseURL(t *testing.T) { + c := &JiraReceiverConfig{Site: "https://acme.atlassian.net"} + assert.Equal(t, "https://acme.atlassian.net/rest/api/3", c.APIBaseURL("")) + assert.Equal(t, "https://api.atlassian.com/ex/jira/09851b38-1a40-4c01-a36a-0a9336293200/rest/api/3", c.APIBaseURL("09851b38-1a40-4c01-a36a-0a9336293200")) +} + +func TestJiraIsServiceAccount(t *testing.T) { + withUser := func(username string) *JiraReceiverConfig { + return &JiraReceiverConfig{HTTPConfig: &commoncfg.HTTPClientConfig{BasicAuth: &commoncfg.BasicAuth{Username: username}}} + } + assert.True(t, withUser("bot@serviceaccount.atlassian.com").IsServiceAccount()) + assert.True(t, withUser("Bot@ServiceAccount.Atlassian.Com").IsServiceAccount()) + assert.False(t, withUser("temp@signoz.io").IsServiceAccount()) + assert.False(t, (&JiraReceiverConfig{}).IsServiceAccount()) +} + +func TestJiraReceiverConfigTrailingSlashSite(t *testing.T) { + r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net/", "KAN", "Task", true)) + require.NoError(t, err) + assert.Equal(t, "https://acme.atlassian.net", r.JiraConfigs[0].Site) + assert.Equal(t, "https://acme.atlassian.net/rest/api/3", r.JiraConfigs[0].APIBaseURL("")) +} + +func TestJiraReceiverConfigValidation(t *testing.T) { + cases := []struct { + name string + json string + }{ + {"missing site", `{"name":"j","jira_configs":[{"project":"KAN","issue_type":"Task","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`}, + {"http site", jiraReceiverJSON("http://acme.atlassian.net", "KAN", "Task", true)}, + {"non-cloud host", jiraReceiverJSON("https://jira.acme.com", "KAN", "Task", true)}, + {"lookalike host suffix", jiraReceiverJSON("https://www.iamnotatlassian.net", "KAN", "Task", true)}, + {"bare atlassian.net", jiraReceiverJSON("https://atlassian.net", "KAN", "Task", true)}, + {"missing project", jiraReceiverJSON("https://acme.atlassian.net", "", "Task", true)}, + {"missing issue_type", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "", true)}, + {"missing basic auth", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", false)}, + {"invalid reopen_duration format", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"3days","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`}, + {"sub-minute reopen_duration", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"30s","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := NewReceiver(c.json) + assert.Error(t, err) + }) + } +} diff --git a/pkg/types/alertmanagertypes/jsmops.go b/pkg/types/alertmanagertypes/jsmops.go new file mode 100644 index 00000000000..50d72acad4a --- /dev/null +++ b/pkg/types/alertmanagertypes/jsmops.go @@ -0,0 +1,75 @@ +package alertmanagertypes + +import ( + "github.com/SigNoz/signoz/pkg/errors" + "github.com/prometheus/alertmanager/config" + commoncfg "github.com/prometheus/common/config" +) + +// JSMOpsAPIBaseURL is the native JSM Ops integration-events gateway. It is a +// single global host keyed by the integration API key (no region/cloud id in +// the path). The trailing slash is required: the Opsgenie notifier appends +// "v2/alerts..." to APIURL.Path with no separator. +const JSMOpsAPIBaseURL = "https://api.atlassian.com/jsm/ops/integration/" + +// JSM Ops speaks the Opsgenie alert API, so a JSM alert description takes the +// same HTML subset and 15,000-char limit; message caps at 130. The templates +// mirror Google Chat / Jira for a consistent default across channels. +const ( + DefaultJSMOpsMessageTemplate = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}` + + DefaultJSMOpsDescriptionTemplate = `{{ range .Alerts -}} +**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }} + +{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }} + +{{ end }}{{ if .Annotations.description }}**Description:** {{ .Annotations.description }} + +{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }}) + +{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }}) + +{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }}) + +{{ end }}{{ end }}` +) + +// JSMOpsReceiverConfig is the SigNoz Jira Service Management Ops receiver. It is +// delivered by reusing the Opsgenie notifier (JSM Ops is the ex-Opsgenie alert +// API): the notifier package maps these fields onto config.OpsGenieConfig with +// APIURL pinned to JSMOpsAPIBaseURL. +type JSMOpsReceiverConfig struct { + config.NotifierConfig `yaml:",inline" json:",inline"` + + HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` + + APIKey config.Secret `yaml:"api_key,omitempty" json:"api_key,omitempty"` + Message string `yaml:"message,omitempty" json:"message,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + Priority string `yaml:"priority,omitempty" json:"priority,omitempty"` + Tags string `yaml:"tags,omitempty" json:"tags,omitempty"` +} + +// send_resolved has no omitempty upstream, so a var default here is overwritten +// by the yaml round-trip to the request value (false when omitted); the UI sends +// it explicitly, defaulted on, so JSM alerts close on resolve. +var DefaultJSMOpsReceiverConfig = JSMOpsReceiverConfig{ + NotifierConfig: config.NotifierConfig{ + VSendResolved: false, + }, + Message: DefaultJSMOpsMessageTemplate, + Description: DefaultJSMOpsDescriptionTemplate, + Tags: "signoz", +} + +func (c *JSMOpsReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error { + *c = DefaultJSMOpsReceiverConfig + type plain JSMOpsReceiverConfig + if err := unmarshal((*plain)(c)); err != nil { + return err + } + if c.APIKey == "" { + return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jsm ops api_key is required") + } + return nil +} diff --git a/pkg/types/alertmanagertypes/jsmops_test.go b/pkg/types/alertmanagertypes/jsmops_test.go new file mode 100644 index 00000000000..3f105e8e262 --- /dev/null +++ b/pkg/types/alertmanagertypes/jsmops_test.go @@ -0,0 +1,70 @@ +package alertmanagertypes + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSMOpsReceiverConfigDefaults(t *testing.T) { + r, err := NewReceiver(`{"name":"jsm","jsmops_configs":[{"api_key":"key-123"}]}`) + require.NoError(t, err) + require.Len(t, r.JSMOpsConfigs, 1) + + c := r.JSMOpsConfigs[0] + assert.Equal(t, "key-123", string(c.APIKey)) + assert.Equal(t, DefaultJSMOpsMessageTemplate, c.Message) + assert.Equal(t, DefaultJSMOpsDescriptionTemplate, c.Description) + assert.Equal(t, "signoz", c.Tags) + assert.False(t, c.SendResolved()) // default off when omitted, like other channels + + ch, err := NewChannelFromReceiver(r, "org-1") + require.NoError(t, err) + assert.Equal(t, "jsmops", ch.Type) +} + +func TestJSMOpsReceiverConfigOverrides(t *testing.T) { + r, err := NewReceiver(`{"name":"jsm","jsmops_configs":[{"api_key":"k","message":"m","description":"d","priority":"P1","tags":"a,b","send_resolved":true}]}`) + require.NoError(t, err) + require.Len(t, r.JSMOpsConfigs, 1) + + c := r.JSMOpsConfigs[0] + assert.Equal(t, "m", c.Message) + assert.Equal(t, "d", c.Description) + assert.Equal(t, "P1", c.Priority) + assert.Equal(t, "a,b", c.Tags) + assert.True(t, c.SendResolved()) +} + +func TestJSMOpsReceiverConfigSendResolved(t *testing.T) { + withSendResolved := func(v bool) string { + return fmt.Sprintf(`{"name":"jsm","jsmops_configs":[{"api_key":"k","send_resolved":%t}]}`, v) + } + on, err := NewReceiver(withSendResolved(true)) + require.NoError(t, err) + require.Len(t, on.JSMOpsConfigs, 1) + assert.True(t, on.JSMOpsConfigs[0].SendResolved()) + + off, err := NewReceiver(withSendResolved(false)) + require.NoError(t, err) + require.Len(t, off.JSMOpsConfigs, 1) + assert.False(t, off.JSMOpsConfigs[0].SendResolved()) +} + +func TestJSMOpsReceiverConfigValidation(t *testing.T) { + cases := []struct { + name string + json string + }{ + {"missing api_key", `{"name":"jsm","jsmops_configs":[{"message":"m"}]}`}, + {"empty api_key", `{"name":"jsm","jsmops_configs":[{"api_key":""}]}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := NewReceiver(c.json) + assert.Error(t, err) + }) + } +} diff --git a/pkg/types/alertmanagertypes/receiver.go b/pkg/types/alertmanagertypes/receiver.go index 695545b9000..d6752a08c89 100644 --- a/pkg/types/alertmanagertypes/receiver.go +++ b/pkg/types/alertmanagertypes/receiver.go @@ -23,6 +23,11 @@ import ( type Receiver struct { *config.Receiver GoogleChatConfigs []*GoogleChatReceiverConfig `json:"googlechat_configs,omitempty" yaml:"googlechat_configs,omitempty"` + // Shadows upstream's jira_configs so our custom notifier (rich ADF, deep-links, + // lifecycle comments) handles it instead of upstream's plain Jira notifier. + JiraConfigs []*JiraReceiverConfig `json:"jira_configs,omitempty" yaml:"jira_configs,omitempty"` + // JSM Ops (ex-Opsgenie alert API); delivered by reusing the Opsgenie notifier. + JSMOpsConfigs []*JSMOpsReceiverConfig `json:"jsmops_configs,omitempty" yaml:"jsmops_configs,omitempty"` } // NewReceiver builds a Receiver from its JSON input, applying each notifier @@ -51,6 +56,22 @@ func NewReceiver(input string) (*Receiver, error) { receiver.GoogleChatConfigs[i] = defaulted } + for i, jc := range receiver.JiraConfigs { + defaulted, err := defaultedNotifierConfig(jc) + if err != nil { + return nil, err + } + receiver.JiraConfigs[i] = defaulted + } + + for i, jc := range receiver.JSMOpsConfigs { + defaulted, err := defaultedNotifierConfig(jc) + if err != nil { + return nil, err + } + receiver.JSMOpsConfigs[i] = defaulted + } + return receiver, nil } diff --git a/tests/fixtures/alerts.py b/tests/fixtures/alerts.py index 5a38188e3e4..abe9b783888 100644 --- a/tests/fixtures/alerts.py +++ b/tests/fixtures/alerts.py @@ -143,6 +143,16 @@ def wait_for_firing_timeline_entry(signoz: types.SigNoz, token: str, rule_id: st raise AssertionError(f"No firing entry recorded in rule state history within {wait_seconds}s, items: {items}") +def get_rule(signoz: types.SigNoz, token: str, rule_id: str) -> dict: + response = requests.get( + signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}"), + headers={"Authorization": f"Bearer {token}"}, + timeout=5, + ) + assert response.status_code == HTTPStatus.OK, f"Failed to get rule, api returned {response.status_code} with response: {response.text}" + return response.json()["data"] + + def get_rule_history_top_contributors(signoz: types.SigNoz, token: str, rule_id: str, start_ms: int, end_ms: int) -> list[dict]: response = requests.get( signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}/history/top_contributors"), diff --git a/tests/integration/testdata/alerts/test_scenarios/disabled_rule/alert_data.jsonl b/tests/integration/testdata/alerts/test_scenarios/disabled_rule/alert_data.jsonl new file mode 100644 index 00000000000..6a3ff353c1f --- /dev/null +++ b/tests/integration/testdata/alerts/test_scenarios/disabled_rule/alert_data.jsonl @@ -0,0 +1,12 @@ +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:01:00+00:00","value":1,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:02:00+00:00","value":2,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:03:00+00:00","value":4,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:04:00+00:00","value":4,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:05:00+00:00","value":15,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:06:00+00:00","value":10,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:07:00+00:00","value":36,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:08:00+00:00","value":25,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:09:00+00:00","value":37,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:10:00+00:00","value":35,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:11:00+00:00","value":39,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} +{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:12:00+00:00","value":25,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}} diff --git a/tests/integration/testdata/alerts/test_scenarios/disabled_rule/rule.json b/tests/integration/testdata/alerts/test_scenarios/disabled_rule/rule.json new file mode 100644 index 00000000000..59aaebd4140 --- /dev/null +++ b/tests/integration/testdata/alerts/test_scenarios/disabled_rule/rule.json @@ -0,0 +1,59 @@ +{ + "alert": "disabled_rule", + "ruleType": "threshold_rule", + "alertType": "METRIC_BASED_ALERT", + "disabled": true, + "condition": { + "thresholds": { + "kind": "basic", + "spec": [ + { + "name": "critical", + "target": 10, + "matchType": "at_least_once", + "op": "above", + "channels": [ + "test channel" + ] + } + ] + }, + "compositeQuery": { + "queryType": "clickhouse_sql", + "panelType": "graph", + "queries": [ + { + "type": "clickhouse_sql", + "spec": { + "name": "A", + "query": "WITH __temporal_aggregation_cte AS (\n SELECT \n fingerprint, \n toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, \n avg(value) AS per_series_value \n FROM signoz_metrics.distributed_samples_v4 AS points \n INNER JOIN (\n SELECT fingerprint \n FROM signoz_metrics.time_series_v4 \n WHERE metric_name IN ('request_total_disabled_rule') \n AND LOWER(temporality) LIKE LOWER('cumulative') \n GROUP BY fingerprint\n ) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint \n WHERE metric_name IN ('request_total_disabled_rule') \n AND unix_milli >= $start_timestamp_ms \n AND unix_milli < $end_timestamp_ms \n GROUP BY fingerprint, ts \n ORDER BY fingerprint, ts\n), \n__spatial_aggregation_cte AS (\n SELECT \n ts, \n sum(per_series_value) AS value \n FROM __temporal_aggregation_cte \n WHERE isNaN(per_series_value) = 0 \n GROUP BY ts\n) \nSELECT * FROM __spatial_aggregation_cte \nORDER BY ts" + } + } + ] + }, + "selectedQueryName": "A" + }, + "evaluation": { + "kind": "rolling", + "spec": { + "evalWindow": "5m0s", + "frequency": "15s" + } + }, + "labels": {}, + "annotations": { + "description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})", + "summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})" + }, + "notificationSettings": { + "groupBy": [], + "usePolicy": false, + "renotify": { + "enabled": false, + "interval": "30m", + "alertStates": [] + } + }, + "version": "v5", + "schemaVersion": "v2alpha1" +} diff --git a/tests/integration/tests/alerts/05_disabled_rule.py b/tests/integration/tests/alerts/05_disabled_rule.py new file mode 100644 index 00000000000..4a293730bd2 --- /dev/null +++ b/tests/integration/tests/alerts/05_disabled_rule.py @@ -0,0 +1,109 @@ +import json +import time +import uuid +from collections.abc import Callable +from datetime import UTC, datetime, timedelta + +from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse + +from fixtures import types +from fixtures.alerts import ( + collect_webhook_firing_alerts, + get_rule, + update_rule_channel_name, +) +from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD +from fixtures.fs import get_testdata_file_path +from fixtures.logger import setup_logger + +logger = setup_logger(__name__) + +# The rule evaluates every 15s and the alert data is set up to fire on the +# first evaluation, so a buggy evaluator would transition the rule and fire +# well within this window. +OBSERVATION_WINDOW_SECONDS = 35 + + +def test_disabled_rule_does_not_evaluate_or_notify( + signoz: types.SigNoz, + # Notification channel related fixtures + notification_channel: types.TestContainerDocker, + make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None], + create_webhook_notification_channel: Callable[[str, str, dict, bool], str], + # Alert rule related fixtures + create_alert_rule: Callable[[dict], str], + # Alert data insertion related fixtures + insert_alert_data: Callable[[list[types.AlertData], datetime], None], + get_token: Callable[[str, str], str], +): + """ + A rule created with disabled: true must not be evaluated: its state must + stay "disabled" and it must not send any notification, even though the + inserted data would fire the rule if it were evaluated. The companion + scenario threshold_above_at_least_once in 02_basic_alert_conditions.py + uses the same data shape and fires when the rule is enabled. + """ + token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD) + + # Prepare notification channel name and webhook endpoint + notification_channel_name = str(uuid.uuid4()) + webhook_endpoint_path = f"/alert/{notification_channel_name}" + notification_url = notification_channel.container_configs["8080"].get(webhook_endpoint_path) + + # register the mock endpoint in notification channel + make_http_mocks( + notification_channel, + [ + Mapping( + request=MappingRequest( + method=HttpMethods.POST, + url=webhook_endpoint_path, + ), + response=MappingResponse( + status=200, + json_body={}, + ), + persistent=False, + ) + ], + ) + + # Create an alert channel using the given route + create_webhook_notification_channel( + channel_name=notification_channel_name, + webhook_url=notification_url, + http_config={}, + send_resolved=False, + ) + + # Insert alert data that would fire the rule if it were evaluated + insert_alert_data( + [types.AlertData(type="metrics", data_path="alerts/test_scenarios/disabled_rule/alert_data.jsonl")], + base_time=datetime.now(tz=UTC) - timedelta(minutes=5), + ) + + # Create the disabled alert rule + rule_path = get_testdata_file_path("alerts/test_scenarios/disabled_rule/rule.json") + with open(rule_path, encoding="utf-8") as f: + rule_data = json.loads(f.read()) + update_rule_channel_name(rule_data, notification_channel_name) + rule_id = create_alert_rule(rule_data) + logger.info( + "disabled rule created with id: %s", + {"rule_id": rule_id, "rule_name": rule_data["alert"]}, + ) + + # The rule must stay disabled and must not fire for the whole observation + # window; poll to give a buggy evaluator several chances to run. + deadline = time.time() + OBSERVATION_WINDOW_SECONDS + while time.time() < deadline: + rule = get_rule(signoz, token, rule_id) + assert rule["state"] == "disabled", f"disabled rule transitioned to unexpected state: {rule['state']}" + assert rule["disabled"] is True, "disabled rule was unexpectedly re-enabled" + + firing_alerts = collect_webhook_firing_alerts(notification_channel, notification_channel_name) + assert len(firing_alerts) == 0, f"disabled rule fired alerts: {[alert.labels for alert in firing_alerts]}" + + time.sleep(2) + + logger.info("disabled rule stayed disabled and sent no notifications, as expected")