Compare commits

..

10 Commits

Author SHA1 Message Date
Nikhil Soni
ce05ecca81 Revert "chore: extract out flamegraph building logic for easier review"
This reverts commit f9222c9930.
2026-05-29 11:46:12 +05:30
Nikhil Soni
f9222c9930 chore: extract out flamegraph building logic for easier review 2026-05-29 11:45:48 +05:30
Nikhil Soni
511dd0add8 chore: ignore nil assigment lint error 2026-05-29 11:41:19 +05:30
Nikhil Soni
4ef8500b28 chore: move exported methods to the top 2026-05-28 00:26:26 +05:30
Nikhil Soni
168b2eaa9c feat: query full spans for smaller traces 2026-05-27 00:12:38 +05:30
Nikhil Soni
6b613f18a3 feat: add api and module for flamegraph v3 2026-05-26 20:04:20 +05:30
Nikhil Soni
1b0447181d feat: add method to enrich selected spans 2026-05-26 20:03:47 +05:30
Nikhil Soni
20edff4771 feat: add config for flamegraph 2026-05-26 19:21:33 +05:30
Nikhil Soni
2048ef3d2f chore: remove limit from request payload
It's a new api so doesn't need to be backward compatible
2026-05-26 19:06:48 +05:30
Nikhil Soni
53c551359e feat: add types for flamegraph v3 in module structure 2026-05-26 18:56:35 +05:30
54 changed files with 712 additions and 2159 deletions

View File

@@ -434,6 +434,17 @@ tracedetail:
max_depth_to_auto_expand: 5
# Threshold below which all spans are returned without windowing.
max_limit_to_select_all_spans: 10000
flamegraph:
# Maximum number of BFS depth levels included in a windowed response.
max_selected_levels: 50
# Maximum spans per level before sampling is applied.
max_spans_per_level: 100
# Number of highest-latency spans always included when sampling a level.
sampling_top_latency_count: 5
# Number of timestamp buckets used for uniform sampling within a level.
sampling_bucket_count: 50
# Threshold below which all spans are returned without windowing or sampling.
select_all_spans_limit: 100000
##################### Authz #################################
authz:

View File

@@ -94,19 +94,17 @@ func newProvider(
func (provider *Provider) Start(ctx context.Context) error {
close(provider.healthyC)
startDelay := provider.config.NewJitter()
provider.collect(ctx)
timer := time.NewTimer(startDelay)
defer timer.Stop()
ticker := time.NewTicker(provider.config.Interval)
defer ticker.Stop()
for {
select {
case <-provider.stopC:
return nil
case <-timer.C:
case <-ticker.C:
provider.collect(ctx)
next := provider.config.Interval - provider.config.NewJitter()
timer.Reset(next)
}
}
}
@@ -259,7 +257,6 @@ func (provider *Provider) report(ctx context.Context, orgID valuer.UUID, license
collectedReadings, err := collector.Collect(ctx, orgID, license, window)
if err != nil {
provider.metrics.collections.Add(ctx, 1, metric.WithAttributes(meterAttr, errors.TypeAttr(err)))
provider.settings.Logger().ErrorContext(ctx, "meter collector failed", errors.Attr(err), slog.String("org_id", orgID.StringValue()), slog.String("meter", collector.Name().String()))
continue
}

View File

@@ -1,44 +0,0 @@
# Agent Directives: Mechanical Overrides
You are operating within a constrained context window and strict system prompts. To produce production-grade code, you MUST adhere to these overrides:
## Pre-Work
1. THE "STEP 0" RULE: Dead code accelerates context compaction. Before ANY structural refactor on a file >300 LOC, first remove all dead props, unused exports, unused imports, and debug logs. Commit this cleanup separately before starting the real work.
2. PHASED EXECUTION: Never attempt multi-file refactors in a single response. Break work into explicit phases. Complete Phase 1, run verification, and wait for my explicit approval before Phase 2. Each phase must touch no more than 5 files.
## Code Quality
3. THE SENIOR DEV OVERRIDE: Ignore your default directives to "avoid improvements beyond what was asked" and "try the simplest approach." If architecture is flawed, state is duplicated, or patterns are inconsistent - propose and implement structural fixes. Ask yourself: ">
4. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
- Run `pnpm tsgo --noEmit`
- Run `pnpm lint:js --quiet`
- Run `pnpm build`
- Find if the file has tests for it, or if there's `__test__` folder or the parent folder has tests, and run.
- Fixed ALL resulting errors
## Context Management
5. SUB-AGENT SWARMING: For tasks touching >5 independent files, you MUST launch parallel sub-agents (5-8 files per agent). Each agent gets its own context window. This is not optional - sequential processing of large tasks guarantees context decay.
6. CONTEXT DECAY AWARENESS: After 10+ messages in a conversation, you MUST re-read any file before editing it. Do not trust your memory of file contents. Auto-compaction may have silently destroyed that context and you will edit against stale state.
7. FILE READ BUDGET: Each file read is capped at 2,000 lines. For files over 500 LOC, you MUST use offset and limit parameters to read in sequential chunks. Never assume you have seen a complete file from a single read.
8. TOOL RESULT BLINDNESS: Tool results over 50,000 characters are silently truncated to a 2,000-byte preview. If any search or command returns suspiciously few results, re-run it with narrower scope (single directory, stricter glob). State when you suspect truncation occu>
## Edit Safety
9. EDIT INTEGRITY: Before EVERY file edit, re-read the file. After editing, read it again to confirm the change applied correctly. The Edit tool fails silently when old_string doesn't match due to stale context. Never batch more than 3 edits to the same file without a ve>
10. NO SEMANTIC SEARCH: You have grep, not an AST. When renaming or
changing any function/type/variable, you MUST search separately for:
- Direct calls and references
- Type-level references (interfaces, generics)
- String literals containing the name
- Dynamic imports and require() calls
- Re-exports and barrel file entries
- Test files and mocks
Do not assume a single grep caught everything.

View File

@@ -1 +0,0 @@
AGENTS.md

View File

@@ -54,12 +54,5 @@
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server",
"AI_ASSISTANT": "SigNoz | AI Assistant",
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
"ROLE_DETAILS": "SigNoz | Role Details",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
"MCP_SERVER": "SigNoz | MCP Server"
}

View File

@@ -77,12 +77,5 @@
"ROLES_SETTINGS": "SigNoz | Roles",
"MEMBERS_SETTINGS": "SigNoz | Members",
"SERVICE_ACCOUNTS_SETTINGS": "SigNoz | Service Accounts",
"MCP_SERVER": "SigNoz | MCP Server",
"AI_ASSISTANT": "SigNoz | AI Assistant",
"TRACE_DETAIL_OLD": "SigNoz | Trace Detail",
"SERVICE_TOP_LEVEL_OPERATIONS": "SigNoz | Service Operations",
"ROLE_DETAILS": "SigNoz | Role Details",
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
"PUBLIC_DASHBOARD": "SigNoz | Dashboard"
"MCP_SERVER": "SigNoz | MCP Server"
}

View File

@@ -117,37 +117,7 @@
width: 100% !important;
max-width: 100% !important;
font-family: 'Space Mono', monospace !important;
// Default cap = ~5 suggestion rows + section header + footer.
// Anything beyond scrolls inside this container.
max-height: 250px !important;
overflow-y: auto !important;
// Grow the cap proportionally to the number of recent search
// rows present so all recents render in full while ~5
// suggestions remain visible below them. Each pill card is
// ~52px (36px row + 8px margin top/bottom + 8px padding) plus
// a one-time ~30px for the "Recent searches" section header.
//
// CodeMirror renders recents first (rank: 1), then
// `<completion-section>` between groups, then suggestions —
// so the first N `<li>` direct children are the recents.
// `:nth-of-type` counts only `<li>` (sections use a different
// tag), making the count-via-position trick reliable.
&:has(> li:nth-of-type(1) .cm-completionIcon-recent) {
max-height: 332px !important;
}
&:has(> li:nth-of-type(2) .cm-completionIcon-recent) {
max-height: 384px !important;
}
&:has(> li:nth-of-type(3) .cm-completionIcon-recent) {
max-height: 436px !important;
}
&:has(> li:nth-of-type(4) .cm-completionIcon-recent) {
max-height: 488px !important;
}
&:has(> li:nth-of-type(5) .cm-completionIcon-recent) {
max-height: 540px !important;
}
min-height: 200px !important;
&::-webkit-scrollbar {
width: 0.3rem;
@@ -163,31 +133,6 @@
background: transparent;
}
// CodeMirror renders <completion-section> headers between
// option groups when the `section` field is set on each
// Completion. Style them as small caps labels with a top
// divider so the Suggestions and Recent searches groups are
// clearly distinct.
completion-section {
display: block;
padding: 10px 12px 6px;
font-size: 10px !important;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--l3-foreground, var(--l2-foreground));
opacity: 0.7;
border-top: 1px solid var(--l1-border);
background-color: transparent;
}
// The first section header doesn't need a divider above it.
completion-section:first-child,
> li:first-child + completion-section,
completion-section:first-of-type {
border-top: 0;
}
li {
width: 100% !important;
max-width: 100% !important;
@@ -214,136 +159,11 @@
display: none !important;
}
// Tag the "recent" detail column so it reads as a label,
// not as an inline value. CodeMirror puts the option's
// `detail` string in `.cm-completionDetail`.
.cm-completionDetail {
margin-left: auto;
font-style: normal;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.55;
}
&[aria-selected='true'] {
background: var(--l3-background) !important;
font-weight: 600 !important;
}
}
// Recent search rows render as pill-style cards: rounded
// background per row, padded inset, design-token text color.
// Targeted via the icon class CodeMirror emits — even though
// the icon itself is hidden, its element still carries the
// type class.
li:has(.cm-completionIcon-recent) {
height: auto !important;
margin: 4px 8px !important;
padding: 6px 12px !important;
width: calc(100% - 16px) !important;
max-width: calc(100% - 16px) !important;
background: #23262e !important;
color: var(--text-vanilla-400) !important;
border: 1px solid var(--l1-border) !important;
border-radius: 6px !important;
line-height: 24px !important;
// Long filter expressions truncate to a single line with
// an ellipsis instead of wrapping or pushing the inline
// delete button off-screen. `min-width: 0` on the label
// is required for ellipsis to work inside a flex parent.
.cm-completionLabel {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover,
&[aria-selected='true'] {
background: color-mix(
in srgb,
#23262e 80%,
var(--text-vanilla-400)
) !important;
color: var(--text-vanilla-400) !important;
}
// Reveal the inline delete button more strongly on hover
// or when the row is keyboard-selected.
&:hover .cm-recent-delete,
&[aria-selected='true'] .cm-recent-delete {
opacity: 1;
}
}
// Apply the same truncation to regular suggestion rows so
// long attribute names (e.g. `service.cluster.namespace.x`)
// don't push other cells off the row.
li:not(:has(.cm-completionIcon-recent)) .cm-completionLabel {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Inline per-row delete affordance for recent searches,
// injected via the autocompletion `addToOptions` config.
.cm-recent-delete {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--text-vanilla-400);
font-size: 18px;
line-height: 1;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.12s ease, background-color 0.12s ease;
&:hover {
opacity: 1;
background: color-mix(
in srgb,
var(--text-vanilla-400) 18%,
transparent
);
}
}
}
// Footer hint bar: keyboard shortcuts at the bottom of the
// dropdown. Rendered via ::after so we don't need to inject DOM
// — the popover's outer container gets a pseudo-element below
// the scrollable list.
&::after {
content: '↓↑ to navigate · ↵ to apply · esc to dismiss';
display: block;
padding: 8px 12px;
border-top: 1px solid var(--l1-border);
font-family: 'Space Mono', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--l2-foreground);
opacity: 0.6;
background: color-mix(
in srgb,
var(--l1-background) 50%,
transparent
);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
}

View File

@@ -29,8 +29,6 @@ import {
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import * as recentQueriesStore from 'lib/recentQueries/store';
import { normalizeFilterExpression } from 'lib/recentQueries/normalize';
import { debounce, isNull } from 'lodash-es';
import {
IDetailedError,
@@ -91,24 +89,6 @@ interface QuerySearchProps {
initialExpression?: string;
}
// Maps the legacy `DataSource` enum to the V5 `SignalType` union. The string
// values match (`'logs'` / `'traces'` / `'metrics'`); narrow with a runtime
// check rather than blindly casting. Return type is inlined to keep this
// module's import surface minimal (avoids a transitive dep cycle through
// the v5 queryRange types).
function dataSourceToSignal(
dataSource: DataSource,
): 'logs' | 'traces' | 'metrics' | null {
if (
dataSource === DataSource.LOGS ||
dataSource === DataSource.TRACES ||
dataSource === DataSource.METRICS
) {
return dataSource;
}
return null;
}
function QuerySearch({
placeholder,
onChange,
@@ -1269,144 +1249,6 @@ function QuerySearch({
};
}
// Whole-expression recent-search completions. Folded into the same
// `CompletionResult` as `autoSuggestions` (see `combinedSuggestions` below)
// rather than registered as a separate completion source — CodeMirror picks
// the source with the rightmost `from`, so a parallel source with
// `from: 0` would be silently dropped in favour of the positional one.
const recentsSignal = useMemo(() => dataSourceToSignal(dataSource), [dataSource]);
// Stable section objects so CodeMirror can group options under labelled
// headers in the dropdown. `rank` orders the sections — Recent searches
// render first (top) so users can scan their own history before falling
// through to the larger Suggestions list.
const recentsSection = useMemo(
() => ({ name: 'Recent searches', rank: 1 }),
[],
);
const suggestionsSection = useMemo(() => ({ name: 'Suggestions', rank: 2 }), []);
function getRecentOptions(
fullDoc: string,
): {
label: string;
type: string;
boost: number;
section: typeof recentsSection;
recentId: string;
recentSignal: 'logs' | 'traces' | 'metrics';
apply: (
view: EditorView,
completion: unknown,
from: number,
to: number,
) => void;
}[] {
// Display gates only on signal. Recents are shown across all panel
// types for the same signal so a user can replay a query they built
// anywhere.
if (!recentsSignal) {
return [];
}
const all = recentQueriesStore.list(recentsSignal);
// Normalize both sides before substring matching so formatting
// variations (leading/trailing whitespace, spaces around operators,
// keyword casing) don't accidentally exclude a recent. `attempt != 1`
// stored, ` attempt != 1 ` typed → both normalize to `attempt!=1` and
// substring-match.
const normalizedDoc = normalizeFilterExpression(fullDoc);
const seen = new Set<string>();
const matches = all
.filter((e) => {
if (normalizedDoc === '') {
return true;
}
return normalizeFilterExpression(e.filter.expression).includes(
normalizedDoc,
);
})
// Dedupe by raw expression in case old (pre-strip) entries with
// different ID formats still live in localStorage alongside the
// new ones — most-recently-used wins.
.filter((e) => {
const key = e.filter.expression.toLowerCase().trim();
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
})
.slice(0, 5);
return matches.map((entry) => ({
label: entry.filter.expression,
type: 'recent',
boost: -50,
section: recentsSection,
// Custom fields read by the addToOptions delete-button renderer
// so it can call store.remove on click.
recentId: entry.id,
recentSignal: entry.signal,
apply: (view: EditorView): void => {
// Replace the whole document with the recent's filter
// expression. The editor's onChange fires synchronously and
// pushes the new expression up to the parent via
// `handleSearchChange`, so non-filter fields on the current
// builder query stay as-is.
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: entry.filter.expression,
},
selection: { anchor: entry.filter.expression.length },
});
},
}));
}
function combinedSuggestions(
context: CompletionContext,
): CompletionResult | null {
const fullDoc = context.state.doc.toString();
const recentOptions = getRecentOptions(fullDoc);
const result = autoSuggestions(context);
// Tag positional suggestions with the "Suggestions" section so
// CodeMirror renders a section header above them. No cap — the full
// list scrolls inside the dropdown's max-height container so users
// can always reach any key/value, with or without recents present.
const sectionedSuggestions = (result?.options || []).map((opt) => ({
...opt,
section: suggestionsSection,
}));
if (recentOptions.length === 0 && sectionedSuggestions.length === 0) {
return result;
}
// `filter: false` tells CodeMirror to skip its built-in prefix scorer.
// Both option groups are pre-filtered in their respective sources
// (autoSuggestions filters positional options by the current token;
// getRecentOptions filters recents by full-doc prefix), so disabling
// CM's scorer keeps both visible when our filters allowed them through.
if (!result) {
return {
from: 0,
to: fullDoc.length,
options: recentOptions,
filter: false,
};
}
return {
...result,
options: [...recentOptions, ...sectionedSuggestions],
filter: false,
};
}
// Effect to handle focus state and trigger suggestions
useEffect(() => {
const clearTimeout = toggleSuggestions(10);
@@ -1552,94 +1394,11 @@ function QuerySearch({
})}
extensions={[
autocompletion({
override: [combinedSuggestions],
override: [autoSuggestions],
defaultKeymap: true,
closeOnBlur: true,
activateOnTyping: true,
maxRenderedOptions: 50,
// Inject a delete (×) button into recent-search
// rows so users can remove individual entries
// without opening any other UI. Non-recent rows
// render an empty placeholder.
addToOptions: [
{
// Attach a native `title` tooltip to the row for
// long recent-search expressions. The pill cell
// truncates with ellipsis (see CSS); the tooltip
// reveals the full text on hover. We can't set
// attributes on the `<li>` directly from this
// render — CodeMirror appends our returned Node
// as a cell — so we defer the title-set to a
// microtask once the parent linkage is in place.
render: (completion): Node => {
const cell = document.createElement('span');
cell.style.display = 'none';
if (completion.type === 'recent') {
queueMicrotask(() => {
if (cell.parentElement) {
cell.parentElement.title = completion.label;
}
});
}
return cell;
},
position: 5,
},
{
render: (completion, _state, view): Node => {
if (completion.type !== 'recent') {
const empty = document.createElement('span');
empty.style.display = 'none';
return empty;
}
const c = completion as typeof completion & {
recentId?: string;
recentSignal?: 'logs' | 'traces' | 'metrics';
};
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'cm-recent-delete';
btn.setAttribute(
'aria-label',
'Remove from recent searches',
);
btn.title = 'Remove from recent searches';
btn.textContent = '×';
// Stop pointerdown / mousedown / click so the
// delete doesn't also fire the apply-completion
// path. CodeMirror uses pointerdown to commit
// the selected option, so we must intercept it.
const stop = (e: Event): void => {
e.preventDefault();
e.stopPropagation();
};
btn.addEventListener('pointerdown', stop);
btn.addEventListener('mousedown', stop);
btn.addEventListener('click', (e) => {
stop(e);
if (!c.recentId || !c.recentSignal) {
return;
}
recentQueriesStore.remove(
c.recentId,
c.recentSignal,
);
// The store mutation alone doesn't tell
// CodeMirror to re-render the open
// dropdown. Re-trigger the completion
// source so the deleted row disappears
// immediately, and re-focus the editor
// so the dropdown stays interactive.
if (view) {
view.focus();
startCompletion(view);
}
});
return btn;
},
position: 100,
},
],
}),
javascript({ jsx: false, typescript: false }),
EditorView.lineWrapping,

View File

@@ -1,5 +1,4 @@
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import {
CommandDialog,
CommandEmpty,
@@ -10,17 +9,7 @@ import {
CommandShortcut,
} from '@signozhq/ui/command';
import logEvent from 'api/common/logEvent';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import {
openAIAssistantModal,
useAIAssistantStore,
} from 'container/AIAssistant/store/useAIAssistantStore';
import { useThemeMode } from 'hooks/useDarkMode';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import history from 'lib/history';
import { ROLES as UserRole } from 'types/roles';
@@ -48,11 +37,6 @@ export function CmdKPalette({
const { open, setOpen } = useCmdK();
const { setAutoSwitch, setTheme, theme } = useThemeMode();
const location = useLocation();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
// toggle palette with ⌘/Ctrl+K
function handleGlobalCmdK(
@@ -94,21 +78,9 @@ export function CmdKPalette({
history.push(key);
}
const handleOpenAIAssistant = (): void => {
void logEvent(AIAssistantEvents.Opened, {
source: AIAssistantOpenSource.Cmdk,
currentPage: normalizePage(location.pathname),
});
startNewConversation();
openAIAssistantModal();
};
const actions = createShortcutActions({
navigate: onClickHandler,
handleThemeChange,
aiAssistant: isAIAssistantEnabled
? { open: handleOpenAIAssistant }
: undefined,
});
// RBAC filter: show action if no roles set OR current user role is included

View File

@@ -15,7 +15,6 @@ import {
ListMinus,
ScrollText,
Settings,
Sparkles,
TowerControl,
Workflow,
} from '@signozhq/icons';
@@ -35,20 +34,12 @@ export type CmdAction = {
type ActionDeps = {
navigate: (path: string) => void;
handleThemeChange: (mode: string) => void;
/**
* Provided only when the AI Assistant feature is available for the current
* tenant. When present, the palette surfaces an "Open AI Assistant" entry
* at the top; when absent, the action is omitted entirely.
*/
aiAssistant?: {
open: () => void;
};
};
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
const { navigate, handleThemeChange, aiAssistant } = deps;
const { navigate, handleThemeChange } = deps;
const actions: CmdAction[] = [
return [
{
id: 'home',
name: 'Go to Home',
@@ -288,19 +279,4 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
perform: (): void => navigate(ROUTES.MEMBERS_SETTINGS),
},
];
if (aiAssistant) {
actions.unshift({
id: 'ai-assistant',
name: 'Open AI Assistant',
shortcut: ['cmd+j'],
keywords: 'ai assistant chat ask sparkles copilot',
section: 'AI Assistant',
icon: <Sparkles size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: aiAssistant.open,
});
}
return actions;
}

View File

@@ -10,7 +10,7 @@ import logEvent from 'api/common/logEvent';
import HistorySidebar from '../components/ConversationsList';
import ConversationView from '../ConversationView';
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
import { AIAssistantEvents } from '../events';
import {
normalizePage,
useAIAssistantAnalyticsContext,
@@ -65,7 +65,7 @@ export default function AIAssistantModal(): JSX.Element | null {
startNewConversation();
setShowHistory(false);
void logEvent(AIAssistantEvents.Opened, {
source: AIAssistantOpenSource.Shortcut,
source: 'shortcut',
currentPage: normalizePage(pathname),
});
openModal();
@@ -162,57 +162,57 @@ export default function AIAssistantModal(): JSX.Element | null {
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void => setShowHistory((v) => !v)}
aria-label="Toggle conversations"
className={showHistory ? styles.toggleBtnActive : ''}
prefix={<History size={14} />}
/>
>
<History size={14} />
</Button>
</TooltipSimple>
<TooltipSimple title="New conversation">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleNew}
aria-label="New conversation"
prefix={<Plus size={14} />}
/>
>
<Plus size={14} />
</Button>
</TooltipSimple>
<TooltipSimple title="Open full screen">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
prefix={<Maximize2 size={14} />}
/>
>
<Maximize2 size={14} />
</Button>
</TooltipSimple>
<TooltipSimple title="Minimize to side panel">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleMinimize}
aria-label="Minimize to side panel"
prefix={<Minus size={14} />}
/>
>
<Minus size={14} />
</Button>
</TooltipSimple>
<TooltipSimple title="Close">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={closeModal}
aria-label="Close"
prefix={<X size={14} />}
/>
>
<X size={14} />
</Button>
</TooltipSimple>
</div>
</div>

View File

@@ -150,8 +150,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
color="secondary"
onClick={(): void => setShowHistory((v) => !v)}
aria-label="Toggle conversations"
prefix={<History size={14} />}
/>
>
<History size={14} />
</Button>
</TooltipSimple>
<TooltipSimple title="New conversation">
@@ -161,8 +162,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
color="secondary"
onClick={handleNew}
aria-label="New conversation"
prefix={<Plus size={14} />}
/>
>
<Plus size={14} />
</Button>
</TooltipSimple>
<TooltipSimple title="Open full screen">
@@ -173,8 +175,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
prefix={<Maximize2 size={14} />}
/>
>
<Maximize2 size={14} />
</Button>
</TooltipSimple>
<TooltipSimple title="Close">
@@ -184,8 +187,9 @@ export default function AIAssistantPanel(): JSX.Element | null {
color="secondary"
onClick={closeDrawer}
aria-label="Close panel"
prefix={<X size={14} />}
/>
>
<X size={14} />
</Button>
</TooltipSimple>
</div>
</div>

View File

@@ -6,7 +6,7 @@ import logEvent from 'api/common/logEvent';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
import { AIAssistantEvents } from '../events';
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
import {
openAIAssistant,
@@ -31,7 +31,7 @@ export default function AIAssistantTrigger(): JSX.Element | null {
const handleOpen = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: AIAssistantOpenSource.Icon,
source: 'icon',
currentPage: normalizePage(pathname),
});
openAIAssistant();

View File

@@ -159,7 +159,6 @@ export default function ConversationView({
<ConversationSkeleton />
<div className={inputWrapperClass}>
<ChatInput
key={conversationId}
onSend={handleSend}
disabled
autoContexts={autoContexts}
@@ -173,7 +172,6 @@ export default function ConversationView({
return (
<div className={styles.conversation}>
<VirtualizedMessages
key={conversationId}
conversationId={conversationId}
messages={messages}
isStreaming={isStreamingHere}
@@ -186,7 +184,6 @@ export default function ConversationView({
)}
<div className={inputWrapperClass}>
<ChatInput
key={conversationId}
onSend={handleSend}
onCancel={handleCancel}
disabled={inputDisabled}

View File

@@ -11,7 +11,6 @@ import {
DialogTitle,
} from '@signozhq/ui/dialog';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import type {
ApprovalEventDTO,
ApprovalEventDTODiff,
@@ -101,16 +100,16 @@ export default function ApprovalCard({
<div className={styles.diffSection}>
<div className={styles.diffHeader}>
<span className={styles.diffHeaderLabel}>Diff</span>
<TooltipSimple title="Expand diff">
<Button
variant="link"
size="sm"
color="secondary"
onClick={(): void => setDiffExpanded(true)}
aria-label="Expand diff"
prefix={<Maximize2 size={12} />}
/>
</TooltipSimple>
<Button
variant="link"
size="sm"
color="secondary"
onClick={(): void => setDiffExpanded(true)}
title="Expand diff"
aria-label="Expand diff"
>
<Maximize2 size={12} />
</Button>
</div>
<DiffView diff={approval.diff} />
</div>
@@ -120,8 +119,6 @@ export default function ApprovalCard({
<DialogContent
className={styles.diffDialog}
style={{ width: '80vw', maxWidth: '80vw', height: '70vh' }}
// Skip auto-focus — otherwise the first Copy button opens its tooltip on dialog open.
onOpenAutoFocus={(e): void => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Approval diff</DialogTitle>
@@ -137,22 +134,19 @@ export default function ApprovalCard({
size="sm"
value={viewMode}
onChange={(next): void => {
// Radix `single` group can emit '' when the active item is clicked again.
// Radix `single` group can emit '' when the active item
// is clicked again — preserve the current mode.
if (next === 'split' || next === 'unified') {
setViewMode(next);
}
}}
>
<TooltipSimple title="Split view">
<ToggleGroupItem value="split" aria-label="Split view">
<Columns2 size={12} />
</ToggleGroupItem>
</TooltipSimple>
<TooltipSimple title="Unified view">
<ToggleGroupItem value="unified" aria-label="Unified view">
<List size={12} />
</ToggleGroupItem>
</TooltipSimple>
<ToggleGroupItem value="split" aria-label="Split view">
<Columns2 size={12} />
</ToggleGroupItem>
<ToggleGroupItem value="unified" aria-label="Unified view">
<List size={12} />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
type="multiple"
@@ -160,16 +154,12 @@ export default function ApprovalCard({
value={wrapText ? ['wrap'] : []}
onChange={(next): void => setWrapText(next.includes('wrap'))}
>
<TooltipSimple
title={wrapText ? 'Disable text wrap' : 'Wrap long lines'}
<ToggleGroupItem
value="wrap"
aria-label={wrapText ? 'Disable text wrap' : 'Wrap long lines'}
>
<ToggleGroupItem
value="wrap"
aria-label={wrapText ? 'Disable text wrap' : 'Wrap long lines'}
>
<WrapText size={12} />
</ToggleGroupItem>
</TooltipSimple>
<WrapText size={12} />
</ToggleGroupItem>
</ToggleGroup>
</div>
{approval.diff && (
@@ -467,16 +457,15 @@ function CopyButton({ text, label }: CopyButtonProps): JSX.Element {
};
return (
<TooltipSimple title={copied ? `Copied ${label}` : `Copy ${label}`}>
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleCopy}
aria-label={copied ? `Copied ${label}` : `Copy ${label}`}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</TooltipSimple>
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleCopy}
title={copied ? `Copied ${label}` : `Copy ${label}`}
aria-label={copied ? `Copied ${label}` : `Copy ${label}`}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
);
}

View File

@@ -8,7 +8,12 @@
border-radius: var(--radius-2);
padding: 8px;
border: 1px solid var(--l1-border);
transition: border-color 0.15s;
position: relative;
&:focus-within {
border-color: var(--l1-border);
}
}
.attachments {
@@ -124,18 +129,6 @@
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: 4px;
transition:
border-color 0.15s,
box-shadow 0.15s;
// Scope the focus ring to the textarea row only — the surrounding
// chrome (context chips, "Add Context", mic, send) sits outside this
// element and stays unaffected when the cursor enters the textarea.
&:focus-within {
border-color: var(--accent-primary);
box-shadow: 0 0 0 1px
color-mix(in srgb, var(--accent-primary), transparent 70%);
}
}
.footer {
@@ -251,24 +244,16 @@
}
.contextPopoverCategoryItem {
// Override DS Button's centered layout.
--button-justify-content: flex-start;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
height: 32px;
padding: 0 8px;
background: transparent;
color: inherit;
font: inherit;
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
font-size: 12px;
font-weight: 550;
text-align: left;
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
appearance: none;
cursor: pointer;
transition:
background 0.15s ease,
@@ -324,24 +309,17 @@
}
.contextPopoverEntityItem {
// Override DS Button's centered layout.
--button-justify-content: flex-start;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
width: 100%;
padding: 8px;
background: transparent;
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
color: var(--l1-foreground);
font: inherit;
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: left;
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
appearance: none;
cursor: pointer;
// Required for the inner span's `text-overflow: ellipsis` to engage —
// flex items default to `min-width: auto` (intrinsic width) and would
@@ -407,11 +385,6 @@
border-radius: 50%;
border: none;
cursor: pointer;
// Reset native <button> defaults so the 24px circle isn't inflated by
// browser-default padding / font metrics.
padding: 0;
font: inherit;
appearance: none;
}
.micDiscard {

View File

@@ -1,10 +1,4 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import cx from 'classnames';
import { Badge } from '@signozhq/ui/badge';
@@ -32,11 +26,7 @@ import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
AIAssistantEvents,
VoiceInputSource,
getBrowserInfo,
} from '../../events';
import { AIAssistantEvents, getBrowserInfo } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { MessageAttachment } from '../../types';
@@ -152,10 +142,6 @@ function autoContextCategory(ctx: MessageContext): string {
const MAX_INPUT_LENGTH = 20000;
const WARNING_THRESHOLD = 15000;
// Cap for the auto-growing composer. Past this, the textarea stops growing
// and starts scrolling internally so the message list above doesn't get
// squeezed in tighter container variants (e.g. the floating panel).
const TEXTAREA_MAX_HEIGHT_PX = 200;
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';
@@ -238,18 +224,6 @@ export default function ChatInput({
const [activeContextCategory, setActiveContextCategory] =
useState<ContextCategory>('Dashboards');
const [pickerSearchQuery, setPickerSearchQuery] = useState('');
// Refs to each category tab so we can move DOM focus to the newly-active
// tab on ArrowUp/ArrowDown. Without this the roving-tabindex pattern
// stalls: focus stays on the original button (whose closure has the old
// category), so subsequent arrow keys never advance past the second tab.
const categoryTabRefs = useRef(
new Map<ContextCategory, HTMLButtonElement | null>(),
);
// Refs to each entity row in the active tab panel, so we can cross from
// the category tablist (ArrowRight) into the panel and step through
// entities with ArrowUp/Down. Array is rewritten each render — there's
// only ever one tab panel mounted so stale indices clear naturally.
const entityRefs = useRef<(HTMLButtonElement | null)[]>([]);
const queryClient = useQueryClient();
// When the picker was opened by typing `@` in the textarea, this holds the
@@ -329,92 +303,11 @@ export default function ChatInput({
[mentionRange, selectedContexts, text],
);
const focusCategory = useCallback((category: ContextCategory) => {
setActiveContextCategory(category);
setPickerSearchQuery('');
categoryTabRefs.current.get(category)?.focus();
}, []);
const handleCategoryKeyDown = useCallback(
(
e: React.KeyboardEvent<HTMLButtonElement>,
category: ContextCategory,
): void => {
const total = CONTEXT_CATEGORIES.length;
const idx = CONTEXT_CATEGORIES.indexOf(category);
if (e.key === 'ArrowDown') {
e.preventDefault();
focusCategory(CONTEXT_CATEGORIES[(idx + 1) % total]);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
focusCategory(CONTEXT_CATEGORIES[(idx - 1 + total) % total]);
} else if (e.key === 'Home') {
e.preventDefault();
focusCategory(CONTEXT_CATEGORIES[0]);
} else if (e.key === 'End') {
e.preventDefault();
focusCategory(CONTEXT_CATEGORIES[total - 1]);
} else if (e.key === 'ArrowRight') {
// Cross from tablist into entity panel.
e.preventDefault();
entityRefs.current[0]?.focus();
}
},
[focusCategory],
);
const handleEntityKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>, index: number): void => {
const count = entityRefs.current.length;
if (count === 0) {
return;
}
const focusAt = (i: number): void => {
e.preventDefault();
entityRefs.current[i]?.focus();
};
switch (e.key) {
case 'ArrowDown':
focusAt((index + 1) % count);
break;
case 'ArrowUp':
focusAt((index - 1 + count) % count);
break;
case 'Home':
focusAt(0);
break;
case 'End':
focusAt(count - 1);
break;
case 'ArrowLeft':
// Cross back to tablist.
e.preventDefault();
categoryTabRefs.current.get(activeContextCategory)?.focus();
break;
default:
}
},
[activeContextCategory],
);
// Focus the textarea when this component mounts (panel/modal open)
useEffect(() => {
textareaRef.current?.focus();
}, []);
// Auto-grow the textarea so long prompts aren't trapped in a 2-line
// scrolling porthole. Reset to `auto` first to let the field shrink back
// down when the user deletes content, then snap to scrollHeight capped at
// TEXTAREA_MAX_HEIGHT_PX (overflow-y: auto in CSS handles the rest).
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) {
return;
}
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, TEXTAREA_MAX_HEIGHT_PX)}px`;
}, [text]);
const handleSend = useCallback(async () => {
const trimmed = text.trim();
if (!trimmed && pendingFiles.length === 0) {
@@ -489,7 +382,7 @@ export default function ChatInput({
// 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<VoiceInputSource | 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
@@ -566,7 +459,7 @@ export default function ChatInput({
const showMic = isSupported && micPermission !== 'denied' && !voiceUnavailable;
const startVoiceInput = useCallback(
(source: VoiceInputSource) => {
(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
@@ -643,7 +536,7 @@ export default function ChatInput({
return; // ignore auto-repeat
}
pttActiveRef.current = true;
startVoiceInput(VoiceInputSource.Shortcut);
startVoiceInput('shortcut');
};
const handleKeyUp = (e: KeyboardEvent): void => {
@@ -831,12 +724,6 @@ export default function ChatInput({
entity.value.toLowerCase().includes(activeQuery),
)
: contextEntitiesByCategory[activeContextCategory];
// Truncate the ref array to match the current entity count so that
// switching from a large category (e.g. 100 dashboards) to a smaller one
// doesn't leave stale `null` slots from earlier renders. Keyboard nav math
// already uses `filteredContextOptions.length` for the modulo, so stale
// slots wouldn't be reached — this is purely housekeeping.
entityRefs.current.length = filteredContextOptions.length;
const { isLoading: isActiveContextLoading, isError: isActiveContextError } =
contextCategoryStateByCategory[activeContextCategory];
const currentLength = text.length;
@@ -943,7 +830,7 @@ export default function ChatInput({
onKeyDown={handleKeyDown}
disabled={disabled}
maxLength={MAX_INPUT_LENGTH}
rows={3}
rows={2}
/>
</div>
{showTextWarning && (
@@ -990,37 +877,15 @@ export default function ChatInput({
sideOffset={8}
>
<div className={styles.contextPopoverContent}>
<div
className={styles.contextPopoverCategories}
role="tablist"
aria-orientation="vertical"
aria-label="Context categories"
>
<div className={styles.contextPopoverCategories}>
{CONTEXT_CATEGORIES.map((category) => {
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
const isActive = activeContextCategory === category;
return (
<Button
<div
key={category}
ref={(el): void => {
categoryTabRefs.current.set(category, el);
}}
type="button"
variant="ghost"
color="secondary"
size="sm"
role="tab"
id={`ai-context-tab-${category}`}
// Single stable panel id shared by every tab: only the
// active category's tabpanel is rendered, so per-category
// `aria-controls` ids would point at nonexistent nodes
// for the two inactive tabs. APG allows a single
// dynamic panel whose `aria-labelledby` swaps to the
// active tab.
aria-controls="ai-context-tabpanel"
// Roving tabindex: only the active tab participates in
// the Tab sequence; arrow keys move between tabs.
tabIndex={isActive ? 0 : -1}
tabIndex={0}
aria-selected={isActive}
className={cx(styles.contextPopoverCategoryItem, {
[styles.active]: isActive,
@@ -1029,21 +894,22 @@ export default function ChatInput({
setActiveContextCategory(category);
setPickerSearchQuery('');
}}
onKeyDown={(e): void => handleCategoryKeyDown(e, category)}
prefix={<CategoryIcon size={13} />}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setActiveContextCategory(category);
setPickerSearchQuery('');
}
}}
>
<CategoryIcon size={13} />
<span>{category}</span>
</Button>
</div>
);
})}
</div>
<div
className={styles.contextPopoverRight}
role="tabpanel"
id="ai-context-tabpanel"
aria-labelledby={`ai-context-tab-${activeContextCategory}`}
>
<div className={styles.contextPopoverRight}>
<div className={styles.contextPopoverSearch}>
<Input
type="text"
@@ -1073,7 +939,7 @@ export default function ChatInput({
No matching entities
</div>
) : (
filteredContextOptions.map((option, index) => {
filteredContextOptions.map((option) => {
const isSelected = selectedContexts.some(
(item) =>
item.category === activeContextCategory &&
@@ -1081,16 +947,8 @@ export default function ChatInput({
);
return (
<Button
<div
key={option.id}
ref={(el): void => {
entityRefs.current[index] = el;
}}
type="button"
variant="ghost"
color="secondary"
size="sm"
aria-pressed={isSelected}
className={cx(styles.contextPopoverEntityItem, {
[styles.selected]: isSelected,
})}
@@ -1101,12 +959,11 @@ export default function ChatInput({
option.value,
)
}
onKeyDown={(e): void => handleEntityKeyDown(e, index)}
>
<span className={styles.contextPopoverEntityItemText}>
{option.value}
</span>
</Button>
</div>
);
})
)}
@@ -1120,24 +977,14 @@ export default function ChatInput({
<div className={styles.rightActions}>
{showMic &&
(isListening ? (
<div
className={styles.micRecording}
role="status"
aria-live="polite"
aria-label="Recording voice input"
>
<TooltipSimple title="Discard recording">
<Button
type="button"
variant="ghost"
size="icon"
color="secondary"
className={cx(styles.micDiscard, styles.secondary)}
onClick={handleDiscard}
aria-label="Discard recording"
prefix={<X size={12} />}
/>
</TooltipSimple>
<div className={styles.micRecording}>
<div
className={cx(styles.micDiscard, styles.secondary)}
onClick={handleDiscard}
aria-label="Discard recording"
>
<X size={12} />
</div>
<span className={styles.micWaves} aria-hidden="true">
<span />
<span />
@@ -1148,30 +995,26 @@ export default function ChatInput({
<span />
<span />
</span>
<TooltipSimple title="Stop and send">
<Button
type="button"
variant="ghost"
size="icon"
color="destructive"
className={cx(styles.micStop, styles.destructive)}
onClick={handleStopAndSend}
aria-label="Stop and send"
prefix={<Square size={9} fill="currentColor" strokeWidth={0} />}
/>
</TooltipSimple>
<div
className={cx(styles.micStop, styles.destructive)}
onClick={handleStopAndSend}
aria-label="Stop and send"
>
<Square size={9} fill="currentColor" strokeWidth={0} />
</div>
</div>
) : (
<TooltipSimple title="Voice input">
<Button
variant="ghost"
size="icon"
onClick={(): void => startVoiceInput(VoiceInputSource.Button)}
onClick={(): void => startVoiceInput('button')}
disabled={disabled}
aria-label="Start voice input"
className={styles.micBtn}
prefix={<Mic size={14} />}
/>
>
<Mic size={14} />
</Button>
</TooltipSimple>
))}
@@ -1183,21 +1026,21 @@ export default function ChatInput({
color="destructive"
onClick={onCancel}
aria-label="Stop generating"
prefix={<Square size={10} fill="currentColor" strokeWidth={0} />}
/>
>
<Square size={10} fill="currentColor" strokeWidth={0} />
</Button>
</TooltipSimple>
) : (
<TooltipSimple title="Send message">
<Button
variant="solid"
size="icon"
color="primary"
onClick={isListening ? handleStopAndSend : handleSend}
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
aria-label="Send message"
prefix={<Send size={14} />}
/>
</TooltipSimple>
<Button
variant="solid"
size="icon"
color="primary"
onClick={isListening ? handleStopAndSend : handleSend}
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
aria-label="Send message"
>
<Send size={14} />
</Button>
)}
</div>
</div>

View File

@@ -64,19 +64,6 @@
gap: 4px;
}
// Mirrors `.field` for the multi_select group, but resets the browser's
// default `<fieldset>` border/padding/margin so the visual matches the
// `<div>`-based field rows.
.multiSelectFieldset {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
.label {
font-size: 12px;
font-weight: 500;

View File

@@ -63,14 +63,7 @@ export default function ClarificationForm({
setAnswers((prev) => ({ ...prev, [id]: value }));
};
const isFormValid = fields.every(
(f) => !f.required || isFieldFilled(f, answers[f.id]),
);
const handleSubmit = async (): Promise<void> => {
if (!isFormValid) {
return;
}
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
@@ -143,7 +136,7 @@ export default function ClarificationForm({
variant="solid"
color="primary"
onClick={handleSubmit}
disabled={isStreaming || !isFormValid}
disabled={isStreaming}
prefix={<Send />}
>
Submit
@@ -169,9 +162,8 @@ export default function ClarificationForm({
/**
* Per-type seed value. The DTO's `default` is `string | string[] | null`,
* which doesn't fit boolean / number fields cleanly — we coerce 'true'/'false'
* strings for booleans, parse number defaults out of the string form,
* fall back to `[]` for multi_select, and the raw string otherwise.
* which doesn't fit boolean fields cleanly — we coerce 'true'/'false' strings
* for them, fall back to `[]` for multi_select, and the raw string otherwise.
*/
function initialAnswerFor(f: ClarificationFieldEventDTO): unknown {
const raw = f.default;
@@ -183,41 +175,9 @@ function initialAnswerFor(f: ClarificationFieldEventDTO): unknown {
if (f.type === ClarificationFieldTypeDTO.multi_select) {
return Array.isArray(raw) ? raw : [];
}
if (f.type === ClarificationFieldTypeDTO.number) {
// Server sends number defaults as strings (e.g. `"5"`). Parse so the
// stored value is a real `number` — otherwise `isFieldFilled` (which
// requires `typeof === 'number'`) rejects a visibly-filled field and
// Submit stays disabled.
if (typeof raw !== 'string' || raw === '') {
return null;
}
const parsed = Number(raw);
return Number.isNaN(parsed) ? null : parsed;
}
return raw ?? '';
}
// Whether a required field has been answered. Booleans are always considered
// filled (they're initialised to a concrete true/false). For other types we
// reject empty strings, empty arrays, NaN numbers, and `null` (which the
// number input emits when its raw value is `''` — `Number('')` would
// otherwise silently coerce to `0` and read as a valid answer).
function isFieldFilled(
field: ClarificationFieldEventDTO,
value: unknown,
): boolean {
switch (field.type) {
case ClarificationFieldTypeDTO.multi_select:
return Array.isArray(value) && value.length > 0;
case ClarificationFieldTypeDTO.boolean:
return true;
case ClarificationFieldTypeDTO.number:
return typeof value === 'number' && !Number.isNaN(value);
default:
return typeof value === 'string' && value.trim().length > 0;
}
}
interface FieldInputProps {
field: ClarificationFieldEventDTO;
value: unknown;
@@ -256,21 +216,13 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
<div className={styles.field}>
<label className={styles.label} htmlFor={id}>
{label}
{required && (
<span className={styles.required} aria-hidden="true">
*
</span>
)}
{required && <span className={styles.required}>*</span>}
</label>
<Select
value={isCustom ? CUSTOM_OPTION_SENTINEL : String(value ?? '')}
onChange={handleSelectChange}
>
<SelectTrigger
id={id}
placeholder="Select…"
aria-required={required || undefined}
/>
<SelectTrigger id={id} placeholder="Select…" />
{/* Pin the dropdown width to the trigger via Radix's
`--radix-select-trigger-width`; otherwise the popover
sizes to its widest item and looks misaligned. */}
@@ -315,11 +267,7 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
onChange={(): void => onChange(!checked)}
>
{label}
{required && (
<span className={styles.required} aria-hidden="true">
*
</span>
)}
{required && <span className={styles.required}>*</span>}
</Checkbox>
</div>
);
@@ -364,21 +312,11 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
};
return (
// `fieldset` + `legend` is the WCAG-recommended grouping for
// related checkboxes (1.3.1). SRs announce the legend before each
// option, so users hear the group label as context.
<fieldset
className={styles.multiSelectFieldset}
aria-required={required || undefined}
>
<legend className={styles.label}>
<div className={styles.field}>
<span className={styles.label}>
{label}
{required && (
<span className={styles.required} aria-hidden="true">
*
</span>
)}
</legend>
{required && <span className={styles.required}>*</span>}
</span>
<div className={styles.checkboxGroup}>
{options?.map((opt) => (
<Checkbox
@@ -409,7 +347,7 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
onChange={(e): void => updateCustomValue(e.target.value)}
/>
)}
</fieldset>
</div>
);
}
@@ -418,29 +356,16 @@ function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
<div className={styles.field}>
<label className={styles.label} htmlFor={id}>
{label}
{required && (
<span className={styles.required} aria-hidden="true">
*
</span>
)}
{required && <span className={styles.required}>*</span>}
</label>
<Input
id={id}
type={type === 'number' ? 'number' : 'text'}
className={styles.input}
value={String(value ?? '')}
aria-required={required || undefined}
onChange={(e): void => {
if (type === 'number') {
const raw = e.target.value;
// Map empty input to `null` instead of `Number('') === 0`
// so a required numeric field cleared after typing doesn't
// silently read as a valid `0` in `isFieldFilled`.
onChange(raw === '' ? null : Number(raw));
} else {
onChange(e.target.value);
}
}}
onChange={(e): void =>
onChange(type === 'number' ? Number(e.target.value) : e.target.value)
}
placeholder={label}
/>
</div>

View File

@@ -10,7 +10,6 @@ import { useTimezone } from 'providers/Timezone';
import logEvent from 'api/common/logEvent';
import { FeedbackRatingDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { AIAssistantEvents } from '../../events';
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
@@ -18,22 +17,6 @@ import { FeedbackRating, Message } from '../../types';
import styles from './MessageFeedback.module.scss';
const FEEDBACK_ANALYTICS_RATING = {
[FeedbackRatingDTO.positive]: 'up',
[FeedbackRatingDTO.negative]: 'down',
} as const;
const VOTE_LABEL = {
[FeedbackRatingDTO.positive]: {
tooltip: 'Good response',
ariaLabel: 'Good response',
},
[FeedbackRatingDTO.negative]: {
tooltip: 'Bad response',
ariaLabel: 'Bad response',
},
} as const;
interface MessageFeedbackProps {
message: Message;
onRegenerate?: () => void;
@@ -134,7 +117,7 @@ export default function MessageFeedback({
if (vote === rating) {
return;
}
if (rating === FeedbackRatingDTO.negative) {
if (rating === 'negative') {
setNegativeComment('');
setIsNegativeDialogOpen(true);
return;
@@ -143,7 +126,7 @@ export default function MessageFeedback({
void logEvent(AIAssistantEvents.FeedbackSubmitted, {
messageId: message.id,
threadId,
rating: FEEDBACK_ANALYTICS_RATING[rating],
rating: 'up',
hasComment: false,
commentLength: 0,
});
@@ -153,21 +136,17 @@ export default function MessageFeedback({
);
const handleSubmitNegative = useCallback((): void => {
setVote(FeedbackRatingDTO.negative);
setVote('negative');
setIsNegativeDialogOpen(false);
const trimmed = negativeComment.trim();
void logEvent(AIAssistantEvents.FeedbackSubmitted, {
messageId: message.id,
threadId,
rating: FEEDBACK_ANALYTICS_RATING[FeedbackRatingDTO.negative],
rating: 'down',
hasComment: trimmed.length > 0,
commentLength: trimmed.length,
});
submitMessageFeedback(
message.id,
FeedbackRatingDTO.negative,
trimmed || undefined,
);
submitMessageFeedback(message.id, 'negative', trimmed || undefined);
}, [message.id, negativeComment, submitMessageFeedback, threadId]);
return (
@@ -181,39 +160,32 @@ export default function MessageFeedback({
variant="ghost"
onClick={handleCopy}
color="secondary"
aria-label={copied ? 'Copied' : 'Copy message'}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</TooltipSimple>
<TooltipSimple title={VOTE_LABEL[FeedbackRatingDTO.positive].tooltip}>
<TooltipSimple title="Good response">
<Button
className={cx(styles.btn, {
[styles.votedUp]: vote === FeedbackRatingDTO.positive,
})}
className={cx(styles.btn, { [styles.votedUp]: vote === 'positive' })}
size="icon"
variant="ghost"
color="secondary"
onClick={(): void => handleVote(FeedbackRatingDTO.positive)}
aria-label={VOTE_LABEL[FeedbackRatingDTO.positive].ariaLabel}
aria-pressed={vote === FeedbackRatingDTO.positive}
onClick={(): void => handleVote('positive')}
>
<ThumbsUp size={12} />
</Button>
</TooltipSimple>
<TooltipSimple title={VOTE_LABEL[FeedbackRatingDTO.negative].tooltip}>
<TooltipSimple title="Bad response">
<Button
className={cx(styles.btn, {
[styles.votedDown]: vote === FeedbackRatingDTO.negative,
[styles.votedDown]: vote === 'negative',
})}
size="icon"
variant="ghost"
color="secondary"
onClick={(): void => handleVote(FeedbackRatingDTO.negative)}
aria-label={VOTE_LABEL[FeedbackRatingDTO.negative].ariaLabel}
aria-pressed={vote === FeedbackRatingDTO.negative}
onClick={(): void => handleVote('negative')}
>
<ThumbsDown size={12} />
</Button>
@@ -227,7 +199,6 @@ export default function MessageFeedback({
variant="ghost"
color="secondary"
onClick={onRegenerate}
aria-label="Regenerate response"
>
<RefreshCw size={12} />
</Button>

View File

@@ -47,7 +47,6 @@ export default function UserMessageActions({
variant="ghost"
color="secondary"
onClick={handleCopy}
aria-label={copied ? 'Copied' : 'Copy message'}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>

View File

@@ -90,16 +90,6 @@ export default function VirtualizedMessages({
const virtuosoRef = useRef<VirtuosoHandle>(null);
const scrollerRef = useRef<HTMLElement | Window | null>(null);
// Tracks whether the scroller is pinned to (or near) the bottom. Updated
// via Virtuoso's `atBottomStateChange` so we can stop force-scrolling the
// user back down when they've intentionally scrolled up to read earlier
// content.
const atBottomRef = useRef(true);
// Id of the latest user message we've already anchored to. Used to detect
// a fresh user send so we can re-anchor to the bottom regardless of where
// the user was scrolled — sending a message and not seeing it is worse
// than the anti-yank guarantee.
const lastSeenUserMessageIdRef = useRef<string | null>(null);
const handleRegenerate = useCallback(
(messageId: string): void => {
@@ -121,25 +111,8 @@ export default function VirtualizedMessages({
// align: 'end')` would only reach the last item's bottom and leave the
// padding hidden below the fold. Use `auto` while streaming so the bottom
// stays glued as text deltas arrive; `smooth` lags when triggered every
// few ms. Bail out if the user has scrolled away from the bottom — that's
// an explicit signal they want to read earlier content without being
// yanked back.
// few ms.
useEffect(() => {
const lastMessage = messages[messages.length - 1];
const isFreshUserSend =
lastMessage?.role === 'user' &&
lastMessage.id !== lastSeenUserMessageIdRef.current;
if (isFreshUserSend) {
lastSeenUserMessageIdRef.current = lastMessage.id;
// Re-anchor so the user sees their own send (and the assistant's
// follow-up streaming) even if they were reading history when they
// hit Enter.
atBottomRef.current = true;
}
if (!atBottomRef.current) {
return;
}
const scroller = scrollerRef.current;
if (!(scroller instanceof HTMLElement)) {
return;
@@ -149,7 +122,7 @@ export default function VirtualizedMessages({
behavior: isStreaming ? 'auto' : 'smooth',
});
}, [
messages,
messages.length,
streamingEvents.length,
streamingContentLength,
isStreaming,
@@ -159,18 +132,14 @@ export default function VirtualizedMessages({
const followOutput = useCallback(
(atBottom: boolean): false | 'auto' | 'smooth' => {
if (!atBottom) {
return false;
if (isStreaming) {
return 'auto';
}
return isStreaming ? 'auto' : 'smooth';
return atBottom ? 'smooth' : false;
},
[isStreaming],
);
const handleAtBottomStateChange = useCallback((atBottom: boolean): void => {
atBottomRef.current = atBottom;
}, []);
const showStreamingSlot =
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
@@ -219,8 +188,6 @@ export default function VirtualizedMessages({
className={styles.messages}
totalCount={totalCount}
followOutput={followOutput}
atBottomStateChange={handleAtBottomStateChange}
atBottomThreshold={64}
initialTopMostItemIndex={Math.max(0, totalCount - 1)}
itemContent={(index): JSX.Element => {
if (index < messages.length) {

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Check, Copy } from '@signozhq/icons';
import SyntaxHighlighter, {
a11yDark,
@@ -127,17 +126,16 @@ function CopyButton({ text }: { text: string }): JSX.Element {
};
return (
<TooltipSimple title={copied ? 'Copied' : 'Copy code'}>
<Button
variant="ghost"
size="sm"
color="secondary"
className={styles.copyBtn}
onClick={handleCopy}
aria-label={copied ? 'Copied' : 'Copy code'}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</TooltipSimple>
<Button
variant="ghost"
size="sm"
color="secondary"
className={styles.copyBtn}
onClick={handleCopy}
title={copied ? 'Copied' : 'Copy code'}
aria-label={copied ? 'Copied' : 'Copy code'}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
);
}

View File

@@ -63,26 +63,6 @@ export const SuggestedPromptCategory = {
export type SuggestedPromptCategory =
(typeof SuggestedPromptCategory)[keyof typeof SuggestedPromptCategory];
// `source` attribute on the AI Assistant `Opened` event — describes which
// surface triggered the open. Keep values stable: dashboards downstream
// depend on the literal strings.
export const AIAssistantOpenSource = {
Icon: 'icon',
Shortcut: 'shortcut',
Cmdk: 'cmdk',
} as const;
export type AIAssistantOpenSource =
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];
// `source` attribute on the `VoiceInputUsed` event — which surface initiated
// the recording.
export const VoiceInputSource = {
Button: 'button',
Shortcut: 'shortcut',
} as const;
export type VoiceInputSource =
(typeof VoiceInputSource)[keyof typeof VoiceInputSource];
export enum AIAssistantEvents {
Opened = 'AI Assistant: Opened',
MessageSent = 'AI Assistant: Message sent',

View File

@@ -1,32 +1,9 @@
import ROUTES from 'constants/routes';
const PARAM_SEGMENT = /:[^/]+/g;
const REGEX_SPECIALS = /[.+*?^$()[\]{}|\\]/g;
function templateToRegex(template: string): RegExp {
const pattern = template
.replace(REGEX_SPECIALS, '\\$&')
.replace(PARAM_SEGMENT, '[^/]+');
return new RegExp(`^${pattern}$`);
}
export function getRouteKey(pathname: string): string {
const entries = Object.entries(ROUTES);
const [routeKey] = Object.entries(ROUTES).find(
([, value]) => value === pathname,
) || ['DEFAULT'];
const exact = entries.find(([, value]) => value === pathname);
if (exact) {
return exact[0];
}
// First template that matches wins, so declaration order in `ROUTES`
// matters when templates can overlap. Today's set is unambiguous because
// `[^/]+` is segment-bounded, but if you ever add a sibling like
// `/services/list` next to `SERVICE_METRICS: '/services/:servicename'`,
// list the more-specific (more-static-segments) entry first in `ROUTES`
// — otherwise the param template will swallow the static path.
const dynamic = entries.find(
([, value]) => value.includes(':') && templateToRegex(value).test(pathname),
);
return dynamic?.[0] ?? 'DEFAULT';
return routeKey;
}

View File

@@ -24,7 +24,6 @@ import {
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -196,8 +195,6 @@ function ChartPreview({
},
);
useSaveRecentQuery(query);
useEffect(() => {
onFetchingStateChange?.(queryResponse.isFetching);
}, [queryResponse.isFetching, onFetchingStateChange]);

View File

@@ -11,7 +11,6 @@ import { populateMultipleResults } from 'container/NewWidget/LeftContainer/Widge
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsPanelWaitingOnVariable } from 'hooks/dashboard/useVariableFetchState';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
@@ -284,8 +283,6 @@ function GridCardGraph({
},
);
useSaveRecentQuery(widget?.query);
const isEmptyLayout = widget?.id === PANEL_TYPES.EMPTY_WIDGET;
if (queryResponse.data && widget.panelTypes === PANEL_TYPES.BAR) {

View File

@@ -37,7 +37,6 @@ import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQueryData from 'hooks/useUrlQueryData';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
@@ -189,8 +188,6 @@ function LogsExplorerViewsContainer({
'custom',
);
useSaveRecentQuery(stagedQuery);
const getRequestData = useCallback(
(
query: Query | null,

View File

@@ -16,7 +16,6 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface
import DateTimeSelector from 'container/TopNav/DateTimeSelectionV2';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import {
ICurrentQueryData,
useHandleExplorerTabChange,
@@ -63,9 +62,6 @@ function Explorer(): JSX.Element {
handleSetQueryData,
redirectWithQueryBuilderData,
} = useQueryBuilder();
useSaveRecentQuery(stagedQuery);
const { safeNavigate } = useSafeNavigate();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const isAIAssistantEnabled = useIsAIAssistantEnabled();

View File

@@ -25,7 +25,6 @@ import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import NoLogs from 'container/NoLogs/NoLogs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { AppState } from 'store/reducers';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -70,8 +69,6 @@ function Summary(): JSX.Element {
const { currentQuery, stagedQuery, redirectWithQueryBuilderData } =
useQueryBuilder();
useSaveRecentQuery(stagedQuery);
useShareBuilderUrl({ defaultValue: initialQueriesMap[DataSource.METRICS] });
const query = useMemo(

View File

@@ -7,7 +7,6 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -65,8 +64,6 @@ function LeftContainer({
keepPreviousData: true,
});
useSaveRecentQuery(stagedQuery);
useEffect(() => {
if (queryResponse.isFetching) {
setIsCancelled(false);

View File

@@ -16,7 +16,6 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { QueryTable } from 'container/QueryTable';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
@@ -72,8 +71,6 @@ function TableView({
},
);
useSaveRecentQuery(stagedQuery);
useEffect(() => {
if (isLoading || isFetching) {
setIsLoadingQueries(true);

View File

@@ -1,88 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import * as store from 'lib/recentQueries/store';
import type { RecentQueryInput } from 'lib/recentQueries/store';
import { useRecentQueries } from './useRecentQueries';
const baseInput = (
overrides: Partial<RecentQueryInput> = {},
): RecentQueryInput => ({
signal: 'logs',
filter: { expression: 'a = 1' },
...overrides,
});
describe('useRecentQueries', () => {
beforeEach(() => {
store.__resetForTests();
});
it('returns the entries for the given signal', () => {
act(() => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
store.save(baseInput({ filter: { expression: 'b = 2' } }));
});
const { result } = renderHook(() => useRecentQueries('logs'));
expect(result.current.entries).toHaveLength(2);
expect(result.current.entries.map((e) => e.filter.expression)).toStrictEqual([
'b = 2',
'a = 1',
]);
});
it('does not return entries from other signals', () => {
act(() => {
store.save(baseInput({ signal: 'logs' }));
store.save(baseInput({ signal: 'traces' }));
});
const { result } = renderHook(() => useRecentQueries('logs'));
expect(result.current.entries).toHaveLength(1);
expect(result.current.entries[0].signal).toBe('logs');
});
it('re-renders when a new entry is saved', () => {
const { result } = renderHook(() => useRecentQueries('logs'));
expect(result.current.entries).toHaveLength(0);
act(() => {
store.save(baseInput());
});
expect(result.current.entries).toHaveLength(1);
});
it('re-renders when an entry is removed via the returned remove fn', () => {
const { result } = renderHook(() => useRecentQueries('logs'));
act(() => {
store.save(baseInput());
});
expect(result.current.entries).toHaveLength(1);
const id = result.current.entries[0].id;
act(() => {
result.current.remove(id);
});
expect(result.current.entries).toHaveLength(0);
});
it('keeps a stable entries reference when nothing changed', () => {
act(() => {
store.save(baseInput());
});
const { result, rerender } = renderHook(() => useRecentQueries('logs'));
const first = result.current.entries;
rerender();
const second = result.current.entries;
expect(second).toBe(first);
});
});

View File

@@ -1,39 +0,0 @@
import { useCallback, useSyncExternalStore } from 'react';
import type { SignalType } from 'types/api/v5/queryRange';
import * as store from 'lib/recentQueries/store';
import type { RecentQueryEntry } from 'lib/recentQueries/types';
export interface UseRecentQueriesReturn {
entries: RecentQueryEntry[];
remove: (id: string) => void;
}
/**
* Returns the recent-query entries for a given signal, sorted by
* most-recently-used first (the underlying store already maintains that
* order). Subscribes to the store so saves and removes — including those
* driven by another tab via the `storage` event — re-render the consumer.
*/
export function useRecentQueries(
signal: SignalType,
): UseRecentQueriesReturn {
const getSnapshot = useCallback((): RecentQueryEntry[] => store.list(signal), [
signal,
]);
const entries = useSyncExternalStore(
store.subscribe,
getSnapshot,
getSnapshot,
);
const remove = useCallback(
(id: string): void => {
store.remove(id, signal);
},
[signal],
);
return { entries, remove };
}

View File

@@ -1,144 +0,0 @@
import { renderHook } from '@testing-library/react';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { validateQuery } from 'utils/queryValidationUtils';
import * as store from 'lib/recentQueries/store';
import { useSaveRecentQuery } from './useSaveRecentQuery';
jest.mock('utils/queryValidationUtils', () => ({
validateQuery: jest.fn(),
}));
const mockedValidateQuery = validateQuery as jest.MockedFunction<
typeof validateQuery
>;
const buildQuery = (overrides: Partial<IBuilderQuery>[] = [{}]): Query => ({
queryType: EQueryType.QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
id: 'q1',
builder: {
queryFormulas: [],
queryTraceOperator: [],
queryData: overrides.map((o, i) => ({
queryName: `Q${i}`,
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
aggregateAttribute: undefined as never,
functions: [],
filter: { expression: 'service.name = "frontend"' },
groupBy: [],
expression: `Q${i}`,
disabled: false,
having: [],
limit: null,
stepInterval: null,
orderBy: [],
legend: '',
...o,
})) as IBuilderQuery[],
},
});
describe('useSaveRecentQuery', () => {
beforeEach(() => {
store.__resetForTests();
mockedValidateQuery.mockReturnValue({
isValid: true,
message: '',
errors: [],
});
});
it('saves the staged query when validation passes', () => {
const stagedQuery = buildQuery();
renderHook(() => useSaveRecentQuery(stagedQuery));
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe('service.name = "frontend"');
});
it('does not save when validateQuery rejects the expression', () => {
mockedValidateQuery.mockReturnValue({
isValid: false,
message: 'bad',
errors: [],
});
const stagedQuery = buildQuery();
renderHook(() => useSaveRecentQuery(stagedQuery));
expect(store.list('logs')).toHaveLength(0);
});
it('does not save a builder query with an empty filter expression', () => {
const stagedQuery = buildQuery([{ filter: { expression: '' } }]);
renderHook(() => useSaveRecentQuery(stagedQuery));
expect(store.list('logs')).toHaveLength(0);
});
it('saves each builder query in the composite separately', () => {
const stagedQuery = buildQuery([
{
dataSource: DataSource.LOGS,
filter: { expression: 'service.name = "a"' },
},
{
dataSource: DataSource.TRACES,
filter: { expression: 'service.name = "b"' },
},
]);
renderHook(() => useSaveRecentQuery(stagedQuery));
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
});
it('does not re-save when the staged query has not changed', () => {
const stagedQuery = buildQuery();
const { rerender } = renderHook(
({ q }: { q: Query }) => useSaveRecentQuery(q),
{ initialProps: { q: stagedQuery } },
);
const firstTimestamp = store.list('logs')[0].lastUsedAt;
rerender({ q: stagedQuery });
const second = store.list('logs');
expect(second).toHaveLength(1);
expect(second[0].lastUsedAt).toBe(firstTimestamp);
});
it('re-saves when the staged query filter changes', () => {
const initial = buildQuery([{ filter: { expression: 'a = 1' } }]);
const changed = buildQuery([{ filter: { expression: 'b = 2' } }]);
const { rerender } = renderHook(
({ q }: { q: Query }) => useSaveRecentQuery(q),
{ initialProps: { q: initial } },
);
expect(store.list('logs')).toHaveLength(1);
rerender({ q: changed });
expect(store.list('logs')).toHaveLength(2);
});
it('is a no-op when stagedQuery is null', () => {
renderHook(() => useSaveRecentQuery(null));
expect(store.list('logs')).toHaveLength(0);
});
});

View File

@@ -1,94 +0,0 @@
import { useEffect, useRef } from 'react';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import type { SignalType } from 'types/api/v5/queryRange';
import { validateQuery } from 'utils/queryValidationUtils';
import * as store from 'lib/recentQueries/store';
// Legacy `IBuilderQuery.dataSource` uses the `DataSource` enum, whose string
// values match `SignalType`. Narrow with a runtime check rather than blindly
// casting — guards against any future drift.
function toSignal(dataSource: IBuilderQuery['dataSource']): SignalType | null {
if (
dataSource === 'logs' ||
dataSource === 'traces' ||
dataSource === 'metrics'
) {
return dataSource;
}
return null;
}
// Build a stable signature of the staged filter expressions so the effect
// knows when a re-save is genuinely warranted vs. when we're seeing the same
// state across renders.
function buildSignature(stagedQuery: Query | null | undefined): string | null {
if (!stagedQuery) {
return null;
}
const queryData = stagedQuery.builder?.queryData;
if (!Array.isArray(queryData) || queryData.length === 0) {
return null;
}
return JSON.stringify(
queryData.map((q) => ({
ds: q.dataSource,
f: q.filter?.expression ?? '',
})),
);
}
/**
* Save each builder query's filter expression in
* `stagedQuery.builder.queryData[]` to the recent-queries store.
*
* Gate: frontend grammar — `validateQuery(expression)` must accept it.
*
* We intentionally do NOT gate on a backend `isSuccess` flag. An
* investigation often runs queries that return zero results (e.g. searching
* for a rare error condition that doesn't currently exist in the data),
* and the user still wants to replay that query later. The frontend
* grammar check rejects expressions that don't parse; everything else is
* the user's intent worth preserving.
*
* The hook saves only the filter expression. Selecting a recent restores
* only the filter; the rest of the user's builder query (groupBy/orderBy/
* having/limit) is left untouched.
*/
export function useSaveRecentQuery(
stagedQuery: Query | null | undefined,
): void {
const lastSavedSignatureRef = useRef<string | null>(null);
useEffect(() => {
const signature = buildSignature(stagedQuery);
if (!signature || signature === lastSavedSignatureRef.current) {
return;
}
const queryData = stagedQuery?.builder?.queryData ?? [];
queryData.forEach((q) => {
const expression = q.filter?.expression?.trim();
if (!expression) {
return;
}
const validation = validateQuery(expression);
if (!validation.isValid) {
return;
}
const signal = toSignal(q.dataSource);
if (!signal) {
return;
}
store.save({
signal,
filter: q.filter ?? { expression: '' },
});
});
lastSavedSignatureRef.current = signature;
}, [stagedQuery]);
}

View File

@@ -1,65 +0,0 @@
import { normalizeFilterExpression } from './normalize';
describe('normalizeFilterExpression', () => {
it('returns empty string for empty input', () => {
expect(normalizeFilterExpression('')).toBe('');
});
it('returns empty string for whitespace-only input', () => {
expect(normalizeFilterExpression(' \t ')).toBe('');
});
it('strips whitespace around operators', () => {
expect(normalizeFilterExpression(' a = 1 ')).toBe('a=1');
expect(normalizeFilterExpression('a = 1')).toBe('a=1');
expect(normalizeFilterExpression('a=1')).toBe('a=1');
});
it('lowercases AND / OR / NOT outside quotes', () => {
expect(normalizeFilterExpression('A AND B OR NOT C')).toBe('AandBornotC');
});
it('lowercases IN / LIKE / ILIKE / CONTAINS', () => {
expect(normalizeFilterExpression('host IN [1, 2] AND name LIKE "foo"')).toBe(
'hostin[1,2]andnamelike"foo"',
);
});
it('preserves whitespace and casing inside single-quoted strings', () => {
expect(normalizeFilterExpression("a = 'X Y'")).toBe("a='X Y'");
});
it('preserves whitespace and casing inside double-quoted strings', () => {
expect(normalizeFilterExpression('a = "X Y"')).toBe('a="X Y"');
});
it('does not lowercase keyword-looking substrings inside quotes', () => {
expect(normalizeFilterExpression("msg = 'AND ERROR'")).toBe("msg='AND ERROR'");
});
it('handles escaped quotes inside strings', () => {
expect(normalizeFilterExpression("msg = 'a\\'b' AND x = 1")).toBe(
"msg='a\\'b'andx=1",
);
});
it('treats two formattings of the same expression as identical', () => {
const a = normalizeFilterExpression(
'service.name = "frontend" AND severity = error',
);
const b = normalizeFilterExpression(
'service.name="frontend" and severity=error',
);
expect(a).toBe(b);
});
it('preserves unquoted value casing (treats them as identifiers)', () => {
expect(normalizeFilterExpression('status = OK')).toBe('status=OK');
});
it('handles mixed quotes in one expression', () => {
expect(normalizeFilterExpression(`a = 'X' AND b = "Y"`)).toBe(
`a='X'andb="Y"`,
);
});
});

View File

@@ -1,53 +0,0 @@
// Lowercase only the standalone keywords/operators that have case-insensitive
// semantics in the query grammar. Identifiers and unquoted values are left
// alone — case may be meaningful there.
const KEYWORDS_RE = /\b(AND|OR|NOT|IN|LIKE|ILIKE|CONTAINS|EXISTS|BETWEEN|IS|NULL|TRUE|FALSE)\b/gi;
// Match a quoted string literal (single- or double-quoted) with simple
// backslash escapes. Used to skip over value regions so we don't touch their
// casing or whitespace.
const QUOTED_RE = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g;
function processOutsideQuotes(s: string): string {
// 1. Lowercase keywords while spaces are still in place so word-boundary
// matching works (`\bAND\b`).
// 2. Strip all remaining whitespace. Glued identifiers are acceptable —
// this string is only used as an internal dedup key, never displayed.
return s.replace(KEYWORDS_RE, (m) => m.toLowerCase()).replace(/\s+/g, '');
}
/**
* Normalize a filter expression for dedup purposes only.
*
* Pipeline: lowercase known keywords/operators (AND, OR, IN, NOT, LIKE, …)
* outside quoted strings, then strip all whitespace outside quoted strings.
* Casing and whitespace inside quoted string values are preserved.
*
* The result is intentionally not human-readable — it's an internal dedup key.
* The original raw expression text stays on the entry for display and prefix
* matching.
*
* This is not a parser — it catches the common formatting drift
* (`a = "x"` vs `a="x"` vs `a = 'x'` vs `A AND B` vs `a and b`) without
* trying to canonicalize the AST.
*/
export function normalizeFilterExpression(input: string): string {
if (!input) {
return '';
}
let result = '';
let lastIndex = 0;
QUOTED_RE.lastIndex = 0;
let match = QUOTED_RE.exec(input);
while (match !== null) {
result += processOutsideQuotes(input.slice(lastIndex, match.index));
result += match[0];
lastIndex = QUOTED_RE.lastIndex;
match = QUOTED_RE.exec(input);
}
result += processOutsideQuotes(input.slice(lastIndex));
return result.trim();
}

View File

@@ -1,206 +0,0 @@
import * as store from './store';
import type { RecentQueryInput } from './store';
import type { RecentQueryEntry } from './types';
const baseInput = (
overrides: Partial<RecentQueryInput> = {},
): RecentQueryInput => ({
signal: 'logs',
filter: { expression: 'service.name = "frontend"' },
...overrides,
});
function saveOrThrow(input: RecentQueryInput): RecentQueryEntry {
const saved = store.save(input);
if (!saved) {
throw new Error('expected save to return an entry');
}
return saved;
}
describe('recentQueries store', () => {
beforeEach(() => {
store.__resetForTests();
});
describe('save + list', () => {
it('saves an entry and lists it', () => {
store.save(baseInput());
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe('service.name = "frontend"');
expect(entries[0].id).toBeTruthy();
expect(entries[0].lastUsedAt).toBeGreaterThan(0);
});
it('does not save when the filter expression is empty', () => {
const result = store.save(baseInput({ filter: { expression: '' } }));
expect(result).toBeNull();
expect(store.list('logs')).toHaveLength(0);
});
it('does not save when the filter expression is whitespace only', () => {
const result = store.save(baseInput({ filter: { expression: ' ' } }));
expect(result).toBeNull();
expect(store.list('logs')).toHaveLength(0);
});
});
describe('LRU ordering', () => {
it('places the most recently saved entry at the front', () => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
store.save(baseInput({ filter: { expression: 'b = 2' } }));
store.save(baseInput({ filter: { expression: 'c = 3' } }));
const entries = store.list('logs');
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
'c = 3',
'b = 2',
'a = 1',
]);
});
it('re-saving an existing filter bumps it to the front', () => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
store.save(baseInput({ filter: { expression: 'b = 2' } }));
store.save(baseInput({ filter: { expression: 'a = 1' } }));
const entries = store.list('logs');
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
'a = 1',
'b = 2',
]);
});
});
describe('dedup', () => {
it('treats formatting variations of the same filter as one entry', () => {
store.save(baseInput({ filter: { expression: 'a = 1 AND b = 2' } }));
store.save(baseInput({ filter: { expression: 'a=1 and b=2' } }));
expect(store.list('logs')).toHaveLength(1);
});
});
describe('signal partitioning', () => {
it('saves to the right bucket per signal', () => {
store.save(baseInput({ signal: 'logs', filter: { expression: 'a = 1' } }));
store.save(
baseInput({ signal: 'traces', filter: { expression: 'b = 2' } }),
);
store.save(
baseInput({ signal: 'metrics', filter: { expression: 'c = 3' } }),
);
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
expect(store.list('metrics')).toHaveLength(1);
expect(store.list('logs')[0].filter.expression).toBe('a = 1');
expect(store.list('traces')[0].filter.expression).toBe('b = 2');
expect(store.list('metrics')[0].filter.expression).toBe('c = 3');
});
it('does not leak between signals on dedup', () => {
store.save(baseInput({ signal: 'logs', filter: { expression: 'a = 1' } }));
store.save(
baseInput({ signal: 'traces', filter: { expression: 'a = 1' } }),
);
expect(store.list('logs')).toHaveLength(1);
expect(store.list('traces')).toHaveLength(1);
});
});
describe('LRU cap', () => {
it('caps the bucket at 10 entries and evicts the oldest', () => {
for (let i = 0; i < 11; i += 1) {
store.save(baseInput({ filter: { expression: `attr_${i} = 1` } }));
}
const entries = store.list('logs');
expect(entries).toHaveLength(10);
expect(entries[0].filter.expression).toBe('attr_10 = 1');
expect(
entries.some((e) => e.filter.expression === 'attr_0 = 1'),
).toBe(false);
});
});
describe('remove', () => {
it('removes an entry by id', () => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
const saved = saveOrThrow(baseInput({ filter: { expression: 'b = 2' } }));
store.remove(saved.id, 'logs');
const entries = store.list('logs');
expect(entries).toHaveLength(1);
expect(entries[0].filter.expression).toBe('a = 1');
});
it('is a no-op when the id does not exist', () => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
store.remove('does-not-exist', 'logs');
expect(store.list('logs')).toHaveLength(1);
});
it('does not touch other signals', () => {
const logsEntry = saveOrThrow(
baseInput({ signal: 'logs', filter: { expression: 'a = 1' } }),
);
store.save(baseInput({ signal: 'traces', filter: { expression: 'a = 1' } }));
store.remove(logsEntry.id, 'logs');
expect(store.list('logs')).toHaveLength(0);
expect(store.list('traces')).toHaveLength(1);
});
});
describe('persistence', () => {
it('reads back the same entries after the in-memory cache is dropped', () => {
store.save(baseInput({ filter: { expression: 'a = 1' } }));
store.save(baseInput({ filter: { expression: 'b = 2' } }));
store.__dropCacheForTests();
const entries = store.list('logs');
expect(entries.map((e) => e.filter.expression)).toStrictEqual([
'b = 2',
'a = 1',
]);
});
});
describe('subscribe', () => {
it('notifies subscribers on save', () => {
const cb = jest.fn();
store.subscribe(cb);
store.save(baseInput({ filter: { expression: 'a = 1' } }));
expect(cb).toHaveBeenCalledTimes(1);
});
it('notifies subscribers on remove', () => {
const saved = saveOrThrow(baseInput({ filter: { expression: 'a = 1' } }));
const cb = jest.fn();
store.subscribe(cb);
store.remove(saved.id, 'logs');
expect(cb).toHaveBeenCalledTimes(1);
});
it('stops notifying after unsubscribe', () => {
const cb = jest.fn();
const unsubscribe = store.subscribe(cb);
unsubscribe();
store.save(baseInput({ filter: { expression: 'a = 1' } }));
expect(cb).not.toHaveBeenCalled();
});
it('does not notify for no-op removes', () => {
const cb = jest.fn();
store.subscribe(cb);
store.remove('does-not-exist', 'logs');
expect(cb).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,187 +0,0 @@
import type { SignalType } from 'types/api/v5/queryRange';
import { normalizeFilterExpression } from './normalize';
import type {
RecentQueriesStoreShape,
RecentQueryEntry,
} from './types';
const STORAGE_KEY_PREFIX = 'qb_recent_v1';
const STORAGE_VERSION = 1;
const MAX_ENTRIES = 10;
const QUOTA_RETRY_FRACTION = 0.8;
const SIGNALS: readonly SignalType[] = ['logs', 'traces', 'metrics'];
const storageKey = (signal: SignalType): string =>
`${STORAGE_KEY_PREFIX}:${signal}`;
const cache = new Map<SignalType, RecentQueryEntry[]>();
const subscribers = new Set<() => void>();
function hasLocalStorage(): boolean {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
function readFromStorage(signal: SignalType): RecentQueryEntry[] {
if (!hasLocalStorage()) {
return [];
}
try {
const raw = window.localStorage.getItem(storageKey(signal));
if (!raw) {
return [];
}
const parsed = JSON.parse(raw) as RecentQueriesStoreShape;
if (parsed?.version !== STORAGE_VERSION || !Array.isArray(parsed.entries)) {
return [];
}
return parsed.entries;
} catch {
return [];
}
}
// Attempts to persist; on write failure (typically QuotaExceededError), drops
// the oldest fraction and retries once. Returns the entries that were actually
// written (caller should mirror this into the in-memory cache so reads stay
// consistent).
function writeToStorage(
signal: SignalType,
entries: RecentQueryEntry[],
): RecentQueryEntry[] {
if (!hasLocalStorage()) {
return entries;
}
const persist = (toWrite: RecentQueryEntry[]): boolean => {
try {
window.localStorage.setItem(
storageKey(signal),
JSON.stringify({ version: STORAGE_VERSION, entries: toWrite }),
);
return true;
} catch {
// Swallow quota and other write errors. Caller retries with a trimmed
// list when this returns false.
return false;
}
};
if (persist(entries)) {
return entries;
}
const trimmed = entries.slice(
0,
Math.max(1, Math.floor(entries.length * QUOTA_RETRY_FRACTION)),
);
if (persist(trimmed)) {
return trimmed;
}
return entries;
}
function getCache(signal: SignalType): RecentQueryEntry[] {
const existing = cache.get(signal);
if (existing) {
return existing;
}
const fresh = readFromStorage(signal);
cache.set(signal, fresh);
return fresh;
}
function notify(): void {
subscribers.forEach((cb) => cb());
}
// Deterministic id derived from the dedup key. Two saves with the same
// (signal, normalized filter) produce the same id and upsert.
function makeId(signal: SignalType, normalizedFilter: string): string {
return `${signal}|${normalizedFilter}`;
}
export type RecentQueryInput = Omit<RecentQueryEntry, 'id' | 'lastUsedAt'>;
export function list(signal: SignalType): RecentQueryEntry[] {
return getCache(signal);
}
export function save(entry: RecentQueryInput): RecentQueryEntry | null {
const normalized = normalizeFilterExpression(entry.filter.expression);
if (!normalized) {
return null;
}
const id = makeId(entry.signal, normalized);
const current = getCache(entry.signal);
const filtered = current.filter((e) => e.id !== id);
const newEntry: RecentQueryEntry = {
...entry,
id,
lastUsedAt: Date.now(),
};
const next = [newEntry, ...filtered].slice(0, MAX_ENTRIES);
const persisted = writeToStorage(entry.signal, next);
cache.set(entry.signal, persisted);
notify();
return newEntry;
}
export function remove(id: string, signal: SignalType): void {
const current = getCache(signal);
const next = current.filter((e) => e.id !== id);
if (next.length === current.length) {
return;
}
const persisted = writeToStorage(signal, next);
cache.set(signal, persisted);
notify();
}
export function subscribe(cb: () => void): () => void {
subscribers.add(cb);
return (): void => {
subscribers.delete(cb);
};
}
function handleCrossTabStorageEvent(event: StorageEvent): void {
if (!event.key || !event.key.startsWith(`${STORAGE_KEY_PREFIX}:`)) {
return;
}
const signal = event.key.slice(STORAGE_KEY_PREFIX.length + 1) as SignalType;
if (!SIGNALS.includes(signal)) {
return;
}
cache.set(signal, readFromStorage(signal));
notify();
}
if (
typeof window !== 'undefined' &&
typeof window.addEventListener === 'function'
) {
window.addEventListener('storage', handleCrossTabStorageEvent);
}
// Test-only escape hatch. Clears in-memory cache, subscribers, and the
// localStorage keys we own. Should not be called from production code.
export function __resetForTests(): void {
cache.clear();
subscribers.clear();
if (hasLocalStorage()) {
SIGNALS.forEach((s) => window.localStorage.removeItem(storageKey(s)));
}
}
// Test-only escape hatch. Drops the in-memory cache without touching
// localStorage, letting tests simulate a fresh page load that rehydrates from
// persisted data. Should not be called from production code.
export function __dropCacheForTests(): void {
cache.clear();
}

View File

@@ -1,16 +0,0 @@
import type { Filter, SignalType } from 'types/api/v5/queryRange';
// A stored recent search. We persist only the where-clause expression — the
// rest of the query bundle (groupBy/orderBy/having/limit) lives on the
// current builder query and is left untouched when a recent is selected.
export interface RecentQueryEntry {
id: string;
signal: SignalType;
filter: Filter;
lastUsedAt: number;
}
export interface RecentQueriesStoreShape {
version: 1;
entries: RecentQueryEntry[];
}

View File

@@ -15,7 +15,6 @@ import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSaveRecentQuery } from 'hooks/recentQueries/useSaveRecentQuery';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
@@ -95,8 +94,6 @@ function TimeSeriesViewContainer({
},
);
useSaveRecentQuery(stagedQuery);
useEffect(() => {
if (data?.payload) {
setWarning(data?.warning);

View File

@@ -48,5 +48,23 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v3/traces/{traceID}/flamegraph", handler.New(
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetFlamegraph),
handler.OpenAPIDef{
ID: "GetFlamegraph",
Tags: []string{"tracedetail"},
Summary: "Get flamegraph view for a trace",
Description: "Returns the flamegraph view of spans for a given trace ID.",
Request: new(spantypes.PostableFlamegraph),
RequestContentType: "application/json",
Response: new(spantypes.GettableFlamegraphTrace),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -1,7 +1,6 @@
package meterreporter
import (
"math/rand/v2"
"time"
"github.com/SigNoz/signoz/pkg/errors"
@@ -16,21 +15,12 @@ type Config struct {
// Backfill enables sealed-day catch-up from the license creation day.
Backfill bool `mapstructure:"backfill"`
// Jitter is the randomness applied to both the first collect after
// Start() and to every subsequent cycle. The first fire happens at a
// random time in [0, Jitter); each subsequent cycle takes
// Interval - random(0, Jitter). Negative (the default) means "derive
// from Interval" via ResolvedJitter, so the value scales with whatever
// Interval the user picks.
Jitter time.Duration `mapstructure:"jitter"`
}
func newConfig() factory.Config {
return Config{
Interval: 6 * time.Hour,
Backfill: true,
Jitter: -1, // Negative sentinel. Resolved at use time unless explicitly set.
}
}
@@ -39,27 +29,9 @@ func NewConfigFactory() factory.ConfigFactory {
}
func (c Config) Validate() error {
if c.Interval < 10*time.Minute || c.Interval > 24*time.Hour {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput, "meterreporter::interval must be between 10m and 24h")
}
if c.Jitter >= 0 && (c.Jitter < 10*time.Minute || c.Jitter > c.Interval) {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput, "meterreporter::jitter must be between 10m and interval")
if c.Interval < 5*time.Minute || c.Interval > 24*time.Hour {
return errors.New(errors.TypeInvalidInput, ErrCodeInvalidInput, "meterreporter::interval must be between 5m and 24h")
}
return nil
}
// NewJitter returns a fresh random duration sampled uniformly from
// [0, jitter), where jitter is the configured Jitter or, if the sentinel
// default is still in place, min(Interval, 2h).
func (c Config) NewJitter() time.Duration {
defaultJitter := 2 * time.Hour
cap := c.Jitter
if cap < 0 {
cap = min(c.Interval, defaultJitter)
}
return time.Duration(rand.Int64N(int64(cap)))
}

View File

@@ -6,7 +6,16 @@ import (
)
type Config struct {
Waterfall WaterfallConfig `mapstructure:"waterfall"`
Waterfall WaterfallConfig `mapstructure:"waterfall"`
Flamegraph FlamegraphConfig `mapstructure:"flamegraph"`
}
type FlamegraphConfig struct {
MaxSelectedLevels int `mapstructure:"max_selected_levels"`
MaxSpansPerLevel int `mapstructure:"max_spans_per_level"`
SamplingTopLatencySpansCount int `mapstructure:"sampling_top_latency_count"`
SamplingBucketCount int `mapstructure:"sampling_bucket_count"`
SelectAllSpansLimit uint `mapstructure:"select_all_spans_limit"`
}
type WaterfallConfig struct {
@@ -29,6 +38,13 @@ func newConfig() factory.Config {
MaxDepthToAutoExpand: 5,
MaxLimitToSelectAllSpans: 10_000,
},
Flamegraph: FlamegraphConfig{
MaxSelectedLevels: 50,
MaxSpansPerLevel: 100,
SamplingTopLatencySpansCount: 5,
SamplingBucketCount: 50,
SelectAllSpansLimit: 100_000,
},
}
}
@@ -45,5 +61,25 @@ func (c Config) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.waterfall.max_limit_to_select_all_spans must be positive")
}
if c.Flamegraph.MaxSelectedLevels <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.flamegraph.level_limit must be positive, got %d", c.Flamegraph.MaxSelectedLevels)
}
if c.Flamegraph.MaxSpansPerLevel <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.flamegraph.spans_per_level must be positive, got %d", c.Flamegraph.MaxSpansPerLevel)
}
if c.Flamegraph.SamplingTopLatencySpansCount < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.flamegraph.top_latency_count cannot be negative, got %d", c.Flamegraph.SamplingTopLatencySpansCount)
}
if c.Flamegraph.SamplingBucketCount <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.flamegraph.bucket_count must be positive, got %d", c.Flamegraph.SamplingBucketCount)
}
if c.Flamegraph.SelectAllSpansLimit == 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput,
"tracedetail.flamegraph.max_limit_to_select_all_spans must be positive")
}
return nil
}

View File

@@ -59,3 +59,19 @@ func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) GetFlamegraph(rw http.ResponseWriter, r *http.Request) {
req := new(spantypes.PostableFlamegraph)
if err := binding.JSON.BindBody(r.Body, req); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetFlamegraph(r.Context(), mux.Vars(r)["traceID"], req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -46,6 +46,33 @@ func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantype
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans, aggregationResults), nil
}
// GetWaterfallV4 is the OOM-safe V4 waterfall.
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
// minimal fields for all spans to build the tree, then full fields for the
// visible window only. Aggregations are not returned.
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
effectiveLimit := min(selectAllLimit, m.config.Waterfall.MaxLimitToSelectAllSpans)
if summary.NumSpans > uint64(effectiveLimit) {
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End)
}
return m.getFullWaterfall(ctx, traceID, summary)
}
func (m *module) GetFlamegraph(ctx context.Context, traceID string, req *spantypes.PostableFlamegraph) (*spantypes.GettableFlamegraphTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
if summary.NumSpans <= uint64(m.config.Flamegraph.SelectAllSpansLimit) {
return m.getFullFlamegraph(ctx, traceID, summary)
}
return m.getWindowedFlamegraph(ctx, traceID, req.SelectedSpanID, summary)
}
// getTraceData fetches all spans for a trace and builds the WaterfallTrace.
func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.WaterfallTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
@@ -69,22 +96,6 @@ func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.W
return spantypes.NewWaterfallTraceFromSpans(nodes), nil
}
// GetWaterfallV4 is the OOM-safe V4 waterfall.
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
// minimal fields for all spans to build the tree, then full fields for the
// visible window only. Aggregations are not returned.
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
return nil, err
}
effectiveLimit := min(selectAllLimit, m.config.Waterfall.MaxLimitToSelectAllSpans)
if summary.NumSpans > uint64(effectiveLimit) {
return m.getWindowedWaterfall(ctx, traceID, selectedSpanID, uncollapsedSpans, summary.Start, summary.End)
}
return m.getFullWaterfall(ctx, traceID, summary)
}
func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *spantypes.TraceSummary) (*spantypes.GettableWaterfallTrace, error) {
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
if err != nil {
@@ -145,3 +156,50 @@ func (m *module) getWindowedWaterfall(ctx context.Context, traceID, selectedSpan
waterfallTrace, selectedSpans, uncollapsedSpans, false, nil,
), nil
}
func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary *spantypes.TraceSummary) (*spantypes.GettableFlamegraphTrace, error) {
fullSpans, err := m.store.GetTraceSpans(ctx, traceID, summary)
if err != nil {
return nil, err
}
if len(fullSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans)
return spantypes.NewGettableFlamegraphTrace(
flamegraphTrace.GetAllLevels(),
summary.Start.UnixMilli(), summary.End.UnixMilli(), false,
), nil
}
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpanID string, summary *spantypes.TraceSummary) (*spantypes.GettableFlamegraphTrace, error) {
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary.Start, summary.End)
if err != nil {
return nil, err
}
if len(minimalSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromMinimal(minimalSpans)
minimalSpans = nil //nolint:ineffassign,wastedassign // release backing array before further db calls
cfg := m.config.Flamegraph
selectedSpans := flamegraphTrace.GetSelectedLevels(selectedSpanID,
cfg.MaxSelectedLevels, cfg.MaxSpansPerLevel, cfg.SamplingTopLatencySpansCount, cfg.SamplingBucketCount)
if len(selectedSpans) == 0 {
return nil, spantypes.ErrTraceNotFound
}
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, traceID, summary.Start, summary.End,
spantypes.FlamegraphWindowSpanIDs(selectedSpans))
if err != nil {
return nil, err
}
return spantypes.NewGettableFlamegraphTrace(
flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans),
summary.Start.UnixMilli(), summary.End.UnixMilli(), true,
), nil
}

View File

@@ -11,10 +11,12 @@ import (
type Handler interface {
GetWaterfall(http.ResponseWriter, *http.Request)
GetWaterfallV4(http.ResponseWriter, *http.Request)
GetFlamegraph(http.ResponseWriter, *http.Request)
}
// Module defines the business logic for trace detail operations.
type Module interface {
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
GetWaterfallV4(ctx context.Context, traceID string, selectedSpanID string, uncollapsedSpans []string, selectAllLimit uint) (*spantypes.GettableWaterfallTrace, error)
GetFlamegraph(ctx context.Context, traceID string, req *spantypes.PostableFlamegraph) (*spantypes.GettableFlamegraphTrace, error)
}

View File

@@ -208,7 +208,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateCloudIntegrationDashboardsFactory(sqlstore),
sqlmigration.NewAddScopeToPlannedMaintenanceFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateInstalledIntegrationDashboardsFactory(sqlstore),
sqlmigration.NewAddDashboardNameFactory(sqlstore, sqlschema),
)
}

View File

@@ -1,85 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addDashboardName struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddDashboardNameFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_dashboard_name"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addDashboardName{sqlstore: sqlstore, sqlschema: sqlschema}, nil
},
)
}
func (migration *addDashboardName) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addDashboardName) Up(ctx context.Context, db *bun.DB) error {
// dashboard is referenced by public_dashboard and integration_dashboard;
// FK enforcement must be off for the SQLite recreate-table fallback.
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
table, uniqueConstraints, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("dashboard"))
if err != nil {
return err
}
nameColumn := &sqlschema.Column{
Name: sqlschema.ColumnName("name"),
DataType: sqlschema.DataTypeText,
Nullable: false,
}
// Only v2 dashboards populate this column. Existing v1 rows are left with
// the zero value (empty string) so v1 create/update paths can keep
// inserting without a name.
//
// TODO: once v1 dashboards are migrated to v2 and every row has a real
// name, a follow-up migration should add a unique index on
// (org_id, name) to enforce per-org name uniqueness.
sqls := migration.sqlschema.Operator().AddColumn(table, uniqueConstraints, nameColumn, nil)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, true); err != nil {
return err
}
return nil
}
func (migration *addDashboardName) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -33,7 +33,6 @@ type StorableDashboard struct {
Locked bool `bun:"locked,notnull,default:false"`
OrgID valuer.UUID `bun:"org_id,notnull"`
Source Source `bun:"source,type:text,notnull"`
Name string `bun:"name,type:text,notnull"`
}
type Dashboard struct {

View File

@@ -0,0 +1,81 @@
package spantypes
import (
"maps"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
type FlamegraphSpan struct {
SpanID string `json:"spanId"`
ParentSpanID string `json:"parentSpanId"`
Timestamp uint64 `json:"timestamp"`
DurationNano uint64 `json:"durationNano"`
HasError bool `json:"hasError"`
ServiceName string `json:"serviceName"`
Name string `json:"name"`
Level int64 `json:"level"`
Events []Event `json:"event"`
Attributes map[string]any `json:"attributes,omitempty"`
Resource map[string]string `json:"resource,omitempty"`
Children []*FlamegraphSpan `json:"-"` // internal tree use only
}
// FlamegraphLevel groups span IDs at a single level within the selected window.
type FlamegraphLevel struct {
Level int64
SpanIDs []string
}
type PostableFlamegraph struct {
SelectedSpanID string `json:"selectedSpanId"`
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitempty"`
}
// GettableFlamegraphTrace is the response for the v3 flamegraph API.
type GettableFlamegraphTrace struct {
Spans [][]*FlamegraphSpan `json:"spans"`
StartTimestampMillis int64 `json:"startTimestampMillis"`
EndTimestampMillis int64 `json:"endTimestampMillis"`
HasMore bool `json:"hasMore"`
}
func NewGettableFlamegraphTrace(spans [][]*FlamegraphSpan, startMs, endMs int64, hasMore bool) *GettableFlamegraphTrace {
return &GettableFlamegraphTrace{
Spans: spans,
StartTimestampMillis: startMs,
EndTimestampMillis: endMs,
HasMore: hasMore,
}
}
func NewFlamegraphSpanFromStorable(s *StorableSpan, level int64) *FlamegraphSpan {
resources := make(map[string]string, len(s.ResourcesString))
maps.Copy(resources, s.ResourcesString)
return &FlamegraphSpan{
SpanID: s.SpanID,
ParentSpanID: s.ParentSpanID,
Timestamp: uint64(s.StartTime.UnixNano()),
DurationNano: s.DurationNano,
HasError: s.HasError,
ServiceName: s.ServiceName,
Name: s.Name,
Level: level,
Events: s.UnmarshalledEvents(),
Attributes: s.Attributes(),
Resource: resources,
}
}
// FlamegraphWindowSpanIDs collects all span IDs from a level window into a flat slice.
func FlamegraphWindowSpanIDs(window []FlamegraphLevel) []string {
total := 0
for _, lvl := range window {
total += len(lvl.SpanIDs)
}
ids := make([]string, 0, total)
for _, lvl := range window {
ids = append(ids, lvl.SpanIDs...)
}
return ids
}

View File

@@ -0,0 +1,276 @@
package spantypes
import (
"sort"
)
// FlamegraphTrace holds the level wise tree built from minimal spans.
type FlamegraphTrace struct {
roots []*FlamegraphSpan
nodeByID map[string]*FlamegraphSpan
startTime uint64
endTime uint64
}
func NewFlamegraphTraceFromMinimal(spans []MinimalSpan) *FlamegraphTrace {
t := &FlamegraphTrace{
nodeByID: make(map[string]*FlamegraphSpan, len(spans)),
}
for i := range spans {
node := spans[i].ToFlamegraphSpan()
t.updateTimeRange(node.Timestamp, node.DurationNano)
t.nodeByID[node.SpanID] = node
}
t.buildSpanTree()
return t
}
func NewFlamegraphTraceFromStorable(spans []StorableSpan) *FlamegraphTrace {
t := &FlamegraphTrace{
nodeByID: make(map[string]*FlamegraphSpan, len(spans)),
}
for i := range spans {
node := NewFlamegraphSpanFromStorable(&spans[i], 0) // level is set later by BFS
t.updateTimeRange(node.Timestamp, node.DurationNano)
t.nodeByID[node.SpanID] = node
}
t.buildSpanTree()
return t
}
func (t *FlamegraphTrace) GetAllLevels() [][]*FlamegraphSpan {
allLevels := t.buildAllLevels()
for _, node := range t.nodeByID {
node.Children = nil // children not required after building tree
}
return allLevels
}
// GetSelectedLevels returns the level window for selectedSpanID with sampling applied to
// dense levels. It always applies windowing — callers should only invoke this when the
// trace is known to exceed the select-all limit.
// Children are cleared after traversal so the tree can be GC'd.
func (t *FlamegraphTrace) GetSelectedLevels(
selectedSpanID string,
levelLimit, spansPerLevel, topLatencyCount, bucketCount int,
) []FlamegraphLevel {
allLevels := t.buildAllLevels()
for _, node := range t.nodeByID {
node.Children = nil
}
selectedIndex := 0
if selectedSpanID != "" {
outer:
for i, lvl := range allLevels {
for _, span := range lvl {
if span.SpanID == selectedSpanID {
selectedIndex = i
break outer
}
}
}
}
lowerLimit := selectedIndex - int(float64(levelLimit)*0.4)
upperLimit := selectedIndex + int(float64(levelLimit)*0.6)
if lowerLimit < 0 {
upperLimit -= lowerLimit
lowerLimit = 0
}
if upperLimit > len(allLevels) {
lowerLimit -= upperLimit - len(allLevels)
upperLimit = len(allLevels)
}
if lowerLimit < 0 {
lowerLimit = 0
}
result := make([]FlamegraphLevel, 0, upperLimit-lowerLimit)
for i := lowerLimit; i < upperLimit; i++ {
lvl := allLevels[i]
if len(lvl) == 0 {
continue
}
var sampled []*FlamegraphSpan
if len(lvl) > spansPerLevel {
sampled = sampleFlamegraphLevel(lvl, selectedSpanID, i == selectedIndex,
t.startTime, t.endTime, topLatencyCount, bucketCount)
} else {
sampled = lvl
}
if len(sampled) == 0 {
continue
}
spanIDs := make([]string, len(sampled))
for j, s := range sampled {
spanIDs[j] = s.SpanID
}
result = append(result, FlamegraphLevel{
Level: sampled[0].Level,
SpanIDs: spanIDs,
})
}
return result
}
func (t *FlamegraphTrace) EnrichSelectedSpans(selectedSpans []FlamegraphLevel, fullSpans []StorableSpan) [][]*FlamegraphSpan {
fullByID := make(map[string]*StorableSpan, len(fullSpans))
for i := range fullSpans {
fullByID[fullSpans[i].SpanID] = &fullSpans[i]
}
result := make([][]*FlamegraphSpan, len(selectedSpans))
for i, lvl := range selectedSpans {
result[i] = make([]*FlamegraphSpan, 0, len(lvl.SpanIDs))
for _, spanID := range lvl.SpanIDs {
if full, ok := fullByID[spanID]; ok {
result[i] = append(result[i], NewFlamegraphSpanFromStorable(full, lvl.Level))
} else if lean, ok := t.nodeByID[spanID]; ok {
result[i] = append(result[i], lean)
}
}
}
return result
}
func (t *FlamegraphTrace) updateTimeRange(timestamp, durationNano uint64) {
if t.startTime == 0 || timestamp < t.startTime {
t.startTime = timestamp
}
if end := timestamp + durationNano; end > t.endTime {
t.endTime = end
}
}
func (t *FlamegraphTrace) buildSpanTree() {
for _, node := range t.nodeByID {
if node.ParentSpanID != "" {
if parent, ok := t.nodeByID[node.ParentSpanID]; ok {
parent.Children = append(parent.Children, node)
} else {
missing := &FlamegraphSpan{
SpanID: node.ParentSpanID,
Name: "Missing Span",
Timestamp: node.Timestamp,
DurationNano: node.DurationNano,
Children: []*FlamegraphSpan{node},
}
t.nodeByID[missing.SpanID] = missing
t.roots = append(t.roots, missing)
}
} else if flamegraphSpanIndex(t.roots, node.SpanID) == -1 {
t.roots = append(t.roots, node)
}
}
sort.Slice(t.roots, func(i, j int) bool {
if t.roots[i].Timestamp == t.roots[j].Timestamp {
return t.roots[i].SpanID < t.roots[j].SpanID
}
return t.roots[i].Timestamp < t.roots[j].Timestamp
})
}
func (t *FlamegraphTrace) buildAllLevels() [][]*FlamegraphSpan {
var result [][]*FlamegraphSpan
type entry struct {
node *FlamegraphSpan
depth int64
}
for _, root := range t.roots {
levelMap := make(map[int64][]*FlamegraphSpan)
maxDepth := int64(-1)
queue := []entry{{root, 0}}
for len(queue) > 0 {
curr := queue[0]
queue = queue[1:]
curr.node.Level = curr.depth
levelMap[curr.depth] = append(levelMap[curr.depth], curr.node)
if curr.depth > maxDepth {
maxDepth = curr.depth
}
for _, child := range curr.node.Children {
queue = append(queue, entry{child, curr.depth + 1})
}
}
for depth := int64(0); depth <= maxDepth; depth++ {
if spans, ok := levelMap[depth]; ok {
result = append(result, spans)
}
}
}
return result
}
func sampleFlamegraphLevel(
spans []*FlamegraphSpan,
selectedSpanID string,
isSelectedLevel bool,
startTime, endTime uint64,
topLatencyCount, bucketCount int,
) []*FlamegraphSpan {
sorted := make([]*FlamegraphSpan, len(spans))
copy(sorted, spans)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].DurationNano > sorted[j].DurationNano
})
var sampled []*FlamegraphSpan
topK := min(topLatencyCount, len(sorted))
sampled = append(sampled, sorted[:topK]...)
if isSelectedLevel {
for _, span := range sorted {
if span.SpanID == selectedSpanID {
sampled = append(sampled, span)
break
}
}
}
bucketSize := (endTime - startTime) / uint64(bucketCount)
if bucketSize == 0 {
bucketSize = 1
}
buckets := make([][]*FlamegraphSpan, bucketCount)
for _, span := range sorted {
if span.Timestamp < startTime || span.Timestamp > endTime {
continue
}
idx := int((span.Timestamp - startTime) / bucketSize)
if idx < 0 {
idx = 0
} else if idx >= bucketCount {
idx = bucketCount - 1
}
buckets[idx] = append(buckets[idx], span)
}
for i := range buckets {
if len(buckets[i]) > 2 {
buckets[i] = buckets[i][:2]
}
}
for _, bucket := range buckets {
sampled = append(sampled, bucket...)
}
return sampled
}
func flamegraphSpanIndex(spans []*FlamegraphSpan, spanID string) int {
for i, s := range spans {
if s != nil && s.SpanID == spanID {
return i
}
}
return -1
}

View File

@@ -156,6 +156,18 @@ func (item *MinimalSpan) ToWaterfallSpan(traceID string) *WaterfallSpan {
}
}
func (item *MinimalSpan) ToFlamegraphSpan() *FlamegraphSpan {
return &FlamegraphSpan{
SpanID: item.SpanID,
ParentSpanID: item.ParentSpanID,
Timestamp: uint64(item.StartTime.UnixNano()),
DurationNano: item.DurationNano,
HasError: item.HasError,
ServiceName: item.ServiceName,
Children: make([]*FlamegraphSpan, 0),
}
}
// NewMissingWaterfallSpan creates a synthetic placeholder span for a parent that has no recorded data.
func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano uint64) *WaterfallSpan {
return &WaterfallSpan{