Compare commits

..

11 Commits

Author SHA1 Message Date
Gaurav Tewari
4dc855b1e2 Merge branch 'main' into fix/time-picker-issue-in-traces 2026-06-02 11:34:25 +05:30
Gaurav Tewari
c45283d650 fix: test cases 2026-06-02 11:21:49 +05:30
Gaurav Tewari
349318342d fix: add test cases 2026-06-02 11:17:16 +05:30
Gaurav Tewari
6d8d76e639 refactor: code 2026-06-02 11:10:16 +05:30
Gaurav Tewari
1865c4c333 Merge branch 'main' into fix/time-picker-issue-in-traces 2026-05-29 12:49:38 +05:30
Gaurav Tewari
8719f06153 fix: test cases 2026-05-26 20:12:40 +05:30
Gaurav Tewari
a0aefd021e Merge branch 'main' into fix/time-picker-issue-in-traces 2026-05-26 20:09:38 +05:30
Gaurav Tewari
e14f83f911 fix: test cases 2026-05-26 20:01:19 +05:30
Gaurav Tewari
c61b8c640c chore: more refactor 2026-05-26 19:33:39 +05:30
Gaurav Tewari
4d08341312 refactor: make a hook 2026-05-26 19:19:11 +05:30
Gaurav Tewari
6c266a134f fix: time not being updated issue in domainlist and traces 2026-05-26 19:03:35 +05:30
14 changed files with 220 additions and 304 deletions

View File

@@ -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"

View File

@@ -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';

View File

@@ -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>
);
}

View File

@@ -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,
]);

View File

@@ -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',
() =>

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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');
});
});

View File

@@ -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]);
}

View File

@@ -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()

View File

@@ -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))
})
}

View File

@@ -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"
)