mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-09 03:30:55 +01:00
Compare commits
6 Commits
feat/googl
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67f883a8a8 | ||
|
|
fe8aebc15a | ||
|
|
0ae98b05c5 | ||
|
|
7c76ba8d9e | ||
|
|
104758f3cf | ||
|
|
504be0dfd7 |
@@ -21,6 +21,7 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip/TextToolTip';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
@@ -29,7 +30,6 @@ import { capitalize, isEmpty } from 'lodash-es';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import TruncatedTooltip from './TruncatedTooltip';
|
||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
ALL_SELECTED_VALUE,
|
||||
@@ -729,71 +729,65 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<TruncatedTooltip
|
||||
<div
|
||||
key={option.value || `option-${index}`}
|
||||
title={String(option.label || '')}
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onKeyDown={(e): void => {
|
||||
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => {
|
||||
setActiveIndex(index ?? -1);
|
||||
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{(textRef): JSX.Element => (
|
||||
<div
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onKeyDown={(e): void => {
|
||||
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => {
|
||||
setActiveIndex(index ?? -1);
|
||||
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<Checkbox
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
<Checkbox
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
<div className="option-content">
|
||||
<Typography.Text truncate={1} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</Typography.Text>
|
||||
{(option.type === 'custom' || option.type === 'regex') && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
{option.value && ensureValidOption(option.value) && (
|
||||
<Button
|
||||
type="text"
|
||||
className="only-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'option')}
|
||||
>
|
||||
{currentToggleTagValue({ option: option.value })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
className="toggle-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
<div className="option-content">
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
{(option.type === 'custom' || option.type === 'regex') && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
{option.value && ensureValidOption(option.value) && (
|
||||
<Button
|
||||
type="text"
|
||||
className="only-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'option')}
|
||||
>
|
||||
{currentToggleTagValue({ option: option.value })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
className="toggle-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</Checkbox>
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
@@ -26,7 +25,6 @@ import { capitalize, isEmpty } from 'lodash-es';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import TruncatedTooltip from './TruncatedTooltip';
|
||||
import { CustomSelectProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
@@ -209,46 +207,41 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const optionId = `option-${index}`;
|
||||
|
||||
return (
|
||||
<TruncatedTooltip key={option.value} title={String(option.label || '')}>
|
||||
{(textRef): JSX.Element => (
|
||||
<div
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSelection();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
e.preventDefault();
|
||||
handleSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<div className="option-content">
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
{option.type === 'custom' && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
<div
|
||||
key={option.value}
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSelection();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
e.preventDefault();
|
||||
handleSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<div className="option-content">
|
||||
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
|
||||
{option.type === 'custom' && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[highlightMatchedText, searchText, onChange, activeOptionIndex],
|
||||
@@ -709,67 +702,50 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
// ===== Final Processing =====
|
||||
|
||||
// Apply highlight to matched text in options
|
||||
const optionsWithHighlight = useMemo(() => {
|
||||
const matching =
|
||||
options?.filter((option) =>
|
||||
String(option.label || '')
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()),
|
||||
) ?? [];
|
||||
|
||||
// antd shows a value with no matching option as a bare string, so keep the
|
||||
// selected value listed to render it through the tooltip label below.
|
||||
return (
|
||||
isEmpty(value)
|
||||
? matching
|
||||
: prioritizeOrAddOptionForSingleSelect(matching, value)
|
||||
).map((option) => ({
|
||||
...option,
|
||||
// Rendered as the selected value in the closed control.
|
||||
label: (
|
||||
<TruncatedTooltip title={String(option.label || '')} side="bottom">
|
||||
{(textRef): JSX.Element => (
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
),
|
||||
}));
|
||||
}, [options, searchText, value, highlightMatchedText]);
|
||||
const optionsWithHighlight = useMemo(
|
||||
() =>
|
||||
options
|
||||
?.filter((option) =>
|
||||
String(option.label || '')
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()),
|
||||
)
|
||||
?.map((option) => ({
|
||||
...option,
|
||||
label: highlightMatchedText(String(option.label || ''), searchText),
|
||||
})),
|
||||
[options, searchText, highlightMatchedText],
|
||||
);
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
// Self-provided so label tooltips work wherever this select is rendered.
|
||||
<TooltipProvider>
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// Above the antd select popup it is shown from.
|
||||
.tooltipContent {
|
||||
--tooltip-z-index: 2100;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { ReactElement, RefObject, useRef, useState } from 'react';
|
||||
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './TruncatedTooltip.module.scss';
|
||||
|
||||
interface TruncatedTooltipProps {
|
||||
title: string;
|
||||
side?: TooltipSimpleProps['side'];
|
||||
/** Renders the trigger; attach the ref to the element that may truncate. */
|
||||
children: (textRef: RefObject<HTMLSpanElement>) => ReactElement;
|
||||
}
|
||||
|
||||
function TruncatedTooltip({
|
||||
title,
|
||||
side = 'right',
|
||||
children,
|
||||
}: TruncatedTooltipProps): JSX.Element {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = (next: boolean): void => {
|
||||
const el = textRef.current;
|
||||
setOpen(next && !!el && el.scrollWidth > el.clientWidth);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipSimple
|
||||
title={title}
|
||||
side={side}
|
||||
arrow
|
||||
delayDuration={300}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
tooltipContentProps={{ className: styles.tooltipContent }}
|
||||
>
|
||||
{children(textRef)}
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default TruncatedTooltip;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import CustomSelect from '../CustomSelect';
|
||||
|
||||
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
|
||||
|
||||
describe('CustomSelect selected value tooltip', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(400);
|
||||
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(200);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['listed in the options', [{ label: LABEL, value: LABEL }]],
|
||||
['missing from the options', [{ label: 'other', value: 'other' }]],
|
||||
])('reveals a truncated selected value %s on hover', async (_, options) => {
|
||||
render(<CustomSelect options={options} value={LABEL} />);
|
||||
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await user.hover(screen.getByText(LABEL));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import TruncatedTooltip from '../TruncatedTooltip';
|
||||
|
||||
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
|
||||
|
||||
function renderRow(scrollWidth: number, clientWidth: number): void {
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
|
||||
.mockReturnValue(scrollWidth);
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
||||
.mockReturnValue(clientWidth);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<TruncatedTooltip title={LABEL}>
|
||||
{(textRef): JSX.Element => (
|
||||
<div data-testid="row">
|
||||
<span ref={textRef}>{LABEL}</span>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function hover(element: HTMLElement): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await user.hover(element);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
}
|
||||
|
||||
describe('TruncatedTooltip', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reveals truncated text when its row is hovered', async () => {
|
||||
renderRow(400, 200);
|
||||
|
||||
await hover(screen.getByTestId('row'));
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
|
||||
});
|
||||
|
||||
it('shows no tooltip when the text fits', async () => {
|
||||
renderRow(200, 200);
|
||||
|
||||
await hover(screen.getByTestId('row'));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -135,51 +135,6 @@ const LONG_LABEL_OPTION = {
|
||||
value: 'checkout-payment-authorisation',
|
||||
};
|
||||
|
||||
/** Truncation: hovering a cut-off option reveals its full label beside the row. */
|
||||
export const TruncatedOption: Story = {
|
||||
args: { options: [LONG_LABEL_OPTION, ...options] },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await userEvent.hover(
|
||||
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
|
||||
);
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/** Truncation: hovering a cut-off selected value reveals it in full. */
|
||||
export const TruncatedValue: Story = {
|
||||
args: {
|
||||
options: [LONG_LABEL_OPTION, ...options],
|
||||
value: LONG_LABEL_OPTION.value,
|
||||
},
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.hover(await screen.findByText(LONG_LABEL_OPTION.label));
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/** Truncation: a multi-select option reveals its full label the same way. */
|
||||
export const MultiTruncatedOption: Story = {
|
||||
render: (): JSX.Element => (
|
||||
<CustomMultiSelect
|
||||
aria-label="Services"
|
||||
options={[LONG_LABEL_OPTION, ...options]}
|
||||
/>
|
||||
),
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Services' }),
|
||||
);
|
||||
await userEvent.hover(
|
||||
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
|
||||
);
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the select renders, held open: the selected chip revealing the
|
||||
* option label it was cut from. Nothing bounds that label, so the chip is given
|
||||
|
||||
@@ -29,13 +29,6 @@ $custom-border-color: #2c3044;
|
||||
background-color: var(--l2-background);
|
||||
color: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
// The search input overlays the selected value; lift the value above it while
|
||||
// closed so hovers reach its tooltip. Clicks still open the select and focus the input.
|
||||
&:not(.ant-select-open) .ant-select-selection-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep chip styles ONLY in the multi-select
|
||||
@@ -230,33 +223,6 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
|
||||
// Ellipsized option label; also the selected value in the closed single select.
|
||||
.option-label-text {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// overscroll-behavior only holds on a box that overflows; a 1px sentinel past
|
||||
// the bottom makes even a short list overflow, so the wheel never scrolls the page.
|
||||
.custom-select-dropdown,
|
||||
.custom-multiselect-dropdown {
|
||||
position: relative;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom dropdown styles for single select
|
||||
.custom-select-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
|
||||
@@ -27,31 +27,14 @@ import AnalyticsPanel from '../SpanDetailsPanel/AnalyticsPanel/AnalyticsPanel';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import MissingSpansBanner from './MissingSpansBanner';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
import { useTraceSummary } from './useTraceSummary';
|
||||
|
||||
import styles from './TraceDetailsHeader.module.scss';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
interface FilterMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export interface TraceMetadataForHeader {
|
||||
startTimestampMillis: number;
|
||||
endTimestampMillis: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
rootSpanStatusCode: string;
|
||||
hasMissingSpans: boolean;
|
||||
totalSpansCount: number;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
filterMetadata: FilterMetadata;
|
||||
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
isDataLoaded?: boolean;
|
||||
traceMetadata?: TraceMetadataForHeader;
|
||||
showTraceDetailsHeaderOptions?: boolean;
|
||||
}
|
||||
|
||||
const SKELETON_COUNT = 3;
|
||||
@@ -73,16 +56,15 @@ function DetailsLoader(): JSX.Element {
|
||||
}
|
||||
|
||||
function TraceDetailsHeader({
|
||||
filterMetadata,
|
||||
onFilteredSpansChange,
|
||||
isDataLoaded,
|
||||
traceMetadata,
|
||||
showTraceDetailsHeaderOptions,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV3URLProps>();
|
||||
const [showTraceDetails, setShowTraceDetails] = useState(true);
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
const { data: traceSummary } = useTraceSummary(traceID || '');
|
||||
const previewFields = useTraceStore((s) => s.previewFields);
|
||||
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
|
||||
|
||||
@@ -116,8 +98,11 @@ function TraceDetailsHeader({
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const durationMs = traceMetadata
|
||||
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
|
||||
const startTime = (traceSummary?.startTimestampMillis ?? 0) / 1e3;
|
||||
const endTime = (traceSummary?.endTimestampMillis ?? 0) / 1e3;
|
||||
|
||||
const durationMs = traceSummary
|
||||
? traceSummary.endTimestampMillis - traceSummary.startTimestampMillis
|
||||
: 0;
|
||||
|
||||
return (
|
||||
@@ -142,7 +127,7 @@ function TraceDetailsHeader({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isDataLoaded && (
|
||||
{showTraceDetailsHeaderOptions && traceSummary && (
|
||||
<div
|
||||
className={cx(
|
||||
styles.filterSection,
|
||||
@@ -171,9 +156,9 @@ function TraceDetailsHeader({
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
traceId={traceID || ''}
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
totalSpansCount={traceMetadata?.totalSpansCount || 0}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
totalSpansCount={traceSummary.totalSpansCount}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -183,9 +168,9 @@ function TraceDetailsHeader({
|
||||
className={cx(styles.filter, isFilterExpanded && styles.isExpanded)}
|
||||
>
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
traceID={filterMetadata.traceId}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
traceID={traceID || ''}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
isExpanded={isFilterExpanded}
|
||||
onExpand={(): void => setIsFilterExpanded(true)}
|
||||
@@ -198,18 +183,18 @@ function TraceDetailsHeader({
|
||||
|
||||
{showTraceDetails && (
|
||||
<div className={styles.subHeader}>
|
||||
{traceMetadata ? (
|
||||
{traceSummary ? (
|
||||
<EntityMetadataRow
|
||||
entity="trace"
|
||||
service={{
|
||||
name: traceMetadata.rootServiceName,
|
||||
entryPoint: traceMetadata.rootServiceEntryPoint,
|
||||
name: traceSummary.rootServiceName,
|
||||
entryPoint: traceSummary.rootServiceEntryPoint,
|
||||
}}
|
||||
durationMs={durationMs}
|
||||
timestamp={dayjs(traceMetadata.startTimestampMillis).format(
|
||||
timestamp={dayjs(traceSummary.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
statusCode={traceMetadata.rootSpanStatusCode}
|
||||
statusCode={traceSummary.rootSpanStatusCode}
|
||||
/>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
@@ -217,7 +202,7 @@ function TraceDetailsHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{traceMetadata?.hasMissingSpans && <MissingSpansBanner />}
|
||||
{traceSummary?.hasMissingSpans && <MissingSpansBanner />}
|
||||
|
||||
<FieldsSelector
|
||||
isOpen={isPreviewFieldsOpen}
|
||||
|
||||
@@ -5,6 +5,11 @@ import ROUTES from 'constants/routes';
|
||||
import { render } from 'tests/test-utils';
|
||||
|
||||
import TraceDetailsHeader from '../TraceDetailsHeader';
|
||||
import { useTraceSummary } from '../useTraceSummary';
|
||||
|
||||
jest.mock('../useTraceSummary', () => ({
|
||||
useTraceSummary: jest.fn(() => ({ data: undefined, isLoading: false })),
|
||||
}));
|
||||
|
||||
const mockGoBack = jest.fn();
|
||||
const mockPush = jest.fn();
|
||||
@@ -51,13 +56,19 @@ jest.mock('components/FieldsSelector', () => ({
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
filterMetadata: {
|
||||
startTime: 0,
|
||||
endTime: 1,
|
||||
traceId: 'trace-123',
|
||||
},
|
||||
onFilteredSpansChange: jest.fn(),
|
||||
isDataLoaded: false,
|
||||
showTraceDetailsHeaderOptions: false,
|
||||
};
|
||||
|
||||
const SUMMARY = {
|
||||
startTimestampMillis: 1_700_000_000_000,
|
||||
endTimestampMillis: 1_700_000_120_000,
|
||||
rootServiceName: 'frontend',
|
||||
rootServiceEntryPoint: 'GET /checkout',
|
||||
rootSpanStatusCode: '200',
|
||||
hasMissingSpans: false,
|
||||
totalSpansCount: 3,
|
||||
totalErrorSpansCount: 0,
|
||||
};
|
||||
|
||||
describe('TraceDetailsHeader – back button', () => {
|
||||
@@ -92,10 +103,32 @@ describe('TraceDetailsHeader – back button', () => {
|
||||
describe('TraceDetailsHeader – action cluster', () => {
|
||||
beforeEach(() => {
|
||||
mockReplace.mockClear();
|
||||
jest
|
||||
.mocked(useTraceSummary)
|
||||
.mockReturnValue({ data: SUMMARY, isLoading: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest
|
||||
.mocked(useTraceSummary)
|
||||
.mockReturnValue({ data: undefined, isLoading: false });
|
||||
});
|
||||
|
||||
it('does not render the action buttons until the summary loads', () => {
|
||||
jest
|
||||
.mocked(useTraceSummary)
|
||||
.mockReturnValue({ data: undefined, isLoading: true });
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the action buttons while data is still loading', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
|
||||
render(
|
||||
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions={false} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^analytics$/i }),
|
||||
@@ -106,7 +139,7 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
it('renders Analytics and Settings action buttons once data is loaded', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /^analytics$/i }),
|
||||
@@ -117,7 +150,7 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
const panel = screen.getByTestId('analytics-panel');
|
||||
expect(panel).toHaveAttribute('data-open', 'false');
|
||||
@@ -133,7 +166,7 @@ describe('TraceDetailsHeader – action cluster', () => {
|
||||
});
|
||||
|
||||
describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
// Plain prop, no API mock needed: traceMetadata is passed straight in.
|
||||
// useTraceSummary is mocked, so no API call is made.
|
||||
const traceMetadata = {
|
||||
startTimestampMillis: 1_700_000_000_000,
|
||||
endTimestampMillis: 1_700_000_120_000, // +120000ms = 2 min
|
||||
@@ -142,16 +175,20 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
rootSpanStatusCode: '404',
|
||||
hasMissingSpans: false,
|
||||
totalSpansCount: 42,
|
||||
totalErrorSpansCount: 0,
|
||||
};
|
||||
|
||||
const mockSummary = (data?: typeof traceMetadata): void => {
|
||||
jest.mocked(useTraceSummary).mockReturnValue({ data, isLoading: false });
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mockSummary(undefined);
|
||||
});
|
||||
|
||||
it('renders the metadata (service, entry point, duration, status) when provided', () => {
|
||||
render(
|
||||
<TraceDetailsHeader
|
||||
{...baseProps}
|
||||
isDataLoaded
|
||||
traceMetadata={traceMetadata}
|
||||
/>,
|
||||
);
|
||||
mockSummary(traceMetadata);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
expect(screen.getByText('large-trace-root')).toBeInTheDocument();
|
||||
@@ -166,13 +203,8 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
|
||||
it('is shown by default and can be hidden / shown again via the Trace options menu', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(
|
||||
<TraceDetailsHeader
|
||||
{...baseProps}
|
||||
isDataLoaded
|
||||
traceMetadata={traceMetadata}
|
||||
/>,
|
||||
);
|
||||
mockSummary(traceMetadata);
|
||||
render(<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />);
|
||||
|
||||
// Visible by default (showTraceDetails defaults to true).
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
@@ -192,9 +224,12 @@ describe('TraceDetailsHeader – trace metadata row', () => {
|
||||
expect(screen.getByText(/inventory-frontend/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the metadata row when traceMetadata is absent', () => {
|
||||
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
|
||||
it('shows skeletons instead of the metadata when the summary is absent', () => {
|
||||
const { container } = render(
|
||||
<TraceDetailsHeader {...baseProps} showTraceDetailsHeaderOptions />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/inventory-frontend/)).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.ant-skeleton-input')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
|
||||
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
interface UseTraceSummaryResult {
|
||||
data: SpantypesGettableTraceSummaryDTO | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useTraceSummary(traceId: string): UseTraceSummaryResult {
|
||||
const { data, isLoading } = useGetTraceSummary(
|
||||
{ traceID: traceId },
|
||||
{ query: { enabled: !!traceId, keepPreviousData: true } },
|
||||
);
|
||||
|
||||
return { data: data?.data, isLoading };
|
||||
}
|
||||
@@ -28,7 +28,6 @@ import TraceStoreSync from './stores/TraceStoreSync';
|
||||
import { useTraceStore } from './stores/traceStore';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
||||
@@ -323,38 +322,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
[],
|
||||
);
|
||||
|
||||
const filterMetadata = useMemo(
|
||||
() => ({
|
||||
startTime: (traceData?.payload?.startTimestampMillis || 0) / 1e3,
|
||||
endTime: (traceData?.payload?.endTimestampMillis || 0) / 1e3,
|
||||
traceId: traceId || '',
|
||||
}),
|
||||
[
|
||||
traceData?.payload?.startTimestampMillis,
|
||||
traceData?.payload?.endTimestampMillis,
|
||||
traceId,
|
||||
],
|
||||
);
|
||||
|
||||
const traceMetadataForHeader = useMemo(():
|
||||
| TraceMetadataForHeader
|
||||
| undefined => {
|
||||
const payload = traceData?.payload;
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
const rootSpan = payload.spans?.find((s) => s.level === 0);
|
||||
return {
|
||||
startTimestampMillis: payload.startTimestampMillis,
|
||||
endTimestampMillis: payload.endTimestampMillis,
|
||||
rootServiceName: payload.rootServiceName,
|
||||
rootServiceEntryPoint: payload.rootServiceEntryPoint,
|
||||
rootSpanStatusCode: rootSpan?.response_status_code || '',
|
||||
hasMissingSpans: payload.hasMissingSpans || false,
|
||||
totalSpansCount: payload.totalSpansCount || 0,
|
||||
};
|
||||
}, [traceData?.payload]);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
@@ -393,10 +360,10 @@ function TraceDetailsV3(): JSX.Element {
|
||||
<TraceStoreSync availableColorByFields={availableColorByFields}>
|
||||
<div className={styles.root}>
|
||||
<TraceDetailsHeader
|
||||
filterMetadata={filterMetadata}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
|
||||
traceMetadata={traceMetadataForHeader}
|
||||
showTraceDetailsHeaderOptions={
|
||||
!!traceData?.payload?.spans?.length && !showNoData
|
||||
}
|
||||
/>
|
||||
|
||||
{showNoData ? (
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
traceDetailFieldKeys,
|
||||
traceDetailFieldValues,
|
||||
traceFlamegraphResponse,
|
||||
traceSummaryResponse,
|
||||
traceWaterfallResponse,
|
||||
} from './__story_mockdata__/traceDetails';
|
||||
|
||||
@@ -149,6 +150,13 @@ export const traceDetailsMocks = defineStoryMocks({
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/traces/:traceId/summary',
|
||||
response.json(() =>
|
||||
traceSummaryResponse({ ...trace, missingSpans: values.missingSpans }),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v3/traces/:traceId/flamegraph',
|
||||
response.json(() => traceFlamegraphResponse(trace)),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import type {
|
||||
GetFlamegraph200,
|
||||
GetTraceAggregations200,
|
||||
GetTraceSummary200,
|
||||
GetWaterfallV4200,
|
||||
SpantypesFlamegraphSpanDTO,
|
||||
SpantypesSpanAggregationDTO,
|
||||
@@ -303,6 +304,27 @@ export const traceWaterfallResponse = (
|
||||
};
|
||||
};
|
||||
|
||||
export const traceSummaryResponse = (
|
||||
options: TraceOptions & { missingSpans: boolean },
|
||||
): GetTraceSummary200 => {
|
||||
const spans = buildSpans(options);
|
||||
const root = spans[0];
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
startTimestampMillis: Math.round(options.traceStart),
|
||||
endTimestampMillis: Math.round(options.traceStart + ROOT_DURATION_MS),
|
||||
rootServiceName: root?.template.service ?? '',
|
||||
rootServiceEntryPoint: root?.template.name ?? '',
|
||||
rootSpanStatusCode: root?.hasError ? '503' : '200',
|
||||
totalSpansCount: spans.length,
|
||||
totalErrorSpansCount: spans.filter(({ hasError }) => hasError).length,
|
||||
hasMissingSpans: options.missingSpans,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const flamegraphSpan = (span: BuiltSpan): SpantypesFlamegraphSpanDTO => ({
|
||||
spanId: span.spanId,
|
||||
parentSpanId: span.parentSpanId,
|
||||
|
||||
41
tests/fixtures/alerts.py
vendored
41
tests/fixtures/alerts.py
vendored
@@ -392,40 +392,20 @@ def verify_webhook_notification_expectation(
|
||||
notification_channel: types.TestContainerDocker,
|
||||
validation_data: dict,
|
||||
) -> bool:
|
||||
"""Check that wiremock received the expected request(s) at the given path.
|
||||
|
||||
validation_data supports (all optional except one of path/path_pattern):
|
||||
- path: request url path (matched as urlPath, so query strings are ignored)
|
||||
- path_pattern: url path regex instead of path, for paths that embed a
|
||||
dynamic segment (e.g. a group-hash alias)
|
||||
- json_body: expected JSON subset of the request body
|
||||
- count: exact number of requests required at the path
|
||||
- min_count: minimum number of requests required (e.g. retries)
|
||||
The body constraint must be satisfied by a single request; count constraints
|
||||
apply to the total at the path."""
|
||||
path = validation_data.get("path")
|
||||
json_body = validation_data.get("json_body")
|
||||
# urlPath ignores query strings; real webhook urls may carry their own (e.g. key/token).
|
||||
matcher = {"method": "POST", "urlPath": path} if path is not None else {"method": "POST", "urlPathPattern": validation_data["path_pattern"]}
|
||||
"""Check if wiremock received a request at the given path
|
||||
whose JSON body is a superset of the expected json_body."""
|
||||
path = validation_data["path"]
|
||||
json_body = validation_data["json_body"]
|
||||
|
||||
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
|
||||
try:
|
||||
res = requests.post(url, json=matcher, timeout=10)
|
||||
res = requests.post(url, json={"method": "POST", "url": path}, timeout=10)
|
||||
except requests.exceptions.RequestException:
|
||||
return False
|
||||
if res.status_code != HTTPStatus.OK:
|
||||
return False
|
||||
|
||||
reqs = res.json()["requests"]
|
||||
if "count" in validation_data and len(reqs) != validation_data["count"]:
|
||||
return False
|
||||
if "min_count" in validation_data and len(reqs) < validation_data["min_count"]:
|
||||
return False
|
||||
|
||||
if json_body is None:
|
||||
return True
|
||||
|
||||
for req in reqs:
|
||||
for req in res.json()["requests"]:
|
||||
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
|
||||
if _is_json_subset(json_body, body):
|
||||
return True
|
||||
@@ -488,10 +468,8 @@ def _received_notifications(
|
||||
if validation.destination_type != "webhook":
|
||||
continue
|
||||
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
|
||||
path = validation.validation_data.get("path")
|
||||
matcher = {"method": "POST", "urlPath": path} if path is not None else {"method": "POST", "urlPathPattern": validation.validation_data["path_pattern"]}
|
||||
try:
|
||||
res = requests.post(url, json=matcher, timeout=10)
|
||||
res = requests.post(url, json={"method": "POST", "url": validation.validation_data["path"]}, timeout=10)
|
||||
webhook_bodies.extend(json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8")) for req in res.json()["requests"])
|
||||
except requests.exceptions.RequestException as exc:
|
||||
webhook_bodies.append(f"<failed to fetch wiremock journal: {exc}>")
|
||||
@@ -520,8 +498,6 @@ def update_raw_channel_config(
|
||||
"webhook_configs": "url",
|
||||
"pagerduty_configs": "url",
|
||||
"opsgenie_configs": "api_url",
|
||||
"googlechat_configs": "webhook_url",
|
||||
"incidentio_configs": "url",
|
||||
}
|
||||
|
||||
for config_key, url_field in url_field_map.items():
|
||||
@@ -532,7 +508,4 @@ def update_raw_channel_config(
|
||||
path = urlparse(original_url).path
|
||||
entry[url_field] = notification_channel.container_configs["8080"].get(path)
|
||||
|
||||
for entry in config.get("jira_configs", []):
|
||||
entry["site"] = notification_channel.container_configs["8080"].base()
|
||||
|
||||
return config
|
||||
|
||||
431
tests/fixtures/notification_channel.py
vendored
431
tests/fixtures/notification_channel.py
vendored
@@ -1,12 +1,9 @@
|
||||
# pylint: disable=line-too-long
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
|
||||
import docker
|
||||
import docker.errors
|
||||
@@ -14,28 +11,15 @@ import pytest
|
||||
import requests
|
||||
from sqlalchemy import sql
|
||||
from testcontainers.core.container import Network
|
||||
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
from wiremock.testing.testcontainer import WireMockContainer
|
||||
|
||||
from fixtures import reuse, types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.maildev import MAILDEV_INCOMING_PASS, SMTP_TEST_FROM
|
||||
from fixtures.tls import CA_ID_LABEL, KEYSTORE_PASSWORD, ca_id, issue_server_keystore
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
# The JSM Ops and Jira service-account gateways are hardcoded in signoz, so the
|
||||
# WireMock container joins the network under this alias and serves HTTPS on 443
|
||||
# with a certificate issued by the integration CA that signoz trusts.
|
||||
ATLASSIAN_API_HOST = "api.atlassian.com"
|
||||
TLS_HOSTS = [ATLASSIAN_API_HOST]
|
||||
|
||||
# A reused container serving a cert without a newly added host (or missing its
|
||||
# network alias) fails TLS opaquely; this label records the hosts it was built
|
||||
# for so stale() recreates it when the list changes.
|
||||
TLS_HOSTS_LABEL = "signoz.integration.tls-hosts"
|
||||
|
||||
|
||||
EMAIL_TRANSPORT_KEYS = [
|
||||
"from",
|
||||
@@ -170,364 +154,9 @@ email_default_config = {
|
||||
}
|
||||
|
||||
|
||||
def googlechat_config(space: str) -> dict:
|
||||
"""Google Chat channel config for a per-test WireMock space path. Title/text are
|
||||
omitted so the backend applies its default templates. The host is injected at
|
||||
runtime by update_raw_channel_config."""
|
||||
return {
|
||||
"googlechat_configs": [
|
||||
{
|
||||
"webhook_url": f"/v1/spaces/{space}/messages", # host set on runtime
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def googlechat_ok_mappings(path: str) -> list[Mapping]:
|
||||
return [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=path),
|
||||
response=MappingResponse(status=200, json_body={"name": "spaces/x/messages/x"}),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def googlechat_retry_mappings(path: str) -> list[Mapping]:
|
||||
"""429 on the first call then 200, via a wiremock scenario transition."""
|
||||
scenario = f"gc-retry-{path}"
|
||||
return [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=path),
|
||||
response=MappingResponse(status=429, json_body={"error": {"code": 429, "status": "RESOURCE_EXHAUSTED"}}),
|
||||
scenario_name=scenario,
|
||||
required_scenario_state="Started",
|
||||
new_scenario_state="ok",
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=path),
|
||||
response=MappingResponse(status=200, json_body={"name": "spaces/x/messages/x"}),
|
||||
scenario_name=scenario,
|
||||
required_scenario_state="ok",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def googlechat_card_subset(alertname: str, buttons: list[tuple[str, str]]) -> dict:
|
||||
"""A cardsV2 subset asserting title, firing banner, rendered body, and each
|
||||
button's text AND deep-link url (as a regex), so a broken link is caught too.
|
||||
buttons: list of (text, url_regex)."""
|
||||
return {
|
||||
"text": f"[FIRING:1] {alertname}",
|
||||
"cardsV2": [
|
||||
{
|
||||
"cardId": "signoz-alert",
|
||||
"card": {
|
||||
"header": {"title": f"[FIRING:1] {alertname}"},
|
||||
"sections": [
|
||||
# firing banner
|
||||
{"widgets": [{"textParagraph": {"text": re.compile("FIRING")}}]},
|
||||
# rendered alert body mentions the alertname
|
||||
{"widgets": [{"textParagraph": {"text": re.compile(re.escape(alertname))}}]},
|
||||
]
|
||||
+ [{"widgets": [{"buttonList": {"buttons": [{"text": text, "onClick": {"openLink": {"url": re.compile(url)}}}]}}]} for text, url in buttons],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
INCIDENTIO_TEST_TOKEN = "incidentio-test-token" # noqa: S105
|
||||
|
||||
|
||||
def incidentio_path(source_id: str) -> str:
|
||||
return f"/v2/alert_events/http/{source_id}"
|
||||
|
||||
|
||||
def incidentio_config(source_id: str) -> dict:
|
||||
"""incident.io channel config for a per-test alert source id. Title/description
|
||||
are omitted so the backend applies its default templates. The host is injected
|
||||
at runtime by update_raw_channel_config."""
|
||||
return {
|
||||
"incidentio_configs": [
|
||||
{
|
||||
"url": incidentio_path(source_id), # host set on runtime
|
||||
"token": INCIDENTIO_TEST_TOKEN,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# recorded incident.io Alert Events V2 responses: 202 accepted-for-processing
|
||||
# echoing the dedup key; errors are {type, status, errors: [{code, message}]}
|
||||
def incidentio_ok_mappings(path: str) -> list[Mapping]:
|
||||
return [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=path),
|
||||
response=MappingResponse(status=202, json_body={"status": "accepted", "message": "Event accepted for processing", "deduplication_key": "x"}),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def incidentio_retry_mappings(path: str) -> list[Mapping]:
|
||||
"""429 on the first call then 202, via a wiremock scenario transition."""
|
||||
scenario = f"incidentio-retry-{path}"
|
||||
return [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=path),
|
||||
response=MappingResponse(status=429, json_body={"type": "rate_limit_error", "status": 429}),
|
||||
scenario_name=scenario,
|
||||
required_scenario_state="Started",
|
||||
new_scenario_state="ok",
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=path),
|
||||
response=MappingResponse(status=202, json_body={"status": "accepted", "message": "Event accepted for processing", "deduplication_key": "x"}),
|
||||
scenario_name=scenario,
|
||||
required_scenario_state="ok",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def incidentio_event_subset(alertname: str, links: list[tuple[str, str]]) -> dict:
|
||||
"""An alert-event subset asserting title, firing status, dedup key, SigNoz
|
||||
source_url, metadata labels, and each markdown link's text AND url (as a
|
||||
regex), so a broken link is caught too. links: (text, url_regex) pairs in
|
||||
default-template order (View in SigNoz -> related logs -> related traces)."""
|
||||
description = "(?s)" + re.escape(f"**Alert:** {alertname}")
|
||||
for text, url in links:
|
||||
description += rf".*\[{re.escape(text)}\]\([^)]*{url}"
|
||||
return {
|
||||
"title": f"[FIRING:1] {alertname}",
|
||||
"status": "firing",
|
||||
"deduplication_key": re.compile(r".+"),
|
||||
"source_url": re.compile(r"/alerts/overview\?ruleId="),
|
||||
"description": re.compile(description),
|
||||
"metadata": {"alertname": alertname},
|
||||
}
|
||||
|
||||
|
||||
JIRA_TEST_EMAIL = "user@acme.io"
|
||||
JIRA_SA_EMAIL = "svc@serviceaccount.atlassian.com"
|
||||
JIRA_TEST_TOKEN = "jira-test-token" # noqa: S105
|
||||
JIRA_API_BASE = "/rest/api/3"
|
||||
|
||||
|
||||
def jira_config(**overrides) -> dict:
|
||||
"""Jira channel config with personal API token auth. Summary/description are
|
||||
omitted so the backend applies its default templates; overrides lay extra
|
||||
receiver fields on top. The site is injected at runtime by
|
||||
update_raw_channel_config."""
|
||||
return {
|
||||
"jira_configs": [
|
||||
{
|
||||
"site": "", # set on runtime
|
||||
"project": "OPS",
|
||||
"issue_type": "Task",
|
||||
"http_config": {"basic_auth": {"username": JIRA_TEST_EMAIL, "password": JIRA_TEST_TOKEN}},
|
||||
**overrides,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def jira_search_issue(key: str, done: bool, labels: list[str]) -> dict:
|
||||
"""One issue as returned by the /search/jql stub, with the fields the
|
||||
notifier requests (status category + labels)."""
|
||||
return {
|
||||
"key": key,
|
||||
"fields": {"status": {"statusCategory": {"key": "done" if done else "indeterminate"}}, "labels": labels},
|
||||
}
|
||||
|
||||
|
||||
# Jira flows span several endpoints; each mapping helper stubs one, on any base
|
||||
# (site host for personal tokens, /ex/jira/<cloud_id> gateway for service accounts).
|
||||
def jira_search_mapping(issues: list[dict], base: str = JIRA_API_BASE) -> Mapping:
|
||||
return Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{base}/search/jql"),
|
||||
response=MappingResponse(status=200, json_body={"issues": issues}),
|
||||
)
|
||||
|
||||
|
||||
def jira_create_mapping(key: str = "OPS-1", base: str = JIRA_API_BASE) -> Mapping:
|
||||
return Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{base}/issue"),
|
||||
response=MappingResponse(status=201, json_body={"id": "10001", "key": key}),
|
||||
)
|
||||
|
||||
|
||||
def jira_update_mapping(key: str, base: str = JIRA_API_BASE) -> Mapping:
|
||||
return Mapping(
|
||||
request=MappingRequest(method=HttpMethods.PUT, url_path=f"{base}/issue/{key}"),
|
||||
response=MappingResponse(status=204),
|
||||
)
|
||||
|
||||
|
||||
def jira_transitions_mapping(key: str, transitions: list[dict], base: str = JIRA_API_BASE) -> Mapping:
|
||||
return Mapping(
|
||||
request=MappingRequest(method=HttpMethods.GET, url_path=f"{base}/issue/{key}/transitions"),
|
||||
response=MappingResponse(status=200, json_body={"transitions": transitions}),
|
||||
)
|
||||
|
||||
|
||||
def jira_transition_post_mapping(key: str, base: str = JIRA_API_BASE) -> Mapping:
|
||||
return Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{base}/issue/{key}/transitions"),
|
||||
response=MappingResponse(status=204),
|
||||
)
|
||||
|
||||
|
||||
def jira_comment_mapping(key: str, base: str = JIRA_API_BASE) -> Mapping:
|
||||
return Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{base}/issue/{key}/comment"),
|
||||
response=MappingResponse(status=201, json_body={"id": "1"}),
|
||||
)
|
||||
|
||||
|
||||
def jira_retry_search_mappings() -> list[Mapping]:
|
||||
"""429 on the first search then 200-empty, via a wiremock scenario transition."""
|
||||
scenario = "jira-retry-search"
|
||||
return [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JIRA_API_BASE}/search/jql"),
|
||||
response=MappingResponse(status=429, json_body={"errorMessages": ["Rate limit exceeded"]}),
|
||||
scenario_name=scenario,
|
||||
required_scenario_state="Started",
|
||||
new_scenario_state="ok",
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JIRA_API_BASE}/search/jql"),
|
||||
response=MappingResponse(status=200, json_body={"issues": []}),
|
||||
scenario_name=scenario,
|
||||
required_scenario_state="ok",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def find_requests(notification_channel: types.TestContainerDocker, method: str, path: str | None = None, path_pattern: str | None = None) -> list[dict]:
|
||||
"""The wiremock journal entries for method+path (query strings ignored);
|
||||
path_pattern matches the path as a regex instead, for paths that embed a
|
||||
dynamic segment like the group-hash alias."""
|
||||
matcher = {"method": method, "urlPath": path} if path is not None else {"method": method, "urlPathPattern": path_pattern}
|
||||
find = requests.post(
|
||||
notification_channel.host_configs["8080"].get("/__admin/requests/find"),
|
||||
json=matcher,
|
||||
timeout=10,
|
||||
)
|
||||
return find.json()["requests"]
|
||||
|
||||
|
||||
JSMOPS_TEST_API_KEY = "jsmops-test-api-key" # noqa: S105
|
||||
# The JSM Ops gateway lives on api.atlassian.com (already aliased for Jira
|
||||
# service accounts); the notifier appends v2/alerts... to this base.
|
||||
JSMOPS_API_BASE = "/jsm/ops/integration"
|
||||
JSMOPS_NOTES_PATH_PATTERN = f"{JSMOPS_API_BASE}/v2/alerts/[a-f0-9]+/notes"
|
||||
|
||||
|
||||
def jsmops_config(**overrides) -> dict:
|
||||
"""JSM Ops channel config. Message/description/tags are omitted so the
|
||||
backend applies its defaults; overrides lay extra receiver fields on top."""
|
||||
return {
|
||||
"jsmops_configs": [
|
||||
{
|
||||
"api_key": JSMOPS_TEST_API_KEY,
|
||||
**overrides,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def jsmops_create_mapping() -> Mapping:
|
||||
return Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JSMOPS_API_BASE}/v2/alerts"),
|
||||
response=MappingResponse(status=202, json_body={"result": "Request will be processed", "took": 0.005, "requestId": "1b1f0000-0000-4000-8000-000000000001"}),
|
||||
)
|
||||
|
||||
|
||||
def jsmops_notes_mapping(status: int = 202, body: dict | None = None) -> Mapping:
|
||||
return Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path_pattern=JSMOPS_NOTES_PATH_PATTERN),
|
||||
response=MappingResponse(status=status, json_body=body or {"result": "Request will be processed", "took": 0.002, "requestId": "1b1f0000-0000-4000-8000-000000000002"}),
|
||||
)
|
||||
|
||||
|
||||
def jsmops_retry_create_mappings() -> list[Mapping]:
|
||||
"""429 on the first create then 202, via a wiremock scenario transition."""
|
||||
scenario = "jsmops-retry-create"
|
||||
return [
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JSMOPS_API_BASE}/v2/alerts"),
|
||||
response=MappingResponse(status=429, json_body={"message": "You are making too many requests!", "took": 0.001, "requestId": "x"}),
|
||||
scenario_name=scenario,
|
||||
required_scenario_state="Started",
|
||||
new_scenario_state="ok",
|
||||
),
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JSMOPS_API_BASE}/v2/alerts"),
|
||||
response=MappingResponse(status=202, json_body={"result": "Request will be processed", "took": 0.005, "requestId": "x"}),
|
||||
scenario_name=scenario,
|
||||
required_scenario_state="ok",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def jsmops_alert_subset(alertname: str, links: list[tuple[str, str]]) -> dict:
|
||||
"""A created-alert subset asserting message, alias, source, default tags,
|
||||
details labels, and the HTML description: the rendered bold Alert run plus
|
||||
each link's anchor (href as a regex), so a broken link is caught too.
|
||||
links: (text, url_regex) pairs in default-template order."""
|
||||
description = "(?s)" + re.escape("<strong>Alert:</strong>")
|
||||
for text, url in links:
|
||||
description += rf'.*<a href="[^"]*{url}[^"]*"[^>]*>{re.escape(text)}</a>'
|
||||
return {
|
||||
"alias": re.compile(r".+"),
|
||||
"message": f"[FIRING:1] {alertname}",
|
||||
"source": "SigNoz",
|
||||
"tags": ["signoz"],
|
||||
"details": {"alertname": alertname},
|
||||
"description": re.compile(description),
|
||||
}
|
||||
|
||||
|
||||
def jira_issue_subset(alertname: str, links: list[tuple[str, str]]) -> dict:
|
||||
"""A created-issue subset asserting summary, group labels, ADF status panel,
|
||||
the rendered alert text, and each deep-link's text AND url (as a regex), so
|
||||
a broken link is caught too. links: (text, url_regex) pairs."""
|
||||
# the ADF renderer splits text nodes at underscores, so the alertname never
|
||||
# sits in one node; the summary pins it exactly, the body asserts the
|
||||
# rendered "Alert:" strong run followed by the name's first fragment
|
||||
description_content = [
|
||||
{"type": "panel", "attrs": {"panelType": "error"}},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{"type": "text", "text": "Alert:", "marks": [{"type": "strong"}]},
|
||||
{"type": "text", "text": re.compile(re.escape(alertname.split("_", maxsplit=1)[0]))},
|
||||
],
|
||||
},
|
||||
]
|
||||
if links:
|
||||
description_content.append(
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [{"type": "text", "text": text, "marks": [{"type": "link", "attrs": {"href": re.compile(url)}}]} for text, url in links],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"fields": {
|
||||
"project": {"key": "OPS"},
|
||||
"issuetype": {"name": "Task"},
|
||||
"summary": f"[FIRING:1] {alertname}",
|
||||
"labels": ["signoz-alert", re.compile(r"ALERT\{")],
|
||||
"description": {"type": "doc", "version": 1, "content": description_content},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="notification_channel", scope="package")
|
||||
def notification_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
def notification_channel(
|
||||
network: Network,
|
||||
tls: types.TLS,
|
||||
tmpfs: Callable[[str], Path],
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.TestContainerDocker:
|
||||
@@ -536,25 +165,9 @@ def notification_channel( # pylint: disable=too-many-arguments,too-many-positio
|
||||
"""
|
||||
|
||||
def create() -> types.TestContainerDocker:
|
||||
# http:8080 for admin API + plain webhook delivery; https:443 aliased as
|
||||
# api.atlassian.com with a CA-issued cert so the hardcoded Atlassian
|
||||
# gateways route here over real TLS (signoz trusts the integration CA).
|
||||
keystore_path = issue_server_keystore(tls, tmpfs("notification-channel-certs"), *TLS_HOSTS)
|
||||
|
||||
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
|
||||
container.with_volume_mapping(str(keystore_path.parent), "/certs", "ro")
|
||||
container.with_network(network)
|
||||
container.with_network_aliases(*TLS_HOSTS)
|
||||
container.with_kwargs(labels={CA_ID_LABEL: ca_id(tls), TLS_HOSTS_LABEL: ",".join(TLS_HOSTS)})
|
||||
|
||||
try:
|
||||
container.start(f"--port 8080 --https-port 443 --https-keystore /certs/keystore.p12 --keystore-type PKCS12 --keystore-password {KEYSTORE_PASSWORD}")
|
||||
except Exception:
|
||||
# Ryuk is disabled: a started-but-unready container would survive and
|
||||
# keep squatting on the api.atlassian.com alias, poisoning DNS for
|
||||
# any replacement on the shared network.
|
||||
container.stop()
|
||||
raise
|
||||
container.start()
|
||||
|
||||
return types.TestContainerDocker(
|
||||
id=container.get_wrapped_container().id,
|
||||
@@ -565,9 +178,7 @@ def notification_channel( # pylint: disable=too-many-arguments,too-many-positio
|
||||
container.get_exposed_port(8080),
|
||||
)
|
||||
},
|
||||
container_configs={
|
||||
"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080),
|
||||
},
|
||||
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
|
||||
)
|
||||
|
||||
def delete(container: types.TestContainerDocker):
|
||||
@@ -584,16 +195,6 @@ def notification_channel( # pylint: disable=too-many-arguments,too-many-positio
|
||||
def restore(cache: dict) -> types.TestContainerDocker:
|
||||
return types.TestContainerDocker.from_cache(cache)
|
||||
|
||||
def stale(container: types.TestContainerDocker) -> bool:
|
||||
# A container built against a rotated/absent CA can't serve a cert signoz
|
||||
# trusts; recreate it instead of failing TLS opaquely.
|
||||
client = docker.from_env()
|
||||
try:
|
||||
labels = client.containers.get(container_id=container.id).attrs["Config"]["Labels"]
|
||||
except docker.errors.NotFound:
|
||||
return True
|
||||
return labels.get(CA_ID_LABEL) != ca_id(tls) or labels.get(TLS_HOSTS_LABEL) != ",".join(TLS_HOSTS)
|
||||
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
@@ -602,7 +203,6 @@ def notification_channel( # pylint: disable=too-many-arguments,too-many-positio
|
||||
create,
|
||||
delete,
|
||||
restore,
|
||||
stale=stale,
|
||||
)
|
||||
|
||||
|
||||
@@ -704,31 +304,6 @@ def create_webhook_notification_channel(
|
||||
return _create_webhook_notification_channel
|
||||
|
||||
|
||||
def wait_for_alertmanager_sync(signoz: types.SigNoz, token: str, notification_channel: types.TestContainerDocker, wait_seconds: int = 60) -> None:
|
||||
"""Polls until the org's alertmanager server is registered (one poll tick).
|
||||
|
||||
channels/test 404s until then, before reaching any notifier. The sentinel
|
||||
receiver posts to its own unstubbed wiremock path, so request journals
|
||||
asserted by tests stay clean."""
|
||||
sentinel = {
|
||||
"name": str(uuid.uuid4()),
|
||||
"webhook_configs": [{"url": notification_channel.container_configs["8080"].get("/org-registration-sentinel")}],
|
||||
}
|
||||
deadline = time.time() + wait_seconds
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
last = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=sentinel,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
if last.status_code != HTTPStatus.NOT_FOUND:
|
||||
return
|
||||
time.sleep(2)
|
||||
raise AssertionError(f"org alertmanager did not register within {wait_seconds}s, last response: {last.status_code} {last.text}")
|
||||
|
||||
|
||||
def send_test_notification(signoz: types.SigNoz, token: str, receiver: dict, wait_seconds: int = 90) -> None:
|
||||
deadline = time.time() + wait_seconds
|
||||
last = None
|
||||
|
||||
13
tests/fixtures/tls.py
vendored
13
tests/fixtures/tls.py
vendored
@@ -107,11 +107,10 @@ def tls(
|
||||
)
|
||||
|
||||
|
||||
def issue_server_keystore(tls: types.TLS, directory: Path, *hostnames: str) -> Path:
|
||||
def issue_server_keystore(tls: types.TLS, directory: Path, hostname: str) -> Path:
|
||||
"""Write a PKCS12 keystore (keystore.p12, password KEYSTORE_PASSWORD) into
|
||||
directory, holding a certificate for the hostnames (SANs, CN is the first)
|
||||
issued by the integration CA. Mount it into a mock container that must
|
||||
serve TLS as those hostnames."""
|
||||
directory, holding a certificate for hostname issued by the integration CA.
|
||||
Mount it into a mock container that must serve TLS as hostname."""
|
||||
ca_cert = x509.load_pem_x509_certificate(Path(tls.ca_cert_path).read_bytes())
|
||||
ca_key = serialization.load_pem_private_key(Path(tls.ca_key_path).read_bytes(), password=None)
|
||||
|
||||
@@ -119,13 +118,13 @@ def issue_server_keystore(tls: types.TLS, directory: Path, *hostnames: str) -> P
|
||||
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
leaf_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostnames[0])]))
|
||||
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]))
|
||||
.issuer_name(ca_cert.subject)
|
||||
.public_key(leaf_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - datetime.timedelta(days=1))
|
||||
.not_valid_after(now + datetime.timedelta(days=3650))
|
||||
.add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname) for hostname in hostnames]), critical=False)
|
||||
.add_extension(x509.SubjectAlternativeName([x509.DNSName(hostname)]), critical=False)
|
||||
.add_extension(x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
@@ -133,7 +132,7 @@ def issue_server_keystore(tls: types.TLS, directory: Path, *hostnames: str) -> P
|
||||
keystore_path = directory / "keystore.p12"
|
||||
keystore_path.write_bytes(
|
||||
pkcs12.serialize_key_and_certificates(
|
||||
name=hostnames[0].encode(),
|
||||
name=hostname.encode(),
|
||||
key=leaf_key,
|
||||
cert=leaf_cert,
|
||||
cas=[ca_cert],
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import (
|
||||
get_testdata_file_path,
|
||||
update_raw_channel_config,
|
||||
update_rule_channel_name,
|
||||
verify_notification_expectation,
|
||||
)
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.notification_channel import (
|
||||
googlechat_card_subset,
|
||||
googlechat_config,
|
||||
googlechat_ok_mappings,
|
||||
googlechat_retry_mappings,
|
||||
wait_for_alertmanager_sync,
|
||||
)
|
||||
|
||||
|
||||
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
|
||||
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
|
||||
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
|
||||
LOGS_RULE = "ruler/test_scenarios/threshold_below_at_least_once/rule.json"
|
||||
TRACES_DATA = "ruler/test_scenarios/threshold_above_average/alert_data.jsonl"
|
||||
TRACES_RULE = "ruler/test_scenarios/threshold_above_average/rule.json"
|
||||
|
||||
|
||||
GOOGLECHAT_CASES = [
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="googlechat_default_metrics_firing",
|
||||
rule_path=METRICS_RULE,
|
||||
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
|
||||
channel_config=googlechat_config("gc-metrics"),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/v1/spaces/gc-metrics/messages",
|
||||
"count": 1,
|
||||
"json_body": googlechat_card_subset("threshold_above_at_least_once", [("Open in SigNoz", r"/alerts/overview\?ruleId=")]),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="googlechat_rich_card_logs",
|
||||
rule_path=LOGS_RULE,
|
||||
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
|
||||
channel_config=googlechat_config("gc-logs"),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/v1/spaces/gc-logs/messages",
|
||||
"count": 1,
|
||||
"json_body": googlechat_card_subset(
|
||||
"threshold_below_at_least_once",
|
||||
[("View Related Logs", r"/logs/logs-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="googlechat_rich_card_traces",
|
||||
rule_path=TRACES_RULE,
|
||||
alert_data=[types.AlertData(type="traces", data_path=TRACES_DATA)],
|
||||
channel_config=googlechat_config("gc-traces"),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": "/v1/spaces/gc-traces/messages",
|
||||
"count": 1,
|
||||
"json_body": googlechat_card_subset(
|
||||
"threshold_above_average",
|
||||
[("View Related Traces", r"traces-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"gc_test_case",
|
||||
GOOGLECHAT_CASES,
|
||||
ids=lambda c: c.name,
|
||||
)
|
||||
def test_googlechat_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
gc_test_case: types.AlertManagerNotificationTestCase,
|
||||
) -> None:
|
||||
channel_name = str(uuid.uuid4())
|
||||
path = gc_test_case.notification_expectation.notification_validations[0].validation_data["path"]
|
||||
|
||||
channel_config = update_raw_channel_config(gc_test_case.channel_config, channel_name, notification_channel)
|
||||
|
||||
make_http_mocks(notification_channel, googlechat_ok_mappings(path))
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
|
||||
|
||||
insert_alert_data(gc_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
|
||||
|
||||
with open(get_testdata_file_path(gc_test_case.rule_path), encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, channel_name)
|
||||
create_alert_rule(rule_data)
|
||||
|
||||
verify_notification_expectation(notification_channel, maildev, gc_test_case.notification_expectation)
|
||||
|
||||
|
||||
def test_googlechat_retry_429_then_200( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
) -> None:
|
||||
channel_name = str(uuid.uuid4())
|
||||
path = "/v1/spaces/gc-retry/messages"
|
||||
|
||||
channel_config = update_raw_channel_config(googlechat_config("gc-retry"), channel_name, notification_channel)
|
||||
|
||||
make_http_mocks(notification_channel, googlechat_retry_mappings(path))
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
|
||||
|
||||
insert_alert_data([types.AlertData(type="metrics", data_path=METRICS_DATA)], base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
|
||||
|
||||
with open(get_testdata_file_path(METRICS_RULE), encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, channel_name)
|
||||
create_alert_rule(rule_data)
|
||||
|
||||
verify_notification_expectation(
|
||||
notification_channel,
|
||||
maildev,
|
||||
types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
# a retryable 429 is followed by a successful re-POST => >=2 hits
|
||||
"path": path,
|
||||
"min_count": 2,
|
||||
"json_body": {"cardsV2": [{"cardId": "signoz-alert"}]},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1,111 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from typing import NamedTuple
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import update_raw_channel_config
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.notification_channel import googlechat_config
|
||||
|
||||
|
||||
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
|
||||
# with a hardcoded test alert and no retry — the deterministic place to assert
|
||||
# permanent-failure behaviour. Rich cards + retry are covered in alertmanager/09_googlechat.py.
|
||||
class TestChannelCase(NamedTuple):
|
||||
__test__ = False
|
||||
name: str
|
||||
space: str
|
||||
status: int # stub status
|
||||
body: dict # stub body
|
||||
expect_delivered: bool # expect channels/test 204
|
||||
|
||||
|
||||
TEST_CHANNEL_CASES = [
|
||||
TestChannelCase("success", "gc-tc-ok", 200, {"name": "spaces/x/messages/x"}, True),
|
||||
TestChannelCase("permanent_400", "gc-tc-400", 400, {"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "Message cannot be empty."}}, False),
|
||||
TestChannelCase("permission_403", "gc-tc-403", 403, {"error": {"code": 403, "status": "PERMISSION_DENIED", "message": "Method doesn't allow unregistered callers"}}, False),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
TEST_CHANNEL_CASES,
|
||||
ids=lambda c: c.name,
|
||||
)
|
||||
def test_googlechat_test_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
case: TestChannelCase,
|
||||
) -> None:
|
||||
path = f"/v1/spaces/{case.space}/messages"
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=path),
|
||||
response=MappingResponse(status=case.status, json_body=case.body),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
channel_name = str(uuid.uuid4())
|
||||
receiver = update_raw_channel_config(googlechat_config(case.space), channel_name, notification_channel)
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# channels/test 404s until the org's alertmanager registers (one poll tick),
|
||||
# without reaching the notifier — so the first non-404 response is the single
|
||||
# authoritative delivery attempt and the count == 1 assertion below holds
|
||||
deadline = time.time() + 60
|
||||
while True:
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != HTTPStatus.NOT_FOUND or time.time() > deadline:
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
if case.expect_delivered:
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
|
||||
else:
|
||||
# a downstream 400/403 surfaces as a 500 (untyped notify error) whose body
|
||||
# carries the real downstream status code; pin it to distinguish 400 vs 403
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"expected 500, got {response.status_code}: {response.text}"
|
||||
assert f"unexpected status code {case.status}" in response.text, f"expected downstream {case.status} in error body: {response.text}"
|
||||
|
||||
# exactly one delivery attempt either way (testChannel never retries)
|
||||
count = requests.post(
|
||||
notification_channel.host_configs["8080"].get("/__admin/requests/count"),
|
||||
json={"method": "POST", "urlPath": path},
|
||||
timeout=10,
|
||||
)
|
||||
assert count.json()["count"] == 1, f"expected exactly 1 request (no retry), got {count.text}"
|
||||
|
||||
if case.expect_delivered:
|
||||
find = requests.post(
|
||||
notification_channel.host_configs["8080"].get("/__admin/requests/find"),
|
||||
json={"method": "POST", "urlPath": path},
|
||||
timeout=10,
|
||||
)
|
||||
req = find.json()["requests"][0]
|
||||
# the configured webhook url is posted verbatim, nothing appended
|
||||
assert req["url"] == path, f"expected webhook url {path} posted verbatim, got {req['url']}"
|
||||
# cardsV2 shape with the hardcoded test alert
|
||||
card = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
|
||||
assert card["cardsV2"][0]["cardId"] == "signoz-alert"
|
||||
assert re.search(r"Test Alert \(", card["cardsV2"][0]["card"]["header"]["title"])
|
||||
@@ -1,161 +0,0 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import (
|
||||
get_testdata_file_path,
|
||||
update_raw_channel_config,
|
||||
update_rule_channel_name,
|
||||
verify_notification_expectation,
|
||||
)
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.notification_channel import (
|
||||
incidentio_config,
|
||||
incidentio_event_subset,
|
||||
incidentio_ok_mappings,
|
||||
incidentio_path,
|
||||
incidentio_retry_mappings,
|
||||
wait_for_alertmanager_sync,
|
||||
)
|
||||
|
||||
|
||||
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
|
||||
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
|
||||
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
|
||||
LOGS_RULE = "ruler/test_scenarios/threshold_below_at_least_once/rule.json"
|
||||
|
||||
|
||||
INCIDENTIO_CASES = [
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="incidentio_default_metrics_firing",
|
||||
rule_path=METRICS_RULE,
|
||||
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
|
||||
channel_config=incidentio_config("inc-metrics"),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": incidentio_path("inc-metrics"),
|
||||
"count": 1,
|
||||
"json_body": incidentio_event_subset("threshold_above_at_least_once", [("View in SigNoz", r"/alerts/overview\?ruleId=")]),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="incidentio_rich_event_logs",
|
||||
rule_path=LOGS_RULE,
|
||||
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
|
||||
channel_config=incidentio_config("inc-logs"),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": incidentio_path("inc-logs"),
|
||||
"count": 1,
|
||||
"json_body": incidentio_event_subset(
|
||||
"threshold_below_at_least_once",
|
||||
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View related logs", r"/logs/logs-explorer\?")],
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"incidentio_test_case",
|
||||
INCIDENTIO_CASES,
|
||||
ids=lambda c: c.name,
|
||||
)
|
||||
def test_incidentio_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
incidentio_test_case: types.AlertManagerNotificationTestCase,
|
||||
) -> None:
|
||||
channel_name = str(uuid.uuid4())
|
||||
path = incidentio_test_case.notification_expectation.notification_validations[0].validation_data["path"]
|
||||
|
||||
channel_config = update_raw_channel_config(incidentio_test_case.channel_config, channel_name, notification_channel)
|
||||
|
||||
make_http_mocks(notification_channel, incidentio_ok_mappings(path))
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
|
||||
|
||||
insert_alert_data(incidentio_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
|
||||
|
||||
with open(get_testdata_file_path(incidentio_test_case.rule_path), encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, channel_name)
|
||||
create_alert_rule(rule_data)
|
||||
|
||||
verify_notification_expectation(notification_channel, maildev, incidentio_test_case.notification_expectation)
|
||||
|
||||
|
||||
def test_incidentio_retry_429_then_202( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
) -> None:
|
||||
channel_name = str(uuid.uuid4())
|
||||
path = incidentio_path("inc-retry")
|
||||
|
||||
channel_config = update_raw_channel_config(incidentio_config("inc-retry"), channel_name, notification_channel)
|
||||
|
||||
make_http_mocks(notification_channel, incidentio_retry_mappings(path))
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
|
||||
|
||||
insert_alert_data([types.AlertData(type="metrics", data_path=METRICS_DATA)], base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
|
||||
|
||||
with open(get_testdata_file_path(METRICS_RULE), encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, channel_name)
|
||||
create_alert_rule(rule_data)
|
||||
|
||||
verify_notification_expectation(
|
||||
notification_channel,
|
||||
maildev,
|
||||
types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
# a retryable 429 is followed by a successful re-POST => >=2 hits
|
||||
"path": path,
|
||||
"min_count": 2,
|
||||
"json_body": {"status": "firing"},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1,145 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from typing import NamedTuple
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import update_raw_channel_config
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.notification_channel import INCIDENTIO_TEST_TOKEN, incidentio_config, incidentio_path
|
||||
|
||||
|
||||
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
|
||||
# with a hardcoded test alert and no retry — the deterministic place to assert
|
||||
# permanent-failure behaviour. Rich events + retry are covered in alertmanager/11_incidentio.py.
|
||||
# Stub bodies are the recorded incident.io Alert Events V2 responses.
|
||||
class TestChannelCase(NamedTuple):
|
||||
__test__ = False
|
||||
name: str
|
||||
source: str
|
||||
status: int # stub status
|
||||
body: dict # stub body
|
||||
expect_delivered: bool # expect channels/test 204
|
||||
|
||||
|
||||
TEST_CHANNEL_CASES = [
|
||||
TestChannelCase("success", "inc-tc-ok", 202, {"status": "accepted", "message": "Event accepted for processing", "deduplication_key": "x"}, True),
|
||||
TestChannelCase("permanent_401", "inc-tc-401", 401, {"type": "authentication_error", "status": 401, "errors": [{"code": "invalid_authentication_material", "message": "Secret token not valid"}]}, False),
|
||||
TestChannelCase("permanent_422", "inc-tc-422", 422, {"type": "validation_error", "status": 422, "errors": [{"code": "missing_field", "message": '"title" is missing from body'}]}, False),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
TEST_CHANNEL_CASES,
|
||||
ids=lambda c: c.name,
|
||||
)
|
||||
def test_incidentio_test_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
case: TestChannelCase,
|
||||
) -> None:
|
||||
path = incidentio_path(case.source)
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=path),
|
||||
response=MappingResponse(status=case.status, json_body=case.body),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
channel_name = str(uuid.uuid4())
|
||||
receiver = update_raw_channel_config(incidentio_config(case.source), channel_name, notification_channel)
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# channels/test 404s until the org's alertmanager registers (one poll tick),
|
||||
# without reaching the notifier — so the first non-404 response is the single
|
||||
# authoritative delivery attempt and the count == 1 assertion below holds
|
||||
deadline = time.time() + 60
|
||||
while True:
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != HTTPStatus.NOT_FOUND or time.time() > deadline:
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
if case.expect_delivered:
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
|
||||
else:
|
||||
# a downstream 401/422 surfaces as a 500 (untyped notify error) whose body
|
||||
# carries the real downstream status code; pin it to distinguish 401 vs 422
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"expected 500, got {response.status_code}: {response.text}"
|
||||
assert f"unexpected status code {case.status}" in response.text, f"expected downstream {case.status} in error body: {response.text}"
|
||||
|
||||
# exactly one delivery attempt either way (testChannel never retries)
|
||||
count = requests.post(
|
||||
notification_channel.host_configs["8080"].get("/__admin/requests/count"),
|
||||
json={"method": "POST", "urlPath": path},
|
||||
timeout=10,
|
||||
)
|
||||
assert count.json()["count"] == 1, f"expected exactly 1 request (no retry), got {count.text}"
|
||||
|
||||
find = requests.post(
|
||||
notification_channel.host_configs["8080"].get("/__admin/requests/find"),
|
||||
json={"method": "POST", "urlPath": path},
|
||||
timeout=10,
|
||||
)
|
||||
req = find.json()["requests"][0]
|
||||
# the configured url is posted verbatim, nothing appended, and the token is
|
||||
# sent with a single Bearer prefix (header name lowercased on the wire by h2)
|
||||
assert req["url"] == path, f"expected alert events url {path} posted verbatim, got {req['url']}"
|
||||
headers = {name.lower(): value for name, value in req["headers"].items()}
|
||||
assert headers.get("authorization") == f"Bearer {INCIDENTIO_TEST_TOKEN}", f"expected single Bearer prefix, got {headers.get('authorization')}"
|
||||
|
||||
if case.expect_delivered:
|
||||
# alert event shape with the hardcoded test alert
|
||||
event = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
|
||||
assert re.search(r"\[FIRING:1\] Test Alert \(", event["title"]), f"unexpected title: {event['title']}"
|
||||
assert event["status"] == "firing"
|
||||
assert event["deduplication_key"], "expected a non-empty deduplication_key"
|
||||
|
||||
|
||||
def test_incidentio_test_channel_rejects_bearer_prefix(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
) -> None:
|
||||
path = incidentio_path("inc-tc-bearer")
|
||||
receiver = update_raw_channel_config(incidentio_config("inc-tc-bearer"), str(uuid.uuid4()), notification_channel)
|
||||
receiver["incidentio_configs"][0]["token"] = f"Bearer {INCIDENTIO_TEST_TOKEN}"
|
||||
|
||||
# rejected while parsing the receiver, before the org's alertmanager is looked up
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, f"expected 400, got {response.status_code}: {response.text}"
|
||||
assert "without the Bearer prefix" in response.text, response.text
|
||||
|
||||
count = requests.post(
|
||||
notification_channel.host_configs["8080"].get("/__admin/requests/count"),
|
||||
json={"method": "POST", "urlPath": path},
|
||||
timeout=10,
|
||||
)
|
||||
assert count.json()["count"] == 0, f"expected no delivery attempt, got {count.text}"
|
||||
@@ -1,165 +0,0 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import (
|
||||
get_testdata_file_path,
|
||||
update_raw_channel_config,
|
||||
update_rule_channel_name,
|
||||
verify_notification_expectation,
|
||||
)
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.notification_channel import (
|
||||
JIRA_API_BASE,
|
||||
jira_config,
|
||||
jira_create_mapping,
|
||||
jira_issue_subset,
|
||||
jira_retry_search_mappings,
|
||||
jira_search_mapping,
|
||||
wait_for_alertmanager_sync,
|
||||
)
|
||||
|
||||
|
||||
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
|
||||
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
|
||||
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
|
||||
LOGS_RULE = "ruler/test_scenarios/threshold_below_at_least_once/rule.json"
|
||||
|
||||
|
||||
JIRA_CASES = [
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="jira_default_metrics_firing",
|
||||
rule_path=METRICS_RULE,
|
||||
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
|
||||
channel_config=jira_config(),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": f"{JIRA_API_BASE}/issue",
|
||||
"count": 1,
|
||||
"json_body": jira_issue_subset("threshold_above_at_least_once", [("Open in SigNoz", r"/alerts/overview\?ruleId=")]),
|
||||
},
|
||||
),
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={"path": f"{JIRA_API_BASE}/search/jql", "count": 1},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="jira_rich_issue_logs",
|
||||
rule_path=LOGS_RULE,
|
||||
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
|
||||
channel_config=jira_config(),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": f"{JIRA_API_BASE}/issue",
|
||||
"count": 1,
|
||||
"json_body": jira_issue_subset(
|
||||
"threshold_below_at_least_once",
|
||||
[("Open in SigNoz", r"/alerts/overview\?ruleId="), ("View Related Logs", r"/logs/logs-explorer\?")],
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"jira_test_case",
|
||||
JIRA_CASES,
|
||||
ids=lambda c: c.name,
|
||||
)
|
||||
def test_jira_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
jira_test_case: types.AlertManagerNotificationTestCase,
|
||||
) -> None:
|
||||
channel_name = str(uuid.uuid4())
|
||||
|
||||
channel_config = update_raw_channel_config(jira_test_case.channel_config, channel_name, notification_channel)
|
||||
|
||||
make_http_mocks(notification_channel, [jira_search_mapping([]), jira_create_mapping()])
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
|
||||
|
||||
insert_alert_data(jira_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
|
||||
|
||||
with open(get_testdata_file_path(jira_test_case.rule_path), encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, channel_name)
|
||||
create_alert_rule(rule_data)
|
||||
|
||||
verify_notification_expectation(notification_channel, maildev, jira_test_case.notification_expectation)
|
||||
|
||||
|
||||
def test_jira_retry_429_then_200( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
) -> None:
|
||||
channel_name = str(uuid.uuid4())
|
||||
|
||||
channel_config = update_raw_channel_config(jira_config(), channel_name, notification_channel)
|
||||
|
||||
make_http_mocks(notification_channel, [*jira_retry_search_mappings(), jira_create_mapping()])
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
|
||||
|
||||
insert_alert_data([types.AlertData(type="metrics", data_path=METRICS_DATA)], base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
|
||||
|
||||
with open(get_testdata_file_path(METRICS_RULE), encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, channel_name)
|
||||
create_alert_rule(rule_data)
|
||||
|
||||
verify_notification_expectation(
|
||||
notification_channel,
|
||||
maildev,
|
||||
types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
# a retryable 429 on the search re-runs the whole notify => >=2 searches
|
||||
validation_data={"path": f"{JIRA_API_BASE}/search/jql", "min_count": 2},
|
||||
),
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
# but the issue is still only created once
|
||||
validation_data={"path": f"{JIRA_API_BASE}/issue", "count": 1},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1,322 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from typing import NamedTuple
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import update_raw_channel_config
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.notification_channel import (
|
||||
JIRA_API_BASE,
|
||||
JIRA_SA_EMAIL,
|
||||
JIRA_TEST_EMAIL,
|
||||
JIRA_TEST_TOKEN,
|
||||
find_requests,
|
||||
jira_comment_mapping,
|
||||
jira_config,
|
||||
jira_create_mapping,
|
||||
jira_search_issue,
|
||||
jira_search_mapping,
|
||||
jira_transition_post_mapping,
|
||||
jira_transitions_mapping,
|
||||
jira_update_mapping,
|
||||
wait_for_alertmanager_sync,
|
||||
)
|
||||
|
||||
|
||||
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
|
||||
# with a hardcoded firing test alert and no retry. The search stub decides which
|
||||
# branch runs (create / update / reopen), so the whole issue lifecycle is
|
||||
# deterministic here; default-template events + retry are in alertmanager/13_jira.py.
|
||||
|
||||
BASIC_AUTH = "Basic " + base64.b64encode(f"{JIRA_TEST_EMAIL}:{JIRA_TEST_TOKEN}".encode()).decode()
|
||||
|
||||
|
||||
def test_jira_create_issue(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
) -> None:
|
||||
make_http_mocks(notification_channel, [jira_search_mapping([]), jira_create_mapping()])
|
||||
|
||||
receiver = update_raw_channel_config(jira_config(), str(uuid.uuid4()), notification_channel)
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
|
||||
|
||||
searches = find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/search/jql")
|
||||
assert len(searches) == 1
|
||||
# basic auth on every call (header name lowercased on the wire by h2)
|
||||
headers = {name.lower(): value for name, value in searches[0]["headers"].items()}
|
||||
assert headers.get("authorization") == BASIC_AUTH, f"expected basic auth, got {headers.get('authorization')}"
|
||||
jql = json.loads(base64.b64decode(searches[0]["bodyAsBase64"]).decode("utf-8"))["jql"]
|
||||
assert 'project="OPS"' in jql, jql
|
||||
assert 'labels="ALERT{' in jql, jql
|
||||
# default reopen_duration (72h) becomes the firing reopen window
|
||||
assert "resolutiondate >= -4320m" in jql, jql
|
||||
|
||||
creates = find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue")
|
||||
assert len(creates) == 1
|
||||
fields = json.loads(base64.b64decode(creates[0]["bodyAsBase64"]).decode("utf-8"))["fields"]
|
||||
assert fields["project"] == {"key": "OPS"}
|
||||
assert fields["issuetype"] == {"name": "Task"}
|
||||
assert re.search(r"\[FIRING:1\] Test Alert \(", fields["summary"]), fields["summary"]
|
||||
assert "signoz-alert" in fields["labels"]
|
||||
assert any(label.startswith("ALERT{") for label in fields["labels"]), fields["labels"]
|
||||
# ADF body leads with the firing status panel
|
||||
panel = fields["description"]["content"][0]
|
||||
assert panel["attrs"] == {"panelType": "error"}
|
||||
assert panel["content"][0]["content"][0]["text"] == "🔴 FIRING"
|
||||
|
||||
|
||||
def test_jira_wont_fix_resolution_in_search_jql(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
) -> None:
|
||||
make_http_mocks(notification_channel, [jira_search_mapping([]), jira_create_mapping()])
|
||||
|
||||
receiver = update_raw_channel_config(jira_config(wont_fix_resolution="Won't Do"), str(uuid.uuid4()), notification_channel)
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
|
||||
|
||||
searches = find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/search/jql")
|
||||
assert len(searches) == 1
|
||||
jql = json.loads(base64.b64decode(searches[0]["bodyAsBase64"]).decode("utf-8"))["jql"]
|
||||
# issues resolved as won't-fix stay closed: the search skips them so a
|
||||
# refire creates a fresh issue instead of reopening
|
||||
assert '(resolution is EMPTY or resolution != "Won\'t Do")' in jql, jql
|
||||
|
||||
|
||||
def test_jira_updates_existing_open_issue(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
) -> None:
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[
|
||||
jira_search_mapping([jira_search_issue("OPS-7", done=False, labels=["user-added", "signoz-alert"])]),
|
||||
jira_update_mapping("OPS-7"),
|
||||
jira_comment_mapping("OPS-7"),
|
||||
],
|
||||
)
|
||||
|
||||
receiver = update_raw_channel_config(jira_config(), str(uuid.uuid4()), notification_channel)
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
|
||||
|
||||
# still-firing group with an open issue: refresh + comment, no create, no transition
|
||||
updates = find_requests(notification_channel, "PUT", f"{JIRA_API_BASE}/issue/OPS-7")
|
||||
assert len(updates) == 1
|
||||
fields = json.loads(base64.b64decode(updates[0]["bodyAsBase64"]).decode("utf-8"))["fields"]
|
||||
assert "user-added" in fields["labels"], f"user-added labels must survive the update: {fields['labels']}"
|
||||
assert "signoz-alert" in fields["labels"]
|
||||
assert "project" not in fields and "issuetype" not in fields, "create-only fields must not be sent on update"
|
||||
|
||||
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue")) == 0
|
||||
assert len(find_requests(notification_channel, "GET", f"{JIRA_API_BASE}/issue/OPS-7/transitions")) == 0
|
||||
|
||||
comments = find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue/OPS-7/comment")
|
||||
assert len(comments) == 1
|
||||
body = json.loads(base64.b64decode(comments[0]["bodyAsBase64"]).decode("utf-8"))["body"]
|
||||
assert body["content"][0]["attrs"] == {"panelType": "error"}, "comment carries the same ADF snapshot"
|
||||
|
||||
|
||||
def test_jira_reopens_done_issue(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
) -> None:
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[
|
||||
jira_search_mapping([jira_search_issue("OPS-7", done=True, labels=["signoz-alert"])]),
|
||||
jira_update_mapping("OPS-7"),
|
||||
jira_transitions_mapping(
|
||||
"OPS-7",
|
||||
[
|
||||
{"id": "31", "name": "Done", "to": {"statusCategory": {"key": "done"}}},
|
||||
{"id": "11", "name": "To Do", "to": {"statusCategory": {"key": "new"}}},
|
||||
],
|
||||
),
|
||||
jira_transition_post_mapping("OPS-7"),
|
||||
jira_comment_mapping("OPS-7"),
|
||||
],
|
||||
)
|
||||
|
||||
receiver = update_raw_channel_config(jira_config(), str(uuid.uuid4()), notification_channel)
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
|
||||
|
||||
# firing group whose issue is done: update, then transition out of done, then comment
|
||||
assert len(find_requests(notification_channel, "PUT", f"{JIRA_API_BASE}/issue/OPS-7")) == 1
|
||||
transitions = find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue/OPS-7/transitions")
|
||||
assert len(transitions) == 1
|
||||
body = json.loads(base64.b64decode(transitions[0]["bodyAsBase64"]).decode("utf-8"))
|
||||
assert body == {"transition": {"id": "11"}}, f"expected the not-done transition to be applied: {body}"
|
||||
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue/OPS-7/comment")) == 1
|
||||
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue")) == 0
|
||||
|
||||
|
||||
class PermanentErrorCase(NamedTuple):
|
||||
__test__ = False
|
||||
name: str
|
||||
mappings: list[Mapping]
|
||||
downstream_status: int
|
||||
search_count: int
|
||||
create_count: int
|
||||
|
||||
|
||||
PERMANENT_ERROR_CASES = [
|
||||
PermanentErrorCase(
|
||||
name="create_400",
|
||||
mappings=[
|
||||
jira_search_mapping([]),
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JIRA_API_BASE}/issue"),
|
||||
response=MappingResponse(status=400, json_body={"errorMessages": [], "errors": {"issuetype": "The issue type selected is invalid."}}),
|
||||
),
|
||||
],
|
||||
downstream_status=400,
|
||||
search_count=1,
|
||||
create_count=1,
|
||||
),
|
||||
PermanentErrorCase(
|
||||
name="search_401",
|
||||
mappings=[
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JIRA_API_BASE}/search/jql"),
|
||||
response=MappingResponse(status=401, json_body={"errorMessages": ["Client must be authenticated to access this resource."]}),
|
||||
),
|
||||
],
|
||||
downstream_status=401,
|
||||
search_count=1,
|
||||
create_count=0,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
PERMANENT_ERROR_CASES,
|
||||
ids=lambda c: c.name,
|
||||
)
|
||||
def test_jira_permanent_error( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
case: PermanentErrorCase,
|
||||
) -> None:
|
||||
make_http_mocks(notification_channel, case.mappings)
|
||||
|
||||
receiver = update_raw_channel_config(jira_config(), str(uuid.uuid4()), notification_channel)
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
# a downstream 4xx surfaces as a 500 (untyped notify error) whose body
|
||||
# carries the real downstream status code; testChannel never retries
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"expected 500, got {response.status_code}: {response.text}"
|
||||
assert f"unexpected status code {case.downstream_status}" in response.text, response.text
|
||||
|
||||
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/search/jql")) == case.search_count
|
||||
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/issue")) == case.create_count
|
||||
|
||||
|
||||
def test_jira_service_account_uses_gateway(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
) -> None:
|
||||
cloud_id = "b8e7c297-4c56-4d39-9e1a-000000000001"
|
||||
gateway_base = f"/ex/jira/{cloud_id}/rest/api/3"
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.GET, url_path="/_edge/tenant_info"),
|
||||
response=MappingResponse(status=200, json_body={"cloudId": cloud_id}),
|
||||
),
|
||||
jira_search_mapping([], base=gateway_base),
|
||||
jira_create_mapping(base=gateway_base),
|
||||
],
|
||||
)
|
||||
|
||||
receiver = update_raw_channel_config(
|
||||
jira_config(http_config={"basic_auth": {"username": JIRA_SA_EMAIL, "password": JIRA_TEST_TOKEN}}),
|
||||
str(uuid.uuid4()),
|
||||
notification_channel,
|
||||
)
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
|
||||
|
||||
# cloud id resolved from the site's tenant_info, then every API call goes
|
||||
# through the api.atlassian.com gateway instead of the site host
|
||||
assert len(find_requests(notification_channel, "GET", "/_edge/tenant_info")) == 1
|
||||
assert len(find_requests(notification_channel, "POST", f"{gateway_base}/search/jql")) == 1
|
||||
assert len(find_requests(notification_channel, "POST", f"{gateway_base}/issue")) == 1
|
||||
assert len(find_requests(notification_channel, "POST", f"{JIRA_API_BASE}/search/jql")) == 0
|
||||
@@ -1,167 +0,0 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from wiremock.resources.mappings import Mapping
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import (
|
||||
get_testdata_file_path,
|
||||
update_raw_channel_config,
|
||||
update_rule_channel_name,
|
||||
verify_notification_expectation,
|
||||
)
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.notification_channel import (
|
||||
JSMOPS_API_BASE,
|
||||
JSMOPS_NOTES_PATH_PATTERN,
|
||||
jsmops_alert_subset,
|
||||
jsmops_config,
|
||||
jsmops_create_mapping,
|
||||
jsmops_notes_mapping,
|
||||
jsmops_retry_create_mappings,
|
||||
wait_for_alertmanager_sync,
|
||||
)
|
||||
|
||||
|
||||
METRICS_DATA = "ruler/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
|
||||
METRICS_RULE = "ruler/test_scenarios/threshold_above_at_least_once/rule.json"
|
||||
LOGS_DATA = "ruler/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
|
||||
LOGS_RULE = "ruler/test_scenarios/threshold_below_at_least_once/rule.json"
|
||||
|
||||
|
||||
JSMOPS_CASES = [
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="jsmops_default_metrics_firing",
|
||||
rule_path=METRICS_RULE,
|
||||
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
|
||||
channel_config=jsmops_config(),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": f"{JSMOPS_API_BASE}/v2/alerts",
|
||||
"count": 1,
|
||||
"json_body": jsmops_alert_subset("threshold_above_at_least_once", [("View in SigNoz", r"/alerts/overview\?ruleId=")]),
|
||||
},
|
||||
),
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
# every fire appends a timeline note
|
||||
validation_data={"path_pattern": JSMOPS_NOTES_PATH_PATTERN, "count": 1},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
types.AlertManagerNotificationTestCase(
|
||||
name="jsmops_rich_alert_logs",
|
||||
rule_path=LOGS_RULE,
|
||||
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
|
||||
channel_config=jsmops_config(),
|
||||
notification_expectation=types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
validation_data={
|
||||
"path": f"{JSMOPS_API_BASE}/v2/alerts",
|
||||
"count": 1,
|
||||
"json_body": jsmops_alert_subset(
|
||||
"threshold_below_at_least_once",
|
||||
[("View in SigNoz", r"/alerts/overview\?ruleId="), ("View related logs", r"/logs/logs-explorer\?")],
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"jsmops_test_case",
|
||||
JSMOPS_CASES,
|
||||
ids=lambda c: c.name,
|
||||
)
|
||||
def test_jsmops_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
jsmops_test_case: types.AlertManagerNotificationTestCase,
|
||||
) -> None:
|
||||
channel_name = str(uuid.uuid4())
|
||||
|
||||
channel_config = update_raw_channel_config(jsmops_test_case.channel_config, channel_name, notification_channel)
|
||||
|
||||
make_http_mocks(notification_channel, [jsmops_create_mapping(), jsmops_notes_mapping()])
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
|
||||
|
||||
insert_alert_data(jsmops_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
|
||||
|
||||
with open(get_testdata_file_path(jsmops_test_case.rule_path), encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, channel_name)
|
||||
create_alert_rule(rule_data)
|
||||
|
||||
verify_notification_expectation(notification_channel, maildev, jsmops_test_case.notification_expectation)
|
||||
|
||||
|
||||
def test_jsmops_retry_429_then_202( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_notification_channel: Callable[[dict], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
maildev: types.TestContainerDocker,
|
||||
) -> None:
|
||||
channel_name = str(uuid.uuid4())
|
||||
|
||||
channel_config = update_raw_channel_config(jsmops_config(), channel_name, notification_channel)
|
||||
|
||||
make_http_mocks(notification_channel, [*jsmops_retry_create_mappings(), jsmops_notes_mapping()])
|
||||
|
||||
create_notification_channel(channel_config)
|
||||
wait_for_alertmanager_sync(signoz, get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD), notification_channel)
|
||||
|
||||
insert_alert_data([types.AlertData(type="metrics", data_path=METRICS_DATA)], base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
|
||||
|
||||
with open(get_testdata_file_path(METRICS_RULE), encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, channel_name)
|
||||
create_alert_rule(rule_data)
|
||||
|
||||
verify_notification_expectation(
|
||||
notification_channel,
|
||||
maildev,
|
||||
types.AMNotificationExpectation(
|
||||
should_notify=True,
|
||||
wait_time_seconds=60,
|
||||
notification_validations=[
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
# a retryable 429 on the create re-runs the whole notify => >=2 creates
|
||||
validation_data={"path": f"{JSMOPS_API_BASE}/v2/alerts", "min_count": 2},
|
||||
),
|
||||
types.NotificationValidation(
|
||||
destination_type="webhook",
|
||||
# the note only goes out after the create succeeded
|
||||
validation_data={"path_pattern": JSMOPS_NOTES_PATH_PATTERN, "count": 1},
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -1,163 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
from typing import NamedTuple
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from wiremock.resources.mappings import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import update_raw_channel_config
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.notification_channel import (
|
||||
JSMOPS_API_BASE,
|
||||
JSMOPS_NOTES_PATH_PATTERN,
|
||||
JSMOPS_TEST_API_KEY,
|
||||
find_requests,
|
||||
jsmops_config,
|
||||
jsmops_create_mapping,
|
||||
jsmops_notes_mapping,
|
||||
wait_for_alertmanager_sync,
|
||||
)
|
||||
|
||||
|
||||
# channel test (POST /api/v1/channels/test) drives the notifier once, synchronously,
|
||||
# with a hardcoded firing test alert and no retry: create alert on the JSM Ops
|
||||
# gateway, then append a timeline note. Default-template events + retry are in
|
||||
# alertmanager/15_jsmops.py.
|
||||
|
||||
|
||||
def test_jsmops_create_alert_with_note(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
) -> None:
|
||||
make_http_mocks(notification_channel, [jsmops_create_mapping(), jsmops_notes_mapping()])
|
||||
|
||||
receiver = update_raw_channel_config(jsmops_config(), str(uuid.uuid4()), notification_channel)
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
|
||||
|
||||
creates = find_requests(notification_channel, "POST", f"{JSMOPS_API_BASE}/v2/alerts")
|
||||
assert len(creates) == 1
|
||||
# GenieKey auth on every call (header name lowercased on the wire by h2)
|
||||
headers = {name.lower(): value for name, value in creates[0]["headers"].items()}
|
||||
assert headers.get("authorization") == f"GenieKey {JSMOPS_TEST_API_KEY}", f"expected GenieKey auth, got {headers.get('authorization')}"
|
||||
alert = json.loads(base64.b64decode(creates[0]["bodyAsBase64"]).decode("utf-8"))
|
||||
assert alert["alias"], "alias carries the group hash for dedup/close"
|
||||
assert re.search(r"\[FIRING:1\] Test Alert \(", alert["message"]), alert["message"]
|
||||
assert alert["source"] == "SigNoz"
|
||||
assert alert["tags"] == ["signoz"]
|
||||
# advanced treatment renders the default body as HTML
|
||||
assert "<div>" in alert["description"], alert["description"]
|
||||
|
||||
notes = find_requests(notification_channel, "POST", path_pattern=JSMOPS_NOTES_PATH_PATTERN)
|
||||
assert len(notes) == 1
|
||||
assert notes[0]["queryParams"]["identifierType"]["values"] == ["alias"]
|
||||
note = json.loads(base64.b64decode(notes[0]["bodyAsBase64"]).decode("utf-8"))
|
||||
assert note["source"] == "SigNoz"
|
||||
assert note["note"].strip(), "the timeline note carries the plain-text snapshot"
|
||||
|
||||
|
||||
def test_jsmops_failed_note_does_not_fail_delivery(
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
) -> None:
|
||||
# notes are enrichment: a permanent note failure (e.g. the first-fire note
|
||||
# racing JSM's async alert create) is dropped and the delivery still succeeds
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[
|
||||
jsmops_create_mapping(),
|
||||
jsmops_notes_mapping(status=404, body={"message": "Alert with id/alias does not exist", "took": 0.001, "requestId": "x"}),
|
||||
],
|
||||
)
|
||||
|
||||
receiver = update_raw_channel_config(jsmops_config(), str(uuid.uuid4()), notification_channel)
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204 despite the failed note, got {response.status_code}: {response.text}"
|
||||
|
||||
assert len(find_requests(notification_channel, "POST", f"{JSMOPS_API_BASE}/v2/alerts")) == 1
|
||||
assert len(find_requests(notification_channel, "POST", path_pattern=JSMOPS_NOTES_PATH_PATTERN)) == 1
|
||||
|
||||
|
||||
class PermanentErrorCase(NamedTuple):
|
||||
__test__ = False
|
||||
name: str
|
||||
status: int
|
||||
body: dict
|
||||
|
||||
|
||||
PERMANENT_ERROR_CASES = [
|
||||
PermanentErrorCase("create_422", 422, {"message": "Message can not be empty.", "took": 0.001, "requestId": "x"}),
|
||||
PermanentErrorCase("create_401", 401, {"message": "Could not authenticate.", "took": 0.001, "requestId": "x"}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
PERMANENT_ERROR_CASES,
|
||||
ids=lambda c: c.name,
|
||||
)
|
||||
def test_jsmops_permanent_error( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
get_token: Callable[[str, str], str],
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
case: PermanentErrorCase,
|
||||
) -> None:
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(method=HttpMethods.POST, url_path=f"{JSMOPS_API_BASE}/v2/alerts"),
|
||||
response=MappingResponse(status=case.status, json_body=case.body),
|
||||
),
|
||||
jsmops_notes_mapping(),
|
||||
],
|
||||
)
|
||||
|
||||
receiver = update_raw_channel_config(jsmops_config(), str(uuid.uuid4()), notification_channel)
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
wait_for_alertmanager_sync(signoz, admin_token, notification_channel)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/channels/test"),
|
||||
json=receiver,
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=30,
|
||||
)
|
||||
# a downstream 4xx on the create surfaces as a 500 (untyped notify error)
|
||||
# whose body carries the real downstream status code; testChannel never retries
|
||||
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR, f"expected 500, got {response.status_code}: {response.text}"
|
||||
assert f"unexpected status code {case.status}" in response.text, response.text
|
||||
|
||||
assert len(find_requests(notification_channel, "POST", f"{JSMOPS_API_BASE}/v2/alerts")) == 1
|
||||
# the request loop stops at the failed create, so the note is never attempted
|
||||
assert len(find_requests(notification_channel, "POST", path_pattern=JSMOPS_NOTES_PATH_PATTERN)) == 0
|
||||
@@ -13,7 +13,6 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
tls: types.TLS,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
maildev: types.TestContainerDocker,
|
||||
@@ -25,7 +24,6 @@ def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
tls=tls,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz_alertmanager",
|
||||
|
||||
Reference in New Issue
Block a user