Compare commits

..

4 Commits

Author SHA1 Message Date
Swapnil Nakade
cbfe328936 Merge branch 'main' into issue-2977 2026-09-26 06:53:21 +05:30
swapnil-signoz
9708e89d8c feat: adding sync state in cloud integration 2026-09-26 06:48:19 +05:30
Aditya Singh
ed1bf7ab89 fix(bottom-strip): size pages from the layout instead of the viewport (#12940)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- pages that hardcoded `100vh` minus a guess at what sits above them
came out taller
than the pane they live in, which showed up as scroll that should not be
there. they
  now take what the layout gives them.
- most of the `100vh` in the app turned out to be harmless.. either
flex-shrink absorbs
it or the pane scrolls anyway. those are left alone, only the ones with
a real symptom
  are changed here.
- alert rules and triggered alerts also needed the AlertList tabs chain
to hand height
  down, that page uses antd `Tabs` directly instead of `RouteTab`.
- licenses, status and support pages had `max-height: 100vh` with
`overflow: hidden` and
no inner scroller, so anything past a viewport was clipped with no way
to reach it.
  removed the cap on all three.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6074


#### Screenshots/ Screen recording

Home page


https://github.com/user-attachments/assets/8ba3e3c9-1959-4393-b503-ddf579e9d139

Without bottom strip


https://github.com/user-attachments/assets/2ee08181-710a-4c34-a4e7-893c4a320453



Status page

<img width="1728" height="1000" alt="status"
src="https://github.com/user-attachments/assets/f44ec7d7-4ee0-4866-9a90-fe25622fe25b"
/>

Without bottom strip

<img width="1728" height="997" alt="status2"
src="https://github.com/user-attachments/assets/be57dce6-65ca-4450-8f0d-0337625a1d64"
/>

Alert rules


https://github.com/user-attachments/assets/7cc25f56-54e4-4489-bdb4-453409151bac

Triggered alerts


https://github.com/user-attachments/assets/2d8e1885-7cad-4659-90f6-59960f4afce8

Without bottom strip



https://github.com/user-attachments/assets/5247f6c0-3675-4910-b1e4-4076bf93c16e



Support
<img width="1728" height="997" alt="support"
src="https://github.com/user-attachments/assets/c2b3c0b2-2abd-4538-bb52-2660e75817b3"
/>

Trace funnel



https://github.com/user-attachments/assets/c4872135-3cb5-4348-b6c6-2d3e4dabdc1b

Without bottom strip


https://github.com/user-attachments/assets/aef94d5d-02e5-4154-995b-76819d55c2d8


Trace details



https://github.com/user-attachments/assets/24d701b0-296f-4fb4-ba9d-615a47b33285

Without bottom strip


https://github.com/user-attachments/assets/23083f20-67bf-4b06-aaec-aba390a6f594



#### Additional Information

- every page here was checked on screen before and after. the ones left
untouched
(infra hosts/k8s, traces + llm explorer list views, llm settings tables,
the k8s logs
  drawer) were checked too and are fine.
2026-09-25 15:14:44 +00:00
Aditya Singh
6e979c8318 feat(bottom-strip): add the layout shell behind a feature flag (#12936)
#### Description

- adds the bottom strip to the app layout behind a localStorage flag.
shows the build
version on the left for now.. right side actions and the per page count
come in the
  next tickets.
- `.app-content` is a column flex now and `LayoutContent` takes the
height left over
instead of `height: 100%`, so the strip has a stable box to sit under.
this is the
  only bit not behind the flag.
- fixed bottom elements read `--bottom-strip-height`. the var only
exists while the
strip is mounted, so with the flag off everything falls back to where it
is today.
- hides nothing. each later ticket hides the piece it replaces.

#### Issues closed by this PR

Part of https://github.com/SigNoz/engineering-pod/issues/6074

<img width="3084" height="1566" alt="image"
src="https://github.com/user-attachments/assets/b1821fda-5c33-40e7-926a-5d91fedb797e"
/>


#### Additional Information

- pages that still hardcode `100vh` (infra hosts/k8s, trace details,
traces and llm
list views) push the strip off screen. that is the next PR on this
ticket.
- pylon chat window offset is not here.. needs a pylon enabled tenant to
verify so it
  goes with the right side actions ticket.
2026-09-25 12:04:21 +00:00
46 changed files with 538 additions and 1939 deletions

View File

@@ -1763,12 +1763,15 @@ components:
additionalProperties: {}
nullable: true
type: object
syncState:
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
timestampMillis:
format: int64
type: integer
required:
- timestampMillis
- data
- syncState
type: object
CloudintegrationtypesAzureAccountConfig:
properties:
@@ -2013,6 +2016,8 @@ components:
format: date-time
nullable: true
type: string
syncState:
$ref: '#/components/schemas/CloudintegrationtypesSyncState'
required:
- account_id
- cloud_account_id
@@ -2022,6 +2027,7 @@ components:
- providerAccountId
- integrationConfig
- removedAt
- syncState
type: object
CloudintegrationtypesGettableServicesMetadata:
properties:
@@ -2121,6 +2127,9 @@ components:
type: object
providerAccountId:
type: string
syncedVersion:
nullable: true
type: integer
required:
- data
type: object
@@ -2133,6 +2142,18 @@ components:
gcp:
$ref: '#/components/schemas/CloudintegrationtypesGCPIntegrationConfig'
type: object
CloudintegrationtypesRegionState:
enum:
- present
- removed
type: string
CloudintegrationtypesRegionSyncState:
properties:
state:
$ref: '#/components/schemas/CloudintegrationtypesRegionState'
required:
- state
type: object
CloudintegrationtypesService:
properties:
assets:
@@ -2274,6 +2295,23 @@ components:
metrics:
type: boolean
type: object
CloudintegrationtypesSyncState:
nullable: true
properties:
inSync:
type: boolean
regions:
additionalProperties:
$ref: '#/components/schemas/CloudintegrationtypesRegionSyncState'
type: object
version:
format: int64
type: integer
required:
- version
- inSync
- regions
type: object
CloudintegrationtypesUpdatableAccount:
properties:
config:

View File

@@ -188,27 +188,41 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
return nil, err
}
// Get account as domain object for config access (enabled regions, etc.)
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
if err != nil {
return nil, err
}
syncState := domainAccount.NextSyncState(req.SyncedVersion)
// If account has been removed (disconnected), return a minimal response with empty integration config.
// The agent uses this response to clean up resources
if account.RemovedAt != nil {
// Heartbeat stays frozen after removal, only the sync state is updated.
if domainAccount.AgentReport != nil && syncState != nil {
domainAccount.AgentReport.SyncState = syncState
account.Update(account.AccountID, domainAccount.AgentReport)
err = module.store.UpdateAgentReport(ctx, account)
if err != nil {
return nil, err
}
}
return cloudintegrationtypes.NewAgentCheckInResponse(
req.ProviderAccountID,
account.ID.StringValue(),
new(cloudintegrationtypes.ProviderIntegrationConfig),
account.RemovedAt,
syncState,
), nil
}
// update account with cloud provider account id and agent report (heartbeat)
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data))
account.Update(&req.ProviderAccountID, cloudintegrationtypes.NewAgentReport(req.Data, syncState))
err = module.store.UpdateAccount(ctx, account)
if err != nil {
return nil, err
}
// Get account as domain object for config access (enabled regions, etc.)
domainAccount, err := cloudintegrationtypes.NewAccountFromStorable(account)
err = module.store.UpdateAgentReport(ctx, account)
if err != nil {
return nil, err
}
@@ -234,6 +248,7 @@ func (module *module) AgentCheckIn(ctx context.Context, orgID valuer.UUID, provi
account.ID.StringValue(),
integrationConfig,
account.RemovedAt,
syncState,
), nil
}

View File

@@ -3366,6 +3366,37 @@ export interface CloudintegrationtypesAWSServiceConfigDTO {
metrics?: CloudintegrationtypesAWSServiceMetricsConfigDTO;
}
export enum CloudintegrationtypesRegionStateDTO {
present = 'present',
removed = 'removed',
}
export interface CloudintegrationtypesRegionSyncStateDTO {
state: CloudintegrationtypesRegionStateDTO;
}
export type CloudintegrationtypesSyncStateDTORegions = {
[key: string]: CloudintegrationtypesRegionSyncStateDTO;
};
/**
* @nullable
*/
export type CloudintegrationtypesSyncStateDTO = {
/**
* @type boolean
*/
inSync: boolean;
/**
* @type object
*/
regions: CloudintegrationtypesSyncStateDTORegions;
/**
* @type integer
* @format int64
*/
version: number;
} | null;
export type CloudintegrationtypesAgentReportDTODataAnyOf = {
[key: string]: unknown;
};
@@ -3384,6 +3415,7 @@ export type CloudintegrationtypesAgentReportDTO = {
* @type object,null
*/
data: CloudintegrationtypesAgentReportDTOData;
syncState: CloudintegrationtypesSyncStateDTO | null;
/**
* @type integer
* @format int64
@@ -3812,6 +3844,7 @@ export interface CloudintegrationtypesGettableAgentCheckInDTO {
* @format date-time
*/
removedAt: string | null;
syncState: CloudintegrationtypesSyncStateDTO | null;
}
export interface CloudintegrationtypesServiceMetadataDTO {
@@ -3882,6 +3915,10 @@ export interface CloudintegrationtypesPostableAgentCheckInDTO {
* @type string
*/
providerAccountId?: string;
/**
* @type integer,null
*/
syncedVersion?: number | null;
}
export interface CloudintegrationtypesStorableIntegrationDashboardDTO {

View File

@@ -47,4 +47,5 @@ export enum LOCALSTORAGE {
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
LLM_ATTRIBUTE_MAPPING_TEST_SPAN = 'LLM_ATTRIBUTE_MAPPING_TEST_SPAN',
SAVED_VIEW_ENABLED = 'SAVED_VIEW_ENABLED',
}

View File

@@ -53,6 +53,10 @@
z-index: 0;
background: var(--l1-background);
// Column so the bottom strip sits under the scrolling content, not inside it.
display: flex;
flex-direction: column;
&.full-screen-content {
width: 100%;
}
@@ -70,7 +74,9 @@
.chat-support-gateway {
position: fixed;
bottom: 20px;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: calc(20px + var(--bottom-strip-height, 0px));
right: 20px;
z-index: 1000;

View File

@@ -43,6 +43,7 @@ import { USER_PREFERENCES } from 'constants/userPreferences';
import AIAssistantModal from 'container/AIAssistant/AIAssistantModal';
import AIAssistantPanel from 'container/AIAssistant/AIAssistantPanel';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import BottomStrip from 'container/BottomStrip';
import SideNav from 'container/SideNav';
import TopNav from 'container/TopNav';
import dayjs from 'dayjs';
@@ -51,6 +52,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useNotifications } from 'hooks/useNotifications';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import useTabVisibility from 'hooks/useTabFocus';
import history from 'lib/history';
import { isNull } from 'lodash-es';
@@ -402,6 +404,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
}, [pathname]);
const isToDisplayLayout = isLoggedIn;
const isSavedViewEnabled = useSavedViewEnabled();
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
const pageTitle = t(routeKey);
@@ -868,6 +871,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</OverlayScrollbar>
</LayoutContent>
</Sentry.ErrorBoundary>
{isSavedViewEnabled && isToDisplayLayout && !renderFullScreen && (
<BottomStrip />
)}
</div>
{isLoggedIn && isAIAssistantEnabled && (

View File

@@ -12,8 +12,12 @@ export const Layout = styled(LayoutComponent)`
}
`;
// Takes the height left in `.app-content` after the bottom strip.
// `min-height: 0` is not needed right now, overlayscrollbars already sets
// `overflow: auto` here. Kept so this does not break if that goes away.
export const LayoutContent = styled(LayoutComponent.Content)`
height: 100%;
flex: 1;
min-height: 0;
&::-webkit-scrollbar {
width: 0.1rem;
}

View File

@@ -0,0 +1,36 @@
.strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-6);
flex-shrink: 0;
height: var(--bottom-strip-height);
padding: 0 var(--spacing-6);
background: var(--l2-background);
border-top: 1px solid var(--l2-border);
font-family: var(--font-family-sf-mono, monospace);
// Above page content, below the body-portalled overlays that are meant to
// cover the strip.
position: relative;
z-index: 1;
}
.left,
.right {
display: flex;
align-items: center;
gap: var(--spacing-6);
min-width: 0;
}
// Temporary placeholder for the left slot. Replaced later.
.version {
color: var(--l2-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,49 @@
import { render } from 'tests/test-utils';
import BottomStrip, {
BOTTOM_STRIP_HEIGHT,
BOTTOM_STRIP_HEIGHT_VAR,
BOTTOM_STRIP_ON_CLASS,
} from '..';
describe('BottomStrip', () => {
it('publishes the body class and height property while mounted', () => {
const { unmount } = render(<BottomStrip />);
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(true);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
`${BOTTOM_STRIP_HEIGHT}px`,
);
unmount();
expect(document.body.classList.contains(BOTTOM_STRIP_ON_CLASS)).toBe(false);
expect(document.body.style.getPropertyValue(BOTTOM_STRIP_HEIGHT_VAR)).toBe(
'',
);
});
// The string is whatever the Go build injected, so it is rendered untouched —
// same as SideNav. Release tags carry the "v", local builds do not.
it.each([['v0.134.67'], ['main-64f1c2a']])(
'renders the build version %p exactly as given',
(version) => {
const { getByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: {
versionData: { version, ee: 'Y', setupCompleted: true },
},
});
expect(getByTestId('bottom-strip-version')).toHaveTextContent(version);
},
);
it('renders the strip without a version when none is available', () => {
const { getByTestId, queryByTestId } = render(<BottomStrip />, undefined, {
appContextOverrides: { versionData: null },
});
expect(getByTestId('bottom-strip')).toBeInTheDocument();
expect(queryByTestId('bottom-strip-version')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,42 @@
import { useLayoutEffect } from 'react';
import { useAppContext } from 'providers/App/App';
import styles from './BottomStrip.module.scss';
export const BOTTOM_STRIP_HEIGHT = 24;
export const BOTTOM_STRIP_ON_CLASS = 'bottom-strip-on';
export const BOTTOM_STRIP_HEIGHT_VAR = '--bottom-strip-height';
function BottomStrip(): JSX.Element {
const { versionData } = useAppContext();
const version = versionData?.version?.trim();
useLayoutEffect(() => {
document.body.classList.add(BOTTOM_STRIP_ON_CLASS);
document.body.style.setProperty(
BOTTOM_STRIP_HEIGHT_VAR,
`${BOTTOM_STRIP_HEIGHT}px`,
);
return (): void => {
document.body.classList.remove(BOTTOM_STRIP_ON_CLASS);
document.body.style.removeProperty(BOTTOM_STRIP_HEIGHT_VAR);
};
}, []);
return (
<div className={styles.strip} data-testid="bottom-strip">
<div className={styles.left}>
{version && (
<span className={styles.version} data-testid="bottom-strip-version">
{version}
</span>
)}
</div>
<div className={styles.right} />
</div>
);
}
export default BottomStrip;

View File

@@ -1,6 +1,8 @@
.create-alert-v2-footer {
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
left: 63px;
right: 0;
background-color: var(--l1-background);

View File

@@ -1,6 +1,8 @@
.explorer-options-container {
position: fixed;
bottom: 0px;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
left: calc(50% + 240px);
transform: translate(calc(-50% - 120px), 0);
transition: left 0.2s linear;

View File

@@ -1,6 +1,8 @@
.explorer-option-droppable-container {
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
width: -webkit-fill-available;
height: 24px;
display: flex;

View File

@@ -1,7 +1,6 @@
.home-container {
display: flex;
flex-direction: column;
min-height: 100vh;
overflow-y: auto;
height: 100%;
width: 100%;

View File

@@ -24,6 +24,7 @@ const accountsResponse: ListAccounts200 = {
agentReport: {
timestampMillis: 1747114366214,
data: null,
syncState: null,
},
providerAccountId: PROVIDER_ACCOUNT_ID,
removedAt: null,

View File

@@ -1,7 +1,4 @@
.licenses-page {
max-height: 100vh;
overflow: hidden;
.licenses-page-header {
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);
@@ -32,7 +29,6 @@
.licenses-page-content {
flex: 1;
height: calc(100vh - 48px);
background: var(--l1-background);
padding: 10px 8px;
overflow-y: auto;

View File

@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
gap: 1rem;
height: calc(100vh - 62px);
flex: 1;
min-height: 400px;
}

View File

@@ -181,7 +181,9 @@
.ant-pagination {
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new
// fixed-bottom UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
width: calc(100% - 54px);
background: var(--l1-background);
padding: 16px;

View File

@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
gap: 1rem;
height: calc(100vh - 62px);
flex: 1;
min-height: 400px;
padding-top: var(--spacing-8);
}

View File

@@ -1,7 +1,4 @@
.version-container {
max-height: 100vh;
overflow: hidden;
.version-page-header {
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);

View File

@@ -0,0 +1,11 @@
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { useState } from 'react';
export function useSavedViewEnabled(): boolean {
const [isEnabled] = useState(
() => getLocalStorageKey(LOCALSTORAGE.SAVED_VIEW_ENABLED) === 'true',
);
return isEnabled;
}

View File

@@ -1,4 +1,29 @@
.alerts-container {
// Hands the page height down to the active tab so its content can bound itself
// instead of guessing with 100vh. Child combinators only, nested Tabs
// (Configuration) must not be caught.
flex: 1;
min-height: 0;
> .ant-tabs-content-holder {
display: flex;
flex-direction: column;
> .ant-tabs-content {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
> .ant-tabs-tabpane-active {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}
}
.top-level-tab.periscope-tab {
padding: 2px 0;
}
@@ -40,5 +65,9 @@
.alert-rules-container {
margin-top: 10px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}

View File

@@ -2,7 +2,9 @@
display: flex;
flex-direction: column;
position: fixed;
bottom: 0;
// Lifted above the bottom strip. Don't extend this pattern — new fixed-bottom
// UI belongs in the bounded layout, not in another offset here.
bottom: var(--bottom-strip-height, 0px);
left: 0;
width: 100%;
z-index: 100;

View File

@@ -295,7 +295,11 @@ const account = (
provider,
providerAccountId: ACCOUNTS[provider][index],
config: accountConfig(provider),
agentReport: { timestampMillis: Date.now() - 45 * 1000, data: null },
agentReport: {
timestampMillis: Date.now() - 45 * 1000,
data: null,
syncState: null,
},
createdAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
removedAt: null,

View File

@@ -1,7 +1,4 @@
.support-page-container {
max-height: 100vh;
overflow: hidden;
.support-page-header {
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);

View File

@@ -1,5 +1,6 @@
.root {
height: calc(100vh);
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}

View File

@@ -1,13 +1,24 @@
.traces-funnel-details {
display: flex;
// 45px -> height of the tab bar
height: calc(100vh - 45px);
height: 100%;
&__steps-config {
flex-shrink: 0;
width: 600px;
border-right: 1px solid var(--l1-border);
// Positioning context for the absolute .steps-footer.
position: relative;
display: flex;
flex-direction: column;
// Scoped here so the modal usage of FunnelConfiguration on trace details
// stays in normal flow.
.funnel-configuration {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
}
&__steps-results {
width: 100%;

View File

@@ -4,14 +4,17 @@
flex-direction: column;
justify-content: flex-start;
&.funnel-details-page {
height: calc(
100vh - 170px
); // 64px bottom bar + 61px configuration header + 45px page navbar
flex: 1;
min-height: 0;
// .steps-footer is absolute against the config column, so its 64px is
// reserved rather than laid out.
margin-bottom: 64px;
overflow: auto;
}
}
&__header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;

View File

@@ -134,6 +134,24 @@ func (store *store) UpdateAccount(ctx context.Context, account *cloudintegration
BunDBCtx(ctx).
NewUpdate().
Model(account).
Column("config").
Column("updated_at").
WherePK().
Where("org_id = ?", account.OrgID).
Where("provider = ?", account.Provider).
Exec(ctx)
return err
}
func (store *store) UpdateAgentReport(ctx context.Context, account *cloudintegrationtypes.StorableCloudIntegration) error {
_, err := store.
store.
BunDBCtx(ctx).
NewUpdate().
Model(account).
Column("account_id").
Column("last_agent_report").
WherePK().
Where("org_id = ?", account.OrgID).
Where("provider = ?", account.Provider).

View File

@@ -26,6 +26,17 @@ type Account struct {
type AgentReport struct {
TimestampMillis int64 `json:"timestampMillis" required:"true"`
Data map[string]any `json:"data" required:"true" nullable:"true"`
SyncState *SyncState `json:"syncState" required:"true" nullable:"true"`
}
type SyncState struct {
Version int64 `json:"version" required:"true"`
InSync bool `json:"inSync" required:"true"`
Regions map[string]*RegionSyncState `json:"regions" required:"true" nullable:"false"`
}
type RegionSyncState struct {
State RegionState `json:"state" required:"true"`
}
type AccountConfig struct {
@@ -150,6 +161,7 @@ func NewAccountFromStorable(storableAccount *StorableCloudIntegration) (*Account
account.AgentReport = &AgentReport{
TimestampMillis: storableAccount.LastAgentReport.TimestampMillis,
Data: storableAccount.LastAgentReport.Data,
SyncState: NewSyncStateFromStorable(storableAccount.LastAgentReport.SyncState),
}
}
@@ -308,10 +320,101 @@ func NewAccountConfigFromUpdatable(provider CloudProviderType, config *Updatable
}
}
func NewAgentReport(data map[string]any) *AgentReport {
func NewAgentReport(data map[string]any, syncState *SyncState) *AgentReport {
return &AgentReport{
TimestampMillis: time.Now().UnixMilli(),
Data: data,
SyncState: syncState,
}
}
// NewSyncState returns the sync state after a check-in without mutating previous.
// The ack is applied before the config diff, so it is checked against the version the agent was last sent.
func NewSyncState(previous *SyncState, regions []string, removed bool, syncedVersion *int64) *SyncState {
next := &SyncState{Version: 1, InSync: true, Regions: make(map[string]*RegionSyncState)}
// First check-in: seed from the config as in sync. Otherwise start from a copy of previous.
if previous == nil {
for _, region := range regions {
next.Regions[region] = &RegionSyncState{State: RegionStatePresent}
}
} else {
next.Version = previous.Version
next.InSync = previous.InSync
for region, regionSyncState := range previous.Regions {
next.Regions[region] = &RegionSyncState{State: regionSyncState.State}
}
}
// The agent synced this version, so its removed regions are cleaned up and can be dropped.
if syncedVersion != nil && *syncedVersion == next.Version {
next.InSync = true
for region, regionSyncState := range next.Regions {
if regionSyncState.State == RegionStateRemoved {
delete(next.Regions, region)
}
}
}
changed := false
if removed {
// Integration removed: every present region must be cleaned up.
for _, regionSyncState := range next.Regions {
if regionSyncState.State != RegionStateRemoved {
regionSyncState.State = RegionStateRemoved
changed = true
}
}
} else {
desiredRegions := make(map[string]struct{}, len(regions))
for _, region := range regions {
desiredRegions[region] = struct{}{}
regionSyncState, ok := next.Regions[region]
switch {
case !ok:
// Region added to the config.
next.Regions[region] = &RegionSyncState{State: RegionStatePresent}
changed = true
case regionSyncState.State == RegionStateRemoved:
// Region added back before its removal was acked.
regionSyncState.State = RegionStatePresent
changed = true
}
}
for region, regionSyncState := range next.Regions {
if _, desired := desiredRegions[region]; !desired && regionSyncState.State == RegionStatePresent {
// Region removed from the config.
regionSyncState.State = RegionStateRemoved
changed = true
}
}
}
if changed {
next.Version++
next.InSync = false
}
return next
}
func NewSyncStateFromStorable(storableSyncState *StorableSyncState) *SyncState {
if storableSyncState == nil {
return nil
}
regions := make(map[string]*RegionSyncState, len(storableSyncState.Regions))
for region, regionSyncState := range storableSyncState.Regions {
regions[region] = &RegionSyncState{State: regionSyncState.State}
}
return &SyncState{
Version: storableSyncState.Version,
InSync: storableSyncState.InSync,
Regions: regions,
}
}
@@ -335,6 +438,26 @@ func (account *Account) Update(provider CloudProviderType, config *AccountConfig
return nil
}
// NextSyncState returns the sync state for this check-in, or nil for providers without one.
func (account *Account) NextSyncState(syncedVersion *int64) *SyncState {
if account.Provider != CloudProviderTypeAWS {
return nil
}
var previous *SyncState
if account.AgentReport != nil {
previous = account.AgentReport.SyncState
}
regions := account.Config.AWS.Regions
// Removed before the agent ever checked in: no region was sent to it, so there is nothing to clean up.
if account.AgentReport == nil && account.RemovedAt != nil {
regions = nil
}
return NewSyncState(previous, regions, account.RemovedAt != nil, syncedVersion)
}
func (postableAccount *PostableAccount) UnmarshalJSON(data []byte) error {
type Alias PostableAccount

View File

@@ -12,7 +12,8 @@ type AgentCheckInRequest struct {
ProviderAccountID string `json:"providerAccountId" required:"false"`
CloudIntegrationID valuer.UUID `json:"cloudIntegrationId" required:"false"`
Data map[string]any `json:"data" required:"true" nullable:"true"`
Data map[string]any `json:"data" required:"true" nullable:"true"`
SyncedVersion *int64 `json:"syncedVersion" required:"false" nullable:"true"`
}
type PostableAgentCheckIn struct {
@@ -28,6 +29,7 @@ type AgentCheckInResponse struct {
ProviderAccountID string `json:"providerAccountId" required:"true"`
IntegrationConfig *ProviderIntegrationConfig `json:"integrationConfig" required:"true"`
RemovedAt *time.Time `json:"removedAt" required:"true" nullable:"true"`
SyncState *SyncState `json:"syncState" required:"true" nullable:"true"`
}
type GettableAgentCheckIn struct {
@@ -73,12 +75,13 @@ func NewGettableAgentCheckIn(provider CloudProviderType, resp *AgentCheckInRespo
return gettable
}
func NewAgentCheckInResponse(providerAccountID, cloudIntegrationID string, integrationConfig *ProviderIntegrationConfig, removedAt *time.Time) *AgentCheckInResponse {
func NewAgentCheckInResponse(providerAccountID, cloudIntegrationID string, integrationConfig *ProviderIntegrationConfig, removedAt *time.Time, syncState *SyncState) *AgentCheckInResponse {
return &AgentCheckInResponse{
CloudIntegrationID: cloudIntegrationID,
ProviderAccountID: providerAccountID,
IntegrationConfig: integrationConfig,
RemovedAt: removedAt,
SyncState: syncState,
}
}

View File

@@ -25,6 +25,17 @@ var (
ErrCodeServiceDefinitionNotFound = errors.MustNewCode("service_definition_not_found")
)
var (
RegionStatePresent = RegionState{valuer.NewString("present")}
RegionStateRemoved = RegionState{valuer.NewString("removed")}
)
type RegionState struct{ valuer.String }
func (RegionState) Enum() []any {
return []any{RegionStatePresent, RegionStateRemoved}
}
// StorableCloudIntegration represents a cloud integration stored in the database.
// This is also referred as "Account" in the context of cloud integrations.
type StorableCloudIntegration struct {
@@ -43,8 +54,16 @@ type StorableCloudIntegration struct {
// StorableAgentReport represents the last heartbeat and arbitrary data sent by the agent
// as of now there is no use case for Data field, but keeping it for backwards compatibility with older structure.
type StorableAgentReport struct {
TimestampMillis int64 `json:"timestamp_millis"` // backward compatibility
Data map[string]any `json:"data"`
TimestampMillis int64 `json:"timestamp_millis"` // backward compatibility
Data map[string]any `json:"data"`
SyncState *StorableSyncState `json:"sync_state,omitempty"`
}
// StorableSyncState holds every region sent to the agent. A removed region is dropped only after the agent acks Version.
type StorableSyncState struct {
Version int64 `json:"version"`
InSync bool `json:"in_sync"`
Regions map[string]*RegionSyncState `json:"regions"`
}
// StorableCloudIntegrationService is to store service config for a cloud integration, which is a cloud provider specific configuration.
@@ -148,12 +167,30 @@ func NewStorableCloudIntegration(account *Account) (*StorableCloudIntegration, e
storableAccount.LastAgentReport = &StorableAgentReport{
TimestampMillis: account.AgentReport.TimestampMillis,
Data: account.AgentReport.Data,
SyncState: NewStorableSyncState(account.AgentReport.SyncState),
}
}
return storableAccount, nil
}
func NewStorableSyncState(syncState *SyncState) *StorableSyncState {
if syncState == nil {
return nil
}
regions := make(map[string]*RegionSyncState, len(syncState.Regions))
for region, regionSyncState := range syncState.Regions {
regions[region] = &RegionSyncState{State: regionSyncState.State}
}
return &StorableSyncState{
Version: syncState.Version,
InSync: syncState.InSync,
Regions: regions,
}
}
// NewStorableCloudIntegrationService creates a new StorableCloudIntegrationService with
// generated ID and timestamps from a CloudIntegrationService and its serialized config JSON.
func NewStorableCloudIntegrationService(svc *CloudIntegrationService, configJSON string) *StorableCloudIntegrationService {
@@ -172,6 +209,7 @@ func (account *StorableCloudIntegration) Update(providerAccountID *string, agent
account.LastAgentReport = &StorableAgentReport{
TimestampMillis: agentReport.TimestampMillis,
Data: agentReport.Data,
SyncState: NewStorableSyncState(agentReport.SyncState),
}
}
}

View File

@@ -25,9 +25,12 @@ type Store interface {
// CreateAccount creates a new cloud integration account
CreateAccount(ctx context.Context, account *StorableCloudIntegration) error
// UpdateAccount updates an existing cloud integration account
// UpdateAccount updates the user updatable fields (config) of an existing cloud integration account
UpdateAccount(ctx context.Context, account *StorableCloudIntegration) error
// UpdateAgentReport updates the provider account id and last agent report of an existing cloud integration account
UpdateAgentReport(ctx context.Context, account *StorableCloudIntegration) error
// RemoveAccount marks a cloud integration account as removed by setting the RemovedAt field
RemoveAccount(ctx context.Context, orgID, id valuer.UUID, provider CloudProviderType) error

View File

@@ -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}>")
@@ -530,9 +508,4 @@ def update_raw_channel_config(
path = urlparse(original_url).path
entry[url_field] = notification_channel.container_configs["8080"].get(path)
# Google Chat validates the webhook host
for entry in config.get("googlechat_configs", []):
https = notification_channel.container_configs["443"]
entry["webhook_url"] = f"{https.scheme}://{https.address}{urlparse(entry['webhook_url']).path}"
return config

View File

@@ -222,12 +222,7 @@ def create_clickhouse( # pylint: disable=too-many-arguments,too-many-positional
remote_servers=render_remote_servers([("127.0.0.1", 9000)]),
)
# The mounted configs cannot live in tmpfs: pytest wipes basetemp at
# every session start, and clickhouse hot-reloads config.d, so a reused
# container would silently lose its cluster definition. Like the CA,
# each container gets a fresh directory in the cross-session cache.
tmp_dir = pytestconfig.cache.mkdir(f"{cache_key}-config") / uuid4().hex
tmp_dir.mkdir()
tmp_dir = tmpfs(cache_key)
cluster_config_file_path = os.path.join(tmp_dir, "cluster.xml")
with open(cluster_config_file_path, "w", encoding="utf-8") as f:
f.write(cluster_config)
@@ -411,10 +406,7 @@ def create_clickhouse_cluster( # pylint: disable=too-many-arguments,too-many-po
distributed_ddl_path=distributed_ddl_path,
)
# Not tmpfs: see create_clickhouse — basetemp wipes would make
# reused nodes lose their hot-reloaded cluster definition.
tmp_dir = pytestconfig.cache.mkdir(f"{cache_key}-config") / f"{suffix}-{i:02d}"
tmp_dir.mkdir()
tmp_dir = tmpfs(f"clickhouse-{suffix}-{i:02d}")
cluster_config_file_path = os.path.join(tmp_dir, "cluster.xml")
with open(cluster_config_file_path, "w", encoding="utf-8") as f:
f.write(node_config)

View File

@@ -1,46 +1,23 @@
# pylint: disable=line-too-long
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
import pytest
import requests
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__)
# Google Chat validates the webhook host, 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; channels point at https://<host>/...
GOOGLE_CHAT_HOST = "chat.googleapis.com"
# incident.io doesn't pin the host, but the same alias trick keeps channel URLs
# identical to production ones.
INCIDENTIO_HOST = "api.incident.io"
# Jira validates the site host (*.atlassian.net); service accounts additionally
# go through the api.atlassian.com gateway.
JIRA_HOST = "signoz-test.atlassian.net"
ATLASSIAN_API_HOST = "api.atlassian.com"
TLS_HOSTS = [GOOGLE_CHAT_HOST, INCIDENTIO_HOST, JIRA_HOST, 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",
@@ -147,363 +124,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 URL host is the
wiremock network alias, so no runtime injection is needed."""
return {
"incidentio_configs": [
{
"url": f"https://{INCIDENTIO_HOST}{incidentio_path(source_id)}",
"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 against the wiremock atlassian.net alias, personal
API token auth. Summary/description are omitted so the backend applies its
default templates; overrides lay extra receiver fields on top."""
return {
"jira_configs": [
{
"site": f"https://{JIRA_HOST}",
"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:
@@ -512,25 +135,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
# chat.googleapis.com with a CA-issued cert so Google Chat's validated
# webhook host routes 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 chat.googleapis.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,
@@ -541,11 +148,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),
# Google Chat delivery: https to the validated host via the network alias.
"443": types.TestContainerUrlConfig("https", GOOGLE_CHAT_HOST, 443),
},
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
)
def delete(container: types.TestContainerDocker):
@@ -562,16 +165,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,
@@ -580,7 +173,6 @@ def notification_channel( # pylint: disable=too-many-arguments,too-many-positio
create,
delete,
restore,
stale=stale,
)
@@ -682,31 +274,6 @@ def create_webhook_notification_channel(
return _create_webhook_notification_channel
def wait_for_org_registration(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
View File

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

View File

@@ -1,187 +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.logger import setup_logger
from fixtures.notification_channel import (
googlechat_card_subset,
googlechat_config,
googlechat_ok_mappings,
googlechat_retry_mappings,
wait_for_org_registration,
)
logger = setup_logger(__name__)
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_org_registration(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_org_registration(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"}]},
},
),
],
),
)

View File

@@ -1,114 +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.logger import setup_logger
from fixtures.notification_channel import googlechat_config
logger = setup_logger(__name__)
# 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/07_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"])

View File

@@ -1,163 +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.logger import setup_logger
from fixtures.notification_channel import (
incidentio_config,
incidentio_event_subset,
incidentio_ok_mappings,
incidentio_path,
incidentio_retry_mappings,
wait_for_org_registration,
)
logger = setup_logger(__name__)
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_org_registration(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_org_registration(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"},
},
),
],
),
)

View File

@@ -1,120 +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.logger import setup_logger
from fixtures.notification_channel import INCIDENTIO_TEST_TOKEN, incidentio_config, incidentio_path
logger = setup_logger(__name__)
# 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/09_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"

View File

@@ -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.logger import setup_logger
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_org_registration,
)
logger = setup_logger(__name__)
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_org_registration(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_org_registration(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},
),
],
),
)

View File

@@ -1,324 +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.logger import setup_logger
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_org_registration,
)
logger = setup_logger(__name__)
# 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/11_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_org_registration(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_org_registration(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_org_registration(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_org_registration(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_org_registration(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_org_registration(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

View File

@@ -1,169 +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.logger import setup_logger
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_org_registration,
)
logger = setup_logger(__name__)
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_org_registration(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_org_registration(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},
),
],
),
)

View File

@@ -1,165 +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.logger import setup_logger
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_org_registration,
)
logger = setup_logger(__name__)
# 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/13_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_org_registration(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_org_registration(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_org_registration(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

View File

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