Compare commits

..

5 Commits

Author SHA1 Message Date
makeavish
ebcba581f1 fix(ai-assistant): apply PR review feedback on analytics events
- HeaderRightSection: rename Opened source 'icon' -> 'header' to reflect
  where the icon lives, not how it looks.
- AIAssistantPage: normalize pathname on NewChatClicked so the
  conversation id doesn't leak into the page attribute.
- ConversationView: invert the streaming useEffect to an early bailout
  when not streaming for readability.
- ActionsSection: extract resource-type case strings into a ResourceType
  constants object shared by targetModuleForResource and resourceRoute.
- VirtualizedMessages + ActionsSection: replace 'follow_up' / 'empty_state'
  magic strings with a SuggestedPromptCategory constants in events.ts.
2026-05-19 18:31:13 +05:30
Vishal Sharma
451de553e2 Merge branch 'main' into feat/ai-assistant-analytics-events 2026-05-18 00:16:16 +05:30
makeavish
26e8ba64de refactor(ai-assistant): simplify analytics scaffolding
- Trim getBrowserInfo to UA-sniffing + Brave probe; drop the brand
  priority list, isGenericBrand gate, and userAgent/platform fields
  the backend can derive from request headers anyway.
- Inline the router-state shape at its three call sites instead of
  exporting a named interface for { fromInApp?: boolean }.
- Tighten comments across the module — keep the non-obvious "why"
  bits, drop the restated ones.
2026-05-17 23:39:26 +05:30
makeavish
f1955e7c39 fix(ai-assistant): address review feedback on analytics events
- Replace markExpand module flag with router state so the Opened event
  stays correct across StrictMode double-mounts and aborted navigations.
- Guard the voice push-to-talk shortcut on voiceUnavailable so it can't
  bypass the persisted hide-after-failure flag.
- Fire SuggestedPromptClicked (category: follow_up) alongside MessageSent
  on server-emitted follow_up chips so click-through can be measured.
- Normalize the page/currentPage attribute to its ROUTES template via
  matchPath, bounding cardinality and avoiding customer IDs in analytics.
- Pick browsers from userAgentData via a derivative-first priority list,
  fall through to UA sniffing for generic Chromium hits, and probe
  navigator.brave to distinguish Brave from plain Chrome.
2026-05-17 23:30:14 +05:30
makeavish
879f282136 chore(ai-assistant): add product analytics events
Wire 14 frontend product-analytics events for the AI Assistant feature
so we can measure the open→send funnel, action conversion, voice usage,
and feature adoption. All events go through the existing `logEvent`
helper, with a shared `useAIAssistantAnalyticsContext` hook providing
`{ threadId, page, mode }`.

Events shipped:
- AI Assistant: Opened (source: icon | shortcut | deeplink)
- AI Assistant: New chat clicked
- AI Assistant: Message sent
- AI Assistant: Suggested prompt clicked
- AI Assistant: Cancel clicked
- AI Assistant: Regenerate clicked
- AI Assistant: Message copied
- AI Assistant: Feedback submitted
- AI Assistant: Resource opened
- AI Assistant: Doc opened
- AI Assistant: Apply filter clicked
- AI Assistant: Thread opened from history
- AI Assistant: Voice input used
- AI Assistant: Voice input failed

Additional changes:
- Suppress duplicate `Opened` fires when expanding drawer/modal to the
  full-screen page (markExpandFromInApp / consumeExpandFromInApp flag).
- Toast + analytics + sessionStorage-persisted hide for voice failures
  on Chromium derivatives that lack the Google Speech API key.
- Browser info (name, version, platform, userAgent) attached to voice
  events to triage browser-specific failures.

Skipped per scope: executionId on Cancel clicked, toolName on action
events, turnCategory on Feedback submitted, promptCategory on suggested
prompts — would require store/DTO changes beyond instrumentation.
2026-05-17 22:44:49 +05:30
95 changed files with 1129 additions and 1316 deletions

View File

@@ -80,15 +80,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
fineGrainedAuthz := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseFineGrainedAuthz, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseFineGrainedAuthz.String()),
Active: fineGrainedAuthz,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -10,13 +10,6 @@ export default defineConfig({
signoz: {
input: {
target: '../docs/api/openapi.yml',
// Perses' `common.JSONRef` (used by `DashboardGridItem.content`) has a
// field tagged `json:"$ref"`, so our spec contains a property literally
// named `$ref`.
// Orval v8's validator (`@scalar/openapi-parser`) treats every `$ref` key
// as a JSON Reference and aborts with `INVALID_REFERENCE` when the value isn't a URI string.
// Safe to disable: yes, the spec is generated by `cmd/openapi.go` and gated by backend CI, not hand-edited.
unsafeDisableValidation: true,
},
output: {
target: './src/api/generated/services',

View File

@@ -144,18 +144,18 @@ const routes: AppRoutes[] = [
// /trace-old serves V3 (URL-only access). Flip the two `component`
// values back to release V3.
{
path: ROUTES.TRACE_DETAIL_OLD,
path: ROUTES.TRACE_DETAIL,
exact: true,
component: TraceDetail,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
key: 'TRACE_DETAIL',
},
{
path: ROUTES.TRACE_DETAIL,
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL',
key: 'TRACE_DETAIL_OLD',
},
{
path: ROUTES.SETTINGS,

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
export interface AlertmanagertypesChannelDTO {
/**

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -3,6 +3,7 @@
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
* OpenAPI spec version: 0.0.1
*/
import { useMutation, useQuery } from 'react-query';
import type {

View File

@@ -5,6 +5,8 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import {
openAIAssistant,
useAIAssistantStore,
@@ -50,6 +52,14 @@ function HeaderRightSection({
setOpenAnnouncementsModal(false);
}, [location.pathname]);
const handleOpenAIAssistant = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: 'header',
currentPage: normalizePage(location.pathname),
});
openAIAssistant();
}, [location.pathname]);
const handleOpenShareURLModal = useCallback((): void => {
logEvent('Share: Clicked', {
page: location.pathname,
@@ -101,7 +111,7 @@ function HeaderRightSection({
<Button
variant="solid"
color="secondary"
onClick={openAIAssistant}
onClick={handleOpenAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1

View File

@@ -144,7 +144,6 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
loading={loading}
notFoundContent={notFoundContent}
options={options}
optionFilterProp="label"
optionRender={(option): JSX.Element => (
<Checkbox
checked={value.includes(option.value as string)}
@@ -163,7 +162,6 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
return (
<Select
id={id}
showSearch
value={value || undefined}
onChange={onChange}
placeholder={placeholder}
@@ -172,7 +170,6 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
loading={loading}
notFoundContent={notFoundContent}
options={options}
optionFilterProp="label"
getPopupContainer={getPopupContainer}
disabled={disabled}
/>

View File

@@ -10,5 +10,4 @@ export enum FeatureKeys {
ONBOARDING_V3 = 'onboarding_v3',
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
USE_JSON_BODY = 'use_json_body',
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
}

View File

@@ -1,13 +1,20 @@
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHistory } from 'react-router-dom';
import { useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import HistorySidebar from '../components/ConversationsList';
import ConversationView from '../ConversationView';
import { AIAssistantEvents } from '../events';
import {
normalizePage,
useAIAssistantAnalyticsContext,
} from '../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
@@ -24,6 +31,7 @@ import styles from './AIAssistantModal.module.scss';
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function AIAssistantModal(): JSX.Element | null {
const history = useHistory();
const { pathname } = useLocation();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
@@ -36,6 +44,7 @@ export default function AIAssistantModal(): JSX.Element | null {
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const analyticsCtx = useAIAssistantAnalyticsContext();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
@@ -55,6 +64,10 @@ export default function AIAssistantModal(): JSX.Element | null {
} else {
startNewConversation();
setShowHistory(false);
void logEvent(AIAssistantEvents.Opened, {
source: 'shortcut',
currentPage: normalizePage(pathname),
});
openModal();
}
return;
@@ -68,7 +81,7 @@ export default function AIAssistantModal(): JSX.Element | null {
window.addEventListener('keydown', handleKeyDown);
return (): void => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, openModal, closeModal, startNewConversation]);
}, [isOpen, openModal, closeModal, startNewConversation, pathname]);
// ── Handlers ────────────────────────────────────────────────────────────────
@@ -77,15 +90,28 @@ export default function AIAssistantModal(): JSX.Element | null {
return;
}
closeModal();
// Router state tells AIAssistantPage to skip its mount-time Opened fire:
// the assistant was already open in the modal, so this is a surface
// switch, not a new open.
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
{ fromInApp: true },
);
}, [activeConversationId, closeModal, history]);
const handleNew = useCallback(() => {
void logEvent(AIAssistantEvents.NewChatClicked, {
...analyticsCtx,
// useAIAssistantAnalyticsContext() runs above this component's
// VariantContext.Provider, so the hook reports the default 'page'
// mode. Override here: the modal collapses to 'sidepane' in our
// taxonomy alongside the drawer.
mode: 'sidepane',
source: 'header',
});
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
}, [startNewConversation, analyticsCtx]);
const handleHistorySelect = useCallback(() => {
setShowHistory(false);

View File

@@ -5,8 +5,12 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import ConversationsList from '../components/ConversationsList';
import ConversationView from '../ConversationView';
import { AIAssistantEvents } from '../events';
import { useAIAssistantAnalyticsContext } from '../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
@@ -32,21 +36,35 @@ export default function AIAssistantPanel(): JSX.Element | null {
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const analyticsCtx = useAIAssistantAnalyticsContext();
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeDrawer();
// Router state tells AIAssistantPage to skip its mount-time Opened fire:
// the assistant was already open in the drawer, so this is a surface
// switch, not a new open.
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
{ fromInApp: true },
);
}, [activeConversationId, closeDrawer, history]);
const handleNew = useCallback(() => {
void logEvent(AIAssistantEvents.NewChatClicked, {
...analyticsCtx,
// useAIAssistantAnalyticsContext() runs above this component's
// VariantContext.Provider, so the hook reports the default 'page'
// mode. Override here: this handler only runs when the drawer
// itself is mounted, which is unambiguously the sidepane surface.
mode: 'sidepane',
source: 'header',
});
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
}, [startNewConversation, analyticsCtx]);
// When user picks a conversation from the list, close the sidebar
const handleHistorySelect = useCallback(() => {

View File

@@ -1,9 +1,13 @@
import { useCallback } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
import { AIAssistantEvents } from '../events';
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
import {
openAIAssistant,
useAIAssistantStore,
@@ -25,6 +29,14 @@ export default function AIAssistantTrigger(): JSX.Element | null {
exact: true,
});
const handleOpen = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: 'icon',
currentPage: normalizePage(pathname),
});
openAIAssistant();
}, [pathname]);
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
return null;
}
@@ -35,7 +47,7 @@ export default function AIAssistantTrigger(): JSX.Element | null {
variant="solid"
color="primary"
className={styles.trigger}
onClick={openAIAssistant}
onClick={handleOpen}
aria-label="Open AI Assistant"
>
<Bot size={20} />

View File

@@ -1,11 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import cx from 'classnames';
import logEvent from 'api/common/logEvent';
import ChatInput, { autoContextKey } from '../components/ChatInput';
import ConversationSkeleton from '../components/ConversationSkeleton';
import VirtualizedMessages from '../components/VirtualizedMessages';
import { AIAssistantEvents } from '../events';
import { getAutoContexts } from '../getAutoContexts';
import { useAIAssistantAnalyticsContext } from '../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { MessageAttachment } from '../types';
import { MessageContext } from '../../../api/ai-assistant/chat';
@@ -39,6 +43,7 @@ export default function ConversationView({
);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
const analyticsCtx = useAIAssistantAnalyticsContext(conversationId);
// Auto-derived contexts come from the route the user is currently looking
// at (dashboard detail, service metrics, an explorer, …). Skip when the
@@ -82,14 +87,50 @@ export default function ConversationView({
attachments?: MessageAttachment[],
contexts?: MessageContext[],
) => {
const hasAuto = contexts?.some((c) => c.source === 'auto') ?? false;
const hasManual = contexts?.some((c) => c.source === 'mention') ?? false;
let contextType: 'manual' | 'auto' | 'both' | undefined;
if (hasAuto && hasManual) {
contextType = 'both';
} else if (hasAuto) {
contextType = 'auto';
} else if (hasManual) {
contextType = 'manual';
}
void logEvent(AIAssistantEvents.MessageSent, {
...analyticsCtx,
queryLength: text.length,
hasContext: hasAuto || hasManual,
contextType,
respondingToClarification: Boolean(pendingClarificationHere),
});
void sendMessage(text, attachments, contexts);
},
[sendMessage],
[sendMessage, analyticsCtx, pendingClarificationHere],
);
// Wall-clock timestamp of the current streaming start, used to compute
// `secondsSinceStart` on Cancel clicked. Cleared whenever streaming ends.
const streamStartedAtRef = useRef<number | null>(null);
useEffect(() => {
if (!isStreamingHere) {
streamStartedAtRef.current = null;
return;
}
if (streamStartedAtRef.current === null) {
streamStartedAtRef.current = Date.now();
}
}, [isStreamingHere]);
const handleCancel = useCallback(() => {
const startedAt = streamStartedAtRef.current;
void logEvent(AIAssistantEvents.CancelClicked, {
threadId: analyticsCtx.threadId,
secondsSinceStart:
startedAt !== null ? Math.round((Date.now() - startedAt) / 1000) : null,
});
cancelStream(conversationId);
}, [cancelStream, conversationId]);
}, [cancelStream, conversationId, analyticsCtx.threadId]);
const messages = conversation?.messages ?? [];
const showDisclaimer = messages.length > 0;
@@ -134,6 +175,7 @@ export default function ConversationView({
conversationId={conversationId}
messages={messages}
isStreaming={isStreamingHere}
onSendSuggestedPrompt={(text): void => handleSend(text)}
/>
{showDisclaimer && (
<div className={disclaimerClass} role="note" aria-live="polite">

View File

@@ -41,12 +41,68 @@ import {
Undo,
} from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import styles from './ActionsSection.module.scss';
interface ActionsSectionProps {
actions: MessageActionDTO[];
/** ID of the assistant message these actions belong to — used in analytics. */
messageId: string;
}
/**
* Resource-type strings the backend uses for `open_resource` and rollback
* actions. Centralized here so the route/module lookups below stay in sync.
*/
const ResourceType = {
dashboard: 'dashboard',
alert: 'alert',
service: 'service',
saved_view: 'saved_view',
logs_explorer: 'logs_explorer',
traces_explorer: 'traces_explorer',
metrics_explorer: 'metrics_explorer',
} as const;
/** Maps an open_resource action's resourceType to its product module name. */
function targetModuleForResource(resourceType: string): string | null {
switch (resourceType) {
case ResourceType.dashboard:
return 'dashboards';
case ResourceType.alert:
return 'alerts';
case ResourceType.service:
return 'apm';
case ResourceType.saved_view:
return 'savedViews';
case ResourceType.logs_explorer:
return 'logs';
case ResourceType.traces_explorer:
return 'traces';
case ResourceType.metrics_explorer:
return 'metrics';
default:
return null;
}
}
/** Maps an apply_filter signal to its product module name. */
function targetModuleForSignal(signal: ApplyFilterSignalDTO): string | null {
switch (signal) {
case ApplyFilterSignalDTO.logs:
return 'logs';
case ApplyFilterSignalDTO.traces:
return 'traces';
case ApplyFilterSignalDTO.metrics:
return 'metrics';
default:
return null;
}
}
type ChipState = 'idle' | 'loading' | 'success' | 'error';
@@ -94,23 +150,23 @@ function resourceRoute(
resourceId: string,
): string | null {
switch (resourceType) {
case 'dashboard':
case ResourceType.dashboard:
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
case 'alert': {
case ResourceType.alert: {
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
}
case 'service':
case ResourceType.service:
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
case 'saved_view':
case ResourceType.saved_view:
// No detail route — saved views land on the list page.
// Caller may provide signal-aware metadata in future; default to logs.
return ROUTES.LOGS_SAVE_VIEWS;
case 'logs_explorer':
case ResourceType.logs_explorer:
return ROUTES.LOGS_EXPLORER;
case 'traces_explorer':
case ResourceType.traces_explorer:
return ROUTES.TRACES_EXPLORER;
case 'metrics_explorer':
case ResourceType.metrics_explorer:
return ROUTES.METRICS_EXPLORER_EXPLORER;
default:
return null;
@@ -224,6 +280,24 @@ function actionKey(action: MessageActionDTO, index: number): string {
: `${action.kind}:${action.label}:${index}`;
}
/**
* Resolves the prompt to send for a follow_up action. The chip's `label` is
* the short display text (e.g. "Python setup"); the real prompt lives in
* `input.intent` per the schema doc. Falls back to label defensively so a
* malformed server payload doesn't drop the click silently. Both branches
* are trimmed so whitespace-only payloads don't become whitespace messages.
*/
function followUpIntent(action: MessageActionDTO): string {
const intent = action.input?.intent;
if (typeof intent === 'string') {
const trimmed = intent.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return action.label.trim();
}
/** Maps a signal to its target explorer route. */
function explorerRouteForSignal(signal: ApplyFilterSignalDTO): string | null {
switch (signal) {
@@ -353,10 +427,12 @@ function rollbackCall(
*/
export default function ActionsSection({
actions,
messageId,
}: ActionsSectionProps): JSX.Element | null {
const history = useHistory();
const { pathname } = useLocation();
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
// Per-chip click state, keyed by chip key (see `key` below). Persists
@@ -430,13 +506,39 @@ export default function ActionsSection({
switch (action.kind) {
case MessageActionKindDTO.open_docs: {
if (action.url) {
void logEvent(AIAssistantEvents.DocOpened, {
threadId,
messageId,
docPath: action.url,
});
openInNewTab(action.url);
}
break;
}
case MessageActionKindDTO.follow_up: {
if (action.label) {
void sendMessage(action.label);
const intent = followUpIntent(action);
if (intent) {
// Fire SuggestedPromptClicked + MessageSent so analytics can compute
// both the click-through rate against follow-ups offered *and* keep
// the unified send funnel intact. `category` distinguishes server-
// emitted follow-ups from the empty-state grid. `promptId` stays the
// label so dashboards group identical chip texts together regardless
// of the dynamic intent payload.
void logEvent(AIAssistantEvents.SuggestedPromptClicked, {
threadId,
messageId,
promptId: action.label,
category: SuggestedPromptCategory.FollowUp,
});
void logEvent(AIAssistantEvents.MessageSent, {
threadId,
page,
mode,
queryLength: intent.length,
hasContext: false,
respondingToClarification: false,
});
void sendMessage(intent);
}
break;
}
@@ -444,6 +546,12 @@ export default function ActionsSection({
if (action.resourceType && action.resourceId) {
const path = resourceRoute(action.resourceType, action.resourceId);
if (path) {
void logEvent(AIAssistantEvents.ResourceOpened, {
threadId,
messageId,
targetModule: targetModuleForResource(action.resourceType),
resourceId: action.resourceId,
});
history.push(path);
}
}
@@ -456,6 +564,13 @@ export default function ActionsSection({
break;
}
case MessageActionKindDTO.apply_filter: {
if (action.signal) {
void logEvent(AIAssistantEvents.ApplyFilterClicked, {
threadId,
messageId,
targetModule: targetModuleForSignal(action.signal),
});
}
applyFilter(action, {
history,
pathname,

View File

@@ -5,13 +5,17 @@ import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type { UploadFile } from 'antd';
import getSessionStorage from 'api/browser/sessionstorage/get';
import setSessionStorage from 'api/browser/sessionstorage/set';
import {
getListRulesQueryKey,
useListRules,
} from 'api/generated/services/rules';
import type { ListRules200 } from 'api/generated/services/sigNoz.schemas';
import logEvent from 'api/common/logEvent';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useQueryService } from 'hooks/useQueryService';
@@ -22,6 +26,8 @@ import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { AIAssistantEvents, getBrowserInfo } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { MessageAttachment } from '../../types';
import { MessageContext } from '../../../../api/ai-assistant/chat';
@@ -137,6 +143,8 @@ function autoContextCategory(ctx: MessageContext): string {
const MAX_INPUT_LENGTH = 20000;
const WARNING_THRESHOLD = 15000;
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
/** sessionStorage key for the "voice input failed this tab" flag. */
const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
@@ -368,6 +376,28 @@ export default function ChatInput({
// ── Voice input ────────────────────────────────────────────────────────────
const analyticsCtx = useAIAssistantAnalyticsContext();
// Captured at the start of a voice session, consumed when it ends.
// Tracks both the trigger (button vs. PTT shortcut) and the wall-clock
// start time so we can attribute `durationMs` on the Voice input used
// event regardless of which control ended the session.
const voiceStartedAtRef = useRef<number | null>(null);
const voiceSourceRef = useRef<'button' | 'shortcut' | null>(null);
// Set to true after a `network`, `not-allowed`, or `not-supported` failure
// so we hide the mic button for the rest of the tab session — silent
// retries don't help, and Chromium derivatives without the Google Speech
// API key always fail with `network` no matter how many times the user
// clicks. Persisted to sessionStorage so a page reload doesn't surface the
// button again (closing the tab still resets, in case the user fixed
// permissions or switched browsers).
const [voiceUnavailable, setVoiceUnavailable] = useState(
() => getSessionStorage(VOICE_UNAVAILABLE_KEY) === 'true',
);
const markVoiceUnavailable = useCallback((): void => {
setVoiceUnavailable(true);
setSessionStorage(VOICE_UNAVAILABLE_KEY, 'true');
}, []);
const {
isListening,
isSupported,
@@ -388,9 +418,81 @@ export default function ChatInput({
setText(capText(committedTextRef.current + separator + transcriptText));
}
},
onError: (error) => {
// Guard against double-fire: Chrome can fire `onerror` more than
// once per session when `continuous = true` (it retries internally
// before giving up). Only fire the analytics event for the first
// error in a given session — voiceSourceRef being null means we've
// already handled it.
const source = voiceSourceRef.current;
if (source === null) {
return;
}
voiceStartedAtRef.current = null;
voiceSourceRef.current = null;
void logEvent(AIAssistantEvents.VoiceInputFailed, {
...analyticsCtx,
...getBrowserInfo(),
source,
errorType: error,
});
if (error === 'network') {
markVoiceUnavailable();
toast.error('Voice input unavailable in this browser', {
description:
'This browser cannot reach the speech recognition service. Try Google Chrome or Microsoft Edge.',
});
} else if (error === 'not-allowed') {
markVoiceUnavailable();
toast.error('Microphone access denied', {
description:
'Grant microphone permission in your browser settings to use voice input.',
});
} else if (error === 'not-supported') {
markVoiceUnavailable();
toast.error('Voice input is not supported in this browser.');
}
// `no-speech` is benign (just silence) — don't toast or hide.
},
});
const showMic = isSupported && micPermission !== 'denied';
const showMic = isSupported && micPermission !== 'denied' && !voiceUnavailable;
const startVoiceInput = useCallback(
(source: 'button' | 'shortcut') => {
// Defense in depth: the button is hidden when `voiceUnavailable` is
// true, but the PTT shortcut listener can still call us. Bailing here
// keeps a single source of truth and prevents repeat `Voice input
// failed` events in the same session.
if (voiceUnavailable) {
return;
}
voiceStartedAtRef.current = Date.now();
voiceSourceRef.current = source;
start();
},
[start, voiceUnavailable],
);
const fireVoiceInputEvent = useCallback(
(outcome: 'sent' | 'discarded') => {
const startedAt = voiceStartedAtRef.current;
const source = voiceSourceRef.current;
voiceStartedAtRef.current = null;
voiceSourceRef.current = null;
if (startedAt === null || source === null) {
return;
}
void logEvent(AIAssistantEvents.VoiceInputUsed, {
...analyticsCtx,
...getBrowserInfo(),
source,
outcome,
durationMs: Date.now() - startedAt,
});
},
[analyticsCtx],
);
// Stop recording and immediately send whatever is in the textarea.
const handleStopAndSend = useCallback(async () => {
@@ -398,15 +500,17 @@ export default function ChatInput({
committedTextRef.current = capText(text);
// Stop recognition without triggering onTranscript again (would double-append).
discard();
fireVoiceInputEvent('sent');
await handleSend();
}, [text, discard, handleSend, capText]);
}, [text, discard, handleSend, capText, fireVoiceInputEvent]);
// Stop recording and revert the textarea to what it was before voice started.
const handleDiscard = useCallback(() => {
discard();
fireVoiceInputEvent('discarded');
setText(committedTextRef.current);
textareaRef.current?.focus();
}, [discard]);
}, [discard, fireVoiceInputEvent]);
// ── Push-to-talk (Cmd/Ctrl + Shift + Space) ────────────────────────────────
// Hold the combo to record; release Space to submit. We track which key
@@ -415,7 +519,7 @@ export default function ChatInput({
// "session active" ref so a held key only calls `start()` once.
const pttActiveRef = useRef(false);
useEffect(() => {
if (!isSupported || micPermission === 'denied') {
if (!isSupported || micPermission === 'denied' || voiceUnavailable) {
return undefined;
}
@@ -432,7 +536,7 @@ export default function ChatInput({
return; // ignore auto-repeat
}
pttActiveRef.current = true;
start();
startVoiceInput('shortcut');
};
const handleKeyUp = (e: KeyboardEvent): void => {
@@ -466,9 +570,10 @@ export default function ChatInput({
}, [
isSupported,
micPermission,
voiceUnavailable,
disabled,
isStreaming,
start,
startVoiceInput,
handleStopAndSend,
]);
@@ -903,7 +1008,7 @@ export default function ChatInput({
<Button
variant="ghost"
size="icon"
onClick={start}
onClick={(): void => startVoiceInput('button')}
disabled={disabled}
aria-label="Start voice input"
className={styles.micBtn}

View File

@@ -9,6 +9,7 @@ import {
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import logEvent from 'api/common/logEvent';
import { ClarificationFieldTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import type {
ClarificationEventDTO,
@@ -16,6 +17,8 @@ import type {
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { CircleHelp, Send, X } from '@signozhq/icons';
import { AIAssistantEvents } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import styles from './ClarificationForm.module.scss';
@@ -44,6 +47,8 @@ export default function ClarificationForm({
const isStreaming = useAIAssistantStore(
(s) => s.streams[conversationId]?.isStreaming ?? false,
);
const { threadId, page, mode } =
useAIAssistantAnalyticsContext(conversationId);
const fields = clarification.fields ?? [];
const initialAnswers = Object.fromEntries(
@@ -60,6 +65,18 @@ export default function ClarificationForm({
const handleSubmit = async (): Promise<void> => {
setSubmitted(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".
const queryLength = JSON.stringify(answers).length;
void logEvent(AIAssistantEvents.MessageSent, {
threadId,
page,
mode,
queryLength,
hasContext: false,
respondingToClarification: true,
});
await submitClarification(
conversationId,
clarification.clarificationId,
@@ -69,6 +86,10 @@ export default function ClarificationForm({
const handleCancel = (): void => {
setCancelled(true);
void logEvent(AIAssistantEvents.CancelClicked, {
threadId,
secondsSinceStart: null,
});
cancelStream(conversationId);
};

View File

@@ -5,6 +5,9 @@ import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Plus, Search } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from '../../events';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { Conversation } from '../../types';
import { useVariant } from '../../VariantContext';
@@ -136,6 +139,17 @@ export default function ConversationsList({
const handleSelect = (id: string): void => {
const conv = conversations[id];
// Skip re-selecting the currently active thread — Notion-style click on
// the highlighted row in the history list shouldn't inflate the funnel.
const isReselectingActive = id === activeConversationId;
if (conv?.threadId && !isReselectingActive) {
void logEvent(AIAssistantEvents.ThreadOpenedFromHistory, {
threadId: conv.threadId,
threadAgeDays: Math.floor(
(Date.now() - conv.createdAt) / (24 * 60 * 60 * 1000),
),
});
}
if (conv?.threadId) {
// Always load from backend — refreshes messages and reconnects
// to active execution if the thread is still busy.

View File

@@ -144,7 +144,7 @@ export default function MessageBubble({
)}
{!isUser && message.actions && message.actions.length > 0 && (
<ActionsSection actions={message.actions} />
<ActionsSection actions={message.actions} messageId={message.id} />
)}
</div>
</div>

View File

@@ -8,6 +8,10 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { FeedbackRating, Message } from '../../types';
@@ -54,6 +58,7 @@ export default function MessageFeedback({
const submitMessageFeedback = useAIAssistantStore(
(s) => s.submitMessageFeedback,
);
const { threadId } = useAIAssistantAnalyticsContext();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
@@ -91,10 +96,21 @@ export default function MessageFeedback({
}, [message.createdAt]);
const handleCopy = useCallback((): void => {
void logEvent(AIAssistantEvents.MessageCopied, {
role: message.role,
messageId: message.id,
hadToolCalls: Boolean(message.blocks?.some((b) => b.type === 'tool_call')),
});
copyToClipboard(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [copyToClipboard, message.content]);
}, [
copyToClipboard,
message.content,
message.id,
message.role,
message.blocks,
]);
const handleVote = useCallback(
(rating: FeedbackRating): void => {
@@ -107,20 +123,31 @@ export default function MessageFeedback({
return;
}
setVote(rating);
void logEvent(AIAssistantEvents.FeedbackSubmitted, {
messageId: message.id,
threadId,
rating: 'up',
hasComment: false,
commentLength: 0,
});
submitMessageFeedback(message.id, rating);
},
[vote, message.id, submitMessageFeedback],
[vote, message.id, submitMessageFeedback, threadId],
);
const handleSubmitNegative = useCallback((): void => {
setVote('negative');
setIsNegativeDialogOpen(false);
submitMessageFeedback(
message.id,
'negative',
negativeComment.trim() || undefined,
);
}, [message.id, negativeComment, submitMessageFeedback]);
const trimmed = negativeComment.trim();
void logEvent(AIAssistantEvents.FeedbackSubmitted, {
messageId: message.id,
threadId,
rating: 'down',
hasComment: trimmed.length > 0,
commentLength: trimmed.length,
});
submitMessageFeedback(message.id, 'negative', trimmed || undefined);
}, [message.id, negativeComment, submitMessageFeedback, threadId]);
return (
<>

View File

@@ -4,6 +4,9 @@ import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Check, Copy } from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents } from '../../events';
import { Message } from '../../types';
import styles from './UserMessageActions.module.scss';
@@ -25,10 +28,15 @@ export default function UserMessageActions({
const [, copyToClipboard] = useCopyToClipboard();
const handleCopy = useCallback((): void => {
void logEvent(AIAssistantEvents.MessageCopied, {
role: message.role,
messageId: message.id,
hadToolCalls: false,
});
copyToClipboard(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [copyToClipboard, message.content]);
}, [copyToClipboard, message.content, message.id, message.role]);
return (
<div className={styles.actions}>

View File

@@ -10,6 +10,10 @@ import {
Sparkles,
} from '@signozhq/icons';
import logEvent from 'api/common/logEvent';
import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { Message, StreamingEventItem } from '../../types';
import MessageBubble from '../MessageBubble';
@@ -46,17 +50,24 @@ interface VirtualizedMessagesProps {
conversationId: string;
messages: Message[];
isStreaming: boolean;
/**
* Called when a user clicks an empty-state suggested prompt. Routed
* through the parent so analytics (Message sent) fire with the same
* page/mode/context attribution as a normal send.
*/
onSendSuggestedPrompt: (text: string) => void;
}
export default function VirtualizedMessages({
conversationId,
messages,
isStreaming,
onSendSuggestedPrompt,
}: VirtualizedMessagesProps): JSX.Element {
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const regenerateAssistantMessage = useAIAssistantStore(
(s) => s.regenerateAssistantMessage,
);
const { threadId } = useAIAssistantAnalyticsContext(conversationId);
const streamingStatus = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingStatus ?? '',
);
@@ -85,9 +96,13 @@ export default function VirtualizedMessages({
if (isStreaming) {
return;
}
void logEvent(AIAssistantEvents.RegenerateClicked, {
messageId,
threadId,
});
void regenerateAssistantMessage(conversationId, messageId);
},
[conversationId, isStreaming, regenerateAssistantMessage],
[conversationId, isStreaming, regenerateAssistantMessage, threadId],
);
// Scroll all the way to the actual bottom — including the 64px of bottom
@@ -146,7 +161,11 @@ export default function VirtualizedMessages({
color="secondary"
className={styles.emptyChip}
onClick={(): void => {
sendMessage(s.text);
void logEvent(AIAssistantEvents.SuggestedPromptClicked, {
promptId: s.text,
category: SuggestedPromptCategory.EmptyState,
});
onSendSuggestedPrompt(s.text);
}}
prefix={<s.icon size={14} />}
>

View File

@@ -1,7 +1,10 @@
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import logEvent from 'api/common/logEvent';
import { Check, X } from '@signozhq/icons';
import { AIAssistantEvents } from '../../../events';
import { useAIAssistantAnalyticsContext } from '../../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../../store/useAIAssistantStore';
import { useMessageContext } from '../../MessageContext';
@@ -37,6 +40,7 @@ export default function ConfirmBlock({
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
// Durable answered state — survives re-renders/remounts
const answeredChoice = messageId ? answeredBlocks[messageId] : undefined;
@@ -47,6 +51,14 @@ export default function ConfirmBlock({
if (messageId) {
markBlockAnswered(messageId, choice);
}
void logEvent(AIAssistantEvents.MessageSent, {
threadId,
page,
mode,
queryLength: responseText.length,
hasContext: false,
respondingToClarification: false,
});
sendMessage(responseText);
};

View File

@@ -1,8 +1,11 @@
import { useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import logEvent from 'api/common/logEvent';
import { Checkbox, Radio } from 'antd';
import { AIAssistantEvents } from '../../../events';
import { useAIAssistantAnalyticsContext } from '../../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../../store/useAIAssistantStore';
import { useMessageContext } from '../../MessageContext';
@@ -36,6 +39,7 @@ export default function InteractiveQuestion({
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { threadId, page, mode } = useAIAssistantAnalyticsContext();
// Persist selected state locally only for the pending (not-yet-submitted) case
const [selected, setSelected] = useState<string[]>([]);
@@ -52,6 +56,14 @@ export default function InteractiveQuestion({
if (messageId) {
markBlockAnswered(messageId, answer);
}
void logEvent(AIAssistantEvents.MessageSent, {
threadId,
page,
mode,
queryLength: answer.length,
hasContext: false,
respondingToClarification: false,
});
sendMessage(answer);
};

View File

@@ -0,0 +1,81 @@
/**
* Analytics event names for the AI Assistant feature. Backend-emitted events
* (Execution finished, Approval resolved, Resource mutated, Clarification
* requested, Limit hit) are not declared here — they fire from the AI service.
*/
export interface BrowserInfo {
browserName: string;
browserVersion: string;
}
type NavigatorWithBrandHints = Navigator & {
userAgentData?: { brands: { brand: string; version: string }[] };
brave?: { isBrave: () => Promise<boolean> };
};
/**
* We mainly need to distinguish Chrome / Edge (Speech API works) from Chromium
* derivatives (no Google API key → voice fails with `network`). UA sniffing is
* the source of truth for derivative identification; `userAgentData` is used
* only as a fast happy path for Chrome / Edge. Brave needs its own probe — it
* advertises Chrome in both UA and brand hints.
*/
export function getBrowserInfo(): BrowserInfo {
if (typeof navigator === 'undefined') {
return { browserName: 'unknown', browserVersion: 'unknown' };
}
const nav = navigator as NavigatorWithBrandHints;
const ua = nav.userAgent;
// Order matters: derivatives put "Chrome" in their UA; Chrome puts "Safari".
const matchers: { name: string; re: RegExp }[] = [
{ name: 'Edge', re: /Edg(?:e|A|iOS)?\/([\d.]+)/ },
{ name: 'Opera', re: /OPR\/([\d.]+)/ },
{ name: 'Vivaldi', re: /Vivaldi\/([\d.]+)/ },
{ name: 'Chrome', re: /Chrome\/([\d.]+)/ },
{ name: 'Firefox', re: /Firefox\/([\d.]+)/ },
{ name: 'Safari', re: /Version\/([\d.]+).*Safari/ },
];
let browserName = 'unknown';
let browserVersion = 'unknown';
for (const { name, re } of matchers) {
const m = ua.match(re);
if (m) {
browserName = name;
browserVersion = m[1];
break;
}
}
// Brave hides as Chrome in UA + brand hints; its probe is the only tell.
if (nav.brave?.isBrave) {
browserName = 'Brave';
}
return { browserName, browserVersion };
}
export const SuggestedPromptCategory = {
FollowUp: 'follow_up',
EmptyState: 'empty_state',
} as const;
export type SuggestedPromptCategory =
(typeof SuggestedPromptCategory)[keyof typeof SuggestedPromptCategory];
export enum AIAssistantEvents {
Opened = 'AI Assistant: Opened',
MessageSent = 'AI Assistant: Message sent',
SuggestedPromptClicked = 'AI Assistant: Suggested prompt clicked',
CancelClicked = 'AI Assistant: Cancel clicked',
RegenerateClicked = 'AI Assistant: Regenerate clicked',
MessageCopied = 'AI Assistant: Message copied',
FeedbackSubmitted = 'AI Assistant: Feedback submitted',
ResourceOpened = 'AI Assistant: Resource opened',
DocOpened = 'AI Assistant: Doc opened',
ApplyFilterClicked = 'AI Assistant: Apply filter clicked',
ThreadOpenedFromHistory = 'AI Assistant: Thread opened from history',
VoiceInputUsed = 'AI Assistant: Voice input used',
VoiceInputFailed = 'AI Assistant: Voice input failed',
NewChatClicked = 'AI Assistant: New chat clicked',
}

View File

@@ -0,0 +1,60 @@
import { matchPath, useLocation } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { useVariant } from '../VariantContext';
export interface AIAssistantAnalyticsContext {
/** Backend thread ID for the resolved conversation; undefined before the first send. */
threadId: string | undefined;
/**
* Normalised route template for the current page (e.g. `/dashboard/:dashboardId`).
* Falls back to the raw pathname for routes not in ROUTES. We normalise to keep
* analytics cardinality bounded and avoid leaking customer identifiers
* (dashboard IDs, service names, trace IDs, conversation IDs) into the event.
*/
page: string;
/** Surface the assistant is rendered on. `panel` / `modal` collapse to `sidepane`. */
mode: 'sidepane' | 'full_screen';
}
// Pre-sorted longest-first so more specific templates match before their
// less specific siblings (e.g. `/services/:s/top-level-operations` wins
// over `/services/:s`). Module-level — ROUTES is static.
const ROUTE_TEMPLATES = Object.values(ROUTES).sort(
(a, b) => b.length - a.length,
);
export function normalizePage(pathname: string): string {
for (const template of ROUTE_TEMPLATES) {
if (matchPath(pathname, { path: template, exact: true })) {
return template;
}
}
return pathname;
}
/**
* Shared base attributes for AI Assistant analytics events (Message sent,
* Cancel clicked, Feedback submitted, Resource/Doc/Apply filter, …).
*
* Pass `conversationId` when the caller is scoped to a specific
* conversation (e.g. `ClarificationForm`, `VirtualizedMessages`); omit
* to fall back to the store's active conversation.
*/
export function useAIAssistantAnalyticsContext(
conversationId?: string,
): AIAssistantAnalyticsContext {
const { pathname } = useLocation();
const variant = useVariant();
const threadId = useAIAssistantStore((s) => {
const id = conversationId ?? s.activeConversationId;
return id ? s.conversations[id]?.threadId : undefined;
});
return {
threadId,
page: normalizePage(pathname),
mode: variant === 'page' ? 'full_screen' : 'sidepane',
};
}

View File

@@ -5,8 +5,7 @@ import { useQueryClient } from 'react-query';
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { BellDot, CircleAlert, ExternalLink, Save } from '@signozhq/icons';
import { Button, FormInstance, SelectProps } from 'antd';
import { ConfirmDialog } from '@signozhq/ui/dialog';
import { Button, FormInstance, Modal, SelectProps } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -163,7 +162,6 @@ function FormAlertRules({
const alertTypeFromURL = urlQuery.get(QueryParams.ruleType);
const [detectionMethod, setDetectionMethod] = useState<string | null>(null);
const [isConfirmSaveOpen, setIsConfirmSaveOpen] = useState(false);
useEffect(() => {
if (!isEqual(currentQuery.unit, yAxisUnit)) {
@@ -579,16 +577,19 @@ function FormAlertRules({
});
// invalidate rule in cache
await ruleCache.invalidateQueries([
ruleCache.invalidateQueries([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
`${ruleId}`,
]);
urlQuery.delete(QueryParams.compositeQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
// eslint-disable-next-line sonarjs/no-identical-functions
setTimeout(() => {
urlQuery.delete(QueryParams.compositeQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
safeNavigate(`${ROUTES.LIST_ALL_ALERT}?${urlQuery.toString()}`);
}, 2000);
} catch (e) {
const apiError = convertToApiError(e as AxiosError<RenderErrorResponseDTO>);
logData = {
@@ -624,9 +625,24 @@ function FormAlertRules({
urlQuery,
]);
const onSaveHandler = useCallback(() => {
setIsConfirmSaveOpen(true);
}, []);
const onSaveHandler = useCallback(async () => {
const content = (
<Typography.Text>
{' '}
{t('confirm_save_content_part1')}{' '}
<QueryTypeTag queryType={currentQuery.queryType} />{' '}
{t('confirm_save_content_part2')}
</Typography.Text>
);
Modal.confirm({
icon: <CircleAlert size="md" />,
title: t('confirm_save_title'),
centered: true,
content,
onOk: saveRule,
className: 'create-alert-modal',
});
}, [t, saveRule, currentQuery]);
const onTestRuleHandler = useCallback(async () => {
if (!isFormValid()) {
@@ -972,27 +988,6 @@ function FormAlertRules({
</ButtonContainer>
</MainFormContainer>
</div>
<ConfirmDialog
open={isConfirmSaveOpen}
onOpenChange={setIsConfirmSaveOpen}
title={t('confirm_save_title')}
titleIcon={<CircleAlert size={14} />}
confirmText="OK"
confirmColor="primary"
onConfirm={async (): Promise<boolean> => {
await saveRule();
return true;
}}
onCancel={() => setIsConfirmSaveOpen(false)}
width="narrow"
>
<Typography.Text>
{t('confirm_save_content_part1')}{' '}
<QueryTypeTag queryType={currentQuery.queryType} />{' '}
{t('confirm_save_content_part2')}
</Typography.Text>
</ConfirmDialog>
</>
);
}

View File

@@ -1,36 +0,0 @@
.actionContent {
display: flex;
flex-direction: column;
}
.actionBtn {
display: flex;
padding: 8px;
height: unset;
align-items: center;
gap: 6px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
:global(.ant-icon-btn) {
margin-inline-end: 0px;
}
}
.deleteBtn {
composes: actionBtn;
color: var(--danger-background) !important;
border-top: 1px solid var(--l1-border);
}
.deleteBtn:hover {
background-color: color-mix(in srgb, var(--l1-foreground) 12%, transparent);
}
.deleteModal :global(.ant-modal-confirm-body) {
align-items: center;
}

View File

@@ -745,6 +745,52 @@
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
padding: 0px;
.dashboard-action-content {
.section-1 {
display: flex;
flex-direction: column;
.action-btn {
display: flex;
padding: 8px;
height: unset;
align-items: center;
gap: 6px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
.ant-icon-btn {
margin-inline-end: 0px;
}
}
}
.section-2 {
display: flex;
flex-direction: column;
border-top: 1px solid var(--l1-border);
.ant-typography {
display: flex;
padding: 12px 8px;
align-items: center;
gap: 6px;
color: var(--bg-cherry-400) !important;
font-family: Inter;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
letter-spacing: 0.14px;
}
}
}
}
}

View File

@@ -102,7 +102,6 @@ import {
filterDashboards,
} from './utils';
import styles from './DashboardActions.module.scss';
import './DashboardList.styles.scss';
// eslint-disable-next-line sonarjs/cognitive-complexity
@@ -437,53 +436,57 @@ function DashboardsList(): JSX.Element {
{action && (
<Popover
content={
<div className={styles.actionContent}>
<Button
type="text"
className={styles.actionBtn}
icon={<Expand size={12} />}
onClick={onClickHandler}
>
View
</Button>
<Button
type="text"
className={styles.actionBtn}
icon={<SquareArrowOutUpRight size={12} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(getLink());
}}
>
Open in New Tab
</Button>
<Button
type="text"
className={styles.actionBtn}
icon={<Link2 size={12} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(getLink()));
}}
>
Copy Link
</Button>
<Button
type="text"
className={styles.actionBtn}
icon={<FileJson size={12} />}
onClick={handleJsonExport}
>
Export JSON
</Button>
<DeleteButton
name={dashboard.name}
id={dashboard.id}
isLocked={dashboard.isLocked}
createdBy={dashboard.createdBy}
/>
<div className="dashboard-action-content">
<section className="section-1">
<Button
type="text"
className="action-btn"
icon={<Expand size={12} />}
onClick={onClickHandler}
>
View
</Button>
<Button
type="text"
className="action-btn"
icon={<SquareArrowOutUpRight size={12} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
openInNewTab(getLink());
}}
>
Open in New Tab
</Button>
<Button
type="text"
className="action-btn"
icon={<Link2 size={12} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(getAbsoluteUrl(getLink()));
}}
>
Copy Link
</Button>
<Button
type="text"
className="action-btn"
icon={<FileJson size={12} />}
onClick={handleJsonExport}
>
Export JSON
</Button>
</section>
<section className="section-2">
<DeleteButton
name={dashboard.name}
id={dashboard.id}
isLocked={dashboard.isLocked}
createdBy={dashboard.createdBy}
/>
</section>
</div>
}
placement="bottomRight"

View File

@@ -0,0 +1,9 @@
.delete-modal {
.ant-modal-confirm-body {
align-items: center;
}
}
.delete-btn:hover {
background-color: color-mix(in srgb, var(--l1-foreground) 12%, transparent);
}

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from 'react-query';
import { CircleAlert, Trash2 } from '@signozhq/icons';
import { Button, Modal, Tooltip } from 'antd';
import { Flex, Modal, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
@@ -12,8 +12,10 @@ import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import styles from '../DashboardActions.module.scss';
import { Data } from '../DashboardsList';
import { TableLinkText } from './styles';
import './DeleteButton.styles.scss';
interface DeleteButtonProps {
createdBy: string;
@@ -83,7 +85,7 @@ export function DeleteButton({
},
},
centered: true,
className: styles.deleteModal,
className: 'delete-modal',
});
}, [
modal,
@@ -107,16 +109,10 @@ export function DeleteButton({
return '';
};
const isDisabled = isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor);
return (
<>
<Tooltip placement="left" title={getDeleteTooltipContent()}>
<Button
type="text"
className={styles.deleteBtn}
icon={<Trash2 size={12} />}
disabled={isDisabled}
<TableLinkText
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
@@ -124,9 +120,13 @@ export function DeleteButton({
openConfirmationDialog();
}
}}
className="delete-btn"
disabled={isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor)}
>
Delete Dashboard
</Button>
<Flex align="center" justify="center" gap={4}>
<Trash2 size={14} /> Delete dashboard
</Flex>
</TableLinkText>
</Tooltip>
{contextHolder}

View File

@@ -0,0 +1,8 @@
import styled from 'styled-components';
export const TableLinkText = styled.span<{ disabled: boolean }>`
color: var(--destructive);
cursor: ${({ disabled }): string => (disabled ? 'not-allowed' : 'pointer')};
${({ disabled }): string => (disabled ? 'opacity: 0.5;' : '')}
padding: var(--spacing-3) var(--spacing-4);
`;

View File

@@ -1,11 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
import { Input as AntdInput } from 'antd';
import logEvent from 'api/common/logEvent';
import { ArrowRight } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
@@ -33,31 +32,11 @@ const interestedInOptions: Record<string, string> = {
openSourceTooling: 'Prefer open-source tooling',
};
function seededShuffle<T>(array: T[], seed: string): T[] {
const result = [...array];
let num = 0;
for (let i = 0; i < seed.length; i++) {
num = Math.imul(num + seed.charCodeAt(i), 2654435761);
num = Math.abs(num);
}
for (let i = result.length - 1; i > 0; i--) {
num = Math.abs(Math.imul(num, 1664525) + 1013904223);
const j = num % (i + 1);
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
export function AboutSigNozQuestions({
signozDetails,
setSignozDetails,
onNext,
}: AboutSigNozQuestionsProps): JSX.Element {
const { versionData } = useAppContext();
const [interestInSignoz, setInterestInSignoz] = useState<string[]>(
signozDetails?.interestInSignoz || [],
);
@@ -69,12 +48,6 @@ export function AboutSigNozQuestions({
);
const [isNextDisabled, setIsNextDisabled] = useState<boolean>(true);
const shuffledOptionKeys = useMemo(
() =>
seededShuffle(Object.keys(interestedInOptions), versionData?.version ?? ''),
[versionData?.version],
);
useEffect((): void => {
if (
discoverSignoz !== '' &&
@@ -142,7 +115,7 @@ export function AboutSigNozQuestions({
<div className="form-group">
<div className="question">What got you interested in SigNoz?</div>
<div className="checkbox-grid">
{shuffledOptionKeys.map((option: string) => (
{Object.keys(interestedInOptions).map((option: string) => (
<div key={option} className="checkbox-item">
<Checkbox
id={`checkbox-${option}`}

View File

@@ -16,31 +16,35 @@ enum SpanScope {
ENTRYPOINT_SPANS = 'entrypoint_spans',
}
interface SpanFilterConfig {
key: string;
type: string;
}
interface SpanScopeSelectorProps {
onChange?: (value: TagFilter) => void;
query?: IBuilderQuery;
skipQueryBuilderRedirect?: boolean;
}
const SPAN_FILTER_KEY: Record<SpanScope, string | null> = {
const SPAN_FILTER_CONFIG: Record<SpanScope, SpanFilterConfig | null> = {
[SpanScope.ALL_SPANS]: null,
[SpanScope.ROOT_SPANS]: 'isRoot',
[SpanScope.ENTRYPOINT_SPANS]: 'isEntryPoint',
[SpanScope.ROOT_SPANS]: {
key: 'isRoot',
type: 'spanSearchScope',
},
[SpanScope.ENTRYPOINT_SPANS]: {
key: 'isEntryPoint',
type: 'spanSearchScope',
},
};
const SCOPE_FILTER_KEYS = Object.values(SPAN_FILTER_KEY).filter(
(key): key is string => key !== null,
);
const isScopeFilter = (filter: TagFilterItem, key: string): boolean =>
filter.key?.key === key && String(filter.value) === 'true';
const createFilterItem = (key: string): TagFilterItem => ({
const createFilterItem = (config: SpanFilterConfig): TagFilterItem => ({
id: uuid().slice(0, 8),
key: {
key,
key: config.key,
dataType: undefined,
type: '',
type: config?.type,
},
op: '=',
value: 'true',
@@ -66,7 +70,12 @@ function SpanScopeSelector({
filters: TagFilterItem[] = [],
): SpanScope => {
const hasFilter = (key: string): boolean =>
filters?.some((filter) => isScopeFilter(filter, key));
filters?.some(
(filter) =>
filter.key?.type === 'spanSearchScope' &&
filter.key.key === key &&
filter.value === 'true',
);
if (hasFilter('isRoot')) {
return SpanScope.ROOT_SPANS;
@@ -104,21 +113,28 @@ function SpanScopeSelector({
const nonScopeFilters = currentFilters.filter(
(filter) =>
!SCOPE_FILTER_KEYS.some((scopeKey) => isScopeFilter(filter, scopeKey)),
!(
filter.key?.type === 'spanSearchScope' &&
(filter.key.key === 'isRoot' || filter.key.key === 'isEntryPoint')
),
);
const scopeKey = SPAN_FILTER_KEY[newScope];
const newScopeFilter = scopeKey !== null ? [createFilterItem(scopeKey)] : [];
const config = SPAN_FILTER_CONFIG[newScope];
const newScopeFilter = config !== null ? [createFilterItem(config)] : [];
return [...nonScopeFilters, ...newScopeFilter];
};
const keysToRemove = Object.values(SPAN_FILTER_CONFIG)
.map((config) => config?.key)
.filter((key): key is string => typeof key === 'string');
newQuery.builder.queryData = newQuery.builder.queryData.map((item) => ({
...item,
filter: {
expression: removeKeysFromExpression(
item.filter?.expression ?? '',
SCOPE_FILTER_KEYS,
keysToRemove,
),
},
filters: {

View File

@@ -20,16 +20,12 @@ import SpanScopeSelector from '../SpanScopeSelector';
const mockRedirectWithQueryBuilderData = jest.fn();
const SCOPE_KEYS = ['isRoot', 'isEntryPoint'];
const isScopeFilter = (filter: TagFilterItem): boolean =>
SCOPE_KEYS.includes(filter.key?.key ?? '') && String(filter.value) === 'true';
// Helper to create filter items
const createSpanScopeFilter = (key: string): TagFilterItem => ({
id: 'span-filter',
key: {
key,
type: '',
type: 'spanSearchScope',
},
op: '=',
value: 'true',
@@ -147,6 +143,7 @@ describe('SpanScopeSelector', () => {
expect.objectContaining({
key: expect.objectContaining({
key: expectedKey,
type: 'spanSearchScope',
}),
op: '=',
value: 'true',
@@ -165,7 +162,11 @@ describe('SpanScopeSelector', () => {
expect(mockRedirectWithQueryBuilderData).toHaveBeenCalled();
const updatedQuery = mockRedirectWithQueryBuilderData.mock.calls[0][0];
const filters = updatedQuery.builder.queryData[0].filters.items;
expect(filters.some(isScopeFilter)).toBe(false);
expect(filters).not.toContainEqual(
expect.objectContaining({
key: expect.objectContaining({ type: 'spanSearchScope' }),
}),
);
});
it('should add isRoot filter when selecting ROOT_SPANS', async () => {
@@ -205,27 +206,6 @@ describe('SpanScopeSelector', () => {
await expect(screen.findByText(expectedText)).resolves.toBeInTheDocument();
},
);
// Round-trip from filter.expression can deserialize the value as a boolean
// `true` (unquoted in the expression) instead of the string `'true'` produced
// by the dropdown. The dropdown must still recognize that as the scope filter.
it.each([
['Root Spans', 'isRoot'],
['Entrypoint Spans', 'isEntryPoint'],
])(
'should initialize with %s selected when %s = true (boolean value)',
async (expectedText, filterKey) => {
const booleanScopeFilter: TagFilterItem = {
id: 'span-filter',
key: { key: filterKey, type: '' },
op: '=',
value: true as unknown as string,
};
const queryWithFilter = createQueryWithFilters([booleanScopeFilter]);
renderWithContext(queryWithFilter, undefined, defaultQueryBuilderQuery);
await expect(screen.findByText(expectedText)).resolves.toBeInTheDocument();
},
);
});
describe('when onChange and query props are provided', () => {
@@ -253,7 +233,9 @@ describe('SpanScopeSelector', () => {
expect(items).toContainEqual(nonScopeItem);
});
const scopeFiltersInPayload = items.filter(isScopeFilter);
const scopeFiltersInPayload = items.filter(
(filter) => filter.key?.type === 'spanSearchScope',
);
if (expectedScopeKey) {
expect(scopeFiltersInPayload).toHaveLength(1);
@@ -452,7 +434,9 @@ describe('SpanScopeSelector', () => {
items: [],
};
// Count non-scope filters
const nonScopeFilters = items.filter((filter) => !isScopeFilter(filter));
const nonScopeFilters = items.filter(
(filter) => filter.key?.type !== 'spanSearchScope',
);
expect(nonScopeFilters).toHaveLength(1);
expect(nonScopeFilters).toContainEqual(

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Redirect, useHistory, useLocation } from 'react-router-dom';
import { useHistory, useLocation } from 'react-router-dom';
import { Trash2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
@@ -21,7 +21,6 @@ import {
buildRoleUpdatePermission,
} from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import type { AuthzResources } from '../utils';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
@@ -53,10 +52,8 @@ function RoleDetailsPage(): JSX.Element {
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const { isRolesEnabled, isLoading: isRolesGateLoading } =
useRolesFeatureGate();
const authzResources: AuthzResources = permissionsType.data;
const authzResources = permissionsType.data as unknown as AuthzResources;
// Extract roleId from URL pathname since useParams doesn't work in nested routing
const roleIdMatch = pathname.match(ROLE_ID_REGEX);
@@ -161,22 +158,6 @@ function RoleDetailsPage(): JSX.Element {
},
});
if (isRolesGateLoading) {
return (
<div className="role-details-page">
<Skeleton
active
paragraph={{ rows: 8 }}
className="role-details-skeleton"
/>
</div>
);
}
if (!isRolesEnabled) {
return <Redirect to={ROUTES.ROLES_SETTINGS} />;
}
if (!hasReadPermission && readPerms !== null) {
return <PermissionDeniedFullPage permissionName="role:read" />;
}

View File

@@ -5,9 +5,7 @@ import {
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { Route, Switch } from 'react-router-dom';
import {
defaultFeatureFlags,
fireEvent,
render,
screen,
@@ -15,10 +13,8 @@ import {
waitFor,
within,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import {
invalidLicense,
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
} from 'tests/authz-test-utils';
@@ -234,56 +230,6 @@ describe('RoleDetailsPage', () => {
).resolves.toBeInTheDocument();
});
it('redirects to the roles list when license is not valid', async () => {
render(
<Switch>
<Route path="/settings/roles/:roleId">
<RoleDetailsPage />
</Route>
<Route path="/settings/roles" exact>
<div data-testid="roles-list-redirect-target" />
</Route>
</Switch>,
undefined,
{
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
appContextOverrides: { activeLicense: invalidLicense },
},
);
await expect(
screen.findByTestId('roles-list-redirect-target'),
).resolves.toBeInTheDocument();
});
it('redirects to the roles list when fine-grained authz flag is inactive', async () => {
render(
<Switch>
<Route path="/settings/roles/:roleId">
<RoleDetailsPage />
</Route>
<Route path="/settings/roles" exact>
<div data-testid="roles-list-redirect-target" />
</Route>
</Switch>,
undefined,
{
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
appContextOverrides: {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
},
},
);
await expect(
screen.findByTestId('roles-list-redirect-target'),
).resolves.toBeInTheDocument();
});
describe('permission side panel', () => {
beforeEach(() => {
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.

View File

@@ -9,7 +9,6 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import ROUTES from 'constants/routes';
import { RoleListPermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useTimezone } from 'providers/Timezone';
@@ -31,8 +30,6 @@ interface RolesListingTableProps {
function RolesListingTable({
searchQuery,
}: RolesListingTableProps): JSX.Element {
const { isRolesEnabled } = useRolesFeatureGate();
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
RoleListPermission,
]);
@@ -206,27 +203,19 @@ function RolesListingTable({
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
<div
key={role.id}
className={`roles-table-row${isRolesEnabled ? ' roles-table-row--clickable' : ''}`}
role={isRolesEnabled ? 'button' : undefined}
tabIndex={isRolesEnabled ? 0 : undefined}
onClick={
isRolesEnabled
? (): void => {
if (role.id) {
navigateToRole(role.id, role.name);
}
}
: undefined
}
onKeyDown={
isRolesEnabled
? (e): void => {
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
navigateToRole(role.id, role.name);
}
}
: undefined
}
className="roles-table-row roles-table-row--clickable"
role="button"
tabIndex={0}
onClick={(): void => {
if (role.id) {
navigateToRole(role.id, role.name);
}
}}
onKeyDown={(e): void => {
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
navigateToRole(role.id, role.name);
}
}}
>
<div className="roles-table-cell roles-table-cell--name">
{role.name ?? '—'}

View File

@@ -4,7 +4,6 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import CreateRoleModal from './RolesComponents/CreateRoleModal';
import RolesListingTable from './RolesComponents/RolesListingTable';
@@ -14,7 +13,6 @@ import './RolesSettings.styles.scss';
function RolesSettings(): JSX.Element {
const [searchQuery, setSearchQuery] = useState('');
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const { isRolesEnabled } = useRolesFeatureGate();
return (
<div className="roles-settings" data-testid="roles-settings">
@@ -40,19 +38,17 @@ function RolesSettings(): JSX.Element {
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
{isRolesEnabled && (
<AuthZTooltip checks={[RoleCreatePermission]}>
<Button
variant="solid"
color="primary"
className="role-settings-toolbar-button"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={14} />
Custom role
</Button>
</AuthZTooltip>
)}
<AuthZTooltip checks={[RoleCreatePermission]}>
<Button
variant="solid"
color="primary"
className="role-settings-toolbar-button"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={14} />
Custom role
</Button>
</AuthZTooltip>
</div>
<RolesListingTable searchQuery={searchQuery} />
</div>

View File

@@ -4,15 +4,9 @@ import {
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
defaultFeatureFlags,
fireEvent,
render,
screen,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import { fireEvent, render, screen } from 'tests/test-utils';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
import RolesSettings from '../RolesSettings';
@@ -182,50 +176,6 @@ describe('RolesSettings', () => {
}
});
it('hides the create button and disables row clicks when fine-grained authz flag is inactive', async () => {
render(<RolesSettings />, undefined, {
appContextOverrides: {
featureFlags: defaultFeatureFlags.map((f) =>
f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ
? { ...f, active: false }
: f,
),
},
});
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /custom role/i }),
).not.toBeInTheDocument();
const rows = document.querySelectorAll('.roles-table-row');
rows.forEach((row) => {
expect(row).not.toHaveClass('roles-table-row--clickable');
expect(row.getAttribute('role')).not.toBe('button');
});
});
it('hides the create button and disables row clicks when license is not valid', async () => {
render(<RolesSettings />, undefined, {
appContextOverrides: { activeLicense: invalidLicense },
});
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
// Create button must be absent
expect(
screen.queryByRole('button', { name: /custom role/i }),
).not.toBeInTheDocument();
// Rows must not carry the clickable class or button role
const rows = document.querySelectorAll('.roles-table-row');
rows.forEach((row) => {
expect(row).not.toHaveClass('roles-table-row--clickable');
expect(row.getAttribute('role')).not.toBe('button');
});
});
it('handles invalid dates gracefully by showing fallback', async () => {
const invalidRole = {
id: 'edge-0009',

View File

@@ -1,4 +1,5 @@
import type {
CoretypesResourceRefDTO,
CoretypesObjectGroupDTO,
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -7,7 +8,11 @@ import type {
PermissionConfig,
ResourceDefinition,
} from '../PermissionSidePanel/PermissionSidePanel.types';
import type { AuthzResources } from '../utils';
type AuthzResources = {
resources: CoretypesResourceRefDTO[];
relations: Record<string, string[]>;
};
import { PermissionScope } from '../PermissionSidePanel/PermissionSidePanel.types';
import {
buildConfig,
@@ -36,14 +41,12 @@ jest.mock('../RoleDetails/constants', () => {
const dashboardResource: AuthzResources['resources'][number] = {
kind: 'dashboard',
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
type: 'metaresource' as CoretypesTypeDTO,
};
const alertResource: AuthzResources['resources'][number] = {
kind: 'alert',
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
type: 'metaresource' as CoretypesTypeDTO,
};
const baseAuthzResources: AuthzResources = {
@@ -54,16 +57,6 @@ const baseAuthzResources: AuthzResources = {
},
};
// API payload resource refs — only kind+type, no allowedVerbs (matches CoretypesResourceRefDTO shape)
const dashboardResourceRef = {
kind: 'dashboard',
type: 'metaresource' as CoretypesTypeDTO,
};
const alertResourceRef = {
kind: 'alert',
type: 'metaresource' as CoretypesTypeDTO,
};
const resourceDefs: ResourceDefinition[] = [
{
id: 'metaresource:dashboard',
@@ -114,7 +107,7 @@ describe('buildPatchPayload', () => {
});
expect(result.additions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: [ID_B] },
{ resource: dashboardResource, selectors: [ID_B] },
]);
expect(result.deletions).toBeNull();
});
@@ -149,7 +142,7 @@ describe('buildPatchPayload', () => {
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: [ID_B] },
{ resource: dashboardResource, selectors: [ID_B] },
]);
expect(result.additions).toBeNull();
});
@@ -214,10 +207,10 @@ describe('buildPatchPayload', () => {
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: ['*'] },
{ resource: dashboardResource, selectors: ['*'] },
]);
expect(result.additions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
]);
});
@@ -248,7 +241,7 @@ describe('buildPatchPayload', () => {
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: ['*'] },
{ resource: dashboardResource, selectors: ['*'] },
]);
expect(result.additions).toBeNull();
});
@@ -271,7 +264,7 @@ describe('buildPatchPayload', () => {
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: ['*'] },
{ resource: dashboardResource, selectors: ['*'] },
]);
expect(result.additions).toBeNull();
});
@@ -294,7 +287,7 @@ describe('buildPatchPayload', () => {
});
expect(result.additions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: ['*'] },
{ resource: dashboardResource, selectors: ['*'] },
]);
expect(result.deletions).toBeNull();
});
@@ -320,7 +313,7 @@ describe('buildPatchPayload', () => {
});
expect(result.deletions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
]);
expect(result.additions).toBeNull();
});
@@ -346,7 +339,7 @@ describe('buildPatchPayload', () => {
});
expect(result.additions).toStrictEqual([
{ resource: dashboardResourceRef, selectors: [ID_A] },
{ resource: dashboardResource, selectors: [ID_A] },
]);
expect(result.deletions).toBeNull();
});
@@ -392,7 +385,7 @@ describe('buildPatchPayload', () => {
});
expect(result.additions).toStrictEqual([
{ resource: alertResourceRef, selectors: [ID_B] },
{ resource: alertResource, selectors: [ID_B] },
]);
expect(result.deletions).toBeNull();
});
@@ -401,7 +394,7 @@ describe('buildPatchPayload', () => {
describe('objectsToPermissionConfig', () => {
it('maps a wildcard selector to ALL scope', () => {
const objects: CoretypesObjectGroupDTO[] = [
{ resource: dashboardResourceRef, selectors: ['*'] },
{ resource: dashboardResource, selectors: ['*'] },
];
const result = objectsToPermissionConfig(objects, resourceDefs);
@@ -414,7 +407,7 @@ describe('objectsToPermissionConfig', () => {
it('maps specific selectors to ONLY_SELECTED scope with the IDs', () => {
const objects: CoretypesObjectGroupDTO[] = [
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
];
const result = objectsToPermissionConfig(objects, resourceDefs);
@@ -573,41 +566,4 @@ describe('deriveResourcesForRelation', () => {
deriveResourcesForRelation(baseAuthzResources, 'nonexistent'),
).toHaveLength(0);
});
describe('allowedVerbs filtering', () => {
it('excludes resources whose allowedVerbs does not include the relation', () => {
const authz: AuthzResources = {
resources: [
{
kind: 'dashboard',
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
},
{
kind: 'alert',
type: 'metaresource',
allowedVerbs: ['create', 'read', 'update', 'delete', 'list', 'attach'],
},
],
relations: { attach: ['metaresource'] },
};
const result = deriveResourcesForRelation(authz, 'attach');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('metaresource:alert');
});
it('requires both type-relation match and allowedVerbs — neither condition alone is sufficient', () => {
const authz: AuthzResources = {
resources: [
{ kind: 'dashboard', type: 'metaresource', allowedVerbs: ['read'] },
{ kind: 'role', type: 'role', allowedVerbs: ['create'] },
],
relations: { create: ['metaresource'] },
};
expect(deriveResourcesForRelation(authz, 'create')).toHaveLength(0);
});
});
});

View File

@@ -1,9 +1,8 @@
import React from 'react';
import { Badge } from '@signozhq/ui/badge';
import type {
CoretypesObjectGroupDTO,
CoretypesResourceRefDTO,
CoretypesTypeDTO,
CoretypesObjectGroupDTO,
} from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { capitalize } from 'lodash-es';
@@ -22,11 +21,7 @@ import {
} from './RoleDetails/constants';
export type AuthzResources = {
resources: ReadonlyArray<{
kind: string;
type: string;
allowedVerbs: readonly string[];
}>;
resources: ReadonlyArray<CoretypesResourceRefDTO>;
relations: Readonly<Record<string, ReadonlyArray<string>>>;
};
@@ -74,9 +69,7 @@ export function deriveResourcesForRelation(
}
const supportedTypes = authzResources.relations[relation] ?? [];
return authzResources.resources
.filter(
(r) => supportedTypes.includes(r.type) && r.allowedVerbs.includes(relation),
)
.filter((r) => supportedTypes.includes(r.type))
.map((r) => ({
id: `${r.type}:${r.kind}`,
kind: r.kind,
@@ -148,7 +141,7 @@ export function buildPatchPayload({
}
const resourceDef: CoretypesResourceRefDTO = {
kind: found.kind,
type: found.type as CoretypesTypeDTO,
type: found.type,
};
const initialScope = initial?.scope ?? PermissionScope.NONE;

View File

@@ -1,27 +0,0 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
import { LicenseStatus } from 'types/api/licensesV3/getActive';
export const useRolesFeatureGate = (): {
isRolesEnabled: boolean;
isLoading: boolean;
} => {
const {
activeLicense,
featureFlags,
isFetchingActiveLicense,
isFetchingFeatureFlags,
} = useAppContext();
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
const isFineGrainedAuthzEnabled =
featureFlags?.find((f) => f.name === FeatureKeys.USE_FINE_GRAINED_AUTHZ)
?.active ?? false;
return {
isRolesEnabled: isValidLicense && isFineGrainedAuthzEnabled,
isLoading:
(isFetchingActiveLicense && !activeLicense) ||
(isFetchingFeatureFlags && !featureFlags),
};
};

View File

@@ -189,7 +189,7 @@ describe('Tooltip utils', () => {
];
}
it('builds tooltip content sorted by value descending with isActive flag set correctly', () => {
it('builds tooltip content in series-index order with isActive flag set correctly', () => {
const data: AlignedData = [[0], [10], [20], [30]];
const series = createSeriesConfig();
const dataIndexes = [null, 0, 0, 0];
@@ -206,21 +206,21 @@ describe('Tooltip utils', () => {
});
expect(result).toHaveLength(2);
// Sorted by value descending: B (20) before A (10)
// Series are returned in series-index order (A=index 1 before B=index 2)
expect(result[0]).toMatchObject<Partial<TooltipContentItem>>({
label: 'B',
value: 20,
tooltipValue: 'formatted-20',
color: 'color-2',
isActive: true,
});
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
label: 'A',
value: 10,
tooltipValue: 'formatted-10',
color: '#ff0000',
isActive: false,
});
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
label: 'B',
value: 20,
tooltipValue: 'formatted-20',
color: 'color-2',
isActive: true,
});
});
it('skips series with null data index or non-finite values', () => {
@@ -274,7 +274,7 @@ describe('Tooltip utils', () => {
expect(result[1].value).toBe(30);
});
it('returns items sorted by value descending', () => {
it('returns items in series-index order', () => {
// Series values in non-sorted order: 3, 1, 4, 2
const data: AlignedData = [[0], [3], [1], [4], [2]];
const series: Series[] = [
@@ -297,7 +297,7 @@ describe('Tooltip utils', () => {
decimalPrecision,
});
expect(result.map((item) => item.value)).toStrictEqual([4, 3, 2, 1]);
expect(result.map((item) => item.value)).toStrictEqual([3, 1, 4, 2]);
});
});
});

View File

@@ -142,7 +142,5 @@ export function buildTooltipContent({
}
}
items.sort((a, b) => b.value - a.value);
return items;
}

View File

@@ -1,9 +1,12 @@
import { useCallback, useEffect, useRef } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import ConversationView from 'container/AIAssistant/ConversationView';
import { AIAssistantEvents } from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { VariantContext } from 'container/AIAssistant/VariantContext';
import { Sparkles } from '@signozhq/icons';
@@ -17,8 +20,27 @@ interface RouteParams {
export default function AIAssistantPage(): JSX.Element {
const history = useHistory();
const location = useLocation<{ fromInApp?: boolean } | undefined>();
const { pathname } = location;
const { conversationId } = useParams<RouteParams>();
// Skip the mount-time Opened fire when the user expanded an already-open
// drawer/modal — that surface already emitted Opened with the right source.
// Router state (vs a module flag) survives StrictMode double-mount and
// aborted navigations.
const fromInApp = location.state?.fromInApp === true;
useEffect(() => {
if (fromInApp) {
return;
}
void logEvent(AIAssistantEvents.Opened, {
source: 'deeplink',
currentPage: normalizePage(pathname),
});
// Only on mount; route param changes inside the same page aren't a re-open.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const conversations = useAIAssistantStore((s) => s.conversations);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
@@ -71,9 +93,14 @@ export default function AIAssistantPage(): JSX.Element {
);
const handleNewConversation = useCallback(() => {
void logEvent(AIAssistantEvents.NewChatClicked, {
page: normalizePage(pathname),
mode: 'full_screen',
source: 'history_list',
});
const newId = startNewConversation();
history.push(ROUTES.AI_ASSISTANT.replace(':conversationId', newId));
}, [startNewConversation, history]);
}, [startNewConversation, history, pathname]);
// Prefer the URL param, but fall back to the store's `activeConversationId`
// for the brief render after a re-key (client UUID → backend threadId), so

View File

@@ -473,7 +473,6 @@ export const SpanDuration = memo(function SpanDuration({
const columnDefHelper = createColumnHelper<SpanV3>();
const ROW_HEIGHT = 28;
const WATERFALL_BOTTOM_PADDING = 24;
const DEFAULT_SIDEBAR_WIDTH = 450;
const MIN_SIDEBAR_WIDTH = 240;
const MAX_SIDEBAR_WIDTH = 900;
@@ -741,69 +740,53 @@ function Success(props: ISuccessProps): JSX.Element {
);
}, [spans, sidebarWidth]);
// Scroll a span to viewport center if it isn't already visible. Shared by
// the two effects below — one keyed on interestedSpanId (chevron, boundary
// pagination, deep-link to unloaded), the other on selectedSpan (in-window
// URL navigation that doesn't mutate interestedSpanId).
const scrollSpanIntoView = useCallback(
(span: SpanV3, spansList: SpanV3[]): void => {
if (!virtualizerRef.current) {
return;
}
const idx = spansList.findIndex((s) => s.span_id === span.span_id);
if (idx === -1) {
return;
}
const scrollEl = scrollContainerRef.current;
const scrollTop = scrollEl?.scrollTop ?? 0;
const viewportHeight = scrollEl?.clientHeight ?? 0;
const viewportStartIdx = Math.floor(scrollTop / ROW_HEIGHT);
const viewportEndIdx =
Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) - 1;
const isOnScreen =
viewportHeight > 0 && idx >= viewportStartIdx && idx <= viewportEndIdx;
if (isOnScreen) {
return;
}
setTimeout(() => {
virtualizerRef.current?.scrollToIndex(idx, {
align: 'center',
behavior: 'auto',
});
const sidebarScrollEl = scrollContainerRef.current?.querySelector(
'.resizable-box__content',
);
if (sidebarScrollEl) {
const targetScrollLeft = Math.max(0, span.level * CONNECTOR_WIDTH - 40);
(sidebarScrollEl as HTMLElement).scrollLeft = targetScrollLeft;
}
}, 100);
},
[],
);
// Scroll to the interested span only when it isn't already on screen.
// Covers every entry point uniformly: deep-link, flamegraph click,
// filter prev/next, browser back/forward all scroll only if needed;
// waterfall row clicks and chevron expand/collapse don't yank the viewport
// because the affected row is by definition already visible.
useEffect(() => {
if (interestedSpanId.spanId !== '') {
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
const idx = spans.findIndex(
(span) => span.span_id === interestedSpanId.spanId,
);
if (idx !== -1) {
scrollSpanIntoView(spans[idx], spans);
const visible = virtualizerRef.current.getVirtualItems();
const isOnScreen =
visible.length > 0 &&
idx >= visible[0].index &&
idx <= visible[visible.length - 1].index;
if (!isOnScreen) {
setTimeout(() => {
virtualizerRef.current?.scrollToIndex(idx, {
align: 'center',
behavior: 'auto',
});
// Auto-scroll sidebar horizontally to show the span name
const span = spans[idx];
const sidebarScrollEl = scrollContainerRef.current?.querySelector(
'.resizable-box__content',
);
if (sidebarScrollEl) {
const targetScrollLeft = Math.max(0, span.level * CONNECTOR_WIDTH - 40);
sidebarScrollEl.scrollLeft = targetScrollLeft;
}
}, 400);
}
setSelectedSpan(spans[idx]);
}
} else {
setSelectedSpan((prev) => prev ?? spans[0]);
setSelectedSpan((prev) => {
if (!prev) {
return spans[0];
}
return prev;
});
}
}, [interestedSpanId, setSelectedSpan, spans, scrollSpanIntoView]);
// Covers URL-driven navigation to an already-loaded span (flamegraph /
// filter / browser back) that the interestedSpanId-keyed effect doesn't see.
useEffect(() => {
if (selectedSpan) {
scrollSpanIntoView(selectedSpan, spans);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSpan, scrollSpanIntoView]);
}, [interestedSpanId, setSelectedSpan, spans]);
const virtualItems = virtualizer.getVirtualItems();
const leftRows = leftTable.getRowModel().rows;
@@ -863,7 +846,7 @@ function Success(props: ISuccessProps): JSX.Element {
<div
className={styles.splitBody}
style={{
minHeight: virtualizer.getTotalSize() + WATERFALL_BOTTOM_PADDING,
minHeight: virtualizer.getTotalSize(),
height: '100%',
}}
>

View File

@@ -74,21 +74,17 @@ function TraceDetailsV3(): JSX.Element {
onClose: handleSpanDetailsClose,
});
const allSpansRef = useRef<SpanV3[]>([]);
// Refetch only when the URL target isn't already loaded. Keeps row clicks
// and other in-window URL navigation from triggering a backend window slide.
useEffect(() => {
const spanId = urlQuery.get('spanId') || '';
// Only update interestedSpanId when a new span is selected,
// not when it's cleared (panel close) — avoids unnecessary API refetch
if (!spanId) {
return;
}
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
if (idx !== -1) {
setSelectedSpan(allSpansRef.current[idx]);
return;
}
setInterestedSpanId({ spanId, isUncollapsed: true });
setInterestedSpanId({
spanId,
isUncollapsed: true,
});
}, [urlQuery]);
// Hardcoded for now — fetch aggregations for all 3 candidate color-by fields
@@ -149,10 +145,6 @@ function TraceDetailsV3(): JSX.Element {
};
}
useEffect(() => {
allSpansRef.current = allSpans;
}, [allSpans]);
// Frontend mode: expand all parents by default when full data arrives
useEffect(() => {
if (isFullDataLoaded && allSpans.length > 0) {

View File

@@ -11,13 +11,6 @@ import type {
} from 'hooks/useAuthZ/types';
import { rest } from 'msw';
import type { RestHandler } from 'msw';
import {
LicenseEvent,
LicensePlatform,
type LicenseResModel,
LicenseState,
LicenseStatus,
} from 'types/api/licensesV3/getActive';
export const AUTHZ_CHECK_URL = `${ENVIRONMENT.baseURL || ''}/api/v1/authz/check`;
@@ -104,40 +97,6 @@ export function setupAuthzAllow(
});
}
export function buildLicense(
overrides?: Partial<LicenseResModel>,
): LicenseResModel {
return {
key: 'test-key',
status: LicenseStatus.VALID,
state: LicenseState.ACTIVATED,
platform: LicensePlatform.CLOUD,
event_queue: {
created_at: '0',
event: LicenseEvent.NO_EVENT,
scheduled_at: '0',
status: '',
updated_at: '0',
},
plan: {
created_at: '0',
description: '',
is_active: true,
name: '',
updated_at: '0',
},
plan_id: '0',
free_until: '0',
updated_at: '0',
valid_from: 0,
valid_until: 0,
created_at: '0',
...overrides,
};
}
export const invalidLicense = buildLicense({ status: LicenseStatus.INVALID });
export function mockUseAuthZGrantAll(
permissions: BrandedPermission[],
_options?: UseAuthZOptions,

View File

@@ -105,59 +105,6 @@ jest.mock('react-i18next', () => ({
}),
}));
export const defaultFeatureFlags = [
{ name: FeatureKeys.SSO, active: true, usage: 0, usage_limit: -1, route: '' },
{
name: FeatureKeys.USE_SPAN_METRICS,
active: false,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.GATEWAY,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.PREMIUM_SUPPORT,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.ONBOARDING,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.CHAT_SUPPORT,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.USE_FINE_GRAINED_AUTHZ,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
];
export function getAppContextMock(
role: string,
appContextOverrides?: Partial<IAppContext>,
@@ -221,7 +168,57 @@ export function getAppContextMock(
hasEditPermission: role === USER_ROLES.ADMIN || role === USER_ROLES.EDITOR,
isFetchingUser: false,
userFetchError: null,
featureFlags: defaultFeatureFlags,
featureFlags: [
{
name: FeatureKeys.SSO,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.USE_SPAN_METRICS,
active: false,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.GATEWAY,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.PREMIUM_SUPPORT,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.ANOMALY_DETECTION,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.ONBOARDING,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
{
name: FeatureKeys.CHAT_SUPPORT,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
isFetchingFeatureFlags: false,
featureFlagsFetchError: null,
hostsData: null,

View File

@@ -48,7 +48,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
HOME: ['ADMIN', 'EDITOR', 'VIEWER'],
ALERTS_NEW: ['ADMIN', 'EDITOR'],
ORG_SETTINGS: ['ADMIN'],
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
INGESTION_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -72,7 +72,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR', 'ANONYMOUS'],
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
SIGN_UP: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACES_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACE: ['ADMIN', 'EDITOR', 'VIEWER'],
@@ -98,10 +98,10 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER'],
MEMBERS_SETTINGS: ['ADMIN'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -64,8 +64,7 @@ func New(ctx context.Context, settings factory.ProviderSettings, config cache.Co
o.ObserveInt64(telemetry.setsRejected, int64(metrics.SetsRejected()), metric.WithAttributes(attributes...))
o.ObserveInt64(telemetry.getsDropped, int64(metrics.GetsDropped()), metric.WithAttributes(attributes...))
o.ObserveInt64(telemetry.getsKept, int64(metrics.GetsKept()), metric.WithAttributes(attributes...))
o.ObserveInt64(telemetry.costUsed, int64(metrics.CostAdded())-int64(metrics.CostEvicted()), metric.WithAttributes(attributes...))
o.ObserveInt64(telemetry.totalCost, cc.MaxCost(), metric.WithAttributes(attributes...))
o.ObserveInt64(telemetry.totalCost, int64(cc.MaxCost()), metric.WithAttributes(attributes...))
return nil
},
telemetry.cacheRatio,
@@ -80,7 +79,6 @@ func New(ctx context.Context, settings factory.ProviderSettings, config cache.Co
telemetry.setsRejected,
telemetry.getsDropped,
telemetry.getsKept,
telemetry.costUsed,
telemetry.totalCost,
)
if err != nil {
@@ -114,13 +112,11 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
}
if cloneable, ok := data.(cachetypes.Cloneable); ok {
cost := max(cloneable.Cost(), 1)
// Clamp to a minimum of 1: ristretto treats cost 0 specially and we
// never want zero-size entries to bypass admission accounting.
span.SetAttributes(attribute.Bool("memory.cloneable", true))
span.SetAttributes(attribute.Int64("memory.cost", cost))
span.SetAttributes(attribute.Int64("memory.cost", 1))
toCache := cloneable.Clone()
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, cost, ttl); !ok {
// In case of contention we are choosing to evict the cloneable entries first hence cost is set to 1
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, 1, ttl); !ok {
return errors.New(errors.TypeInternal, errors.CodeInternal, "error writing to cache")
}
@@ -129,15 +125,15 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
}
toCache, err := provider.marshalBinary(ctx, data)
cost := int64(len(toCache))
if err != nil {
return err
}
cost := max(int64(len(toCache)), 1)
span.SetAttributes(attribute.Bool("memory.cloneable", false))
span.SetAttributes(attribute.Int64("memory.cost", cost))
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, cost, ttl); !ok {
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, 1, ttl); !ok {
return errors.New(errors.TypeInternal, errors.CodeInternal, "error writing to cache")
}

View File

@@ -31,10 +31,6 @@ func (cloneable *CloneableA) Clone() cachetypes.Cacheable {
}
}
func (cloneable *CloneableA) Cost() int64 {
return int64(len(cloneable.Key)) + 16
}
func (cloneable *CloneableA) MarshalBinary() ([]byte, error) {
return json.Marshal(cloneable)
}
@@ -169,45 +165,6 @@ func TestSetGetWithDifferentTypes(t *testing.T) {
assert.Error(t, err)
}
// LargeCloneable reports a large byte cost so we can test ristretto eviction
// without allocating the full payload in memory.
type LargeCloneable struct {
Key string
CostHint int64
}
func (c *LargeCloneable) Clone() cachetypes.Cacheable {
return &LargeCloneable{Key: c.Key, CostHint: c.CostHint}
}
func (c *LargeCloneable) Cost() int64 { return c.CostHint }
func (c *LargeCloneable) MarshalBinary() ([]byte, error) { return json.Marshal(c) }
func (c *LargeCloneable) UnmarshalBinary(data []byte) error { return json.Unmarshal(data, c) }
func TestCloneableExceedingMaxCostIsRejected(t *testing.T) {
const maxCost int64 = 1 << 20 // 1 MiB
const oversize int64 = 2 << 20 // 2 MiB, larger than the entire cache
c, err := New(context.Background(), factorytest.NewSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{
NumCounters: 10 * 1000,
MaxCost: maxCost,
}})
require.NoError(t, err)
orgID := valuer.GenerateUUID()
const key = "oversize-key"
assert.NoError(t, c.Set(context.Background(), orgID, key,
&LargeCloneable{Key: key, CostHint: oversize}, time.Minute))
// Ristretto rejects any entry with cost > MaxCost (policy.go:100). Probe
// ristretto directly to confirm no admission, instead of relying on metrics.
cc := c.(*provider).cc
_, ok := cc.Get(strings.Join([]string{orgID.StringValue(), key}, "::"))
assert.False(t, ok, "entry with Cost() > MaxCost must be rejected")
}
func TestCloneableConcurrentSetGet(t *testing.T) {
cache, err := New(context.Background(), factorytest.NewSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{
NumCounters: 10 * 1000,

View File

@@ -7,18 +7,17 @@ import (
type telemetry struct {
cacheRatio metric.Float64ObservableGauge
cacheHits metric.Int64ObservableCounter
cacheMisses metric.Int64ObservableCounter
costAdded metric.Int64ObservableCounter
costEvicted metric.Int64ObservableCounter
keysAdded metric.Int64ObservableCounter
keysEvicted metric.Int64ObservableCounter
keysUpdated metric.Int64ObservableCounter
setsDropped metric.Int64ObservableCounter
setsRejected metric.Int64ObservableCounter
getsDropped metric.Int64ObservableCounter
getsKept metric.Int64ObservableCounter
costUsed metric.Int64ObservableGauge
cacheHits metric.Int64ObservableGauge
cacheMisses metric.Int64ObservableGauge
costAdded metric.Int64ObservableGauge
costEvicted metric.Int64ObservableGauge
keysAdded metric.Int64ObservableGauge
keysEvicted metric.Int64ObservableGauge
keysUpdated metric.Int64ObservableGauge
setsDropped metric.Int64ObservableGauge
setsRejected metric.Int64ObservableGauge
getsDropped metric.Int64ObservableGauge
getsKept metric.Int64ObservableGauge
totalCost metric.Int64ObservableGauge
}
@@ -29,67 +28,62 @@ func newMetrics(meter metric.Meter) (*telemetry, error) {
errs = errors.Join(errs, err)
}
cacheHits, err := meter.Int64ObservableCounter("signoz.cache.hits", metric.WithDescription("Hits is the number of Get calls where a value was found for the corresponding key."))
cacheHits, err := meter.Int64ObservableGauge("signoz.cache.hits", metric.WithDescription("Hits is the number of Get calls where a value was found for the corresponding key."))
if err != nil {
errs = errors.Join(errs, err)
}
cacheMisses, err := meter.Int64ObservableCounter("signoz.cache.misses", metric.WithDescription("Misses is the number of Get calls where a value was not found for the corresponding key"))
cacheMisses, err := meter.Int64ObservableGauge("signoz.cache.misses", metric.WithDescription("Misses is the number of Get calls where a value was not found for the corresponding key"))
if err != nil {
errs = errors.Join(errs, err)
}
costAdded, err := meter.Int64ObservableCounter("signoz.cache.cost.added", metric.WithDescription("CostAdded is the sum of costs that have been added (successful Set calls)"))
costAdded, err := meter.Int64ObservableGauge("signoz.cache.cost.added", metric.WithDescription("CostAdded is the sum of costs that have been added (successful Set calls)"))
if err != nil {
errs = errors.Join(errs, err)
}
costEvicted, err := meter.Int64ObservableCounter("signoz.cache.cost.evicted", metric.WithDescription("CostEvicted is the sum of all costs that have been evicted"))
costEvicted, err := meter.Int64ObservableGauge("signoz.cache.cost.evicted", metric.WithDescription("CostEvicted is the sum of all costs that have been evicted"))
if err != nil {
errs = errors.Join(errs, err)
}
keysAdded, err := meter.Int64ObservableCounter("signoz.cache.keys.added", metric.WithDescription("KeysAdded is the total number of Set calls where a new key-value item was added"))
keysAdded, err := meter.Int64ObservableGauge("signoz.cache.keys.added", metric.WithDescription("KeysAdded is the total number of Set calls where a new key-value item was added"))
if err != nil {
errs = errors.Join(errs, err)
}
keysEvicted, err := meter.Int64ObservableCounter("signoz.cache.keys.evicted", metric.WithDescription("KeysEvicted is the total number of keys evicted"))
keysEvicted, err := meter.Int64ObservableGauge("signoz.cache.keys.evicted", metric.WithDescription("KeysEvicted is the total number of keys evicted"))
if err != nil {
errs = errors.Join(errs, err)
}
keysUpdated, err := meter.Int64ObservableCounter("signoz.cache.keys.updated", metric.WithDescription("KeysUpdated is the total number of Set calls where the value was updated"))
keysUpdated, err := meter.Int64ObservableGauge("signoz.cache.keys.updated", metric.WithDescription("KeysUpdated is the total number of Set calls where the value was updated"))
if err != nil {
errs = errors.Join(errs, err)
}
setsDropped, err := meter.Int64ObservableCounter("signoz.cache.sets.dropped", metric.WithDescription("SetsDropped is the number of Set calls that don't make it into internal buffers (due to contention or some other reason)"))
setsDropped, err := meter.Int64ObservableGauge("signoz.cache.sets.dropped", metric.WithDescription("SetsDropped is the number of Set calls that don't make it into internal buffers (due to contention or some other reason)"))
if err != nil {
errs = errors.Join(errs, err)
}
setsRejected, err := meter.Int64ObservableCounter("signoz.cache.sets.rejected", metric.WithDescription("SetsRejected is the number of Set calls rejected by the policy (TinyLFU)"))
setsRejected, err := meter.Int64ObservableGauge("signoz.cache.sets.rejected", metric.WithDescription("SetsRejected is the number of Set calls rejected by the policy (TinyLFU)"))
if err != nil {
errs = errors.Join(errs, err)
}
getsDropped, err := meter.Int64ObservableCounter("signoz.cache.gets.dropped", metric.WithDescription("GetsDropped is the number of Get calls that don't make it into internal buffers (due to contention or some other reason)"))
getsDropped, err := meter.Int64ObservableGauge("signoz.cache.gets.dropped", metric.WithDescription("GetsDropped is the number of Get calls that don't make it into internal buffers (due to contention or some other reason)"))
if err != nil {
errs = errors.Join(errs, err)
}
getsKept, err := meter.Int64ObservableCounter("signoz.cache.gets.kept", metric.WithDescription("GetsKept is the number of Get calls that make it into internal buffers"))
getsKept, err := meter.Int64ObservableGauge("signoz.cache.gets.kept", metric.WithDescription("GetsKept is the number of Get calls that make it into internal buffers"))
if err != nil {
errs = errors.Join(errs, err)
}
costUsed, err := meter.Int64ObservableGauge("signoz.cache.cost.used", metric.WithDescription("CostUsed is the current retained cost in the cache (CostAdded - CostEvicted)."))
if err != nil {
errs = errors.Join(errs, err)
}
totalCost, err := meter.Int64ObservableGauge("signoz.cache.total.cost", metric.WithDescription("TotalCost is the configured MaxCost ceiling for the cache."))
totalCost, err := meter.Int64ObservableGauge("signoz.cache.total.cost", metric.WithDescription("TotalCost is the available cost configured for the cache"))
if err != nil {
errs = errors.Join(errs, err)
}
@@ -111,7 +105,6 @@ func newMetrics(meter metric.Meter) (*telemetry, error) {
setsRejected: setsRejected,
getsDropped: getsDropped,
getsKept: getsKept,
costUsed: costUsed,
totalCost: totalCost,
}, nil
}

View File

@@ -29,10 +29,6 @@ func (cacheable *CacheableA) Clone() cachetypes.Cacheable {
}
}
func (cacheable *CacheableA) Cost() int64 {
return int64(len(cacheable.Key)) + 16
}
func (cacheable *CacheableA) MarshalBinary() ([]byte, error) {
return json.Marshal(cacheable)
}

View File

@@ -9,8 +9,7 @@ var (
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
)
func MustNewRegistry() featuretypes.Registry {
@@ -71,14 +70,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureUseFineGrainedAuthz,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether fine-grained authorization is enabled",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
)
if err != nil {
panic(err)

View File

@@ -335,8 +335,10 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
}
case qbtypes.RequestTypeScalar:
result := q.processScalarFormula(ctx, results, formula, req)
// For scalar results, apply limit by processScalarFormula itself since it needs to be applied before converting back to scalar format
results[name] = result
if result != nil {
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
results[name] = result
}
}
}
@@ -524,9 +526,6 @@ func (q *querier) processScalarFormula(
return nil
}
// Apply ordering (and limit) before converting to scalar format.
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit)
// Convert back to scalar format
scalarResult := &qbtypes.ScalarData{
QueryName: formula.Name,

View File

@@ -1,155 +1,15 @@
package querier
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
// scalarInputResult builds a ScalarData result with one group column ("service")
// and one aggregation column ("__result"), holding the provided (service, value) rows.
func scalarInputResult(queryName string, rows []struct {
service string
value float64
}) *qbtypes.Result {
serviceKey := telemetrytypes.TelemetryFieldKey{
Name: "service",
FieldDataType: telemetrytypes.FieldDataTypeString,
}
resultKey := telemetrytypes.TelemetryFieldKey{
Name: "__result",
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
}
data := make([][]any, 0, len(rows))
for _, r := range rows {
data = append(data, []any{r.service, r.value})
}
return &qbtypes.Result{
Value: &qbtypes.ScalarData{
QueryName: queryName,
Columns: []*qbtypes.ColumnDescriptor{
{
TelemetryFieldKey: serviceKey,
QueryName: queryName,
Type: qbtypes.ColumnTypeGroup,
},
{
TelemetryFieldKey: resultKey,
QueryName: queryName,
AggregationIndex: 0,
Type: qbtypes.ColumnTypeAggregation,
},
},
Data: data,
},
}
}
func TestProcessScalarFormula_AppliesOrderAndLimit(t *testing.T) {
q := &querier{
logger: instrumentationtest.New().Logger(),
}
// Mimic what a dashboard emits: orderBy keyed by the formula name ("F1"),
// which applyFormulas rewrites to __result before sorting.
orderByFormula := func(name string, dir qbtypes.OrderDirection) []qbtypes.OrderBy {
return []qbtypes.OrderBy{
{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: name,
},
},
Direction: dir,
},
}
}
// A+B per service: a=101, b=11, c=2
makeInputs := func() map[string]*qbtypes.Result {
return map[string]*qbtypes.Result{
"A": scalarInputResult("A", []struct {
service string
value float64
}{
{"a", 100},
{"b", 10},
{"c", 1},
}),
"B": scalarInputResult("B", []struct {
service string
value float64
}{
{"a", 1},
{"b", 0},
{"c", 1},
}),
}
}
makeReq := func(formula qbtypes.QueryBuilderFormula) *qbtypes.QueryRangeRequest {
return &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{Name: "A"}},
{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{Name: "B"}},
{Type: qbtypes.QueryTypeFormula, Spec: formula},
},
},
}
}
t.Run("F1 desc with limit truncates and sorts", func(t *testing.T) {
formula := qbtypes.QueryBuilderFormula{
Name: "F1",
Expression: "A + B",
Order: orderByFormula("F1", qbtypes.OrderDirectionDesc),
Limit: 2,
}
out := q.applyFormulas(context.Background(), makeInputs(), makeReq(formula))
got, ok := out["F1"]
require.True(t, ok, "formula result missing")
scalar, ok := got.Value.(*qbtypes.ScalarData)
require.True(t, ok, "expected *ScalarData, got %T", got.Value)
// Limit=2 + F1 desc: the two largest __result rows in descending order.
require.Len(t, scalar.Data, 2, "limit=2 was ignored before the fix")
require.Equal(t, "a", scalar.Data[0][0])
require.InDelta(t, 101.0, scalar.Data[0][1].(float64), 1e-9)
require.Equal(t, "b", scalar.Data[1][0])
require.InDelta(t, 10.0, scalar.Data[1][1].(float64), 1e-9)
})
t.Run("F1 desc without limit sorts all rows", func(t *testing.T) {
formula := qbtypes.QueryBuilderFormula{
Name: "F1",
Expression: "A / B",
Order: orderByFormula("F1", qbtypes.OrderDirectionAsc),
}
out := q.applyFormulas(context.Background(), makeInputs(), makeReq(formula))
got, ok := out["F1"]
require.True(t, ok)
scalar, ok := got.Value.(*qbtypes.ScalarData)
require.True(t, ok)
require.Len(t, scalar.Data, 2)
require.Equal(t, "c", scalar.Data[0][0])
require.InDelta(t, 1.0, scalar.Data[0][1].(float64), 1e-9)
require.Equal(t, "a", scalar.Data[1][0])
require.InDelta(t, 100.0, scalar.Data[1][1].(float64), 1e-9)
})
}
// Multiple series with different number of labels, shouldn't panic and should align labels correctly.
func TestConvertTimeSeriesDataToScalar_RaggedLabels(t *testing.T) {
label := func(name string, value any) *qbtypes.Label {

View File

@@ -1784,15 +1784,6 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
fineGrainedAuthz := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureUseFineGrainedAuthz, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseFineGrainedAuthz.String()),
Active: fineGrainedAuthz,
Usage: 0,
UsageLimit: -1,
Route: "",
})
if constants.IsDotMetricsEnabled {
for idx, feature := range featureSet {
if feature.Name == licensetypes.DotMetricsEnabled {

View File

@@ -769,13 +769,6 @@ func ParseQueryRangeParams(r *http.Request) (*v3.QueryRangeParamsV3, *model.ApiE
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
}
// Clamp the top-level Step for PromQL
if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypePromQL {
if minStep := common.MinAllowedStepInterval(queryRangeParams.Start, queryRangeParams.End); queryRangeParams.Step < minStep {
queryRangeParams.Step = minStep
}
}
// prepare the variables for the corresponding query type
formattedVars := make(map[string]interface{})
for name, value := range queryRangeParams.Variables {

View File

@@ -41,11 +41,6 @@ func (c *GetWaterfallSpansForTraceWithMetadataCache) Clone() cachetypes.Cacheabl
}
}
func (c *GetWaterfallSpansForTraceWithMetadataCache) Cost() int64 {
const perSpanBytes = 256
return int64(c.TotalSpans) * perSpanBytes
}
func (c *GetWaterfallSpansForTraceWithMetadataCache) MarshalBinary() (data []byte, err error) {
return json.Marshal(c)
}
@@ -71,16 +66,6 @@ func (c *GetFlamegraphSpansForTraceCache) Clone() cachetypes.Cacheable {
}
}
func (c *GetFlamegraphSpansForTraceCache) Cost() int64 {
const perSpanBytes = 128
var spans int64
for _, row := range c.SelectedSpans {
spans += int64(len(row))
}
spans += int64(len(c.TraceRoots))
return spans * perSpanBytes
}
func (c *GetFlamegraphSpansForTraceCache) MarshalBinary() (data []byte, err error) {
return json.Marshal(c)
}

View File

@@ -2,11 +2,9 @@ package sqlrulestore
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -16,14 +14,10 @@ import (
type maintenance struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
func NewMaintenanceStore(store sqlstore.SQLStore, providerSettings factory.ProviderSettings) ruletypes.MaintenanceStore {
return &maintenance{
sqlstore: store,
logger: providerSettings.Logger,
}
func NewMaintenanceStore(store sqlstore.SQLStore) ruletypes.MaintenanceStore {
return &maintenance{sqlstore: store}
}
func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string) ([]*ruletypes.PlannedMaintenance, error) {
@@ -41,11 +35,7 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
gettablePlannedMaintenance := make([]*ruletypes.PlannedMaintenance, 0)
for _, gettableMaintenancesRule := range gettableMaintenancesRules {
m := gettableMaintenancesRule.ToPlannedMaintenance()
gettablePlannedMaintenance = append(gettablePlannedMaintenance, m)
if m.HasScheduleRecurrenceBoundsMismatch() {
r.logger.WarnContext(ctx, "planned_downtime_recurrence_schedule_mismatch", slog.String("maintenance_id", m.ID.StringValue()))
}
gettablePlannedMaintenance = append(gettablePlannedMaintenance, gettableMaintenancesRule.ToPlannedMaintenance())
}
return gettablePlannedMaintenance, nil

View File

@@ -44,7 +44,7 @@ func NewFactory(
) factory.ProviderFactory[ruler.Ruler, ruler.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config ruler.Config) (ruler.Ruler, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore, providerSettings)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
managerOpts := &rules.ManagerOptions{
TelemetryStore: telemetryStore,

View File

@@ -18,10 +18,6 @@ type Cloneable interface {
// Creates a deep copy of the Cacheable. This method is useful for memory caches to avoid the need for serialization/deserialization. It also prevents
// race conditions in the memory cache.
Clone() Cacheable
// Cost returns the weight of this entry for cost-based cache accounting
// and eviction. Typically derived from the approximate retained byte size,
// but the value represents cache cost, not literal bytes.
Cost() int64
}
func NewSha1CacheKey(val string) string {

View File

@@ -59,21 +59,3 @@ func (c *CachedData) Clone() cachetypes.Cacheable {
return clonedCachedData
}
// Cost approximates the retained bytes of this CachedData for use as the
// ristretto cache cost. The dominant contributor is the serialized bucket
// values (json.RawMessage); other fields are fixed-size or small strings.
func (c *CachedData) Cost() int64 {
var size int64
for _, b := range c.Buckets {
if b == nil {
continue
}
// Value is the bulk of the payload
size += int64(len(b.Value))
}
for _, w := range c.Warnings {
size += int64(len(w))
}
return size
}

View File

@@ -11,7 +11,9 @@ import (
"github.com/uptrace/bun"
)
var ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
var (
ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
)
type MaintenanceStatus struct {
valuer.String
@@ -131,26 +133,6 @@ type PlannedMaintenanceWithRules struct {
Rules []*StorablePlannedMaintenanceRule `bun:"rel:has-many,join:id=planned_maintenance_id"`
}
// HasScheduleRecurrenceBoundsMismatch reports whether a recurring maintenance
// has different start/end bounds in Schedule and Schedule.Recurrence.
//
// This is used to detect if there are any entries with recurrence that don't
// have the same timestamps stored at the schedule-level.
// UI payloads duplicated those values in both places, but direct API users may
// have stored bounds that are missing from, or different than, the schedule-level bounds.
// We need to observe these before we can safely drop Recurrence.StartTime and
// Recurrence.EndTime.
func (m *PlannedMaintenance) HasScheduleRecurrenceBoundsMismatch() bool {
recurrence := m.Schedule.Recurrence
if recurrence == nil {
return false
}
return !recurrence.StartTime.Equal(m.Schedule.StartTime) ||
(recurrence.EndTime == nil && !m.Schedule.EndTime.IsZero()) ||
(recurrence.EndTime != nil && !recurrence.EndTime.Equal(m.Schedule.EndTime))
}
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
// Check if the alert ID is in the maintenance window
found := false
@@ -177,43 +159,42 @@ func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
return false
}
startTime := m.Schedule.StartTime
endTime := m.Schedule.EndTime
recurrence := m.Schedule.Recurrence
currentTime := now.In(loc)
// fixed schedule — only when no recurrence is configured.
// When recurrence is set, the recurring check below handles everything;
// falling through here would cause the window to match the absolute
// StartTimeEndTime range instead of the daily/weekly/monthly pattern.
if recurrence == nil && !startTime.IsZero() && !endTime.IsZero() {
if now.Equal(startTime) || now.Equal(endTime) ||
(now.After(startTime) && now.Before(endTime)) {
// fixed schedule
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
startTime := m.Schedule.StartTime.In(loc)
endTime := m.Schedule.EndTime.In(loc)
if currentTime.Equal(startTime) || currentTime.Equal(endTime) ||
(currentTime.After(startTime) && currentTime.Before(endTime)) {
return true
}
}
// recurring schedule
if recurrence != nil {
if m.Schedule.Recurrence != nil {
start := m.Schedule.Recurrence.StartTime
// Make sure the recurrence has started
if now.Before(recurrence.StartTime) {
if currentTime.Before(start.In(loc)) {
return false
}
// Check if recurrence has expired
if recurrence.EndTime != nil {
if !recurrence.EndTime.IsZero() && now.After(*recurrence.EndTime) {
if m.Schedule.Recurrence.EndTime != nil {
endTime := *m.Schedule.Recurrence.EndTime
if !endTime.IsZero() && currentTime.After(endTime.In(loc)) {
return false
}
}
currentTime := now.In(loc)
switch recurrence.RepeatType {
switch m.Schedule.Recurrence.RepeatType {
case RepeatTypeDaily:
return m.checkDaily(currentTime, recurrence, loc)
return m.checkDaily(currentTime, m.Schedule.Recurrence, loc)
case RepeatTypeWeekly:
return m.checkWeekly(currentTime, recurrence, loc)
return m.checkWeekly(currentTime, m.Schedule.Recurrence, loc)
case RepeatTypeMonthly:
return m.checkMonthly(currentTime, recurrence, loc)
return m.checkMonthly(currentTime, m.Schedule.Recurrence, loc)
}
}

View File

@@ -13,6 +13,7 @@ func timePtr(t time.Time) *time.Time {
}
func TestShouldSkipMaintenance(t *testing.T) {
cases := []struct {
name string
maintenance *PlannedMaintenance
@@ -498,7 +499,7 @@ func TestShouldSkipMaintenance(t *testing.T) {
},
},
},
ts: time.Date(2024, 4, 1, 12, 10, 0, 0, time.UTC),
ts: time.Date(2024, 04, 1, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
@@ -507,14 +508,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
},
},
ts: time.Date(2024, 4, 15, 12, 10, 0, 0, time.UTC),
ts: time.Date(2024, 04, 15, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
@@ -523,14 +524,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
},
},
ts: time.Date(2024, 4, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday
ts: time.Date(2024, 04, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday
skip: false,
},
{
@@ -539,14 +540,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
},
},
ts: time.Date(2024, 4, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday
ts: time.Date(2024, 04, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday
skip: false,
},
{
@@ -555,14 +556,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
},
},
ts: time.Date(2024, 5, 6, 12, 10, 0, 0, time.UTC),
ts: time.Date(2024, 05, 06, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
@@ -571,14 +572,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
},
},
ts: time.Date(2024, 5, 6, 14, 0, 0, 0, time.UTC),
ts: time.Date(2024, 05, 06, 14, 00, 0, 0, time.UTC),
skip: true,
},
{
@@ -587,13 +588,13 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
},
},
ts: time.Date(2024, 4, 4, 12, 10, 0, 0, time.UTC),
ts: time.Date(2024, 04, 04, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
@@ -602,13 +603,13 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
},
},
ts: time.Date(2024, 4, 4, 14, 10, 0, 0, time.UTC),
ts: time.Date(2024, 04, 04, 14, 10, 0, 0, time.UTC),
skip: false,
},
{
@@ -617,52 +618,13 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
},
},
ts: time.Date(2024, 5, 4, 12, 10, 0, 0, time.UTC),
skip: true,
},
// The recurrence should govern, when set. Not the fixed range.
{
name: "recurring-daily-with-fixed-times-outside-daily-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
// These fixed fields should be ignored when Recurrence is set.
StartTime: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC), // daily at 14:00
Duration: valuer.MustParseTextDuration("2h"), // until 16:00
RepeatType: RepeatTypeDaily,
},
},
},
// 11:00 is inside the fixed range but outside the daily 14:00-16:00 window.
// Before the fix this returned true (bug); after fix it returns false.
ts: time.Date(2026, 4, 15, 11, 0, 0, 0, time.UTC),
skip: false,
},
{
name: "recurring-daily-with-fixed-times-inside-daily-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
// 15:00 is inside the daily 14:00-16:00 window — should skip.
ts: time.Date(2026, 4, 15, 15, 0, 0, 0, time.UTC),
ts: time.Date(2024, 05, 04, 12, 10, 0, 0, time.UTC),
skip: true,
},
}

View File

@@ -200,8 +200,6 @@ def build_formula_query(
*,
functions: list[dict] | None = None,
disabled: bool = False,
order: list[dict] | None = None,
limit: int | None = None,
) -> dict:
spec: dict[str, Any] = {
"name": name,
@@ -210,10 +208,6 @@ def build_formula_query(
}
if functions:
spec["functions"] = functions
if order:
spec["order"] = order
if limit is not None:
spec["limit"] = limit
return {"type": "builder_formula", "spec": spec}

View File

@@ -11,11 +11,6 @@ from fixtures.logs import Logs
from fixtures.querier import (
assert_identical_query_response,
assert_minutely_bucket_values,
build_formula_query,
build_group_by_field,
build_logs_aggregation,
build_order_by,
build_scalar_query,
find_named_result,
index_series_by_label,
make_query_request,
@@ -2116,180 +2111,3 @@ def test_logs_fill_zero_formula_with_group_by(
expected_by_ts=expectations[service_name],
context=f"logs/fillZero/F1/{service_name}",
)
def test_logs_formula_orderby_and_limit(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
) -> None:
"""
Test that formula results are correctly ordered and limited when
order and limit are applied on the formula.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs: list[Logs] = []
# For service-i (i in 0..9): insert (10 - i) ERROR logs and 2 INFO logs.
# A counts ERROR, B counts INFO, so A/B = (10 - i) / 2.
# service-0 ratio = 5.0 (highest), service-9 ratio = 0.5 (lowest).
for i in range(10):
for j in range(10 - i):
logs.append(
Logs(
timestamp=now - timedelta(minutes=j + 1),
resources={"service.name": f"service-{i}"},
attributes={"code.file": "test.py"},
body=f"Error log {i}-{j}",
severity_text="ERROR",
)
)
for k in range(2):
logs.append(
Logs(
timestamp=now - timedelta(minutes=k + 1),
resources={"service.name": f"service-{i}"},
attributes={"code.file": "test.py"},
body=f"Info log {i}-{k}",
severity_text="INFO",
)
)
# Extra INFO-only services that appear in B but not in A. The formula
for name in ("service-info-only-1", "service-info-only-2"):
for k in range(2):
logs.append(
Logs(
timestamp=now - timedelta(minutes=k + 1),
resources={"service.name": name},
attributes={"code.file": "test.py"},
body=f"Info log {name}-{k}",
severity_text="INFO",
)
)
# Logs look like this (columns = minutes before `now`; query range is
# (now - 15m, now], so the `now` column is the exclusive upper bound and
# no log lands there). E = ERROR, I = INFO, X = both at that minute.
#
# t-10 t-9 t-8 t-7 t-6 t-5 t-4 t-3 t-2 t-1 |now | A B A/B
# service-0: E E E E E E E E X X | | 10 2 5.0
# service-1: . E E E E E E E X X | | 9 2 4.5
# service-2: . . E E E E E E X X | | 8 2 4.0
# service-3: . . . E E E E E X X | | 7 2 3.5
# service-4: . . . . E E E E X X | | 6 2 3.0
# service-5: . . . . . E E E X X | | 5 2 2.5
# service-6: . . . . . . E E X X | | 4 2 2.0
# service-7: . . . . . . . E X X | | 3 2 1.5
# service-8: . . . . . . . . X X | | 2 2 1.0
# service-9: . . . . . . . . I X | | 1 2 0.5
# info-only-1: . . . . . . . . I I | | 0* 2 0.0
# info-only-2: . . . . . . . . I I | | 0* 2 0.0
#
# * A is missing for the info-only services; because A is count(), the
# formula evaluator defaults missing A to 0, yielding A/B = 0.
insert_logs(logs)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
result = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=15)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type="scalar",
queries=[
build_scalar_query(
name="A",
signal="logs",
aggregations=[build_logs_aggregation("count()")],
group_by=[build_group_by_field("service.name")],
filter_expression="severity_text = 'ERROR'",
disabled=True,
),
build_scalar_query(
name="B",
signal="logs",
aggregations=[build_logs_aggregation("count()")],
group_by=[build_group_by_field("service.name")],
filter_expression="severity_text = 'INFO'",
disabled=True,
),
build_formula_query(
"F1",
"A / B",
order=[build_order_by("__result", "desc")],
limit=3,
),
build_formula_query(
"F2",
"A / B",
order=[build_order_by("__result", "desc")],
),
build_formula_query(
"F3",
"A / B",
order=[build_order_by("__result", "asc")],
limit=3,
),
build_formula_query(
"F4",
"A / B",
order=[build_order_by("__result", "asc")],
),
],
)
assert result.status_code == HTTPStatus.OK
assert result.json()["status"] == "success"
results = result.json()["data"]["data"]["results"]
def extract_services_and_values(query_name: str) -> tuple[list, list]:
res = find_named_result(results, query_name)
assert res is not None, f"Expected formula result named {query_name}"
cols = res["columns"]
s_col = next(i for i, c in enumerate(cols) if c["name"] == "service.name")
v_col = next(i for i, c in enumerate(cols) if c["name"] == "__result")
rows = res["data"]
return [row[s_col] for row in rows], [row[v_col] for row in rows]
# Because A is count(), canDefaultZero["A"] is true; the formula evaluator
# defaults A to 0 for services that exist only in B. So the two INFO-only
# services appear in the formula result with value 0.0 (extreme bottom in
# desc order, extreme top in asc order). Their relative ordering is not
# deterministic across separate formula evaluations (tied values).
info_only_services = {"service-info-only-1", "service-info-only-2"}
# F2: desc, no limit -> 12 rows in descending order by value.
f2_services, f2_values = extract_services_and_values("F2")
assert len(f2_services) == 12, f"F2: expected 12 rows with no limit, got {len(f2_services)}"
assert f2_values == [5.0, 4.5, 4.0, 3.5, 3.0, 2.5, 2.0, 1.5, 1.0, 0.5, 0.0, 0.0], f2_values
# Top 10 have distinct positive values -> deterministic service ordering.
assert f2_services[:10] == [f"service-{i}" for i in range(10)], f2_services[:10]
# Tail 2 are the INFO-only services tied at 0.0 (order between them not guaranteed).
assert set(f2_services[10:]) == info_only_services, f2_services[10:]
# F1: desc + limit 3 -> must be exactly the first 3 rows of F2.
# Top 3 are not in the tie region, so prefix equality is safe.
f1_services, f1_values = extract_services_and_values("F1")
assert len(f1_services) == 3, f"F1: expected 3 rows after limit, got {len(f1_services)}"
assert f1_services == f2_services[:3], f"F1 services {f1_services} are not the prefix of F2 services {f2_services}"
assert f1_values == f2_values[:3], f"F1 values {f1_values} are not the prefix of F2 values {f2_values}"
# F4: asc, no limit -> 12 rows in ascending order by value.
f4_services, f4_values = extract_services_and_values("F4")
assert len(f4_services) == 12, f"F4: expected 12 rows with no limit, got {len(f4_services)}"
assert f4_values == sorted(f4_values), f"F4 not ascending: {f4_values}"
# First 2 are the INFO-only services tied at 0.0 (order between them not guaranteed).
assert set(f4_services[:2]) == info_only_services, f4_services[:2]
assert f4_values[:2] == [0.0, 0.0], f4_values[:2]
# Tail 10 are service-9 down to service-0 by value.
assert f4_services[2:] == [f"service-{i}" for i in reversed(range(10))], f4_services[2:]
assert f4_values[2:] == [(10 - i) / 2 for i in reversed(range(10))], f4_values[2:]
# F3: asc + limit 3 -> values must match F4[:3] exactly; service set must
# match too. Direct prefix equality on services would be flaky because the
# two tied INFO-only entries can swap order between formula evaluations.
f3_services, f3_values = extract_services_and_values("F3")
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"