Compare commits

..

9 Commits

Author SHA1 Message Date
Gaurav Tewari
75e6ceb4bb Merge remote-tracking branch 'refs/remotes/origin/main' into feat/ai-query-dashboard-editor 2026-09-01 16:58:23 +05:30
Gaurav Tewari
7d3273c423 feat(dashboards): support AI queries in the V2 panel editor
Adds an "AI Query Builder" tab to the V2 panel editor and makes
builder_ai_query survive a save/load round trip.

The tab is not an EQueryType: AI-ness stays on the per-query
builderQueryType tag, so the wire query type remains `builder` and the
existing serialization paths are untouched.

Assisted-by: Claude Opus 4.5
2026-09-01 12:42:00 +05:30
Gaurav Tewari
a88cc79ef9 chore: remove extra comments 2026-08-27 17:33:36 +05:30
Gaurav Tewari
e424082835 Merge remote-tracking branch 'origin/main' into feat/qb-changes-for-ai-explorer 2026-08-27 15:01:58 +05:30
Gaurav Tewari
2afed07b5b Merge remote-tracking branch 'refs/remotes/origin/main' into feat/qb-changes-for-ai-explorer 2026-08-27 12:31:02 +05:30
Gaurav Tewari
1c0dc018e0 chore: remove feildcontext 2026-08-27 08:58:28 +05:30
Gaurav Tewari
bb2511ce53 chore: update feild value changes 2026-08-27 08:39:35 +05:30
Gaurav Tewari
33d22c8b59 fix: keep the shared key-suggestion call site on the response envelope
fetchFieldKeysForQuery returned the unwrapped keys map, so QuerySearch guarded
on `keys` instead of `response.data.data`. That silently changed behaviour for
every explorer: a response carrying an envelope with no keys used to throw, and
had started skipping instead.

The fetcher now returns the response envelope, so the call site in the shared
component is unchanged from before apart from the fetcher swap. The generic
branch passes its response straight through; only the ai_observability branch
adapts, normalizing its nullable keys map.
2026-08-27 00:05:24 +05:30
Gaurav Tewari
c1b9de0c8a feat(llm-observability): wire the ai explorer to builder_ai_query
Adds the builder_ai_query envelope type, which the backend routes to the
gen_ai-scoped trace builder. Queries without the field still serialize as
builder_query, so nothing outside the AI explorer changes.

- filter bar keys and values come from the ai_observability endpoints; the
  per-trace aggregates are computed in SQL and never reach the metadata
  store, so the generic endpoints cannot serve them
- values forward fieldContext, which is what lets the endpoint reach those
  aggregates instead of scanning for attributes that were never ingested
- the span scope selector is hidden for AI queries: root and entrypoint
  spans AND badly with the gate, since GenAI attributes sit on nested spans
- newly added builder queries inherit builderQueryType from the first, the
  way they already inherit source
- response conversion reads aggregation metadata from both builder envelopes
- Trace replaces List as the explorer's default view, and the builder no
  longer renders its own order by for the list and trace panels

Assisted-by: Claude Opus 4.5
2026-08-26 20:37:00 +05:30
89 changed files with 1276 additions and 5198 deletions

View File

@@ -109,57 +109,6 @@ components:
webhook_url:
$ref: '#/components/schemas/ConfigSecretURL'
type: object
AlertmanagertypesJSMOpsReceiverConfig:
properties:
api_key:
type: string
description:
type: string
http_config:
$ref: '#/components/schemas/ConfigHTTPClientConfig'
message:
type: string
priority:
type: string
send_resolved:
type: boolean
tags:
type: string
type: object
AlertmanagertypesJiraReceiverConfig:
properties:
custom_fields:
additionalProperties: {}
type: object
description:
type: string
http_config:
$ref: '#/components/schemas/ConfigHTTPClientConfig'
issue_type:
type: string
labels:
items:
type: string
type: array
priority:
type: string
project:
type: string
reopen_duration:
$ref: '#/components/schemas/ModelDuration'
reopen_transition:
type: string
resolve_transition:
type: string
send_resolved:
type: boolean
site:
type: string
summary:
type: string
wont_fix_resolution:
type: string
type: object
AlertmanagertypesMaintenanceKind:
enum:
- fixed
@@ -213,10 +162,6 @@ components:
oneOf:
- required:
- googlechat_configs
- required:
- jira_configs
- required:
- jsmops_configs
- required:
- discord_configs
- required:
@@ -247,6 +192,8 @@ components:
- msteams_configs
- required:
- msteamsv2_configs
- required:
- jira_configs
- required:
- rocketchat_configs
- required:
@@ -270,11 +217,7 @@ components:
type: array
jira_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
type: array
jsmops_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
$ref: '#/components/schemas/ConfigJiraConfig'
type: array
mattermost_configs:
items:
@@ -401,11 +344,7 @@ components:
type: array
jira_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
type: array
jsmops_configs:
items:
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
$ref: '#/components/schemas/ConfigJiraConfig'
type: array
mattermost_configs:
items:

View File

@@ -385,93 +385,6 @@ export interface AlertmanagertypesGoogleChatReceiverConfigDTO {
webhook_url?: ConfigSecretURLDTO;
}
export interface AlertmanagertypesJSMOpsReceiverConfigDTO {
/**
* @type string
*/
api_key?: string;
/**
* @type string
*/
description?: string;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
message?: string;
/**
* @type string
*/
priority?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
tags?: string;
}
export type AlertmanagertypesJiraReceiverConfigDTOCustomFields = {
[key: string]: unknown;
};
export type ModelDurationDTO = number;
export interface AlertmanagertypesJiraReceiverConfigDTO {
/**
* @type object
*/
custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields;
/**
* @type string
*/
description?: string;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
issue_type?: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project?: string;
reopen_duration?: ModelDurationDTO;
/**
* @type string
*/
reopen_transition?: string;
/**
* @type string
*/
resolve_transition?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
/**
* @type string
*/
site?: string;
/**
* @type string
*/
summary?: string;
/**
* @type string
*/
wont_fix_resolution?: string;
}
export enum AlertmanagertypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
@@ -718,6 +631,69 @@ export interface ConfigIncidentioConfigDTO {
url_file?: string;
}
export interface ConfigJiraFieldConfigDTO {
/**
* @type boolean,null
*/
enable_update?: boolean | null;
/**
* @type string
*/
template?: string;
}
export type ModelDurationDTO = number;
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
export interface ConfigJiraConfigDTO {
/**
* @type string
*/
api_type?: string;
api_url?: ConfigURLType2DTO;
/**
* @type object
*/
custom_fields?: ConfigJiraConfigDTOCustomFields;
description?: ConfigJiraFieldConfigDTO;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
issue_type?: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project?: string;
reopen_duration?: ModelDurationDTO;
/**
* @type string
*/
reopen_transition?: string;
/**
* @type string
*/
resolve_transition?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
summary?: ConfigJiraFieldConfigDTO;
/**
* @type string
*/
wont_fix_resolution?: string;
}
export interface ConfigMattermostFieldDTO {
/**
* @type boolean,null
@@ -1676,11 +1652,7 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
/**
* @type array
*/
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
/**
* @type array
*/
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
jira_configs?: ConfigJiraConfigDTO[];
/**
* @type array
*/
@@ -1807,11 +1779,7 @@ export interface AlertmanagertypesReceiverDTO {
/**
* @type array
*/
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
/**
* @type array
*/
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
jira_configs?: ConfigJiraConfigDTO[];
/**
* @type array
*/
@@ -3300,67 +3268,6 @@ export interface CommonJSONRefDTO {
$ref?: string;
}
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
export interface ConfigJiraFieldConfigDTO {
/**
* @type boolean,null
*/
enable_update?: boolean | null;
/**
* @type string
*/
template?: string;
}
export interface ConfigJiraConfigDTO {
/**
* @type string
*/
api_type?: string;
api_url?: ConfigURLType2DTO;
/**
* @type object
*/
custom_fields?: ConfigJiraConfigDTOCustomFields;
description?: ConfigJiraFieldConfigDTO;
http_config?: ConfigHTTPClientConfigDTO;
/**
* @type string
*/
issue_type?: string;
/**
* @type array
*/
labels?: string[];
/**
* @type string
*/
priority?: string;
/**
* @type string
*/
project?: string;
reopen_duration?: ModelDurationDTO;
/**
* @type string
*/
reopen_transition?: string;
/**
* @type string
*/
resolve_transition?: string;
/**
* @type boolean
*/
send_resolved?: boolean;
summary?: ConfigJiraFieldConfigDTO;
/**
* @type string
*/
wont_fix_resolution?: string;
}
export interface DashboardGridItemDTO {
content?: CommonJSONRefDTO;
/**

View File

@@ -2,8 +2,10 @@ import { cloneDeep, isEmpty } from 'lodash-es';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import {
BuilderQuery,
DistributionData,
MetricRangePayloadV5,
QueryEnvelope,
QueryRangeRequestV5,
RawData,
ScalarData,
@@ -11,6 +13,11 @@ import {
} from 'types/api/v5/queryRange';
import { QueryDataV3 } from 'types/api/widgets/getQuery';
const isBuilderQueryEnvelope = (
envelope: QueryEnvelope,
): envelope is QueryEnvelope & { spec: BuilderQuery } =>
envelope.type === 'builder_query' || envelope.type === 'builder_ai_query';
function getColName(
col: ScalarData['columns'][number],
legendMap: Record<string, string>,
@@ -409,21 +416,15 @@ export function convertV5ResponseToLegacy(
const v5Data = payload?.data;
const aggregationPerQuery =
params?.compositeQuery?.queries
?.filter((query) => query.type === 'builder_query')
.reduce(
(acc, query) => {
if (
query.type === 'builder_query' &&
'aggregations' in query.spec &&
query.spec.name
) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
params?.compositeQuery?.queries?.filter(isBuilderQueryEnvelope).reduce(
(acc, query) => {
if ('aggregations' in query.spec && query.spec.name) {
acc[query.spec.name] = query.spec.aggregations;
}
return acc;
},
{} as Record<string, any>,
) || {};
// clickhouse_sql queries have no aggregation metadata; their value columns
// are named/keyed by the real SQL alias the response carries (see getColId).

View File

@@ -14,6 +14,7 @@ import {
QueryBuilderFormula as V5QueryBuilderFormula,
QueryEnvelope,
QueryRangePayloadV5,
RequestType,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -935,3 +936,41 @@ describe('convertBuilderQueriesToV5 having normalization', () => {
});
});
});
describe('convertBuilderQueriesToV5 builder query type', () => {
const buildEnvelope = (
builderQueryType: IBuilderQuery['builderQueryType'],
requestType: RequestType,
): QueryEnvelope => {
const [envelope] = convertBuilderQueriesToV5(
{
A: {
dataSource: DataSource.TRACES,
queryName: 'A',
builderQueryType,
} as unknown as IBuilderQuery,
},
requestType,
);
return envelope;
};
it.each<[RequestType]>([
['trace'],
['raw'],
['time_series'],
['scalar'],
['distribution'],
])('sends builder_ai_query for the %s request type', (requestType) => {
expect(buildEnvelope('builder_ai_query', requestType).type).toBe(
'builder_ai_query',
);
});
it.each<[string, IBuilderQuery['builderQueryType']]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('sends builder_query for %s', (_label, builderQueryType) => {
expect(buildEnvelope(builderQueryType, 'trace').type).toBe('builder_query');
});
});

View File

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

View File

@@ -16,8 +16,6 @@ import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import cx from 'classnames';
import {
negationQueryOperatorSuggestions,
@@ -54,6 +52,12 @@ import {
SUGGESTION_FETCH_DEBOUNCE_MS,
SUGGESTIONS_SECTION,
} from './constants';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
SuggestedFieldKey,
SuggestedFieldKeysByName,
} from './fieldSuggestions';
import {
combineInitialAndUserExpression,
dedupeOptionsByLabel,
@@ -264,10 +268,8 @@ function QuerySearch({
);
// Add back the generateOptions function and useEffect
const generateOptions = (keys: {
[key: string]: QueryKeyDataSuggestionsProps[];
}): any[] =>
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
items.map(({ name, fieldDataType, fieldContext }) => ({
label: name,
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
@@ -320,8 +322,9 @@ function QuerySearch({
lastFetchedKeyRef.current = searchText || '';
const response = await getKeySuggestions({
signal: dataSource,
const response = await fetchFieldKeysForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
searchText: searchText || '',
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
@@ -363,6 +366,7 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
queryData.builderQueryType,
],
);
@@ -496,10 +500,11 @@ function QuerySearch({
try {
const values = valueSuggestionsOverride
? await valueSuggestionsOverride(key, sanitizedSearchText)
: await getValueSuggestions({
: await fetchFieldValuesForQuery({
builderQueryType: queryData.builderQueryType,
dataSource,
key,
searchText: sanitizedSearchText,
signal: dataSource,
signalSource: signalSource as 'meter' | '',
metricName: debouncedMetricName ?? undefined,
}).then((response) => {
@@ -604,6 +609,7 @@ function QuerySearch({
signalSource,
toggleSuggestions,
valueSuggestionsOverride,
queryData.builderQueryType,
],
);

View File

@@ -0,0 +1,215 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { DataSource } from 'types/common/queryBuilder';
import {
fetchFieldKeysForQuery,
fetchFieldValuesForQuery,
} from '../fieldSuggestions';
jest.mock('api/generated/services/ai-observability', () => ({
getAIObservabilityFieldsKeys: jest.fn(),
getAIObservabilityFieldsValues: jest.fn(),
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn(),
}));
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
getValueSuggestions: jest.fn(),
}));
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
typeof getAIObservabilityFieldsKeys
>;
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions
>;
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
typeof getAIObservabilityFieldsValues
>;
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions
>;
const aiValuesResponse = (
values: { stringValues?: string[]; numberValues?: number[] } | null,
complete = true,
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
({
status: 'success',
data: { complete, values },
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
describe('fetchFieldKeysForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: {
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
},
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const keys = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: 'llm',
});
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
expect(mockedGenericKeys).not.toHaveBeenCalled();
expect(keys.data.data).toStrictEqual({
complete: true,
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
mockedGenericKeys.mockResolvedValue({
data: { status: 'success', data: { complete: true, keys: {} } },
} as Awaited<ReturnType<typeof getKeySuggestions>>);
await fetchFieldKeysForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
searchText: 'svc',
});
expect(mockedAIKeys).not.toHaveBeenCalled();
expect(mockedGenericKeys).toHaveBeenCalledWith(
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
);
});
it('normalizes a null ai_observability keys payload to an empty map', async () => {
mockedAIKeys.mockResolvedValue({
status: 'success',
data: { complete: false, keys: null },
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
const response = await fetchFieldKeysForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
searchText: '',
});
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
});
it('passes the generic response through untouched', async () => {
const genericResponse = {
data: { status: 'success', data: { complete: true, keys: {} } },
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
mockedGenericKeys.mockResolvedValue(genericResponse);
await expect(
fetchFieldKeysForQuery({
builderQueryType: 'builder_query',
dataSource: DataSource.TRACES,
searchText: '',
}),
).resolves.toBe(genericResponse);
});
});
describe('fetchFieldValuesForQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
mockedAIValues.mockResolvedValue(
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
);
const response = await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'gen_ai.request.model',
searchText: 'gpt',
});
expect(mockedGenericValues).not.toHaveBeenCalled();
expect(response).toStrictEqual({
data: {
data: {
complete: true,
values: { stringValues: ['gpt-4o'], numberValues: [] },
},
},
});
});
it('forwards the key as the name the endpoint expects', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
await fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'total_tokens',
searchText: '',
});
expect(mockedAIValues).toHaveBeenCalledWith({
name: 'total_tokens',
searchText: '',
});
});
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
await expect(
fetchFieldValuesForQuery({
builderQueryType: 'builder_ai_query',
dataSource: DataSource.TRACES,
key: 'llm_call_count',
searchText: '',
}),
).resolves.toStrictEqual({
data: { data: { complete: false, values: null } },
});
});
it.each<[string, 'builder_query' | undefined]>([
['an unmarked query', undefined],
['an explicitly generic query', 'builder_query'],
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
const genericResponse = {
data: {
data: { complete: false, values: { stringValues: ['frontend'] } },
},
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
mockedGenericValues.mockResolvedValue(genericResponse);
const response = await fetchFieldValuesForQuery({
builderQueryType,
dataSource: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
});
expect(mockedAIValues).not.toHaveBeenCalled();
expect(mockedGenericValues).toHaveBeenCalledWith(
expect.objectContaining({
signal: DataSource.TRACES,
key: 'service.name',
searchText: 'front',
}),
);
expect(response).toBe(genericResponse);
});
});

View File

@@ -0,0 +1,111 @@
import {
getAIObservabilityFieldsKeys,
getAIObservabilityFieldsValues,
} from 'api/generated/services/ai-observability';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export interface SuggestedFieldKey {
name: string;
fieldContext?: string;
fieldDataType?: string;
}
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
export interface SuggestedFieldKeysPayload {
complete: boolean;
keys: SuggestedFieldKeysByName;
}
export interface SuggestedFieldKeysResponse {
data: { data?: SuggestedFieldKeysPayload };
}
export interface SuggestedFieldValuesPayload {
complete?: boolean;
values?: {
stringValues?: string[] | null;
numberValues?: number[] | null;
} | null;
}
export interface SuggestedFieldValuesResponse {
data: { data?: SuggestedFieldValuesPayload };
}
interface FetchFieldKeysParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
metricNamespace?: string;
}
interface FetchFieldValuesParams {
builderQueryType: IBuilderQuery['builderQueryType'];
dataSource: DataSource;
key: string;
searchText: string;
metricName?: string;
signalSource?: 'meter' | '';
}
export const fetchFieldKeysForQuery = async ({
builderQueryType,
dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsKeys({ searchText });
return {
data: {
data: response.data
? { complete: response.data.complete, keys: response.data.keys ?? {} }
: undefined,
},
};
}
return getKeySuggestions({
signal: dataSource,
searchText,
metricName,
signalSource,
metricNamespace,
});
};
export const fetchFieldValuesForQuery = async ({
builderQueryType,
dataSource,
key,
searchText,
metricName,
signalSource,
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
if (builderQueryType === 'builder_ai_query') {
const response = await getAIObservabilityFieldsValues({
name: key,
searchText,
});
return { data: { data: response.data } };
}
// getValueSuggestions' declared response type does not match what the endpoint returns.
return getValueSuggestions({
signal: dataSource,
key,
searchText,
signalSource,
metricName,
}) as unknown as Promise<SuggestedFieldValuesResponse>;
};

View File

@@ -54,7 +54,7 @@ export const QueryV2 = forwardRef(function QueryV2(
const { cloneQuery, panelType } = useQueryBuilder();
const showFunctions = query?.functions?.length > 0;
const { dataSource } = query;
const { dataSource, builderQueryType } = query;
const [isCollapsed, setIsCollapsed] = useState(false);
@@ -94,8 +94,9 @@ export const QueryV2 = forwardRef(function QueryV2(
);
const showSpanScopeSelector = useMemo(
() => dataSource === DataSource.TRACES,
[dataSource],
() =>
dataSource === DataSource.TRACES && builderQueryType !== 'builder_ai_query',
[dataSource, builderQueryType],
);
const showInlineQuerySearch = useMemo(() => {

View File

@@ -29,7 +29,6 @@ export enum InfraMonitoringEvents {
MetricsView = 'metrics',
Total = 'total',
Cluster = 'cluster',
Container = 'container',
DaemonSet = 'daemonSet',
Deployment = 'deployment',
Job = 'job',

View File

@@ -348,6 +348,19 @@ export const initialQueryMeterWithType: Query = {
},
};
export const initialQueryAIWithType: Query = {
...initialQueryWithType,
builder: {
...initialQueryWithType.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
builderQueryType: 'builder_ai_query',
},
],
},
};
export const operatorsByTypes: Record<LocalDataType, string[]> = {
string: Object.values(StringOperators),
number: Object.values(NumberOperators),

View File

@@ -4,9 +4,8 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './ColumnHeader.module.scss';
import cx from 'classnames';
import { MouseEventHandler } from 'react';
import { DOCS_BASE_URL } from 'constants/app';
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
interface ColumnHeaderProps {
children?: React.ReactNode;
@@ -44,7 +43,7 @@ function ColumnHeader({
<div onClick={stopPropagationHandler}>
{tooltipTitle}{' '}
<a
href={`${DOCS_ROOT}${docPath}`}
href={`${DOCS_BASE_URL}${docPath}`}
target="_blank"
rel="noopener"
onClick={stopPropagationHandler}

View File

@@ -2,6 +2,7 @@
display: flex;
align-items: center;
gap: var(--spacing-5);
padding-left: 4px;
}
.infoIcon {

View File

@@ -2,9 +2,8 @@ import { Group, Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './EntityGroupHeader.module.scss';
import { DOCS_BASE_URL } from 'constants/app';
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
interface EntityGroupHeaderProps {
title: string;
@@ -29,7 +28,7 @@ function EntityGroupHeader({
<>
{tooltipTitle}{' '}
<a
href={`${DOCS_ROOT}${docPath}`}
href={`${DOCS_BASE_URL}${docPath}`}
target="_blank"
rel="noopener"
onClick={(e): void => e.stopPropagation()}

View File

@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQuery } from 'react-query';
import { X } from '@signozhq/icons';
import { useCopyToClipboard } from 'react-use';
import { Copy, X } from '@signozhq/icons';
import { Divider } from '@signozhq/ui/divider';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
@@ -18,7 +20,6 @@ import {
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
import { useInfraMonitoringSelectedItemParams } from '../hooks';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import LoadingContainer from '../LoadingContainer';
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
@@ -94,14 +95,12 @@ export default function K8sBaseDetails<T>({
selectedItem,
selectedItemParams.clusterName,
selectedItemParams.namespaceName,
selectedItemParams.containerName,
),
[
queryKeyPrefix,
selectedItem,
selectedItemParams.clusterName,
selectedItemParams.namespaceName,
selectedItemParams.containerName,
selectedTime,
getAutoRefreshQueryKey,
],
@@ -171,9 +170,14 @@ export default function K8sBaseDetails<T>({
[handleClose],
);
const [, copyToClipboard] = useCopyToClipboard();
const handleCopyId = useCallback((): void => {
toast.success('ID copied to clipboard', { position: 'bottom-left' });
}, []);
if (selectedItem) {
copyToClipboard(selectedItem);
toast.success('ID copied to clipboard', { position: 'bottom-left' });
}
}, [copyToClipboard, selectedItem]);
const entityName = entity ? getEntityName(entity) : '';
@@ -207,13 +211,17 @@ export default function K8sBaseDetails<T>({
(isEntityLoading && 'Loading...') ||
'-'}
</Typography.Text>
<CopyButton
value={selectedItem ?? ''}
ariaLabel="Copy ID"
className={styles.copyIdButton}
testId="copy-id-button"
onCopy={handleCopyId}
/>
<TooltipSimple title="Copy ID">
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleCopyId}
data-testid="copy-id-button"
>
<Copy size={14} />
</Button>
</TooltipSimple>
</>
) as unknown as string;

View File

@@ -9,6 +9,7 @@ import {
import { Button } from '@signozhq/ui/button';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
@@ -41,7 +42,6 @@ import {
} from '../hooks';
import { EntityCountsSection } from './components/EntityCountsSection/EntityCountsSection';
import { EntityMetadataItem } from './components/EntityMetadataItem/EntityMetadataItem';
import { K8sBaseDetailsContentProps } from './types';
import { getDrawerDurationMs } from './useDrawerLifecycleStore';
@@ -239,18 +239,41 @@ export default function K8sBaseDetailsContent<T>({
<>
<div className={styles.entityDetailsEntity}>
<div className={styles.entityDetailsGrid}>
{metadataConfig.map((config) => {
const value = config.getValue(entity);
return (
<EntityMetadataItem
<div className={styles.labelsRow}>
{metadataConfig.map((config) => (
<Typography.Text
key={config.label}
label={config.label}
value={String(value)}
renderedValue={config.render?.(value, entity)}
/>
);
})}
color="muted"
size="small"
weight="medium"
className={styles.entityDetailsMetadataLabel}
>
{config.label}
</Typography.Text>
))}
</div>
<div className={styles.valuesRow}>
{metadataConfig.map((config) => {
const value = config.getValue(entity);
if (config.render) {
return config.render(value, entity);
}
const displayValue = String(value);
return (
<Typography.Text
key={config.label}
size="small"
weight="medium"
className={styles.entityDetailsMetadataValue}
>
{displayValue}
</Typography.Text>
);
})}
</div>
</div>
{countsConfig &&

View File

@@ -296,7 +296,6 @@ export function K8sBaseList<
params.selectedItem,
params.clusterName,
params.namespaceName,
params.containerName,
);
queryClient.setQueryData(detailQueryKey, { data: record });
}
@@ -349,12 +348,6 @@ export function K8sBaseList<
params.namespaceName,
);
}
if (params.containerName) {
url.searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME,
params.containerName,
);
}
} else {
url.searchParams.set(
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM,

View File

@@ -19,12 +19,6 @@
& [data-hide-expanded='true'] {
display: none;
}
// The icon slot is rendered even when the icon inside it is hidden, and the
// header's flex gap then indents the title past the values below it.
& [data-slot='icon']:has([data-hide-expanded='true']) {
display: none;
}
}
.expandedTableFooter {

View File

@@ -214,7 +214,6 @@ export function K8sExpandedRow<
params.selectedItem,
params.clusterName,
params.namespaceName,
params.containerName,
);
queryClient.setQueryData(detailQueryKey, { data: row });
}

View File

@@ -1,44 +0,0 @@
.metadataItem {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
min-width: 0;
}
.valueRow {
display: flex;
align-items: center;
gap: var(--spacing-1);
min-width: 0;
}
.label {
letter-spacing: 0.44px;
text-transform: uppercase;
}
// Single-line ellipsis rather than Typography's `truncate`, which line-clamps:
// clamping still wraps the text, so a value breaking at a hyphen ends its line
// early and leaves a gap between the ellipsis and the copy button.
//
// This has to be a block: overflow and text-overflow do not apply to inline
// boxes, and Typography.Text renders an inline span.
.value {
display: block;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.valueText {
font-family: var(--periscope-font-family-mono);
}
// Sized to the icon rather than the default 2rem tap target, so it sits flush
// against the value instead of floating in its own block of padding.
.copyButton {
--button-padding: 2px;
--button-height: auto;
--button-width: auto;
}

View File

@@ -1,63 +0,0 @@
import { ReactNode, useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import styles from './EntityMetadataItem.module.scss';
export interface EntityMetadataItemProps {
label: string;
value: string;
/** An entity-supplied renderer, which opts out of clamping, tooltip and copy. */
renderedValue?: ReactNode;
}
export function EntityMetadataItem({
label,
value,
renderedValue,
}: EntityMetadataItemProps): JSX.Element {
const handleCopy = useCallback((): void => {
toast.success(`${label} copied to clipboard`, { position: 'bottom-left' });
}, [label]);
return (
<div className={styles.metadataItem}>
<Typography.Text
color="muted"
size="small"
weight="medium"
className={styles.label}
>
{label}
</Typography.Text>
{renderedValue ?? (
<div className={styles.valueRow}>
<TooltipSimple title={value} arrow side="bottom" align="start">
<span className={styles.value}>
<Typography.Text
size="small"
weight="medium"
className={styles.valueText}
>
{value}
</Typography.Text>
</span>
</TooltipSimple>
{!!value && (
<CopyButton
value={value}
size={10}
ariaLabel={`Copy ${label}`}
className={styles.copyButton}
testId={`copy-metadata-${label.toLowerCase().replace(/\s+/g, '-')}`}
onCopy={handleCopy}
/>
)}
</div>
)}
</div>
);
}

View File

@@ -1,110 +0,0 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { EntityMetadataItem } from '../EntityMetadataItem';
const mockCopyToClipboard = jest.fn();
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, jest.Mock] => [null, mockCopyToClipboard],
}));
const mockToastSuccess = jest.fn();
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: {
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
},
}));
describe('EntityMetadataItem', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the label and its value', () => {
render(<EntityMetadataItem label="Cluster Name" value="prod-cluster" />);
expect(screen.getByText('Cluster Name')).toBeInTheDocument();
expect(screen.getByText('prod-cluster')).toBeInTheDocument();
});
it('copies the full value and confirms which field was copied', async () => {
render(
<EntityMetadataItem
label="Image:Tag"
value="ghcr.io/open-telemetry/demo:1.12.0-loadgenerator"
/>,
);
await userEvent.click(screen.getByTestId('copy-metadata-image:tag'));
await waitFor(() => {
expect(mockCopyToClipboard).toHaveBeenCalledWith(
'ghcr.io/open-telemetry/demo:1.12.0-loadgenerator',
);
});
expect(mockToastSuccess).toHaveBeenCalledWith(
'Image:Tag copied to clipboard',
expect.anything(),
);
});
it('offers no copy control when the value is empty', () => {
render(<EntityMetadataItem label="Node" value="" />);
expect(screen.queryByTestId('copy-metadata-node')).not.toBeInTheDocument();
});
it('exposes the full value on hover', async () => {
render(
<EntityMetadataItem
label="Node"
value="gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t"
/>,
);
await userEvent.hover(
screen.getByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t'),
);
await waitFor(() => {
expect(
screen.getAllByText('gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-zv4t').length,
).toBeGreaterThan(1);
});
});
it('never presents the value as clickable', () => {
render(<EntityMetadataItem label="Node" value="a-very-long-node-name" />);
const valueEl = screen.getByText('a-very-long-node-name');
expect(valueEl).not.toHaveAttribute('data-interactive');
expect(valueEl).not.toHaveAttribute('data-truncate');
});
it('triggers the tooltip from the wrapper, never from the text itself', () => {
render(<EntityMetadataItem label="Node" value="a-very-long-node-name" />);
// Radix merges its handlers onto the trigger, and Typography styles
// itself interactive off any merged onClick — so the trigger has to stay
// off the text.
const textEl = screen.getByText('a-very-long-node-name');
expect(textEl).not.toHaveAttribute('data-slot', 'tooltip-trigger');
expect(textEl.parentElement).toHaveAttribute('data-slot', 'tooltip-trigger');
});
it('leaves an entity-supplied renderer alone', () => {
render(
<EntityMetadataItem
label="Status"
value="running"
renderedValue={<span data-testid="custom">custom node</span>}
/>,
);
expect(screen.getByTestId('custom')).toBeInTheDocument();
expect(screen.queryByTestId('copy-metadata-status')).not.toBeInTheDocument();
});
});

View File

@@ -11,7 +11,6 @@ import { jobEntityConfig } from '../Jobs/entity.config';
import { daemonSetEntityConfig } from '../DaemonSets/entity.config';
import { statefulSetEntityConfig } from '../StatefulSets/entity.config';
import { volumeEntityConfig } from '../Volumes/entity.config';
import { containerEntityConfig } from '../Containers/entity.config';
type AnyEntityConfig = K8sEntityConfig<
K8sEntityData,
@@ -35,7 +34,6 @@ export const entityRegistry: Record<string, AnyEntityConfig> = {
[K8sCategories.DAEMONSETS]: registerConfig(daemonSetEntityConfig),
[K8sCategories.STATEFULSETS]: registerConfig(statefulSetEntityConfig),
[K8sCategories.VOLUMES]: registerConfig(volumeEntityConfig),
[K8sCategories.CONTAINERS]: registerConfig(containerEntityConfig),
};
export function getEntityConfig(category: string): AnyEntityConfig | undefined {

View File

@@ -1,142 +0,0 @@
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
import {
k8sContainerGetSelectedItemExpression,
k8sContainerInitialEventsExpression,
k8sContainerInitialLogTracesExpression,
} from 'container/InfraMonitoringK8sV2/Containers/constants';
import { getContainerMetricsQueryPayload } from 'container/InfraMonitoringK8sV2/Containers/metrics';
import {
getK8sContainerItemKey,
getK8sContainerRowKey,
} from 'container/InfraMonitoringK8sV2/Containers/table.config';
import { getContainerImageWithTag } from 'container/InfraMonitoringK8sV2/Containers/utils';
function makeContainer(
overrides: Partial<InframonitoringtypesContainerRecordDTO> = {},
): InframonitoringtypesContainerRecordDTO {
return {
containerName: 'nginx',
podUID: 'pod-uid-1',
meta: {
'k8s.container.name': 'nginx',
'k8s.pod.uid': 'pod-uid-1',
'k8s.pod.name': 'web-0',
'k8s.namespace.name': 'production',
'k8s.cluster.name': 'prod-cluster',
'container.image.name': 'nginx',
'container.image.tag': '1.27',
},
...overrides,
} as InframonitoringtypesContainerRecordDTO;
}
describe('container identity', () => {
it('keys a row by the (pod UID, container name) pair', () => {
expect(getK8sContainerRowKey(makeContainer())).toBe('pod-uid-1/nginx');
});
it('carries the container name alongside the pod UID into the drawer params', () => {
expect(getK8sContainerItemKey(makeContainer())).toStrictEqual({
selectedItem: 'pod-uid-1',
containerName: 'nginx',
clusterName: null,
namespaceName: null,
});
});
it('falls back to meta when the record fields are empty', () => {
const container = makeContainer({ containerName: '', podUID: '' });
expect(getK8sContainerItemKey(container)).toStrictEqual({
selectedItem: 'pod-uid-1',
containerName: 'nginx',
clusterName: null,
namespaceName: null,
});
});
it('scopes the details fetch to both halves of the identity', () => {
expect(
k8sContainerGetSelectedItemExpression({
selectedItem: 'pod-uid-1',
containerName: 'nginx',
}),
).toBe("k8s.pod.uid = 'pod-uid-1' AND k8s.container.name = 'nginx'");
});
});
describe('getContainerImageWithTag', () => {
it('renders name and tag together', () => {
expect(getContainerImageWithTag(makeContainer())).toBe('nginx:1.27');
});
it('drops the tag when the image is not pinned', () => {
const container = makeContainer({
meta: { 'container.image.name': 'nginx' },
});
expect(getContainerImageWithTag(container)).toBe('nginx');
});
it('renders nothing when the image name is missing', () => {
expect(getContainerImageWithTag(makeContainer({ meta: {} }))).toBe('');
});
});
describe('container drawer expressions', () => {
it('scopes logs and traces to the container within its pod', () => {
expect(k8sContainerInitialLogTracesExpression(makeContainer())).toBe(
"k8s.pod.uid = 'pod-uid-1' AND k8s.cluster.name = 'prod-cluster' AND k8s.namespace.name = 'production' AND k8s.container.name = 'nginx'",
);
});
it('scopes events to the pod, since k8s emits events per pod', () => {
expect(k8sContainerInitialEventsExpression(makeContainer())).toBe(
"k8s.object.kind = 'Pod' AND k8s.object.name = 'web-0' AND k8s.cluster.name = 'prod-cluster' AND attribute.k8s.namespace.name = 'production'",
);
});
});
describe('getContainerMetricsQueryPayload', () => {
const payloads = getContainerMetricsQueryPayload(makeContainer(), 1000, 2000);
it('returns one payload per documented chart', () => {
expect(payloads).toHaveLength(10);
});
it('scopes every query to the selected container', () => {
payloads.forEach((payload) => {
payload.query.builder.queryData.forEach((query) => {
expect(query.filters?.items).toStrictEqual([
expect.objectContaining({
key: expect.objectContaining({ key: 'k8s.pod.uid' }),
op: '=',
value: 'pod-uid-1',
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'k8s.container.name' }),
op: '=',
value: 'nginx',
}),
]);
});
});
});
it('derives cache memory from the working set and RSS queries', () => {
const memoryByState = payloads[4];
expect(
memoryByState.query.builder.queryData.map((query) => [
query.queryName,
query.aggregateAttribute?.key,
]),
).toStrictEqual([
['A', 'container.memory.rss'],
['B', 'container.memory.working_set'],
]);
expect(memoryByState.query.builder.queryFormulas).toStrictEqual([
expect.objectContaining({ expression: 'B - A', legend: 'Cache Memory' }),
]);
});
});

View File

@@ -1,170 +0,0 @@
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
import {
buildEventsExpression,
buildLogsTracesExpression,
} from '../Base/utils';
import { K8sDetailsMetadataConfig, K8sDetailsWidgetInfo } from '../Base/types';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
CONTAINERS_DOC_PATH,
getContainerImageWithTag,
getContainerName,
getContainerPodUID,
} from './utils';
/** A container row is identified by the (pod UID, container name) pair. */
export const k8sContainerGetSelectedItemExpression = (
params: SelectedItemParams,
): string =>
[
`${INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID} = ${formatValueForExpression(
params.selectedItem ?? '',
)}`,
`${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME} = ${formatValueForExpression(
params.containerName ?? '',
)}`,
].join(' AND ');
export const k8sContainerGetEntityName = getContainerName;
export const k8sContainerDetailsMetadataConfig: K8sDetailsMetadataConfig<InframonitoringtypesContainerRecordDTO>[] =
[
{
label: 'Pod',
getValue: (c): string =>
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
},
{
label: 'NAMESPACE',
getValue: (c): string =>
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
},
{
label: 'Node',
getValue: (c): string =>
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
},
{
label: 'Cluster Name',
getValue: (c): string =>
c.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
},
{
label: 'Image:Tag',
getValue: getContainerImageWithTag,
},
];
export const k8sContainerInitialLogTracesExpression = (
container: InframonitoringtypesContainerRecordDTO,
): string => {
const base = buildLogsTracesExpression({
mainAttributeKey: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
mainAttributeValue: getContainerPodUID(container),
clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName:
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
const containerName = getContainerName(container);
if (!containerName) {
return base;
}
const containerClause = `${
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME
} = ${formatValueForExpression(containerName)}`;
return base ? `${base} AND ${containerClause}` : containerClause;
};
/**
* Kubernetes emits events against the pod, not the container, so the events tab
* is scoped to the container's pod.
*/
export const k8sContainerInitialEventsExpression = (
container: InframonitoringtypesContainerRecordDTO,
): string =>
buildEventsExpression({
objectKind: 'Pod',
objectName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
clusterName: container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME],
namespaceName:
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME],
});
export const containerWidgetInfo: K8sDetailsWidgetInfo[] = [
{
title: 'CPU Usage (cores)',
yAxisUnit: '',
docPath: `${CONTAINERS_DOC_PATH}#cpu-usage-cores-1`,
description:
'Avg, max and min CPU consumption of the container in cores, showing how bursty it is.',
},
{
title: 'CPU Request, Limit Utilization',
yAxisUnit: 'percentunit',
docPath: `${CONTAINERS_DOC_PATH}#cpu-request-limit-utilization`,
description:
'Container CPU usage as a fraction of its own CPU request and limit; limit lines near 100% mean throttling.',
},
{
title: 'Memory Usage (bytes)',
yAxisUnit: 'bytes',
docPath: `${CONTAINERS_DOC_PATH}#memory-usage-bytes`,
description:
'Total memory charged to the container, including reclaimable page cache, against the headroom left before its limit.',
},
{
title: 'Memory Request, Limit Utilization',
yAxisUnit: 'percentunit',
docPath: `${CONTAINERS_DOC_PATH}#memory-request-limit-utilization`,
description:
'Container memory usage as a fraction of its own memory request and limit; limit lines near 100% risk an OOMKill.',
},
{
title: 'Memory by State',
yAxisUnit: 'bytes',
docPath: `${CONTAINERS_DOC_PATH}#memory-by-state`,
description:
'RSS, working set and cache memory of the container, separating heap growth from file cache.',
},
{
title: 'Memory Major Page Faults',
yAxisUnit: '',
docPath: `${CONTAINERS_DOC_PATH}#memory-major-page-faults`,
description:
'Major page fault rate of the container; sustained values mean the working set is paging to disk.',
},
{
title: 'File System (bytes)',
yAxisUnit: 'bytes',
docPath: `${CONTAINERS_DOC_PATH}#file-system-bytes`,
description:
'Capacity, available and used bytes of the container filesystem.',
},
{
title: 'Container Uptime',
yAxisUnit: 's',
docPath: `${CONTAINERS_DOC_PATH}#container-uptime`,
description:
'Time since the container last started; a sawtooth of resets means it is restarting repeatedly.',
},
{
title: 'Node CPU Utilization by Container',
yAxisUnit: 'percentunit',
docPath: `${CONTAINERS_DOC_PATH}#node-cpu-utilization-by-container`,
description:
"The container's CPU usage as a fraction of the whole node's capacity, to spot noisy neighbours.",
},
{
title: 'Node Memory Utilization by Container',
yAxisUnit: 'percentunit',
docPath: `${CONTAINERS_DOC_PATH}#node-memory-utilization-by-container`,
description:
"The container's memory usage as a fraction of the whole node's capacity.",
},
];

View File

@@ -1,140 +0,0 @@
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { listContainers } from 'api/generated/services/inframonitoring';
import {
InframonitoringtypesContainerRecordDTO,
InframonitoringtypesResponseTypeDTO,
Querybuildertypesv5OrderDirectionDTO,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { InfraMonitoringEvents } from 'constants/events';
import { K8sEntityConfig } from '../Base/entity.config.types';
import { K8sBaseFilters, K8sDetailsFilters } from '../Base/types';
import { InfraMonitoringEntity } from '../constants';
import { SelectedItemParams } from '../hooks';
import {
containerWidgetInfo,
k8sContainerDetailsMetadataConfig,
k8sContainerGetEntityName,
k8sContainerGetSelectedItemExpression,
k8sContainerInitialEventsExpression,
k8sContainerInitialLogTracesExpression,
} from './constants';
import { getContainerMetricsQueryPayload } from './metrics';
import {
getK8sContainerItemKey,
getK8sContainerRowKey,
k8sContainerColumnsConfig,
} from './table.config';
async function fetchListData(
filters: K8sBaseFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<
InframonitoringtypesContainerRecordDTO,
SelectedItemParams
>['list']['fetchListData']
> {
try {
const response = await listContainers(
{
filter: { expression: filters.filter.expression },
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,
start: filters.start,
end: filters.end,
orderBy: filters.orderBy
? {
key: { name: filters.orderBy.key.name },
direction:
filters.orderBy.direction === 'asc'
? Querybuildertypesv5OrderDirectionDTO.asc
: Querybuildertypesv5OrderDirectionDTO.desc,
}
: undefined,
},
signal,
);
const data = response.data;
return {
type:
data.type === InframonitoringtypesResponseTypeDTO.grouped_list
? ('grouped_list' as const)
: ('list' as const),
records: data.records,
total: data.total,
endTimeBeforeRetention: data.endTimeBeforeRetention,
warning: data.warning,
};
} catch (error) {
return {
type: 'list' as const,
records: [] as InframonitoringtypesContainerRecordDTO[],
total: 0,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
async function fetchEntityData(
filters: K8sDetailsFilters,
signal?: AbortSignal,
): ReturnType<
K8sEntityConfig<InframonitoringtypesContainerRecordDTO>['details']['fetchEntityData']
> {
try {
const response = await listContainers(
{
filter: { expression: filters.filter.expression },
start: filters.start,
end: filters.end,
limit: 1,
offset: 0,
},
signal,
);
return {
data: response.data.records.length > 0 ? response.data.records[0] : null,
};
} catch (error) {
return {
data: null,
error:
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) ?? null,
};
}
}
export const containerEntityConfig: K8sEntityConfig<
InframonitoringtypesContainerRecordDTO,
SelectedItemParams
> = {
list: {
entity: InfraMonitoringEntity.CONTAINERS,
eventCategory: InfraMonitoringEvents.Container,
tableColumns: k8sContainerColumnsConfig,
fetchListData,
getRowKey: getK8sContainerRowKey,
getItemKey: getK8sContainerItemKey,
detailsQueryKeyPrefix: 'container',
},
details: {
category: InfraMonitoringEntity.CONTAINERS,
eventCategory: InfraMonitoringEvents.Container,
queryKeyPrefix: 'container',
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
fetchEntityData,
getEntityName: k8sContainerGetEntityName,
getInitialLogTracesExpression: k8sContainerInitialLogTracesExpression,
getInitialEventsExpression: k8sContainerInitialEventsExpression,
metadataConfig: k8sContainerDetailsMetadataConfig,
entityWidgetInfo: containerWidgetInfo,
getEntityQueryPayload: getContainerMetricsQueryPayload,
},
};

View File

@@ -1,275 +0,0 @@
import { InframonitoringtypesContainerRecordDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 } from 'uuid';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import { getContainerName, getContainerPodUID } from './utils';
const QUERY_NAMES = ['A', 'B', 'C', 'D', 'E', 'F'];
const STEP_INTERVAL = 60;
type TimeAggregation = 'avg' | 'max' | 'min' | 'latest';
type SpaceAggregation = 'sum' | 'avg' | 'max';
interface SeriesSpec {
metricKey: string;
legend: string;
timeAggregation: TimeAggregation;
spaceAggregation: SpaceAggregation;
}
interface FormulaSpec {
expression: string;
legend: string;
}
/**
* Every panel is scoped to a single container by the (k8s.pod.uid,
* k8s.container.name) pair that identifies its row in the list.
*/
function buildScopeFilters(
container: InframonitoringtypesContainerRecordDTO,
): TagFilter {
return {
items: [
{
id: 'pod-uid',
key: {
dataType: DataTypes.String,
id: `k8s_pod_uid--string--tag--false`,
key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
type: 'tag',
},
op: '=',
value: getContainerPodUID(container),
},
{
id: 'container-name',
key: {
dataType: DataTypes.String,
id: `k8s_container_name--string--tag--false`,
key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
type: 'tag',
},
op: '=',
value: getContainerName(container),
},
],
op: 'AND',
};
}
function buildQuery(
container: InframonitoringtypesContainerRecordDTO,
start: number,
end: number,
series: SeriesSpec[],
formulas: FormulaSpec[] = [],
): GetQueryResultsProps {
const filters = buildScopeFilters(container);
return {
selectedTime: 'GLOBAL_TIME',
graphType: PANEL_TYPES.TIME_SERIES,
query: {
builder: {
queryData: series.map((spec, index) => ({
aggregateAttribute: {
dataType: DataTypes.Float64,
id: `${spec.metricKey.replace(/\./g, '_')}--float64--Gauge--true`,
key: spec.metricKey,
type: 'Gauge',
},
aggregateOperator: spec.timeAggregation,
dataSource: DataSource.METRICS,
disabled: false,
expression: QUERY_NAMES[index],
filters,
functions: [],
groupBy: [],
having: [],
legend: spec.legend,
limit: null,
orderBy: [],
queryName: QUERY_NAMES[index],
reduceTo: ReduceOperators.AVG,
spaceAggregation: spec.spaceAggregation,
stepInterval: STEP_INTERVAL,
timeAggregation: spec.timeAggregation,
})),
queryFormulas: formulas.map((formula, index) => ({
disabled: false,
expression: formula.expression,
legend: formula.legend,
queryName: `F${index + 1}`,
})),
queryTraceOperator: [],
},
clickhouse_sql: [{ disabled: false, legend: '', name: 'A', query: '' }],
id: v4(),
promql: [{ disabled: false, legend: '', name: 'A', query: '' }],
queryType: EQueryType.QUERY_BUILDER,
},
variables: {},
formatForWeb: false,
start,
end,
};
}
/** Absolute usage metrics are summed across series, ratios are averaged. */
function usageSeries(metricKey: string, legendPrefix = ''): SeriesSpec[] {
const prefix = legendPrefix ? `${legendPrefix} - ` : '';
return [
{
metricKey,
legend: `${prefix}Avg`,
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
{
metricKey,
legend: `${prefix}Max`,
timeAggregation: 'max',
spaceAggregation: 'sum',
},
{
metricKey,
legend: `${prefix}Min`,
timeAggregation: 'min',
spaceAggregation: 'sum',
},
];
}
function utilizationSeries(
metricKey: string,
legendPrefix: string,
): SeriesSpec[] {
return (['avg', 'max', 'min'] as TimeAggregation[]).map((timeAggregation) => ({
metricKey,
legend: `${legendPrefix} - ${
timeAggregation.charAt(0).toUpperCase() + timeAggregation.slice(1)
}`,
timeAggregation,
spaceAggregation: 'avg' as const,
}));
}
export const getContainerMetricsQueryPayload = (
container: InframonitoringtypesContainerRecordDTO,
start: number,
end: number,
): GetQueryResultsProps[] => {
const query = (
series: SeriesSpec[],
formulas?: FormulaSpec[],
): GetQueryResultsProps => buildQuery(container, start, end, series, formulas);
return [
query(usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE)),
query([
...utilizationSeries(
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_REQUEST_UTILIZATION,
'Request util %',
),
...utilizationSeries(
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_LIMIT_UTILIZATION,
'Limit util %',
),
]),
query([
...usageSeries(INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_USAGE, 'Usage'),
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_AVAILABLE,
legend: 'Available',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
]),
query([
...utilizationSeries(
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION,
'Request util %',
),
...utilizationSeries(
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION,
'Limit util %',
),
]),
query(
[
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_RSS,
legend: 'RSS Memory',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_WORKING_SET,
legend: 'Working Set Memory',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
],
[{ expression: 'B - A', legend: 'Cache Memory' }],
),
query([
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_MEMORY_MAJOR_PAGE_FAULTS,
legend: 'Major Page Faults',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
]),
query([
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_CAPACITY,
legend: 'Capacity',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_AVAILABLE,
legend: 'Available',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_FILESYSTEM_USAGE,
legend: 'Usage',
timeAggregation: 'avg',
spaceAggregation: 'sum',
},
]),
query([
{
metricKey: INFRA_MONITORING_ATTR_KEYS.CONTAINER_UPTIME,
legend: 'Uptime',
timeAggregation: 'latest',
spaceAggregation: 'sum',
},
]),
query([
{
metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_CPU_NODE_UTILIZATION,
legend: 'Node CPU Utilization',
timeAggregation: 'avg',
spaceAggregation: 'avg',
},
]),
query([
{
metricKey: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_MEMORY_NODE_UTILIZATION,
legend: 'Node Memory Utilization',
timeAggregation: 'avg',
spaceAggregation: 'avg',
},
]),
];
};

View File

@@ -1,470 +0,0 @@
import { Container } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import {
InframonitoringtypesContainerReadyDTO,
InframonitoringtypesContainerRecordDTO,
InframonitoringtypesContainerStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { SelectedItemParams } from '../hooks';
import {
CONTAINER_READY_COLORS,
CONTAINER_READY_LABELS,
CONTAINER_STATUS_COLORS,
CONTAINER_STATUS_LABELS,
CONTAINERS_DOC_PATH,
getContainerImageWithTag,
getContainerReadyItems,
getContainerStatusItems,
} from './utils';
export function getK8sContainerRowKey(
container: InframonitoringtypesContainerRecordDTO,
): string {
return (
[container.podUID, container.containerName].filter(Boolean).join('/') ||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
''
);
}
export function getK8sContainerItemKey(
container: InframonitoringtypesContainerRecordDTO,
): SelectedItemParams {
return {
selectedItem:
container.podUID ||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] ||
null,
containerName:
container.containerName ||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
null,
clusterName: null,
namespaceName: null,
};
}
export type ContainerTableColumnConfig =
TableColumnDef<InframonitoringtypesContainerRecordDTO>;
/**
* The grouped table and its nested rows are separate tables, so a column and the
* one that replaces it while grouped share a width to keep the two aligned.
*/
const NAME_COLUMN_WIDTH = 220;
const STATUS_COLUMN_WIDTH = 250;
export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
{
id: 'containerGroup',
header: (): React.ReactNode => <EntityGroupHeader title="Container Group" />,
accessorFn: (row): string => row.containerName || '',
width: { min: NAME_COLUMN_WIDTH },
enableSort: false,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-collapse',
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => (
<ExpandButtonWrapper isExpanded={isExpanded} toggleExpanded={toggleExpanded}>
<K8sGroupCell row={row} />
</ExpandButtonWrapper>
),
},
{
id: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
header: (): React.ReactNode => (
<EntityGroupHeader
title="Container Name"
icon={<Container data-hide-expanded="true" size={14} />}
docPath={`${CONTAINERS_DOC_PATH}#container-name`}
/>
),
accessorFn: (row): string =>
row.containerName ||
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
'',
width: { min: NAME_COLUMN_WIDTH },
enableSort: true,
enableRemove: false,
enableMove: false,
pin: 'left',
visibilityBehavior: 'hidden-on-expand',
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'podName',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#pod-name`}>
Pod Name
</ColumnHeader>
),
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME] || '',
width: { min: 260 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const podName = value as string;
if (!podName) {
return <TextNoData type="tanstack" />;
}
return <TanStackTable.Text>{podName}</TanStackTable.Text>;
},
},
{
id: 'namespace',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
Namespace
</ColumnHeader>
),
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME] || '',
width: { min: 160 },
enableSort: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'image',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#imagetag`}>
Image:Tag
</ColumnHeader>
),
accessorFn: (row): string => getContainerImageWithTag(row),
width: { min: 240 },
enableSort: false,
cell: ({ value }): React.ReactNode => {
const image = value as string;
if (!image) {
return <TextNoData type="tanstack" />;
}
return <TanStackTable.Text>{image}</TanStackTable.Text>;
},
},
{
id: 'containerStatus',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#status`}>Status</ColumnHeader>
),
accessorFn: (row): string => row.status,
width: { min: STATUS_COLUMN_WIDTH },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
if (
!row.status ||
row.status === InframonitoringtypesContainerStatusDTO.no_data
) {
return <TextNoData type="tanstack" />;
}
return (
<Badge color={CONTAINER_STATUS_COLORS[row.status]} variant="outline">
{CONTAINER_STATUS_LABELS[row.status]}
</Badge>
);
},
},
{
id: 'containerCountsByStatus',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#status`}>Status</ColumnHeader>
),
accessorFn: (
row,
): InframonitoringtypesContainerRecordDTO['containerCountsByStatus'] =>
row.containerCountsByStatus,
width: { min: STATUS_COLUMN_WIDTH },
enableSort: false,
visibilityBehavior: 'hidden-on-collapse',
cell: ({ row, rowId }): React.ReactNode => {
if (!row.containerCountsByStatus) {
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
items={getContainerStatusItems(row.containerCountsByStatus)}
rowId={rowId}
/>
);
},
},
{
id: 'containerReady',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#ready`}>Ready</ColumnHeader>
),
accessorFn: (row): string => row.ready,
width: { min: 130 },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
if (
!row.ready ||
row.ready === InframonitoringtypesContainerReadyDTO.no_data
) {
return <TextNoData type="tanstack" />;
}
return (
<Badge color={CONTAINER_READY_COLORS[row.ready]} variant="outline">
{CONTAINER_READY_LABELS[row.ready]}
</Badge>
);
},
},
{
id: 'containerCountsByReady',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#ready`}>Ready</ColumnHeader>
),
accessorFn: (
row,
): InframonitoringtypesContainerRecordDTO['containerCountsByReady'] =>
row.containerCountsByReady,
width: { min: 130 },
enableSort: false,
visibilityBehavior: 'hidden-on-collapse',
cell: ({ row, rowId }): React.ReactNode => {
if (!row.containerCountsByReady) {
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
items={getContainerReadyItems(row.containerCountsByReady)}
rowId={rowId}
/>
);
},
},
{
id: 'containerRestarts',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#restarts`}>
Restarts
</ColumnHeader>
),
accessorFn: (row): number => row.restarts,
width: { min: 130 },
enableSort: false,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="Restarts"
>
<TanStackTable.Text>{value as number}</TanStackTable.Text>
</ValidateColumnValueWrapper>
),
},
{
id: 'cpu_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath={`${CONTAINERS_DOC_PATH}#cpu-req-usage-`}
tooltip={<EntityProgressThresholds type="cpu-request" />}
>
CPU Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.cpuRequestUtilization,
width: { min: 210 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="CPU Request"
>
<EntityProgressBar value={value as number} type="cpu-request" />
</ValidateColumnValueWrapper>
),
},
{
id: 'cpu_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath={`${CONTAINERS_DOC_PATH}#cpu-limit-usage-`}
tooltip={<EntityProgressThresholds type="cpu-limit" />}
>
CPU Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.cpuLimitUtilization,
width: { min: 220 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="CPU Limit"
>
<EntityProgressBar value={value as number} type="cpu-limit" />
</ValidateColumnValueWrapper>
),
},
{
id: 'cpu',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#cpu-usage-cores`}>
CPU Usage (cores)
</ColumnHeader>
),
accessorFn: (row): number => row.cpu,
width: { min: 160 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={Number(value)}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="CPU metric"
>
<TanStackTable.Text>{Number(value).toFixed(2)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
),
},
{
id: 'memory_request',
header: (): React.ReactNode => (
<ColumnHeader
docPath={`${CONTAINERS_DOC_PATH}#mem-req-usage-`}
tooltip={<EntityProgressThresholds type="memory-request" />}
>
Memory Request Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.memoryRequestUtilization,
width: { min: 210 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="Memory Request"
>
<EntityProgressBar value={value as number} type="memory-request" />
</ValidateColumnValueWrapper>
),
},
{
id: 'memory_limit',
header: (): React.ReactNode => (
<ColumnHeader
docPath={`${CONTAINERS_DOC_PATH}#mem-limit-usage-`}
tooltip={<EntityProgressThresholds type="memory-limit" />}
>
Memory Limit Usage (%)
</ColumnHeader>
),
accessorFn: (row): number => row.memoryLimitUtilization,
width: { min: 220 },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="Memory Limit"
>
<EntityProgressBar value={value as number} type="memory-limit" />
</ValidateColumnValueWrapper>
),
},
{
id: 'memory',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#mem-usage-wss`}>
Memory Usage (WSS)
</ColumnHeader>
),
accessorFn: (row): number => row.memory,
width: { min: 210, default: '100%' },
enableSort: true,
cell: ({ value, rowId }): React.ReactNode => (
<ValidateColumnValueWrapper
rowId={rowId}
value={value as number}
entity={InfraMonitoringEntity.CONTAINERS}
attribute="memory metric"
>
<TanStackTable.Text>{formatBytes(value as number)}</TanStackTable.Text>
</ValidateColumnValueWrapper>
),
},
{
id: 'node',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
Node
</ColumnHeader>
),
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME] || '',
width: { default: 100 },
enableSort: false,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'cluster',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
Cluster
</ColumnHeader>
),
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME] || '',
width: { default: 100 },
enableSort: false,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
{
id: 'deployment',
header: (): React.ReactNode => (
<ColumnHeader docPath={`${CONTAINERS_DOC_PATH}#additional-columns`}>
Deployment
</ColumnHeader>
),
accessorFn: (row): string =>
row.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME] || '',
width: { default: 100 },
enableSort: false,
defaultVisibility: false,
cell: ({ value }): React.ReactNode => (
<TanStackTable.Text>{value as string}</TanStackTable.Text>
),
},
];

View File

@@ -1,169 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { BadgeColor } from '@signozhq/ui/badge';
import {
InframonitoringtypesContainerCountsByReadyDTO,
InframonitoringtypesContainerCountsByStatusDTO,
InframonitoringtypesContainerReadyDTO,
InframonitoringtypesContainerRecordDTO,
InframonitoringtypesContainerStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { StatusCountItem } from '../components/GroupedStatusCounts';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
export const CONTAINERS_DOC_PATH =
'/infrastructure-monitoring/kubernetes/containers';
/** Renders as `name:tag`; the tag is dropped when the image is not pinned. */
export function getContainerImageWithTag(
container: InframonitoringtypesContainerRecordDTO,
): string {
const name = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME];
const tag = container.meta?.[INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_TAG];
if (!name) {
return '';
}
return tag ? `${name}:${tag}` : name;
}
export function getContainerName(
container: InframonitoringtypesContainerRecordDTO,
): string {
return (
container.containerName ||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME] ||
''
);
}
export function getContainerPodUID(
container: InframonitoringtypesContainerRecordDTO,
): string {
return (
container.podUID ||
container.meta?.[INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID] ||
''
);
}
export const CONTAINER_STATUS_COLORS: Record<
InframonitoringtypesContainerStatusDTO,
BadgeColor
> = {
[InframonitoringtypesContainerStatusDTO.running]: 'forest',
[InframonitoringtypesContainerStatusDTO.completed]: 'robin',
[InframonitoringtypesContainerStatusDTO.waiting]: 'amber',
[InframonitoringtypesContainerStatusDTO.containercreating]: 'amber',
[InframonitoringtypesContainerStatusDTO.terminated]: 'sienna',
[InframonitoringtypesContainerStatusDTO.unknown]: 'vanilla',
[InframonitoringtypesContainerStatusDTO.no_data]: 'vanilla',
[InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'cherry',
[InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'cherry',
[InframonitoringtypesContainerStatusDTO.errimagepull]: 'cherry',
[InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]: 'cherry',
[InframonitoringtypesContainerStatusDTO.oomkilled]: 'cherry',
[InframonitoringtypesContainerStatusDTO.error]: 'cherry',
[InframonitoringtypesContainerStatusDTO.containercannotrun]: 'cherry',
};
/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */
export const CONTAINER_STATUS_LABELS: Record<
InframonitoringtypesContainerStatusDTO,
string
> = {
[InframonitoringtypesContainerStatusDTO.running]: 'Running',
[InframonitoringtypesContainerStatusDTO.completed]: 'Completed',
[InframonitoringtypesContainerStatusDTO.waiting]: 'Waiting',
[InframonitoringtypesContainerStatusDTO.containercreating]:
'ContainerCreating',
[InframonitoringtypesContainerStatusDTO.terminated]: 'Terminated',
[InframonitoringtypesContainerStatusDTO.unknown]: 'Unknown',
[InframonitoringtypesContainerStatusDTO.no_data]: 'No data',
[InframonitoringtypesContainerStatusDTO.crashloopbackoff]: 'CrashLoopBackOff',
[InframonitoringtypesContainerStatusDTO.imagepullbackoff]: 'ImagePullBackOff',
[InframonitoringtypesContainerStatusDTO.errimagepull]: 'ErrImagePull',
[InframonitoringtypesContainerStatusDTO.createcontainerconfigerror]:
'CreateContainerConfigError',
[InframonitoringtypesContainerStatusDTO.oomkilled]: 'OOMKilled',
[InframonitoringtypesContainerStatusDTO.error]: 'Error',
[InframonitoringtypesContainerStatusDTO.containercannotrun]:
'ContainerCannotRun',
};
const CONTAINER_ERROR_STATUS_LABELS: Partial<
Record<keyof InframonitoringtypesContainerCountsByStatusDTO, string>
> = {
crashLoopBackOff: 'CrashLoopBackOff',
imagePullBackOff: 'ImagePullBackOff',
errImagePull: 'ErrImagePull',
createContainerConfigError: 'CreateContainerConfigError',
oomKilled: 'OOMKilled',
error: 'Error',
containerCannotRun: 'ContainerCannotRun',
};
export function getContainerStatusItems(
counts: InframonitoringtypesContainerCountsByStatusDTO,
): StatusCountItem[] {
const errorKeys = Object.keys(CONTAINER_ERROR_STATUS_LABELS) as Array<
keyof typeof CONTAINER_ERROR_STATUS_LABELS
>;
return [
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
{
value: counts.waiting + counts.containerCreating,
label: 'Waiting',
color: Color.BG_AMBER_500,
breakdown: [
{ label: 'Waiting', value: counts.waiting },
{ label: 'ContainerCreating', value: counts.containerCreating },
],
},
{
value: counts.terminated,
label: 'Terminated',
color: Color.BG_SIENNA_500,
},
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
{
value: errorKeys.reduce((sum, key) => sum + counts[key], 0),
label: 'Error Status',
color: Color.BG_CHERRY_500,
breakdown: errorKeys.map((key) => ({
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
value: counts[key],
})),
},
];
}
export const CONTAINER_READY_COLORS: Record<
InframonitoringtypesContainerReadyDTO,
BadgeColor
> = {
[InframonitoringtypesContainerReadyDTO.ready]: 'forest',
[InframonitoringtypesContainerReadyDTO.not_ready]: 'cherry',
[InframonitoringtypesContainerReadyDTO.no_data]: 'vanilla',
};
export const CONTAINER_READY_LABELS: Record<
InframonitoringtypesContainerReadyDTO,
string
> = {
[InframonitoringtypesContainerReadyDTO.ready]: 'Ready',
[InframonitoringtypesContainerReadyDTO.not_ready]: 'Not Ready',
[InframonitoringtypesContainerReadyDTO.no_data]: 'No data',
};
export function getContainerReadyItems(
counts: InframonitoringtypesContainerCountsByReadyDTO,
): StatusCountItem[] {
return [
{ value: counts.ready, label: 'Ready', color: Color.BG_FOREST_500 },
{ value: counts.notReady, label: 'Not Ready', color: Color.BG_CHERRY_500 },
];
}

View File

@@ -3,9 +3,8 @@ import { Compass, Info } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import styles from './ChartHeader.module.scss';
import { DOCS_BASE_URL } from 'constants/app';
const DOCS_ROOT = `${DOCS_BASE_URL}/docs`;
const DOCS_BASE_URL = `${process.env.DOCS_BASE_URL}/docs`;
interface ChartHeaderProps {
title: string;
@@ -34,7 +33,7 @@ function ChartHeader({
<>
{tooltipTitle}{' '}
<a
href={`${DOCS_ROOT}${docPath}`}
href={`${DOCS_BASE_URL}${docPath}`}
target="_blank"
rel="noopener"
onClick={(e): void => e.stopPropagation()}

View File

@@ -37,15 +37,7 @@
.title {
font-family: var(--periscope-font-family-mono);
--typography-margin: 0px var(--spacing-1) 0px 0px;
}
// Sized to the icon rather than the default tap target, so it sits beside the
// entity name instead of a gap away from it.
.copyIdButton {
--button-padding: 2px;
--button-height: auto;
--button-width: auto;
--typography-margin: 0px var(--spacing-4) 0px 0px;
}
.entityDetailsEntity {
@@ -53,12 +45,30 @@
flex-direction: column;
}
// Tracks size to the entity's field count instead of a fixed four, so entities
// with more fields stay on one row rather than spilling a near-empty second one.
.entityDetailsGrid {
display: flex;
flex-direction: column;
}
.labelsRow,
.valuesRow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-4) var(--spacing-6);
grid-template-columns: 1.5fr 1.5fr 1.5fr 1.5fr;
gap: 30px;
align-items: center;
}
.labelsRow {
margin-bottom: var(--spacing-4);
}
.entityDetailsMetadataLabel {
letter-spacing: 0.44px;
text-transform: uppercase;
}
.entityDetailsMetadataValue {
font-family: var(--periscope-font-family-mono);
}
.viewsTabsContainer {

View File

@@ -16,7 +16,6 @@ import {
ArrowUpDown,
ArrowUpToLine,
Bolt,
Box,
Boxes,
Computer,
Container,
@@ -32,7 +31,6 @@ import { DataSource } from 'types/common/queryBuilder';
import { K8sDynamicList } from './Base/K8sDynamicList';
import {
GetClustersQuickFiltersConfig,
GetContainersQuickFiltersConfig,
GetDaemonsetsQuickFiltersConfig,
GetDeploymentsQuickFiltersConfig,
GetJobsQuickFiltersConfig,
@@ -154,12 +152,6 @@ export default function InfraMonitoringK8s(): JSX.Element {
const categories = useMemo(
() => [
{
key: K8sCategories.CONTAINERS,
label: 'Containers',
icon: <Box size={14} />,
config: GetContainersQuickFiltersConfig(),
},
{
key: K8sCategories.PODS,
label: 'Pods',

View File

@@ -61,26 +61,10 @@ export const INFRA_MONITORING_ATTR_KEYS = {
K8S_CONTAINER_CPU_LIMIT: 'k8s.container.cpu_limit',
K8S_CONTAINER_MEMORY_REQUEST: 'k8s.container.memory_request',
K8S_CONTAINER_MEMORY_LIMIT: 'k8s.container.memory_limit',
K8S_CONTAINER_CPU_REQUEST_UTILIZATION: 'k8s.container.cpu_request_utilization',
K8S_CONTAINER_CPU_LIMIT_UTILIZATION: 'k8s.container.cpu_limit_utilization',
K8S_CONTAINER_MEMORY_REQUEST_UTILIZATION:
'k8s.container.memory_request_utilization',
K8S_CONTAINER_MEMORY_LIMIT_UTILIZATION:
'k8s.container.memory_limit_utilization',
K8S_CONTAINER_CPU_NODE_UTILIZATION: 'k8s.container.cpu.node.utilization',
K8S_CONTAINER_MEMORY_NODE_UTILIZATION: 'k8s.container.memory.node.utilization',
CONTAINER_CPU_USAGE: 'container.cpu.usage',
CONTAINER_MEMORY_USAGE: 'container.memory.usage',
CONTAINER_MEMORY_AVAILABLE: 'container.memory.available',
CONTAINER_MEMORY_WORKING_SET: 'container.memory.working_set',
CONTAINER_MEMORY_RSS: 'container.memory.rss',
CONTAINER_MEMORY_MAJOR_PAGE_FAULTS: 'container.memory.major_page_faults',
CONTAINER_FILESYSTEM_AVAILABLE: 'container.filesystem.available',
CONTAINER_FILESYSTEM_CAPACITY: 'container.filesystem.capacity',
CONTAINER_FILESYSTEM_USAGE: 'container.filesystem.usage',
CONTAINER_UPTIME: 'container.uptime',
CONTAINER_IMAGE_NAME: 'container.image.name',
CONTAINER_IMAGE_TAG: 'container.image.tag',
// Deployment
K8S_DEPLOYMENT_NAME: 'k8s.deployment.name',
@@ -181,9 +165,6 @@ export const K8sCategories = {
VOLUMES: 'volumes',
};
/** The section the Kubernetes view opens on when a link names none. */
export const DEFAULT_K8S_CATEGORY = K8sCategories.CONTAINERS;
const dotMap = {
[InfraMonitoringEntity.HOSTS]:
INFRA_MONITORING_ATTR_KEYS.SYSTEM_CPU_LOAD_AVERAGE_15M,
@@ -200,7 +181,7 @@ const dotMap = {
[InfraMonitoringEntity.DAEMONSETS]:
INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
[InfraMonitoringEntity.CONTAINERS]:
INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
INFRA_MONITORING_ATTR_KEYS.K8S_POD_CPU_USAGE,
[InfraMonitoringEntity.JOBS]:
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_DESIRED_SUCCESSFUL_PODS,
[InfraMonitoringEntity.VOLUMES]:
@@ -340,161 +321,6 @@ export function GetPodsQuickFiltersConfig(): IQuickFiltersConfig[] {
];
}
export function GetContainersQuickFiltersConfig(): IQuickFiltersConfig[] {
return [
{
type: FiltersType.CHECKBOX,
title: 'Container',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Pod',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: true,
},
{
type: FiltersType.CHECKBOX,
title: 'Namespace',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Node',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Cluster',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Image',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.CONTAINER_IMAGE_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Deployment',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Statefulset',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'DaemonSet',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Job',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
dataType: DataTypes.String,
type: 'resource',
id: `${INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME}--string--resource--false`,
},
aggregateOperator: 'noop',
aggregateAttribute: INFRA_MONITORING_ATTR_KEYS.CONTAINER_CPU_USAGE,
dataSource: DataSource.METRICS,
defaultOpen: false,
},
{
type: FiltersType.CHECKBOX,
title: 'Environment',
attributeKey: {
key: INFRA_MONITORING_ATTR_KEYS.DEPLOYMENT_ENVIRONMENT,
dataType: DataTypes.String,
type: 'resource',
},
defaultOpen: true,
},
];
}
export function GetNodesQuickFiltersConfig(): IQuickFiltersConfig[] {
return [
{
@@ -941,7 +767,6 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
SELECTED_ITEM: 'selectedItem',
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName',
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
DETAIL_START_TIME: 'detailStartTime',
DETAIL_END_TIME: 'detailEndTime',
@@ -958,7 +783,7 @@ export const METRIC_NAMESPACE_BY_ENTITY: Record<InfraMonitoringEntity, string> =
[InfraMonitoringEntity.DEPLOYMENTS]: 'k8s.',
[InfraMonitoringEntity.STATEFULSETS]: 'k8s.',
[InfraMonitoringEntity.DAEMONSETS]: 'k8s.',
[InfraMonitoringEntity.CONTAINERS]: 'k8s.container.',
[InfraMonitoringEntity.CONTAINERS]: 'k8s.pod.',
[InfraMonitoringEntity.JOBS]: 'k8s.',
[InfraMonitoringEntity.VOLUMES]: 'k8s.volume.',
};

View File

@@ -16,8 +16,8 @@ import {
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import {
DEFAULT_K8S_CATEGORY,
INFRA_MONITORING_K8S_PARAMS_KEYS,
K8sCategories,
VIEWS,
} from './constants';
import { orderBySchema, OrderBySchemaType } from './schemas';
@@ -130,23 +130,19 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY,
parseAsString
.withDefault(DEFAULT_K8S_CATEGORY)
.withOptions({ ...defaultNuqsOptions, clearOnDefault: false }),
parseAsString.withDefault(K8sCategories.PODS).withOptions(defaultNuqsOptions),
);
export interface SelectedItemParams {
selectedItem: string | null;
clusterName?: string | null;
namespaceName?: string | null;
containerName?: string | null;
}
const selectedItemParamsParsers = {
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString,
};
export type UseSelectedItemParamsReturn = [
@@ -171,9 +167,6 @@ export const useInfraMonitoringSelectedItemParams =
namespaceName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME] ??
null,
containerName:
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME] ??
null,
}),
[rawParams],
);
@@ -185,7 +178,6 @@ export const useInfraMonitoringSelectedItemParams =
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null,
});
return;
}
@@ -197,8 +189,6 @@ export const useInfraMonitoringSelectedItemParams =
newParams.clusterName ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:
newParams.namespaceName ?? null,
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]:
newParams.containerName ?? null,
});
},
[setRawParams],

View File

@@ -11,7 +11,7 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
import { useOptionsMenu } from 'container/OptionsMenu';
@@ -51,7 +51,7 @@ import {
} from 'utils/explorerUtils';
import { v4 } from 'uuid';
import { TOOLBAR_VIEWS } from './constants';
import { DEFAULT_PANEL_TYPE, TOOLBAR_VIEWS } from './constants';
import ListView from './ListView/ListView';
import { defaultSelectedColumns } from './ListView/configs';
import QuerySection from './QuerySection/QuerySection';
@@ -88,7 +88,7 @@ function Explorer(): JSX.Element {
const listQueryKeyRef = useRef<any>();
// Get panel type from URL
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const panelTypesFromUrl = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
const [isCancelled, setIsCancelled] = useState(false);
@@ -118,8 +118,8 @@ function Explorer(): JSX.Element {
const defaultQuery = useMemo(
(): Query =>
updateAllQueriesOperators(
initialQueriesMap.traces,
PANEL_TYPES.LIST,
initialQueryAIWithType,
DEFAULT_PANEL_TYPE,
DataSource.TRACES,
),
[updateAllQueriesOperators],
@@ -185,8 +185,8 @@ function Explorer(): JSX.Element {
const exportDefaultQuery = useMemo(
() =>
getQueryByPanelType(
stagedQuery || initialQueriesMap.traces,
panelType || PANEL_TYPES.LIST,
stagedQuery || initialQueryAIWithType,
panelType || DEFAULT_PANEL_TYPE,
),
[stagedQuery, panelType],
);

View File

@@ -17,7 +17,7 @@ import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
@@ -94,7 +94,7 @@ function ListView({
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
[stagedQuery, orderBy],
);

View File

@@ -1,42 +1,25 @@
import { memo, useCallback, useMemo } from 'react';
import { memo, useMemo } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
import { DEFAULT_PANEL_TYPE } from '../constants';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
const config: QueryBuilderProps['filterConfigs'] = {
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => ({
stepInterval: { isHidden: false, isDisabled: false },
limit: { isHidden: isList, isDisabled: true },
having: { isHidden: isList, isDisabled: true },
};
return config;
}, [panelTypes]);
const renderOrderBy = useCallback(
({ query, onChange }: OrderByFilterProps) => (
<ExplorerOrderBy query={query} onChange={onChange} />
),
limit: { isHidden: false, isDisabled: true },
having: { isHidden: false, isDisabled: true },
}),
[],
);
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
const shouldRenderCustomOrderBy =
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
return {
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
};
}, [panelTypes, renderOrderBy]);
const isListViewPanel = useMemo(
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
[panelTypes],
@@ -45,14 +28,10 @@ function QuerySection(): JSX.Element {
return (
<QueryBuilderV2
isListViewPanel={isListViewPanel}
showTraceOperator
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}
showOnlyWhereClause={isListViewPanel}
version="v3" // setting this to v3 as we this is rendered in logs explorer
/>
);

View File

@@ -14,7 +14,7 @@ import logEvent from 'api/common/logEvent';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
@@ -60,7 +60,7 @@ function TracesView({
);
const transformedQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
[stagedQuery],
);

View File

@@ -1,3 +1,7 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
export const TOOLBAR_VIEWS = {
list: {
name: 'list',

View File

@@ -37,11 +37,13 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
compositeQuery.queries?.forEach((q) => {
const spec = q.spec as BuilderQuery | PromQuery | ClickHouseQuery;
if (q.type === 'builder_query') {
if (q.type === 'builder_query' || q.type === 'builder_ai_query') {
if (spec.name) {
builderQueries[spec.name] = convertBuilderQueryToIBuilderQuery(
spec as BuilderQuery,
);
builderQueries[spec.name] = {
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
builderQueryType: q.type,
};
// Both share the builder bucket; the AI variant rides on the query itself.
builderQueryTypes[spec.name] = 'builder_query';
}
} else if (q.type === 'builder_formula') {

View File

@@ -5,7 +5,7 @@ import {
useMemo,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { Atom, Terminal } from '@signozhq/icons';
import { Atom, Sparkles, Terminal } from '@signozhq/icons';
import { Tabs } from 'antd';
import cx from 'classnames';
import { Typography } from '@signozhq/ui/typography';
@@ -21,15 +21,24 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
supportsAIQuery,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import {
AI_QUERY_TAB,
type QueryTabKey,
resolveActiveQueryTab,
toAIQuery,
withAIQueryType,
} from './utils';
import styles from './PanelEditorQueryBuilder.module.scss';
@@ -69,11 +78,20 @@ function PanelEditorQueryBuilder({
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
// The AI tab is not a query type — it stamps `builderQueryType` onto the builder
// queries (and pins them to traces, the only signal AI queries support).
const handleQueryCategoryChange = useCallback(
(queryType: string): void => {
(nextTab: string): void => {
if (nextTab === AI_QUERY_TAB) {
redirectWithQueryBuilderData({
...toAIQuery(currentQuery),
queryType: EQueryType.QUERY_BUILDER,
});
return;
}
redirectWithQueryBuilderData({
...currentQuery,
queryType: queryType as EQueryType,
...withAIQueryType(currentQuery, false),
queryType: nextTab as EQueryType,
});
},
[currentQuery, redirectWithQueryBuilderData],
@@ -101,9 +119,32 @@ function PanelEditorQueryBuilder({
);
const items = useMemo(() => {
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
const supportedQueryTypes: QueryTabKey[] = getSupportedQueryTypes(panelKind);
const supportedTabs = supportsAIQuery(panelKind)
? [...supportedQueryTypes, AI_QUERY_TAB]
: supportedQueryTypes;
const queryTypeComponents = {
[AI_QUERY_TAB]: {
icon: <Sparkles size={14} />,
label: 'AI Query Builder',
component: (
<div className="query-builder-v2-container">
<QueryBuilderV2
panelType={panelType}
filterConfigs={filterConfigs}
config={{
initialDataSource: DataSource.TRACES,
queryVariant: 'static',
}}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
queryComponents={{}}
savePreviousQuery
/>
</div>
),
},
[EQueryType.QUERY_BUILDER]: {
icon: <Atom size={14} />,
label: 'Query Builder',
@@ -138,15 +179,15 @@ function PanelEditorQueryBuilder({
},
};
return supportedQueryTypes.map((queryType) => ({
key: queryType,
return supportedTabs.map((tabKey) => ({
key: tabKey,
label: (
<div className={styles.queryTypeTab}>
{queryTypeComponents[queryType].icon}
<Typography>{queryTypeComponents[queryType].label}</Typography>
{queryTypeComponents[tabKey].icon}
<Typography>{queryTypeComponents[tabKey].label}</Typography>
</div>
),
children: queryTypeComponents[queryType].component,
children: queryTypeComponents[tabKey].component,
}));
}, [panelKind, panelType, filterConfigs, isDarkMode]);
@@ -163,7 +204,7 @@ function PanelEditorQueryBuilder({
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
activeKey={currentQuery.queryType}
activeKey={resolveActiveQueryTab(currentQuery)}
onChange={handleQueryCategoryChange}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>

View File

@@ -3,6 +3,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { OPERATORS } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
@@ -61,6 +62,7 @@ function lastQueryBuilderProps(): {
panelType: string;
isListViewPanel: boolean;
filterConfigs: unknown;
config?: unknown;
} {
const calls = mockQueryBuilderV2.mock.calls;
return calls[calls.length - 1][0];
@@ -70,15 +72,20 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
});
it('shows only the Query Builder tab for the List kind', () => {
it('shows only the Query Builder tabs for the List kind', () => {
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
expect(screen.queryByText('ClickHouse Query')).not.toBeInTheDocument();
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
@@ -91,21 +98,62 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
});
it('shows all three tabs for the Time Series kind', () => {
it('shows all four tabs for the Time Series kind', () => {
renderBuilder('signoz/TimeSeriesPanel');
expect(screen.getByText('Query Builder')).toBeInTheDocument();
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
expect(screen.getByText('PromQL')).toBeInTheDocument();
});
// The AI tab is derived from `builderQueryType`, not from a stored tab key.
it('activates the AI tab when the builder query carries the AI envelope tag', () => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
renderBuilder('signoz/TimeSeriesPanel');
expect(
screen.getByRole('tab', { name: 'AI Query Builder', selected: true }),
).toBeInTheDocument();
});
it('pins the AI tab builder to traces so the signal cannot be changed', () => {
mockUseQueryBuilder.mockReturnValue({
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
renderBuilder('signoz/TimeSeriesPanel');
expect(lastQueryBuilderProps().config).toStrictEqual({
initialDataSource: DataSource.TRACES,
queryVariant: 'static',
});
});
});
describe('PanelEditorQueryBuilder field visibility (driven by the capabilities guard)', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQueryBuilder.mockReturnValue({
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
currentQuery: {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [] },
},
redirectWithQueryBuilderData: jest.fn(),
updateAllQueriesOperators: jest.fn(),
});
});

View File

@@ -0,0 +1,140 @@
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import {
AI_QUERY_TAB,
isAIQuery,
resolveActiveQueryTab,
toAIQuery,
withAIQueryType,
} from '../utils';
function makeQuery(
queryData: Record<string, unknown>[],
queryType: EQueryType = EQueryType.QUERY_BUILDER,
): Query {
return {
queryType,
builder: { queryData, queryFormulas: [], queryTraceOperator: [] },
promql: [],
clickhouse_sql: [],
id: 'test',
} as unknown as Query;
}
describe('isAIQuery', () => {
it('is true when any builder query carries the AI envelope tag', () => {
expect(
isAIQuery(
makeQuery([{ queryName: 'A' }, { builderQueryType: 'builder_ai_query' }]),
),
).toBe(true);
});
it('is false for plain builder queries and for an empty builder', () => {
expect(isAIQuery(makeQuery([{ queryName: 'A' }]))).toBe(false);
expect(isAIQuery(makeQuery([]))).toBe(false);
});
});
describe('resolveActiveQueryTab', () => {
it('selects the AI tab for a tagged builder query', () => {
expect(
resolveActiveQueryTab(makeQuery([{ builderQueryType: 'builder_ai_query' }])),
).toBe(AI_QUERY_TAB);
});
it('selects the query type for an untagged query', () => {
expect(resolveActiveQueryTab(makeQuery([{ queryName: 'A' }]))).toBe(
EQueryType.QUERY_BUILDER,
);
});
// A PromQL panel reads its queries from a different bucket, so a stale tag on the
// builder bucket must not steal the active tab.
it('keeps PromQL selected even if the builder bucket carries a tag', () => {
expect(
resolveActiveQueryTab(
makeQuery([{ builderQueryType: 'builder_ai_query' }], EQueryType.PROM),
),
).toBe(EQueryType.PROM);
});
});
describe('toAIQuery', () => {
// The backend decodes a builder_ai_query spec as QueryBuilderQuery[TraceAggregation],
// which has no `metricName` — a carried-over metrics aggregation fails the request.
it('re-seeds a metrics query onto traces, dropping the metric aggregation', () => {
const result = toAIQuery(
makeQuery([
{
queryName: 'A',
dataSource: DataSource.METRICS,
aggregations: [{ metricName: 'signoz_latency_bucket' }],
},
]),
);
const [queryData] = result.builder.queryData;
expect(queryData.dataSource).toBe(DataSource.TRACES);
expect(queryData.aggregations).toStrictEqual([{ expression: 'count() ' }]);
expect(queryData.builderQueryType).toBe('builder_ai_query');
});
it('keeps the filter on a query already using traces', () => {
const result = toAIQuery(
makeQuery([
{
queryName: 'A',
dataSource: DataSource.TRACES,
filter: { expression: "service.name = 'checkout'" },
},
]),
);
expect(result.builder.queryData[0].filter).toStrictEqual({
expression: "service.name = 'checkout'",
});
expect(result.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
});
it('preserves the query name when re-seeding', () => {
const result = toAIQuery(
makeQuery([{ queryName: 'B', dataSource: DataSource.LOGS }]),
);
expect(result.builder.queryData[0].queryName).toBe('B');
});
});
describe('withAIQueryType', () => {
it('stamps the tag onto every builder query', () => {
const result = withAIQueryType(
makeQuery([{ queryName: 'A' }, { queryName: 'B' }]),
true,
);
expect(
result.builder.queryData.map((item) => item.builderQueryType),
).toStrictEqual(['builder_ai_query', 'builder_ai_query']);
});
it('deletes the key when clearing, rather than setting undefined', () => {
const result = withAIQueryType(
makeQuery([{ queryName: 'A', builderQueryType: 'builder_ai_query' }]),
false,
);
expect(result.builder.queryData[0]).not.toHaveProperty('builderQueryType');
expect(result.builder.queryData[0]).toStrictEqual({ queryName: 'A' });
});
it('returns the query untouched when it already matches', () => {
const tagged = makeQuery([{ builderQueryType: 'builder_ai_query' }]);
const plain = makeQuery([{ queryName: 'A' }]);
expect(withAIQueryType(tagged, true)).toBe(tagged);
expect(withAIQueryType(plain, false)).toBe(plain);
});
});

View File

@@ -0,0 +1,91 @@
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
/**
* Tab key for the AI query builder. Deliberately not an `EQueryType`: an AI query is
* a builder query carrying `builderQueryType: 'builder_ai_query'`, so the query type
* on the wire stays `builder` and only the per-query envelope tag differs. Keeping the
* tab out of the enum leaves that tag the single source of truth.
*/
export const AI_QUERY_TAB = 'ai_builder' as const;
export type QueryTabKey = EQueryType | typeof AI_QUERY_TAB;
export function isAIQuery(query: Query): boolean {
return query.builder.queryData.some(
(item) => item.builderQueryType === 'builder_ai_query',
);
}
/** The tab to highlight — derived from the queries, never stored separately. */
export function resolveActiveQueryTab(query: Query): QueryTabKey {
return query.queryType === EQueryType.QUERY_BUILDER && isAIQuery(query)
? AI_QUERY_TAB
: query.queryType;
}
/** Carried across a signal switch, mirroring the builder's own datasource selector. */
const PRESERVED_ON_SIGNAL_SWITCH = ['queryName', 'expression'];
/**
* Re-seed a non-traces query with the traces defaults, the way `handleChangeDataSource`
* does. AI queries are traces-only, and a leftover metrics aggregation carries
* `metricName` — a field the backend rejects on a trace spec. A query already on traces
* keeps its filters, so switching tabs on a trace query is non-destructive.
*/
function toTracesQueryData(item: IBuilderQuery): IBuilderQuery {
if (item.dataSource === DataSource.TRACES) {
return item;
}
const tracesDefaults = Object.fromEntries(
Object.entries(initialQueryBuilderFormValuesMap[DataSource.TRACES]).filter(
([key]) => !PRESERVED_ON_SIGNAL_SWITCH.includes(key),
),
);
return { ...item, ...tracesDefaults, dataSource: DataSource.TRACES };
}
/** Move a query onto the AI builder: pin every query to traces and tag it. */
export function toAIQuery(query: Query): Query {
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({
...toTracesQueryData(item),
builderQueryType: 'builder_ai_query' as const,
})),
},
};
}
/**
* Stamp or clear `builderQueryType` across every builder query. Returns the query
* untouched when it already matches, and deletes the key rather than setting it to
* `undefined` — the dirty checks compare by value, so a stray key reads as an edit.
*/
export function withAIQueryType(query: Query, enabled: boolean): Query {
const needsUpdate = query.builder.queryData.some(
(item) => (item.builderQueryType === 'builder_ai_query') !== enabled,
);
if (!needsUpdate) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item): IBuilderQuery => {
const { builderQueryType: _dropped, ...rest } = item;
return enabled ? { ...rest, builderQueryType: 'builder_ai_query' } : rest;
}),
},
};
}

View File

@@ -5,7 +5,10 @@ import { handleQueryChange } from 'container/NewWidget/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../../Panels/capabilities';
import {
resolveQueryType,
supportsAIQuery,
} from '../../../Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
@@ -19,6 +22,7 @@ jest.mock('container/NewWidget/utils', () => ({
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
supportsAIQuery: jest.fn(),
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),
@@ -33,6 +37,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
const mockSupportsAIQuery = supportsAIQuery as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
@@ -96,7 +101,11 @@ describe('usePanelTypeSwitch', () => {
it('does nothing when switching to the current kind', () => {
const setSpec = jest.fn();
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
const state = builderState({
id: 'q',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
@@ -114,7 +123,11 @@ describe('usePanelTypeSwitch', () => {
it('on first visit: transforms the query and resets the spec to the new kind', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const tableQuery = {
id: 'table-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const state = builderState(tableQuery);
mockUseQueryBuilder.mockReturnValue(state);
@@ -142,7 +155,11 @@ describe('usePanelTypeSwitch', () => {
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
const setSpec = jest.fn();
mockUseQueryBuilder.mockReturnValue(
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
builderState({
id: 'ts-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query),
);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
@@ -169,7 +186,11 @@ describe('usePanelTypeSwitch', () => {
it('coerces the query type when the new kind disallows it (promql → List)', () => {
const setSpec = jest.fn();
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
const promQuery = {
id: 'prom',
queryType: 'promql',
builder: { queryData: [] },
} as unknown as Query;
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
@@ -191,10 +212,88 @@ describe('usePanelTypeSwitch', () => {
expect((queryArg as Query).queryType).toBe('builder');
});
// `handleQueryChange` rebuilds from a field allow-list that omits `builderQueryType`,
// so the tag has to be re-applied after the rebuild or the AI tab silently reverts.
it('re-applies the AI envelope tag when the new kind supports AI queries', () => {
const setSpec = jest.fn();
mockSupportsAIQuery.mockReturnValue(true);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }] },
} as unknown as Query);
const aiQuery = {
id: 'ai-current',
queryType: 'builder',
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
} as unknown as Query;
const state = builderState(aiQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
});
it('drops the AI envelope tag when the new kind has no AI tab', () => {
const setSpec = jest.fn();
mockSupportsAIQuery.mockReturnValue(false);
mockHandleQueryChange.mockReturnValue({
id: 'transformed',
queryType: 'builder',
builder: { queryData: [{ orderBy: [] }] },
} as unknown as Query);
const aiQuery = {
id: 'ai-current',
queryType: 'builder',
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
} as unknown as Query;
const state = builderState(aiQuery);
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelTypeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// The rebuild receives an untagged query…
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).builder.queryData[0]).not.toHaveProperty(
'builderQueryType',
);
// …and nothing re-applies it afterwards.
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].builderQueryType).toBeUndefined();
});
it('restores the original kind verbatim on switch-back (reversibility)', () => {
const setSpec = jest.fn();
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
const tableQuery = {
id: 'table-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
const listQuery = {
id: 'list-current',
queryType: 'builder',
builder: { queryData: [] },
} as unknown as Query;
let state = builderState(tableQuery);
mockUseQueryBuilder.mockImplementation(() => state);

View File

@@ -18,7 +18,7 @@ import type {
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from '../../Panels/capabilities';
import { resolveQueryType, supportsAIQuery } from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
@@ -29,6 +29,7 @@ import {
getSwitchedPluginSpec,
type SwitchedPluginSpec,
} from '../getSwitchedPluginSpec';
import { isAIQuery, withAIQueryType } from '../PanelEditorQueryBuilder/utils';
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
@@ -139,16 +140,24 @@ export function usePanelTypeSwitch({
// First visit → coerce the query type if the new kind disallows it, then
// rebuild the builder query for the new type.
const queryType = resolveQueryType(newKind, query.queryType);
// AI-ness rides on the query, not on `queryType`, so `resolveQueryType` can't
// see it: carry it across only when the new kind has an AI tab to surface it.
const keepAIQueryType = supportsAIQuery(newKind) && isAIQuery(query);
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType },
{ ...withAIQueryType(query, false), queryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
const nextQuery =
const reordered =
newPanelType === PANEL_TYPES.LIST
? withDefaultListOrder(transformed)
: transformed;
// `handleQueryChange` rebuilds each query from an allow-list of fields that
// doesn't include `builderQueryType`, so re-stamp it after the rebuild.
const nextQuery = keepAIQueryType
? withAIQueryType(reordered, true)
: reordered;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;

View File

@@ -39,6 +39,15 @@ export function isQueryTypeSupportedByPanelKind(
return getSupportedQueryTypes(kind).includes(queryType);
}
/**
* Whether a kind offers the AI query builder. Separate from `supportedQueryTypes`
* because an AI query is a builder query carrying `builderQueryType`, not its own
* `EQueryType` — the tab is UI state, the wire type stays `builder`.
*/
export function supportsAIQuery(kind: PanelKind): boolean {
return getPanelDefinition(kind).supportsAIQuery === true;
}
/**
* Master guard: is this panel kind renderable with this query type (and, in builder
* mode, this signal)? ClickHouse/PromQL queries carry no signal, so the signal is

View File

@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
queryBuilderFields: {},
actions: {
view: true,

View File

@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
queryBuilderFields: {},
actions: {
view: true,

View File

@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
// list configs — the capabilities guard is the single source for both.
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
supportsAIQuery: true,
queryBuilderFields: {
default: {
stepInterval: { isHidden: true, isDisabled: true },

View File

@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
queryBuilderFields: {},
actions: {
view: true,

View File

@@ -15,6 +15,7 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportsAIQuery: true,
queryBuilderFields: {},
actions: {
view: true,

View File

@@ -15,6 +15,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
TelemetrytypesSignalDTO.traces,
],
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
supportsAIQuery: true,
queryBuilderFields: {},
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
actions: {

View File

@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
EQueryType.CLICKHOUSE,
EQueryType.PROM,
],
supportsAIQuery: true,
queryBuilderFields: {},
actions: {
view: true,

View File

@@ -48,6 +48,8 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
supportedQueryTypes: EQueryType[];
/** Kind offers the AI query builder — a traces-only builder variant, not its own query language. */
supportsAIQuery?: boolean;
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
queryBuilderFields: QueryBuilderFieldRule;
actions: PanelActionCapabilities;

View File

@@ -5,8 +5,8 @@ import type {
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Flattens a panel's queries into its builder queries, unwrapping
* `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
* Flattens a panel's queries into its builder queries (`builder_query` and its AI
* variant), unwrapping `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
* TraceOperator) are dropped — they lack the legend/groupBy/aggregation context
* downstream code needs. Returns the generated v5 `BuilderQuery` shape directly.
*/
@@ -22,7 +22,7 @@ export function getBuilderQueries(
}
if (plugin.kind === 'signoz/CompositeQuery') {
(plugin.spec.queries || []).forEach((sub) => {
if (sub.type === 'builder_query') {
if (sub.type === 'builder_query' || sub.type === 'builder_ai_query') {
flattened.push(sub.spec as BuilderQuery);
}
});

View File

@@ -2,7 +2,11 @@ import type {
DashboardtypesQueryDTO,
Querybuildertypesv5QueryEnvelopeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
initialQueriesMap,
initialQueryAIWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -171,6 +175,23 @@ describe('persesQueryAdapters', () => {
);
});
it('preserves an AI builder query through toPerses → fromPerses', () => {
const original: Query = initialQueryAIWithType;
const perses = toPerses(original, PANEL_TYPES.TIME_SERIES);
const { queries } = perses[0].spec.plugin.spec as {
queries: Querybuildertypesv5QueryEnvelopeDTO[];
};
expect(queries[0].type).toBe('builder_ai_query');
const restored = fromPerses(perses, PANEL_TYPES.TIME_SERIES);
expect(restored.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(restored.builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
});
it('preserves a List builder query through toPerses → fromPerses', () => {
const original: Query = initialQueriesMap[DataSource.LOGS];

View File

@@ -2,10 +2,6 @@ import { TabRoutes } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
import InfraMonitoringHostsV2 from 'container/InfraMonitoringHostsV2';
import InfraMonitoringK8sV2 from 'container/InfraMonitoringK8sV2';
import {
DEFAULT_K8S_CATEGORY,
INFRA_MONITORING_K8S_PARAMS_KEYS,
} from 'container/InfraMonitoringK8sV2/constants';
import { Inbox } from '@signozhq/icons';
function HostsContainer(): JSX.Element {
@@ -34,6 +30,6 @@ export const Kubernetes: TabRoutes = {
<Inbox size={16} /> Kubernetes
</div>
),
route: `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=${DEFAULT_K8S_CATEGORY}`,
route: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
key: ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES,
};

View File

@@ -475,6 +475,7 @@ export function QueryBuilderProvider({
const newQuery: IBuilderQuery = {
...initialBuilderQuery,
source: queries?.[0]?.source || '',
builderQueryType: queries?.[0]?.builderQueryType,
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
expression: createNewBuilderItemName({
existNames,

View File

@@ -0,0 +1,55 @@
import {
initialQueriesMap,
initialQueryAIWithType,
} from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const renderQueryBuilder = (
initialQuery: Query,
): ReturnType<
typeof renderHook<ReturnType<typeof useQueryBuilder>, unknown>
> => {
const hook = renderHook(() => useQueryBuilder(), {
wrapper: AllTheProviders,
});
act(() => {
hook.result.current.initQueryBuilderData(initialQuery);
});
return hook;
};
describe('createNewBuilderQuery builderQueryType propagation', () => {
it('carries builderQueryType from the first query onto an added query', () => {
const { result } = renderQueryBuilder(initialQueryAIWithType);
expect(
result.current.currentQuery.builder.queryData[0].builderQueryType,
).toBe('builder_ai_query');
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
expect(
result.current.currentQuery.builder.queryData[1].builderQueryType,
).toBe('builder_ai_query');
});
it('leaves builderQueryType unset when the first query has none', () => {
const { result } = renderQueryBuilder(initialQueriesMap.traces);
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
expect(
result.current.currentQuery.builder.queryData[1].builderQueryType,
).toBeUndefined();
});
});

View File

@@ -8,6 +8,7 @@ import {
} from 'types/common/queryBuilder';
import {
BuilderQueryType,
Filter,
Having as HavingV5,
LogAggregation,
@@ -90,6 +91,7 @@ export type IBuilderQuery = {
offset?: number;
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
source?: 'meter' | '';
builderQueryType?: BuilderQueryType;
};
export interface IClickHouseQuery {

View File

@@ -16,6 +16,7 @@ export type RequestType =
export type QueryType =
| 'builder_query'
| 'builder_ai_query'
| 'builder_trace_operator'
| 'builder_formula'
| 'builder_sub_query'
@@ -23,6 +24,11 @@ export type QueryType =
| 'clickhouse_sql'
| 'promql';
export type BuilderQueryType = Extract<
QueryType,
'builder_query' | 'builder_ai_query'
>;
export type OrderDirection = 'asc' | 'desc';
export type JoinType = 'inner' | 'left' | 'right' | 'full' | 'cross';

View File

@@ -1,557 +0,0 @@
// Copyright (c) 2026 SigNoz, Inc.
// Copyright 2023 Prometheus Team
// SPDX-License-Identifier: Apache-2.0
package jira
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sort"
"strings"
"time"
"unicode/utf16"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/adf"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/template"
"github.com/prometheus/alertmanager/types"
)
const Integration = "jira"
const (
maxSummaryLenRunes = 255
maxDescriptionLenRunes = 32767
)
// Notifier implements notify.Notifier for Jira.
type Notifier struct {
conf *alertmanagertypes.JiraReceiverConfig
logger *slog.Logger
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
}
func New(conf *alertmanagertypes.JiraReceiverConfig, _ *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) (*Notifier, error) {
if conf.HTTPConfig == nil {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira http_config is nil")
}
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration)
if err != nil {
return nil, err
}
return &Notifier{
conf: conf,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
}, nil
}
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
key, err := notify.ExtractGroupKey(ctx)
if err != nil {
return false, err
}
groupID := key.Hash()
firing := types.Alerts(as...).HasFiring()
n.logger.DebugContext(ctx, "sending jira notification", slog.String("group_key", key.String()), slog.Bool("firing", firing))
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Summary,
DefaultBodyTemplate: n.conf.Description,
}, as)
if err != nil {
return false, err
}
summary := truncateRunes(result.Title, maxSummaryLenRunes)
var parts []string
for _, body := range result.Body {
if body != "" {
parts = append(parts, body)
}
}
// custom body templates render per alert; join them under ADF rule dividers.
// The default body is a single combined part, so the join is a no-op there.
descText := truncateRunes(strings.Join(parts, "\n\n---\n\n"), maxDescriptionLenRunes)
baseURL, retry, err := n.resolveAPIBaseURL(ctx)
if err != nil {
return retry, err
}
existing, retry, err := n.searchIssue(ctx, baseURL, groupID, firing)
if err != nil {
return retry, err
}
fields := n.buildFields(groupID, summary, descText, as, firing)
// No existing issue: create for firing groups; never create for resolved-only.
if existing == nil {
if !firing {
return false, nil
}
return n.createIssue(ctx, baseURL, fields)
}
// Existing issue: refresh it, then transition + comment based on the new state.
if retry, err := n.updateIssue(ctx, baseURL, existing, fields); err != nil {
return retry, err
}
// Each state-change comment carries the same rich snapshot as the description
// (panel + details + deep-links), so the comment timeline mirrors the card
// Google Chat re-posts on every notification.
switch {
case firing && existing.isDone(): // re-fired after resolution → reopen
if retry, err := n.applyTransition(ctx, baseURL, existing.Key, false, n.conf.ReopenTransition); err != nil {
return retry, err
}
case !firing: // resolved (search returns only open issues, so this one is open)
if retry, err := n.applyTransition(ctx, baseURL, existing.Key, true, n.conf.ResolveTransition); err != nil {
return retry, err
}
}
// firing && !isDone (still firing) needs no transition.
return n.addComment(ctx, baseURL, existing.Key, fields.Description)
}
func (n *Notifier) buildFields(groupID, summary, descText string, alerts []*types.Alert, firing bool) *issueFields {
f := &issueFields{
Project: &idKey{Key: n.conf.Project},
Issuetype: &idName{Name: n.conf.IssueType},
Summary: summary,
Labels: n.labels(groupID),
Description: n.buildBoundedDescription(descText, alerts, firing),
}
if n.conf.Priority != "" {
f.Priority = &idName{Name: n.conf.Priority}
}
return f
}
// buildBoundedDescription builds the ADF issue body and keeps it within Jira's
// description limit, which counts text characters plus per-node overhead — so a
// text-only markdown cap is not enough. Over-limit bodies are shrunk at the
// markdown level and rebuilt; the panel and deep-links are part of the measured
// document, so the result always fits.
func (n *Notifier) buildBoundedDescription(descText string, alerts []*types.Alert, firing bool) map[string]any {
doc := n.buildDescription(descText, alerts, firing)
for range 4 {
size := adfDocLen(doc)
if size <= maxDescriptionLenRunes {
return doc
}
runes := []rune(descText)
keep := len(runes) * maxDescriptionLenRunes / size * 9 / 10
if keep >= len(runes) {
keep = len(runes) - 1
}
if keep <= 0 {
break
}
descText = string(runes[:keep]) + "…"
doc = n.buildDescription(descText, alerts, firing)
}
if adfDocLen(doc) <= maxDescriptionLenRunes {
return doc
}
// still over after shrinking: keep just the panel and deep-links
return n.buildDescription("", alerts, firing)
}
// adfDocLen approximates how Jira measures an ADF document against the 32767
// limit: text length in UTF-16 code units, plus per-node overhead (block
// boundaries count like newlines), plus link targets. Deliberately counts on
// the high side so a passing measurement never 400s.
func adfDocLen(node any) int {
m, ok := node.(map[string]any)
if !ok {
return 0
}
size := 2
if text, ok := m["text"].(string); ok {
for _, r := range text {
size += utf16.RuneLen(r)
}
}
if marks, ok := m["marks"].([]any); ok {
for _, mark := range marks {
if mm, ok := mark.(map[string]any); ok {
if attrs, ok := mm["attrs"].(map[string]any); ok {
if href, ok := attrs["href"].(string); ok {
size += len(href)
}
}
}
}
}
if content, ok := m["content"].([]any); ok {
for _, child := range content {
size += adfDocLen(child)
}
}
return size
}
// buildDescription assembles the ADF issue body: a firing/resolved status panel,
// the rendered markdown body, and SigNoz deep-links.
func (n *Notifier) buildDescription(descText string, alerts []*types.Alert, firing bool) map[string]any {
content := []any{statusPanel(firing)}
content = append(content, adf.Render(descText)...)
if links := deepLinks(alerts); links != nil {
content = append(content, links)
}
return map[string]any{"type": "doc", "version": 1, "content": content}
}
func statusPanel(firing bool) map[string]any {
panelType, label := "success", "🟢 RESOLVED"
if firing {
panelType, label = "error", "🔴 FIRING"
}
return map[string]any{
"type": "panel",
"attrs": map[string]any{"panelType": panelType},
"content": []any{map[string]any{
"type": "paragraph",
"content": []any{map[string]any{"type": "text", "text": label, "marks": []any{map[string]any{"type": "strong"}}}},
}},
}
}
// deepLinks builds a paragraph of SigNoz links from the per-rule ruleSource label
// and the related-logs/traces annotations. Returns nil when none are present.
func deepLinks(alerts []*types.Alert) map[string]any {
if len(alerts) == 0 {
return nil
}
a := alerts[0]
var parts []any
add := func(label, url string) {
if url == "" {
return
}
if len(parts) > 0 {
parts = append(parts, map[string]any{"type": "text", "text": " · "})
}
parts = append(parts, map[string]any{
"type": "text",
"text": label,
"marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": url}}},
})
}
add("Open in SigNoz", string(a.Labels[ruletypes.LabelRuleSource]))
add("View Related Logs", string(a.Annotations[ruletypes.AnnotationRelatedLogs]))
add("View Related Traces", string(a.Annotations[ruletypes.AnnotationRelatedTraces]))
if len(parts) == 0 {
return nil
}
return map[string]any{"type": "paragraph", "content": parts}
}
func (n *Notifier) labels(groupID string) []string {
out := append([]string{}, n.conf.Labels...)
out = append(out, "signoz-alert", fmt.Sprintf("ALERT{%s}", groupID))
sort.Strings(out)
return out
}
func (n *Notifier) searchIssue(ctx context.Context, baseURL, groupID string, firing bool) (*issue, bool, error) {
var jql strings.Builder
if n.conf.WontFixResolution != "" {
// != alone also drops unresolved (EMPTY) issues, so keep those explicitly.
fmt.Fprintf(&jql, `(resolution is EMPTY or resolution != %q) and `, n.conf.WontFixResolution)
}
if reopenMin := int64(time.Duration(n.conf.ReopenDuration).Minutes()); firing && reopenMin > 0 {
fmt.Fprintf(&jql, `(resolutiondate is EMPTY OR resolutiondate >= -%dm) and `, reopenMin)
} else {
jql.WriteString(`statusCategory != Done and `)
}
fmt.Fprintf(&jql, `project=%q and labels=%q order by status ASC, resolutiondate DESC`, n.conf.Project, fmt.Sprintf("ALERT{%s}", groupID))
body, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/search/jql", searchRequest{
JQL: jql.String(), MaxResults: 2, Fields: []string{"status", "labels"},
})
if err != nil {
return nil, retry, err
}
var res searchResult
if err := json.Unmarshal(body, &res); err != nil {
return nil, false, err
}
if len(res.Issues) == 0 {
return nil, false, nil
}
// the JQL order is not category-aware, so prefer an open issue over a done
// one; all done falls back to the most recently resolved (resolutiondate DESC)
for i := range res.Issues {
if !res.Issues[i].isDone() {
return &res.Issues[i], false, nil
}
}
return &res.Issues[0], false, nil
}
func (n *Notifier) createIssue(ctx context.Context, baseURL string, fields *issueFields) (bool, error) {
_, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/issue", issue{Fields: fields})
return retry, err
}
func (n *Notifier) updateIssue(ctx context.Context, baseURL string, existing *issue, fields *issueFields) (bool, error) {
// project and issue type are set at creation and cannot be edited.
upd := *fields
upd.Project = nil
upd.Issuetype = nil
// Jira replaces the labels array wholesale, so union in the labels already
// on the issue to keep user-added ones.
if existing.Fields != nil {
upd.Labels = mergeLabels(existing.Fields.Labels, fields.Labels)
}
_, retry, err := n.callAPI(ctx, http.MethodPut, n.issueURL(baseURL, existing.Key, ""), issue{Fields: &upd})
return retry, err
}
func mergeLabels(existing, ours []string) []string {
seen := make(map[string]bool, len(existing)+len(ours))
var merged []string
for _, label := range append(append([]string{}, existing...), ours...) {
if !seen[label] {
seen[label] = true
merged = append(merged, label)
}
}
sort.Strings(merged)
return merged
}
// applyTransition moves the issue into (toDone) or out of (!toDone) the "done"
// status category, preferring the named override, else the first matching
// transition, else skipping without error when none is available.
func (n *Notifier) applyTransition(ctx context.Context, baseURL, key string, toDone bool, override string) (bool, error) {
transitions, retry, err := n.getTransitions(ctx, baseURL, key)
if err != nil {
return retry, err
}
id := selectTransition(transitions, toDone, override)
if id == "" {
n.logger.WarnContext(ctx, "jira: no matching transition, leaving issue as-is", slog.String("issue", key), slog.Bool("to_done", toDone))
return false, nil
}
_, retry, err = n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "transitions"), issue{Transition: &idName{ID: id}})
return retry, err
}
func (n *Notifier) getTransitions(ctx context.Context, baseURL, key string) ([]jiraTransition, bool, error) {
body, retry, err := n.callAPI(ctx, http.MethodGet, n.issueURL(baseURL, key, "transitions"), nil)
if err != nil {
return nil, retry, err
}
var tr transitionsResponse
if err := json.Unmarshal(body, &tr); err != nil {
return nil, false, err
}
return tr.Transitions, false, nil
}
func (n *Notifier) addComment(ctx context.Context, baseURL, key string, body any) (bool, error) {
_, retry, err := n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "comment"), comment{Body: body})
return retry, err
}
func (n *Notifier) issueURL(baseURL, key, sub string) string {
u := baseURL + "/issue/" + key
if sub != "" {
u += "/" + sub
}
return u
}
func (n *Notifier) callAPI(ctx context.Context, method, url string, reqBody any) ([]byte, bool, error) {
var body io.Reader
if reqBody != nil {
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(reqBody); err != nil {
return nil, false, err
}
body = &buf
}
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, false, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := n.client.Do(req) //nolint:bodyclose // notify.Drain closes the body
if err != nil {
return nil, true, notify.RedactURL(err)
}
defer notify.Drain(resp)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, false, err
}
shouldRetry, err := n.retrier.Check(resp.StatusCode, bytes.NewReader(respBody))
if err != nil {
return respBody, shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
return respBody, false, nil
}
// resolveAPIBaseURL resolves the service-account cloud id per notification (it
// is never persisted); personal API tokens use the site host directly.
func (n *Notifier) resolveAPIBaseURL(ctx context.Context) (string, bool, error) {
if !n.conf.IsServiceAccount() {
return n.conf.APIBaseURL(""), false, nil
}
cloudID, retry, err := n.resolveCloudID(ctx)
if err != nil {
return "", retry, err
}
return n.conf.APIBaseURL(cloudID), false, nil
}
// resolveCloudID fetches the site's cloud id from its unauthenticated
// tenant_info endpoint; transport failures are retryable, bad responses are not.
func (n *Notifier) resolveCloudID(ctx context.Context) (string, bool, error) {
url := strings.TrimRight(n.conf.Site, "/") + "/_edge/tenant_info"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", false, err
}
req.Header.Set("Accept", "application/json")
resp, err := n.client.Do(req)
if err != nil {
return "", true, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch jira cloud id")
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", true, err
}
if resp.StatusCode != http.StatusOK {
return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to resolve jira cloud id from %s: status %d", url, resp.StatusCode)
}
var out struct {
CloudID string `json:"cloudId"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", false, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse jira tenant_info response")
}
if out.CloudID == "" {
return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira tenant_info returned an empty cloud id for %s", n.conf.Site)
}
return out.CloudID, false, nil
}
// selectTransition returns the id of the transition whose target status category
// matches toDone, preferring one named override when present.
func selectTransition(transitions []jiraTransition, toDone bool, override string) string {
if override != "" {
for _, t := range transitions {
if t.Name == override {
return t.ID
}
}
}
for _, t := range transitions {
if (t.To.StatusCategory.Key == "done") == toDone {
return t.ID
}
}
return ""
}
// Jira API types.
type issue struct {
Key string `json:"key,omitempty"`
Fields *issueFields `json:"fields,omitempty"`
Transition *idName `json:"transition,omitempty"`
}
type issueFields struct {
Project *idKey `json:"project,omitempty"`
Issuetype *idName `json:"issuetype,omitempty"`
Summary string `json:"summary,omitempty"`
Labels []string `json:"labels,omitempty"`
Priority *idName `json:"priority,omitempty"`
Description any `json:"description,omitempty"`
Status *issueStatus `json:"status,omitempty"`
}
type idKey struct {
Key string `json:"key"`
}
type idName struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
}
type issueStatus struct {
StatusCategory struct {
Key string `json:"key"`
} `json:"statusCategory"`
}
func (i *issue) isDone() bool {
return i.Fields != nil && i.Fields.Status != nil && i.Fields.Status.StatusCategory.Key == "done"
}
type searchRequest struct {
JQL string `json:"jql"`
MaxResults int `json:"maxResults"`
Fields []string `json:"fields"`
}
type searchResult struct {
Issues []issue `json:"issues"`
}
type transitionsResponse struct {
Transitions []jiraTransition `json:"transitions"`
}
type jiraTransition struct {
ID string `json:"id"`
Name string `json:"name"`
To struct {
StatusCategory struct {
Key string `json:"key"`
} `json:"statusCategory"`
} `json:"to"`
}
type comment struct {
Body any `json:"body"`
}
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}

View File

@@ -1,489 +0,0 @@
package jira
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/types"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockReq struct {
method string
path string
body map[string]any
}
type mockJira struct {
srv *httptest.Server
mu sync.Mutex
reqs []mockReq
searchIssues []issue
transitions []jiraTransition
createStatus int
}
func newMockJira(t *testing.T) *mockJira {
t.Helper()
m := &mockJira{}
m.srv = httptest.NewServer(http.HandlerFunc(m.handle))
t.Cleanup(m.srv.Close)
return m
}
func (m *mockJira) handle(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
m.mu.Lock()
m.reqs = append(m.reqs, mockReq{r.Method, r.URL.Path, body})
m.mu.Unlock()
p := r.URL.Path
switch {
case strings.HasSuffix(p, "/search/jql"):
_ = json.NewEncoder(w).Encode(searchResult{Issues: m.searchIssues})
case strings.HasSuffix(p, "/transitions") && r.Method == http.MethodGet:
_ = json.NewEncoder(w).Encode(transitionsResponse{Transitions: m.transitions})
case strings.HasSuffix(p, "/transitions"):
w.WriteHeader(http.StatusNoContent)
case strings.HasSuffix(p, "/comment"):
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":"1"}`))
case strings.HasSuffix(p, "/issue") && r.Method == http.MethodPost:
st := m.createStatus
if st == 0 {
st = http.StatusCreated
}
w.WriteHeader(st)
_, _ = w.Write([]byte(`{"key":"KAN-1"}`))
case r.Method == http.MethodPut:
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}
func (m *mockJira) countPost(suffix string) int {
m.mu.Lock()
defer m.mu.Unlock()
c := 0
for _, r := range m.reqs {
if r.method == http.MethodPost && strings.HasSuffix(r.path, suffix) {
c++
}
}
return c
}
func (m *mockJira) countPuts() int {
m.mu.Lock()
defer m.mu.Unlock()
c := 0
for _, r := range m.reqs {
if r.method == http.MethodPut {
c++
}
}
return c
}
func newNotifier(t *testing.T, m *mockJira) *Notifier {
t.Helper()
tmpl := test.CreateTmpl(t)
n, err := New(&alertmanagertypes.JiraReceiverConfig{
Site: m.srv.URL,
Project: "KAN",
IssueType: "Task",
Summary: alertmanagertypes.DefaultJiraSummaryTemplate,
Description: alertmanagertypes.DefaultJiraDescriptionTemplate,
HTTPConfig: &commoncfg.HTTPClientConfig{},
ReopenDuration: model.Duration(3 * 24 * time.Hour),
}, tmpl, slog.New(slog.DiscardHandler), alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler)))
require.NoError(t, err)
return n
}
func alert(firing bool) *types.Alert {
a := &types.Alert{Alert: model.Alert{
Labels: model.LabelSet{"alertname": "HighCPU", "severity": "critical"},
Annotations: model.LabelSet{"summary": "cpu high"},
StartsAt: time.Now().Add(-time.Minute),
}}
if firing {
a.EndsAt = time.Now().Add(time.Hour)
} else {
a.EndsAt = time.Now().Add(-time.Minute)
}
return a
}
func ctx() context.Context {
return notify.WithGroupKey(context.Background(), "test-jira")
}
func doneIssue() issue {
i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}}
i.Fields.Status.StatusCategory.Key = "done"
return i
}
func openIssue() issue {
i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}}
i.Fields.Status.StatusCategory.Key = "new"
return i
}
func transition(id, name, category string) jiraTransition {
tr := jiraTransition{ID: id, Name: name}
tr.To.StatusCategory.Key = category
return tr
}
func TestNotifyCreatesWhenNoExistingIssue(t *testing.T) {
m := newMockJira(t)
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 1, m.countPost("/issue"))
assert.Equal(t, 0, m.countPost("/comment")) // no comment on create
assert.Equal(t, 0, m.countPuts()) // no update
}
func TestNotifyResolvedOnlyWithNoIssueIsNoop(t *testing.T) {
m := newMockJira(t)
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 1, m.countPost("/search/jql"))
assert.Equal(t, 0, m.countPost("/issue"))
}
func TestNotifyStillFiringUpdatesAndComments(t *testing.T) {
m := newMockJira(t)
m.searchIssues = []issue{openIssue()}
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 0, m.countPost("/issue")) // no create
assert.Equal(t, 1, m.countPuts()) // update
assert.Equal(t, 1, m.countPost("/comment"))
assert.Equal(t, 0, m.countPost("/transitions")) // still open, no transition
// comment carries the full rich snapshot (panel + labeled body), not a one-liner.
cjs, err := json.Marshal(m.lastBody(t, "/comment"))
require.NoError(t, err)
assert.Contains(t, string(cjs), `"panel"`)
assert.Contains(t, string(cjs), "Summary:")
}
func TestNotifyResolveTransitionsToDoneAndComments(t *testing.T) {
m := newMockJira(t)
m.searchIssues = []issue{openIssue()}
m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")}
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 1, m.countPuts()) // update
assert.Equal(t, 1, m.countPost("/transitions")) // resolve transition
assert.Equal(t, 1, m.countPost("/comment"))
}
func TestNotifyReopensDoneIssue(t *testing.T) {
m := newMockJira(t)
m.searchIssues = []issue{doneIssue()}
m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")}
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 1, m.countPost("/transitions")) // reopen transition
assert.Equal(t, 1, m.countPost("/comment"))
}
func TestNotifySafeSkipsWhenNoMatchingTransition(t *testing.T) {
m := newMockJira(t)
m.searchIssues = []issue{openIssue()}
m.transitions = []jiraTransition{transition("11", "To Do", "new")} // no done-category transition
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
require.NoError(t, err) // must not error
assert.False(t, retry)
assert.Equal(t, 0, m.countPost("/transitions")) // skipped
assert.Equal(t, 1, m.countPost("/comment")) // comment still posted
}
func TestNotifyPrefersOpenIssueOverRecentlyDone(t *testing.T) {
m := newMockJira(t)
open := openIssue()
open.Key = "KAN-2"
// the JQL order can put a recently-done issue first; the open one must win
m.searchIssues = []issue{doneIssue(), open}
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
assert.False(t, retry)
assert.Equal(t, 0, m.countPost("/issue")) // no duplicate create
assert.Equal(t, 0, m.countPost("/transitions")) // open issue → no reopen
assert.Equal(t, 1, m.countPuts())
assert.Equal(t, 1, m.countPost("/comment"))
m.mu.Lock()
defer m.mu.Unlock()
for _, r := range m.reqs {
if r.method == http.MethodPut || strings.HasSuffix(r.path, "/comment") {
assert.Contains(t, r.path, "KAN-2")
}
}
}
func TestNotifyRetriesOn429(t *testing.T) {
m := newMockJira(t)
m.createStatus = http.StatusTooManyRequests
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.Error(t, err)
assert.True(t, retry)
}
func (m *mockJira) lastBody(t *testing.T, suffix string) map[string]any {
t.Helper()
m.mu.Lock()
defer m.mu.Unlock()
for i := len(m.reqs) - 1; i >= 0; i-- {
if m.reqs[i].method == http.MethodPost && strings.HasSuffix(m.reqs[i].path, suffix) {
return m.reqs[i].body
}
}
t.Fatalf("no POST request to %s", suffix)
return nil
}
func TestNotifyRichDescriptionPanelAndLinks(t *testing.T) {
m := newMockJira(t)
a := alert(true)
a.Labels[ruletypes.LabelRuleSource] = model.LabelValue("https://app.signoz.io/alerts?ruleId=1")
a.Annotations[ruletypes.AnnotationRelatedLogs] = model.LabelValue("https://app.signoz.io/logs")
_, err := newNotifier(t, m).Notify(ctx(), a)
require.NoError(t, err)
body := m.lastBody(t, "/issue")
js, err := json.Marshal(body)
require.NoError(t, err)
s := string(js)
assert.Contains(t, s, `"panel"`) // status panel present
assert.Contains(t, s, `"error"`) // firing → error panel
assert.Contains(t, s, "Open in SigNoz") // rule deep-link
assert.Contains(t, s, "https://app.signoz.io/alerts?ruleId=1") // rule url
assert.Contains(t, s, "View Related Logs") // related-logs deep-link
assert.Contains(t, s, "Summary:") // labeled body section
assert.Contains(t, s, "cpu high") // rendered annotation
}
func TestNotifyCustomTemplateAnnotationsOverrideDefaults(t *testing.T) {
m := newMockJira(t)
a1 := alert(true)
a1.Labels["service"] = "payment"
a1.Labels["namespace"] = "ns-one"
a1.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service"
a1.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace"
a2 := alert(true)
a2.Labels["service"] = "payment"
a2.Labels["namespace"] = "ns-two"
a2.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service"
a2.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace"
_, err := newNotifier(t, m).Notify(ctx(), a1, a2)
require.NoError(t, err)
body := m.lastBody(t, "/issue")
fields, ok := body["fields"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "High throughput for payment", fields["summary"])
js, err := json.Marshal(fields["description"])
require.NoError(t, err)
s := string(js)
assert.Contains(t, s, "Firing in NS: ns-one")
assert.Contains(t, s, "Firing in NS: ns-two")
// per-alert custom bodies are separated by an ADF rule divider
assert.Contains(t, s, `"rule"`)
assert.NotContains(t, s, "Summary:") // default body template not used
}
// Jira replaces labels wholesale on PUT, so the update must union in the
// labels already on the issue or user-added ones get wiped.
func TestNotifyUpdatePreservesUserAddedLabels(t *testing.T) {
m := newMockJira(t)
existing := openIssue()
existing.Fields.Labels = []string{"user-added-label", "signoz-alert"}
m.searchIssues = []issue{existing}
_, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
search := m.lastBody(t, "/search/jql")
assert.Contains(t, search["fields"], "labels")
m.mu.Lock()
var putLabels []any
for _, r := range m.reqs {
if r.method == http.MethodPut {
putLabels, _ = r.body["fields"].(map[string]any)["labels"].([]any)
}
}
m.mu.Unlock()
assert.Contains(t, putLabels, "user-added-label")
assert.Contains(t, putLabels, "signoz-alert")
assert.Equal(t, 1, strings.Count(fmt.Sprint(putLabels), "signoz-alert")) // no duplicates
// the dedup label is re-asserted
found := false
for _, l := range putLabels {
if s, ok := l.(string); ok && strings.HasPrefix(s, "ALERT{") {
found = true
}
}
assert.True(t, found)
}
func TestADFDocLen(t *testing.T) {
text := func(s string) map[string]any { return map[string]any{"type": "text", "text": s} }
para := func(children ...any) map[string]any {
return map[string]any{"type": "paragraph", "content": children}
}
cases := []struct {
name string
node any
want int
}{
{"text node", text("hello"), 7}, // 5 utf16 + 2 overhead
{"emoji counts utf16", text("🔴"), 4}, // 2 utf16 units + 2 overhead
{"paragraph wraps text", para(text("hi")), 6}, // 2 + (2+2)
{"link href counted", map[string]any{"type": "text", "text": "a", "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": "https://x"}}}}, 12}, // 1 + 9 href + 2
{"non-map is zero", "junk", 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, adfDocLen(c.node))
})
}
}
// 30 fat custom bodies overflow Jira's description accounting (text + per-node
// overhead); the built doc must be shrunk under the limit, never rejected.
func TestNotifyDescriptionShrunkUnderJiraLimit(t *testing.T) {
m := newMockJira(t)
filler := strings.Repeat("This is a long runbook detail line used to inflate the alert body. ", 25)
alerts := make([]*types.Alert, 0, 30)
for i := range 30 {
a := alert(true)
a.Labels["service"] = model.LabelValue(strings.Repeat("s", 3) + string(rune('a'+i%26)))
a.Annotations[ruletypes.AnnotationTitleTemplate] = "overflow probe"
a.Annotations[ruletypes.AnnotationBodyTemplate] = model.LabelValue("**Alert in service** $labels.service\n\n" + filler)
alerts = append(alerts, a)
}
_, err := newNotifier(t, m).Notify(ctx(), alerts...)
require.NoError(t, err)
body := m.lastBody(t, "/issue")
fields, ok := body["fields"].(map[string]any)
require.True(t, ok)
desc := fields["description"]
assert.LessOrEqual(t, adfDocLen(desc), maxDescriptionLenRunes)
js, err := json.Marshal(desc)
require.NoError(t, err)
assert.Contains(t, string(js), "FIRING") // status panel survives the shrink
assert.Contains(t, string(js), "…") // body ends with the shrink marker
}
func TestFiringSearchJQLHasReopenWindow(t *testing.T) {
m := newMockJira(t)
_, err := newNotifier(t, m).Notify(ctx(), alert(true))
require.NoError(t, err)
body := m.lastBody(t, "/search/jql")
jql, ok := body["jql"].(string)
require.True(t, ok)
// newNotifier uses a 3d window → 4320 minutes.
assert.Contains(t, jql, "resolutiondate >= -4320m")
}
func TestSelectTransition(t *testing.T) {
ts := []jiraTransition{
transition("41", "Done", "done"),
transition("51", "Won't Do", "done"),
transition("11", "To Do", "new"),
}
assert.Equal(t, "41", selectTransition(ts, true, "")) // first done-category
assert.Equal(t, "51", selectTransition(ts, true, "Won't Do")) // named override
assert.Equal(t, "11", selectTransition(ts, false, "")) // first non-done
assert.Equal(t, "41", selectTransition(ts, true, "Nonexistent")) // bad override → fallback
assert.Equal(t, "", selectTransition([]jiraTransition{transition("11", "To Do", "new")}, true, "")) // none → skip
}
func TestResolveCloudID(t *testing.T) {
cases := []struct {
name string
handler http.HandlerFunc
want string
wantErr bool
wantRetry bool
}{
{
name: "success",
handler: func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/_edge/tenant_info", r.URL.Path)
_, _ = w.Write([]byte(`{"cloudId":"abc-123"}`))
},
want: "abc-123",
},
{
name: "non-200 is not retryable",
handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) },
wantErr: true,
},
{
name: "empty cloud id",
handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"cloudId":""}`)) },
wantErr: true,
},
{
name: "bad json",
handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`not json`)) },
wantErr: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv := httptest.NewServer(c.handler)
defer srv.Close()
n, err := New(&alertmanagertypes.JiraReceiverConfig{Site: srv.URL, HTTPConfig: &commoncfg.HTTPClientConfig{}}, nil, slog.New(slog.DiscardHandler), nil)
require.NoError(t, err)
got, retry, err := n.resolveCloudID(context.Background())
if c.wantErr {
assert.Error(t, err)
assert.Equal(t, c.wantRetry, retry)
return
}
require.NoError(t, err)
assert.Equal(t, c.want, got)
})
}
}

View File

@@ -1,61 +0,0 @@
// Copyright (c) 2026 SigNoz, Inc.
// SPDX-License-Identifier: Apache-2.0
// Package jsmops delivers Jira Service Management Ops alerts by reusing the
// Opsgenie notifier: JSM Ops is the ex-Opsgenie alert API, so we map the JSM
// config onto config.OpsGenieConfig with APIURL pinned to the JSM native
// integration-events gateway.
package jsmops
import (
"log/slog"
"net/url"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/template"
commoncfg "github.com/prometheus/common/config"
)
const (
Integration = "jsmops"
source = "SigNoz"
)
// New builds an Opsgenie notifier pointed at the JSM native endpoint.
// advancedFeatures enables the rich treatment: HTML body and a note timeline
// (per fire and on resolve).
func New(c *alertmanagertypes.JSMOpsReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool) (*opsgenie.Notifier, error) {
conf, err := toOpsGenieConfig(c)
if err != nil {
return nil, err
}
return opsgenie.New(conf, t, l, templater, advancedFeatures)
}
// toOpsGenieConfig maps the JSM config onto config.OpsGenieConfig with APIURL
// pinned to the JSM native gateway.
func toOpsGenieConfig(c *alertmanagertypes.JSMOpsReceiverConfig) (*config.OpsGenieConfig, error) {
apiURL, err := url.Parse(alertmanagertypes.JSMOpsAPIBaseURL)
if err != nil {
return nil, err
}
httpConfig := c.HTTPConfig
if httpConfig == nil {
httpConfig = &commoncfg.HTTPClientConfig{}
}
return &config.OpsGenieConfig{
NotifierConfig: c.NotifierConfig,
HTTPConfig: httpConfig,
APIKey: c.APIKey,
APIURL: &config.URL{URL: apiURL},
Message: c.Message,
Description: c.Description,
Priority: c.Priority,
Tags: c.Tags,
Source: source,
}, nil
}

View File

@@ -1,41 +0,0 @@
package jsmops
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
commoncfg "github.com/prometheus/common/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestToOpsGenieConfig(t *testing.T) {
c := &alertmanagertypes.JSMOpsReceiverConfig{
APIKey: "key-123",
Message: "msg",
Description: "desc",
Priority: "P1",
Tags: "signoz",
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
og, err := toOpsGenieConfig(c)
require.NoError(t, err)
// Trailing slash is required: the Opsgenie notifier appends "v2/alerts..."
// with no separator, yielding /jsm/ops/integration/v2/alerts.
assert.Equal(t, "https://api.atlassian.com/jsm/ops/integration/", og.APIURL.String())
assert.Equal(t, "key-123", string(og.APIKey))
assert.Equal(t, "msg", og.Message)
assert.Equal(t, "desc", og.Description)
assert.Equal(t, "P1", og.Priority)
assert.Equal(t, "signoz", og.Tags)
assert.Equal(t, source, og.Source)
assert.Same(t, c.HTTPConfig, og.HTTPConfig)
}
func TestToOpsGenieConfigNilHTTPConfig(t *testing.T) {
og, err := toOpsGenieConfig(&alertmanagertypes.JSMOpsReceiverConfig{APIKey: "k"})
require.NoError(t, err)
assert.NotNil(t, og.HTTPConfig)
}

View File

@@ -14,7 +14,6 @@ import (
"net/http"
"os"
"strings"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/errors"
@@ -33,13 +32,8 @@ const (
Integration = "opsgenie"
)
// https://support.atlassian.com/opsgenie/docs/alert-fields/ - message 130,
// description 15000, note 25000 runes.
const (
maxMessageLenRunes = 130
maxDescriptionLenRunes = 15000
maxNoteLenRunes = 25000
)
// https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes.
const maxMessageLenRunes = 130
// Notifier implements a Notifier for OpsGenie notifications.
type Notifier struct {
@@ -49,29 +43,21 @@ type Notifier struct {
client *http.Client
retrier *notify.Retrier
templater alertmanagertypes.Templater
// advancedFeatures bundles the JSM Ops enrichments: render the default body as
// HTML (markdown -> HTML), and post a note per fire and on resolve to build an
// immutable timeline. Off for plain OpsGenie. The alert-refresh-on-refire part
// rides on the upstream UpdateAlerts config flag, set alongside this.
advancedFeatures bool
}
// New returns a new OpsGenie notifier. advancedFeatures enables the JSM Ops
// enrichments (HTML default body + a note timeline per fire and on resolve);
// pass false for plain OpsGenie.
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
// New returns a new OpsGenie notifier.
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
advancedFeatures: advancedFeatures,
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
templater: templater,
}, nil
}
@@ -108,30 +94,6 @@ type opsGenieUpdateDescriptionMessage struct {
Description string `json:"description,omitempty"`
}
type opsGenieAddNoteMessage struct {
Note string `json:"note"`
Source string `json:"source"`
}
// noteRequest builds a POST to the alert's notes endpoint (append-only timeline).
func (n *Notifier) noteRequest(ctx context.Context, alias, note, source string) (*http.Request, error) {
noteEndpointURL := n.conf.APIURL.Copy()
noteEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/notes", alias)
q := noteEndpointURL.Query()
q.Set("identifierType", "alias")
noteEndpointURL.RawQuery = q.Encode()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(&opsGenieAddNoteMessage{Note: note, Source: source}); err != nil {
return nil, err
}
req, err := http.NewRequest("POST", noteEndpointURL.String(), &buf)
if err != nil {
return nil, err
}
return req.WithContext(ctx), nil
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
requests, retry, err := n.createRequests(ctx, as...)
@@ -148,24 +110,12 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
notify.Drain(resp)
if err != nil {
// notes are enrichment; a permanently-failed note (e.g. the first-fire
// note racing JSM's async alert create) must not fail the notification
if !shouldRetry && isNoteRequest(req) {
n.logger.WarnContext(ctx, "dropping failed note", slog.Int("status_code", resp.StatusCode), errors.Attr(err))
continue
}
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
}
return true, nil
}
// isNoteRequest reports whether req targets the notes endpoint, the only one
// built by noteRequest.
func isNoteRequest(req *http.Request) bool {
return strings.HasSuffix(req.URL.Path, "/notes")
}
// Like Split but filter out empty strings.
func safeSplit(s, sep string) []string {
a := strings.Split(strings.TrimSpace(s), sep)
@@ -195,13 +145,28 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s
}
var description string
if result.IsDefaultBody && !n.advancedFeatures {
if result.IsDefaultBody {
description = strings.Join(result.Body, "\n")
} else {
description, err = buildHTMLDescription(result.Body, maxDescriptionLenRunes)
if err != nil {
return "", "", err
var b strings.Builder
first := true
for _, part := range result.Body {
if part == "" {
continue
}
rendered, renderErr := markdownrenderer.RenderHTML(part)
if renderErr != nil {
return "", "", renderErr
}
if !first {
b.WriteString("<hr>")
}
b.WriteString("<div>")
b.WriteString(rendered)
b.WriteString("</div>")
first = false
}
description = b.String()
}
title, truncated := notify.TruncateInRunes(result.Title, maxMessageLenRunes)
@@ -209,141 +174,9 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s
n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes))
}
// The API silently truncates over-limit descriptions, which would drop the
// trailing SigNoz link; cap here with an ellipsis instead. The HTML path is
// pre-fitted above, so this only ever cuts the plain-text default body.
description, descTruncated := notify.TruncateInRunes(description, maxDescriptionLenRunes)
if descTruncated {
n.logger.WarnContext(ctx, "Truncated description", slog.Int("max_runes", maxDescriptionLenRunes))
}
return title, description, nil
}
const (
// room reserved for the "+N more" trailer appended when parts are dropped.
descriptionTrailerReserveRunes = 80
// below this rendering budget a shrunk part carries no signal; drop it instead.
minShrinkBudgetRunes = 64
)
// buildHTMLDescription renders each markdown part to HTML (<div>-wrapped,
// <hr>-joined) while keeping the total within budget runes. An over-budget part
// is shrunk at the markdown level and re-rendered so the HTML stays well-formed;
// fully dropped parts are summarized by a "+N more" trailer.
func buildHTMLDescription(parts []string, budget int) (string, error) {
rendering := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
rendering = append(rendering, part)
}
}
budget -= descriptionTrailerReserveRunes
var b strings.Builder
used, included := 0, 0
for _, part := range rendering {
rendered, err := markdownrenderer.RenderHTML(part)
if err != nil {
return "", err
}
overhead := len("<div></div>")
if included > 0 {
overhead += len("<hr>")
}
if used+overhead+utf8.RuneCountInString(rendered) > budget {
rendered, err = shrinkMarkdownToFit(part, budget-used-overhead)
if err != nil {
return "", err
}
if rendered == "" {
break
}
}
if included > 0 {
b.WriteString("<hr>")
}
b.WriteString("<div>")
b.WriteString(rendered)
b.WriteString("</div>")
used += overhead + utf8.RuneCountInString(rendered)
included++
}
if dropped := len(rendering) - included; dropped > 0 {
fmt.Fprintf(&b, "<hr><div><i>…and %d more alerts. Open in SigNoz for the full list.</i></div>", dropped)
}
return b.String(), nil
}
// shrinkMarkdownToFit cuts markdown until its rendered HTML fits within budget
// runes, returning "" when the budget is too small to carry anything useful.
// Only the markdown is ever cut, never the rendered HTML, so goldmark always
// emits balanced markup.
func shrinkMarkdownToFit(md string, budget int) (string, error) {
if budget < minShrinkBudgetRunes {
return "", nil
}
for range 4 {
rendered, err := markdownrenderer.RenderHTML(md)
if err != nil {
return "", err
}
renderedLen := utf8.RuneCountInString(rendered)
if renderedLen <= budget {
return rendered, nil
}
runes := []rune(md)
keep := len(runes) * budget / renderedLen * 9 / 10
if keep >= len(runes) {
keep = len(runes) - 1
}
if keep < minShrinkBudgetRunes {
return "", nil
}
md = string(runes[:keep]) + "…"
}
return "", nil
}
// prepareNote renders the same body template as plain text for a timeline note.
// JSM Ops notes render neither HTML nor markdown, so links flatten to
// "text (url)" and all markers are stripped.
func (n *Notifier) prepareNote(ctx context.Context, alerts []*types.Alert) (string, error) {
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
TitleTemplate: customTitle,
BodyTemplate: customBody,
DefaultTitleTemplate: n.conf.Message,
DefaultBodyTemplate: n.conf.Description,
}, alerts)
if err != nil {
return "", err
}
var b strings.Builder
first := true
for _, part := range result.Body {
text, renderErr := markdownrenderer.RenderPlainText(part)
if renderErr != nil {
return "", renderErr
}
if text = strings.TrimSpace(text); text == "" {
continue
}
if !first {
b.WriteString("\n\n")
}
b.WriteString(text)
first = false
}
note, truncated := notify.TruncateInRunes(b.String(), maxNoteLenRunes)
if truncated {
n.logger.WarnContext(ctx, "Truncated note", slog.Int("max_runes", maxNoteLenRunes))
}
return note, nil
}
// Create requests for a list of alerts.
func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) {
key, err := notify.ExtractGroupKey(ctx)
@@ -373,21 +206,6 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
)
switch alerts.Status() {
case model.AlertResolved:
// Post the resolved snapshot to the timeline before closing (closed alerts
// reject notes), so the note lands first.
if n.advancedFeatures {
note, err := n.prepareNote(ctx, as)
if err != nil {
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
return nil, false, err
}
noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source))
if err != nil {
return nil, true, err
}
requests = append(requests, noteReq)
}
resolvedEndpointURL := n.conf.APIURL.Copy()
resolvedEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/close", alias)
q := resolvedEndpointURL.Query()
@@ -504,21 +322,6 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
}
requests = append(requests, req.WithContext(ctx))
}
// Append this fire's snapshot to the timeline (every fire, including the
// first, so no datapoint is lost when the description is overwritten).
// Notes are plain text, so this uses the plain-text render, not the HTML body.
if n.advancedFeatures {
note, err := n.prepareNote(ctx, as)
if err != nil {
return nil, false, err
}
noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source))
if err != nil {
return nil, true, err
}
requests = append(requests, noteReq)
}
}
var apiKey string

View File

@@ -6,18 +6,14 @@ package opsgenie
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
@@ -48,7 +44,6 @@ func TestOpsGenieRetry(t *testing.T) {
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
false,
)
require.NoError(t, err)
@@ -74,7 +69,6 @@ func TestOpsGenieRedactedURL(t *testing.T) {
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
false,
)
require.NoError(t, err)
@@ -102,7 +96,6 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
tmpl,
promslog.NewNopLogger(),
newTestTemplater(tmpl),
false,
)
require.NoError(t, err)
@@ -223,7 +216,7 @@ func TestOpsGenie(t *testing.T) {
},
} {
t.Run(tc.title, func(t *testing.T) {
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl), false)
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl))
require.NoError(t, err)
ctx := context.Background()
@@ -299,7 +292,7 @@ func TestOpsGenieWithUpdate(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false)
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
alert := &types.Alert{
Alert: model.Alert{
StartsAt: time.Now(),
@@ -331,111 +324,6 @@ func TestOpsGenieWithUpdate(t *testing.T) {
assert.JSONEq(t, `{"description":"new description"}`, body2)
}
func TestOpsGenieAdvancedFeatures(t *testing.T) {
u, err := url.Parse("https://test-opsgenie-url")
require.NoError(t, err)
tmpl := test.CreateTmpl(t)
ctx := notify.WithGroupKey(context.Background(), "1")
key, _ := notify.ExtractGroupKey(ctx)
alias := key.Hash()
cfg := &config.OpsGenieConfig{
NotifierConfig: config.NotifierConfig{VSendResolved: true},
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
UpdateAlerts: true,
APIKey: "k",
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifier, err := New(cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true)
require.NoError(t, err)
firing := &types.Alert{Alert: model.Alert{
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
Labels: model.LabelSet{"Message": "m", "Description": "**Alert:** d [View](https://s.io/a)"},
}}
// Fire: create + update message + update description + a timeline note.
reqs, _, err := notifier.createRequests(ctx, firing)
require.NoError(t, err)
require.Len(t, reqs, 4)
assert.Equal(t, "https://test-opsgenie-url/v2/alerts", reqs[0].URL.String())
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[3].URL.String())
assert.Equal(t, http.MethodPost, reqs[3].Method)
// the note body is the plain-text render: markers stripped, link flattened
var noteMsg opsGenieAddNoteMessage
require.NoError(t, json.Unmarshal([]byte(readBody(t, reqs[3])), &noteMsg))
assert.Equal(t, "Alert: d View (https://s.io/a)", noteMsg.Note)
// Resolve: note posted before the close.
resolved := &types.Alert{Alert: model.Alert{
StartsAt: time.Now().Add(-time.Hour),
EndsAt: time.Now().Add(-time.Minute),
Labels: model.LabelSet{"Message": "m", "Description": "d"},
}}
reqs, _, err = notifier.createRequests(ctx, resolved)
require.NoError(t, err)
require.Len(t, reqs, 2)
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[0].URL.String())
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/close?identifierType=alias", alias), reqs[1].URL.String())
}
func TestOpsGenieNotifyBestEffortNote(t *testing.T) {
tmpl := test.CreateTmpl(t)
ctx := notify.WithGroupKey(context.Background(), "1")
firing := &types.Alert{Alert: model.Alert{
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
Labels: model.LabelSet{"Message": "m", "Description": "d"},
}}
for _, tc := range []struct {
name string
createStatus int
noteStatus int
wantErr bool
wantRetry bool
}{
{name: "note_404_is_dropped", createStatus: http.StatusAccepted, noteStatus: http.StatusNotFound, wantErr: false, wantRetry: true},
{name: "note_429_still_retries", createStatus: http.StatusAccepted, noteStatus: http.StatusTooManyRequests, wantErr: true, wantRetry: true},
{name: "create_404_still_fails", createStatus: http.StatusNotFound, noteStatus: http.StatusAccepted, wantErr: true, wantRetry: false},
} {
t.Run(tc.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/notes") {
w.WriteHeader(tc.noteStatus)
return
}
w.WriteHeader(tc.createStatus)
}))
defer srv.Close()
u, err := url.Parse(srv.URL)
require.NoError(t, err)
notifier, err := New(&config.OpsGenieConfig{
Message: `{{ .CommonLabels.Message }}`,
Description: `{{ .CommonLabels.Description }}`,
APIKey: "k",
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true)
require.NoError(t, err)
retry, err := notifier.Notify(ctx, firing)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Equal(t, tc.wantRetry, retry)
})
}
}
func TestOpsGenieApiKeyFile(t *testing.T) {
u, err := url.Parse("https://test-opsgenie-url")
require.NoError(t, err)
@@ -447,7 +335,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
APIURL: &config.URL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
}
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false)
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
require.NoError(t, err)
requests, _, err := notifierWithUpdate.createRequests(ctx)
@@ -549,109 +437,6 @@ func TestPrepareContent(t *testing.T) {
})
}
func TestShrinkMarkdownToFit(t *testing.T) {
cases := []struct {
name string
md string
budget int
wantEmpty bool
}{
{"fits untouched", "**bold** text", 1000, false},
{"shrinks to fit", strings.Repeat("lorem ipsum ", 500), 1000, false},
{"budget too small", strings.Repeat("lorem ipsum ", 500), 10, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := shrinkMarkdownToFit(c.md, c.budget)
require.NoError(t, err)
if c.wantEmpty {
assert.Empty(t, got)
return
}
assert.NotEmpty(t, got)
assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget)
assert.Equal(t, strings.Count(got, "<p>"), strings.Count(got, "</p>"))
})
}
}
func TestBuildHTMLDescriptionOverflow(t *testing.T) {
bigPart := strings.Repeat("alpha beta gamma ", 100)
cases := []struct {
name string
parts []string
budget int
wantTrailer bool
}{
{"all parts fit", []string{"**a**", "**b**"}, maxDescriptionLenRunes, false},
{"empty parts skipped", []string{"", "hello", ""}, maxDescriptionLenRunes, false},
{"overflow drops parts with trailer", repeatParts(bigPart, 12), maxDescriptionLenRunes, true},
{"single huge part shrunk without trailer", []string{strings.Repeat(bigPart, 20)}, maxDescriptionLenRunes, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := buildHTMLDescription(c.parts, c.budget)
require.NoError(t, err)
assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget)
assert.Equal(t, strings.Count(got, "<div>"), strings.Count(got, "</div>"))
assert.True(t, strings.HasSuffix(got, "</div>"))
if c.wantTrailer {
assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, got)
} else {
assert.NotContains(t, got, "more alerts")
}
})
}
}
// prepareContent end-to-end: 40 custom-template alerts overflow the description
// budget yet the posted HTML stays within limits and well-formed.
func TestPrepareContentDescriptionOverflow(t *testing.T) {
tmpl := test.CreateTmpl(t)
notifier := &Notifier{
conf: &config.OpsGenieConfig{
Message: `{{ .CommonLabels.alertname }}`,
Description: `{{ .CommonLabels.alertname }}`,
},
tmpl: tmpl,
logger: promslog.NewNopLogger(),
templater: newTestTemplater(tmpl),
advancedFeatures: true,
}
bodyTemplate := "**Alert in** $labels.namespace\n\n" + strings.Repeat("detail line for the runbook ", 30)
alerts := make([]*types.Alert, 0, 40)
for i := range 40 {
alerts = append(alerts, &types.Alert{
Alert: model.Alert{
Labels: model.LabelSet{
"alertname": "overflow",
"namespace": model.LabelValue(fmt.Sprintf("ns-%d", i)),
},
Annotations: model.LabelSet{
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
})
}
_, desc, err := notifier.prepareContent(notify.WithGroupKey(context.Background(), "1"), alerts)
require.NoError(t, err)
assert.LessOrEqual(t, utf8.RuneCountInString(desc), maxDescriptionLenRunes)
assert.Equal(t, strings.Count(desc, "<div>"), strings.Count(desc, "</div>"))
assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, desc)
}
func repeatParts(part string, n int) []string {
parts := make([]string, n)
for i := range parts {
parts[i] = part
}
return parts
}
func readBody(t *testing.T, r *http.Request) string {
t.Helper()
body, err := io.ReadAll(r.Body)

View File

@@ -6,8 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/googlechat"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jira"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jsmops"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
@@ -28,8 +26,6 @@ var customNotifierIntegrations = []string{
slack.Integration,
msteamsv2.Integration,
googlechat.Integration,
jira.Integration,
jsmops.Integration,
}
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
@@ -70,7 +66,7 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, templater) })
}
for i, c := range nc.OpsGenieConfigs {
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater, false) })
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater) })
}
for i, c := range nc.SlackConfigs {
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, templater) })
@@ -85,16 +81,6 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
return googlechat.New(c, tmpl, l, templater)
})
}
for i, c := range nc.JiraConfigs {
add(jira.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return jira.New(c, tmpl, l, templater)
})
}
for i, c := range nc.JSMOpsConfigs {
add(jsmops.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
return jsmops.New(c, tmpl, l, templater, true)
})
}
if errs.Len() > 0 {
return nil, &errs

View File

@@ -544,7 +544,7 @@ func (m *Manager) deleteTask(taskName string) {
}
// CreateRule stores rule def into db and also
// starts an executor for the rule, unless the rule is disabled
// starts an executor for the rule
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
@@ -611,7 +611,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
}
taskName := prepareTaskName(id.StringValue())
if err = m.syncRuleStateWithTask(ctx, orgID, taskName, &parsedRule); err != nil {
if err = m.addTask(ctx, orgID, &parsedRule, taskName); err != nil {
return err
}

View File

@@ -3,13 +3,12 @@ package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
@@ -40,52 +39,6 @@ func (m *createQuickFilters) Register(migrations *migrate.Migrations) error {
}
func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error {
// Frozen copy of the defaults as this migration shipped (hence the old
// camelCase keys); migrations must not read live types. 031 replaces these rows.
defaultFilters := []struct {
signal string
filters []map[string]any
}{
{"traces", []map[string]any{
{"key": "duration_nano", "dataType": "float64", "type": "tag"},
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "hasError", "dataType": "bool", "type": "tag"},
{"key": "serviceName", "dataType": "string", "type": "tag"},
{"key": "name", "dataType": "string", "type": "resource"},
{"key": "rpcMethod", "dataType": "string", "type": "tag"},
{"key": "responseStatusCode", "dataType": "string", "type": "resource"},
{"key": "httpHost", "dataType": "string", "type": "tag"},
{"key": "httpMethod", "dataType": "string", "type": "tag"},
{"key": "httpRoute", "dataType": "string", "type": "tag"},
{"key": "httpUrl", "dataType": "string", "type": "tag"},
{"key": "traceID", "dataType": "string", "type": "tag"},
}},
{"logs", []map[string]any{
{"key": "severity_text", "dataType": "string", "type": "resource"},
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "serviceName", "dataType": "string", "type": "tag"},
{"key": "host.name", "dataType": "string", "type": "resource"},
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
}},
{"api_monitoring", []map[string]any{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "serviceName", "dataType": "string", "type": "tag"},
{"key": "rpcMethod", "dataType": "string", "type": "tag"},
}},
{"exceptions", []map[string]any{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "serviceName", "dataType": "string", "type": "tag"},
{"key": "host.name", "dataType": "string", "type": "resource"},
{"key": "k8s.cluster.name", "dataType": "string", "type": "tag"},
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
{"key": "k8s.namespace.name", "dataType": "string", "type": "tag"},
{"key": "k8s.pod.name", "dataType": "string", "type": "tag"},
}},
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
@@ -119,31 +72,15 @@ func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error {
return err
}
now := time.Now()
quickFilters := make([]*quickFilter, 0, len(defaultFilters))
for _, defaultFilter := range defaultFilters {
filterJSON, err := json.Marshal(defaultFilter.filters)
if err != nil {
return err
}
quickFilters = append(quickFilters, &quickFilter{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
OrgID: defaultOrg.StringValue(),
Filter: string(filterJSON),
Signal: defaultFilter.signal,
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
})
// Get the default quick filters
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(defaultOrg)
if err != nil {
return err
}
// Insert all filters at once
_, err = tx.NewInsert().
Model(&quickFilters).
Model(&storableQuickFilters).
Exec(ctx)
if err != nil {

View File

@@ -3,13 +3,11 @@ package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
@@ -40,61 +38,6 @@ func (migration *updateQuickFilters) Register(migrations *migrate.Migrations) er
}
func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
// Frozen copy of the defaults as this migration shipped; migrations must not
// read live types. api_monitoring's service.name is "tag" here — 035 fixes it.
defaultFilters := []struct {
signal string
filters []map[string]any
}{
{"traces", []map[string]any{
{"key": "duration_nano", "dataType": "float64", "type": "tag"},
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "hasError", "dataType": "bool", "type": "tag"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "name", "dataType": "string", "type": "tag"},
{"key": "rpc.method", "dataType": "string", "type": "tag"},
{"key": "response_status_code", "dataType": "string", "type": "tag"},
{"key": "http_host", "dataType": "string", "type": "tag"},
{"key": "http.method", "dataType": "string", "type": "tag"},
{"key": "http.route", "dataType": "string", "type": "tag"},
{"key": "http_url", "dataType": "string", "type": "tag"},
{"key": "trace_id", "dataType": "string", "type": "tag"},
}},
{"logs", []map[string]any{
{"key": "severity_text", "dataType": "string", "type": "resource"},
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "host.name", "dataType": "string", "type": "resource"},
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
}},
{"api_monitoring", []map[string]any{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "tag"},
{"key": "rpc.method", "dataType": "string", "type": "tag"},
}},
{"exceptions", []map[string]any{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "host.name", "dataType": "string", "type": "resource"},
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
}},
}
signalFilters := make([]struct{ signal, filter string }, 0, len(defaultFilters))
for _, defaultFilter := range defaultFilters {
filterJSON, err := json.Marshal(defaultFilter.filters)
if err != nil {
return err
}
signalFilters = append(signalFilters, struct{ signal, filter string }{defaultFilter.signal, string(filterJSON)})
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
@@ -130,28 +73,17 @@ func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
return err
}
// For each organization, create new quick filters with the updated defaults
// For each organization, create new quick filters with the updated NewDefaultQuickFilter function
for _, orgID := range orgIDs {
now := time.Now()
quickFilters := make([]*quickFilter, 0, len(signalFilters))
for _, signalFilter := range signalFilters {
quickFilters = append(quickFilters, &quickFilter{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
OrgID: orgID,
Filter: signalFilter.filter,
Signal: signalFilter.signal,
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
})
// Get the updated default quick filters
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID))
if err != nil {
return err
}
// Insert all filters for this organization
_, err = tx.NewInsert().
Model(&quickFilters).
Model(&storableQuickFilters).
Exec(ctx)
if err != nil {

View File

@@ -2,16 +2,20 @@ package sqlmigration
import (
"context"
"encoding/json"
"database/sql"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type updateApiMonitoringFilters struct{}
type updateApiMonitoringFilters struct {
store sqlstore.SQLStore
}
func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("update_api_monitoring_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
@@ -19,8 +23,10 @@ func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.Provi
})
}
func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, _ sqlstore.SQLStore) (SQLMigration, error) {
return &updateApiMonitoringFilters{}, nil
func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
return &updateApiMonitoringFilters{
store: store,
}, nil
}
func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrations) error {
@@ -32,29 +38,63 @@ func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrat
}
func (migration *updateApiMonitoringFilters) Up(ctx context.Context, db *bun.DB) error {
// Frozen copy of the api_monitoring defaults as this migration shipped; the
// change over 031 is service.name moving from "tag" to "resource".
apiMonitoringFilters := []map[string]any{
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
{"key": "service.name", "dataType": "string", "type": "resource"},
{"key": "rpc.method", "dataType": "string", "type": "tag"},
}
apiMonitoringFilterJSON, err := json.Marshal(apiMonitoringFilters)
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// The filter JSON is org-independent, so one update covers every org's row.
_, err = db.NewUpdate().
Table("quick_filter").
Set("filter = ?, updated_at = ?", string(apiMonitoringFilterJSON), time.Now()).
Where("signal = ?", "api_monitoring").
Exec(ctx)
defer func() {
_ = tx.Rollback()
}()
// Get all organization IDs as strings
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil {
if err == sql.ErrNoRows {
if err := tx.Commit(); err != nil {
return err
}
return nil
}
return err
}
for _, orgID := range orgIDs {
// Get the updated default quick filters which includes the new API monitoring filters
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID))
if err != nil {
return err
}
// Find the API monitoring filter from the storable quick filters
var apiMonitoringFilterJSON string
for _, filter := range storableQuickFilters {
if filter.Signal == quickfiltertypes.SignalApiMonitoring {
apiMonitoringFilterJSON = filter.Filter
break
}
}
if apiMonitoringFilterJSON != "" {
_, err = tx.NewUpdate().
Table("quick_filter").
Set("filter = ?, updated_at = ?", apiMonitoringFilterJSON, time.Now()).
Where("signal = ? AND org_id = ?", quickfiltertypes.SignalApiMonitoring, orgID).
Exec(ctx)
if err != nil {
return err
}
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}

View File

@@ -1,173 +0,0 @@
// Package adf converts Markdown into Atlassian Document Format (ADF) nodes,
// the JSON rich-text format used by Jira Cloud's v3 API.
package adf
import (
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/text"
)
// parser is stateless across Parse calls and safe for concurrent use; only
// goldmark's renderers hold per-document state (which we don't use). Strikethrough
// is included for the strike mark; linkify is deliberately omitted since it
// fragments plain text into word tokens while scanning for bare URLs.
var parser = goldmark.New(goldmark.WithExtensions(extension.Strikethrough)).Parser()
// Render returns the ADF block nodes for markdown (without the doc wrapper),
// so callers can embed them alongside their own nodes (panels, links, …).
func Render(markdown string) []any {
src := []byte(markdown)
return blockChildren(parser.Parse(text.NewReader(src)), src)
}
func blockChildren(parent ast.Node, src []byte) []any {
var out []any
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
if b := block(c, src); b != nil {
out = append(out, b)
}
}
return out
}
func block(n ast.Node, src []byte) any {
switch node := n.(type) {
case *ast.Heading:
return map[string]any{"type": "heading", "attrs": map[string]any{"level": node.Level}, "content": inlineChildren(node, src, nil)}
case *ast.Paragraph:
return paragraph(inlineChildren(node, src, nil))
case *ast.TextBlock:
return paragraph(inlineChildren(node, src, nil))
case *ast.List:
typ := "bulletList"
if node.IsOrdered() {
typ = "orderedList"
}
return map[string]any{"type": typ, "content": blockChildren(node, src)}
case *ast.ListItem:
return map[string]any{"type": "listItem", "content": blockChildren(node, src)}
case *ast.Blockquote:
return map[string]any{"type": "blockquote", "content": blockChildren(node, src)}
case *ast.FencedCodeBlock:
return codeBlock(codeText(node, src), string(node.Language(src)))
case *ast.CodeBlock:
return codeBlock(codeText(node, src), "")
case *ast.ThematicBreak:
return map[string]any{"type": "rule"}
default:
return nil
}
}
func paragraph(content []any) map[string]any {
p := map[string]any{"type": "paragraph"}
if len(content) > 0 {
p["content"] = content
}
return p
}
func codeBlock(code, lang string) map[string]any {
cb := map[string]any{"type": "codeBlock"}
if lang != "" {
cb["attrs"] = map[string]any{"language": lang}
}
if code = strings.TrimRight(code, "\n"); code != "" {
cb["content"] = []any{map[string]any{"type": "text", "text": code}}
}
return cb
}
// inlineChildren flattens an inline subtree into ADF text nodes, carrying the
// active marks (strong/em/code/strike/link) down the tree.
func inlineChildren(parent ast.Node, src []byte, marks []any) []any {
var out []any
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
switch node := c.(type) {
case *ast.Text:
if t := string(node.Segment.Value(src)); t != "" {
out = append(out, textNode(t, marks))
}
if node.HardLineBreak() {
out = append(out, map[string]any{"type": "hardBreak"})
} else if node.SoftLineBreak() {
out = append(out, textNode(" ", marks))
}
case *ast.String:
if len(node.Value) > 0 {
out = append(out, textNode(string(node.Value), marks))
}
case *ast.CodeSpan:
if t := rawText(node, src); t != "" {
out = append(out, textNode(t, withMark(marks, mark("code"))))
}
case *ast.Emphasis:
m := "em"
if node.Level == 2 {
m = "strong"
}
out = append(out, inlineChildren(node, src, withMark(marks, mark(m)))...)
case *extast.Strikethrough:
out = append(out, inlineChildren(node, src, withMark(marks, mark("strike")))...)
case *ast.Link:
out = append(out, inlineChildren(node, src, withMark(marks, linkMark(string(node.Destination))))...)
case *ast.AutoLink:
if u := string(node.URL(src)); u != "" {
out = append(out, textNode(u, withMark(marks, linkMark(u))))
}
default:
out = append(out, inlineChildren(c, src, marks)...)
}
}
return out
}
func textNode(s string, marks []any) map[string]any {
tn := map[string]any{"type": "text", "text": s}
if len(marks) > 0 {
tn["marks"] = marks
}
return tn
}
func mark(typ string) any { return map[string]any{"type": typ} }
func linkMark(href string) any {
return map[string]any{"type": "link", "attrs": map[string]any{"href": href}}
}
func withMark(marks []any, m any) []any {
out := make([]any, 0, len(marks)+1)
out = append(out, marks...)
return append(out, m)
}
func rawText(n ast.Node, src []byte) string {
var b strings.Builder
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
switch t := c.(type) {
case *ast.Text:
b.Write(t.Segment.Value(src))
case *ast.String:
b.Write(t.Value)
default:
b.WriteString(rawText(c, src))
}
}
return b.String()
}
func codeText(n ast.Node, src []byte) string {
var b strings.Builder
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
seg := lines.At(i)
b.Write(seg.Value(src))
}
return b.String()
}

View File

@@ -1,85 +0,0 @@
package adf
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func toJSON(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return string(b)
}
func TestRenderInlineMarks(t *testing.T) {
js := toJSON(t, Render("**bold** and *em* and `code` and [txt](https://x.io)"))
assert.Contains(t, js, `"type":"strong"`)
assert.Contains(t, js, `"type":"em"`)
assert.Contains(t, js, `"type":"code"`)
assert.Contains(t, js, `"type":"link"`)
assert.Contains(t, js, `"href":"https://x.io"`)
assert.Contains(t, js, `"text":"bold"`)
}
func TestRenderHeadingAndList(t *testing.T) {
js := toJSON(t, Render("# Title\n\n- a\n- b"))
assert.Contains(t, js, `"type":"heading"`)
assert.Contains(t, js, `"level":1`)
assert.Contains(t, js, `"type":"bulletList"`)
assert.Contains(t, js, `"type":"listItem"`)
}
func TestRenderOrderedList(t *testing.T) {
js := toJSON(t, Render("1. one\n2. two"))
assert.Contains(t, js, `"type":"orderedList"`)
}
func TestRenderCodeBlock(t *testing.T) {
js := toJSON(t, Render("```go\nx := 1\n```"))
assert.Contains(t, js, `"type":"codeBlock"`)
assert.Contains(t, js, `"language":"go"`)
assert.Contains(t, js, `x := 1`)
}
func TestRenderStrikethrough(t *testing.T) {
js := toJSON(t, Render("~~gone~~"))
assert.Contains(t, js, `"type":"strike"`)
assert.Contains(t, js, `"text":"gone"`)
}
func TestRenderBlockquote(t *testing.T) {
js := toJSON(t, Render("> quoted"))
assert.Contains(t, js, `"type":"blockquote"`)
assert.Contains(t, js, `"text":"quoted"`)
}
func TestRenderAutoLink(t *testing.T) {
js := toJSON(t, Render("see <https://signoz.io>"))
assert.Contains(t, js, `"type":"link"`)
assert.Contains(t, js, `"href":"https://signoz.io"`)
assert.Contains(t, js, `"text":"https://signoz.io"`)
}
func TestRenderLineBreaks(t *testing.T) {
js := toJSON(t, Render("one \ntwo"))
assert.Contains(t, js, `"type":"hardBreak"`)
// a soft break renders as a space, keeping the paragraph intact
js = toJSON(t, Render("one\ntwo"))
assert.NotContains(t, js, `"type":"hardBreak"`)
assert.Contains(t, js, `"text":" "`)
}
func TestRenderPlainText(t *testing.T) {
js := toJSON(t, Render("just text"))
assert.Contains(t, js, `"type":"paragraph"`)
assert.Contains(t, js, `"text":"just text"`)
}
func TestRenderEmptyIsEmpty(t *testing.T) {
assert.Empty(t, Render(""))
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn"
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/plaintext"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
)
@@ -33,11 +32,6 @@ var (
return goldmark.New(goldmark.WithExtensions(mrkdwn.Extender))
},
}
plaintextPool = sync.Pool{
New: func() any {
return goldmark.New(goldmark.WithExtensions(plaintext.Extender))
},
}
)
// RenderHTML converts markdown to HTML.
@@ -59,14 +53,6 @@ func RenderSlackMrkdwn(markdown string) (string, error) {
return render(md, markdown, "Slack mrkdwn")
}
// RenderPlainText converts markdown to plain text: no markers, links flattened
// to "text (url)".
func RenderPlainText(markdown string) (string, error) {
md := plaintextPool.Get().(goldmark.Markdown)
defer plaintextPool.Put(md)
return render(md, markdown, "plain text")
}
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
var buf bytes.Buffer
if err := md.Convert([]byte(markdown), &buf); err != nil {

View File

@@ -1,301 +0,0 @@
// Package plaintext provides a goldmark node renderer that emits plain text:
// no markdown or HTML markers, and links flattened to "text (url)". It is used
// for JSM Ops timeline notes, which render neither HTML nor markdown.
package plaintext
import (
"bytes"
"fmt"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extensionast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
// Extender registers the plain-text node renderer plus the GFM extensions it
// handles (tables, strikethrough).
var Extender goldmark.Extender = &extender{}
type extender struct{}
func (e *extender) Extend(m goldmark.Markdown) {
extension.Table.Extend(m)
extension.Strikethrough.Extend(m)
m.Renderer().AddOptions(
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
)
}
// nodeRenderer holds per-document nesting prefixes, so it is not safe for
// concurrent Convert calls; callers pool one instance per goroutine.
type nodeRenderer struct {
prefixes []string
}
func newRenderer() renderer.NodeRenderer {
return &nodeRenderer{}
}
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
// Blocks
reg.Register(ast.KindDocument, r.renderDocument)
reg.Register(ast.KindHeading, r.renderBlock)
reg.Register(ast.KindBlockquote, r.renderBlock)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
reg.Register(ast.KindList, r.renderList)
reg.Register(ast.KindListItem, r.renderListItem)
reg.Register(ast.KindParagraph, r.renderBlock)
reg.Register(ast.KindTextBlock, r.renderTextBlock)
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
// Inlines
reg.Register(ast.KindAutoLink, r.renderAutoLink)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindEmphasis, r.renderPassthrough)
reg.Register(ast.KindImage, r.renderLink)
reg.Register(ast.KindLink, r.renderLink)
reg.Register(ast.KindText, r.renderText)
reg.Register(ast.KindString, r.renderString)
reg.Register(ast.KindRawHTML, r.renderRawHTML)
// Extensions
reg.Register(extensionast.KindStrikethrough, r.renderPassthrough)
reg.Register(extensionast.KindTable, r.renderTable)
}
func (r *nodeRenderer) writePrefix(w util.BufWriter) {
for _, p := range r.prefixes {
_, _ = w.WriteString(p)
}
}
func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) {
_ = w.WriteByte('\n')
r.writePrefix(w)
}
// writeBlockSeparator writes a blank line between block-level elements.
func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) {
r.writeLineSeparator(w)
r.writeLineSeparator(w)
}
func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
if n.PreviousSibling() != nil {
r.writeBlockSeparator(w)
}
}
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
// The renderer is pooled; wipe any prefix stack left over from a prior
// document (e.g. one that errored mid-walk) before starting fresh.
r.prefixes = r.prefixes[:0]
}
return ast.WalkContinue, nil
}
// renderBlock separates block-level nodes (paragraph, heading, blockquote) from
// their previous sibling with a blank line, emitting no markers of their own.
func (r *nodeRenderer) renderBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, node)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
_, _ = w.Write(line.Value(source))
}
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering && node.PreviousSibling() != nil {
r.writeLineSeparator(w)
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
r.writeLineSeparator(w)
}
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if n.PreviousSibling() != nil {
r.writeLineSeparator(w)
}
parent := n.Parent().(*ast.List)
var prefixStr string
if parent.IsOrdered() {
index := parent.Start
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
index++
}
prefixStr = fmt.Sprintf("%d. ", index)
} else {
prefixStr = "- "
}
_, _ = w.WriteString(prefixStr)
r.prefixes = append(r.prefixes, " ") // indent wrapped/nested lines
} else {
r.prefixes = r.prefixes[:len(r.prefixes)-1]
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering && n.PreviousSibling() != nil {
r.writeLineSeparator(w)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
r.separateFromPrevious(w, n)
_, _ = w.WriteString("---")
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.AutoLink)
url := string(n.URL(source))
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
url = "mailto:" + url
}
_, _ = w.WriteString(url)
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
segment := c.(*ast.Text).Segment
value := segment.Value(source)
if bytes.HasSuffix(value, []byte("\n")) {
_, _ = w.Write(value[:len(value)-1])
_ = w.WriteByte(' ')
} else {
_, _ = w.Write(value)
}
}
return ast.WalkSkipChildren, nil
}
return ast.WalkContinue, nil
}
// renderPassthrough emits no markers; the node's children render as plain text
// (used for emphasis/strong and strikethrough).
func (r *nodeRenderer) renderPassthrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
return ast.WalkContinue, nil
}
// renderLink flattens links and images to "text (url)": children render the
// label, then the destination is appended in parentheses on exit.
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
var dest []byte
switch n := node.(type) {
case *ast.Link:
dest = n.Destination
case *ast.Image:
dest = n.Destination
}
if !entering && len(dest) > 0 {
_, _ = fmt.Fprintf(w, " (%s)", dest)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.Text)
_, _ = w.Write(n.Segment.Value(source))
if n.HardLineBreak() || n.SoftLineBreak() {
r.writeLineSeparator(w)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
_, _ = w.Write(node.(*ast.String).Value)
}
return ast.WalkContinue, nil
}
func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
// Drop inline raw HTML tags; a plain-text note should never carry markup.
return ast.WalkSkipChildren, nil
}
func (r *nodeRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
// Drop block-level raw HTML for the same reason as inline raw HTML.
return ast.WalkSkipChildren, nil
}
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
r.separateFromPrevious(w, node)
first := true
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
if c.Kind() != extensionast.KindTableHeader && c.Kind() != extensionast.KindTableRow {
continue
}
if !first {
r.writeLineSeparator(w)
}
first = false
cellFirst := true
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
if cc.Kind() != extensionast.KindTableCell {
continue
}
if !cellFirst {
_, _ = w.WriteString(" | ")
}
cellFirst = false
_, _ = w.WriteString(extractPlainText(cc, source))
}
}
return ast.WalkSkipChildren, nil
}
// extractPlainText collects the text content of a node.
func extractPlainText(n ast.Node, source []byte) string {
var buf bytes.Buffer
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch t := node.(type) {
case *ast.Text:
buf.Write(t.Segment.Value(source))
case *ast.String:
buf.Write(t.Value)
}
return ast.WalkContinue, nil
})
return strings.TrimSpace(buf.String())
}

View File

@@ -1,55 +0,0 @@
package plaintext
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yuin/goldmark"
)
func render(t *testing.T, md string) string {
t.Helper()
var b []byte
buf := bytesBuffer{&b}
g := goldmark.New(goldmark.WithExtensions(Extender))
require.NoError(t, g.Convert([]byte(md), &buf))
return string(b)
}
// bytesBuffer is a tiny io.Writer so the test needs no extra imports.
type bytesBuffer struct{ b *[]byte }
func (w bytesBuffer) Write(p []byte) (int, error) {
*w.b = append(*w.b, p...)
return len(p), nil
}
func TestPlainText(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"strips bold and italic", "**bold** and *italic*", "bold and italic"},
{"link becomes text (url)", "[View in SigNoz](https://signoz.io/alert)", "View in SigNoz (https://signoz.io/alert)"},
{"bold label kept, marker dropped", "**Alert:** name (critical)", "Alert: name (critical)"},
{"strikethrough stripped", "~~gone~~", "gone"},
{"inline code unwrapped", "run `foo bar`", "run foo bar"},
{"paragraphs separated by blank line", "one\n\ntwo", "one\n\ntwo"},
{"unordered list", "- a\n- b", "- a\n- b"},
{"ordered list keeps numbering", "1. a\n2. b", "1. a\n2. b"},
{"nested list indents under parent", "- a\n - b", "- a\n - b"},
{"fenced code block unwrapped", "```go\nx := 1\n```", "x := 1\n"},
{"table flattens to pipe-separated rows", "| h1 | h2 |\n|---|---|\n| a | b |\n| c | d |", "h1 | h2\na | b\nc | d"},
{"autolink kept as bare url", "see <https://signoz.io>", "see https://signoz.io"},
{"inline raw html dropped", "a <b>bold</b> word", "a bold word"},
{"html block dropped", "before\n\n<div>markup</div>\n\nafter", "before\n\nafter"},
{"hard break becomes newline", "one \ntwo", "one\ntwo"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
assert.Equal(t, c.want, render(t, c.in))
})
}
}

View File

@@ -216,20 +216,13 @@ func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
schema.WithRequired("name")
var oneOf []jsonschema.SchemaOrBool
seen := map[string]struct{}{}
// Walk both halves: native fields on Receiver, upstream on the embed. A native
// field can shadow an upstream one with the same tag (e.g. jira_configs), so
// dedupe to avoid emitting two identical oneOf branches.
// Walk both halves: native fields on Receiver, upstream on the embed.
collect := func(t reflect.Type) {
for i := 0; i < t.NumField(); i++ {
jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
if !strings.HasSuffix(jsonTag, "_configs") {
continue
}
if _, ok := seen[jsonTag]; ok {
continue
}
seen[jsonTag] = struct{}{}
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
oneOf = append(oneOf, branch.ToSchemaOrBool())
}

View File

@@ -70,19 +70,15 @@ type Config struct {
// on Receiver, and extensions to customConfigsOf + isEmpty.
type customReceiverConfigs struct {
GoogleChat []*GoogleChatReceiverConfig
Jira []*JiraReceiverConfig
JSMOps []*JSMOpsReceiverConfig
}
func (c customReceiverConfigs) isEmpty() bool {
return len(c.GoogleChat) == 0 && len(c.Jira) == 0 && len(c.JSMOps) == 0
return len(c.GoogleChat) == 0
}
func customConfigsOf(receiver *Receiver) customReceiverConfigs {
return customReceiverConfigs{
GoogleChat: receiver.GoogleChatConfigs,
Jira: receiver.JiraConfigs,
JSMOps: receiver.JSMOpsConfigs,
}
}
@@ -191,8 +187,6 @@ func extendedReceivers(c *config.Config, customConfigs map[string]customReceiver
receivers[i] = &Receiver{
Receiver: &base,
GoogleChatConfigs: custom.GoogleChat,
JiraConfigs: custom.Jira,
JSMOpsConfigs: custom.JSMOps,
}
}
@@ -368,8 +362,6 @@ func (c *Config) GetReceiver(name string) (*Receiver, error) {
return &Receiver{
Receiver: &base,
GoogleChatConfigs: custom.GoogleChat,
JiraConfigs: custom.Jira,
JSMOpsConfigs: custom.JSMOps,
}, nil
}
}
@@ -449,16 +441,6 @@ func (c *Config) applyNativeDefaults() {
gc.HTTPConfig = httpDefault
}
}
for _, jc := range custom.Jira {
if jc.HTTPConfig == nil {
jc.HTTPConfig = httpDefault
}
}
for _, jc := range custom.JSMOps {
if jc.HTTPConfig == nil {
jc.HTTPConfig = httpDefault
}
}
}
}

View File

@@ -1,119 +0,0 @@
package alertmanagertypes
import (
"fmt"
"net/url"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
)
const defaultJiraReopenDuration = model.Duration(3 * 24 * time.Hour)
// Service accounts authenticate against the api.atlassian.com gateway (keyed by
// cloud id) instead of the site host; they are identified by their email domain.
const (
jiraCloudHostSuffix = ".atlassian.net"
jiraServiceAccountEmailDomain = "@serviceaccount.atlassian.com"
jiraGatewayBaseURL = "https://api.atlassian.com/ex/jira/"
)
// Default templates for the issue title and body. The body is rendered to
// markdown and then wrapped in the ADF status panel + deep-links by the notifier.
const (
DefaultJiraSummaryTemplate = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`
DefaultJiraDescriptionTemplate = `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}
**Summary:** {{ .Annotations.summary }}
{{ end }}{{ if .Annotations.description }}
**Description:** {{ .Annotations.description }}
{{ end }}
{{ end }}`
)
// JiraReceiverConfig is the SigNoz Jira receiver. Fields are declared explicitly
// instead of embedding upstream config.JiraConfig because that type's own
// UnmarshalYAML would reset our defaults and drop sibling fields on the yaml
// round-trip. Only Jira Cloud (v3/ADF) is supported, so api_url is derived from Site.
type JiraReceiverConfig struct {
config.NotifierConfig `yaml:",inline"`
Site string `json:"site,omitempty" yaml:"site,omitempty"`
Project string `json:"project,omitempty" yaml:"project,omitempty"`
IssueType string `json:"issue_type,omitempty" yaml:"issue_type,omitempty"`
Summary string `json:"summary,omitempty" yaml:"summary,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
Priority string `json:"priority,omitempty" yaml:"priority,omitempty"`
Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"`
ResolveTransition string `json:"resolve_transition,omitempty" yaml:"resolve_transition,omitempty"`
ReopenTransition string `json:"reopen_transition,omitempty" yaml:"reopen_transition,omitempty"`
ReopenDuration model.Duration `json:"reopen_duration" yaml:"reopen_duration"`
WontFixResolution string `json:"wont_fix_resolution,omitempty" yaml:"wont_fix_resolution,omitempty"`
CustomFields map[string]any `json:"custom_fields,omitempty" yaml:"custom_fields,omitempty"`
HTTPConfig *commoncfg.HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty"`
}
func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
type plain JiraReceiverConfig
if err := unmarshal((*plain)(c)); err != nil {
return err
}
if c.ReopenDuration <= 0 {
c.ReopenDuration = defaultJiraReopenDuration
}
// sub-minute windows truncate to 0 in the reopen JQL and silently disable
// reopening, so reject them.
if c.ReopenDuration < model.Duration(time.Minute) {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira reopen_duration must be at least 1m")
}
if c.Summary == "" {
c.Summary = DefaultJiraSummaryTemplate
}
if c.Description == "" {
c.Description = DefaultJiraDescriptionTemplate
}
site := strings.TrimRight(strings.TrimSpace(c.Site), "/")
u, err := url.Parse(site)
if site == "" || err != nil || u.Scheme != "https" || !strings.HasSuffix(strings.ToLower(u.Hostname()), jiraCloudHostSuffix) {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("jira site must be a Jira Cloud URL (https://<site>%s)", jiraCloudHostSuffix))
}
c.Site = site
if c.Project == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira project is required")
}
if c.IssueType == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira issue_type is required")
}
if c.HTTPConfig == nil || c.HTTPConfig.BasicAuth == nil {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira requires basic auth (email + API token)")
}
return nil
}
// IsServiceAccount reports whether the basic-auth user is an Atlassian service
// account, identified by its email domain. Service accounts must go through the
// api.atlassian.com gateway; personal API tokens use the site host directly.
func (c *JiraReceiverConfig) IsServiceAccount() bool {
if c.HTTPConfig == nil || c.HTTPConfig.BasicAuth == nil {
return false
}
return strings.HasSuffix(strings.ToLower(c.HTTPConfig.BasicAuth.Username), jiraServiceAccountEmailDomain)
}
// APIBaseURL returns the Jira Cloud REST v3 base URL: the api.atlassian.com
// gateway when a cloud id is given (service accounts), else the site host.
func (c *JiraReceiverConfig) APIBaseURL(cloudID string) string {
if cloudID != "" {
return fmt.Sprintf("%s%s/rest/api/3", jiraGatewayBaseURL, cloudID)
}
return fmt.Sprintf("%s/rest/api/3", strings.TrimRight(c.Site, "/"))
}

View File

@@ -1,119 +0,0 @@
package alertmanagertypes
import (
"fmt"
"testing"
"time"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func jiraReceiverJSON(site, project, issueType string, withAuth bool) string {
auth := ""
if withAuth {
auth = `,"http_config":{"basic_auth":{"username":"me@acme.com","password":"token"}}`
}
return fmt.Sprintf(
`{"name":"jira","jira_configs":[{"site":%q,"project":%q,"issue_type":%q%s}]}`,
site, project, issueType, auth,
)
}
func TestJiraReceiverConfigDefaults(t *testing.T) {
r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", true))
require.NoError(t, err)
require.Len(t, r.JiraConfigs, 1)
jc := r.JiraConfigs[0]
assert.Equal(t, "https://acme.atlassian.net", jc.Site)
assert.Equal(t, "https://acme.atlassian.net/rest/api/3", jc.APIBaseURL(""))
assert.False(t, jc.SendResolved()) // default off when omitted, like other channels
assert.Equal(t, defaultJiraReopenDuration, jc.ReopenDuration)
assert.Equal(t, DefaultJiraSummaryTemplate, jc.Summary)
assert.Equal(t, DefaultJiraDescriptionTemplate, jc.Description)
ch, err := NewChannelFromReceiver(r, "org-1")
require.NoError(t, err)
assert.Equal(t, "jira", ch.Type)
}
func TestJiraReceiverConfigSendResolved(t *testing.T) {
withSendResolved := func(v bool) string {
return fmt.Sprintf(
`{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","send_resolved":%t,"http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`,
v,
)
}
on, err := NewReceiver(withSendResolved(true))
require.NoError(t, err)
assert.True(t, on.JiraConfigs[0].SendResolved())
off, err := NewReceiver(withSendResolved(false))
require.NoError(t, err)
assert.False(t, off.JiraConfigs[0].SendResolved())
}
func TestJiraReceiverConfigReopenDurationMinimum(t *testing.T) {
withReopen := func(v string) string {
return fmt.Sprintf(
`{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":%q,"http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`,
v,
)
}
r, err := NewReceiver(withReopen("1m"))
require.NoError(t, err)
assert.Equal(t, model.Duration(time.Minute), r.JiraConfigs[0].ReopenDuration)
_, err = NewReceiver(withReopen("30s"))
assert.Error(t, err)
}
func TestJiraAPIBaseURL(t *testing.T) {
c := &JiraReceiverConfig{Site: "https://acme.atlassian.net"}
assert.Equal(t, "https://acme.atlassian.net/rest/api/3", c.APIBaseURL(""))
assert.Equal(t, "https://api.atlassian.com/ex/jira/09851b38-1a40-4c01-a36a-0a9336293200/rest/api/3", c.APIBaseURL("09851b38-1a40-4c01-a36a-0a9336293200"))
}
func TestJiraIsServiceAccount(t *testing.T) {
withUser := func(username string) *JiraReceiverConfig {
return &JiraReceiverConfig{HTTPConfig: &commoncfg.HTTPClientConfig{BasicAuth: &commoncfg.BasicAuth{Username: username}}}
}
assert.True(t, withUser("bot@serviceaccount.atlassian.com").IsServiceAccount())
assert.True(t, withUser("Bot@ServiceAccount.Atlassian.Com").IsServiceAccount())
assert.False(t, withUser("temp@signoz.io").IsServiceAccount())
assert.False(t, (&JiraReceiverConfig{}).IsServiceAccount())
}
func TestJiraReceiverConfigTrailingSlashSite(t *testing.T) {
r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net/", "KAN", "Task", true))
require.NoError(t, err)
assert.Equal(t, "https://acme.atlassian.net", r.JiraConfigs[0].Site)
assert.Equal(t, "https://acme.atlassian.net/rest/api/3", r.JiraConfigs[0].APIBaseURL(""))
}
func TestJiraReceiverConfigValidation(t *testing.T) {
cases := []struct {
name string
json string
}{
{"missing site", `{"name":"j","jira_configs":[{"project":"KAN","issue_type":"Task","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
{"http site", jiraReceiverJSON("http://acme.atlassian.net", "KAN", "Task", true)},
{"non-cloud host", jiraReceiverJSON("https://jira.acme.com", "KAN", "Task", true)},
{"lookalike host suffix", jiraReceiverJSON("https://www.iamnotatlassian.net", "KAN", "Task", true)},
{"bare atlassian.net", jiraReceiverJSON("https://atlassian.net", "KAN", "Task", true)},
{"missing project", jiraReceiverJSON("https://acme.atlassian.net", "", "Task", true)},
{"missing issue_type", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "", true)},
{"missing basic auth", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", false)},
{"invalid reopen_duration format", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"3days","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
{"sub-minute reopen_duration", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"30s","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := NewReceiver(c.json)
assert.Error(t, err)
})
}
}

View File

@@ -1,75 +0,0 @@
package alertmanagertypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/prometheus/alertmanager/config"
commoncfg "github.com/prometheus/common/config"
)
// JSMOpsAPIBaseURL is the native JSM Ops integration-events gateway. It is a
// single global host keyed by the integration API key (no region/cloud id in
// the path). The trailing slash is required: the Opsgenie notifier appends
// "v2/alerts..." to APIURL.Path with no separator.
const JSMOpsAPIBaseURL = "https://api.atlassian.com/jsm/ops/integration/"
// JSM Ops speaks the Opsgenie alert API, so a JSM alert description takes the
// same HTML subset and 15,000-char limit; message caps at 130. The templates
// mirror Google Chat / Jira for a consistent default across channels.
const (
DefaultJSMOpsMessageTemplate = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`
DefaultJSMOpsDescriptionTemplate = `{{ range .Alerts -}}
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
{{ end }}{{ if .Annotations.description }}**Description:** {{ .Annotations.description }}
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
{{ end }}{{ end }}`
)
// JSMOpsReceiverConfig is the SigNoz Jira Service Management Ops receiver. It is
// delivered by reusing the Opsgenie notifier (JSM Ops is the ex-Opsgenie alert
// API): the notifier package maps these fields onto config.OpsGenieConfig with
// APIURL pinned to JSMOpsAPIBaseURL.
type JSMOpsReceiverConfig struct {
config.NotifierConfig `yaml:",inline" json:",inline"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
APIKey config.Secret `yaml:"api_key,omitempty" json:"api_key,omitempty"`
Message string `yaml:"message,omitempty" json:"message,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Priority string `yaml:"priority,omitempty" json:"priority,omitempty"`
Tags string `yaml:"tags,omitempty" json:"tags,omitempty"`
}
// send_resolved has no omitempty upstream, so a var default here is overwritten
// by the yaml round-trip to the request value (false when omitted); the UI sends
// it explicitly, defaulted on, so JSM alerts close on resolve.
var DefaultJSMOpsReceiverConfig = JSMOpsReceiverConfig{
NotifierConfig: config.NotifierConfig{
VSendResolved: false,
},
Message: DefaultJSMOpsMessageTemplate,
Description: DefaultJSMOpsDescriptionTemplate,
Tags: "signoz",
}
func (c *JSMOpsReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
*c = DefaultJSMOpsReceiverConfig
type plain JSMOpsReceiverConfig
if err := unmarshal((*plain)(c)); err != nil {
return err
}
if c.APIKey == "" {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jsm ops api_key is required")
}
return nil
}

View File

@@ -1,70 +0,0 @@
package alertmanagertypes
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestJSMOpsReceiverConfigDefaults(t *testing.T) {
r, err := NewReceiver(`{"name":"jsm","jsmops_configs":[{"api_key":"key-123"}]}`)
require.NoError(t, err)
require.Len(t, r.JSMOpsConfigs, 1)
c := r.JSMOpsConfigs[0]
assert.Equal(t, "key-123", string(c.APIKey))
assert.Equal(t, DefaultJSMOpsMessageTemplate, c.Message)
assert.Equal(t, DefaultJSMOpsDescriptionTemplate, c.Description)
assert.Equal(t, "signoz", c.Tags)
assert.False(t, c.SendResolved()) // default off when omitted, like other channels
ch, err := NewChannelFromReceiver(r, "org-1")
require.NoError(t, err)
assert.Equal(t, "jsmops", ch.Type)
}
func TestJSMOpsReceiverConfigOverrides(t *testing.T) {
r, err := NewReceiver(`{"name":"jsm","jsmops_configs":[{"api_key":"k","message":"m","description":"d","priority":"P1","tags":"a,b","send_resolved":true}]}`)
require.NoError(t, err)
require.Len(t, r.JSMOpsConfigs, 1)
c := r.JSMOpsConfigs[0]
assert.Equal(t, "m", c.Message)
assert.Equal(t, "d", c.Description)
assert.Equal(t, "P1", c.Priority)
assert.Equal(t, "a,b", c.Tags)
assert.True(t, c.SendResolved())
}
func TestJSMOpsReceiverConfigSendResolved(t *testing.T) {
withSendResolved := func(v bool) string {
return fmt.Sprintf(`{"name":"jsm","jsmops_configs":[{"api_key":"k","send_resolved":%t}]}`, v)
}
on, err := NewReceiver(withSendResolved(true))
require.NoError(t, err)
require.Len(t, on.JSMOpsConfigs, 1)
assert.True(t, on.JSMOpsConfigs[0].SendResolved())
off, err := NewReceiver(withSendResolved(false))
require.NoError(t, err)
require.Len(t, off.JSMOpsConfigs, 1)
assert.False(t, off.JSMOpsConfigs[0].SendResolved())
}
func TestJSMOpsReceiverConfigValidation(t *testing.T) {
cases := []struct {
name string
json string
}{
{"missing api_key", `{"name":"jsm","jsmops_configs":[{"message":"m"}]}`},
{"empty api_key", `{"name":"jsm","jsmops_configs":[{"api_key":""}]}`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := NewReceiver(c.json)
assert.Error(t, err)
})
}
}

View File

@@ -23,11 +23,6 @@ import (
type Receiver struct {
*config.Receiver
GoogleChatConfigs []*GoogleChatReceiverConfig `json:"googlechat_configs,omitempty" yaml:"googlechat_configs,omitempty"`
// Shadows upstream's jira_configs so our custom notifier (rich ADF, deep-links,
// lifecycle comments) handles it instead of upstream's plain Jira notifier.
JiraConfigs []*JiraReceiverConfig `json:"jira_configs,omitempty" yaml:"jira_configs,omitempty"`
// JSM Ops (ex-Opsgenie alert API); delivered by reusing the Opsgenie notifier.
JSMOpsConfigs []*JSMOpsReceiverConfig `json:"jsmops_configs,omitempty" yaml:"jsmops_configs,omitempty"`
}
// NewReceiver builds a Receiver from its JSON input, applying each notifier
@@ -56,22 +51,6 @@ func NewReceiver(input string) (*Receiver, error) {
receiver.GoogleChatConfigs[i] = defaulted
}
for i, jc := range receiver.JiraConfigs {
defaulted, err := defaultedNotifierConfig(jc)
if err != nil {
return nil, err
}
receiver.JiraConfigs[i] = defaulted
}
for i, jc := range receiver.JSMOpsConfigs {
defaulted, err := defaultedNotifierConfig(jc)
if err != nil {
return nil, err
}
receiver.JSMOpsConfigs[i] = defaulted
}
return receiver, nil
}

View File

@@ -143,16 +143,6 @@ def wait_for_firing_timeline_entry(signoz: types.SigNoz, token: str, rule_id: st
raise AssertionError(f"No firing entry recorded in rule state history within {wait_seconds}s, items: {items}")
def get_rule(signoz: types.SigNoz, token: str, rule_id: str) -> dict:
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, f"Failed to get rule, api returned {response.status_code} with response: {response.text}"
return response.json()["data"]
def get_rule_history_top_contributors(signoz: types.SigNoz, token: str, rule_id: str, start_ms: int, end_ms: int) -> list[dict]:
response = requests.get(
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}/history/top_contributors"),

View File

@@ -1,12 +0,0 @@
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:01:00+00:00","value":1,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:02:00+00:00","value":2,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:03:00+00:00","value":4,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:04:00+00:00","value":4,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:05:00+00:00","value":15,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:06:00+00:00","value":10,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:07:00+00:00","value":36,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:08:00+00:00","value":25,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:09:00+00:00","value":37,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:10:00+00:00","value":35,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:11:00+00:00","value":39,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
{"metric_name":"request_total_disabled_rule","labels":{"service":"api","endpoint":"/health","status_code":"200"},"timestamp":"2026-01-29T10:12:00+00:00","value":25,"temporality":"Cumulative","type_":"Sum","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}

View File

@@ -1,59 +0,0 @@
{
"alert": "disabled_rule",
"ruleType": "threshold_rule",
"alertType": "METRIC_BASED_ALERT",
"disabled": true,
"condition": {
"thresholds": {
"kind": "basic",
"spec": [
{
"name": "critical",
"target": 10,
"matchType": "at_least_once",
"op": "above",
"channels": [
"test channel"
]
}
]
},
"compositeQuery": {
"queryType": "clickhouse_sql",
"panelType": "graph",
"queries": [
{
"type": "clickhouse_sql",
"spec": {
"name": "A",
"query": "WITH __temporal_aggregation_cte AS (\n SELECT \n fingerprint, \n toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts, \n avg(value) AS per_series_value \n FROM signoz_metrics.distributed_samples_v4 AS points \n INNER JOIN (\n SELECT fingerprint \n FROM signoz_metrics.time_series_v4 \n WHERE metric_name IN ('request_total_disabled_rule') \n AND LOWER(temporality) LIKE LOWER('cumulative') \n GROUP BY fingerprint\n ) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint \n WHERE metric_name IN ('request_total_disabled_rule') \n AND unix_milli >= $start_timestamp_ms \n AND unix_milli < $end_timestamp_ms \n GROUP BY fingerprint, ts \n ORDER BY fingerprint, ts\n), \n__spatial_aggregation_cte AS (\n SELECT \n ts, \n sum(per_series_value) AS value \n FROM __temporal_aggregation_cte \n WHERE isNaN(per_series_value) = 0 \n GROUP BY ts\n) \nSELECT * FROM __spatial_aggregation_cte \nORDER BY ts"
}
}
]
},
"selectedQueryName": "A"
},
"evaluation": {
"kind": "rolling",
"spec": {
"evalWindow": "5m0s",
"frequency": "15s"
}
},
"labels": {},
"annotations": {
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
},
"notificationSettings": {
"groupBy": [],
"usePolicy": false,
"renotify": {
"enabled": false,
"interval": "30m",
"alertStates": []
}
},
"version": "v5",
"schemaVersion": "v2alpha1"
}

View File

@@ -1,109 +0,0 @@
import json
import time
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import (
collect_webhook_firing_alerts,
get_rule,
update_rule_channel_name,
)
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.logger import setup_logger
logger = setup_logger(__name__)
# The rule evaluates every 15s and the alert data is set up to fire on the
# first evaluation, so a buggy evaluator would transition the rule and fire
# well within this window.
OBSERVATION_WINDOW_SECONDS = 35
def test_disabled_rule_does_not_evaluate_or_notify(
signoz: types.SigNoz,
# Notification channel related fixtures
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_webhook_notification_channel: Callable[[str, str, dict, bool], str],
# Alert rule related fixtures
create_alert_rule: Callable[[dict], str],
# Alert data insertion related fixtures
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
get_token: Callable[[str, str], str],
):
"""
A rule created with disabled: true must not be evaluated: its state must
stay "disabled" and it must not send any notification, even though the
inserted data would fire the rule if it were evaluated. The companion
scenario threshold_above_at_least_once in 02_basic_alert_conditions.py
uses the same data shape and fires when the rule is enabled.
"""
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# Prepare notification channel name and webhook endpoint
notification_channel_name = str(uuid.uuid4())
webhook_endpoint_path = f"/alert/{notification_channel_name}"
notification_url = notification_channel.container_configs["8080"].get(webhook_endpoint_path)
# register the mock endpoint in notification channel
make_http_mocks(
notification_channel,
[
Mapping(
request=MappingRequest(
method=HttpMethods.POST,
url=webhook_endpoint_path,
),
response=MappingResponse(
status=200,
json_body={},
),
persistent=False,
)
],
)
# Create an alert channel using the given route
create_webhook_notification_channel(
channel_name=notification_channel_name,
webhook_url=notification_url,
http_config={},
send_resolved=False,
)
# Insert alert data that would fire the rule if it were evaluated
insert_alert_data(
[types.AlertData(type="metrics", data_path="alerts/test_scenarios/disabled_rule/alert_data.jsonl")],
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
)
# Create the disabled alert rule
rule_path = get_testdata_file_path("alerts/test_scenarios/disabled_rule/rule.json")
with open(rule_path, encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, notification_channel_name)
rule_id = create_alert_rule(rule_data)
logger.info(
"disabled rule created with id: %s",
{"rule_id": rule_id, "rule_name": rule_data["alert"]},
)
# The rule must stay disabled and must not fire for the whole observation
# window; poll to give a buggy evaluator several chances to run.
deadline = time.time() + OBSERVATION_WINDOW_SECONDS
while time.time() < deadline:
rule = get_rule(signoz, token, rule_id)
assert rule["state"] == "disabled", f"disabled rule transitioned to unexpected state: {rule['state']}"
assert rule["disabled"] is True, "disabled rule was unexpectedly re-enabled"
firing_alerts = collect_webhook_firing_alerts(notification_channel, notification_channel_name)
assert len(firing_alerts) == 0, f"disabled rule fired alerts: {[alert.labels for alert in firing_alerts]}"
time.sleep(2)
logger.info("disabled rule stayed disabled and sent no notifications, as expected")