Compare commits

..

2 Commits

Author SHA1 Message Date
Gaurav Tewari
605105b962 test(ai-assistant): cover /clarify rejection 2026-11-04 11:58:02 +05:30
Gaurav Tewari
35f6ecb128 fix(ai-assistant): keep clarification form open when /clarify fails
Retries re-issue /clarify; a dead execution can't be re-attached.
2026-11-04 10:53:46 +05:30
46 changed files with 456 additions and 1452 deletions

View File

@@ -18,7 +18,6 @@ function InputWithLabel({
className,
closeIcon,
disabled,
sanitize,
}: {
label: string;
initialValue?: string | number | null;
@@ -30,17 +29,14 @@ function InputWithLabel({
className?: string;
closeIcon?: React.ReactNode;
disabled?: boolean;
/** Rewrites the typed value before it is shown and reported. */
sanitize?: (value: string) => string;
}): JSX.Element {
const [inputValue, setInputValue] = useState<string>(
initialValue ? initialValue.toString() : '',
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const value = sanitize ? sanitize(e.target.value) : e.target.value;
setInputValue(value);
onChange?.(value);
setInputValue(e.target.value);
onChange?.(e.target.value);
};
return (
@@ -82,7 +78,6 @@ InputWithLabel.defaultProps = {
initialValue: undefined,
className: undefined,
closeIcon: undefined,
sanitize: undefined,
};
export default InputWithLabel;

View File

@@ -26,7 +26,6 @@ import {
RAW_QUERY_FIELDS,
resolveQueryBuilderFields,
} from '../../queryBuilderFields.utils';
import { parseLimit, sanitizeLimit } from '../../utils';
import BucketOptions from './BucketOptions/BucketOptions';
import HavingFilter from './HavingFilter/HavingFilter';
@@ -279,7 +278,7 @@ function QueryAddOns({
const handleChangeLimit = useCallback(
(value: string) => {
handleChangeQueryData('limit', parseLimit(value));
handleChangeQueryData('limit', Number(value) || null);
},
[handleChangeQueryData],
);
@@ -375,7 +374,6 @@ function QueryAddOns({
label="Limit"
type="number"
onChange={handleChangeLimit}
sanitize={sanitizeLimit}
initialValue={query?.limit ?? undefined}
placeholder={placeholderOf(QueryBuilderField.Limit)}
onClose={(): void => handleRemoveView(QueryBuilderField.Limit)}

View File

@@ -169,15 +169,8 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(input.value).toBe('5');
const user = userEvent.setup();
await user.clear(input);
await user.type(input, '10');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 10);
await user.clear(input);
await user.type(input, '-5');
expect(input.value).toBe('5');
expect(mockHandleChangeQueryData).toHaveBeenLastCalledWith('limit', 5);
fireEvent.change(input, { target: { value: '10' } });
expect(mockHandleChangeQueryData).toHaveBeenCalledWith('limit', 10);
});
it.each([

View File

@@ -12,62 +12,10 @@ import {
convertExpressionToFilters,
convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
formatValueForExpression,
parseLimit,
removeKeysFromExpression,
sanitizeLimit,
} from '../utils';
describe('convertHavingToExpression', () => {
it.each([
['COUNT()', 'count() > 5'],
['COUNT_DISTINCT(service.name)', 'count_distinct(service.name) > 5'],
['P99(duration_nano)', 'p99(duration_nano) > 5'],
['SUM(http.Request.Size)', 'sum(http.Request.Size) > 5'],
['count()', 'count() > 5'],
])('references %s by its lower-case function', (columnName, expected) => {
expect(
convertHavingToExpression([{ columnName, op: '>', value: 5 }]),
).toStrictEqual({ expression: expected });
});
it('joins several conditions and formats list values', () => {
expect(
convertHavingToExpression([
{ columnName: 'COUNT()', op: '>', value: 5 },
{ columnName: 'COUNT()', op: 'NOT IN', value: [1, 2] },
]),
).toStrictEqual({ expression: 'count() > 5 AND count() NOT IN [1, 2]' });
});
});
describe('sanitizeLimit', () => {
it.each([
['10', '10'],
['-10', '10'],
['2.5', '25'],
['1e3', '13'],
['', ''],
])('sanitizes %j to %j', (value, expected) => {
expect(sanitizeLimit(value)).toBe(expected);
});
});
describe('parseLimit', () => {
it.each([
['10', 10],
['1', 1],
['', null],
['0', null],
['-5', null],
['2.5', null],
['abc', null],
])('parses %j as %j', (value, expected) => {
expect(parseLimit(value)).toBe(expected);
});
});
describe('convertFiltersToExpression', () => {
beforeEach(() => {
jest.clearAllMocks();

View File

@@ -840,16 +840,6 @@ export const appendAndClause = (
return hasTopLevelOr ? `(${base}) AND ${clause}` : `${base} AND ${clause}`;
};
/** Strips everything but digits, so a limit input can't hold a sign or decimal. */
export const sanitizeLimit = (value: string): string =>
value.replace(/\D/g, '');
/** A query or formula limit: a positive integer, otherwise none. */
export const parseLimit = (value: string): number | null => {
const limit = Number(value);
return Number.isInteger(limit) && limit > 0 ? limit : null;
};
/**
* Convert old having format to new having format
* @param having - Array of old having objects with columnName, op, and value
@@ -881,14 +871,7 @@ export const convertHavingToExpression = (
formattedValue = String(value);
}
// The old having filter upper-cased the function (`COUNT()`); v5 only
// resolves the aggregation's own lower-case expression.
const reference = columnName.replace(
/^\s*([A-Za-z_]\w*)\(/,
(_, fn: string) => `${fn.toLowerCase()}(`,
);
return `${reference} ${op} ${formattedValue}`;
return `${columnName} ${op} ${formattedValue}`;
})
.filter((expression) => expression !== ''); // Remove empty expressions

View File

@@ -26,7 +26,6 @@ import {
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { buildExplorerNavigationUrl } from 'utils/buildExplorerNavigationUrl';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -48,10 +47,6 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from './utils/getPanelTypeForRequestType';
import { openSavedViewByKey } from './utils/openSavedView';
import {
isSavedViewOpenAction,
@@ -338,11 +333,6 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
);
return;
}
// Read before toUrlCompositeQuery, which drops the requestType envelope.
const panelType = getPanelTypeForRequestType(
requestTypeFromActionQuery(action.query),
action.signal,
);
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
@@ -352,9 +342,7 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized, {
[QueryParams.panelTypes]: panelType,
});
deps.redirectWithQueryBuilderData(normalized);
return;
}
const base = explorerRouteForSignal(action.signal);
@@ -365,10 +353,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → navigate', base);
const url = buildExplorerNavigationUrl(base, normalized, {
[QueryParams.panelTypes]: panelType,
});
navigate(url);
const encoded = encodeURIComponent(JSON.stringify(normalized));
navigate(`${base}?${QueryParams.compositeQuery}=${encoded}`);
}
/** Picks the right rollback API call for a given action kind. */
@@ -627,7 +613,6 @@ export default function ActionsSection({
disabled={isSuccess}
loading={isLoading}
aria-label={action.label}
testId={`ai-action-chip-${action.kind}`}
prefix={icon}
>
<span className={styles.chipLabel}>{action.label}</span>

View File

@@ -1,16 +1,7 @@
import { undoExecution } from 'api/ai-assistant/chat';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getCurrentLocation } from 'lib/router/navigation';
import { MessageActionKindDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QueryBuilderContextType } from 'types/common/queryBuilder';
import ActionsSection from '../ActionsSection';
@@ -37,37 +28,6 @@ function renderUndoChip(): HTMLElement {
return screen.getByRole('button', { name: 'Undo change' });
}
// Labels differ per test: auto-applied filters are deduped module-wide by label.
function countByServiceAction(label: string): MessageActionDTO {
return {
kind: MessageActionKindDTO.apply_filter,
label,
signal: ApplyFilterSignalDTO.logs,
query: {
requestType: Querybuildertypesv5RequestTypeDTO.scalar,
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
name: 'A',
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
},
};
}
function currentPanelTypesParam(): string | null {
return new URLSearchParams(getCurrentLocation().search).get(
QueryParams.panelTypes,
);
}
describe('ActionsSection rollback chip', () => {
beforeEach(() => {
mockUndo.mockReset();
@@ -121,52 +81,3 @@ describe('ActionsSection rollback chip', () => {
).resolves.toBeInTheDocument();
});
});
describe('ActionsSection apply_filter', () => {
it('opens the explorer in the Table view when the chip is clicked from another page', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<ActionsSection
actions={[countByServiceAction('Open in Logs Explorer')]}
messageId="message-1"
/>,
undefined,
{ initialRoute: ROUTES.HOME },
);
await user.click(screen.getByTestId('ai-action-chip-apply_filter'));
await waitFor(() =>
expect(getCurrentLocation().pathname).toBe(ROUTES.LOGS_EXPLORER),
);
expect(currentPanelTypesParam()).toBe(JSON.stringify(PANEL_TYPES.TABLE));
});
it('switches the open explorer to the Table view when applied on it', async () => {
const redirectWithQueryBuilderData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['redirectWithQueryBuilderData']
>;
const handleSetQueryData = jest.fn() as jest.MockedFunction<
QueryBuilderContextType['handleSetQueryData']
>;
render(
<ActionsSection
actions={[countByServiceAction('Apply on Logs Explorer')]}
messageId="message-2"
/>,
undefined,
{
initialRoute: ROUTES.LOGS_EXPLORER,
queryBuilderOverrides: { redirectWithQueryBuilderData, handleSetQueryData },
},
);
await waitFor(() =>
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1),
);
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({ builder: expect.anything() }),
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
});
});

View File

@@ -1,106 +0,0 @@
import { ApplyFilterSignalDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getPanelTypeForRequestType,
requestTypeFromActionQuery,
} from '../getPanelTypeForRequestType';
describe('getPanelTypeForRequestType', () => {
it.each([
[
Querybuildertypesv5RequestTypeDTO.scalar,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TABLE,
],
[
Querybuildertypesv5RequestTypeDTO.time_series,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.TIME_SERIES,
],
[
Querybuildertypesv5RequestTypeDTO.raw,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.traces,
PANEL_TYPES.TRACE,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.logs,
PANEL_TYPES.LIST,
],
[
Querybuildertypesv5RequestTypeDTO.trace,
ApplyFilterSignalDTO.metrics,
PANEL_TYPES.LIST,
],
])('maps %s on the %s signal to the %s view', (requestType, signal, view) => {
expect(getPanelTypeForRequestType(requestType, signal)).toBe(view);
});
it.each([
undefined,
null,
'',
Querybuildertypesv5RequestTypeDTO.raw_stream,
Querybuildertypesv5RequestTypeDTO.heatmap,
'distribution',
'nonsense',
'toString',
'constructor',
42,
{},
])(
'falls back to the List view for unmapped or invalid requestType (%p)',
(value) => {
expect(getPanelTypeForRequestType(value, ApplyFilterSignalDTO.traces)).toBe(
PANEL_TYPES.LIST,
);
},
);
});
describe('requestTypeFromActionQuery', () => {
it('reads the requestType envelope field and returns undefined when absent', () => {
expect(
requestTypeFromActionQuery({
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: { queries: [] },
}),
).toBe('scalar');
expect(requestTypeFromActionQuery({})).toBeUndefined();
expect(requestTypeFromActionQuery(null)).toBeUndefined();
expect(requestTypeFromActionQuery(undefined)).toBeUndefined();
});
it('maps a grouped count() payload to the Table view', () => {
const query = {
requestType: 'scalar',
schemaVersion: 'v5',
compositeQuery: {
queries: [
{
type: 'builder_query',
spec: {
signal: 'logs',
aggregations: [{ expression: 'count()' }],
groupBy: [{ name: 'service.name' }],
},
},
],
},
};
expect(
getPanelTypeForRequestType(
requestTypeFromActionQuery(query),
ApplyFilterSignalDTO.logs,
),
).toBe(PANEL_TYPES.TABLE);
});
});

View File

@@ -1,30 +0,0 @@
import {
ApplyFilterSignalDTO,
type MessageActionDTOQuery,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { Querybuildertypesv5RequestTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
export function getPanelTypeForRequestType(
requestType: unknown,
signal: ApplyFilterSignalDTO,
): PANEL_TYPES {
switch (requestType) {
case Querybuildertypesv5RequestTypeDTO.scalar:
return PANEL_TYPES.TABLE;
case Querybuildertypesv5RequestTypeDTO.time_series:
return PANEL_TYPES.TIME_SERIES;
case Querybuildertypesv5RequestTypeDTO.trace:
return signal === ApplyFilterSignalDTO.traces
? PANEL_TYPES.TRACE
: PANEL_TYPES.LIST;
default:
return PANEL_TYPES.LIST;
}
}
export function requestTypeFromActionQuery(
query: MessageActionDTOQuery | undefined,
): unknown {
return query?.requestType;
}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { Callout } from '@signozhq/ui/callout';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
import {
@@ -56,11 +57,13 @@ export default function ClarificationForm({
);
const [answers, setAnswers] =
useState<Record<string, unknown>>(initialAnswers);
const [submitted, setSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [cancelled, setCancelled] = useState(false);
const setField = (id: string, value: unknown): void => {
setAnswers((prev) => ({ ...prev, [id]: value }));
setSubmitError(null);
};
const isFormValid = fields.every(
@@ -68,10 +71,11 @@ export default function ClarificationForm({
);
const handleSubmit = async (): Promise<void> => {
if (!isFormValid) {
if (!isFormValid || isSubmitting) {
return;
}
setSubmitted(true);
setSubmitError(null);
setIsSubmitting(true);
// Approximate queryLength as the JSON encoding of the form answers — the
// clarification API doesn't render a single user-visible string, but the
// JSON size is a reasonable stand-in for "how much did the user provide".
@@ -84,11 +88,15 @@ export default function ClarificationForm({
hasContext: false,
respondingToClarification: true,
});
await submitClarification(
const error = await submitClarification(
conversationId,
clarification.clarificationId,
answers,
);
if (error) {
setSubmitError(error);
setIsSubmitting(false);
}
};
const handleCancel = (): void => {
@@ -100,15 +108,6 @@ export default function ClarificationForm({
cancelStream(conversationId);
};
if (submitted) {
return (
<div className={cx(styles.clarification, styles.submitted)}>
<Send size={13} className={styles.icon} />
<span className={styles.statusText}>Answers submitted — resuming…</span>
</div>
);
}
if (cancelled) {
return (
<div className={cx(styles.clarification, styles.submitted)}>
@@ -138,6 +137,15 @@ export default function ClarificationForm({
))}
</div>
{submitError && (
<Callout
type="error"
size="small"
title={submitError}
testId="clarification-submit-error"
/>
)}
<div className={styles.actions}>
<Button
disabledTooltip={
@@ -150,6 +158,8 @@ export default function ClarificationForm({
color="primary"
onClick={handleSubmit}
disabled={isStreaming || !isFormValid}
loading={isSubmitting}
testId="clarification-submit"
prefix={<Send />}
>
Submit
@@ -160,7 +170,7 @@ export default function ClarificationForm({
variant="outlined"
color="secondary"
onClick={handleCancel}
disabled={isStreaming}
disabled={isStreaming || isSubmitting}
prefix={<X />}
>
Cancel request

View File

@@ -0,0 +1,50 @@
import {
type ClarificationEventDTO,
ClarificationFieldTypeDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import {
type AIAssistantStore,
useAIAssistantStore,
} from '../../../store/useAIAssistantStore';
import ClarificationForm from '../ClarificationForm';
const VALIDATION_MESSAGE = 'Clarification field threshold must be a number.';
const CLARIFICATION: ClarificationEventDTO = {
executionId: 'exec-1',
clarificationId: 'clar-1',
message: 'A few details needed',
fields: [
{
id: 'threshold',
type: ClarificationFieldTypeDTO.text,
label: 'Threshold',
required: true,
},
],
};
describe('ClarificationForm', () => {
it('keeps the answers and shows the error inline when submitting fails', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const submitClarification = jest.fn() as jest.MockedFunction<
AIAssistantStore['submitClarification']
>;
submitClarification.mockResolvedValueOnce(VALIDATION_MESSAGE);
useAIAssistantStore.setState({ submitClarification });
render(
<ClarificationForm conversationId="conv-1" clarification={CLARIFICATION} />,
);
await user.type(screen.getByLabelText(/threshold/i), 'five');
await user.click(screen.getByTestId('clarification-submit'));
await expect(
screen.findByTestId('clarification-submit-error'),
).resolves.toHaveTextContent(VALIDATION_MESSAGE);
expect(screen.getByLabelText(/threshold/i)).toHaveValue('five');
expect(screen.getByTestId('clarification-submit')).toBeEnabled();
});
});

View File

@@ -0,0 +1,75 @@
import { AxiosError, AxiosHeaders, type AxiosResponse } from 'axios';
import { clarifyExecution, streamEvents } from 'api/ai-assistant/chat';
import {
type ClarificationEventDTO,
ClarificationFieldTypeDTO,
ErrorCodeDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { useAIAssistantStore } from '../useAIAssistantStore';
jest.mock('api/ai-assistant/chat');
const VALIDATION_MESSAGE = 'Clarification field threshold must be a number.';
const CLARIFICATION: ClarificationEventDTO = {
executionId: 'exec-1',
clarificationId: 'clar-1',
message: 'A few details needed',
fields: [
{
id: 'threshold',
type: ClarificationFieldTypeDTO.number,
label: 'Threshold',
required: true,
},
],
};
function validationError(): AxiosError {
const response = {
status: 400,
statusText: 'Bad Request',
headers: {},
config: { headers: new AxiosHeaders() },
data: {
error: { code: ErrorCodeDTO.validation_error, message: VALIDATION_MESSAGE },
},
} as AxiosResponse;
return new AxiosError('Bad Request', '400', undefined, undefined, response);
}
function startAwaitingClarification(): string {
const convId = useAIAssistantStore.getState().startNewConversation();
useAIAssistantStore.setState((s) => {
s.streams[convId] = {
isStreaming: false,
streamingContent: '',
streamingStatus: 'awaiting_clarification',
streamingEvents: [],
streamingMessageId: null,
streamingActions: null,
pendingApproval: null,
pendingClarification: CLARIFICATION,
};
});
return convId;
}
describe('useAIAssistantStore — submitClarification', () => {
it('keeps the form and returns the backend message when /clarify fails', async () => {
jest.mocked(clarifyExecution).mockRejectedValueOnce(validationError());
const convId = startAwaitingClarification();
const error = await useAIAssistantStore
.getState()
.submitClarification(convId, 'clar-1', { threshold: 'abc' });
expect(error).toBe(VALIDATION_MESSAGE);
const { streams, conversations } = useAIAssistantStore.getState();
expect(streams[convId].pendingClarification).toStrictEqual(CLARIFICATION);
expect(streams[convId].isStreaming).toBe(false);
expect(conversations[convId].messages).toHaveLength(0);
expect(streamEvents).not.toHaveBeenCalled();
});
});

View File

@@ -659,11 +659,12 @@ export interface AIAssistantStore {
) => Promise<void>;
approveAction: (conversationId: string, approvalId: string) => Promise<void>;
rejectAction: (conversationId: string, approvalId: string) => Promise<void>;
/** Resolves with an error message when /clarify rejects the answers, which keeps the form open. */
submitClarification: (
conversationId: string,
clarificationId: string,
answers: Record<string, unknown>,
) => Promise<void>;
) => Promise<string | null>;
cancelStream: (conversationId: string) => void;
regenerateAssistantMessage: (
conversationId: string,
@@ -1401,7 +1402,17 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
conversationId: string,
clarificationId: string,
answers: Record<string, unknown>,
): Promise<void> => {
): Promise<string | null> => {
const fallback =
'Something went wrong while processing your answers. Please try again.';
let acceptedExecutionId: string | null;
try {
acceptedExecutionId = await clarifyExecution(clarificationId, answers);
} catch (err) {
console.error('[AIAssistant] submitClarification failed:', err);
return resolveAssistantError(err, fallback).message;
}
set((s) => {
const st = s.streams[conversationId];
if (st) {
@@ -1413,17 +1424,26 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
}
});
// Streams the accepted execution first; retries re-issue /clarify for a fresh one.
const start = (): Promise<string> => {
const id = acceptedExecutionId;
acceptedExecutionId = null;
return id
? Promise.resolve(id)
: clarifyExecution(clarificationId, answers);
};
const run = (): Promise<void> =>
streamAndFinalize(
conversationId,
() => clarifyExecution(clarificationId, answers),
'Something went wrong while processing your answers. Please try again.',
start,
fallback,
'[AIAssistant] submitClarification failed:',
set,
get,
run,
);
await run();
return null;
},
retryAssistantMessage: async (conversationId: string): Promise<void> => {

View File

@@ -2,7 +2,6 @@ import { ChangeEvent, useCallback, useMemo, useState } from 'react';
import { Col, Input, Row, Select } from 'antd';
import InputWithLabel from 'components/InputWithLabel/InputWithLabel';
import BucketOptions from 'components/QueryBuilderV2/QueryV2/QueryAddOns/BucketOptions/BucketOptions';
import { parseLimit, sanitizeLimit } from 'components/QueryBuilderV2/utils';
import { LEGEND } from 'constants/global';
import { PANEL_TYPES } from 'constants/queryBuilder';
// ** Hooks
@@ -172,8 +171,7 @@ export function Formula({
<InputWithLabel
label="Limit"
type="number"
onChange={(value): void => handleChangeLimit(parseLimit(value))}
sanitize={sanitizeLimit}
onChange={(value): void => handleChangeLimit(Number(value))}
initialValue={formula?.limit ?? undefined}
placeholder="Enter limit"
/>

View File

@@ -33,13 +33,6 @@
flex-shrink: 0;
}
.aiDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-primary);
}
.filterSection {
display: flex;
align-items: center;

View File

@@ -137,7 +137,7 @@ function TraceDetailsHeader({
badgeValue={traceID}
maxCharacters={100}
/>
<TraceDetailsTabs hasAiSummary={!!traceSummary?.ai} />
<TraceDetailsTabs />
</div>
)}
{isOverview && showTraceDetailsHeaderOptions && traceSummary && (

View File

@@ -8,13 +8,20 @@ import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import styles from './TraceDetailsHeader.module.scss';
interface TraceDetailsTabsProps {
hasAiSummary: boolean;
}
const TAB_ITEMS = [
{
value: TraceDetailsTab.Overview,
label: 'Overview',
testId: 'trace-details-tab-overview',
},
{
value: TraceDetailsTab.Thread,
label: 'Thread',
testId: 'trace-details-tab-thread',
},
];
function TraceDetailsTabs({
hasAiSummary,
}: TraceDetailsTabsProps): JSX.Element {
function TraceDetailsTabs(): JSX.Element {
const { id: traceId = '' } = useAppParams<'id'>();
const [tab, setTab] = useTraceDetailsTab();
const logTraceEvent = useTraceDetailLogEvent('v3', traceId);
@@ -28,26 +35,6 @@ function TraceDetailsTabs({
void setTab(value as TraceDetailsTab, { history: 'push' });
};
const items = [
{
value: TraceDetailsTab.Overview,
label: 'Overview',
testId: 'trace-details-tab-overview',
},
{
value: TraceDetailsTab.Thread,
label: 'Thread',
testId: 'trace-details-tab-thread',
suffix: hasAiSummary ? (
<span
className={styles.aiDot}
aria-hidden
data-testid="trace-details-tab-thread-ai-dot"
/>
) : undefined,
},
];
return (
<div className={styles.tabs}>
<ToggleGroup
@@ -57,7 +44,7 @@ function TraceDetailsTabs({
value={tab}
onChange={handleChange}
size="sm"
items={items}
items={TAB_ITEMS}
/>
</div>
);

View File

@@ -394,27 +394,6 @@ describe('TraceDetailsHeader – tabs', () => {
expect(screen.getByText(metadataText)).toBeInTheDocument();
});
it('shows the AI dot on the Thread tab only when the summary has an AI block', () => {
renderHeader();
expect(
screen.queryByTestId('trace-details-tab-thread-ai-dot'),
).not.toBeInTheDocument();
jest.mocked(useTraceSummary).mockReturnValue({
data: {
...SUMMARY,
ai: {
tokens: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
},
},
isLoading: false,
});
renderHeader();
expect(
screen.getByTestId('trace-details-tab-thread-ai-dot'),
).toBeInTheDocument();
});
it('clears the filter result when switching to the Thread tab', async () => {
const user = userEvent.setup({ delay: null });
renderHeader();

View File

@@ -9,7 +9,7 @@ import { useSpanDetailVariant } from '../hooks/useSpanDetailVariant';
import { useTraceDetailLogEvent } from '../hooks/useTraceDetailLogEvent';
import { useTraceDetailsTab } from '../hooks/useTraceDetailsTab';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import { useThreadLoadedEvents } from './hooks/useThreadLoadedEvents';
import { useThreadDataLoadedEvent } from './hooks/useThreadDataLoadedEvent';
import { useThreadSpanSelection } from './hooks/useThreadSpanSelection';
import { useThreadView } from './hooks/useThreadView';
import { useTraceThread } from './hooks/useTraceThread';
@@ -30,12 +30,10 @@ const INITIAL_LOADER_COUNT = 4;
interface TraceDetailsThreadProps {
// Owned by the page so the load event fires once per trace, not per tab visit.
dataLoadedFiredForRef: MutableRefObject<string>;
threadEmptyFiredForRef: MutableRefObject<string>;
}
function TraceDetailsThread({
dataLoadedFiredForRef,
threadEmptyFiredForRef,
}: TraceDetailsThreadProps): JSX.Element {
const { id: traceId } = useAppParams<'id'>();
const [, setTab] = useTraceDetailsTab();
@@ -70,24 +68,17 @@ function TraceDetailsThread({
const interestedSpanIndex = spans.findIndex(
(span) => span.span_id === interestedSpanId,
);
const hasNoThread =
!isLoading &&
interestedSpanStatus !== InterestedSpanStatus.NotFound &&
(isError || spans.length === 0);
const selectedSpan = spans.find((span) => span.span_id === selectedSpanId);
const [spanDetailVariant, setSpanDetailVariant] = useSpanDetailVariant();
const isBottomDocked = spanDetailVariant === SpanDetailVariant.DOCKED;
const logTraceEvent = useTraceDetailLogEvent('v3', traceId || '');
useThreadLoadedEvents({
useThreadDataLoadedEvent({
traceId: traceId || '',
interestedSpanId,
hasSpans: spans.length > 0,
hasNoThread,
spanPanelVariant: spanDetailVariant,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
firedForRef: dataLoadedFiredForRef,
logTraceEvent,
});
useSpanDetailsOpenedEvent({
@@ -105,6 +96,10 @@ function TraceDetailsThread({
onVariantChange={setSpanDetailVariant}
/>
);
const hasNoThread =
!isLoading &&
interestedSpanStatus !== InterestedSpanStatus.NotFound &&
(isError || spans.length === 0);
const renderBody = (): JSX.Element => {
if (isLoading) {

View File

@@ -1,135 +0,0 @@
import { MutableRefObject, ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import {
GetTraceSummary200,
SpantypesGettableTraceSummaryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getGetTraceSummaryQueryKey } from 'api/generated/services/tracedetail';
import { rest, server } from 'mocks-server/server';
import { renderHook, waitFor } from 'tests/test-utils';
import { TraceDetailLogEvent } from '../../../hooks/useTraceDetailLogEvent';
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
import { useThreadLoadedEvents } from '../useThreadLoadedEvents';
const BASE_SUMMARY: SpantypesGettableTraceSummaryDTO = {
startTimestampMillis: 1_000,
endTimestampMillis: 4_000,
totalSpansCount: 12,
totalErrorSpansCount: 2,
hasMissingSpans: true,
rootServiceEntryPoint: 'POST /chat',
rootServiceName: 'agent',
rootSpanStatusCode: 'STATUS_CODE_ERROR',
};
const SUMMARIES: Record<string, SpantypesGettableTraceSummaryDTO> = {
'trace-ai': {
...BASE_SUMMARY,
ai: {
tokens: {
input: 1_200,
output: 300,
cacheRead: 50,
cacheWrite: 10,
reasoning: 40,
},
totalCost: 0.0123,
},
},
'trace-ai-next': {
...BASE_SUMMARY,
endTimestampMillis: 1_500,
totalSpansCount: 4,
ai: {
tokens: { input: 80, output: 20, cacheRead: 0, cacheWrite: 0, reasoning: 0 },
totalCost: 0.0004,
},
},
'trace-no-ai': BASE_SUMMARY,
};
export interface ThreadLoadedEventsProps {
traceId: string;
hasSpans?: boolean;
hasNoThread?: boolean;
interestedSpanId?: string;
spanPanelVariant?: SpanDetailVariant;
}
interface RenderOptions {
dataLoadedFiredForRef?: MutableRefObject<string>;
threadEmptyFiredForRef?: MutableRefObject<string>;
queryClient?: QueryClient;
}
interface RenderResult {
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
rerender: (props: ThreadLoadedEventsProps) => void;
unmount: () => void;
queryClient: QueryClient;
}
/** Serves the fixture summaries and returns the trace ids requested, in order. */
export function mockTraceSummaries(): string[] {
const requests: string[] = [];
server.use(
rest.get('*/api/v1/traces/:traceId/summary', (req, res, ctx) => {
requests.push(req.params.traceId as string);
const body: GetTraceSummary200 = {
status: 'success',
data: SUMMARIES[req.params.traceId as string],
};
return res(ctx.status(200), ctx.json(body));
}),
);
return requests;
}
export function renderThreadLoadedEvents(
initialProps: ThreadLoadedEventsProps,
{
dataLoadedFiredForRef = { current: '' },
threadEmptyFiredForRef = { current: '' },
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
}),
}: RenderOptions = {},
): RenderResult {
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { rerender, unmount } = renderHook(
({
traceId,
hasSpans = false,
hasNoThread = false,
interestedSpanId,
spanPanelVariant = SpanDetailVariant.DOCKED_RIGHT,
}: ThreadLoadedEventsProps) =>
useThreadLoadedEvents({
traceId,
interestedSpanId,
hasSpans,
hasNoThread,
spanPanelVariant,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
logTraceEvent,
}),
{ initialProps, wrapper },
);
return { logTraceEvent, rerender, unmount, queryClient };
}
export async function waitForSummary(
queryClient: QueryClient,
traceId: string,
): Promise<void> {
await waitFor(() =>
expect(
queryClient.getQueryData(getGetTraceSummaryQueryKey({ traceID: traceId })),
).toBeDefined(),
);
}

View File

@@ -0,0 +1,174 @@
import { MutableRefObject, ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import {
GetTraceSummary200,
SpantypesGettableTraceSummaryDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getGetTraceSummaryQueryKey } from 'api/generated/services/tracedetail';
import { rest, server } from 'mocks-server/server';
import { renderHook, waitFor } from 'tests/test-utils';
import { TraceDetailsTab } from '../../../constants';
import { TraceDetailEvents } from '../../../events';
import { TraceDetailLogEvent } from '../../../hooks/useTraceDetailLogEvent';
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
import { useThreadDataLoadedEvent } from '../useThreadDataLoadedEvent';
const SUMMARIES: Record<string, SpantypesGettableTraceSummaryDTO> = {
'trace-a': {
startTimestampMillis: 1_000,
endTimestampMillis: 4_000,
totalSpansCount: 12,
totalErrorSpansCount: 2,
hasMissingSpans: false,
rootServiceEntryPoint: 'POST /chat',
rootServiceName: 'agent',
rootSpanStatusCode: 'STATUS_CODE_ERROR',
},
'trace-b': {
startTimestampMillis: 5_000,
endTimestampMillis: 5_500,
totalSpansCount: 3,
totalErrorSpansCount: 0,
hasMissingSpans: false,
rootServiceEntryPoint: 'POST /chat',
rootServiceName: 'agent',
rootSpanStatusCode: 'STATUS_CODE_OK',
},
};
interface HookProps {
traceId: string;
hasSpans: boolean;
spanPanelVariant?: SpanDetailVariant;
}
function renderDataLoadedEvent(
initialProps: HookProps,
firedForRef: MutableRefObject<string> = { current: '' },
): {
logTraceEvent: jest.MockedFunction<TraceDetailLogEvent>;
rerender: (props: HookProps) => void;
queryClient: QueryClient;
} {
const logTraceEvent = jest.fn() as jest.MockedFunction<TraceDetailLogEvent>;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { rerender } = renderHook(
({ traceId, hasSpans, spanPanelVariant }: HookProps) =>
useThreadDataLoadedEvent({
traceId,
hasSpans,
spanPanelVariant: spanPanelVariant ?? SpanDetailVariant.DOCKED_RIGHT,
firedForRef,
logTraceEvent,
}),
{ initialProps, wrapper },
);
return { logTraceEvent, rerender, queryClient };
}
describe('useThreadDataLoadedEvent', () => {
beforeEach(() => {
server.use(
rest.get('*/api/v1/traces/:traceId/summary', (req, res, ctx) => {
const body: GetTraceSummary200 = {
status: 'success',
data: SUMMARIES[req.params.traceId as string],
};
return res(ctx.status(200), ctx.json(body));
}),
);
});
it('waits for the trace summary, then reports it as a thread load', async () => {
const { logTraceEvent } = renderDataLoadedEvent({
traceId: 'trace-a',
hasSpans: true,
});
expect(logTraceEvent).not.toHaveBeenCalled();
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
expect(logTraceEvent).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
detailsTab: TraceDetailsTab.Thread,
totalSpansCount: 12,
traceDurationMs: 3_000,
hadErrors: true,
spanPanelVariant: SpanDetailVariant.DOCKED_RIGHT,
});
});
it('waits for the thread spans even when the summary is already loaded', async () => {
const { logTraceEvent, rerender, queryClient } = renderDataLoadedEvent({
traceId: 'trace-a',
hasSpans: false,
});
await waitFor(() =>
expect(
queryClient.getQueryData(
getGetTraceSummaryQueryKey({ traceID: 'trace-a' }),
),
).toBeDefined(),
);
rerender({ traceId: 'trace-a', hasSpans: false });
expect(logTraceEvent).not.toHaveBeenCalled();
rerender({ traceId: 'trace-a', hasSpans: true });
expect(logTraceEvent).toHaveBeenCalledTimes(1);
});
it('reports a trace once, including when the tab is visited again', async () => {
const firedForRef = { current: '' };
const firstVisit = renderDataLoadedEvent(
{ traceId: 'trace-a', hasSpans: true },
firedForRef,
);
await waitFor(() =>
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1),
);
firstVisit.rerender({
traceId: 'trace-a',
hasSpans: true,
spanPanelVariant: SpanDetailVariant.DOCKED,
});
const secondVisit = renderDataLoadedEvent(
{ traceId: 'trace-a', hasSpans: true },
firedForRef,
);
await waitFor(() =>
expect(
secondVisit.queryClient.getQueryData(
getGetTraceSummaryQueryKey({ traceID: 'trace-a' }),
),
).toBeDefined(),
);
secondVisit.rerender({ traceId: 'trace-a', hasSpans: true });
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1);
expect(secondVisit.logTraceEvent).not.toHaveBeenCalled();
});
it('reports the next trace with its own summary', async () => {
const { logTraceEvent, rerender } = renderDataLoadedEvent({
traceId: 'trace-a',
hasSpans: true,
});
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
rerender({ traceId: 'trace-b', hasSpans: true });
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
expect(logTraceEvent).toHaveBeenLastCalledWith(
TraceDetailEvents.DataLoaded,
expect.objectContaining({ totalSpansCount: 3, traceDurationMs: 500 }),
);
});
});

View File

@@ -1,111 +0,0 @@
import { waitFor } from 'tests/test-utils';
import { TraceDetailsTab } from '../../../constants';
import { TraceDetailEvents } from '../../../events';
import { SpanDetailVariant } from '../../../SpanDetailsPanel/constants';
import {
mockTraceSummaries,
renderThreadLoadedEvents,
ThreadLoadedEventsProps,
waitForSummary,
} from './threadLoadedEventsTestUtils';
describe('useThreadLoadedEvents: data loaded', () => {
beforeEach(() => {
mockTraceSummaries();
});
it('waits for the trace summary, then reports it as a thread load', async () => {
const { logTraceEvent } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasSpans: true,
});
expect(logTraceEvent).not.toHaveBeenCalled();
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
expect(logTraceEvent).toHaveBeenCalledWith(TraceDetailEvents.DataLoaded, {
detailsTab: TraceDetailsTab.Thread,
totalSpansCount: 12,
traceDurationMs: 3_000,
hadErrors: true,
spanPanelVariant: SpanDetailVariant.DOCKED_RIGHT,
});
});
it('waits for the thread spans even when the summary is already loaded', async () => {
const { logTraceEvent, rerender, queryClient } = renderThreadLoadedEvents({
traceId: 'trace-ai',
});
await waitForSummary(queryClient, 'trace-ai');
rerender({ traceId: 'trace-ai' });
expect(logTraceEvent).not.toHaveBeenCalled();
rerender({ traceId: 'trace-ai', hasSpans: true });
expect(logTraceEvent).toHaveBeenCalledTimes(1);
});
it('reports a trace once, including when the tab is visited again', async () => {
const dataLoadedFiredForRef = { current: '' };
const props: ThreadLoadedEventsProps = {
traceId: 'trace-ai',
hasSpans: true,
};
const firstVisit = renderThreadLoadedEvents(props, { dataLoadedFiredForRef });
await waitFor(() =>
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1),
);
firstVisit.rerender({
...props,
spanPanelVariant: SpanDetailVariant.DOCKED,
});
const secondVisit = renderThreadLoadedEvents(props, {
dataLoadedFiredForRef,
});
await waitForSummary(secondVisit.queryClient, 'trace-ai');
secondVisit.rerender(props);
expect(firstVisit.logTraceEvent).toHaveBeenCalledTimes(1);
expect(secondVisit.logTraceEvent).not.toHaveBeenCalled();
});
it('reports the next trace with its own summary', async () => {
const { logTraceEvent, rerender } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasSpans: true,
});
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
rerender({ traceId: 'trace-ai-next', hasSpans: true });
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
expect(logTraceEvent).toHaveBeenLastCalledWith(
TraceDetailEvents.DataLoaded,
expect.objectContaining({ totalSpansCount: 4, traceDurationMs: 500 }),
);
});
it('also reports an empty thread, once each, when an errored thread still holds spans', async () => {
const props: ThreadLoadedEventsProps = {
traceId: 'trace-ai',
hasSpans: true,
hasNoThread: true,
};
const { logTraceEvent, rerender } = renderThreadLoadedEvents(props);
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
rerender(props);
expect(logTraceEvent).toHaveBeenCalledTimes(2);
expect(logTraceEvent).toHaveBeenCalledWith(
TraceDetailEvents.DataLoaded,
expect.any(Object),
);
expect(logTraceEvent).toHaveBeenCalledWith(
TraceDetailEvents.ThreadEmptyWithAiSummary,
expect.any(Object),
);
});
});

View File

@@ -1,114 +0,0 @@
import { waitFor } from 'tests/test-utils';
import { TraceDetailsTab } from '../../../constants';
import { TraceDetailEvents } from '../../../events';
import {
mockTraceSummaries,
renderThreadLoadedEvents,
ThreadLoadedEventsProps,
waitForSummary,
} from './threadLoadedEventsTestUtils';
describe('useThreadLoadedEvents: thread empty with AI summary', () => {
let summaryRequests: string[];
beforeEach(() => {
summaryRequests = mockTraceSummaries();
});
it('reports the trace summary when the thread is empty but the summary has AI usage', async () => {
const { logTraceEvent } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasNoThread: true,
interestedSpanId: 'span-1',
});
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
expect(logTraceEvent).toHaveBeenCalledWith(
TraceDetailEvents.ThreadEmptyWithAiSummary,
{
detailsTab: TraceDetailsTab.Thread,
spanId: 'span-1',
rootServiceName: 'agent',
rootServiceEntryPoint: 'POST /chat',
totalSpansCount: 12,
traceDurationMs: 3_000,
hadErrors: true,
hasMissingSpans: true,
inputTokens: 1_200,
outputTokens: 300,
cacheReadTokens: 50,
cacheWriteTokens: 10,
reasoningTokens: 40,
totalCost: 0.0123,
},
);
});
it('does not report when the summary has no AI usage', async () => {
const props: ThreadLoadedEventsProps = {
traceId: 'trace-no-ai',
hasNoThread: true,
};
const { logTraceEvent, rerender, queryClient } =
renderThreadLoadedEvents(props);
await waitForSummary(queryClient, 'trace-no-ai');
rerender(props);
expect(logTraceEvent).not.toHaveBeenCalled();
});
it('waits until the thread is known to be empty', async () => {
const { logTraceEvent, rerender, queryClient } = renderThreadLoadedEvents({
traceId: 'trace-ai',
});
await waitForSummary(queryClient, 'trace-ai');
rerender({ traceId: 'trace-ai' });
expect(logTraceEvent).not.toHaveBeenCalled();
rerender({ traceId: 'trace-ai', hasNoThread: true });
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
});
it('reports once per trace and reuses the loaded summary when the tab is visited again', async () => {
const threadEmptyFiredForRef = { current: '' };
const props: ThreadLoadedEventsProps = {
traceId: 'trace-ai',
hasNoThread: true,
};
const first = renderThreadLoadedEvents(props, { threadEmptyFiredForRef });
await waitFor(() => expect(first.logTraceEvent).toHaveBeenCalledTimes(1));
first.unmount();
const second = renderThreadLoadedEvents(props, {
threadEmptyFiredForRef,
queryClient: first.queryClient,
});
expect(second.queryClient.isFetching()).toBe(0);
expect(summaryRequests).toStrictEqual(['trace-ai']);
expect(second.logTraceEvent).not.toHaveBeenCalled();
});
it('reports the next trace with its own summary', async () => {
const { logTraceEvent, rerender } = renderThreadLoadedEvents({
traceId: 'trace-ai',
hasNoThread: true,
});
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(1));
rerender({ traceId: 'trace-ai-next', hasNoThread: true });
await waitFor(() => expect(logTraceEvent).toHaveBeenCalledTimes(2));
expect(logTraceEvent).toHaveBeenLastCalledWith(
TraceDetailEvents.ThreadEmptyWithAiSummary,
expect.objectContaining({
totalSpansCount: 4,
inputTokens: 80,
totalCost: 0.0004,
}),
);
});
});

View File

@@ -0,0 +1,45 @@
import { MutableRefObject, useEffect } from 'react';
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import { TraceDetailsTab } from '../../constants';
import { TraceDetailEventKeys, TraceDetailEvents } from '../../events';
import { TraceDetailLogEvent } from '../../hooks/useTraceDetailLogEvent';
import { SpanDetailVariant } from '../../SpanDetailsPanel/constants';
interface ThreadDataLoadedEventArgs {
traceId: string;
hasSpans: boolean;
spanPanelVariant: SpanDetailVariant;
firedForRef: MutableRefObject<string>;
logTraceEvent: TraceDetailLogEvent;
}
export function useThreadDataLoadedEvent({
traceId,
hasSpans,
spanPanelVariant,
firedForRef,
logTraceEvent,
}: ThreadDataLoadedEventArgs): void {
// No keepPreviousData: a previous trace's summary must not be reported for this one.
const { data } = useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId, refetchOnMount: false } },
);
const summary = data?.data;
useEffect(() => {
if (!traceId || firedForRef.current === traceId || !hasSpans || !summary) {
return;
}
firedForRef.current = traceId;
logTraceEvent(TraceDetailEvents.DataLoaded, {
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Thread,
[TraceDetailEventKeys.TotalSpansCount]: summary.totalSpansCount,
[TraceDetailEventKeys.TraceDurationMs]:
summary.endTimestampMillis - summary.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: summary.totalErrorSpansCount > 0,
[TraceDetailEventKeys.SpanPanelVariant]: spanPanelVariant,
});
}, [traceId, hasSpans, summary, spanPanelVariant, firedForRef, logTraceEvent]);
}

View File

@@ -1,70 +0,0 @@
import { MutableRefObject, useEffect } from 'react';
import { useGetTraceSummary } from 'api/generated/services/tracedetail';
import { TraceDetailEvents } from '../../events';
import { TraceDetailLogEvent } from '../../hooks/useTraceDetailLogEvent';
import { SpanDetailVariant } from '../../SpanDetailsPanel/constants';
import {
getThreadDataLoadedEventAttributes,
getThreadEmptyWithAiEventAttributes,
} from '../utils';
interface ThreadLoadedEventsArgs {
traceId: string;
interestedSpanId?: string;
hasSpans: boolean;
hasNoThread: boolean;
spanPanelVariant: SpanDetailVariant;
dataLoadedFiredForRef: MutableRefObject<string>;
threadEmptyFiredForRef: MutableRefObject<string>;
logTraceEvent: TraceDetailLogEvent;
}
export function useThreadLoadedEvents({
traceId,
interestedSpanId,
hasSpans,
hasNoThread,
spanPanelVariant,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
logTraceEvent,
}: ThreadLoadedEventsArgs): void {
// No keepPreviousData: a previous trace's summary must not be reported for this one.
const { data } = useGetTraceSummary(
{ traceID: traceId },
{ query: { enabled: !!traceId, refetchOnMount: false } },
);
const summary = data?.data;
useEffect(() => {
if (!traceId || !summary) {
return;
}
// Not exclusive: an errored thread can still hold spans, so each event keeps its own ref.
if (hasSpans && dataLoadedFiredForRef.current !== traceId) {
dataLoadedFiredForRef.current = traceId;
logTraceEvent(
TraceDetailEvents.DataLoaded,
getThreadDataLoadedEventAttributes(summary, spanPanelVariant),
);
}
if (hasNoThread && summary.ai && threadEmptyFiredForRef.current !== traceId) {
threadEmptyFiredForRef.current = traceId;
logTraceEvent(
TraceDetailEvents.ThreadEmptyWithAiSummary,
getThreadEmptyWithAiEventAttributes(summary, interestedSpanId),
);
}
}, [
traceId,
interestedSpanId,
hasSpans,
hasNoThread,
summary,
spanPanelVariant,
dataLoadedFiredForRef,
threadEmptyFiredForRef,
logTraceEvent,
]);
}

View File

@@ -1,13 +1,7 @@
import type {
SpantypesGettableTraceSummaryDTO,
SpantypesThreadSpanDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SpantypesThreadSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { isAxiosError } from 'axios';
import { collectToolCallKeys } from '../AIThreadMessage/utils';
import { TraceDetailsTab } from '../constants';
import { TraceDetailEventKeys } from '../events';
import { SpanDetailVariant } from '../SpanDetailsPanel/constants';
import { InterestedSpanStatus, ThreadSpan } from './types';
/** Derives SpanV3's `timestamp` and `service.name`, as the waterfall does. */
@@ -53,44 +47,3 @@ export function getInterestedSpanStatus(
? InterestedSpanStatus.Found
: InterestedSpanStatus.NoIOMessages;
}
function getThreadSummaryAttributes(
summary: SpantypesGettableTraceSummaryDTO,
): Record<string, unknown> {
return {
[TraceDetailEventKeys.DetailsTab]: TraceDetailsTab.Thread,
[TraceDetailEventKeys.TotalSpansCount]: summary.totalSpansCount,
[TraceDetailEventKeys.TraceDurationMs]:
summary.endTimestampMillis - summary.startTimestampMillis,
[TraceDetailEventKeys.HadErrors]: summary.totalErrorSpansCount > 0,
};
}
export function getThreadDataLoadedEventAttributes(
summary: SpantypesGettableTraceSummaryDTO,
spanPanelVariant: SpanDetailVariant,
): Record<string, unknown> {
return {
...getThreadSummaryAttributes(summary),
[TraceDetailEventKeys.SpanPanelVariant]: spanPanelVariant,
};
}
export function getThreadEmptyWithAiEventAttributes(
summary: SpantypesGettableTraceSummaryDTO,
interestedSpanId: string | undefined,
): Record<string, unknown> {
return {
...getThreadSummaryAttributes(summary),
[TraceDetailEventKeys.SpanId]: interestedSpanId,
[TraceDetailEventKeys.RootServiceName]: summary.rootServiceName,
[TraceDetailEventKeys.RootServiceEntryPoint]: summary.rootServiceEntryPoint,
[TraceDetailEventKeys.HasMissingSpans]: summary.hasMissingSpans,
[TraceDetailEventKeys.InputTokens]: summary.ai?.tokens.input,
[TraceDetailEventKeys.OutputTokens]: summary.ai?.tokens.output,
[TraceDetailEventKeys.CacheReadTokens]: summary.ai?.tokens.cacheRead,
[TraceDetailEventKeys.CacheWriteTokens]: summary.ai?.tokens.cacheWrite,
[TraceDetailEventKeys.ReasoningTokens]: summary.ai?.tokens.reasoning,
[TraceDetailEventKeys.TotalCost]: summary.ai?.totalCost,
};
}

View File

@@ -14,7 +14,6 @@ export enum TraceDetailEvents {
SpanPercentileAttributeChanged = 'Trace Detail: Span percentile attribute changed',
SpanDetailsOpened = 'Trace Detail: Span details opened',
DetailsTabChanged = 'Trace Detail: Details tab changed',
ThreadEmptyWithAiSummary = 'Trace Detail: Thread empty with AI summary',
}
export enum TraceDetailEventKeys {
@@ -49,16 +48,6 @@ export enum TraceDetailEventKeys {
Selected = 'selected',
// Data loaded / span details opened: which tab the user is on
DetailsTab = 'detailsTab',
// Thread empty with AI summary (reuses SpanId, DetailsTab, TotalSpansCount, TraceDurationMs, HadErrors)
RootServiceName = 'rootServiceName',
RootServiceEntryPoint = 'rootServiceEntryPoint',
HasMissingSpans = 'hasMissingSpans',
InputTokens = 'inputTokens',
OutputTokens = 'outputTokens',
CacheReadTokens = 'cacheReadTokens',
CacheWriteTokens = 'cacheWriteTokens',
ReasoningTokens = 'reasoningTokens',
TotalCost = 'totalCost',
}
export type TraceDetailView = 'v2' | 'v3';

View File

@@ -17,7 +17,6 @@ function TraceDetailsV3(): JSX.Element {
// between traces (the route component stays mounted) re-fires it once each.
const dataLoadedFiredForRef = useRef<string>('');
const threadDataLoadedFiredForRef = useRef<string>('');
const threadEmptyFiredForRef = useRef<string>('');
const handleFilteredSpansChange = useCallback(
(spanIds: string[], isActive: boolean): void => {
@@ -41,10 +40,7 @@ function TraceDetailsV3(): JSX.Element {
dataLoadedFiredForRef={dataLoadedFiredForRef}
/>
) : (
<TraceDetailsThread
dataLoadedFiredForRef={threadDataLoadedFiredForRef}
threadEmptyFiredForRef={threadEmptyFiredForRef}
/>
<TraceDetailsThread dataLoadedFiredForRef={threadDataLoadedFiredForRef} />
)}
</TraceDetailsWrapper>
);

View File

@@ -475,17 +475,12 @@ export function QueryBuilderProvider({
const createNewBuilderQuery = useCallback(
(queries: IBuilderQuery[]): IBuilderQuery => {
const existNames = queries.map((item) => item.queryName);
// Hosts without a fixed data source (the panel editor) continue the
// previous query's signal rather than resetting to metrics.
const previous = queries[queries.length - 1];
const initialBuilderQuery =
initialQueryBuilderFormValuesMap[
initialDataSource || previous?.dataSource || DataSource.METRICS
];
initialQueryBuilderFormValuesMap[initialDataSource || DataSource.METRICS];
const newQuery: IBuilderQuery = {
...initialBuilderQuery,
source: previous?.source || '',
source: queries?.[0]?.source || '',
builderQueryType: queries?.[0]?.builderQueryType,
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
expression: createNewBuilderItemName({

View File

@@ -1,104 +0,0 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
const renderQueryBuilder = (
initialQuery: Query,
): ReturnType<
typeof renderHook<ReturnType<typeof useQueryBuilder>, unknown>
> => {
const hook = renderHook(() => useQueryBuilder(), {
wrapper: AllTheProviders,
});
act(() => {
hook.result.current.initQueryBuilderData(initialQuery);
});
return hook;
};
describe('createNewBuilderQuery data source', () => {
it.each([DataSource.LOGS, DataSource.TRACES, DataSource.METRICS])(
'continues the previous %s query when the host fixes no data source',
(dataSource) => {
const { result } = renderQueryBuilder(initialQueriesMap[dataSource]);
act(() => {
result.current.addNewBuilderQuery();
});
const [, added] = result.current.currentQuery.builder.queryData;
expect(added.dataSource).toBe(dataSource);
expect(added.aggregations).toStrictEqual(
initialQueryBuilderFormValuesMap[dataSource].aggregations,
);
},
);
it('follows the last query when queries mix signals', () => {
const { result } = renderQueryBuilder(initialQueriesMap.logs);
act(() => {
result.current.addNewBuilderQuery();
});
act(() => {
result.current.handleSetQueryData(1, {
...initialQueryBuilderFormValuesMap.traces,
queryName: 'B',
expression: 'B',
});
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[2].dataSource).toBe(
DataSource.TRACES,
);
});
it('takes the source from the last query, not the first', () => {
const [metricsQuery] = initialQueriesMap.metrics.builder.queryData;
const { result } = renderQueryBuilder({
...initialQueriesMap.metrics,
builder: {
...initialQueriesMap.metrics.builder,
queryData: [
{ ...metricsQuery, source: '' },
{ ...metricsQuery, queryName: 'B', expression: 'B', source: 'meter' },
],
},
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[2]).toMatchObject({
dataSource: DataSource.METRICS,
source: 'meter',
});
});
it('uses the host’s fixed data source over the previous query', () => {
const { result } = renderQueryBuilder(initialQueriesMap.metrics);
act(() => {
result.current.handleSetConfig(PANEL_TYPES.TIME_SERIES, DataSource.LOGS);
});
act(() => {
result.current.addNewBuilderQuery();
});
expect(result.current.currentQuery.builder.queryData[1].dataSource).toBe(
DataSource.LOGS,
);
});
});

View File

@@ -1,7 +1,6 @@
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildExplorerNavigationUrl } from '../buildExplorerNavigationUrl';
@@ -20,14 +19,4 @@ describe('buildExplorerNavigationUrl', () => {
expect(url).toContain(`${QueryParams.compositeQuery}=`);
expect(url).toContain(`${QueryParams.viewKey}=`);
});
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
const url = buildExplorerNavigationUrl(
ROUTES.LOGS_EXPLORER,
initialQueriesMap[DataSource.LOGS],
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
);
expect(url).toContain(`${QueryParams.panelTypes}=%22table%22`);
});
});

View File

@@ -4,13 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"maps"
"sync"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/llmpricingrule"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
@@ -26,14 +22,12 @@ import (
const unmappedModelsLookback = time.Hour
type module struct {
store llmpricingruletypes.Store
querier querier.Querier
telemetryMetadataStore telemetrytypes.MetadataStore
logger *slog.Logger
store llmpricingruletypes.Store
querier querier.Querier
}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier, telemetryMetadataStore telemetrytypes.MetadataStore, providerSettings factory.ProviderSettings) llmpricingrule.Module {
return &module{store: store, querier: querier, telemetryMetadataStore: telemetryMetadataStore, logger: providerSettings.Logger}
func NewModule(store llmpricingruletypes.Store, querier querier.Querier) llmpricingrule.Module {
return &module{store: store, querier: querier}
}
func (module *module) List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error) {
@@ -57,47 +51,13 @@ func (module *module) ListUnmappedModels(ctx context.Context, orgID valuer.UUID)
return nil, err
}
return llmpricingruletypes.UnmappedModels(models, rules), nil
}
// Collect keeps the rule counts even when ClickHouse is unreachable: the
// counts that depend on it are skipped so the reporter retains their last value.
// Models come from the traces tag table rather than a span scan, so the
// unmapped count covers the metadata lookback instead of the last hour.
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
rules, err := module.listAllRules(ctx, orgID)
if err != nil {
return nil, err
}
stats := llmpricingruletypes.NewStatsFromRules(rules)
selectors := aiobservabilitytypes.StatsFieldValueSelectors(time.Now())
results := make([]*telemetrytypes.TelemetryFieldValues, len(selectors))
var lookups sync.WaitGroup
for i, selector := range selectors {
lookups.Go(func() {
fieldValues, _, err := module.telemetryMetadataStore.GetAllValues(ctx, orgID, selector)
if err != nil {
module.logger.DebugContext(ctx, "failed to collect gen_ai attribute stats", slog.String("key", selector.Name), errors.Attr(err))
return
}
results[i] = fieldValues
})
}
lookups.Wait()
values := make(map[string]*telemetrytypes.TelemetryFieldValues, len(selectors))
for i, selector := range selectors {
if results[i] != nil {
values[selector.Name] = results[i]
unmapped := make([]*llmpricingruletypes.UnmappedModel, 0, len(models))
for _, m := range models {
if !llmpricingruletypes.ModelMatchesAnyRule(m.ModelName, rules) {
unmapped = append(unmapped, m)
}
}
maps.Copy(stats, aiobservabilitytypes.NewStatsFromFieldValues(values))
if models := values[aiobservabilitytypes.GenAIRequestModel]; models != nil {
maps.Copy(stats, llmpricingruletypes.NewStatsFromModels(models.StringValues, rules))
}
return stats, nil
return unmapped, nil
}
// CreateOrUpdate saves a batch of pricing rules. isOverride decides how a rule

View File

@@ -5,7 +5,6 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types/llmpricingruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -14,8 +13,6 @@ type Module interface {
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
agentConf.AgentFeature
statsreporter.StatsCollector
List(ctx context.Context, orgID valuer.UUID, offset, limit int, search string, isOverride *bool) ([]*llmpricingruletypes.LLMPricingRule, int, error)
Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*llmpricingruletypes.LLMPricingRule, error)
CreateOrUpdate(ctx context.Context, orgID valuer.UUID, userEmail string, rules []*llmpricingruletypes.UpdatableLLMPricingRule) (err error)

View File

@@ -164,7 +164,7 @@ func NewModules(
CloudIntegration: cloudIntegrationModule,
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore, telemetryMetadataStore, schemaMetadataStore, fl), providerSettings, config.TraceDetail),
SpanMapper: spanMapper,
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier, telemetryMetadataStore, providerSettings),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), querier),
Tag: tagModule,
}
}

View File

@@ -572,7 +572,6 @@ func New(
cloudIntegrationModule,
modules.LogsPipeline,
modules.InfraMonitoring,
modules.LLMPricingRule,
querier,
authz,
}

View File

@@ -20,9 +20,6 @@ var (
ErrCodeUnknownIdentifier = errors.MustNewCode("unknown_identifier")
ErrCodeIllegalArgument = errors.MustNewCode("illegal_argument")
ErrCodeDistributedSubqueryDenied = errors.MustNewCode("distributed_subquery_denied")
ErrCodeDecimalOverflow = errors.MustNewCode("decimal_overflow")
ErrCodeQueryCanceled = errors.MustNewCode("query_canceled")
ErrCodeQueryTimeout = errors.MustNewCode("query_timeout")
ErrCodeExecutionFailed = errors.MustNewCode("execution_failed")
@@ -75,18 +72,6 @@ var clickHouseExceptionWrappers = map[chproto.Error]func(cause error, ex *clickh
chproto.ErrTooLessArgumentsForFunction: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "too few arguments to function: %s", ex.Message)
},
chproto.ErrBadGet: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeIllegalArgument, "parameter of the wrong type: %s", ex.Message).
WithSuggestions("write numeric function parameters in the type the function expects, for example 1.0 rather than 1 where it takes a Float64")
},
chproto.ErrDistributedInJoinSubqueryDenied: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeDistributedSubqueryDenied, "IN or JOIN subquery over a distributed table is not allowed: %s", ex.Message).
WithSuggestions("use GLOBAL IN or GLOBAL JOIN for the subquery")
},
chproto.ErrDecimalOverflow: func(cause error, ex *clickhouse.Exception) error {
return errors.WrapInvalidInputf(cause, ErrCodeDecimalOverflow, "decimal overflow: %s", ex.Message).
WithSuggestions("cast the Decimal values to Float64, or to a wider Decimal such as Decimal128, before the arithmetic")
},
}
type provider struct {

View File

@@ -1,55 +0,0 @@
package aiobservabilitytypes
import (
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
// statsLookback reaches into the previous day; the metadata store rounds
// the start down to a day boundary.
statsLookback = 24 * time.Hour
// statsModelValuesLimit caps the model list, which also feeds the
// unmapped model count. One value is enough for the other keys.
statsModelValuesLimit = 100
)
// statsAttributeKeys are the gen_ai span attributes whose recent presence the
// stats reporter tracks under telemetry.traces.<key>.exists.
var statsAttributeKeys = []string{GenAIRequestModel, GenAIToolName, GenAIAgentName}
// StatsFieldValueSelectors look up each tracked attribute's string values
// since the start of the previous day.
func StatsFieldValueSelectors(now time.Time) []*telemetrytypes.FieldValueSelector {
selectors := make([]*telemetrytypes.FieldValueSelector, 0, len(statsAttributeKeys))
for _, key := range statsAttributeKeys {
limit := 1
if key == GenAIRequestModel {
limit = statsModelValuesLimit
}
selectors = append(selectors, &telemetrytypes.FieldValueSelector{
FieldKeySelector: &telemetrytypes.FieldKeySelector{
StartUnixMilli: now.Add(-statsLookback).UnixMilli(),
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
Name: key,
},
Limit: limit,
})
}
return selectors
}
// NewStatsFromFieldValues reports whether each looked-up key had a value. Keys
// absent from values are left out, so a failed lookup never reports false.
func NewStatsFromFieldValues(values map[string]*telemetrytypes.TelemetryFieldValues) map[string]any {
stats := make(map[string]any, len(values))
for _, key := range statsAttributeKeys {
if fieldValues := values[key]; fieldValues != nil {
stats["telemetry.traces."+key+".exists"] = len(fieldValues.StringValues) > 0
}
}
return stats
}

View File

@@ -1,73 +0,0 @@
package aiobservabilitytypes
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
func TestNewStatsFromFieldValues(t *testing.T) {
testCases := []struct {
name string
values map[string]*telemetrytypes.TelemetryFieldValues
want map[string]any
}{
{
name: "NoLookups_NothingReported",
want: map[string]any{},
},
{
name: "EveryKeySeen_AllTrue",
values: map[string]*telemetrytypes.TelemetryFieldValues{
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
GenAIToolName: {StringValues: []string{"search"}},
GenAIAgentName: {StringValues: []string{"planner"}},
},
want: map[string]any{
"telemetry.traces.gen_ai.request.model.exists": true,
"telemetry.traces.gen_ai.tool.name.exists": true,
"telemetry.traces.gen_ai.agent.name.exists": true,
},
},
{
name: "EmptyAndMissingLookups_OnlyLookedUpKeysReported",
values: map[string]*telemetrytypes.TelemetryFieldValues{
GenAIRequestModel: {StringValues: []string{"gpt-5-nano"}},
GenAIToolName: {},
GenAIOperationName: {StringValues: []string{"chat"}},
},
want: map[string]any{
"telemetry.traces.gen_ai.request.model.exists": true,
"telemetry.traces.gen_ai.tool.name.exists": false,
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, NewStatsFromFieldValues(testCase.values))
})
}
}
func TestStatsFieldValueSelectors(t *testing.T) {
now := time.Date(2026, 11, 3, 10, 0, 0, 0, time.UTC)
selectors := StatsFieldValueSelectors(now)
require.Len(t, selectors, 3)
for _, selector := range selectors {
assert.Equal(t, now.Add(-24*time.Hour).UnixMilli(), selector.StartUnixMilli, selector.Name)
assert.Equal(t, telemetrytypes.SignalTraces, selector.Signal, selector.Name)
assert.Equal(t, telemetrytypes.FieldContextAttribute, selector.FieldContext, selector.Name)
assert.Equal(t, telemetrytypes.FieldDataTypeString, selector.FieldDataType, selector.Name)
}
assert.Equal(t, GenAIRequestModel, selectors[0].Name)
assert.Equal(t, 100, selectors[0].Limit)
assert.Equal(t, 1, selectors[1].Limit)
assert.Equal(t, 1, selectors[2].Limit)
}

View File

@@ -1,7 +1,6 @@
package dashboardtypes
import (
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -11,7 +10,6 @@ const (
statKeyPanelTracesCount = "dashboard.panels.traces.count"
statKeyPanelMetricsCount = "dashboard.panels.metrics.count"
statKeyPanelLogsCount = "dashboard.panels.logs.count"
statKeyPanelAICount = "dashboard.panels.ai.count"
)
// panelSignalStatKeys maps a builder query's signal to the stat it contributes
@@ -24,17 +22,14 @@ var panelSignalStatKeys = map[telemetrytypes.Signal]string{
}
// NewStatsFromStorableDashboards reports the stats of stored dashboards. Rows that
// do not decode as v2 contribute to dashboard.count only. System dashboards are
// provisioned into every org, so they are left out of every count.
// do not decode as v2 contribute to dashboard.count only.
func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]any {
stats := newStats()
stats := newPanelStats()
for _, dashboard := range dashboards {
if dashboard == nil || dashboard.Source == SourceSystem {
if dashboard == nil {
continue
}
incrementStat(stats, statKeyDashboardCount)
dashboardV2, err := dashboard.ToDashboardV2(nil)
if err != nil {
continue
@@ -42,6 +37,7 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
addPanelStats(&dashboardV2.Spec, stats)
}
stats[statKeyDashboardCount] = int64(len(dashboards))
return stats
}
@@ -49,26 +45,24 @@ func NewStatsFromStorableDashboards(dashboards []*StorableDashboard) map[string]
// created, straight off the postable spec — the create path has no reason to make
// a storable round-trip just to be counted.
func NewStatsFromPostableDashboardV2(postable PostableDashboardV2) map[string]any {
stats := newStats()
stats[statKeyDashboardCount] = int64(1)
stats := newPanelStats()
addPanelStats(&postable.Spec, stats)
stats[statKeyDashboardCount] = int64(1)
return stats
}
func newStats() map[string]any {
func newPanelStats() map[string]any {
return map[string]any{
statKeyDashboardCount: int64(0),
statKeyPanelCount: int64(0),
statKeyPanelTracesCount: int64(0),
statKeyPanelMetricsCount: int64(0),
statKeyPanelLogsCount: int64(0),
statKeyPanelAICount: int64(0),
}
}
// addPanelStats counts the panels of a v2 spec, and each panel's queries against
// the signal they read. AI queries count only towards the AI stat.
// the signal they read.
func addPanelStats(spec *DashboardSpec, stats map[string]any) {
for _, panel := range spec.Panels {
if panel == nil {
@@ -82,10 +76,6 @@ func addPanelStats(spec *DashboardSpec, stats map[string]any) {
continue
}
for _, envelope := range composite.Queries {
if envelope.Type == qb.QueryTypeBuilderAI {
incrementStat(stats, statKeyPanelAICount)
continue
}
if key, ok := panelSignalStatKeys[envelope.GetSignal()]; ok {
incrementStat(stats, key)
}

View File

@@ -117,60 +117,20 @@ func TestNewStatsFromStorableDashboardsCountsCompositeSubQueries(t *testing.T) {
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
func statsAIBuilderQuery() string {
return `{
// An AI builder query is always a traces query, so it counts towards traces.
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
aiBuilder := `{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/AIBuilderQuery", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}}}
}`
}
// An AI builder query reads traces but counts only towards the AI stat.
func TestNewStatsFromStorableDashboardsCountsAIBuilderQueries(t *testing.T) {
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
dashboard := newStatsStorableV2(t, `"p1": `+statsPanel(aiBuilder))
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
}
func TestNewStatsFromStorableDashboardsCountsAICompositeSubQueries(t *testing.T) {
composite := `{
"kind": "time_series",
"spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {"queries": [
{"type": "builder_ai_query", "spec": {"name": "A", "aggregations": [{"expression": "count()"}]}},
{"type": "builder_query", "spec": ` + statsBuilderQuerySpec("B", "logs") + `}
]}}}
}`
dashboard := newStatsStorableV2(t, `
"p1": `+statsPanel(composite)+`,
"p2": `+statsPanel(statsAIBuilderQuery())+`
`)
stats := NewStatsFromStorableDashboards([]*StorableDashboard{dashboard})
assert.Equal(t, int64(2), stats[statKeyPanelCount])
assert.Equal(t, int64(2), stats[statKeyPanelAICount])
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
func TestNewStatsFromStorableDashboardsSkipsSystemDashboards(t *testing.T) {
system := newStatsStorableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
system.Source = SourceSystem
system.Name = SystemDashboardNamePrefix + "overview"
user := newStatsStorableV2(t, `"p1": `+statsPanel(statsBuilderQuery("logs")))
stats := NewStatsFromStorableDashboards([]*StorableDashboard{system, user})
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
}
// promql and clickhouse queries carry no signal, so they land in the panel total
@@ -255,17 +215,6 @@ func TestNewStatsFromPostableDashboardV2(t *testing.T) {
assert.Equal(t, int64(1), stats[statKeyPanelLogsCount])
assert.Equal(t, int64(1), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
}
func TestNewStatsFromPostableDashboardV2CountsAIBuilderQueries(t *testing.T) {
postable := newStatsPostableV2(t, `"p1": `+statsPanel(statsAIBuilderQuery()))
stats := NewStatsFromPostableDashboardV2(postable)
assert.Equal(t, int64(1), stats[statKeyDashboardCount])
assert.Equal(t, int64(1), stats[statKeyPanelCount])
assert.Equal(t, int64(1), stats[statKeyPanelAICount])
}
func TestNewStatsFromPostableDashboardV2WithNoPanels(t *testing.T) {
@@ -284,5 +233,4 @@ func TestNewStatsFromStorableDashboardsWithNoDashboards(t *testing.T) {
assert.Equal(t, int64(0), stats[statKeyPanelTracesCount])
assert.Equal(t, int64(0), stats[statKeyPanelMetricsCount])
assert.Equal(t, int64(0), stats[statKeyPanelLogsCount])
assert.Equal(t, int64(0), stats[statKeyPanelAICount])
}

View File

@@ -277,13 +277,3 @@ func ModelMatchesAnyRule(model string, rules []*LLMPricingRule) bool {
}
return false
}
func UnmappedModels(models []*UnmappedModel, rules []*LLMPricingRule) []*UnmappedModel {
unmapped := make([]*UnmappedModel, 0, len(models))
for _, model := range models {
if !ModelMatchesAnyRule(model.ModelName, rules) {
unmapped = append(unmapped, model)
}
}
return unmapped
}

View File

@@ -1,31 +0,0 @@
package llmpricingruletypes
// NewStatsFromRules reports the rules users added themselves and the synced
// defaults users pinned.
func NewStatsFromRules(rules []*LLMPricingRule) map[string]any {
custom, overrides := int64(0), int64(0)
for _, rule := range rules {
switch {
case rule.SourceID == nil:
custom++
case rule.IsOverride:
overrides++
}
}
return map[string]any{
"llmpricingrule.custom.count": custom,
"llmpricingrule.override.count": overrides,
}
}
func NewStatsFromModels(models []string, rules []*LLMPricingRule) map[string]any {
unmapped := int64(0)
for _, model := range models {
if !ModelMatchesAnyRule(model, rules) {
unmapped++
}
}
return map[string]any{"llmpricingrule.unmapped_model.count": unmapped}
}

View File

@@ -1,65 +0,0 @@
package llmpricingruletypes
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/SigNoz/signoz/pkg/valuer"
)
func TestNewStatsFromRules(t *testing.T) {
sourceID := valuer.GenerateUUID()
testCases := []struct {
name string
rules []*LLMPricingRule
want map[string]any
}{
{
name: "NoRules",
want: map[string]any{
"llmpricingrule.custom.count": int64(0),
"llmpricingrule.override.count": int64(0),
},
},
{
name: "CustomPinnedAndSyncedRules_CountedSeparately",
rules: []*LLMPricingRule{
{IsOverride: true},
{IsOverride: false},
{SourceID: &sourceID, IsOverride: true},
{SourceID: &sourceID, IsOverride: false},
},
want: map[string]any{
"llmpricingrule.custom.count": int64(2),
"llmpricingrule.override.count": int64(1),
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.want, NewStatsFromRules(testCase.rules))
})
}
}
func TestNewStatsFromModels(t *testing.T) {
rules := []*LLMPricingRule{{ModelPattern: StringSlice{"gpt-*"}}}
testCases := []struct {
name string
models []string
want int64
}{
{name: "NoModels", want: 0},
{name: "PricedAndUnpricedModels_UnpricedCounted", models: []string{"gpt-5-nano", "claude-haiku-5-5", "gemini-3"}, want: 2},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, map[string]any{"llmpricingrule.unmapped_model.count": testCase.want}, NewStatsFromModels(testCase.models, rules))
})
}
}

View File

@@ -259,20 +259,3 @@ neither antd 5.11 nor `@rc-component/trigger` closes it on Escape, so
it, but when the popover covered the Advanced Options header, that click waited
on "intercepts pointer events" for the rest of the budget. The driver now closes
the popover through its trigger and waits for it to go.
## dnd-kit stops clicks right after a drop
`app-shell/sidenav` TC-03 needed a retry in half its CI jobs and failed all
three attempts in four of 22. dnd-kit 6.1 adds a capture-phase click handler on
`document` that stops propagation once a drag activates, and removes it 50 ms
after the drop. `saveShortcuts` clicked Save inside that window, so the modal
stayed open and no preference request went out. It now clicks again while the
dialog is still open.
## The Kafka configuration button drops clicks while loading
`messaging-queues/kafka` TC-01 failed its first attempt in four of 20 CI jobs.
The health check button passes `loading` to an antd 5.11 `Button` while the
three onboarding status queries fetch, and a loading antd button ignores clicks
without being disabled, so Playwright clicks it and nothing opens.
`openHealthCheck` clicks again until the dialog shows.

View File

@@ -258,15 +258,9 @@ export function messagingQueuesDriver(page: Page): MessagingQueues {
openHealthCheck: async () => {
// "Configuration", or "Missing Configuration (N)" when checks fail.
const button = page.getByRole('button', { name: /Configuration/ });
await page.getByRole('button', { name: /Configuration/ }).click();
const dialog = page.getByRole('dialog');
// The button drops clicks while its checks load, and is not disabled.
await expect(async () => {
if (!(await dialog.isVisible())) {
await button.click({ timeout: 5_000 });
}
await expect(dialog).toBeVisible({ timeout: 1_000 });
}).toPass();
await expect(dialog).toBeVisible();
return dialog;
},

View File

@@ -635,16 +635,7 @@ export function shellDriver(page: Page): Shell {
saveShortcuts: async () => {
const saved = page.waitForResponse(isPreferencePut('nav_shortcuts'));
const dialog = page
.getByRole('dialog')
.filter({ hasText: 'Manage Shortcuts' });
// dnd-kit stops every click for 50 ms after a drop.
await expect(async () => {
if (await dialog.isVisible()) {
await page.getByTestId('save-changes-btn').click({ timeout: 5_000 });
}
await expect(dialog).toBeHidden({ timeout: 1_000 });
}).toPass();
await page.getByTestId('save-changes-btn').click();
const response = await saved;
expect(response.ok()).toBe(true);
return (response.request().postDataJSON() as { value: string[] }).value;