Compare commits

...

8 Commits

Author SHA1 Message Date
aks07
4fe2308c0f docs(logs): add the log body access rule 2026-11-03 17:48:37 +05:30
aks07
8cfe2595bf test(logs): use the shared test harness in the table columns test 2026-11-03 17:48:05 +05:30
aks07
3de9a0196e fix(logs): keep the typed value on add-to-query from the default view 2026-11-03 17:47:26 +05:30
aks07
e21a7e71c5 fix(logs): collapse message-only bodies in the drawer header too 2026-11-03 01:09:27 +05:30
aks07
a609fabd69 test(storybook): json body states for explorer, live logs, list panel and pod events 2026-11-02 19:26:40 +05:30
aks07
11d20d71ff fix(logs): render object-valued fields in the explorer views
Default and Raw resolved selected fields with FlatLogData, which misses
nested body keys and hands an object to React. All three views now resolve
through getLogFieldValue and format with coerceToString. Object values are
not offered as add-to-query filters, the same as the log details drawer.
2026-11-02 19:16:25 +05:30
aks07
265ea49b36 refactor(logs): share the use_json_body lookup in useIsBodyJsonEnabled 2026-11-02 19:03:28 +05:30
aks07
aa653f9221 fix(logs): add logBody accessor for json and missing bodies
Live logs and the dashboard List panel skip the V5 response converter,
so the message-only collapse moves out of it into the accessor. A missing
body reads as an empty string.
2026-11-02 19:00:04 +05:30
37 changed files with 482 additions and 142 deletions

15
.claude/rules/log-body.md Normal file
View File

@@ -0,0 +1,15 @@
---
paths:
- "frontend/src/**/*.ts"
- "frontend/src/**/*.tsx"
---
# Log body
`ILog.body` is `string | ILogBody`. On `use_json_body` tenants the API sends an object, and the collector wraps a plain-string body into `{ message }`. Every read goes through `frontend/src/lib/logs/logBody.ts`.
- **Read with a getter, never directly.** `getBodyDisplay` for a list cell or a log line, `getBodyMessage` for the drawer header. Both accept `undefined` and collapse a `{message}`-only object to its message.
- **Never cast or stringify a body inline.** No `as string`, no `String(log.body)`, no `JSON.stringify(log.body)`, no `typeof body === 'string' ? … : …` outside the module. The v5 converter collapses `{ message }` once with `extractOnlyMessageBody`; live logs and the dashboard list panel skip the converter, so their rows carry the raw object and only a getter renders them right.
- **Resolve a selected field with `getLogFieldValue`** from `lib/logs/flatLogData.ts`, so a `body.<key>` column works on both string and object bodies. An object value renders as JSON and is not offered as an add-to-query filter.
- **The drawer tree and field lookup are the only places that use the raw value.** Everything else renders a string from a getter.
- **Review check.** A raw body value used outside a getter call, `extractOnlyMessageBody` in the converter, `flatLogData.ts` and `LogDetailedView/utils.tsx` is a finding.

View File

@@ -1,3 +1,4 @@
import { extractOnlyMessageBody } from 'lib/logs/logBody';
import { cloneDeep, isEmpty } from 'lodash-es';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
@@ -281,19 +282,6 @@ function convertScalarWithFormatForWeb(
});
}
function extractOnlyMessageBody(body: unknown): unknown {
const isJsonBody = body && typeof body === 'object' && !Array.isArray(body);
if (isJsonBody) {
const keys = Object.keys(body);
const hasOnlyMessageKey = keys.length === 1 && keys[0] === 'message';
if (hasOnlyMessageKey) {
const { message } = body as { message: unknown };
return typeof message === 'string' ? message : JSON.stringify(message);
}
}
return body;
}
/**
* Converts V5 RawData to legacy format
*/

View File

@@ -8,7 +8,6 @@ import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicat
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
@@ -22,6 +21,7 @@ import { useOptionsMenu } from 'container/OptionsMenu';
import { FontSize } from 'container/OptionsMenu/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { getBodyMessage } from 'lib/logs/logBody';
import { cloneDeep } from 'lodash-es';
import {
ArrowDown,
@@ -32,8 +32,6 @@ import {
TextSelect,
X,
} from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -66,10 +64,6 @@ function LogDetailInner({
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
const { featureFlags } = useAppContext();
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const [filters, setFilters] = useState<TagFilter | null>(null);
const [isEdit, setIsEdit] = useState<boolean>(false);
@@ -172,20 +166,7 @@ function LogDetailInner({
}
};
const logBody = useMemo(() => {
if (!isBodyJsonQueryEnabled) {
return (log?.body as string) ?? '';
}
// Feature enabled: body is always a map; message is always a string
const bodyObj = log?.body as ILogBody;
if (!bodyObj) {
return '';
}
if (bodyObj.message) {
return bodyObj.message;
}
return JSON.stringify(bodyObj);
}, [isBodyJsonQueryEnabled, log?.body]);
const logBody = useMemo(() => getBodyMessage(log?.body), [log?.body]);
const htmlBody = useMemo(
() => ({

View File

@@ -4,6 +4,7 @@ import cx from 'classnames';
import { OPERATORS } from 'constants/queryBuilder';
import { FontSize } from 'container/OptionsMenu/types';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { ScalarFieldValue } from 'utils/stringUtils';
import './AddToQueryHOC.styles.scss';
@@ -40,10 +41,10 @@ function AddToQueryHOC({
export interface AddToQueryHOCProps {
fieldKey: string;
fieldValue: string;
fieldValue: ScalarFieldValue;
onAddToQuery: (
fieldKey: string,
fieldValue: string,
fieldValue: ScalarFieldValue,
operator: string,
dataType?: DataTypes,
) => void;

View File

@@ -5,19 +5,19 @@ import cx from 'classnames';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ChangeViewFunctionType } from 'hooks/useHandleExplorerTabChange';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
import { useIsDarkMode } from 'hooks/useDarkMode';
// utils
import { FlatLogData } from 'lib/logs/flatLogData';
import { FlatLogData, getLogFieldValue } from 'lib/logs/flatLogData';
import { getBodyDisplay } from 'lib/logs/logBody';
import { useTimezone } from 'providers/Timezone';
// interfaces
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { coerceToString, isScalarFieldValue } from 'utils/stringUtils';
// components
import AddToQueryHOC, { AddToQueryHOCProps } from '../AddToQueryHOC';
@@ -26,7 +26,6 @@ import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from '../LogStateIndicator/utils';
// styles
import { Container, LogContainer, LogText } from './styles';
import { isValidLogField } from './util';
import './ListLogView.styles.scss';
@@ -37,8 +36,9 @@ interface LogFieldProps {
fontSize: FontSize;
}
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow' | 'fieldValue'> &
Pick<AddToQueryHOCProps, 'fieldValue'> &
Partial<Pick<AddToQueryHOCProps, 'onAddToQuery'>>;
function LogGeneralField({
fieldKey,
@@ -75,27 +75,35 @@ function LogSelectedField({
onAddToQuery,
fontSize,
}: LogSelectedFieldProps): JSX.Element {
const fieldKeyText = (
<Typography.Text>
<span
style={{ color: blue[4] }}
className={cx('selected-log-field-key', fontSize)}
>
{fieldKey}
</span>
</Typography.Text>
);
return (
<div className="log-selected-fields">
<AddToQueryHOC
fieldKey={fieldKey}
fieldValue={fieldValue}
onAddToQuery={onAddToQuery}
fontSize={fontSize}
>
<Typography.Text>
<span
style={{ color: blue[4] }}
className={cx('selected-log-field-key', fontSize)}
>
{fieldKey}
</span>
</Typography.Text>
</AddToQueryHOC>
{onAddToQuery ? (
<AddToQueryHOC
fieldKey={fieldKey}
fieldValue={fieldValue}
onAddToQuery={onAddToQuery}
fontSize={fontSize}
>
{fieldKeyText}
</AddToQueryHOC>
) : (
fieldKeyText
)}
<Typography.Text truncate={1} className={cx('selected-log-kv', fontSize)}>
<span className={cx('selected-log-field-key', fontSize)}>{': '}</span>
<span className={cx('selected-log-value', fontSize)}>
{fieldValue || "''"}
{coerceToString(fieldValue) || "''"}
</span>
</Typography.Text>
</div>
@@ -130,6 +138,7 @@ function ListLogView({
onClearActiveLog,
}: ListLogViewProps): JSX.Element {
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
const isBodyJsonEnabled = useIsBodyJsonEnabled();
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
logData.id,
@@ -199,7 +208,7 @@ function ListLogView({
{updatedSelecedFields.some((field) => field.name === 'body') && (
<LogGeneralField
fieldKey="Log"
fieldValue={getBodyDisplayString(logData.body)}
fieldValue={getBodyDisplay(logData.body)}
linesPerRow={linesPerRow}
fontSize={fontSize}
/>
@@ -221,17 +230,22 @@ function ListLogView({
{updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.map((field) =>
isValidLogField(flattenLogData[field.name] as never) ? (
.map((field) => {
const value = getLogFieldValue(logData, field.name, isBodyJsonEnabled);
if (value == null) {
return null;
}
const queryValue = isScalarFieldValue(value) ? value : undefined;
return (
<LogSelectedField
key={field.name}
fieldKey={field.name}
fieldValue={flattenLogData[field.name] as never}
onAddToQuery={onAddToQuery}
fieldValue={queryValue ?? coerceToString(value)}
onAddToQuery={queryValue === undefined ? undefined : onAddToQuery}
fontSize={fontSize}
/>
) : null,
)}
);
})}
</LogContainer>
</div>
</div>

View File

@@ -1 +0,0 @@
export const isValidLogField = (value: never): boolean => value !== undefined;

View File

@@ -9,16 +9,16 @@ import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
import { FlatLogData } from 'lib/logs/flatLogData';
import { isEmpty, isNumber, isUndefined } from 'lodash-es';
import { getLogFieldValue } from 'lib/logs/flatLogData';
import { getBodyDisplay } from 'lib/logs/logBody';
import { isBoolean, isEmpty, isNumber, isUndefined } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { coerceToString } from 'utils/stringUtils';
import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButtons';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
@@ -46,7 +46,7 @@ function RawLogView({
isLogsExplorerPage,
onLogCopy,
} = useCopyLogLink(data.id);
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
const isBodyJsonEnabled = useIsBodyJsonEnabled();
const isDarkMode = useIsDarkMode();
const isReadOnlyLog = !isLogsExplorerPage || isReadOnly;
@@ -60,15 +60,16 @@ function RawLogView({
const attributesValues = updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.map((field) => flattenLogData[field.name])
.map((field) => getLogFieldValue(data, field.name, isBodyJsonEnabled))
.filter((attribute) => {
// loadash isEmpty doesnot work with numbers
if (isNumber(attribute)) {
if (isNumber(attribute) || isBoolean(attribute)) {
return true;
}
return !isUndefined(attribute) && !isEmpty(attribute);
});
})
.map((attribute) => coerceToString(attribute));
let attributesText = attributesValues.join(' | ');
@@ -102,7 +103,7 @@ function RawLogView({
// Check if body is selected
const showBody = selectedFields.some((field) => field.name === 'body');
if (showBody) {
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
parts.push(`${attributesText} ${getBodyDisplay(data.body)}`);
} else {
parts.push(attributesText);
}

View File

@@ -1,16 +1,20 @@
import { renderHook } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import { render, renderHook, screen } from 'tests/test-utils';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { useLogsTableColumns } from '../useLogsTableColumns';
jest.mock('providers/Timezone', () => ({
__esModule: true,
...jest.requireActual('providers/Timezone'),
useTimezone: (): { formatTimezoneAdjustedTimestamp: jest.Mock } => ({
formatTimezoneAdjustedTimestamp: jest.fn(() => 'TS'),
}),
}));
jest.mock('providers/App/App', () => ({
...jest.requireActual('providers/App/App'),
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
}));
@@ -165,3 +169,36 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
expect(result.current.map((c) => c.id)).toStrictEqual(['state-indicator']);
});
});
describe('useLogsTableColumns — cell values', () => {
const asLog = (partial: Partial<ILog>): ILog => partial as unknown as ILog;
it('shows an empty body cell for a row without a body', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('body', 'log')],
fontSize: FontSize.SMALL,
}),
);
const bodyCol = result.current.find((c) => c.id === 'log:body');
expect(bodyCol?.accessorFn?.(asLog({}))).toBe('');
});
it('renders an object-valued field as JSON, not [object Object]', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('order')],
fontSize: FontSize.SMALL,
}),
);
const orderCol = result.current.find((c) => c.id === 'order');
const Cell = orderCol?.cell as (props: { value: unknown }) => JSX.Element;
render(<Cell value={{ id: 8412, coupons: ['WELCOME10'] }} />);
expect(
screen.getByText('{"id":8412,"coupons":["WELCOME10"]}'),
).toBeInTheDocument();
});
});

View File

@@ -2,18 +2,16 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { FeatureKeys } from 'constants/features';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
import { getLogFieldValue } from 'lib/logs/flatLogData';
import { useAppContext } from 'providers/App/App';
import { getBodyDisplay } from 'lib/logs/logBody';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { coerceToString } from 'utils/stringUtils';
import type { TableColumnDef } from '../../TanStackTableView/types';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
@@ -28,10 +26,7 @@ export function useLogsTableColumns({
fontSize,
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const isBodyJsonEnabled = useIsBodyJsonEnabled();
return useMemo<TableColumnDef<ILog>[]>(() => {
const stateIndicatorCol: TableColumnDef<ILog> = {
@@ -75,7 +70,7 @@ export function useLogsTableColumns({
const bodyCol: TableColumnDef<ILog> = {
id: buildCompositeKey('body', 'log'),
header: 'Body',
accessorFn: (log): string => getBodyDisplayString(log.body),
accessorFn: (log): string => getBodyDisplay(log.body),
canBeHidden: false,
enableRemove: false,
width: { default: '100%', min: 300 },
@@ -99,7 +94,7 @@ export function useLogsTableColumns({
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
<TanStackTable.Text>{coerceToString(value)}</TanStackTable.Text>
),
});

View File

@@ -22,6 +22,7 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { getBodyDisplay } from 'lib/logs/logBody';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
@@ -163,7 +164,7 @@ function EntityEventsContent({
key: event.data.id,
id: event.data.id,
timestamp: event.timestamp,
body: event.data.body,
body: getBodyDisplay(event.data.body),
severity: event.data.severity_text,
attributes_string: event.data.attributes_string,
resources_string: event.data.resources_string,

View File

@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { getEntityEventsQueryPayload } from './utils';
@@ -10,7 +11,7 @@ export const K8S_ENTITY_EVENTS_EXPRESSION_KEY = 'k8sEntityEventsExpression';
export interface EventRowData {
id: string;
body: string;
body: ILog['body'];
severity_text: string;
attributes_string?: Record<string, string>;
resources_string?: Record<string, string>;

View File

@@ -33,6 +33,10 @@ import {
// Mock the useContextLogData hook
const mockHandleRunQuery = jest.fn();
jest.mock('hooks/logs/useIsBodyJsonEnabled', () => ({
useIsBodyJsonEnabled: (): boolean => false,
}));
jest.mock('container/OptionsMenu', () => ({
useOptionsMenu: (): any => ({
options: {

View File

@@ -1,7 +1,6 @@
import { useCallback, useMemo } from 'react';
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
ChangeViewFunctionType,
@@ -13,7 +12,7 @@ import {
PrettyViewAction,
VisibleActionsConfig,
} from 'periscope/components/PrettyView/PrettyView';
import { useAppContext } from 'providers/App/App';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { LogDetailsAction } from '../constants';
@@ -54,11 +53,7 @@ export function useLogAttributeActions({
onApplyLogFilter,
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const { featureFlags } = useAppContext();
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const isBodyJsonQueryEnabled = useIsBodyJsonEnabled();
const filterFor = useCallback(
(context: FieldContext, isFilterIn: boolean): void => {

View File

@@ -2,11 +2,7 @@ import * as Sentry from '@sentry/react';
import Convert from 'ansi-to-html';
import { MetricsType } from 'container/MetricsApplication/constant';
import dompurify from 'dompurify';
import {
ILog,
ILogAggregateAttributesResources,
ILogBody,
} from 'types/api/logs/log';
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
@@ -349,8 +345,3 @@ export const getSanitizedLogBody = (
return '{}';
}
};
// Returns a plain string for display contexts (Monaco editor, table cells, raw log row).
export function getBodyDisplayString(body: string | ILogBody): string {
return typeof body === 'string' ? body : JSON.stringify(body as ILogBody);
}

View File

@@ -2,9 +2,8 @@ import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Form, Select, Space } from 'antd';
import { FeatureKeys } from 'constants/features';
import { ModalFooterTitle } from 'container/PipelinePage/styles';
import { useAppContext } from 'providers/App/App';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
import { ProcessorData } from 'types/api/pipeline/def';
import { formValidationRules } from '../config';
@@ -138,10 +137,7 @@ function ProcessorForm({
selectedProcessorData,
isAdd,
}: ProcessorFormProps): JSX.Element {
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const isBodyJsonEnabled = useIsBodyJsonEnabled();
return (
<div className="processor-form-container">

View File

@@ -2,8 +2,8 @@ import { Expand } from '@signozhq/icons';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { getBodyDisplay } from 'lib/logs/logBody';
import { useTimezone } from 'providers/Timezone';
import { ILog } from 'types/api/logs/log';
@@ -27,7 +27,7 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
)}
</div>
<div className="logs-preview-list-item-body">
{getBodyDisplayString(log.body)}
{getBodyDisplay(log.body)}
</div>
<div
className="logs-preview-list-item-expand"

View File

@@ -1,4 +1,5 @@
import { MouseEvent } from 'react';
import { ScalarFieldValue } from 'utils/stringUtils';
import { ILog } from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -21,7 +22,7 @@ export type UseActiveLog = {
onClearActiveLog: () => void;
onAddToQuery: (
fieldKey: string,
fieldValue: string,
fieldValue: ScalarFieldValue,
operator: string,
dataType?: DataTypes,
) => void;

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ScalarFieldValue } from 'utils/stringUtils';
import { QueryParams } from 'constants/query';
import { getFilterQueryData } from 'container/LogDetailedView/logAttributeActions.utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -37,7 +38,7 @@ export const useActiveLog = (): UseActiveLog => {
const onAddToQuery = useCallback(
(
fieldKey: string,
fieldValue: string,
fieldValue: ScalarFieldValue,
operator: string,
dataType?: DataTypes,
): void => {

View File

@@ -0,0 +1,10 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
export function useIsBodyJsonEnabled(): boolean {
const { featureFlags } = useAppContext();
return (
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active ?? false
);
}

View File

@@ -25,7 +25,7 @@ function getBodyFieldValue(body: ILogBody, key: string): unknown {
}, body);
}
// Resolve one field for the logs table. A JSON body is checked first (use_json_body
// Resolve one field for a log list format. A JSON body is checked first (use_json_body
// only), splitting the key on `.`; otherwise fall back to FlatLogData
// (attributes/resources/scope/top-level).
export function getLogFieldValue(

View File

@@ -0,0 +1,72 @@
import { getBodyDisplay, getBodyMessage } from './logBody';
describe('getBodyDisplay', () => {
it('returns a string body as is', () => {
expect(getBodyDisplay('plain text')).toBe('plain text');
});
it('returns the message when it is the only key', () => {
expect(getBodyDisplay({ message: 'only a message' })).toBe('only a message');
});
it('stringifies a non-string message when it is the only key', () => {
expect(getBodyDisplay({ message: { event: 'login' } as never })).toBe(
'{"event":"login"}',
);
});
it('stringifies an object with more than the message key', () => {
expect(getBodyDisplay({ message: 'with fields', level: 'warn' })).toBe(
'{"message":"with fields","level":"warn"}',
);
});
it('stringifies an object without a message key', () => {
expect(getBodyDisplay({ event: 'cache_evict' })).toBe(
'{"event":"cache_evict"}',
);
});
it('returns an empty string for a missing body', () => {
expect(getBodyDisplay(undefined)).toBe('');
expect(getBodyDisplay(null)).toBe('');
});
it('stringifies a body that is neither a string nor an object', () => {
expect(getBodyDisplay(42)).toBe('42');
expect(getBodyDisplay(['a', 1])).toBe('["a",1]');
});
});
describe('getBodyMessage', () => {
it('returns a string body as is', () => {
expect(getBodyMessage('plain text')).toBe('plain text');
});
it('prefers the message over the other keys', () => {
expect(getBodyMessage({ message: 'with fields', level: 'warn' })).toBe(
'with fields',
);
});
it('stringifies the object when the message is missing or empty', () => {
expect(getBodyMessage({ event: 'cache_evict' })).toBe(
'{"event":"cache_evict"}',
);
expect(getBodyMessage({ message: '', level: 'warn' })).toBe(
'{"message":"","level":"warn"}',
);
});
it('collapses a message-only body the same way as getBodyDisplay', () => {
expect(getBodyMessage({ message: { event: 'login' } as never })).toBe(
'{"event":"login"}',
);
expect(getBodyMessage({ message: '' })).toBe('');
});
it('returns an empty string for a missing body', () => {
expect(getBodyMessage(undefined)).toBe('');
expect(getBodyMessage(null)).toBe('');
});
});

View File

@@ -0,0 +1,33 @@
import { ILogBody } from 'types/api/logs/log';
import { coerceToString } from 'utils/stringUtils';
function isBodyObject(body: unknown): body is ILogBody {
return typeof body === 'object' && body !== null && !Array.isArray(body);
}
export function extractOnlyMessageBody(body: unknown): unknown {
if (!isBodyObject(body)) {
return body;
}
const keys = Object.keys(body);
if (keys.length === 1 && keys[0] === 'message') {
const { message } = body;
return typeof message === 'string' ? message : JSON.stringify(message);
}
return body;
}
export function getBodyDisplay(body: unknown): string {
return coerceToString(extractOnlyMessageBody(body));
}
export function getBodyMessage(body: unknown): string {
const collapsed = extractOnlyMessageBody(body);
if (!isBodyObject(collapsed)) {
return coerceToString(collapsed);
}
if (typeof collapsed.message === 'string' && collapsed.message !== '') {
return collapsed.message;
}
return JSON.stringify(collapsed);
}

View File

@@ -9,6 +9,7 @@ import type {
FormatTimezoneAdjustedTimestamp,
TimestampInput,
} from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { getBodyDisplay } from 'lib/logs/logBody';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { coerceToString } from 'utils/stringUtils';
@@ -36,6 +37,10 @@ function renderCell(value: unknown): ReactNode {
return <Typography.Text>{coerceToString(value)}</Typography.Text>;
}
function renderBodyCell(value: unknown): ReactNode {
return <Typography.Text>{getBodyDisplay(value)}</Typography.Text>;
}
/**
* Scale an epoch integer to milliseconds (what dayjs expects) by its magnitude:
* logs/traces carry nanoseconds, but be tolerant of µs/ms/s too.
@@ -178,7 +183,7 @@ export function buildListColumns({
dataIndex: name,
key: name,
ellipsis: true,
render: renderCell,
render: renderBodyCell,
};
}
return {

View File

@@ -213,6 +213,12 @@ export const panelsMocks = defineStoryMocks({
value: 20,
max: 80,
}),
listJsonBody: toggleControl('List JSON body', {
group: DATA,
description:
'`use_json_body`: the List panel’s log bodies arrive as objects, every third one with keys beside `message`.',
value: false,
}),
},
handlers: (values, response) => {
const args: PanelArgs = values;
@@ -259,6 +265,7 @@ export const panelsMocks = defineStoryMocks({
result: values.result,
series: values.series,
listRows: values.listRows,
listJsonBody: values.listJsonBody,
gaps: values.gaps,
});
}),

View File

@@ -1,5 +1,6 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import {
DashboardtypesHeatmapYScaleDTO as HeatmapYScale,
DashboardtypesLegendPositionDTO as LegendPosition,
@@ -166,6 +167,24 @@ export const List: Story = {
play,
};
/**
* The List panel on a `use_json_body` tenant: a body holding only `message`
* reads as its text, one with keys beside it as JSON.
*/
export const ListJsonBody: Story = {
args: { kind: 'List', listJsonBody: true },
play: async ({ canvasElement, args }): Promise<void> => {
await expectPanel(canvasElement, args);
const [first, , third] = within(canvasElement)
.getByTestId('list-panel-renderer')
.querySelectorAll<HTMLElement>('tbody tr.ant-table-row');
await expect(within(first).getByText(/^log line 1:/)).toBeInTheDocument();
await expect(
within(third).getByText(/^\{"message":"log line 3:/),
).toBeInTheDocument();
},
};
/**
* Logs spanning three pages, 25 a request: Next walks to the second and then
* the last, shorter page, where Next turns off.

View File

@@ -144,18 +144,31 @@ const heatmapResponse = (
});
};
/**
* On a `use_json_body` tenant every body is an object: a plain line arrives as
* `{ message }` alone, a structured one with its own keys beside it.
*/
const logBodyAt = (index: number, line: string): Record<string, unknown> =>
index % 3 === 2
? { message: line, service: serviceAt(index % MAX_SERIES), attempt: 1 }
: { message: line };
/** Spaced by the window, newest first, as the `timestamp desc` order asks for. */
const logRows = (
{ start, end }: PanelWindow,
total: number,
offset: number,
limit: number,
jsonBody: boolean,
): RawRow[] =>
Array.from(
{ length: Math.max(0, Math.min(limit, total - offset)) },
(_unused, position) => {
const index = offset + position;
const service = serviceAt(index % MAX_SERIES);
const line = `log line ${index + 1}: ${service} served the request in ${
8 + (index % 9) * 3
}ms`;
return {
timestamp: new Date(
@@ -163,9 +176,7 @@ const logRows = (
).toISOString(),
data: {
id: `storybook-log-${index + 1}`,
body: `log line ${index + 1}: ${service} served the request in ${
8 + (index % 9) * 3
}ms`,
body: jsonBody ? logBodyAt(index, line) : line,
severity_text: index % 5 === 0 ? 'ERROR' : 'INFO',
resources_string: { 'service.name': service },
},
@@ -179,6 +190,7 @@ export const panelQueryResponse = ({
result,
series,
listRows,
listJsonBody,
gaps,
window,
offset,
@@ -217,9 +229,12 @@ export const panelQueryResponse = ({
case 'Number':
return queryRangeV5ScalarResponse(NUMBER_VALUE, QUERY_NAME, { warning });
case 'List':
return queryRangeV5RawResponse(logRows(window, listRows, offset, limit), {
warning,
});
return queryRangeV5RawResponse(
logRows(window, listRows, offset, limit, listJsonBody),
{
warning,
},
);
default:
return latencySeries(window, series, gaps, warning);
}

View File

@@ -77,5 +77,6 @@ export interface PanelResponseArgs extends PanelRequest {
result: PanelResult;
series: number;
listRows: number;
listJsonBody: boolean;
gaps: Gaps;
}

View File

@@ -311,6 +311,7 @@ export type InfraControls = {
drawer: MockControl<boolean>;
drawerTab: MockControl<VIEWS>;
telemetryRows: MockControl<number>;
eventJsonBody: MockControl<boolean>;
};
const infraControls = (entity: InfraEntity): InfraControls => ({
@@ -361,6 +362,12 @@ const infraControls = (entity: InfraEntity): InfraControls => ({
value: 12,
max: 20,
}),
eventJsonBody: toggleControl('Event JSON body', {
group: DRAWER,
description:
'`use_json_body`: the events tab’s bodies arrive as objects, a warning with its reason and object beside `message`, an info line with `message` alone.',
value: false,
}),
});
/**
@@ -435,7 +442,12 @@ export const infraStoryMocks = (
}
return isEventsQuery(body)
? entityEventRows(start, end, values.telemetryRows)
? entityEventRows(
start,
end,
values.telemetryRows,
values.eventJsonBody,
)
: entityLogRows(start, end, values.telemetryRows);
})();

View File

@@ -46,6 +46,14 @@ export const PodDetailsEvents: StoryObj<PodsArgs> = {
args: { drawer: true, drawerTab: VIEWS.EVENTS },
};
/**
* The events tab on a `use_json_body` tenant: a warning's event object as JSON
* in the Body column, an info line's `message`-only body as its text.
*/
export const PodDetailsEventsJsonBody: StoryObj<PodsArgs> = {
args: { drawer: true, drawerTab: VIEWS.EVENTS, eventJsonBody: true },
};
/** The selected pod's details drawer, switched to its logs tab. */
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
args: { drawer: true },

View File

@@ -1068,10 +1068,28 @@ const K8S_EVENT_BODIES = [
{ severity: 'INFO', body: 'Pulled image "signoz/checkout:1.4.2" in 812ms' },
];
/**
* What the k8sobjects receiver sends on a `use_json_body` tenant: the event
* object for a warning, and a `message`-only object for a plain info line.
*/
const eventJsonBody = (
event: (typeof K8S_EVENT_BODIES)[number],
index: number,
): Record<string, unknown> =>
event.severity === 'WARN'
? {
message: event.body,
type: 'Warning',
reason: 'BackOff',
involvedObject: { kind: 'Pod', name: pick(WORKLOADS, index) },
}
: { message: event.body };
export const entityEventRows = (
start: number,
end: number,
count: number,
jsonBody = false,
): RawRow[] =>
Array.from({ length: count }, (_unused, index) => {
const event = pick(K8S_EVENT_BODIES, index);
@@ -1082,7 +1100,7 @@ export const entityEventRows = (
).toISOString(),
data: {
id: `event-${index + 1}`,
body: event.body,
body: jsonBody ? eventJsonBody(event, index) : event.body,
severity_text: event.severity,
severity_number: event.severity === 'WARN' ? 13 : 9,
attributes_string: {

View File

@@ -4,12 +4,14 @@
*/
import setLocalStorage from 'api/browser/localstorage/set';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { FontSize, type LogViewMode } from 'container/OptionsMenu/types';
import { rest } from 'msw';
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
import type {
BaseBuilderQuery,
QueryRangeRequestV5,
@@ -19,6 +21,7 @@ import {
choiceControl,
countControl,
multiChoiceControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
@@ -94,6 +97,12 @@ export const liveLogsMocks = defineStoryMocks({
options: LIST_FORMATS,
value: 'table',
}),
jsonBody: toggleControl('JSON body', {
group: LIST,
description:
'`use_json_body`: the streamed lines carry the body as an object, every fourth one holding only `message`, which the list shows as its text.',
value: false,
}),
},
handlers: (values, response) => [
// The frequency chart above the list stays empty here: it only queries
@@ -117,7 +126,14 @@ export const liveLogsMocks = defineStoryMocks({
// `Authorization` header, so without these the browser refuses the
// response the same way it would refuse the backend's.
ctx.set('Access-Control-Allow-Origin', '*'),
ctx.body(liveLogsStream(values.liveLogs, values.severities, Date.now())),
ctx.body(
liveLogsStream(
values.liveLogs,
values.severities,
Date.now(),
values.jsonBody,
),
),
),
),
@@ -138,7 +154,9 @@ export const liveLogsMocks = defineStoryMocks({
const timeWindow = { start: body.start, end: body.end };
if (body.requestType === 'raw') {
return logRowsResponse(values.liveLogs, values.severities, body.end);
return logRowsResponse(values.liveLogs, values.severities, body.end, {
jsonBody: values.jsonBody,
});
}
const spec = body.compositeQuery?.queries?.[0]?.spec as
@@ -196,9 +214,23 @@ export const liveLogsMocks = defineStoryMocks({
response.json(dashboardsResponse),
),
],
config: () => ({
config: ({ jsonBody }) => ({
route: liveRoute(),
reduxState: timeRangeState(),
appContext: jsonBody
? {
featureFlags: [
...defaultFeatureFlags,
{
name: FeatureKeys.USE_JSON_BODY,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
}
: {},
}),
effect: ({ format }) => {
setLocalStorage(LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, 'true');

View File

@@ -85,6 +85,14 @@ export const Waiting: Story = {
args: { liveLogs: 0 },
};
/**
* A JSON-logging service tailed live: object bodies as JSON, and a body holding
* only `message` as its text, the same as the explorer shows it.
*/
export const JsonBody: Story = {
args: { jsonBody: true },
};
/** Row actions render on hover, so a streamed line is hovered before its copy link is pressed. */
export const LogLinkCopiedToast: Story = {
play: async ({ mount, canvasElement }): Promise<void> => {

View File

@@ -68,8 +68,9 @@ export const liveLogsStream = (
count: number,
severities: readonly LogSeverity[],
endMilli: number,
jsonBody = false,
): string =>
`retry: ${NO_RECONNECT_MS}\n\n${logRows(count, severities, endMilli, false)
`retry: ${NO_RECONNECT_MS}\n\n${logRows(count, severities, endMilli, jsonBody)
.map(
(row) =>
`data: ${JSON.stringify({

View File

@@ -153,6 +153,12 @@ export const logsMocks = defineStoryMocks({
'`use_json_body`: the service logs JSON and the backend hands the body over parsed, so the body is a tree and its keys are queryable as `body.x`.',
value: false,
}),
bodyColumns: toggleControl('Body field columns', {
group: LIST,
description:
'Keys of the JSON body (`order`, `order.coupons`, `http.status_code`) selected as columns beside body and timestamp. Only shows something with the JSON body on.',
value: false,
}),
filtersPanel: toggleControl('Filters panel', {
group: FILTERS,
description: 'The quick filters panel down the left of the explorer.',
@@ -291,6 +297,7 @@ export const logsMocks = defineStoryMocks({
format,
maxLines,
fontSize,
bodyColumns,
recentFilters,
}) => {
setLocalStorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART, String(frequencyChart));
@@ -301,7 +308,7 @@ export const logsMocks = defineStoryMocks({
// what the options menu opens on.
setLocalStorage(
LOCALSTORAGE.LOGS_LIST_OPTIONS,
JSON.stringify(logsListOptions({ format, maxLines, fontSize })),
JSON.stringify(logsListOptions({ format, maxLines, fontSize, bodyColumns })),
);
},
});

View File

@@ -124,6 +124,28 @@ export const JsonBody: Story = {
args: { jsonBody: true },
};
/**
* Keys of the JSON body as columns in the Column format: the nested `order`
* object and the `order.coupons` array render as JSON, `http.status_code` as
* its number.
*/
export const JsonBodyFieldsColumn: Story = {
args: { jsonBody: true, bodyColumns: true },
};
/**
* The same body keys in the Default format: each selected field under the
* body line, object and array values as JSON with no add-to-query on them.
*/
export const JsonBodyFieldsDefault: Story = {
args: { jsonBody: true, bodyColumns: true, format: 'list' },
};
/** The same body keys joined into the raw line ahead of the body. */
export const JsonBodyFieldsRaw: Story = {
args: { jsonBody: true, bodyColumns: true, format: 'raw' },
};
/** One log opened up: its own fields, then the resource that sent it. */
export const LogDetails: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -27,7 +27,11 @@ import type { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import type { IGetAttributeSuggestionsSuccessResponse } from 'types/api/queryBuilder/getAttributeSuggestions';
import type { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { MetricRangePayloadV5, TimeSeries } from 'types/api/v5/queryRange';
import type {
MetricRangePayloadV5,
TelemetryFieldKey,
TimeSeries,
} from 'types/api/v5/queryRange';
import type { ListItem } from 'types/api/widgets/getQuery';
import { dashboardsForUserResponse } from '@/storybook/msw/__story_mockdata__/dashboards';
@@ -138,6 +142,19 @@ const jsonBodyFor = (
http: { method: 'POST', route: '/api/v1/checkout', status_code: 200 },
});
/**
* Every fourth JSON-body log is what the collector makes of a plain line on a
* `use_json_body` tenant: an object holding only `message`.
*/
const jsonBodyAt = (
severity: LogSeverity,
service: string,
index: number,
): ILogBody =>
index % 4 === 3
? { message: MESSAGES[severity] }
: jsonBodyFor(severity, service, index);
export interface LogsResponseOptions {
/** `use_json_body`: the body arrives as an object instead of a line. */
jsonBody?: boolean;
@@ -169,7 +186,7 @@ export const logRows = (
data: {
id: `01JBQ${hex(index, 8).toUpperCase()}`,
body: jsonBody
? jsonBodyFor(severity, service, index)
? jsonBodyAt(severity, service, index)
: `${timestamp} ${severity} ${service} ${MESSAGES[severity]}`,
severity_text: severity,
severity_number: SEVERITY_NUMBERS[severity],
@@ -456,14 +473,36 @@ export const filterSuggestionsResponse = (
* this is what decides the format, and the columns have to be in it or the list
* loses `body` and `timestamp`.
*/
export const logsListOptions = (
options: Pick<OptionsQuery, 'format' | 'maxLines' | 'fontSize'>,
): OptionsQuery => ({
selectColumns: defaultLogsSelectedColumns,
export const logsListOptions = ({
bodyColumns = false,
...options
}: Pick<OptionsQuery, 'format' | 'maxLines' | 'fontSize'> & {
bodyColumns?: boolean;
}): OptionsQuery => ({
selectColumns: bodyColumns
? [...defaultLogsSelectedColumns, ...BODY_FIELD_COLUMNS]
: defaultLogsSelectedColumns,
version: 1,
...options,
});
/**
* Keys of the JSON body picked as columns: a nested object, an array and a
* leaf, which is what tells a list format apart on how it renders a non-string
* field value.
*/
const BODY_FIELD_COLUMNS: TelemetryFieldKey[] = [
'order',
'order.coupons',
'http.status_code',
].map((name) => ({
name,
signal: 'logs',
fieldContext: 'log',
fieldDataType: '',
isIndexed: false,
}));
const DASHBOARD_NAMES = [
'Logs overview',
'Checkout health',

View File

@@ -19,6 +19,16 @@ export function isQuoted(str: string): boolean {
return trimmed.length >= 2 && /^(["'`])(.*)\1$/.test(trimmed);
}
export type ScalarFieldValue = string | number | boolean;
export function isScalarFieldValue(value: unknown): value is ScalarFieldValue {
return (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
);
}
/**
* Coerce an arbitrary value to a string for display/storage: nullish → '',
* objects/arrays → JSON, other primitives → their string form.