Compare commits

...

9 Commits

Author SHA1 Message Date
vikrantgupta25
e7443ab1cd fix(tokenizer): persist last_observed_at on postgres
Values in the flush CTE were untyped literals, which postgres resolves
to text and refuses to assign to a timestamptz column. Building the
CTE from the token model lets bun cast per dialect.
2026-09-25 11:57:44 +05:30
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
Abhi kumar
370b278f28 fix(dashboard): reserve legend rows the grid actually lays out (#12951)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Bottom legends could silently drop series. The legend box reserved
fewer rows than the grid actually laid out, and the surplus row was
clipped away by the wrapper's `overflow: hidden` — nothing indicated the
series were still there apart from a scrollbar.
- The cause is two different formulas for the same quantity: how many
legend items fit on one row. The height reservation in
`calculateChartDimensions` used `floor((containerWidth - padding) /
itemWidth)`. The grid resolves `auto-fill` over `--legend-item-width`,
which is `itemWidth + LEGEND_ITEM_EXTRA_WIDTH`, separated by a column
gap, inside a scroller with its own gutter. Ignoring the extra width,
the gap and the gutter, the reservation over-counts and reserves one row
where the grid needs two.
- The two formulas only diverge over a narrow band of widths, so the
failure is width-dependent and its boundary is a single pixel. A panel
sitting near that boundary flips between states as the layout reflows,
which is seen as flickering rather than as a fixed layout bug.
- Fix: `legendItemsPerRow` now mirrors the `auto-fill` track count. The
two CSS values it depends on are pinned as constants beside the existing
`LEGEND_ROW_HEIGHT` / `LEGEND_ROW_GAP`, which already carry the same
"must match the stylesheet" caveat.

#### Additional Information

- Adds a regression test at a width where the two formulas diverge; it
fails on `main`.
- Two pre-existing gaps left out of scope and unchanged by this PR:
- `MAX_SHORT_PANEL_LEGEND_RATIO` deliberately reserves a single row on
very short panels while the grid still lays out two, so the clip remains
there. Closing it needs somewhere for the dropped row's series to go —
an overflow affordance, which is a design decision.
2026-09-23 02:22:18 +00:00
Pandey
f2229a1064 fix(analytics): format segment logger messages before passing to slog (#12950)
#### Description

- segment's `Logger` interface is printf-style, but the adapter passed
`format` as the slog message and `args` as key-value pairs.
- slog never substituted the `%d` placeholders and rendered each
positional arg as a `!BADKEY` attr.
- `Logf` and `Errorf` now `fmt.Sprintf` the message first, matching the
opamp logger adapter.
2026-09-22 19:18:36 +00:00
Vinicius Lourenço
099832b26b chore(codeowners): change ownership of storybook (#12949)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Description

Add myself as owner of storybook structural files, the stories still
belongs to each pod.
2026-09-22 18:04:23 +00:00
Ashwin Bhatkal
057571cf6d fix(dashboard): restore related values and API search in dynamic variable dropdowns (#12935)
#### Description

The V1 to V2 dashboard rewrite carried over the *request* for a dynamic
variable's values but not the *response* handling — `relatedValues` and
`complete` were fetched and then thrown away. Both issues below are that
single regression.

- **Related values.** The dropdown now splits a dynamic variable's
values into "Related Values" (scoped by the sibling dynamic variables'
selections) and "All Values", as V1 did. The `existingQuery` that scopes
them was already being sent; only the response was ignored. Worth
knowing while reviewing: the backend never narrows the main list by
`existingQuery` — `GetAllValues` doesn't see it, and `GetRelatedValues`
returns nothing when it is empty — so the scoping is only ever visible
as the second section.
- **Value search.** A variable whose list the backend truncated
(`complete: false`) could only be filtered against the values already
fetched, so typing anything outside that first batch found nothing.
Search now goes to the API. It runs on its own react-query, deliberately
not the fetch engine's, so a keystroke cannot settle the variable's
fetch cycle and re-cascade its dependent variables and panels.
- **Retry action.** Restores V1's gating: the shared select defaults
`showRetryButton` to `true`, so a 4xx offered a retry that could only
fail again.

Commits are split by concern in that order.

#### Screen Recording


https://github.com/user-attachments/assets/ef51f481-de66-4334-9a59-dc98a7c7e50f

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/352
Closes https://github.com/SigNoz/pulse-pod/issues/249
2026-09-22 17:31:51 +00:00
41 changed files with 788 additions and 136 deletions

6
.github/CODEOWNERS vendored
View File

@@ -280,3 +280,9 @@ go.mod @therealpandey
/frontend/src/components/MessagingQueues/ @SigNoz/events-frontend
/frontend/src/components/MessagingQueueHealthCheck/ @SigNoz/events-frontend
/frontend/src/hooks/messagingQueue/ @SigNoz/events-frontend
## Storybook
/frontend/.storybook/ @H4ad
/frontend/src/storybook/ @H4ad
/.claude/skills/signoz-page-story/ @H4ad
/.claude/skills/storybook-visual-diff/ @H4ad

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

@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CustomSelect from '../CustomSelect';
@@ -203,4 +204,21 @@ describe('CustomSelect Component', () => {
// Check onChange was called
expect(handleChange).toHaveBeenCalled();
});
it('tells the consumer its search was cleared when the dropdown closes', async () => {
// The component clears its own search text on close. A consumer running a
// server-side search needs to hear that, or its results outlive the dropdown.
const onSearch = jest.fn();
const user = userEvent.setup();
render(<CustomSelect options={mockOptions} onSearch={onSearch} />);
const selectElement = screen.getByRole('combobox');
await user.click(selectElement);
await user.type(selectElement, 'opt');
expect(onSearch).toHaveBeenLastCalledWith('opt');
await user.keyboard('{Escape}');
expect(onSearch).toHaveBeenLastCalledWith('');
});
});

View File

@@ -258,6 +258,10 @@ $custom-border-color: #2c3044;
overflow: hidden;
.group-label {
display: flex;
align-items: center;
gap: 4px;
font-weight: 500;
padding: 4px 12px;
font-size: 13px;
@@ -442,7 +446,7 @@ $custom-border-color: #2c3044;
.group-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-weight: 500;
padding: 4px 12px;

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

@@ -10,6 +10,11 @@ export const MIN_LEGEND_ITEM_WIDTH = 110;
/** Marker + row padding, on top of the estimated label width. */
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
/** Must match `.gridList`'s column gap and `.scroller`'s padding-right, or the
* reserved row count disagrees with the grid that gets laid out. */
export const LEGEND_COLUMN_GAP = 8;
export const LEGEND_SCROLLER_PADDING_RIGHT = 4;
/** Must match `.row`'s height and the grid's row gap, or the reserved
* rectangle clips a row. */
export const LEGEND_ROW_HEIGHT = 28;

View File

@@ -113,7 +113,7 @@ describe('calculateChartDimensions', () => {
});
it('BOTTOM: items one past a row still reserve two rows', () => {
// 1000px wide fits 5 of these per row, so 6 items need a second row.
// 1000px wide fits 4 of these per row, so 6 items need a second row.
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
@@ -123,6 +123,19 @@ describe('calculateChartDimensions', () => {
expect(dims.legendHeight).toBe(70);
});
it('BOTTOM: reserves the rows the grid actually lays out, not the rows a bare width estimate allows', () => {
// The item width alone suggests three fit on one row; the grid's per-item
// padding and column gap leave room for two.
const dims = calculateChartDimensions({
containerWidth: 412,
containerHeight: 310,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: ['P99', 'P95', 'P50'],
});
expect(dims.legendHeight).toBe(70);
expect(dims.height).toBe(240);
});
it('BOTTOM: drops to a single row rather than take half a short panel', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,

View File

@@ -1,8 +1,11 @@
import {
LEGEND_MAX_BOTTOM_ROWS,
MIN_LEGEND_ITEM_WIDTH,
LEGEND_COLUMN_GAP,
LEGEND_ITEM_EXTRA_WIDTH,
LEGEND_ROW_GAP,
LEGEND_ROW_HEIGHT,
LEGEND_SCROLLER_PADDING_RIGHT,
MAX_LEGEND_WIDTH,
} from 'lib/uPlotV2/components/Legend/constants';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
@@ -143,9 +146,16 @@ export function calculateChartDimensions({
const legendItemWidth = Math.ceil(
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
);
// Must resolve to the same track count as `.gridList`'s `auto-fill`; a more
// generous one under-reserves rows and the grid's last row is clipped away.
const gridWidth =
containerWidth - LEGEND_PADDING * 2 - LEGEND_SCROLLER_PADDING_RIGHT;
const legendItemsPerRow = Math.max(
1,
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
Math.floor(
(gridWidth + LEGEND_COLUMN_GAP) /
(legendItemWidth + LEGEND_ITEM_EXTRA_WIDTH + LEGEND_COLUMN_GAP),
),
);
// The wrapper's bottom padding is inside this height (border-box).
@@ -163,8 +173,8 @@ export function calculateChartDimensions({
);
// Without this, short grid panels hand most of their area to the legend and
// the chart — the pie donut especially — collapses to a sliver. Dropping a
// whole row beats clipping one.
// the chart — the pie donut especially — collapses to a sliver. The dropped
// row's items are clipped rather than removed, so they are scroll-only here.
const legendRowCount =
neededRowCount > 1 &&
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO

View File

@@ -35,6 +35,12 @@ function renderSelector(
);
}
async function openDropdown(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
}
/** Hovers an element and lets the tooltip's open delay elapse. */
async function hover(element: HTMLElement): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
@@ -112,17 +118,95 @@ describe('ValueSelector', () => {
});
});
describe('a dynamic variable', () => {
function renderDynamic(
complete: boolean,
relatedValues: string[],
): jest.Mock {
const onSearch = jest.fn();
render(
<TooltipProvider>
<ValueSelector
options={OPTIONS}
variableType="dynamic"
multiSelect
showAllOption
selection={{ value: [], allSelected: false }}
onChange={jest.fn()}
emptyFallback={{ value: [], allSelected: false }}
testId="variable-select-env"
dynamic={{
values: OPTIONS,
relatedValues,
complete,
onSearch,
onSearchReset: jest.fn(),
}}
/>
</TooltipProvider>,
);
return onSearch;
}
it('splits related values out of the full list', async () => {
renderDynamic(true, ['checkout-service-prod']);
await openDropdown();
expect(
screen.getByRole('heading', { level: 2, name: /Related Values/ }),
).toBeInTheDocument();
expect(
screen.getByRole('heading', { level: 2, name: /All Values/ }),
).toBeInTheDocument();
});
it('still opens its dropdown in single-select', async () => {
// The shared single select spreads unknown props over its own handlers, so
// passing it an `onDropdownVisibleChange` silently kills its open state.
render(
<TooltipProvider>
<ValueSelector
options={OPTIONS}
variableType="dynamic"
multiSelect={false}
showAllOption={false}
selection={{ value: '', allSelected: false }}
onChange={jest.fn()}
emptyFallback={{ value: '', allSelected: false }}
testId="variable-select-env"
dynamic={{
values: OPTIONS,
relatedValues: [],
complete: false,
onSearch: jest.fn(),
onSearchReset: jest.fn(),
}}
/>
</TooltipProvider>,
);
await openDropdown();
expect(screen.getByText('cart-service-prod')).toBeInTheDocument();
});
it('routes typing to the API search when the list is truncated', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const onSearch = renderDynamic(false, []);
await openDropdown();
await user.keyboard('pay');
expect(onSearch).toHaveBeenLastCalledWith('pay');
});
});
describe('clearing', () => {
function clearIcon(): Element | null {
return document.querySelector('.ant-select-clear');
}
async function openDropdown(): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const control = screen.getByTestId('variable-select-env');
await user.click(control.querySelector('input') as HTMLInputElement);
}
it('offers no clear icon while the list is closed', () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);

View File

@@ -114,4 +114,149 @@ describe('useFetchedVariableOptions', () => {
await waitFor(() => expect(result.current.options).toStrictEqual(['prod']));
});
it('keeps related values as their own section and as selectable options', async () => {
mockGetFieldValues.mockResolvedValue({
data: {
normalizedValues: ['cart', 'payments'],
relatedValues: ['checkout'],
complete: true,
},
});
useDashboardStore.setState({
variableFetchStates: { env: VariableFetchState.Loading },
variableCycleIds: { env: 1 },
});
const variable = dynamicVariable('env');
const { result } = renderHook(
() => useFetchedVariableOptions(variable, [variable], {}),
{ wrapper },
);
await waitFor(() =>
expect(result.current.dynamic?.relatedValues).toStrictEqual(['checkout']),
);
expect(result.current.dynamic?.values).toStrictEqual(['cart', 'payments']);
// A related value the unscoped list never returned is still selectable.
expect(result.current.options).toStrictEqual([
'cart',
'payments',
'checkout',
]);
});
it('sends the search to the API when the list is incomplete', async () => {
mockGetFieldValues.mockImplementation((_signal, _name, searchText) =>
Promise.resolve({
data: searchText
? { normalizedValues: ['payments'], relatedValues: [], complete: false }
: { normalizedValues: ['cart'], relatedValues: [], complete: false },
}),
);
useDashboardStore.setState({
variableFetchStates: { env: VariableFetchState.Loading },
variableCycleIds: { env: 1 },
});
const variable = dynamicVariable('env');
const { result } = renderHook(
() => useFetchedVariableOptions(variable, [variable], {}),
{ wrapper },
);
await waitFor(() =>
expect(result.current.dynamic?.values).toStrictEqual(['cart']),
);
act(() => {
result.current.dynamic?.onSearch('pay');
});
await waitFor(() =>
expect(result.current.dynamic?.values).toStrictEqual(['payments']),
);
expect(mockGetFieldValues).toHaveBeenCalledWith(
undefined,
'service.name',
'pay',
1_000,
2_000,
undefined,
expect.anything(),
);
// The search narrows the dropdown only — the selectable set is the full list,
// so a pick made before searching is never reconciled away.
expect(result.current.options).toStrictEqual(['cart']);
// Clearing falls straight back to the base fetch's options — synchronously, so
// closing the dropdown cannot leave the last search's results on screen for a
// debounce interval. They come from the cache of a separate query the search
// never touched, so nothing is refetched.
act(() => {
result.current.dynamic?.onSearchReset();
});
expect(result.current.dynamic?.values).toStrictEqual(['cart']);
expect(mockGetFieldValues).toHaveBeenCalledTimes(2);
});
it('marks a client error as not retryable', async () => {
mockGetFieldValues.mockRejectedValue(
Object.assign(new Error('bad request'), { response: { status: 400 } }),
);
useDashboardStore.setState({
variableFetchStates: { env: VariableFetchState.Loading },
variableCycleIds: { env: 1 },
});
const variable = dynamicVariable('env');
const { result } = renderHook(
() => useFetchedVariableOptions(variable, [variable], {}),
{ wrapper },
);
await waitFor(() => expect(result.current.isRetryable).toBe(false));
});
it('scopes the fetch by a sibling dynamic selection, skipping ALL', async () => {
mockGetFieldValues.mockResolvedValue(fieldValues(['cart']));
useDashboardStore.setState({
variableFetchStates: { env: VariableFetchState.Loading },
variableCycleIds: { env: 1 },
});
const env = dynamicVariable('env');
const namespace: VariableFormModel = {
...dynamicVariable('namespace'),
dynamicAttribute: 'k8s.namespace.name',
};
const region: VariableFormModel = {
...dynamicVariable('region'),
dynamicAttribute: 'cloud.region',
};
renderHook(
() =>
useFetchedVariableOptions(env, [env, namespace, region], {
namespace: { value: ['prod'], allSelected: false },
// ALL means "no filter", so it contributes nothing to existingQuery —
// which is why the backend returns no related values for it.
region: { value: null, allSelected: true },
}),
{ wrapper },
);
await waitFor(() =>
expect(mockGetFieldValues).toHaveBeenCalledWith(
undefined,
'service.name',
undefined,
1_000,
2_000,
"k8s.namespace.name = 'prod'",
),
);
});
});

View File

@@ -4,7 +4,9 @@ import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import type { OptionData } from 'components/NewSelect/types';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import type { DynamicVariableOptions } from '../../hooks/useFetchedVariableOptions';
import type { VariableSelection } from '../../selectionTypes';
import { dynamicVariableOptions } from '../../utils/dynamicVariableOptions';
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
import { selectionFromCommittedValues } from '../../utils/selectionUtils';
import OverflowValuesTooltip from './OverflowValuesTooltip';
@@ -24,6 +26,10 @@ interface ValueSelectorProps {
/** Option-fetch error surfaced in the dropdown, with a retry action. */
errorMessage?: string | null;
onRetry?: () => void;
/** Hides the retry action for an error that retrying cannot fix. */
isRetryable?: boolean;
/** DYNAMIC only: sectioned rendering and server-side search. */
dynamic?: DynamicVariableOptions;
}
function ValueSelector({
@@ -38,10 +44,15 @@ function ValueSelector({
testId,
errorMessage,
onRetry,
isRetryable = true,
dynamic,
}: ValueSelectorProps): JSX.Element {
const optionData = useMemo<OptionData[]>(
() => options.map((option) => ({ label: option, value: option })),
[options],
() =>
dynamic
? dynamicVariableOptions(dynamic.values, dynamic.relatedValues)
: options.map((option) => ({ label: option, value: option })),
[options, dynamic],
);
// All-selected → the full option set so CustomMultiSelect engages its "all"
@@ -119,6 +130,7 @@ function ValueSelector({
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
showRetryButton={isRetryable}
showSearch
// Clearing belongs to the open list: on the closed control the icon would
// appear on hover, in a row of variable pills, for an action whose result is
@@ -136,6 +148,11 @@ function ValueSelector({
)}
// Offer ALL only once options load, else a concrete value reads as "all".
enableAllSelection={showAllOption && options.length > 0}
isDynamicVariable={!!dynamic}
onSearch={dynamic?.onSearch}
showIncompleteDataMessage={
!!dynamic && !dynamic.complete && dynamic.values.length > 0
}
onDropdownVisibleChange={(open): void => {
if (open) {
setDraft(committedValues);
@@ -144,6 +161,7 @@ function ValueSelector({
}
setIsOpen(false);
dynamic?.onSearchReset();
commit(draft);
}}
onChange={(next): void => {
@@ -180,8 +198,14 @@ function ValueSelector({
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
showRetryButton={isRetryable}
showSearch
placeholder="Select value"
isDynamicVariable={!!dynamic}
onSearch={dynamic?.onSearch}
showIncompleteDataMessage={
!!dynamic && !dynamic.complete && dynamic.values.length > 0
}
onChange={(next): void => {
void logEvent(
DashboardDetailEvents.VariableValueSelected,

View File

@@ -42,11 +42,8 @@ function VariableValueControl({
onChange,
onAutoSelect,
}: VariableValueControlProps): JSX.Element {
const { options, loading, errorMessage, onRetry } = useVariableOptions(
variable,
variables,
selections,
);
const { options, loading, errorMessage, onRetry, isRetryable, dynamic } =
useVariableOptions(variable, variables, selections);
useAutoSelect(variable, options, selection, onAutoSelect);
@@ -65,6 +62,8 @@ function VariableValueControl({
loading={loading}
errorMessage={errorMessage}
onRetry={onRetry}
isRetryable={isRetryable}
dynamic={dynamic}
selection={selection}
onChange={onChange}
emptyFallback={emptyFallback}

View File

@@ -0,0 +1,89 @@
import { useCallback, useState } from 'react';
import { useQuery } from 'react-query';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import useDebounce from 'hooks/useDebounce';
interface UseDynamicVariableSearchProps {
signal?: 'traces' | 'logs' | 'metrics';
attribute?: string;
startUnixMilli: number;
endUnixMilli: number;
existingQuery?: string;
/** Only a truncated list needs the API — a complete one is filtered in the dropdown. */
enabled: boolean;
}
export interface DynamicVariableSearch {
/** Results while a server search is in effect, else null — render the base options. */
results: { values: string[]; relatedValues: string[] } | null;
isSearching: boolean;
onSearch: (text: string) => void;
reset: () => void;
}
/**
* Server-side value search for a DYNAMIC variable, deliberately kept off the fetch
* engine's own query: a keystroke must not settle the variable's fetch cycle and
* re-cascade its dependent variables and panels.
*/
export function useDynamicVariableSearch({
signal,
attribute,
startUnixMilli,
endUnixMilli,
existingQuery,
enabled,
}: UseDynamicVariableSearchProps): DynamicVariableSearch {
const [searchText, setSearchText] = useState('');
const debouncedSearchText = useDebounce(searchText, DEBOUNCE_DELAY);
const isActive =
enabled && !!attribute && !!searchText && !!debouncedSearchText;
const { data, isFetching } = useQuery(
[
'dashboard-variable-dynamic-search',
signal,
attribute,
debouncedSearchText,
existingQuery,
startUnixMilli,
endUnixMilli,
],
({ signal: abortSignal }) =>
getFieldValues(
signal,
attribute,
debouncedSearchText,
startUnixMilli,
endUnixMilli,
existingQuery,
abortSignal,
),
{ enabled: isActive, refetchOnWindowFocus: false, keepPreviousData: true },
);
const reset = useCallback((): void => setSearchText(''), []);
// No results yet falls back to the base options rather than an empty dropdown:
// the select filters them locally, so the list narrows while the API answers.
const results = isActive ? data?.data : undefined;
if (!results) {
return {
results: null,
isSearching: isActive && isFetching,
onSearch: setSearchText,
reset,
};
}
return {
results: {
values: results.normalizedValues ?? [],
relatedValues: results.relatedValues ?? [],
},
isSearching: isFetching,
onSearch: setSearchText,
reset,
};
}

View File

@@ -9,6 +9,7 @@ import {
DASHBOARD_CACHE_TIME_ON_REFRESH_ENABLED,
} from 'constants/queryCacheTime';
import type { AppState } from 'store/reducers';
import { isRetryableError } from 'utils/errorUtils';
import type { GlobalReducer } from 'types/reducer/globalTime';
import {
@@ -20,13 +21,29 @@ import { useDashboardStore } from '../../store/useDashboardStore';
import { buildExistingDynamicVariableQuery } from '../utils/dynamicFilter';
import type { VariableSelectionMap } from '../selectionTypes';
import { selectionToPayload } from '../utils/selectionUtils';
import { useDynamicVariableSearch } from './useDynamicVariableSearch';
import { useVariableFetchState } from './useVariableFetchState';
export interface DynamicVariableOptions {
/** ALL VALUES section — narrowed to the API's matches while a search is active. */
values: string[];
/** RELATED VALUES section — scoped by the sibling dynamic variables' selections. */
relatedValues: string[];
/** false when the backend truncated the list, so searching has to hit the API. */
complete: boolean;
onSearch: (text: string) => void;
onSearchReset: () => void;
}
export interface VariableOptions {
options: string[];
loading: boolean;
errorMessage: string | null;
onRetry?: () => void;
/** false for a client error, where retrying the same request cannot help. */
isRetryable?: boolean;
/** DYNAMIC only: what the dropdown renders, sectioned and search-aware. */
dynamic?: DynamicVariableOptions;
}
/**
@@ -150,10 +167,68 @@ export function useFetchedVariableOptions(
return sortValuesByOrder(values, variable.sort).map(String);
}, [dynamicResult.data, variable.sort]);
const dynamicRelatedOptions = useMemo(
() =>
sortValuesByOrder(
dynamicResult.data?.data?.relatedValues ?? [],
variable.sort,
).map(String),
[dynamicResult.data, variable.sort],
);
// Related values are scoped by the sibling selections, so they can name values the
// unscoped list never returned — the selectable set is the union of both sections.
const dynamicSelectableOptions = useMemo(
() => [...new Set([...dynamicOptions, ...dynamicRelatedOptions])],
[dynamicOptions, dynamicRelatedOptions],
);
const isDynamicListComplete = dynamicResult.data?.data?.complete ?? true;
const search = useDynamicVariableSearch({
signal: signalForApi(variable.dynamicSignal),
attribute: variable.dynamicAttribute,
startUnixMilli: minTime,
endUnixMilli: maxTime,
existingQuery: existingQuery || undefined,
enabled: variable.type === 'DYNAMIC' && !isDynamicListComplete,
});
// One stable object: the select rebuilds its whole option list whenever this
// identity changes, so it must not be a literal rebuilt on every render.
const dynamicDisplay = useMemo<DynamicVariableOptions>(() => {
const display = search.results
? {
values: sortValuesByOrder(search.results.values, variable.sort).map(
String,
),
relatedValues: sortValuesByOrder(
search.results.relatedValues,
variable.sort,
).map(String),
}
: { values: dynamicOptions, relatedValues: dynamicRelatedOptions };
return {
...display,
complete: isDynamicListComplete,
onSearch: search.onSearch,
onSearchReset: search.reset,
};
}, [
search.results,
search.onSearch,
search.reset,
isDynamicListComplete,
dynamicOptions,
dynamicRelatedOptions,
variable.sort,
]);
// Flag a variable that settled with zero options so dependent panels fall through
// to "no data" instead of waiting forever. hasFetchedOnce excludes the pre-fetch state.
const effectiveOptions =
variable.type === 'DYNAMIC' ? dynamicOptions : queryOptions;
variable.type === 'DYNAMIC' ? dynamicSelectableOptions : queryOptions;
useEffect(() => {
if (variable.type !== 'QUERY' && variable.type !== 'DYNAMIC') {
return;
@@ -175,14 +250,16 @@ export function useFetchedVariableOptions(
if (variable.type === 'DYNAMIC') {
return {
options: dynamicOptions,
loading: dynamicResult.isFetching || isVariableWaiting,
options: dynamicSelectableOptions,
loading: dynamicResult.isFetching || isVariableWaiting || search.isSearching,
errorMessage: dynamicResult.error
? (dynamicResult.error as Error).message || null
: null,
onRetry: (): void => {
void dynamicResult.refetch();
},
isRetryable: !dynamicResult.error || isRetryableError(dynamicResult.error),
dynamic: dynamicDisplay,
};
}
return {
@@ -194,5 +271,6 @@ export function useFetchedVariableOptions(
onRetry: (): void => {
void queryResult.refetch();
},
isRetryable: !queryResult.error || isRetryableError(queryResult.error),
};
}

View File

@@ -0,0 +1,27 @@
import type { OptionData } from 'components/NewSelect/types';
const toOptions = (values: string[]): OptionData[] =>
values.map((value) => ({ label: value, value }));
/**
* Dropdown options for a DYNAMIC variable: values scoped by the other dynamic
* variables' selections get their own section above the unscoped list. Without
* related values there is nothing to contrast, so the list stays flat.
*/
export function dynamicVariableOptions(
values: string[],
relatedValues: string[],
): OptionData[] {
if (relatedValues.length === 0) {
return toOptions(values);
}
return [
{
label: 'Related Values',
value: 'relatedValues',
options: toOptions(relatedValues),
},
{ label: 'All Values', value: 'allValues', options: toOptions(values) },
];
}

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

@@ -2,6 +2,7 @@ package segmentanalytics
import (
"context"
"fmt"
"github.com/SigNoz/signoz/pkg/factory"
segment "github.com/segmentio/analytics-go/v3"
@@ -18,11 +19,11 @@ func newSegmentLogger(settings factory.ScopedProviderSettings) segment.Logger {
}
func (logger *logger) Logf(format string, args ...interface{}) {
// the no lint directive is needed because the segmentlogger is not a slog.Logger
logger.settings.Logger().InfoContext(context.TODO(), format, args...) //nolint:sloglint
// the no lint directive is needed because the segment logger is not a slog.Logger
logger.settings.Logger().InfoContext(context.TODO(), fmt.Sprintf(format, args...)) //nolint:sloglint
}
func (logger *logger) Errorf(format string, args ...interface{}) {
// the no lint directive is needed because the segment logger is not a slog.Logger
logger.settings.Logger().ErrorContext(context.TODO(), format, args...) //nolint:sloglint
logger.settings.Logger().ErrorContext(context.TODO(), fmt.Sprintf(format, args...)) //nolint:sloglint
}

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

@@ -364,12 +364,26 @@ func (provider *provider) gc(ctx context.Context, org *types.Organization) error
}
func (provider *provider) flushLastObservedAt(ctx context.Context, org *types.Organization) error {
accessTokenToLastObservedAt, err := provider.listLastObservedAtDesc(ctx, org.ID)
tokens, err := provider.tokenStore.ListByOrgID(ctx, org.ID)
if err != nil {
return err
}
if err := provider.tokenStore.UpdateLastObservedAtByAccessToken(ctx, accessTokenToLastObservedAt); err != nil {
observedTokens := make([]*authtypes.StorableToken, 0, len(tokens))
for _, token := range tokens {
cachedLastObservedAt, ok := provider.lastObservedAtCache.Get(lastObservedAtCacheKey(token.AccessToken, token.UserID))
if !ok {
continue
}
if err := token.UpdateLastObservedAt(cachedLastObservedAt); err != nil {
continue
}
observedTokens = append(observedTokens, token)
}
if err := provider.tokenStore.UpdateLastObservedAt(ctx, observedTokens); err != nil {
return err
}

View File

@@ -232,15 +232,16 @@ func (store *store) ListByUserID(ctx context.Context, userID valuer.UUID) ([]*au
return tokens, nil
}
func (store *store) UpdateLastObservedAtByAccessToken(ctx context.Context, accessTokenToLastObservedAt []map[string]any) error {
if len(accessTokenToLastObservedAt) == 0 {
func (store *store) UpdateLastObservedAt(ctx context.Context, tokens []*authtypes.StorableToken) error {
if len(tokens) == 0 {
return nil
}
values := store.
sqlstore.
BunDBCtx(ctx).
NewValues(&accessTokenToLastObservedAt)
NewValues(&tokens).
Column("id", "last_observed_at", "updated_at")
_, err := store.
sqlstore.
@@ -250,8 +251,8 @@ func (store *store) UpdateLastObservedAtByAccessToken(ctx context.Context, acces
Model((*authtypes.StorableToken)(nil)).
TableExpr("update_cte").
Set("last_observed_at = update_cte.last_observed_at").
Where("auth_token.access_token = update_cte.access_token").
Where("auth_token.user_id = update_cte.user_id").
Set("updated_at = update_cte.updated_at").
Where("auth_token.id = update_cte.id").
Exec(ctx)
if err != nil {
return err

View File

@@ -0,0 +1,74 @@
package sqltokenizerstore
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateLastObservedAt(t *testing.T) {
testCases := []struct {
name string
provider string
tokens []*authtypes.StorableToken
expectedQuery string
}{
{
name: "Sqlite_Empty",
provider: "sqlite",
tokens: nil,
expectedQuery: "",
},
{
name: "Postgres_Empty",
provider: "postgres",
tokens: []*authtypes.StorableToken{},
expectedQuery: "",
},
{
name: "Sqlite_OneToken",
provider: "sqlite",
tokens: []*authtypes.StorableToken{
{ID: valuer.MustNewUUID("019984d1-0000-7000-8000-000000000001"), AccessToken: "access-one", RefreshToken: "refresh-one", LastObservedAt: time.Date(2026, 9, 22, 10, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 9, 22, 10, 0, 1, 0, time.UTC)},
},
expectedQuery: `WITH "update_cte" ("id", "last_observed_at", "updated_at") AS (VALUES ('019984d1-0000-7000-8000-000000000001', '2026-09-22 10:00:00+00:00', '2026-09-22 10:00:01+00:00')) UPDATE "auth_token" AS "auth_token" SET last_observed_at = update_cte.last_observed_at, updated_at = update_cte.updated_at FROM update_cte WHERE (auth_token.id = update_cte.id)`,
},
{
name: "Postgres_TwoTokens",
provider: "postgres",
tokens: []*authtypes.StorableToken{
{ID: valuer.MustNewUUID("019984d1-0000-7000-8000-000000000002"), AccessToken: "access-two", RefreshToken: "refresh-two", LastObservedAt: time.Date(2026, 9, 22, 11, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 9, 22, 11, 0, 1, 0, time.UTC)},
{ID: valuer.MustNewUUID("019984d1-0000-7000-8000-000000000003"), AccessToken: "access-three", RefreshToken: "refresh-three", LastObservedAt: time.Date(2026, 9, 22, 12, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 9, 22, 12, 0, 1, 0, time.UTC)},
},
expectedQuery: `WITH "update_cte" ("id", "last_observed_at", "updated_at") AS (VALUES ('019984d1-0000-7000-8000-000000000002'::text, '2026-09-22 11:00:00+00:00'::TIMESTAMPTZ, '2026-09-22 11:00:01+00:00'::TIMESTAMPTZ), ('019984d1-0000-7000-8000-000000000003'::text, '2026-09-22 12:00:00+00:00'::TIMESTAMPTZ, '2026-09-22 12:00:01+00:00'::TIMESTAMPTZ)) UPDATE "auth_token" AS "auth_token" SET last_observed_at = update_cte.last_observed_at, updated_at = update_cte.updated_at FROM update_cte WHERE (auth_token.id = update_cte.id)`,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
var executedQuery string
matcher := sqlmock.QueryMatcherFunc(func(_, actual string) error {
executedQuery = actual
return nil
})
sqlStore := sqlstoretest.New(sqlstore.Config{Provider: testCase.provider}, matcher)
if testCase.expectedQuery != "" {
sqlStore.Mock().ExpectExec("").WillReturnResult(sqlmock.NewResult(0, int64(len(testCase.tokens))))
}
err := NewStore(sqlStore).UpdateLastObservedAt(context.Background(), testCase.tokens)
require.NoError(t, err)
require.NoError(t, sqlStore.Mock().ExpectationsWereMet())
assert.Equal(t, testCase.expectedQuery, executedQuery)
})
}
}

View File

@@ -258,6 +258,6 @@ type TokenStore interface {
// Delete a token by userID.
DeleteByUserID(context.Context, valuer.UUID) error
// Update last observed at by access token.
UpdateLastObservedAtByAccessToken(context.Context, []map[string]any) error
// Update last observed at of the given tokens.
UpdateLastObservedAt(context.Context, []*StorableToken) error
}

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