mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-02 15:10:34 +01:00
Compare commits
11 Commits
inline-log
...
fix/time-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dc855b1e2 | ||
|
|
c45283d650 | ||
|
|
349318342d | ||
|
|
6d8d76e639 | ||
|
|
1865c4c333 | ||
|
|
8719f06153 | ||
|
|
a0aefd021e | ||
|
|
e14f83f911 | ||
|
|
c61b8c640c | ||
|
|
4d08341312 | ||
|
|
6c266a134f |
@@ -4,7 +4,6 @@ import { Dot } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import { Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { AIAssistantEvents } from 'container/AIAssistant/events';
|
||||
@@ -110,7 +109,7 @@ function HeaderRightSection({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<TooltipSimple title="Noz">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/** Shared hover copy for every Noz entry point (header, floating trigger, sidebar). */
|
||||
export const NOZ_TOOLTIP_TITLE = 'Noz, your AI teammate';
|
||||
@@ -5,7 +5,6 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
|
||||
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
|
||||
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
|
||||
@@ -43,15 +42,16 @@ export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<TooltipSimple title="Noz">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`${styles.trigger} noz-wave`}
|
||||
onClick={handleOpen}
|
||||
aria-label="Open Noz"
|
||||
prefix={<Noz size={24} />}
|
||||
/>
|
||||
>
|
||||
<Noz size={24} />
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useSelector } from 'react-redux';
|
||||
import getFromLocalstorage from 'api/browser/localstorage/get';
|
||||
import setToLocalstorage from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -42,7 +42,6 @@ import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { isEmpty, isUndefined } from 'lodash-es';
|
||||
import LiveLogs from 'pages/LiveLogs';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
@@ -77,7 +76,6 @@ function LogsExplorerViewsContainer({
|
||||
handleChangeSelectedView: ChangeViewFunctionType;
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [showFrequencyChart, setShowFrequencyChart] = useState(
|
||||
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
|
||||
@@ -90,10 +88,9 @@ function LogsExplorerViewsContainer({
|
||||
DEFAULT_PER_PAGE_VALUE,
|
||||
);
|
||||
|
||||
const { minTime, maxTime, selectedTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const currentMinTimeRef = useRef<number>(minTime);
|
||||
|
||||
@@ -329,16 +326,6 @@ function LogsExplorerViewsContainer({
|
||||
currentMinTimeRef.current !== minTime ||
|
||||
orderByChanged
|
||||
) {
|
||||
// Recalculate global time when query changes i.e. stage and run query clicked
|
||||
if (
|
||||
!!requestData?.id &&
|
||||
stagedQuery?.id &&
|
||||
requestData?.id !== stagedQuery?.id &&
|
||||
selectedTime !== 'custom'
|
||||
) {
|
||||
dispatch(UpdateTimeInterval(selectedTime));
|
||||
}
|
||||
|
||||
const newRequestData = getRequestData(stagedQuery, {
|
||||
filters: listQuery?.filters || initialFilters,
|
||||
filter: listQuery?.filter || { expression: '' },
|
||||
@@ -360,8 +347,6 @@ function LogsExplorerViewsContainer({
|
||||
minTime,
|
||||
activeLogId,
|
||||
selectedPanelType,
|
||||
dispatch,
|
||||
selectedTime,
|
||||
maxTime,
|
||||
orderBy,
|
||||
]);
|
||||
|
||||
@@ -108,13 +108,21 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/TopNav/DateTimeSelectionV2/index.tsx',
|
||||
() =>
|
||||
function MockDateTimeSelection(): JSX.Element {
|
||||
return <div>MockDateTimeSelection</div>;
|
||||
},
|
||||
);
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => {
|
||||
const { useQueryBuilder } = jest.requireActual(
|
||||
'hooks/queryBuilder/useQueryBuilder',
|
||||
);
|
||||
const { useSyncTimeOnStagedQueryChange } = jest.requireActual(
|
||||
'hooks/queryBuilder/useSyncTimeOnStagedQueryChange',
|
||||
);
|
||||
|
||||
return function MockDateTimeSelection(): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
useSyncTimeOnStagedQueryChange(stagedQuery?.id);
|
||||
return <div>MockDateTimeSelection</div>;
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'container/LogsExplorerChart',
|
||||
() =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Pin, PinOff } from '@signozhq/icons';
|
||||
|
||||
import { SidebarItem } from '../sideNav.types';
|
||||
|
||||
import './NavItem.styles.scss';
|
||||
import './NavItem.styles.scss';
|
||||
|
||||
export default function NavItem({
|
||||
@@ -26,7 +27,7 @@ export default function NavItem({
|
||||
showIcon?: boolean;
|
||||
dataTestId?: string;
|
||||
}): JSX.Element {
|
||||
const { label, icon, isBeta, isNew, isEarlyAccess, tooltip } = item;
|
||||
const { label, icon, isBeta, isNew, isEarlyAccess } = item;
|
||||
|
||||
const handleTogglePinClick = (
|
||||
event: React.MouseEvent<SVGSVGElement, MouseEvent>,
|
||||
@@ -35,7 +36,7 @@ export default function NavItem({
|
||||
onTogglePin?.(item);
|
||||
};
|
||||
|
||||
const navItem = (
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'nav-item',
|
||||
@@ -106,15 +107,6 @@ export default function NavItem({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Only non-pinnable items set `tooltip`; it would nest with the pin tooltip.
|
||||
return tooltip ? (
|
||||
<Tooltip title={tooltip} placement="right">
|
||||
{navItem}
|
||||
</Tooltip>
|
||||
) : (
|
||||
navItem
|
||||
);
|
||||
}
|
||||
|
||||
NavItem.defaultProps = {
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
} from './sideNav.types';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
|
||||
export const getStartedMenuItem = {
|
||||
key: ROUTES.GET_STARTED,
|
||||
@@ -98,7 +97,6 @@ export const aiAssistantMenuItem = {
|
||||
icon: <Noz size={16} />,
|
||||
itemKey: 'ai-assistant',
|
||||
isEarlyAccess: true,
|
||||
tooltip: NOZ_TOOLTIP_TITLE,
|
||||
};
|
||||
|
||||
export const shortcutMenuItem = {
|
||||
|
||||
@@ -15,8 +15,6 @@ export interface SidebarItem {
|
||||
isBeta?: boolean;
|
||||
isNew?: boolean;
|
||||
isEarlyAccess?: boolean;
|
||||
/** Hover copy for the whole item row (e.g. Noz's early-access tagline). */
|
||||
tooltip?: ReactNode;
|
||||
isPinned?: boolean;
|
||||
children?: SidebarItem[];
|
||||
isExternal?: boolean;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
useIsGlobalTimeQueryRefreshing,
|
||||
} from 'store/globalTime';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useSyncTimeOnStagedQueryChange } from 'hooks/queryBuilder/useSyncTimeOnStagedQueryChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
|
||||
import getTimeString from 'lib/getTimeString';
|
||||
@@ -187,6 +188,8 @@ function DateTimeSelection({
|
||||
|
||||
const { stagedQuery, currentQuery, initQueryBuilderData } = useQueryBuilder();
|
||||
|
||||
useSyncTimeOnStagedQueryChange(stagedQuery?.id);
|
||||
|
||||
const getInputLabel = (
|
||||
startTime?: Dayjs,
|
||||
endTime?: Dayjs,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
|
||||
import { useSyncTimeOnStagedQueryChange } from '../useSyncTimeOnStagedQueryChange';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
useDispatch: (): jest.Mock => mockDispatch,
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('store/actions', () => ({
|
||||
UpdateTimeInterval: jest.fn((time: string) => ({
|
||||
type: 'UPDATE_TIME_INTERVAL_THUNK',
|
||||
payload: time,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockedUseSelector = useSelector as jest.Mock;
|
||||
const mockedUpdateTimeInterval = UpdateTimeInterval as unknown as jest.Mock;
|
||||
|
||||
const setSelectedTime = (value: string): void => {
|
||||
mockedUseSelector.mockImplementation(
|
||||
(selector: (state: { globalTime: { selectedTime: string } }) => unknown) =>
|
||||
selector({ globalTime: { selectedTime: value } }),
|
||||
);
|
||||
};
|
||||
|
||||
describe('useSyncTimeOnStagedQueryChange', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setSelectedTime('1h');
|
||||
});
|
||||
|
||||
it('does not dispatch on initial mount when stagedQueryId is undefined', () => {
|
||||
renderHook(() => useSyncTimeOnStagedQueryChange(undefined));
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch on initial mount when stagedQueryId is already defined', () => {
|
||||
renderHook(() => useSyncTimeOnStagedQueryChange('initial-id'));
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch when stagedQueryId transitions from undefined to defined (first staged query arriving)', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: undefined as string | undefined } },
|
||||
);
|
||||
|
||||
rerender({ id: 'first-id' });
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches UpdateTimeInterval with current selectedTime when stagedQueryId changes', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'first-id' as string | undefined } },
|
||||
);
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ id: 'second-id' });
|
||||
|
||||
expect(mockedUpdateTimeInterval).toHaveBeenCalledTimes(1);
|
||||
expect(mockedUpdateTimeInterval).toHaveBeenCalledWith('1h');
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'UPDATE_TIME_INTERVAL_THUNK',
|
||||
payload: '1h',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not dispatch when selectedTime is "custom" even if stagedQueryId changes', () => {
|
||||
setSelectedTime('custom');
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'first-id' as string | undefined } },
|
||||
);
|
||||
|
||||
rerender({ id: 'second-id' });
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockedUpdateTimeInterval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch when only selectedTime changes (stagedQueryId stable)', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'stable-id' as string | undefined } },
|
||||
);
|
||||
|
||||
setSelectedTime('5m');
|
||||
rerender({ id: 'stable-id' });
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches once per distinct stagedQueryId change', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'a' as string | undefined } },
|
||||
);
|
||||
|
||||
rerender({ id: 'b' });
|
||||
rerender({ id: 'c' });
|
||||
rerender({ id: 'c' }); // no change — should not dispatch again
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not dispatch when stagedQueryId transitions from defined to undefined', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'first-id' as string | undefined } },
|
||||
);
|
||||
|
||||
rerender({ id: undefined });
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the latest selectedTime at the moment of stagedQueryId change', () => {
|
||||
const { rerender } = renderHook(
|
||||
({ id }: { id: string | undefined }) => useSyncTimeOnStagedQueryChange(id),
|
||||
{ initialProps: { id: 'a' as string | undefined } },
|
||||
);
|
||||
|
||||
setSelectedTime('15m');
|
||||
rerender({ id: 'b' });
|
||||
|
||||
expect(mockedUpdateTimeInterval).toHaveBeenCalledWith('15m');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
// Push fresh min/max back into Redux whenever the staged query changes for a
|
||||
// relative time interval. The data hooks that read minTime/maxTime from Redux
|
||||
// otherwise keep refetching with the originally frozen window and the time
|
||||
// picker displays a stale absolute range.
|
||||
// ref - SigNoz/signoz#8277
|
||||
export function useSyncTimeOnStagedQueryChange(
|
||||
stagedQueryId: string | undefined,
|
||||
): void {
|
||||
const dispatch = useDispatch();
|
||||
const selectedTime = useSelector<AppState, GlobalReducer['selectedTime']>(
|
||||
(state) => state.globalTime.selectedTime,
|
||||
);
|
||||
const prevStagedQueryIdRef = useRef<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
const prevId = prevStagedQueryIdRef.current;
|
||||
const currentId = stagedQueryId;
|
||||
prevStagedQueryIdRef.current = currentId;
|
||||
|
||||
if (
|
||||
prevId !== undefined &&
|
||||
currentId !== undefined &&
|
||||
prevId !== currentId &&
|
||||
selectedTime !== 'custom'
|
||||
) {
|
||||
dispatch(UpdateTimeInterval(selectedTime));
|
||||
}
|
||||
}, [stagedQueryId, selectedTime, dispatch]);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/contextlinks"
|
||||
@@ -86,35 +85,19 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// logSamples* bound the recent log lines we attach to a firing log-based alert.
|
||||
// They are kept small to bound the notification payload size.
|
||||
const (
|
||||
// logSamplesMaxCount is the number of most-recent matching log records sampled.
|
||||
logSamplesMaxCount = 5
|
||||
// logSampleBodyMaxLen truncates each sampled body (in runes) so a single large
|
||||
// record cannot blow up the annotation/notification.
|
||||
logSampleBodyMaxLen = 512
|
||||
)
|
||||
|
||||
// logsQueryParams extracts, for a log-based alert, the evaluation window and the
|
||||
// per-group where clause: the rule's filter combined with the breaching group's
|
||||
// label values (lbls). The same where clause backs both the related-logs link and
|
||||
// the sample-logs query, so they always refer to the same set of logs. ok is false
|
||||
// when the rule is not a single log builder query (e.g. a formula or non-logs
|
||||
// signal), in which case there is nothing to link to or sample.
|
||||
func (r *ThresholdRule) logsQueryParams(ctx context.Context, ts time.Time, lbls ruletypes.Labels) (start, end time.Time, whereClause string, ok bool) {
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
qr, err := r.prepareQueryRange(ctx, ts)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
return nil
|
||||
}
|
||||
start = time.UnixMilli(int64(qr.Start))
|
||||
end = time.UnixMilli(int64(qr.End))
|
||||
start := time.UnixMilli(int64(qr.Start))
|
||||
end := time.UnixMilli(int64(qr.End))
|
||||
|
||||
// TODO(srikanthccv): handle formula queries
|
||||
if selectedQuery < "A" || selectedQuery > "Z" {
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
return nil
|
||||
}
|
||||
|
||||
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
@@ -129,7 +112,7 @@ func (r *ThresholdRule) logsQueryParams(ctx context.Context, ts time.Time, lbls
|
||||
}
|
||||
|
||||
if q.Signal != telemetrytypes.SignalLogs {
|
||||
return time.Time{}, time.Time{}, "", false
|
||||
return nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
@@ -137,153 +120,11 @@ func (r *ThresholdRule) logsQueryParams(ctx context.Context, ts time.Time, lbls
|
||||
filterExpr = q.Filter.Expression
|
||||
}
|
||||
|
||||
whereClause = contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
|
||||
return start, end, whereClause, true
|
||||
}
|
||||
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
start, end, whereClause, ok := r.logsQueryParams(ctx, ts, lbls)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause)
|
||||
}
|
||||
|
||||
// fetchLogSamples returns up to logSamplesMaxCount of the most-recent log records
|
||||
// matching the alert's filter for the breaching group (lbls), newest first. It
|
||||
// reuses the same where clause as the related-logs link, so the samples are exactly
|
||||
// the logs that link points to.
|
||||
//
|
||||
// Sampling is best-effort enrichment: any failure is logged and yields no samples
|
||||
// rather than failing the rule evaluation.
|
||||
func (r *ThresholdRule) fetchLogSamples(ctx context.Context, ts time.Time, lbls ruletypes.Labels) []*qbtypes.RawRow {
|
||||
start, end, whereClause, ok := r.logsQueryParams(ctx, ts, lbls)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start.UnixMilli()),
|
||||
End: uint64(end.UnixMilli()),
|
||||
RequestType: qbtypes.RequestTypeRaw,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Name: "log_samples",
|
||||
Filter: &qbtypes.Filter{Expression: whereClause},
|
||||
Limit: logSamplesMaxCount,
|
||||
// timestamp,id DESC => most recent first. Both keys with an
|
||||
// identical direction are also what enables the window-list
|
||||
// fast path for raw log queries.
|
||||
Order: []qbtypes.OrderBy{
|
||||
{
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "timestamp", Materialized: true}},
|
||||
},
|
||||
{
|
||||
Direction: qbtypes.OrderDirectionDesc,
|
||||
Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "id", Materialized: true}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
NoCache: true,
|
||||
}
|
||||
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "rules",
|
||||
instrumentationtypes.CodeFunctionName: "fetchLogSamples",
|
||||
})
|
||||
|
||||
resp, err := r.querier.QueryRange(ctx, r.orgID, req)
|
||||
if err != nil {
|
||||
r.logger.WarnContext(ctx, "failed to fetch log samples for alert annotation", errors.Attr(err))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range resp.Data.Results {
|
||||
if raw, ok := item.(*qbtypes.RawData); ok {
|
||||
return raw.Rows
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatLogSamples renders sampled log records as a compact, monospaced markdown
|
||||
// block: one line per record as "[RFC3339] SEVERITY body". Records without a body
|
||||
// are skipped (mirroring Datadog), each body is collapsed to a single line and
|
||||
// truncated to logSampleBodyMaxLen. Returns "" when there is nothing to show.
|
||||
func formatLogSamples(rows []*qbtypes.RawRow) string {
|
||||
lines := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
body := strings.TrimSpace(rawRowStringField(row, "body"))
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
body = truncateRunes(logSampleSingleLine(body), logSampleBodyMaxLen)
|
||||
|
||||
var sb strings.Builder
|
||||
if !row.Timestamp.IsZero() {
|
||||
sb.WriteString("[")
|
||||
sb.WriteString(row.Timestamp.UTC().Format(time.RFC3339))
|
||||
sb.WriteString("] ")
|
||||
}
|
||||
if sev := strings.TrimSpace(rawRowStringField(row, "severity_text")); sev != "" {
|
||||
sb.WriteString(sev)
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
sb.WriteString(body)
|
||||
lines = append(lines, sb.String())
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "```\n" + strings.Join(lines, "\n") + "\n```"
|
||||
}
|
||||
|
||||
// rawRowStringField returns the named field from a raw row as a string, or "" if it
|
||||
// is absent or not a string.
|
||||
func rawRowStringField(row *qbtypes.RawRow, key string) string {
|
||||
if row == nil || row.Data == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := row.Data[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// logSampleSingleLine collapses newlines so a multi-line log body renders as one
|
||||
// line within the samples block.
|
||||
func logSampleSingleLine(s string) string {
|
||||
replacer := strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ")
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
// truncateRunes shortens s to at most max runes, appending an ellipsis when trimmed.
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
return string(runes[:max]) + "…"
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareParamsForTraces(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
|
||||
selectedQuery := r.SelectedQuery(ctx)
|
||||
|
||||
@@ -511,14 +352,6 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
r.logger.InfoContext(ctx, "adding logs link to annotations", slog.String("annotation.link", link))
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogs, Value: link})
|
||||
}
|
||||
// Attach a few recent matching log lines so responders see what fired
|
||||
// the alert without leaving the notification. Skipped for no-data
|
||||
// alerts, which by definition have no matching logs.
|
||||
if !smpl.IsMissing {
|
||||
if samples := formatLogSamples(r.fetchLogSamples(ctx, ts, smpl.Metric)); samples != "" {
|
||||
annotations = append(annotations, ruletypes.Label{Name: ruletypes.AnnotationRelatedLogsSamples, Value: samples})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lbs := lb.Labels()
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormatLogSamples(t *testing.T) {
|
||||
ts := time.Date(2026, time.June, 1, 12, 0, 3, 0, time.UTC)
|
||||
|
||||
t.Run("nil and empty yield empty string", func(t *testing.T) {
|
||||
assert.Equal(t, "", formatLogSamples(nil))
|
||||
assert.Equal(t, "", formatLogSamples([]*qbtypes.RawRow{}))
|
||||
})
|
||||
|
||||
t.Run("skips nil rows and rows without a usable body", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
nil,
|
||||
{Timestamp: ts, Data: map[string]any{"body": ""}},
|
||||
{Timestamp: ts, Data: map[string]any{"body": " "}},
|
||||
{Timestamp: ts, Data: map[string]any{"severity_text": "ERROR"}}, // no body key
|
||||
{Timestamp: ts, Data: map[string]any{"body": 42}}, // body not a string
|
||||
}
|
||||
assert.Equal(t, "", formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("renders timestamp, severity and body inside a code block", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"severity_text": "ERROR", "body": "payment failed"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] ERROR payment failed\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("omits severity when absent and collapses a multi-line body", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": "line1\nline2\r\nline3"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] line1 line2 line3\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("omits the timestamp prefix when zero", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Data: map[string]any{"body": "no ts"}},
|
||||
}
|
||||
assert.Equal(t, "```\nno ts\n```", formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("renders one line per record and preserves input order", func(t *testing.T) {
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": "first"}},
|
||||
{Timestamp: ts.Add(-time.Second), Data: map[string]any{"body": "second"}},
|
||||
}
|
||||
want := "```\n[2026-06-01T12:00:03Z] first\n[2026-06-01T12:00:02Z] second\n```"
|
||||
assert.Equal(t, want, formatLogSamples(rows))
|
||||
})
|
||||
|
||||
t.Run("truncates a long body to logSampleBodyMaxLen runes plus ellipsis", func(t *testing.T) {
|
||||
long := strings.Repeat("a", logSampleBodyMaxLen+50)
|
||||
rows := []*qbtypes.RawRow{
|
||||
{Timestamp: ts, Data: map[string]any{"body": long}},
|
||||
}
|
||||
out := formatLogSamples(rows)
|
||||
assert.Contains(t, out, strings.Repeat("a", logSampleBodyMaxLen)+"…")
|
||||
assert.NotContains(t, out, strings.Repeat("a", logSampleBodyMaxLen+1))
|
||||
})
|
||||
}
|
||||
@@ -30,13 +30,12 @@ const (
|
||||
// {{ .Annotations.value }}, {{ .Annotations.threshold.value }}, etc. in
|
||||
// their channel templates.
|
||||
const (
|
||||
AnnotationTitleTemplate = "_title_template"
|
||||
AnnotationBodyTemplate = "_body_template"
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedLogsSamples = "related_logs_samples"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationValue = "value"
|
||||
AnnotationThresholdValue = "threshold.value"
|
||||
AnnotationCompareOp = "compare_op"
|
||||
AnnotationMatchType = "match_type"
|
||||
AnnotationTitleTemplate = "_title_template"
|
||||
AnnotationBodyTemplate = "_body_template"
|
||||
AnnotationRelatedLogs = "related_logs"
|
||||
AnnotationRelatedTraces = "related_traces"
|
||||
AnnotationValue = "value"
|
||||
AnnotationThresholdValue = "threshold.value"
|
||||
AnnotationCompareOp = "compare_op"
|
||||
AnnotationMatchType = "match_type"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user