mirror of
https://github.com/SigNoz/signoz.git
synced 2026-11-04 15:31:08 +00:00
Compare commits
8 Commits
test/qb-e2
...
feat/log-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fe2308c0f | ||
|
|
8cfe2595bf | ||
|
|
3de9a0196e | ||
|
|
e21a7e71c5 | ||
|
|
a609fabd69 | ||
|
|
11d20d71ff | ||
|
|
265ea49b36 | ||
|
|
aa653f9221 |
15
.claude/rules/log-body.md
Normal file
15
.claude/rules/log-body.md
Normal 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.
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const isValidLogField = (value: never): boolean => value !== undefined;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
10
frontend/src/hooks/logs/useIsBodyJsonEnabled.ts
Normal file
10
frontend/src/hooks/logs/useIsBodyJsonEnabled.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
72
frontend/src/lib/logs/logBody.test.ts
Normal file
72
frontend/src/lib/logs/logBody.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
33
frontend/src/lib/logs/logBody.ts
Normal file
33
frontend/src/lib/logs/logBody.ts
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -77,5 +77,6 @@ export interface PanelResponseArgs extends PanelRequest {
|
||||
result: PanelResult;
|
||||
series: number;
|
||||
listRows: number;
|
||||
listJsonBody: boolean;
|
||||
gaps: Gaps;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user