Compare commits

..

7 Commits

Author SHA1 Message Date
Nikhil Mantri
d27f94abce Merge branch 'main' into feat/google_chat_alert_integration_tests 2026-08-17 22:00:15 +05:30
Aditya Singh
fb106b3253 feat(logs): render DataViewer with filter/group-by actions in log details overview (#12426)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
## Pull Request

---

### 📄 Summary
> Why does this change exist?  
> What problem does it solve, and why is this the right approach?

Renders the new `DataViewer` (Pretty tree + JSON) inside the V2
log-details Overview tab
Wires up filter / group-by on each attribute similar to pretty view in
trace details

**Change points**

**Rendering the View**
- Data Viewer renders using `aggregateAttributesResourcesToObject` which
is not written fresh, just extracted from an existing logic
`aggregateAttributesResourcesToString`.
- jsonData is separately sent to the DataViewer as rendering logic for
pretty and json view is different in this case. unlike trace details
where both views had same source data.

**Group by / FIlter and other logic**

- All the related logic resides in 2 major files:
`useLogAttributeActions.tsx` and `logAttributeActions.utils.ts`.
- we build the fieldKey ourselves (`buildLogFilterTarget`) as this is
now different from old representation.
- filter / group by / replace build the query locally now...we do not
make the `getAggregateKeys` call at all. we fabricate the telemetry
field key ourselves with just the name and dataType filled and rest kept
empty. so no prefetch, no resolver, no loader.
- the query building is extracted into 3 utils: `getFilterQueryData`,
`getGroupByQueryData`, `getReplaceFilterQueryData`. the hook just calls
these over `updateQueriesData`.
- restricted fields apply for body as well: `timestamp` / `id` / `date`
inside body no longer show filter / group by. reuses
`RESTRICTED_SELECTED_FIELDS`.

**Pretty View**
- renderLeafValue: introduce to render custom leaf value. we are using
this here for body. This is extensible for other usecases as well...like
showing md format leaf for LLMs in the future.
- fixed leaf vs nested row indentation so keys line up at every depth.

**Other changes**
- Filter value keeps its data type: dataType is threaded through so a
numeric/bool filter value stays unquoted.
- Removed the redundant outer JSON tab in V2.


#### Screenshots / Screen Recordings (if applicable)


https://github.com/user-attachments/assets/ec1f0842-0a3b-407b-807c-0c3b0f9ed86a



#### Issues closed by this PR
> Reference issues using `Closes #issue-number` to enable automatic
closure on merge.
Closes: https://github.com/SigNoz/engineering-pod/issues/4618
Closes: https://github.com/SigNoz/engineering-pod/issues/4630
Closes: https://github.com/SigNoz/engineering-pod/issues/5781
---

###  Change Type
_Select all that apply_

- [x]  Feature
- [ ] 🐛 Bug fix
- [ ] ♻️ Refactor
- [ ] 🛠️ Infra / Tooling
- [ ] 🧪 Test-only

---
2026-08-17 11:11:26 +00:00
nikhilmantri0902
7774544809 test(alert-channel-integrations): recreate stale notification_channel cache lacking https:8443 2026-08-06 15:55:56 +05:30
nikhilmantri0902
2f48fc8ef8 test(alert-channel-integrations): drop unused 8443 host mapping for google chat wiremock 2026-08-06 15:37:36 +05:30
nikhilmantri0902
946058210b test(alert-channel-integrations): ruff format google chat test 2026-08-06 15:04:46 +05:30
Nikhil Mantri
113943771a Merge branch 'main' into feat/google_chat_alert_integration_tests 2026-08-06 15:02:23 +05:30
nikhilmantri0902
3c517e5bde chore: added alert integration tests for google chat 2026-08-06 14:46:09 +05:30
27 changed files with 1454 additions and 247 deletions

View File

@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -41,8 +41,7 @@
.ant-drawer-body {
display: flex;
flex-direction: column;
padding: 16px;
padding-bottom: 0;
padding: 0 16px;
}
.title {
@@ -79,6 +78,7 @@
align-items: center;
gap: 4px;
position: relative;
margin-top: 16px;
.log-body {
font-family: 'SF Mono';
@@ -123,6 +123,19 @@
}
}
.log-detail-drawer__section-divider {
height: 8px;
margin: 12px 0;
background-image:
radial-gradient(circle, var(--l3-border) 1px, transparent 1px),
radial-gradient(circle, var(--l3-border) 1px, transparent 1px);
background-size: 6px 2px;
background-position:
left top,
left bottom;
background-repeat: repeat-x;
}
.tabs-and-search {
display: flex;
justify-content: space-between;

View File

@@ -11,6 +11,13 @@ jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
// DataViewer pulls in react-json-tree (ESM) + Monaco; mock it (as trace's tests
// do). These drawer tests assert the header/highlights, not the Overview body.
jest.mock('periscope/components/DataViewer', () => ({
__esModule: true,
DataViewer: (): JSX.Element => <div data-testid="overview-data-viewer" />,
}));
// The flag to be removed later
jest.mock('../constants', () => ({
...jest.requireActual('../constants'),
@@ -66,6 +73,12 @@ describe('LogDetail drawer — header (isLogDetailsV2)', () => {
expect(screen.getByTestId('log-details-header-next')).toBeInTheDocument();
});
it('renders the DataViewer in the Overview tab', () => {
renderDrawer();
expect(screen.getByTestId('overview-data-viewer')).toBeInTheDocument();
});
it('shows the log timestamp formatted (DASH_DATETIME) in the header', () => {
// Pin the timezone to UTC so the formatted output is deterministic across
// machines/CI (Jest doesn't fix a TZ).

View File

@@ -402,6 +402,8 @@ function LogDetailInner({
{isLogDetailsV2 && <LogHighlights log={log} />}
{isLogDetailsV2 && <div className="log-detail-drawer__section-divider" />}
<div className="tabs-and-search">
<ToggleGroupSimple
type="single"
@@ -418,15 +420,21 @@ function LogDetailInner({
</div>
),
},
{
value: VIEW_TYPES.JSON,
label: (
<div className="view-title">
<Braces size={14} />
JSON
</div>
),
},
// V2's DataViewer has its own Pretty/JSON toggle, so the separate
// JSON tab is redundant.
...(isLogDetailsV2
? []
: [
{
value: VIEW_TYPES.JSON,
label: (
<div className="view-title">
<Braces size={14} />
JSON
</div>
),
},
]),
{
value: VIEW_TYPES.CONTEXT,
label: (
@@ -509,7 +517,7 @@ function LogDetailInner({
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && (
{!isLogDetailsV2 && selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}

View File

@@ -1,91 +0,0 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import useUpdatedQuery from '../useResolveQuery';
const mockGetSubstituteVars = jest.fn();
const mockDynamicVariables: unknown[] = [];
jest.mock('api/dashboard/substitute_vars', () => ({
getSubstituteVars: (...args: unknown[]): unknown =>
mockGetSubstituteVars(...args),
}));
jest.mock('api/v5/v5', () => ({
prepareQueryRangePayloadV5: (): { queryPayload: unknown } => ({
queryPayload: { start: 0, end: 1 },
}),
}));
jest.mock(
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
() => ({
mapQueryDataFromApi: (): Query => ({ resolved: true }) as unknown as Query,
}),
);
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
useDashboardVariablesByType: (): unknown[] => mockDynamicVariables,
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): unknown => ({
selectedTime: 'GLOBAL_TIME',
}),
}));
const QUERY = { builder: { queryData: [] } } as unknown as Query;
const WIDGET_CONFIG = {
query: QUERY,
panelTypes: PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME' as const,
};
describe('useResolveQuery', () => {
beforeEach(() => {
jest.clearAllMocks();
mockDynamicVariables.length = 0;
});
it('skips the substitute_vars round-trip when there are no variables', async () => {
const { result } = renderHook(() => useUpdatedQuery(), {
wrapper: MockQueryClientProvider,
});
const resolved = await result.current.getUpdatedQuery({
widgetConfig: WIDGET_CONFIG,
});
expect(mockGetSubstituteVars).not.toHaveBeenCalled();
expect(resolved).toBe(QUERY);
});
it('resolves through substitute_vars when the dashboard has variables', async () => {
mockGetSubstituteVars.mockResolvedValue({
httpStatusCode: 200,
data: { compositeQuery: {} },
});
const { result } = renderHook(() => useUpdatedQuery(), {
wrapper: MockQueryClientProvider,
});
const resolved = await result.current.getUpdatedQuery({
widgetConfig: WIDGET_CONFIG,
dashboardData: {
data: {
variables: {
env: { name: 'env', selectedValue: 'prod' },
},
},
},
});
expect(mockGetSubstituteVars).toHaveBeenCalledTimes(1);
expect(resolved).toStrictEqual({ resolved: true });
});
});

View File

@@ -2,7 +2,6 @@ import { useCallback } from 'react';
import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { isEmpty } from 'lodash-es';
import { getSubstituteVars } from 'api/dashboard/substitute_vars';
import { prepareQueryRangePayloadV5 } from 'api/v5/v5';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -47,21 +46,13 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
widgetConfig,
dashboardData,
}: UseUpdatedQueryOptions): Promise<Query> => {
const variables = getDashboardVariables(dashboardData?.data?.variables);
// `/substitute_vars` only rewrites `$variable` references, so on surfaces with no
// dashboard behind them (APM, Celery, API monitoring) the round-trip is a no-op.
if (isEmpty(variables) && isEmpty(dashboardDynamicVariables)) {
return widgetConfig.query;
}
// Prepare query payload with resolved variables
const { queryPayload } = prepareQueryRangePayloadV5({
query: widgetConfig.query,
graphType: getGraphType(widgetConfig.panelTypes),
selectedTime: widgetConfig.timePreferance,
globalSelectedInterval,
variables,
variables: getDashboardVariables(dashboardData?.data?.variables),
originalGraphType: widgetConfig.panelTypes,
dynamicVariables: dashboardDynamicVariables,
});

View File

@@ -1,4 +1,21 @@
.overview-container {
.data-viewer {
min-height: 500px;
}
.pretty-view__search-wrapper {
background: var(--l2-background);
}
.pretty-view__search-input {
background: var(--l2-background) !important;
}
.log-body-value {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.tag {
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);

View File

@@ -13,15 +13,28 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ChevronDown, ChevronRight, Search } from '@signozhq/icons';
import { isLogDetailsV2 } from 'components/LogDetail/constants';
import { DataViewer } from 'periscope/components/DataViewer';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { ActionItemProps } from './ActionItem';
import { useLogAttributeActions } from './hooks/useLogAttributeActions';
import TableView from './TableView';
import { getBodyDisplayString, removeEscapeCharacters } from './utils';
import {
aggregateAttributesResourcesToObject,
getBodyDisplayString,
getSanitizedLogBody,
parseJsonStringBody,
removeEscapeCharacters,
} from './utils';
import './Overview.styles.scss';
// Skip body sanitization above this size. sanitization is expensive and fails
// for large bodies
const MAX_BODY_SANITIZE_CHARS = 64 * 1024;
interface OverviewProps {
logData: ILog;
isListViewPanel?: boolean;
@@ -51,6 +64,56 @@ function Overview({
const isDarkMode = useIsDarkMode();
const { actions, visibleActions } = useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel,
});
if (isLogDetailsV2) {
const raw = aggregateAttributesResourcesToObject(logData);
const prettyData = Object.fromEntries(
Object.entries({ ...raw, body: parseJsonStringBody(raw.body) }).filter(
([, value]) => value !== undefined,
),
);
return (
<div className="overview-container">
<DataViewer
data={prettyData}
drawerKey="logs-details"
fontSize={13}
prettyViewProps={{
actions,
visibleActions,
renderLeafValue: (value, keyPath): ReactNode | undefined => {
// Sanitize (unescape + ANSI→color) string values under `body`.
// Skip huge ones (render raw, still safe) to avoid the sanitize
// choke;
if (
typeof value !== 'string' ||
keyPath[keyPath.length - 1] !== 'body' ||
value.length > MAX_BODY_SANITIZE_CHARS
) {
return undefined;
}
return (
<span
className="log-body-value"
// Safe: getSanitizedLogBody runs the value through dompurify.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value, { shouldEscapeHtml: true }),
}}
/>
);
},
}}
jsonString={JSON.stringify(raw, null, 2)}
/>
</div>
);
}
const options: EditorProps['options'] = {
automaticLayout: true,
readOnly: true,

View File

@@ -0,0 +1,13 @@
export enum LogAttributeBucket {
ATTRIBUTES = 'attributes',
RESOURCES = 'resources',
SCOPE = 'scope',
}
export enum LogDetailsAction {
COPY = 'copy',
FILTER_IN = 'filter-in',
FILTER_OUT = 'filter-out',
GROUP_BY = 'group-by',
REPLACE_FILTER = 'replace-filter',
}

View File

@@ -0,0 +1,232 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
import { FeatureKeys } from 'constants/features';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { useGetSearchQueryParam } from 'hooks/queryBuilder/useGetSearchQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import {
FieldContext,
PrettyViewAction,
VisibleActionsConfig,
} from 'periscope/components/PrettyView/PrettyView';
import { useAppContext } from 'providers/App/App';
import { LogDetailsAction } from '../constants';
import {
buildLogFilterTarget,
getFilterQueryData,
getGroupByQueryData,
getReplaceFilterQueryData,
} from '../logAttributeActions.utils';
interface UseLogAttributeActionsParams {
handleChangeSelectedView?: ChangeViewFunctionType;
isListViewPanel?: boolean;
}
interface UseLogAttributeActionsResult {
actions: PrettyViewAction[];
visibleActions: VisibleActionsConfig;
}
const COPY_ONLY_ACTIONS = [LogDetailsAction.COPY];
const ALL_LEAF_ACTIONS = [
LogDetailsAction.COPY,
LogDetailsAction.FILTER_IN,
LogDetailsAction.FILTER_OUT,
LogDetailsAction.GROUP_BY,
LogDetailsAction.REPLACE_FILTER,
];
/**
* PrettyView filter/group-by/replace actions for the log-details drawer (keys mapped via
* buildLogFilterTarget). Also owns `visibleActions` (leaf/nested + list-panel copy-only).
*/
export function useLogAttributeActions({
handleChangeSelectedView,
isListViewPanel = false,
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
const { pathname } = useLocation();
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { featureFlags } = useAppContext();
const viewName = useGetSearchQueryParam(QueryParams.viewName) || '';
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const isOldExplorerOrLive =
pathname === ROUTES.OLD_LOGS_EXPLORER || pathname === ROUTES.LIVE_LOGS;
const filterFor = useCallback(
(context: FieldContext, isFilterIn: boolean): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
isBodyJsonQueryEnabled,
);
const operator = isFilterIn
? target.filterInOperator
: target.filterOutOperator;
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) =>
index === 0
? getFilterQueryData(item, target, context.fieldValue, operator)
: item,
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
},
[
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
],
);
const groupBy = useCallback(
(context: FieldContext): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
isBodyJsonQueryEnabled,
);
if (!target.groupBySupported || !target.groupByKey) {
return;
}
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) => (index === 0 ? getGroupByQueryData(item, target) : item),
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.TIMESERIES, queryData);
},
[
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
],
);
const replaceFilter = useCallback(
(context: FieldContext): void => {
if (!stagedQuery) {
return;
}
const target = buildLogFilterTarget(
context.fieldKeyPath,
context.fieldValue,
isBodyJsonQueryEnabled,
);
const updatedQuery = updateQueriesData(
stagedQuery,
'queryData',
(item, index) =>
index === 0
? getReplaceFilterQueryData(item, target, context.fieldValue)
: item,
);
const queryData: ICurrentQueryData = {
name: viewName,
id: updatedQuery.id,
query: updatedQuery,
};
handleChangeSelectedView?.(ExplorerViews.LIST, queryData);
},
[
stagedQuery,
isBodyJsonQueryEnabled,
updateQueriesData,
viewName,
handleChangeSelectedView,
],
);
const actions: PrettyViewAction[] = useMemo(() => {
const isRestricted = (fieldKeyPath: (string | number)[]): boolean =>
buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.isRestricted;
return [
{
key: LogDetailsAction.FILTER_IN,
label: 'Filter for value',
icon: <CirclePlus size={12} />,
onClick: (context): void => filterFor(context, true),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.FILTER_OUT,
label: 'Filter out value',
icon: <CircleMinus size={12} />,
onClick: (context): void => filterFor(context, false),
shouldHide: (_key, fieldKeyPath): boolean => isRestricted(fieldKeyPath),
},
{
key: LogDetailsAction.GROUP_BY,
label: 'Group by field',
icon: <Layers size={12} />,
onClick: groupBy,
shouldHide: (_key, fieldKeyPath): boolean =>
!buildLogFilterTarget(fieldKeyPath, undefined, isBodyJsonQueryEnabled)
.groupBySupported || isOldExplorerOrLive,
},
{
key: LogDetailsAction.REPLACE_FILTER,
label: 'Replace filters with this value',
icon: <RefreshCw size={12} />,
onClick: replaceFilter,
shouldHide: (_key, fieldKeyPath): boolean =>
isRestricted(fieldKeyPath) || isOldExplorerOrLive,
},
];
}, [
filterFor,
groupBy,
replaceFilter,
isBodyJsonQueryEnabled,
isOldExplorerOrLive,
]);
const visibleActions = useMemo<VisibleActionsConfig>(
() => ({
leaf: isListViewPanel ? COPY_ONLY_ACTIONS : ALL_LEAF_ACTIONS,
nested: COPY_ONLY_ACTIONS,
}),
[isListViewPanel],
);
return { actions, visibleActions };
}

View File

@@ -0,0 +1,198 @@
import { MetricsType } from 'container/MetricsApplication/constant';
import {
buildLogFilterTarget,
toTypedFilterValue,
} from './logAttributeActions.utils';
describe('buildLogFilterTarget', () => {
describe('attributes / resources / scope / top-level scalars', () => {
it('maps a top-level scalar to its bare key with =/!=, groupable', () => {
const t = buildLogFilterTarget(['severity_text'], 'ERROR', true);
expect(t).toMatchObject({
fieldKey: 'severity_text',
filterInOperator: '=',
filterOutOperator: '!=',
groupBySupported: true,
groupByKey: 'severity_text',
isRestricted: false,
});
expect(t.metricsType).toBeUndefined();
});
it('strips the `attributes` root to a bare dotted key + Tag type', () => {
expect(
buildLogFilterTarget(['attributes', 'http.method'], 'GET', true),
).toMatchObject({
fieldKey: 'http.method',
filterInOperator: '=',
metricsType: MetricsType.Tag,
groupBySupported: true,
});
});
it('maps `resources` with Resource type', () => {
expect(
buildLogFilterTarget(['resources', 'service.name'], 'api', true),
).toMatchObject({
fieldKey: 'service.name',
metricsType: MetricsType.Resource,
});
});
it('maps `scope` with Scope type', () => {
expect(
buildLogFilterTarget(['scope', 'name'], 'my-scope', true),
).toMatchObject({ fieldKey: 'name', metricsType: MetricsType.Scope });
});
it('offers group-by for attributes now', () => {
expect(
buildLogFilterTarget(['attributes', 'k'], 'v', true).groupBySupported,
).toBe(true);
});
});
describe('restricted fields (timestamp / id)', () => {
it.each(['timestamp', 'id'])(
'marks %s restricted with no group-by',
(key) => {
const t = buildLogFilterTarget([key], 'v', true);
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
},
);
});
describe('body scalars', () => {
it('maps a top-level body scalar to body.<key> with =/!=, groupable when json body on', () => {
const t = buildLogFilterTarget(['body', 'message'], 'hello', true);
expect(t).toMatchObject({
fieldKey: 'body.message',
filterInOperator: '=',
filterOutOperator: '!=',
groupBySupported: true,
groupByKey: 'body.message',
isRestricted: false,
});
expect(t.dataType).toBeDefined();
expect(t.metricsType).toBeUndefined();
});
it('maps a nested body scalar to a dotted body key', () => {
expect(buildLogFilterTarget(['body', 'a', 'b'], 'x', true)).toMatchObject({
fieldKey: 'body.a.b',
groupBySupported: true,
groupByKey: 'body.a.b',
});
});
it('does not offer group-by when USE_JSON_BODY is off', () => {
const t = buildLogFilterTarget(['body', 'message'], 'hello', false);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
expect(t.fieldKey).toBe('body.message');
});
it('filters the whole `body` field when body is an unparsed string leaf', () => {
expect(buildLogFilterTarget(['body'], 'raw text', true)).toMatchObject({
fieldKey: 'body',
filterInOperator: '=',
groupBySupported: false,
});
});
it('restricts a body leaf named `timestamp` (no filter / group-by)', () => {
const t = buildLogFilterTarget(['body', 'timestamp'], '2026-01-01', true);
expect(t.fieldKey).toBe('body.timestamp');
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
expect(t.groupByKey).toBeUndefined();
});
it('restricts a nested body leaf named `timestamp`', () => {
const t = buildLogFilterTarget(['body', 'obj', 'timestamp'], 'x', true);
expect(t.isRestricted).toBe(true);
expect(t.groupBySupported).toBe(false);
});
it('restricts a body leaf named `id` (uses RESTRICTED_SELECTED_FIELDS)', () => {
expect(buildLogFilterTarget(['body', 'id'], 'abc', true).isRestricted).toBe(
true,
);
});
it('does not restrict an ordinary body leaf', () => {
expect(
buildLogFilterTarget(['body', 'message'], 'hello', true).isRestricted,
).toBe(false);
});
});
describe('body arrays', () => {
it('uses has()/!has() on the array key for a primitive array element', () => {
const t = buildLogFilterTarget(['body', 'tags', 0], 'urgent', true);
expect(t).toMatchObject({
fieldKey: 'body.tags',
filterInOperator: 'has',
groupBySupported: false,
});
expect(t.filterOutOperator).toContain('has');
expect(t.filterOutOperator).not.toBe('has');
});
it('collapses deep array-element paths to a []-marked has() key', () => {
expect(
buildLogFilterTarget(
['body', 'config', 'features', 1, 'items', 0, 'variants', 2],
'ballpen',
true,
),
).toMatchObject({
fieldKey: 'body.config.features[].items[].variants',
filterInOperator: 'has',
});
});
it('maps a field nested inside an array element with =/!= and a []-marked key, no group-by', () => {
expect(
buildLogFilterTarget(['body', 'items', 2, 'sku'], 'ABC', true),
).toMatchObject({
fieldKey: 'body.items[].sku',
filterInOperator: '=',
filterOutOperator: '!=',
groupBySupported: false,
});
});
it('uses the [*] string-body marker for an array element when USE_JSON_BODY is off', () => {
expect(
buildLogFilterTarget(['body', 'tags', 0], 'urgent', false),
).toMatchObject({ fieldKey: 'body.tags[*]', filterInOperator: 'has' });
});
it('uses [*] for a field nested inside an array element when USE_JSON_BODY is off', () => {
expect(
buildLogFilterTarget(['body', 'items', 2, 'sku'], 'ABC', false),
).toMatchObject({ fieldKey: 'body.items[*].sku', filterInOperator: '=' });
});
});
});
describe('toTypedFilterValue', () => {
const run = (value: unknown): unknown => toTypedFilterValue(value);
it('keeps numbers/booleans as their JS type (so the expression stays unquoted)', () => {
expect(run(848)).toBe(848);
expect(typeof run(848)).toBe('number');
expect(run(1.1)).toBe(1.1);
expect(run(true)).toBe(true);
expect(typeof run(true)).toBe('boolean');
});
it('passes strings through unchanged (no numeric inference)', () => {
expect(run('unknown_service')).toBe('unknown_service');
expect(typeof run('12345')).toBe('string');
});
});

View File

@@ -0,0 +1,229 @@
import { v4 as uuid } from 'uuid';
import {
negateOperator,
OPERATORS,
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { OPERATORS as QUERY_BUILDER_OPERATORS } from 'constants/queryBuilder';
import { RESTRICTED_SELECTED_FIELDS } from 'container/LogsFilters/config';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { LogAttributeBucket } from './constants';
import { generateFieldKeyForArray, getDataTypes } from './utils';
export const toTypedFilterValue = (value: unknown): string =>
typeof value === 'number' || typeof value === 'boolean'
? (value as unknown as string)
: String(value);
export interface LogFilterTarget {
fieldKey: string;
filterInOperator: string;
filterOutOperator: string;
dataType?: DataTypes;
metricsType?: MetricsType;
groupBySupported: boolean;
groupByKey?: string;
isRestricted: boolean;
}
// Collapse a body forward path into the query-builder key segment; array indices become
// `[]` (json body on) or `[*]` (string body off — a distinct operator/search path).
// ['items', 2, 'sku'] -> 'items[].sku' (json on) / 'items[*].sku' (off)
// ['tags', 0] -> 'tags[]' (json on) / 'tags[*]' (off)
const collapseBodyPath = (
subpath: (string | number)[],
isBodyJsonQueryEnabled: boolean,
): string => {
const arrayMarker = isBodyJsonQueryEnabled ? '[]' : '[*]';
let out = '';
subpath.forEach((seg) => {
if (typeof seg === 'number') {
out += arrayMarker;
} else {
out += out ? `.${seg}` : seg;
}
});
return out;
};
const metricsTypeForRoot = (root: string | number): MetricsType | undefined => {
if (root === LogAttributeBucket.ATTRIBUTES) {
return MetricsType.Tag;
}
if (root === LogAttributeBucket.RESOURCES) {
return MetricsType.Resource;
}
if (root === LogAttributeBucket.SCOPE) {
return MetricsType.Scope;
}
return undefined;
};
/**
* Map a PrettyView leaf (forward keyPath) to its query-builder filter/group-by target:
* scalars → `=`/`!=`, body array elements → `has`/`!has`; group-by gated by
* `groupBySupported`. Attribute/resource/scope carry a `metricsType` (Tag/Resource/Scope).
*/
export const buildLogFilterTarget = (
fieldKeyPath: (string | number)[],
value: unknown,
isBodyJsonQueryEnabled: boolean,
): LogFilterTarget => {
const root = fieldKeyPath[0];
// Attributes / resources / scope / top-level scalars: bare dotted key, =/!=.
if (root !== 'body') {
const fieldKey =
fieldKeyPath.length > 1 ? fieldKeyPath.slice(1).join('.') : String(root);
const isRestricted = RESTRICTED_SELECTED_FIELDS.includes(fieldKey);
return {
fieldKey,
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
metricsType: metricsTypeForRoot(root),
groupBySupported: !isRestricted,
groupByKey: isRestricted ? undefined : fieldKey,
isRestricted,
};
}
const subpath = fieldKeyPath.slice(1);
// Whole-body leaf (unparsed string body): filter on the `body` field itself.
if (subpath.length === 0) {
return {
fieldKey: 'body',
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
groupBySupported: false,
isRestricted: false,
};
}
const collapsed = collapseBodyPath(subpath, isBodyJsonQueryEnabled);
const isArrayElement = typeof subpath[subpath.length - 1] === 'number';
if (isArrayElement) {
// has(body.<array>, value): generateFieldKeyForArray strips the trailing value
// segment + `[]` exactly as the old BodyTitleRenderer.filterHandler did.
const fieldKey = generateFieldKeyForArray(
`${collapsed}.${String(value)}`,
getDataTypes(value),
isBodyJsonQueryEnabled,
);
return {
fieldKey,
filterInOperator: QUERY_BUILDER_FUNCTIONS.HAS,
filterOutOperator: negateOperator(QUERY_BUILDER_FUNCTIONS.HAS),
dataType: getDataTypes([value]),
groupBySupported: false,
isRestricted: false,
};
}
const fieldKey = `body.${collapsed}`;
// Restrict body leaves whose own key is a restricted field (e.g. a JSON body's own
// `timestamp`/`id`/`date`) — hides filter / group-by, same as top-level fields.
const leafKey = String(subpath[subpath.length - 1]);
const isRestricted = RESTRICTED_SELECTED_FIELDS.includes(leafKey);
// Group by only for plain body scalars (no array anywhere in the path) with json
// body on — mirrors isGroupBySupported in the old BodyTitleRenderer.
const groupBySupported =
isBodyJsonQueryEnabled && !collapsed.includes('[]') && !isRestricted;
return {
fieldKey,
filterInOperator: OPERATORS['='],
filterOutOperator: OPERATORS['!='],
dataType: getDataTypes(value),
groupBySupported,
groupByKey: groupBySupported ? fieldKey : undefined,
isRestricted,
};
};
const normalizeDataType = (
dataType: DataTypes | undefined,
): DataTypes | undefined =>
dataType && Object.values(DataTypes).includes(dataType) ? dataType : undefined;
// Append a filter item (filter-in / filter-out) to a query-data item. The autocomplete
// key is fabricated locally (empty source list), so no click-time getAggregateKeys fetch.
export const getFilterQueryData = (
item: IBuilderQuery,
target: LogFilterTarget,
value: unknown,
operator: string,
): IBuilderQuery => {
const filterKey = chooseAutocompleteFromCustomValue(
[],
target.fieldKey,
target.dataType,
target.metricsType,
);
return {
...item,
filters: {
items: [
...(item.filters?.items || []),
{
id: uuid(),
key: filterKey,
op: getOperatorValue(operator),
value: toTypedFilterValue(value),
},
],
op: item.filters?.op || 'AND',
},
};
};
// Append a group-by. Caller guards groupBySupported / groupByKey.
export const getGroupByQueryData = (
item: IBuilderQuery,
target: LogFilterTarget,
): IBuilderQuery => {
const newGroupByItem: BaseAutocompleteData = {
key: target.groupByKey || '',
type: target.metricsType || '',
dataType: normalizeDataType(target.dataType),
};
return { ...item, groupBy: [...(item.groupBy || []), newGroupByItem] };
};
// Replace all filters with a single IN filter on this value.
export const getReplaceFilterQueryData = (
item: IBuilderQuery,
target: LogFilterTarget,
value: unknown,
): IBuilderQuery => {
const newFilterItem: BaseAutocompleteData = {
key: target.fieldKey,
type: target.metricsType || '',
dataType: normalizeDataType(target.dataType),
};
return {
...item,
filters: {
items: [
{
id: '',
key: newFilterItem,
op: QUERY_BUILDER_OPERATORS.IN,
value: [toTypedFilterValue(value)],
},
],
op: 'AND',
},
filter: { expression: '' },
};
};

View File

@@ -1,12 +1,87 @@
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
aggregateAttributesResourcesToObject,
flattenObject,
getDataTypes,
getSanitizedLogBody,
parseJsonStringBody,
recursiveParseJSON,
} from './utils';
describe('parseJsonStringBody', () => {
it('parses a JSON-object string into an object', () => {
expect(parseJsonStringBody('{"a":1,"b":{"c":2}}')).toStrictEqual({
a: 1,
b: { c: 2 },
});
});
it('parses a JSON-array string into an array', () => {
expect(parseJsonStringBody('[1,2,3]')).toStrictEqual([1, 2, 3]);
});
it('returns a plain (non-JSON) string unchanged', () => {
expect(parseJsonStringBody('plain log line')).toBe('plain log line');
});
it('returns a string that is not object/array-looking unchanged', () => {
expect(parseJsonStringBody('42')).toBe('42');
});
it('returns an invalid JSON string unchanged', () => {
expect(parseJsonStringBody('{not valid}')).toBe('{not valid}');
});
it('returns an already-object body unchanged (same reference)', () => {
const body = { message: 'hi', a: 1 };
expect(parseJsonStringBody(body)).toBe(body);
});
it('leaves a body larger than the 128KB parse guard as a string', () => {
const huge = `{"x":"${'a'.repeat(130 * 1024)}"}`;
expect(parseJsonStringBody(huge)).toBe(huge);
});
});
describe('aggregateAttributesResourcesToObject', () => {
const mockLog = {
id: 'log-1',
timestamp: 1234,
body: 'hello',
severity_text: 'INFO',
severity_number: 9,
attributes_string: { 'http.method': 'GET' },
attributes_int: { retries: 3 },
resources_string: { 'service.name': 'cart' },
scope_string: { lib: 'otel' },
} as unknown as ILog;
it('merges attributes_/resources_/scope_ maps and keeps scalars + body', () => {
const result = aggregateAttributesResourcesToObject(mockLog);
expect(result.attributes).toStrictEqual({
'http.method': 'GET',
retries: 3,
});
expect(result.resources).toStrictEqual({ 'service.name': 'cart' });
expect(result.scope).toStrictEqual({ lib: 'otel' });
expect(result.body).toBe('hello');
expect(result.id).toBe('log-1');
expect(result.severity_text).toBe('INFO');
});
it('does not parse a JSON-string body (leaves it raw)', () => {
const result = aggregateAttributesResourcesToObject({
...mockLog,
body: '{"a":1}',
} as unknown as ILog);
expect(result.body).toBe('{"a":1}');
});
});
describe('recursiveParseJSON', () => {
it('should return an empty object if the input is not valid JSON', () => {
const result = recursiveParseJSON('not valid JSON');

View File

@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react';
import Convert from 'ansi-to-html';
import type { DataNode } from 'antd/es/tree';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
@@ -262,10 +263,11 @@ export const filterKeyForField = (field: string): string => {
return fieldAttribs?.newField || field;
};
export const aggregateAttributesResourcesToString = (logData: ILog): string => {
export const aggregateAttributesResourcesToObject = (
logData: ILog,
): ILogAggregateAttributesResources => {
const outputJson: ILogAggregateAttributesResources = {
body: logData.body,
date: logData.date,
id: logData.id,
severityNumber: logData.severityNumber,
severityText: logData.severityText,
@@ -281,6 +283,9 @@ export const aggregateAttributesResourcesToString = (logData: ILog): string => {
};
Object.keys(logData).forEach((key) => {
if (key === 'date') {
return;
}
if (key.startsWith('attributes_')) {
outputJson.attributes = outputJson.attributes || {};
Object.assign(outputJson.attributes, logData[key as keyof ILog]);
@@ -291,12 +296,47 @@ export const aggregateAttributesResourcesToString = (logData: ILog): string => {
outputJson.scope = outputJson.scope || {};
Object.assign(outputJson.scope, logData[key as keyof ILog]);
} else {
// @ts-expect-error
// @ts-expect-error dynamic top-level copy
outputJson[key] = logData[key as keyof ILog];
}
});
return JSON.stringify(outputJson, null, 2);
// Show `timestamp` first and `id` last in the details view.
const { timestamp, id, ...rest } = outputJson;
return { timestamp, ...rest, id };
};
export const aggregateAttributesResourcesToString = (logData: ILog): string => {
try {
return JSON.stringify(aggregateAttributesResourcesToObject(logData), null, 2);
} catch (err) {
Sentry.captureException(err);
return '';
}
};
const MAX_JSON_BODY_PARSE_BYTES = 128 * 1024;
// A JSON-encoded object/array `body` is parsed so DataViewer renders it as a
// tree instead of one escaped string; plain-text bodies are returned unchanged.
// Guarded against very large payloads.
export const parseJsonStringBody = (body: ILog['body']): ILog['body'] => {
if (typeof body !== 'string') {
return body;
}
const trimmed = body.trim();
const looksLikeJson = trimmed.startsWith('{') || trimmed.startsWith('[');
if (!looksLikeJson || trimmed.length > MAX_JSON_BODY_PARSE_BYTES) {
return body;
}
try {
const parsed = JSON.parse(trimmed);
return parsed !== null && typeof parsed === 'object'
? (parsed as ILogBody)
: body;
} catch {
return body;
}
};
const isFloat = (num: number): boolean => num % 1 !== 0;

View File

@@ -124,9 +124,6 @@ function Application(): JSX.Element {
start: minTime,
end: maxTime,
}),
// the time range is part of the key, so without this every window change blanks the
// operations list and the widgets below are rebuilt with an empty `operation in []`
keepPreviousData: true,
});
const selectedTraceTags: string = JSON.stringify(

View File

@@ -1,88 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import useBaseAggregateOptions from '../useBaseAggregateOptions';
const mockGetUpdatedQuery = jest.fn();
const mockNotificationsError = jest.fn();
jest.mock('container/GridCardLayout/useResolveQuery', () => ({
__esModule: true,
default: (): unknown => ({
getUpdatedQuery: mockGetUpdatedQuery,
isLoading: false,
}),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): unknown => ({
notifications: { error: mockNotificationsError },
}),
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): unknown => ({ dashboardData: undefined }),
}));
jest.mock('hooks/dashboard/useContextVariables', () => ({
__esModule: true,
default: (): unknown => ({ processedVariables: {} }),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): unknown => ({ safeNavigate: jest.fn() }),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({ pathname: '/services/socky-api' }),
}));
const QUERY = {
builder: {
queryData: [{ queryName: 'A', dataSource: 'traces', aggregations: [] }],
},
} as unknown as Query;
const AGGREGATE_DATA = { queryName: 'A', filters: [] };
const renderOptions = (): ReturnType<typeof renderHook> =>
renderHook(() =>
useBaseAggregateOptions({
query: QUERY,
onClose: jest.fn(),
subMenu: '',
setSubMenu: jest.fn(),
aggregateData: AGGREGATE_DATA,
fieldVariables: {},
}),
);
describe('useBaseAggregateOptions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('notifies and keeps the unresolved query when variable resolution fails', async () => {
mockGetUpdatedQuery.mockRejectedValue(
new Error('syntax errors in expression'),
);
renderOptions();
await waitFor(() =>
expect(mockNotificationsError).toHaveBeenCalledWith({
message: 'Unable to resolve variables',
}),
);
});
it('does not notify when variable resolution succeeds', async () => {
mockGetUpdatedQuery.mockResolvedValue(QUERY);
renderOptions();
await waitFor(() => expect(mockGetUpdatedQuery).toHaveBeenCalled());
expect(mockNotificationsError).not.toHaveBeenCalled();
});
});

View File

@@ -6,7 +6,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import useContextVariables from 'hooks/dashboard/useContextVariables';
import { useNotifications } from 'hooks/useNotifications';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
@@ -51,25 +50,23 @@ const useBaseAggregateOptions = ({
const { getUpdatedQuery, isLoading: isResolveQueryLoading } =
useUpdatedQuery();
const { dashboardData } = useDashboardStore();
const { notifications } = useNotifications();
useEffect(() => {
if (!aggregateData) {
return;
}
getUpdatedQuery({
widgetConfig: {
query,
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
})
.then(setResolvedQuery)
.catch(() => {
setResolvedQuery(query);
notifications.error({ message: 'Unable to resolve variables' });
const resolveQuery = async (): Promise<void> => {
const updatedQuery = await getUpdatedQuery({
widgetConfig: {
query,
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
});
setResolvedQuery(updatedQuery);
};
resolveQuery();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, aggregateData, panelType]);

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { CSSProperties, useMemo, useState } from 'react';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import logEvent from 'api/common/logEvent';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
@@ -24,16 +24,23 @@ export interface DataViewerProps {
data: Record<string, any>;
drawerKey?: string;
prettyViewProps?: Omit<PrettyViewProps, 'data' | 'drawerKey'>;
// Optional override for the JSON view otherwise `data` is
// stringified and used.
jsonString?: string;
fontSize?: number;
}
function DataViewer({
data,
drawerKey = 'default',
prettyViewProps,
jsonString,
fontSize,
}: DataViewerProps): JSX.Element {
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Pretty);
const jsonString = useMemo(() => JSON.stringify(data, null, 2), [data]);
const derivedJson = useMemo(() => JSON.stringify(data, null, 2), [data]);
const json = jsonString ?? derivedJson;
const handleViewModeChange = (value: string): void => {
const next = value as ViewMode;
@@ -55,7 +62,14 @@ function DataViewer({
};
return (
<div className="data-viewer">
<div
className="data-viewer"
style={
fontSize
? ({ '--data-viewer-font-size': `${fontSize}px` } as CSSProperties)
: undefined
}
>
<div className="data-viewer__toolbar">
<ToggleGroupSimple
type="single"
@@ -65,14 +79,14 @@ function DataViewer({
items={VIEW_MODE_OPTIONS}
testId="data-viewer-view-mode"
/>
<CopyButton value={jsonString} ariaLabel="Copy JSON" />
<CopyButton value={json} ariaLabel="Copy JSON" />
</div>
<div className="data-viewer__content">
{viewMode === ViewMode.Pretty && (
<PrettyView data={data} drawerKey={drawerKey} {...prettyViewProps} />
)}
{viewMode === ViewMode.Json && <JsonView data={jsonString} />}
{viewMode === ViewMode.Json && <JsonView data={json} fontSize={fontSize} />}
</div>
</div>
);

View File

@@ -10,6 +10,7 @@ import './JsonView.styles.scss';
export interface JsonViewProps {
data: string;
height?: string;
fontSize?: number;
}
const editorOptions: EditorProps['options'] = {
@@ -56,7 +57,11 @@ function setEditorTheme(monaco: Monaco): void {
});
}
function JsonView({ data, height = '575px' }: JsonViewProps): JSX.Element {
function JsonView({
data,
height = '575px',
fontSize = 12,
}: JsonViewProps): JSX.Element {
const [isWrapWord, setIsWrapWord] = useState(true);
const isDarkMode = useIsDarkMode();
@@ -65,7 +70,11 @@ function JsonView({ data, height = '575px' }: JsonViewProps): JSX.Element {
<MEditor
value={data}
language="json"
options={{ ...editorOptions, wordWrap: isWrapWord ? 'on' : 'off' }}
options={{
...editorOptions,
fontSize,
wordWrap: isWrapWord ? 'on' : 'off',
}}
onChange={(): void => {}}
height={height}
theme={isDarkMode ? 'signoz-dark' : 'light'}

View File

@@ -17,7 +17,7 @@
border-bottom: 1px solid var(--l2-border) !important;
border-radius: 0 !important;
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace !important;
font-size: 12px !important;
font-size: var(--data-viewer-font-size, 12px) !important;
line-height: 18px !important;
background: var(--l1-background) !important;
outline: none !important;
@@ -33,7 +33,7 @@
// Font spec: SF Mono, 12px, 400, 18px line-height, -0.5% letter-spacing
font-family: 'SF Mono', 'Geist Mono', 'Fira Code', monospace;
font-size: 12px;
font-size: var(--data-viewer-font-size, 12px);
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
@@ -77,6 +77,17 @@
margin-right: 16px;
}
// Pin the caret to the leaf padding-left (1.25em) so nested keys align with leaf
// keys at every depth; inline-flex centers the glyph on the text line.
&__arrow {
box-sizing: border-box !important;
width: 1.25em !important;
padding-right: 0 !important;
display: inline-flex !important;
align-items: center !important;
height: 18px; // match the row line-height so the caret centers on the text
}
// Leaf node row — full-width hover highlight
&__row {
display: flex !important;
@@ -84,6 +95,11 @@
position: relative;
isolation: isolate; // own stacking context so ::before sits behind content, not the panel bg
// react-json-tree indents leaf values with a depth-dependent padding-left
// (2.125em when nested vs 1.25em at top); we indent via `ul`, so pin it flat
// to keep leaf and nested keys aligned at every depth.
padding-left: 1.25em !important;
// Keep actions visible on hover, or while this row's menu is open
&:hover .pretty-view__actions,
&:has(> span [data-state='open']) .pretty-view__actions {
@@ -175,6 +191,13 @@
&__row &__value-row {
justify-content: space-between;
width: 100%;
// Anchor the ... to the top of the value (not centered in a tall value)
align-items: flex-start;
.pretty-view__actions {
position: sticky;
top: 4px;
}
}
// ... actions button — hidden by default, shown on row hover

View File

@@ -67,6 +67,15 @@ export interface PrettyViewProps {
*/
pinnedFieldsValue?: string[];
onPinnedFieldsChange?: (next: string[]) => void;
/**
* Optional per-leaf value renderer. Return a node to override the default
* `String(value)` rendering for that leaf, or `undefined` to fall back. Used
* e.g. to sanitize/format log body values (ANSI → color, unescape).
*/
renderLeafValue?: (
value: unknown,
keyPath: readonly (string | number)[],
) => React.ReactNode | undefined;
}
function PrettyView({
@@ -78,6 +87,7 @@ function PrettyView({
drawerKey = 'default',
pinnedFieldsValue,
onPinnedFieldsChange,
renderLeafValue,
}: PrettyViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [, setCopy] = useCopyToClipboard();
@@ -233,7 +243,9 @@ function PrettyView({
>
<span
className="pretty-view__actions"
onClick={(e): void => e.stopPropagation()}
onClick={(e): void => {
e.stopPropagation();
}}
role="button"
tabIndex={0}
>
@@ -278,15 +290,16 @@ function PrettyView({
...keyPath: KeyPath
): React.ReactNode => {
const forwardPath = keyPathToForward(keyPath);
const custom = renderLeafValue?.(value, keyPath);
return renderWithActions({
content: String(valueAsString),
content: custom ?? String(valueAsString),
fieldKey: keyPathToDisplayString(keyPath),
fieldKeyPath: forwardPath,
value,
isNested: typeof value === 'object' && value !== null,
});
},
[renderWithActions],
[renderWithActions, renderLeafValue],
);
const pinnedLabelRenderer = useCallback(

View File

@@ -59,4 +59,12 @@ export const themeExtension = {
style: { ...style },
className: 'pretty-view__nested-row',
}),
arrowContainer: (
{ style }: { style: Record<string, unknown> },
arrowStyle: string,
): { style: Record<string, unknown>; className?: string } => ({
style: { ...style },
// Only pin the single caret's width (for key alignment).
className: arrowStyle === 'double' ? undefined : 'pretty-view__arrow',
}),
};

View File

@@ -29,6 +29,7 @@ type OmitAttributesResources = Pick<
ILog,
Exclude<
keyof ILog,
| 'date'
| 'resources_string'
| 'scope_string'
| 'attributesString'

View File

@@ -335,27 +335,69 @@ def _is_json_subset(subset, superset) -> bool:
return subset == superset
def _match_query_params(expected: dict, req: dict) -> bool:
"""Match a wiremock request's query params. Each expected value may be a string
(exact), an re.Pattern (search), or None (presence only, e.g. a dynamic hash)."""
query_params = req.get("queryParams", {})
for name, want in expected.items():
if name not in query_params:
return False
values = query_params[name].get("values", [])
if want is None:
if not values:
return False
elif isinstance(want, re.Pattern):
if not any(want.search(v) for v in values):
return False
elif want not in values:
return False
return True
def verify_webhook_notification_expectation(
notification_channel: types.TestContainerDocker,
validation_data: dict,
) -> bool:
"""Check if wiremock received a request at the given path
whose JSON body is a superset of the expected json_body."""
"""Check that wiremock received the expected request(s) at the given path.
validation_data supports (all optional except path):
- path: request url path (matched as urlPath, so query strings are ignored)
- json_body: expected JSON subset of the request body
- query_params: {name: str|re.Pattern|None} matched against the request query
- count: exact number of requests required at the path
- min_count: minimum number of requests required (e.g. retries)
Body/query constraints must be satisfied by a single request; count constraints
apply to the total at the path."""
path = validation_data["path"]
json_body = validation_data["json_body"]
json_body = validation_data.get("json_body")
query_params = validation_data.get("query_params")
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
try:
res = requests.post(url, json={"method": "POST", "url": path}, timeout=10)
# urlPath matches the path only; the notifier appends a dynamic threadKey.
res = requests.post(url, json={"method": "POST", "urlPath": path}, timeout=10)
except requests.exceptions.RequestException:
return False
if res.status_code != HTTPStatus.OK:
return False
for req in res.json()["requests"]:
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
if _is_json_subset(json_body, body):
return True
reqs = res.json()["requests"]
if "count" in validation_data and len(reqs) != validation_data["count"]:
return False
if "min_count" in validation_data and len(reqs) < validation_data["min_count"]:
return False
if json_body is None and query_params is None:
return True
for req in reqs:
if json_body is not None:
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
if not _is_json_subset(json_body, body):
continue
if query_params is not None and not _match_query_params(query_params, req):
continue
return True
return False
@@ -416,7 +458,7 @@ def _received_notifications(
continue
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
try:
res = requests.post(url, json={"method": "POST", "url": validation.validation_data["path"]}, timeout=10)
res = requests.post(url, json={"method": "POST", "urlPath": validation.validation_data["path"]}, timeout=10)
webhook_bodies.extend(json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8")) for req in res.json()["requests"])
except requests.exceptions.RequestException as exc:
webhook_bodies.append(f"<failed to fetch wiremock journal: {exc}>")
@@ -455,4 +497,11 @@ def update_raw_channel_config(
path = urlparse(original_url).path
entry[url_field] = notification_channel.container_configs["8080"].get(path)
# Google Chat validates the webhook host, so route via the https alias config
# (chat.googleapis.com:8443) keeping the path, and skip tls for wiremock's cert.
for entry in config.get("googlechat_configs", []):
path = urlparse(entry["webhook_url"]).path
entry["webhook_url"] = notification_channel.container_configs["8443"].get(path)
entry.setdefault("http_config", {}).setdefault("tls_config", {})["insecure_skip_verify"] = True
return config

View File

@@ -18,6 +18,10 @@ from fixtures.maildev import MAILDEV_INCOMING_PASS, SMTP_TEST_FROM
logger = setup_logger(__name__)
# Google Chat validates the webhook host, so the WireMock container is aliased as
# this hostname on the docker network and channels point at https://<host>:8443/...
GOOGLE_CHAT_HOST = "chat.googleapis.com"
EMAIL_TRANSPORT_KEYS = [
"from",
@@ -124,6 +128,19 @@ email_default_config = {
}
def googlechat_config(space: str) -> dict:
"""Google Chat channel config for a per-test WireMock space path. Title/text are
omitted so the backend applies its default templates. The host + tls-skip are
injected at runtime by update_raw_channel_config."""
return {
"googlechat_configs": [
{
"webhook_url": f"/v1/spaces/{space}/messages", # host set on runtime
}
],
}
@pytest.fixture(name="notification_channel", scope="package")
def notification_channel(
network: Network,
@@ -134,9 +151,27 @@ def notification_channel(
Package-scoped fixture for WireMock container to receive notifications for Alert rules.
"""
# A --reuse cache from before the https:8443 alias was added lacks the "8443"
# config that Google Chat delivery needs (and its container lacks the port/alias).
# Drop such a stale cache + container so the fixture recreates a correct one,
# instead of raising KeyError on container_configs["8443"].
cached = pytestconfig.cache.get("notification_channel", None)
if cached and "8443" not in (cached.get("container_configs") or {}):
logger.info("Recreating stale notification_channel (cache missing https:8443)")
try:
docker.from_env().containers.get(cached["id"]).remove(force=True)
except docker.errors.NotFound:
pass
pytestconfig.cache.set("notification_channel", None)
def create() -> types.TestContainerDocker:
# http:8080 admin/webhook delivery, plus https:8443 aliased as
# chat.googleapis.com so Google Chat's validated webhook host routes here.
container = WireMockContainer(image="wiremock/wiremock:2.35.1-1", secure=False)
container.with_cli_arg("--https-port", "8443")
container.with_exposed_ports(8080) # 8443 reached in-network via the alias, no host mapping needed
container.with_network(network)
container.with_network_aliases(GOOGLE_CHAT_HOST)
container.start()
return types.TestContainerDocker(
@@ -148,7 +183,11 @@ def notification_channel(
container.get_exposed_port(8080),
)
},
container_configs={"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080)},
container_configs={
"8080": types.TestContainerUrlConfig("http", container.get_wrapped_container().name, 8080),
# Google Chat delivery: https to the validated host via the network alias.
"8443": types.TestContainerUrlConfig("https", GOOGLE_CHAT_HOST, 8443),
},
)
def delete(container: types.TestContainerDocker):

View File

@@ -0,0 +1,232 @@
"""Google Chat notifier integration tests driven through the real alerting path:
create a rule pointing at a Google Chat channel, insert breaching telemetry, let
the ruler fire, and assert on the cardsV2 payload WireMock received.
WireMock stands in for chat.googleapis.com (network alias + https:8443, see the
notification_channel fixture). Assertions check the actual card structure, deep
links and threading query params so behavioural regressions are caught.
"""
import json
import re
import time
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import pytest
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import (
get_testdata_file_path,
update_raw_channel_config,
update_rule_channel_name,
verify_notification_expectation,
)
from fixtures.logger import setup_logger
from fixtures.notification_channel import googlechat_config
logger = setup_logger(__name__)
METRICS_DATA = "alerts/test_scenarios/threshold_above_at_least_once/alert_data.jsonl"
METRICS_RULE = "alerts/test_scenarios/threshold_above_at_least_once/rule.json"
LOGS_DATA = "alerts/test_scenarios/threshold_below_at_least_once/alert_data.jsonl"
LOGS_RULE = "alerts/test_scenarios/threshold_below_at_least_once/rule.json"
TRACES_DATA = "alerts/test_scenarios/threshold_above_average/alert_data.jsonl"
TRACES_RULE = "alerts/test_scenarios/threshold_above_average/rule.json"
# threading query params the notifier always appends
THREAD_QUERY = {
"messageReplyOption": "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD",
"threadKey": None, # dynamic hash; presence only
}
def _path(space: str) -> str:
return f"/v1/spaces/{space}/messages"
def _stub_200(path: str) -> list[Mapping]:
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=200, json_body={"name": "spaces/x/messages/x"}),
persistent=True,
)
]
def _stub_retry(path: str) -> list[Mapping]:
"""429 on the first call then 200, via a wiremock scenario transition."""
scenario = f"gc-retry-{path}"
return [
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=429, json_body={"error": {"code": 429, "status": "RESOURCE_EXHAUSTED"}}),
scenario_name=scenario,
required_scenario_state="Started",
new_scenario_state="ok",
persistent=True,
),
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=200, json_body={"name": "spaces/x/messages/x"}),
scenario_name=scenario,
required_scenario_state="ok",
persistent=True,
),
]
def _card_subset(alertname: str, buttons: list[tuple[str, str]]) -> dict:
"""A cardsV2 subset asserting title, firing banner, rendered body, and each
button's text AND deep-link url (as a regex), so a broken link is caught too.
buttons: list of (text, url_regex)."""
return {
"text": f"[FIRING:1] {alertname}",
"cardsV2": [
{
"cardId": "signoz-alert",
"card": {
"header": {"title": f"[FIRING:1] {alertname}"},
"sections": [
# firing banner
{"widgets": [{"textParagraph": {"text": re.compile("FIRING")}}]},
# rendered alert body mentions the alertname
{"widgets": [{"textParagraph": {"text": re.compile(re.escape(alertname))}}]},
]
+ [{"widgets": [{"buttonList": {"buttons": [{"text": text, "onClick": {"openLink": {"url": re.compile(url)}}}]}}]} for text, url in buttons],
},
}
],
}
GOOGLECHAT_CASES = [
types.AlertManagerNotificationTestCase(
name="googlechat_default_metrics_firing",
rule_path=METRICS_RULE,
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
channel_config=googlechat_config("gc-metrics"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=150,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": _path("gc-metrics"),
"query_params": THREAD_QUERY,
"json_body": _card_subset("threshold_above_at_least_once", [("Open in SigNoz", r"/alerts/overview\?ruleId=")]),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="googlechat_rich_card_logs",
rule_path=LOGS_RULE,
alert_data=[types.AlertData(type="logs", data_path=LOGS_DATA)],
channel_config=googlechat_config("gc-logs"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=150,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": _path("gc-logs"),
"json_body": _card_subset(
"threshold_below_at_least_once",
[("View Related Logs", r"/logs/logs-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="googlechat_rich_card_traces",
rule_path=TRACES_RULE,
alert_data=[types.AlertData(type="traces", data_path=TRACES_DATA)],
channel_config=googlechat_config("gc-traces"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=150,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
"path": _path("gc-traces"),
"json_body": _card_subset(
"threshold_above_average",
[("View Related Traces", r"traces-explorer\?"), ("Open in SigNoz", r"/alerts/overview\?ruleId=")],
),
},
),
],
),
),
types.AlertManagerNotificationTestCase(
name="googlechat_retry_429_then_200",
rule_path=METRICS_RULE,
alert_data=[types.AlertData(type="metrics", data_path=METRICS_DATA)],
channel_config=googlechat_config("gc-retry"),
notification_expectation=types.AMNotificationExpectation(
should_notify=True,
wait_time_seconds=150,
notification_validations=[
types.NotificationValidation(
destination_type="webhook",
validation_data={
# a retryable 429 is followed by a successful re-POST => >=2 hits
"path": _path("gc-retry"),
"min_count": 2,
"json_body": {"cardsV2": [{"cardId": "signoz-alert"}]},
},
),
],
),
),
]
# per-case wiremock stubs (retry needs a stateful scenario, the rest a plain 200)
CASE_STUBS: dict[str, Callable[[str], list[Mapping]]] = {
"googlechat_retry_429_then_200": _stub_retry,
}
@pytest.mark.parametrize(
"gc_test_case",
GOOGLECHAT_CASES,
ids=lambda c: c.name,
)
def test_googlechat_notifier( # pylint: disable=too-many-arguments,too-many-positional-arguments
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
create_notification_channel: Callable[[dict], str],
create_alert_rule: Callable[[dict], str],
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
maildev: types.TestContainerDocker,
gc_test_case: types.AlertManagerNotificationTestCase,
) -> None:
channel_name = str(uuid.uuid4())
path = gc_test_case.notification_expectation.notification_validations[0].validation_data["path"]
channel_config = update_raw_channel_config(gc_test_case.channel_config, channel_name, notification_channel)
stub_factory = CASE_STUBS.get(gc_test_case.name, _stub_200)
make_http_mocks(notification_channel, stub_factory(path))
create_notification_channel(channel_config)
time.sleep(12) # org registration in alertmanager
insert_alert_data(gc_test_case.alert_data, base_time=datetime.now(tz=UTC) - timedelta(minutes=5))
with open(get_testdata_file_path(gc_test_case.rule_path), encoding="utf-8") as f:
rule_data = json.loads(f.read())
update_rule_channel_name(rule_data, channel_name)
create_alert_rule(rule_data)
verify_notification_expectation(notification_channel, maildev, gc_test_case.notification_expectation)

View File

@@ -0,0 +1,112 @@
"""Google Chat coverage for the testChannel API (POST /api/v1/testChannel).
testChannel drives the notifier once, synchronously, with a hardcoded test alert
and no retry. It is the button users click in the UI, and the deterministic place
to assert permanent-failure / no-retry behaviour. Rich-card and retry behaviour is
covered via the firing-rule path in alertmanager/04_googlechat.py.
"""
import base64
import json
import re
import time
import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
from fixtures import types
from fixtures.alerts import update_raw_channel_config
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.logger import setup_logger
from fixtures.notification_channel import googlechat_config
logger = setup_logger(__name__)
def _path(space: str) -> str:
return f"/v1/spaces/{space}/messages"
# name, space, stub status, stub body, expect testChannel 204
TEST_CHANNEL_CASES = [
("googlechat_test_channel_success", "gc-tc-ok", 200, {"name": "spaces/x/messages/x"}, True),
("googlechat_test_channel_permanent_400", "gc-tc-400", 400, {"error": {"code": 400, "status": "INVALID_ARGUMENT", "message": "Message cannot be empty."}}, False),
("googlechat_test_channel_permission_403", "gc-tc-403", 403, {"error": {"code": 403, "status": "PERMISSION_DENIED", "message": "Method doesn't allow unregistered callers"}}, False),
]
@pytest.mark.parametrize(
"name,space,status,body,expect_delivered",
TEST_CHANNEL_CASES,
ids=lambda v: v if isinstance(v, str) else "",
)
def test_googlechat_test_channel( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
create_user_admin: None, # pylint: disable=unused-argument
notification_channel: types.TestContainerDocker,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
name: str, # pylint: disable=unused-argument
space: str,
status: int,
body: dict,
expect_delivered: bool,
) -> None:
path = _path(space)
make_http_mocks(
notification_channel,
[
Mapping(
request=MappingRequest(method=HttpMethods.POST, url_path=path),
response=MappingResponse(status=status, json_body=body),
persistent=True,
)
],
)
channel_name = str(uuid.uuid4())
receiver = update_raw_channel_config(googlechat_config(space), channel_name, notification_channel)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# org registration in alertmanager
time.sleep(10)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/testChannel"),
json=receiver,
headers={"Authorization": f"Bearer {admin_token}"},
timeout=30,
)
if expect_delivered:
assert response.status_code == HTTPStatus.NO_CONTENT, f"expected 204, got {response.status_code}: {response.text}"
else:
# a 400/403 is a permanent failure: testChannel surfaces it, does not retry
assert response.status_code != HTTPStatus.NO_CONTENT, f"expected failure status, got 204 for {status} stub"
# exactly one delivery attempt either way (testChannel never retries)
count = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/count"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
assert count.json()["count"] == 1, f"expected exactly 1 request (no retry), got {count.text}"
if expect_delivered:
find = requests.post(
notification_channel.host_configs["8080"].get("/__admin/requests/find"),
json={"method": "POST", "urlPath": path},
timeout=10,
)
req = find.json()["requests"][0]
# threading query params are always appended
assert "messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" in req["url"]
assert "threadKey=" in req["url"]
# cardsV2 shape with the hardcoded test alert
card = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
assert card["cardsV2"][0]["cardId"] == "signoz-alert"
assert re.search(r"Test Alert \(", card["cardsV2"][0]["card"]["header"]["title"])