Compare commits

..

2 Commits

Author SHA1 Message Date
Jatinderjit Singh
6ffbb1ddb8 feat(rules): extract recent log samples for log-based alerts
Log-based alerts currently attach only a link to the related logs, so a
responder has to navigate to the explorer to see what fired the alert.
This extracts the most recent matching log lines during rule evaluation
and stores them in a new public `related_logs_samples` annotation,
alongside the existing `related_logs` link.

- Refactor the link's filter computation into a shared `logsQueryParams`
  helper so the samples query reuses the exact same per-group where
  clause as the related-logs link (samples == the logs the link opens).
- `fetchLogSamples` issues a RequestTypeRaw query (timestamp,id DESC,
  limit 5) for the breaching group. It is best-effort: any failure is
  logged and yields no samples rather than failing evaluation.
- `formatLogSamples` renders a compact, code-fenced block (one line per
  record), skipping empty bodies and truncating each to 512 runes.
- Skipped for no-data alerts.

Notification-channel rendering (email/Slack) is intentionally out of
scope here; the annotation already flows to webhook and custom
templates as $related_logs_samples.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:22:38 +05:30
Vishal Sharma
571e23910e feat(ai-assistant): show descriptive Noz hover tooltip on all entry points (#11526)
Noz launched under early access and its three entry points (header button,
floating trigger, sidebar nav item) previously showed a bare "Noz" tooltip,
which does not educate users who don't yet recognize the name. Replace it with
a shared descriptive tooltip ("Noz, your AI teammate") sourced from a single
constant so the surfaces never drift.

- Add NOZ_TOOLTIP_TITLE in components/Noz/Noz.constants.ts
- Header button and floating trigger read the constant
- Add optional tooltip field to SidebarItem; NavItem wraps the whole row in a
  Tooltip (non-pinnable items only, to avoid nesting with the pin tooltip)
- Move the trigger's Noz icon to the Button prefix slot to match the codebase
  convention for icon-only buttons
2026-06-02 06:53:57 +00:00
14 changed files with 304 additions and 220 deletions

View File

@@ -4,6 +4,7 @@ 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';
@@ -109,7 +110,7 @@ function HeaderRightSection({
</span>
) : null}
<TooltipSimple title="Noz">
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
<Button
variant="solid"
color="secondary"

View File

@@ -0,0 +1,2 @@
/** Shared hover copy for every Noz entry point (header, floating trigger, sidebar). */
export const NOZ_TOOLTIP_TITLE = 'Noz, your AI teammate';

View File

@@ -5,6 +5,7 @@ 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';
@@ -42,16 +43,15 @@ export default function AIAssistantTrigger(): JSX.Element | null {
}
return (
<TooltipSimple title="Noz">
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
<Button
variant="solid"
color="primary"
className={`${styles.trigger} noz-wave`}
onClick={handleOpen}
aria-label="Open Noz"
>
<Noz size={24} />
</Button>
prefix={<Noz size={24} />}
/>
</TooltipSimple>
);
}

View File

@@ -10,7 +10,7 @@ import {
useState,
} from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useDispatch, 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,6 +42,7 @@ 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';
@@ -76,6 +77,7 @@ function LogsExplorerViewsContainer({
handleChangeSelectedView: ChangeViewFunctionType;
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const dispatch = useDispatch();
const [showFrequencyChart, setShowFrequencyChart] = useState(
() => getFromLocalstorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART) === 'true',
@@ -88,9 +90,10 @@ function LogsExplorerViewsContainer({
DEFAULT_PER_PAGE_VALUE,
);
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { minTime, maxTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const currentMinTimeRef = useRef<number>(minTime);
@@ -326,6 +329,16 @@ 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: '' },
@@ -347,6 +360,8 @@ function LogsExplorerViewsContainer({
minTime,
activeLogId,
selectedPanelType,
dispatch,
selectedTime,
maxTime,
orderBy,
]);

View File

@@ -108,21 +108,13 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
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/TopNav/DateTimeSelectionV2/index.tsx',
() =>
function MockDateTimeSelection(): JSX.Element {
return <div>MockDateTimeSelection</div>;
},
);
jest.mock(
'container/LogsExplorerChart',
() =>

View File

@@ -5,7 +5,6 @@ import { Pin, PinOff } from '@signozhq/icons';
import { SidebarItem } from '../sideNav.types';
import './NavItem.styles.scss';
import './NavItem.styles.scss';
export default function NavItem({
@@ -27,7 +26,7 @@ export default function NavItem({
showIcon?: boolean;
dataTestId?: string;
}): JSX.Element {
const { label, icon, isBeta, isNew, isEarlyAccess } = item;
const { label, icon, isBeta, isNew, isEarlyAccess, tooltip } = item;
const handleTogglePinClick = (
event: React.MouseEvent<SVGSVGElement, MouseEvent>,
@@ -36,7 +35,7 @@ export default function NavItem({
onTogglePin?.(item);
};
return (
const navItem = (
<div
className={cx(
'nav-item',
@@ -107,6 +106,15 @@ 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 = {

View File

@@ -45,6 +45,7 @@ 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,
@@ -97,6 +98,7 @@ export const aiAssistantMenuItem = {
icon: <Noz size={16} />,
itemKey: 'ai-assistant',
isEarlyAccess: true,
tooltip: NOZ_TOOLTIP_TITLE,
};
export const shortcutMenuItem = {

View File

@@ -15,6 +15,8 @@ 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;

View File

@@ -19,7 +19,6 @@ 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';
@@ -188,8 +187,6 @@ function DateTimeSelection({
const { stagedQuery, currentQuery, initQueryBuilderData } = useQueryBuilder();
useSyncTimeOnStagedQueryChange(stagedQuery?.id);
const getInputLabel = (
startTime?: Dayjs,
endTime?: Dayjs,

View File

@@ -1,139 +0,0 @@
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');
});
});

View File

@@ -1,36 +0,0 @@
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]);
}

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net/url"
"reflect"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/contextlinks"
@@ -85,19 +86,35 @@ func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*q
return req, nil
}
func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time, lbls ruletypes.Labels) url.Values {
// 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) {
selectedQuery := r.SelectedQuery(ctx)
qr, err := r.prepareQueryRange(ctx, ts)
if err != nil {
return nil
return time.Time{}, time.Time{}, "", false
}
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 nil
return time.Time{}, time.Time{}, "", false
}
var q qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
@@ -112,7 +129,7 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
}
if q.Signal != telemetrytypes.SignalLogs {
return nil
return time.Time{}, time.Time{}, "", false
}
filterExpr := ""
@@ -120,11 +137,153 @@ func (r *ThresholdRule) prepareParamsForLogs(ctx context.Context, ts time.Time,
filterExpr = q.Filter.Expression
}
whereClause := contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
whereClause = contextlinks.PrepareFilterExpression(lbls.Map(), filterExpr, q.GroupBy)
return start, end, whereClause, true
}
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)
@@ -352,6 +511,14 @@ 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()

View File

@@ -0,0 +1,72 @@
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))
})
}

View File

@@ -30,12 +30,13 @@ const (
// {{ .Annotations.value }}, {{ .Annotations.threshold.value }}, etc. in
// their channel templates.
const (
AnnotationTitleTemplate = "_title_template"
AnnotationBodyTemplate = "_body_template"
AnnotationRelatedLogs = "related_logs"
AnnotationRelatedTraces = "related_traces"
AnnotationValue = "value"
AnnotationThresholdValue = "threshold.value"
AnnotationCompareOp = "compare_op"
AnnotationMatchType = "match_type"
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"
)