Compare commits

..

4 Commits

Author SHA1 Message Date
Abhi kumar
7ce73f3470 fix(dashboard): don't re-run an errored panel query on scroll back into view (#12958)
#### Description

- Lazy-loaded panels toggle `enabled` on viewport visibility.
react-query treats a key with no data as stale regardless of
`staleTime`, so an errored panel refetched (with retries on 5xx) every
time it scrolled back into view.
- `useGetQueryRangeV5` now keeps an errored key enabled, so only a key
change (time, variables, query) or the Retry button re-runs it. A new
key still stays gated while off-screen.
- Adds a `useGetQueryRangeV5` test suite covering the gating paths.
2026-09-23 07:04:51 +00:00
Nityananda Gohain
5aca8b0d3c chore: remove ai-o11y ff (#12947)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
Remove ai-o11y FF and enable it by default

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of https://github.com/SigNoz/engineering-pod/issues/6107
2026-09-23 06:39:16 +00:00
Gaurav Tewari
f1c9e0f1d0 feat(llm-observability): add ai o11y analytics events (#12952)
#### Description

- Renames AI Observability explorer events from `Traces Explorer: *` to
`AI Observability Explorer: *`, so they no longer mix with the regular
Traces Explorer events.
- Adds page-visit events for Overview, Attribute Mapping and Model
Pricing.
- Adds action events: attribute mapping saved and test run; model cost
saved and deleted; unpriced model mapped.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-23 05:05:06 +00:00
Gaurav Tewari
10c0af327b fix: failing e2e for llm (#12953)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Attribute-mapping e2e now adds a condition key when creating its
group.
- Since #12809 the backend rejects groups without conditions (`400
condition must list at least one attribute or resource substring`), so
the spec failed on save.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Follow-up: we should add check on frontend as well for #12809 ( we
have already decided to add this later )

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-09-23 04:43:36 +00:00
24 changed files with 163 additions and 104 deletions

View File

@@ -23,4 +23,3 @@ We **recommend** (almost enforce) reviewing these guides before contributing to
- [SQL](sql.md) - Database and SQL patterns
- [DSL Filtering to SQL](dslfilteringtosql.md) - Compiling the list filter DSL to relational-store WHERE clauses
- [Types](types.md) - Domain types, request/response bodies, and storage rows in `pkg/types/`
sdsdcdsc

View File

@@ -80,15 +80,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
Active: aiObservability,
Usage: 0,
UsageLimit: -1,
Route: "",
})
metricsReduction := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableMetricsReduction, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableMetricsReduction.String()),

View File

@@ -8,7 +8,6 @@ import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
@@ -44,7 +43,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
const mapRoutes = useMemo(
() =>
new Map(
@@ -135,14 +133,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return <Redirect to={ROUTES.HOME} />;
}
if (
(pathname.startsWith(`${ROUTES.AI_OBSERVABILITY_BASE}/`) ||
pathname === ROUTES.AI_OBSERVABILITY_BASE) &&
!isAIObservabilityEnabled
) {
return <Redirect to={ROUTES.HOME} />;
}
// Check for workspace access restriction (cloud only)
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;

View File

@@ -8,6 +8,5 @@ export enum FeatureKeys {
PREMIUM_SUPPORT = 'premium_support',
ANOMALY_DETECTION = 'anomaly_detection',
USE_JSON_BODY = 'use_json_body',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
}

View File

@@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { Tabs } from 'antd';
import logEvent from 'api/common/logEvent';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import AttributeMappingActions from './components/AttributeMappingActions/AttributeMappingActions';
@@ -20,6 +21,10 @@ function LLMObservabilityAttributeMapping(): JSX.Element {
const groupDrawer = useGroupFormDrawer();
const spanTest = useTestSpanMapper(editor.snapshot, editor.groups);
useEffect(() => {
void logEvent('AI Observability Attribute Mapping: Page visited', {});
}, []);
const { discard } = editor;
// Discarding wipes the whole working copy, so gate it behind a confirm
// prompt rather than firing straight from the button.

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import logEvent from 'api/common/logEvent';
import {
RenderErrorResponseDTO,
SpantypesSpanMapperTestSpanDTO,
@@ -126,6 +127,7 @@ export function useTestSpanMapper(
{ data: body },
{
onSuccess: (response) => {
void logEvent('AI Observability Attribute Mapping: Test run', {});
setTestedAttributes(submittedAttributes);
setTestedResource(submittedResource);
setResult(response.data?.spans ?? []);

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { cloneDeep, isEqual } from 'lodash-es';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import logEvent from 'api/common/logEvent';
import {
useCreateSpanMapper,
useCreateSpanMapperGroup,
@@ -262,6 +263,7 @@ export function useAttributeMappingEditor(): AttributeMappingEditor {
setSaveError(null);
try {
await persistDraft(snapshot, draft, mutations);
void logEvent('AI Observability Attribute Mapping: Changes saved', {});
// Refresh the groups list in place — it stays mounted, so this just
// swaps in fresh data without a loading flash. Using the query's own
// refetch keeps it scoped to the groups list; the per-group mapper

View File

@@ -173,7 +173,7 @@ function Explorer(): JSX.Element {
useEffect(() => {
if (!logEventCalledRef.current) {
logEvent('Traces Explorer: Page visited', {});
logEvent('AI Observability Explorer: Page visited', {});
logEventCalledRef.current = true;
}
}, []);

View File

@@ -173,7 +173,7 @@ function ListView({
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
void logEvent('AI Observability Explorer: Data present', {
panelType,
});
}

View File

@@ -159,7 +159,7 @@ function TracesView({
useEffect(() => {
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
void logEvent('Traces Explorer: Data present', {
void logEvent('AI Observability Explorer: Data present', {
panelType: 'TRACE',
});
}

View File

@@ -1,4 +1,6 @@
import { useEffect } from 'react';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import Spinner from 'components/Spinner';
import DashboardContainer from 'pages/DashboardPage/DashboardContainer';
@@ -8,6 +10,10 @@ import styles from './Overview.module.scss';
function Overview(): JSX.Element {
const { dashboard, isLoading, isError, error, refetch } = useSystemDashboard();
useEffect(() => {
void logEvent('AI Observability Overview: Page visited', {});
}, []);
const renderContent = (): JSX.Element => {
if (isLoading) {
return <Spinner tip="Loading dashboard..." />;

View File

@@ -1,5 +1,7 @@
import { useEffect } from 'react';
import { Badge } from '@signozhq/ui/badge';
import { Tabs } from 'antd';
import logEvent from 'api/common/logEvent';
import { useListUnmappedLLMModels } from 'api/generated/services/llmpricingrules';
import { parseAsStringEnum, useQueryState } from 'nuqs';
@@ -20,6 +22,10 @@ function LLMObservabilityModelPricing(): JSX.Element {
const { data } = useListUnmappedLLMModels();
const unpricedCount = data?.data?.items?.length ?? 0;
useEffect(() => {
void logEvent('AI Observability Model Pricing: Page visited', {});
}, []);
return (
<div
className={styles.llmObservabilityModelPricing}

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import logEvent from 'api/common/logEvent';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
@@ -94,6 +95,10 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
await createOrUpdate({
data: { rules: [buildRulePayload(draft)] },
});
void logEvent('AI Observability Model Pricing: Model cost saved', {
mode,
modelName: draft.modelName,
});
await invalidateList();
setIsOpen(false);
setSelectedRuleId(null);

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import logEvent from 'api/common/logEvent';
import {
getListLLMPricingRulesQueryKey,
useDeleteLLMPricingRule,
@@ -46,6 +47,9 @@ export function useModelCostDelete(): UseModelCostDeleteResult {
}
try {
await deleteRuleApi({ pathParams: { id: pendingDelete.id } });
void logEvent('AI Observability Model Pricing: Model cost deleted', {
modelName: pendingDelete.modelName,
});
await queryClient.invalidateQueries({
queryKey: getListLLMPricingRulesQueryKey(),
});

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { useQueryClient } from 'react-query';
import logEvent from 'api/common/logEvent';
import {
getListLLMPricingRulesQueryKey,
getListUnmappedLLMModelsQueryKey,
@@ -45,6 +46,10 @@ export function useUnpricedModelMapping(): UseUnpricedModelMappingResult {
setIsSaving(true);
try {
await createOrUpdate({ data: { rules: [payload] } });
void logEvent('AI Observability Model Pricing: Unpriced model mapped', {
modelName: model.modelName,
billingModelName: rule.modelName,
});
await Promise.all([
queryClient.invalidateQueries({
queryKey: getListUnmappedLLMModelsQueryKey(),

View File

@@ -47,7 +47,6 @@ import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useIsAIObservabilityEnabled } from 'hooks/useIsAIObservabilityEnabled';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import { isArray } from 'lodash-es';
@@ -255,7 +254,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const isAdmin = user.role === USER_ROLES.ADMIN;
const isEditor = user.role === USER_ROLES.EDITOR;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const isAIObservabilityEnabled = useIsAIObservabilityEnabled();
const aiAssistantActiveConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
@@ -295,9 +293,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
if (item.key === ROUTES.INTEGRATIONS) {
return shouldShowIntegrationsValue;
}
if (item.key === ROUTES.AI_OBSERVABILITY_OVERVIEW) {
return isAIObservabilityEnabled;
}
return item.isEnabled;
};
@@ -314,7 +309,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
isEnterpriseSelfHostedUser,
isAdmin,
isEditor,
isAIObservabilityEnabled,
]);
// Track if we've done the initial sync (to avoid overwriting user actions during session)

View File

@@ -293,9 +293,7 @@ export const defaultMoreMenuItems: SidebarItem[] = [
label: 'AI Observability',
icon: <Brain size={16} />,
isBeta: true,
// Gated behind the `enable_ai_observability` feature flag in
// SideNav's `computedSecondaryMenuItems`; disabled by default.
isEnabled: false,
isEnabled: true,
itemKey: 'ai-observability',
},
{

View File

@@ -1,11 +0,0 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
export function useIsAIObservabilityEnabled(): boolean {
const { featureFlags } = useAppContext();
return (
featureFlags?.find(
(flag) => flag.name === FeatureKeys.ENABLE_AI_OBSERVABILITY,
)?.active || false
);
}

View File

@@ -0,0 +1,113 @@
import type { PropsWithChildren } from 'react';
import { QueryClient, QueryClientProvider, UseQueryResult } from 'react-query';
import { renderHook, RenderHookResult, waitFor } from '@testing-library/react';
import { AxiosError, AxiosHeaders } from 'axios';
import { queryRangeV5 } from 'api/generated/services/querier';
import type {
Querybuildertypesv5QueryRangeRequestDTO,
QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { useGetQueryRangeV5 } from '../useGetQueryRangeV5';
jest.mock('api/generated/services/querier', () => ({
queryRangeV5: jest.fn(),
}));
const mockQueryRangeV5 = queryRangeV5 as jest.Mock;
const REQUEST = {} as Querybuildertypesv5QueryRangeRequestDTO;
const KEY_A = ['query-range', 'panel-1', 'window-a'];
const KEY_B = ['query-range', 'panel-1', 'window-b'];
function clientError(): AxiosError {
return new AxiosError('bad query', 'ERR_BAD_REQUEST', undefined, undefined, {
status: 400,
statusText: 'Bad Request',
data: {},
headers: {},
config: { headers: new AxiosHeaders() },
});
}
interface Props {
enabled: boolean;
queryKey?: unknown[];
}
function renderQuery(
initial: Props,
client = new QueryClient(),
): RenderHookResult<UseQueryResult<QueryRangeV5200, Error>, Props> {
const wrapper = ({ children }: PropsWithChildren): JSX.Element => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return renderHook(
({ enabled, queryKey = KEY_A }: Props) =>
useGetQueryRangeV5({ requestPayload: REQUEST, queryKey, enabled }),
{ wrapper, initialProps: initial },
);
}
describe('useGetQueryRangeV5 enabled gating', () => {
beforeEach(() => {
mockQueryRangeV5.mockReset();
});
it('does not fetch while disabled and fetches once when enabled', async () => {
mockQueryRangeV5.mockResolvedValue({ status: 'success', data: {} });
const { rerender } = renderQuery({ enabled: false });
expect(mockQueryRangeV5).not.toHaveBeenCalled();
rerender({ enabled: true });
await waitFor(() => expect(mockQueryRangeV5).toHaveBeenCalledTimes(1));
});
it('serves a successful key from cache when re-enabled', async () => {
mockQueryRangeV5.mockResolvedValue({ status: 'success', data: {} });
const { result, rerender } = renderQuery({ enabled: true });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
rerender({ enabled: false });
rerender({ enabled: true });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(mockQueryRangeV5).toHaveBeenCalledTimes(1);
});
it('does not re-run an errored key when re-enabled', async () => {
mockQueryRangeV5.mockRejectedValue(clientError());
const { result, rerender } = renderQuery({ enabled: true });
await waitFor(() => expect(result.current.isError).toBe(true));
rerender({ enabled: false });
rerender({ enabled: true });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(mockQueryRangeV5).toHaveBeenCalledTimes(1);
expect(result.current.isError).toBe(true);
});
it('still gates a new key while disabled after a prior key errored', async () => {
mockQueryRangeV5.mockRejectedValue(clientError());
const { result, rerender } = renderQuery({ enabled: true });
await waitFor(() => expect(result.current.isError).toBe(true));
rerender({ enabled: false });
rerender({ enabled: false, queryKey: KEY_B });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(mockQueryRangeV5).toHaveBeenCalledTimes(1);
rerender({ enabled: true, queryKey: KEY_B });
await waitFor(() => expect(mockQueryRangeV5).toHaveBeenCalledTimes(2));
});
it('re-runs an errored key on manual refetch', async () => {
mockQueryRangeV5.mockRejectedValue(clientError());
const { result } = renderQuery({ enabled: true });
await waitFor(() => expect(result.current.isError).toBe(true));
mockQueryRangeV5.mockResolvedValue({ status: 'success', data: {} });
await result.current.refetch();
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(mockQueryRangeV5).toHaveBeenCalledTimes(2);
});
});

View File

@@ -1,4 +1,4 @@
import { useQuery, UseQueryResult } from 'react-query';
import { useQuery, useQueryClient, UseQueryResult } from 'react-query';
import { isAxiosError } from 'axios';
import { queryRangeV5 } from 'api/generated/services/querier';
import type {
@@ -50,10 +50,15 @@ export function useGetQueryRangeV5({
keepPreviousData,
cacheTime,
}: UseGetQueryRangeV5Args): UseQueryResult<QueryRangeV5200, Error> {
const queryClient = useQueryClient();
// An errored key has no data, so it is always stale; keep it enabled so
// re-enabling doesn't re-run it.
const hasErrored = queryClient.getQueryState(queryKey)?.status === 'error';
return useQuery<QueryRangeV5200, Error>({
queryKey,
queryFn: ({ signal }) => queryRangeV5(requestPayload, signal),
enabled,
enabled: enabled || hasErrored,
retry: retryUnlessClientError,
keepPreviousData,
cacheTime,

View File

@@ -9,7 +9,6 @@ var (
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureResolveSemconvFamilies = featuretypes.MustNewName("resolve_semconv_families")
)
@@ -64,14 +63,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureEnableAIObservability,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether ai observability is enabled",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureEnableMetricsReduction,
Kind: featuretypes.KindBoolean,

View File

@@ -1478,15 +1478,6 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
aiObservability := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),
Active: aiObservability,
Usage: 0,
UsageLimit: -1,
Route: "",
})
aH.Respond(w, featureSet)
}

View File

@@ -1,39 +0,0 @@
# TODO: remove this file once enable_ai_observability flag is removed or defaulted to True
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="signoz", scope="package")
def signoz_e2e( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
E2E-scoped SigNoz override. Enables the experimental AI/LLM Observability
module (disabled by default in pkg/flagger/registry.go) so its routes render
instead of redirecting to /home — required by the llm-o11y e2e specs. Scoped
to the e2e package via this conftest so normal integration tests keep the
stock feature set. Follows the same pattern as
tests/integration/tests/metricreduction/conftest.py.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz_e2e",
env_overrides={
"SIGNOZ_FLAGGER_CONFIG_BOOLEAN_ENABLE__AI__OBSERVABILITY": True,
},
)

View File

@@ -9,6 +9,7 @@ import { authToken } from '../../helpers/common';
test.describe.configure({ mode: 'serial' });
const GROUP_NAME = 'e2e-attr-mapping-happy';
const GROUP_CONDITION = 'my_company.llm.';
const TARGET_ATTR = 'gen_ai.content.prompt';
const SOURCE_ATTR = 'my_company.llm.input';
@@ -34,6 +35,8 @@ test.describe('LLM Observability — Attribute Mapping', () => {
const groupDrawer = page.getByTestId('group-form-drawer');
await expect(groupDrawer).toBeVisible();
await page.getByTestId('group-form-name').fill(GROUP_NAME);
await page.getByTestId('group-form-attribute-add').click();
await page.getByTestId('group-form-attribute-0').fill(GROUP_CONDITION);
await page.getByTestId('group-form-save').click();
await expect(groupDrawer).toBeHidden();