Compare commits

..

36 Commits

Author SHA1 Message Date
Jatinderjit Singh
58c2e8366e Add mute endpoint for alert rules
Expose POST /api/v2/rules/{id}/mute as a shortcut to create a
fixed-window planned maintenance scoped to a single rule. The created
PlannedMaintenance is returned so clients can unmute by deleting it via
the existing /api/v1/downtime_schedules/{id} endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:44:33 +05:30
Jatinderjit Singh
17f0c33fe0 feat(planned-downtime): explicit toggle for all vs specific alert rules
Replace the implicit "empty alert list silences everything" behavior
with a Radio toggle ("All alert rules" / "Specific alert rules") so
users can't accidentally silence every alert by forgetting to select
rules. The list view now displays an explicit "All alert rules" tag
instead of a dash for schedules that silence everything.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:44:33 +05:30
Jatinderjit Singh
c31d95f9c2 remove unused function evaluateLabelExpression 2026-05-12 21:43:32 +05:30
Jatinderjit Singh
2b46e35bb0 fix(tests): resolve envprovider env isolation, factory name length, and ShouldSkip signature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
a1e3d75296 fix lset type and update openapi spec 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
1bbccebf16 implement Down migration to drop label_expression column 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
0304c86a6e remove redundant LabelSet->map conversion 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
6994a3875a Move label expression evaluation into ShouldSkip
ShouldSkip now owns all three suppression checks in sequence:
rule ID match → schedule active → label expression.
IsActive passes nil labels so the expression check is skipped
(no instance labels available for UI status).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
2d3625bc60 Remove redundant || undefined from labelExpression assignment 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
931988fcca Add label expression support to planned downtime
Alert instances can now be scoped by label expression
(e.g. env == "prod"), scoping suppression below the rule level.
A window with no rule IDs and a label expression silences any
alert whose labels match, regardless of which rule fired it;
when rule IDs are also present, the expression is evaluated only
within the matched rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
f78bfa1d57 test: add unit tests for MaintenanceMuter
Covers Mutes/MutedBy semantics (empty label, rule match, empty-RuleIDs
matches-all, future windows, multi-window) and the result cache
(single-fetch within TTL, stale-cache fallback on store error,
re-fetch after expiry, concurrency safety).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
20c08b30b3 refactor: move maintenance (planned downtime) to alertmanager packages
Types move from pkg/types/ruletypes/ to pkg/types/alertmanagertypes/:
- maintenance.go, recurrence.go, schedule.go (+ tests)

Store impl moves from pkg/ruler/rulestore/sqlrulestore/ to
pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore/.

Maintenance windows mute alerts, so they belong with alertmanager
rather than the rule types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
46d69f6ddb code cleanup 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
c8caa657e0 feat: surface maintenance-suppressed alerts via mutedBy in GetAlerts
Alerts suppressed by an active maintenance window were being correctly
muted in the notification pipeline but appeared as state=active in the
v2 GetAlerts response, since MaintenanceMuter.Mutes had no marker
side-effect (unlike inhibitor/silencer).

Add MaintenanceMuter.MutedBy returning the matching window IDs, and
plumb a mutedByFunc callback through NewGettableAlertsFromAlertProvider
into AlertToOpenAPIAlert. The upstream v2 API forces state=suppressed
when mutedBy is non-empty, so the frontend's existing state-based
rendering picks it up without further changes.

Use the dedicated mutedBy field rather than SilencedBy to avoid
violating the "complete set of silence IDs" contract that anything
querying silences by ID would rely on.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
31fee32601 remove redundant MemMarker wrapper 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
2362a1d6ec refactor: move MaintenanceMuter to Server and pass it to pipelineBuilder.New
- Remove muter from pipelineBuilder struct and newPipelineBuilder();
  pass it as a parameter to New() instead, consistent with inhibitor/silencer
- Store muter on Server so GetAlerts can call Mutes() alongside the
  inhibitor and silencer, ensuring maintenance-suppressed alerts show
  the correct muted status in API responses

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
023e916446 refactor: always initialize maintenanceStore; remove nil guards
Tests now use a real sqlrulestore-backed MaintenanceMuter instead of
passing nil. With nil no longer a valid input, remove the nil guards
in server.go and pipeline_builder.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
270b05f94d refactor: hoist MuteStage construction out of the receiver loop
MuteStage holds no per-receiver state, so one instance shared across
all receivers is sufficient — matching how is/ss are handled upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
e4e40103d5 refactor: replace maintenanceMuteStage with notify.NewMuteStage
MaintenanceMuter already satisfies types.Muter, and pipelineBuilder has
its own pb.metrics, so the hand-rolled maintenanceMuteStage wrapper is
redundant. Use notify.NewMuteStage(pb.muter, pb.metrics) directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
35a498010d rename buildReceiverStage -> createReceiverStage 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
ed21a51671 refactor: remove dead orgID param from task constructors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
742b0b86b4 refactor: pass MaintenanceMuter directly to pipelineBuilder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
13e1b254b5 chore: replace SPDX tag with full Apache 2.0 license boilerplate
The full license text is unambiguously compliant with Apache 2.0 Section 4(a),
which requires giving recipients "a copy of this License".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
dc579c1001 chore: add license header to pipeline_builder.go
Copied code originates from Apache-2.0 licensed Prometheus Alertmanager;
add dual copyright + SPDX identifier following the repo's convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
95b7186086 refactor: move maintenance mute stage into custom pipelineBuilder
Copy notify.PipelineBuilder locally so we can inject mms between the
silence stage and the receiver stage (GossipSettle → Inhibit →
TimeActive → TimeMute → Silence → mms → Receiver), matching the
correct suppression order the team requires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
096ded8b0e refactor: wrap routing pipeline once instead of per-route injection
Replace the per-route-entry loop with a single MultiStage wrap so
maintenance suppression runs once per dispatch group before routing.
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
14082ee5c7 add maintenanceMuteStage to move planned maintenance to alertmanager
Rules previously skipped rule.Eval() entirely during maintenance windows.
This change moves suppression to MaintenanceMuter, injected as a Stage
in the alertmanager notification pipeline. Now rules always evaluate and
everys suppression is handled by alertmanager.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
5f36ca2992 fix CI issues 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
f0cedec7a8 handle empty initial start time 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
007fedf379 remove redundant param shouldKeepLocalTime 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
4831bea47e fix display timezone 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
217ac56cef Remove start and end time from recurrence 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
15a1d31844 Revert "send empty start/end dates in frontend for recurring windows"
This reverts commit 87bc3fae274ccfd9ce98aeae5ac379fadf657df3.
2026-05-12 20:41:32 +05:30
Jatinderjit Singh
02ab513081 handle zero start and end times in schedule 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
7be19bd773 send empty start/end dates in frontend for recurring windows 2026-05-12 20:41:32 +05:30
Jatinderjit Singh
084168dad7 fix: maintenance ignores recurrence when fixed times also set 2026-05-12 20:41:32 +05:30
113 changed files with 2162 additions and 4202 deletions

View File

@@ -4802,6 +4802,8 @@ components:
type: string
kind:
$ref: '#/components/schemas/RuletypesMaintenanceKind'
labelExpression:
type: string
name:
type: string
schedule:
@@ -4829,6 +4831,8 @@ components:
type: array
description:
type: string
labelExpression:
type: string
name:
type: string
schedule:
@@ -4893,10 +4897,6 @@ components:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/RuletypesRepeatOn'
@@ -4904,11 +4904,7 @@ components:
type: array
repeatType:
$ref: '#/components/schemas/RuletypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
@@ -5072,6 +5068,7 @@ components:
type: string
required:
- timezone
- startTime
type: object
RuletypesScheduleType:
enum:

View File

@@ -13,7 +13,6 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
@@ -49,7 +48,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, tr)
// create ch rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -73,7 +72,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, pr)
// create promql rule task for evaluation
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -96,7 +95,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, ar)
// create anomaly rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
} else {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
@@ -210,9 +209,9 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
}
// newTask returns an appropriate group for the rule type
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) baserules.Task {
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc) baserules.Task {
if taskType == baserules.TaskTypeCh {
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify)
}
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify)
}

View File

@@ -1,19 +0,0 @@
---
description: Prefer SigNoz UI and icons across frontend code
globs: **/*.{ts,tsx,js,jsx}
alwaysApply: true
---
# UI Components and Icons Source of Truth
For all frontend implementation work in this repository:
- Always use UI primitives/components from `@signozhq/ui`.
- Always use icons from `@signozhq/icons`.
- Do not introduce new usage of icon libraries directly (for example `lucide-react`) in app code.
- Do not mix multiple component systems for the same UI surface when an equivalent exists in `@signozhq/ui`.
## Migration guidance
- If touching a file that already uses non-`@signozhq/icons` icons, prefer migrating that file to `@signozhq/icons` as part of the same change when practical.
- If a required component or icon is missing from SigNoz packages, call this out explicitly in the PR/summary before introducing alternatives.

View File

@@ -291,8 +291,6 @@
// Prevents window.open(path), window.location.origin + path, window.location.href = path
"signoz/no-antd-components": "error",
// Prevents the usage of specific antd components in favor of our lib
"signoz/no-signozhq-ui-barrel": "error",
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
"no-restricted-globals": [
"error",
{

View File

@@ -29,18 +29,6 @@ if (!HTMLElement.prototype.scrollIntoView) {
HTMLElement.prototype.scrollIntoView = function (): void {};
}
if (typeof window.IntersectionObserver === 'undefined') {
class IntersectionObserverMock {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}
(window as any).IntersectionObserver = IntersectionObserverMock;
}
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),

View File

@@ -1,210 +0,0 @@
/**
* Rule: no-signozhq-ui-barrel
*
* Forbids importing from the `@signozhq/ui` barrel and requires the matching
* subpath instead.
*
* This rule catches:
* import { Typography } from '@signozhq/ui'
* import { Button, toast } from '@signozhq/ui'
* import '@signozhq/ui'
*
* And expects:
* import { Typography } from '@signozhq/ui/typography'
* import { Button } from '@signozhq/ui/button'
* import { toast } from '@signozhq/ui/sonner'
*
* Why: the barrel eagerly require()s every component (~90 of them) along with
* their Radix/cmdk/motion/react-day-picker dependencies. Under Jest this caused
* 5s timeouts and flaky tests after the Antd→@signozhq/ui Typography migration
* (#11199). Subpath imports (added in @signozhq/ui@0.0.18) load only what's
* used.
*
* The auto-generated `auto-import-registry.d.ts` is a pure declaration file
* that exists solely to nudge VS Code's auto-import indexer; its bare
* `import '@signozhq/ui';` is type-only and not emitted, so it is exempt.
*
* Autofix:
* Rewrites named imports to the matching subpath, splitting one statement
* into multiple when specifiers come from different subpaths. The
* export-name → subpath map is derived lazily from the installed
* `@signozhq/ui` dist `.d.ts` files. Imports we can't classify (namespace,
* default, side-effect, or unknown specifier) are reported without a fix.
*/
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ALLOWED_FILES = new Set(['auto-import-registry.d.ts']);
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
let exportMap = null;
function loadExportMap() {
if (exportMap === null) {
exportMap = buildExportMap();
}
return exportMap;
}
function buildExportMap() {
const map = new Map();
const root = findSignozUiRoot();
if (!root) return map;
let pkg;
try {
pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
} catch {
return map;
}
const subpathKeys = Object.keys(pkg.exports || {}).filter((k) => k !== '.');
for (const key of subpathKeys) {
const subpath = key.replace(/^\.\//, '');
const entry = join(root, 'dist', subpath, 'index.d.ts');
if (!existsSync(entry)) continue;
const names = new Set();
collectExportedNames(entry, names, new Set());
// First-wins: package.json subpath order is the canonical home for
// names re-exported across multiple subpaths (e.g. `ToggleColor` is
// declared in `toggle` and re-exported from `toggle-group`).
for (const name of names) {
if (!map.has(name)) map.set(name, subpath);
}
}
return map;
}
function findSignozUiRoot() {
let dir = PLUGIN_DIR;
while (true) {
const candidate = join(dir, 'node_modules', '@signozhq', 'ui');
if (existsSync(join(candidate, 'package.json'))) return candidate;
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function collectExportedNames(filepath, out, visited) {
if (visited.has(filepath) || !existsSync(filepath)) return;
visited.add(filepath);
let content;
try {
content = readFileSync(filepath, 'utf-8');
} catch {
return;
}
// `export * from './x.js'` / `export type * from './x.js'`
for (const m of content.matchAll(
/export\s+(?:type\s+)?\*\s+from\s+['"]([^'"]+)['"]/g,
)) {
collectExportedNames(resolveRelativeDts(filepath, m[1]), out, visited);
}
// `export { Foo, type Bar, Foo as Baz } from '...';` and `export { ... };`
for (const m of content.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) {
for (const item of m[1].split(',')) {
const cleaned = item.trim().replace(/^type\s+/, '');
if (!cleaned) continue;
const idMatch = cleaned.match(
/^([A-Za-z_$][A-Za-z0-9_$]*)(?:\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*))?$/,
);
if (idMatch) out.add(idMatch[2] || idMatch[1]);
}
}
// `export (declare) const|let|var|function|class|enum|type|interface Foo`
for (const m of content.matchAll(
/export\s+(?:declare\s+)?(?:const|let|var|function|class|enum|type|interface)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g,
)) {
out.add(m[1]);
}
}
function resolveRelativeDts(fromFile, spec) {
const base = dirname(fromFile);
const stripped = spec.replace(/\.(js|mjs|cjs)$/, '');
const sibling = join(base, `${stripped}.d.ts`);
if (existsSync(sibling)) return sibling;
const indexed = join(base, stripped, 'index.d.ts');
if (existsSync(indexed)) return indexed;
return sibling;
}
function buildReplacement(node, map) {
const specifiers = node.specifiers || [];
if (specifiers.length === 0) return null;
for (const spec of specifiers) {
if (spec.type !== 'ImportSpecifier') return null;
if (spec.imported?.type !== 'Identifier') return null;
}
const quote = node.source.raw?.[0] === '"' ? '"' : "'";
const topLevelType = node.importKind === 'type';
const keyword = topLevelType ? 'import type' : 'import';
const groups = new Map();
for (const spec of specifiers) {
const importedName = spec.imported.name;
const subpath = map.get(importedName);
if (!subpath) return null;
const localName = spec.local.name;
const inlineType = !topLevelType && spec.importKind === 'type';
let text = inlineType ? 'type ' : '';
text += importedName;
if (localName !== importedName) text += ` as ${localName}`;
if (!groups.has(subpath)) groups.set(subpath, []);
groups.get(subpath).push(text);
}
const lines = [];
for (const [subpath, items] of groups) {
lines.push(
`${keyword} { ${items.join(', ')} } from ${quote}@signozhq/ui/${subpath}${quote};`,
);
}
return lines.join('\n');
}
export default {
meta: {
fixable: 'code',
},
create(context) {
const filename = context.filename || '';
const basename = filename.split(/[\\/]/).pop();
if (ALLOWED_FILES.has(basename)) {
return {};
}
return {
ImportDeclaration(node) {
if (node.source.value !== '@signozhq/ui') {
return;
}
const replacement = buildReplacement(node, loadExportMap());
const report = {
node: node.source,
message:
"Do not import from the '@signozhq/ui' barrel. Use the matching subpath instead (e.g. '@signozhq/ui/typography', '@signozhq/ui/button', '@signozhq/ui/sonner'). The barrel eagerly loads ~90 components and slows tests substantially.",
};
if (replacement) {
report.fix = (fixer) => fixer.replaceText(node, replacement);
}
context.report(report);
},
};
},
};

View File

@@ -10,7 +10,6 @@ import noNavigatorClipboard from './rules/no-navigator-clipboard.mjs';
import noUnsupportedAssetPattern from './rules/no-unsupported-asset-pattern.mjs';
import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
import noAntdComponents from './rules/no-antd-components.mjs';
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
export default {
meta: {
@@ -22,6 +21,5 @@ export default {
'no-unsupported-asset-pattern': noUnsupportedAssetPattern,
'no-raw-absolute-path': noRawAbsolutePath,
'no-antd-components': noAntdComponents,
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
},
};

View File

@@ -7042,6 +7042,10 @@ export interface RuletypesPlannedMaintenanceDTO {
*/
id: string;
kind: RuletypesMaintenanceKindDTO;
/**
* @type string
*/
labelExpression?: string;
/**
* @type string
*/
@@ -7069,6 +7073,10 @@ export interface RuletypesPostablePlannedMaintenanceDTO {
* @type string
*/
description?: string;
/**
* @type string
*/
labelExpression?: string;
/**
* @type string
*/
@@ -7142,23 +7150,12 @@ export interface RuletypesRecurrenceDTO {
* @type string
*/
duration: string;
/**
* @type string
* @format date-time
* @nullable true
*/
endTime?: Date | null;
/**
* @type array
* @nullable true
*/
repeatOn?: RuletypesRepeatOnDTO[] | null;
repeatType: RuletypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: Date;
}
export interface RuletypesRenotifyDTO {
@@ -7340,7 +7337,7 @@ export interface RuletypesScheduleDTO {
* @type string
* @format date-time
*/
startTime?: Date;
startTime: Date;
/**
* @type string
*/

View File

@@ -40,7 +40,6 @@ const getTraceV3 = async (
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
timestamp: span.time_unix,
}));
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),

View File

@@ -44,11 +44,7 @@ function HttpStatusBadge({
const color = getStatusCodeColor(numericStatusCode);
return (
<Badge color={color} variant="outline">
{statusCode}
</Badge>
);
return <Badge color={color}>{statusCode}</Badge>;
}
export default HttpStatusBadge;

View File

@@ -38,7 +38,6 @@ export enum LOCALSTORAGE {
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
}

View File

@@ -3,7 +3,5 @@ export const USER_PREFERENCES = {
NAV_SHORTCUTS: 'nav_shortcuts',
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
SPAN_DETAILS_PREVIEW_ATTRIBUTES: 'span_details_preview_attributes',
SPAN_DETAILS_COLOR_BY_ATTRIBUTE: 'span_details_color_by_attribute',
SPAN_PERCENTILE_RESOURCE_ATTRIBUTES: 'span_percentile_resource_attributes',
};

View File

@@ -1,4 +1,4 @@
import { Button } from '@signozhq/ui/button';
import { Button } from '@signozhq/ui';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import styles from './ExpandedButtonWrapper.module.scss';
import { useEffect, useState } from 'react';

View File

@@ -81,6 +81,20 @@
}
}
.alert-rule-scope {
margin-bottom: 12px;
.ant-radio-wrapper {
color: var(--l1-foreground);
}
}
.alert-rule-all-warning {
font-size: 12px;
font-weight: 400;
color: var(--l2-foreground);
}
.formItemWithBullet {
margin-bottom: 0;
}

View File

@@ -17,6 +17,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { USER_ROLES } from 'types/roles';
import { DeepPartial } from 'utils/types';
import 'dayjs/locale/en';
@@ -24,7 +25,7 @@ import { PlannedDowntimeDeleteModal } from './PlannedDowntimeDeleteModal';
import { PlannedDowntimeForm } from './PlannedDowntimeForm';
import { PlannedDowntimeList } from './PlannedDowntimeList';
import {
defautlInitialValues,
defaultInitialValues,
deleteDowntimeHandler,
} from './PlannedDowntimeutils';
@@ -48,8 +49,8 @@ export function PlannedDowntime(): JSX.Element {
const urlQuery = useUrlQuery();
const [initialValues, setInitialValues] =
useState<Partial<RuletypesPlannedMaintenanceDTO & { editMode: boolean }>>(
defautlInitialValues,
useState<DeepPartial<RuletypesPlannedMaintenanceDTO & { editMode: boolean }>>(
defaultInitialValues,
);
const downtimeSchedules = useListDowntimeSchedules();
@@ -148,7 +149,7 @@ export function PlannedDowntime(): JSX.Element {
<Button
type="primary"
onClick={(): void => {
setInitialValues({ ...defautlInitialValues, editMode: false });
setInitialValues({ ...defaultInitialValues, editMode: false });
setIsOpen(true);
setEditMode(false);
form.resetFields();

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Check } from '@signozhq/icons';
import { Check, Info } from '@signozhq/icons';
import {
Button,
DatePicker,
@@ -8,12 +8,15 @@ import {
FormInstance,
Input,
Modal,
Radio,
Select,
SelectProps,
Spin,
Tooltip,
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
import { DatePickerProps } from 'antd/es/date-picker';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
createDowntimeSchedule,
@@ -39,6 +42,7 @@ import { defaultTo, isEmpty } from 'lodash-es';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { ALL_TIME_ZONES } from 'utils/timeZoneUtil';
import { DeepPartial } from 'utils/types';
import 'dayjs/locale/en';
@@ -64,20 +68,23 @@ const TIME_FORMAT = DATE_TIME_FORMATS.TIME;
const DATE_FORMAT = DATE_TIME_FORMATS.ORDINAL_DATE;
const ORDINAL_FORMAT = DATE_TIME_FORMATS.ORDINAL_ONLY;
type AlertRuleScope = 'all' | 'specific';
interface PlannedDowntimeFormData {
name: string;
startTime: dayjs.Dayjs | string;
endTime: dayjs.Dayjs | string;
recurrence?: RuletypesRecurrenceDTO | null;
alertRuleScope: AlertRuleScope;
alertRules: DefaultOptionType[];
recurrenceSelect?: RuletypesRecurrenceDTO;
timezone?: string;
labelExpression?: string;
}
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
interface PlannedDowntimeFormProps {
initialValues: Partial<
initialValues: DeepPartial<
RuletypesPlannedMaintenanceDTO & {
editMode: boolean;
}
@@ -89,7 +96,7 @@ interface PlannedDowntimeFormProps {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
refetchAllSchedules: () => void;
isEditMode: boolean;
form: FormInstance<any>;
form: FormInstance;
}
export function PlannedDowntimeForm(
@@ -126,6 +133,12 @@ export function PlannedDowntimeForm(
recurrenceOptions.doesNotRepeat.value,
);
const [alertRuleScope, setAlertRuleScope] = useState<AlertRuleScope>(
initialValues.id && (initialValues.alertIds || []).length === 0
? 'all'
: 'specific',
);
const timezoneInitialValue = !isEmpty(initialValues.schedule?.timezone)
? (initialValues.schedule?.timezone as string)
: undefined;
@@ -133,26 +146,28 @@ export function PlannedDowntimeForm(
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const datePickerFooter = (mode: any): any =>
const datePickerFooter: DatePickerProps['renderExtraFooter'] = (mode) =>
mode === 'time' ? (
<span style={{ color: 'gray' }}>Please select the time</span>
) : null;
const saveHanlder = useCallback(
const saveHandler = useCallback(
async (values: PlannedDowntimeFormData) => {
const shouldKeepLocalTime = !isEditMode;
const data: RuletypesPostablePlannedMaintenanceDTO = {
alertIds: values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[],
alertIds:
values.alertRuleScope === 'all'
? []
: (values.alertRules
.map((alert) => alert.value)
.filter((alert) => alert !== undefined) as string[]),
name: values.name,
labelExpression: values.labelExpression,
schedule: {
startTime: new Date(
handleTimeConversion(
values.startTime,
timezoneInitialValue,
values.timezone,
shouldKeepLocalTime,
),
),
timezone: values.timezone as string,
@@ -162,7 +177,6 @@ export function PlannedDowntimeForm(
values.endTime,
timezoneInitialValue,
values.timezone,
shouldKeepLocalTime,
),
)
: undefined,
@@ -203,38 +217,24 @@ export function PlannedDowntimeForm(
],
);
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
const { recurrence } = values;
const recurrenceData =
values?.recurrence?.repeatType === recurrenceOptions.doesNotRepeat.value
!recurrence ||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
? undefined
: {
duration: values.recurrence?.duration
? `${values.recurrence?.duration}${durationUnit}`
duration: recurrence.duration
? `${recurrence.duration}${durationUnit}`
: undefined,
endTime: !isEmpty(values.endTime)
? handleTimeConversion(
values.endTime,
timezoneInitialValue,
values.timezone,
!isEditMode,
)
: undefined,
startTime: handleTimeConversion(
values.startTime,
timezoneInitialValue,
values.timezone,
!isEditMode,
),
repeatOn: !values.recurrence?.repeatOn?.length
? undefined
: values.recurrence?.repeatOn,
repeatType: values.recurrence?.repeatType,
repeatOn: recurrence.repeatOn?.length ? recurrence.repeatOn : undefined,
repeatType: recurrence.repeatType,
};
const payloadValues = {
...values,
recurrence: recurrenceData as RuletypesRecurrenceDTO | undefined,
};
await saveHanlder(payloadValues);
await saveHandler(payloadValues);
};
const formValidationRules = [
@@ -275,19 +275,20 @@ export function PlannedDowntimeForm(
};
const formatedInitialValues = useMemo(() => {
const initialAlertIds = initialValues.alertIds || [];
const isExistingSchedule = Boolean(initialValues.id);
const formData: PlannedDowntimeFormData = {
name: defaultTo(initialValues.name, ''),
alertRules: getAlertOptionsFromIds(
initialValues.alertIds || [],
alertOptions,
),
alertRuleScope:
isExistingSchedule && initialAlertIds.length === 0 ? 'all' : 'specific',
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
endTime: getEndTime(initialValues) ? dayjs(getEndTime(initialValues)) : '',
startTime: initialValues.schedule?.startTime
? dayjs(initialValues.schedule?.startTime)
: '',
recurrence: {
...initialValues.schedule?.recurrence,
repeatType: (!isScheduleRecurring(initialValues?.schedule)
repeatType: (!isScheduleRecurring(initialValues.schedule)
? recurrenceOptions.doesNotRepeat.value
: initialValues.schedule?.recurrence
?.repeatType) as RuletypesRecurrenceDTO['repeatType'],
@@ -297,12 +298,14 @@ export function PlannedDowntimeForm(
),
} as RuletypesRecurrenceDTO,
timezone: initialValues.schedule?.timezone as string,
labelExpression: initialValues.labelExpression || '',
};
return formData;
}, [initialValues, alertOptions]);
useEffect(() => {
setSelectedTags(formatedInitialValues.alertRules);
setAlertRuleScope(formatedInitialValues.alertRuleScope);
form.setFieldsValue({ ...formatedInitialValues });
}, [form, formatedInitialValues, initialValues]);
@@ -317,7 +320,6 @@ export function PlannedDowntimeForm(
const getTimezoneFormattedTime = (
time: string | dayjs.Dayjs,
timeZone?: string,
isEditMode?: boolean,
format?: string,
): string => {
if (!time) {
@@ -326,20 +328,11 @@ export function PlannedDowntimeForm(
if (!timeZone) {
return dayjs(time).format(format);
}
return dayjs(time).tz(timeZone, isEditMode).format(format);
return dayjs(time).tz(timeZone).format(format);
};
const startTimeText = useMemo((): string => {
let startTime = formData?.startTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
startTime =
(formData?.recurrence?.startTime
? dayjs(formData.recurrence.startTime).toISOString()
: '') ||
formData?.startTime ||
'';
}
let startTime = formData.startTime;
if (!startTime) {
return '';
}
@@ -349,7 +342,6 @@ export function PlannedDowntimeForm(
startTime,
timezoneInitialValue,
formData?.timezone,
!isEditMode,
);
}
const daysOfWeek = formData?.recurrence?.repeatOn;
@@ -357,21 +349,16 @@ export function PlannedDowntimeForm(
const formattedStartTime = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
TIME_FORMAT,
);
const formattedStartDate = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
DATE_FORMAT,
);
const ordinalFormat = getTimezoneFormattedTime(
startTime,
formData.timezone,
!isEditMode,
ORDINAL_FORMAT,
);
@@ -388,21 +375,10 @@ export function PlannedDowntimeForm(
default:
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
}
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
}, [formData, recurrenceType, timezoneInitialValue]);
const endTimeText = useMemo((): string => {
let endTime = formData?.endTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
endTime =
(formData?.recurrence?.endTime
? dayjs(formData.recurrence.endTime).toISOString()
: '') || '';
if (!isEditMode && !endTime) {
endTime = formData?.endTime || '';
}
}
if (!endTime) {
return '';
}
@@ -412,25 +388,21 @@ export function PlannedDowntimeForm(
endTime,
timezoneInitialValue,
formData?.timezone,
!isEditMode,
);
}
const formattedEndTime = getTimezoneFormattedTime(
endTime,
formData.timezone,
!isEditMode,
TIME_FORMAT,
);
const formattedEndDate = getTimezoneFormattedTime(
endTime,
formData.timezone,
!isEditMode,
DATE_FORMAT,
);
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
}, [formData, timezoneInitialValue]);
return (
<Modal
@@ -453,6 +425,9 @@ export function PlannedDowntimeForm(
onFinish={onFinish}
onValuesChange={(): void => {
setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string);
setAlertRuleScope(
form.getFieldValue('alertRuleScope') as AlertRuleScope,
);
setFormData(form.getFieldsValue());
}}
autoComplete="off"
@@ -465,7 +440,7 @@ export function PlannedDowntimeForm(
name="startTime"
rules={formValidationRules}
className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value): any => ({
getValueProps={(value) => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})}
>
@@ -546,7 +521,7 @@ export function PlannedDowntimeForm(
},
]}
className={!isEmpty(endTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value): any => ({
getValueProps={(value) => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})}
>
@@ -564,50 +539,99 @@ export function PlannedDowntimeForm(
<div className="scheduleTimeInfoText">{endTimeText}</div>
)}
<div>
<div className="alert-rule-form">
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
<Typography style={{ marginBottom: 8 }} className="alert-rule-info">
(Leave empty to silence all alerts)
</Typography>
</div>
<Form.Item noStyle shouldUpdate>
<AlertRuleTags
closable
selectedTags={selectedTags}
handleClose={handleClose}
/>
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
<Form.Item
name="alertRuleScope"
initialValue="specific"
className="alert-rule-scope"
>
<Radio.Group>
<Radio value="all">All alert rules</Radio>
<Radio value="specific">Specific alert rules</Radio>
</Radio.Group>
</Form.Item>
<Form.Item name={alertRuleFormName}>
<Select
placeholder="Search for alerts rules or groups..."
mode="multiple"
status={isError ? 'error' : undefined}
loading={isLoading}
tagRender={noTagRenderer}
onChange={handleChange}
showSearch
options={alertOptions}
filterOption={(input, option): boolean =>
(option?.label as string)?.toLowerCase()?.includes(input.toLowerCase())
}
notFoundContent={
isLoading ? (
<span>
<Spin size="small" /> Loading...
</span>
) : (
<span>No alert available.</span>
)
}
{alertRuleScope === 'specific' && (
<>
<Form.Item noStyle shouldUpdate>
<AlertRuleTags
closable
selectedTags={selectedTags}
handleClose={handleClose}
/>
</Form.Item>
<Form.Item
name={alertRuleFormName}
rules={[
{
validator: async (
_rule,
value: DefaultOptionType[] | undefined,
): Promise<void> => {
if (!value || value.length === 0) {
throw new Error(
'Select at least one alert rule, or choose "All alert rules" to silence everything.',
);
}
},
},
]}
>
<Select
placeholder="Search for alerts rules or groups..."
mode="multiple"
status={isError ? 'error' : undefined}
loading={isLoading}
tagRender={noTagRenderer}
onChange={handleChange}
showSearch
options={alertOptions}
filterOption={(input, option): boolean =>
(option?.label as string)?.toLowerCase()?.includes(input.toLowerCase())
}
notFoundContent={
isLoading ? (
<span>
<Spin size="small" /> Loading...
</span>
) : (
<span>No alert available.</span>
)
}
>
{alertOptions?.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
</>
)}
{alertRuleScope === 'all' && (
<Typography
className="alert-rule-info alert-rule-all-warning"
style={{ marginBottom: 16 }}
>
{alertOptions?.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
All alerts will be silenced for the duration of this maintenance window.
</Typography>
)}
</div>
<Form.Item
label={
<span>
Label Expression&nbsp;
<Tooltip title='Filter by alert labels. Examples: env == "prod", region == "us-east-1" && severity == "critical"'>
<Info size={13} />
</Tooltip>
</span>
}
name="labelExpression"
>
<Input.TextArea
placeholder='e.g. env == "prod" && region == "us-east-1"'
autoSize={{ minRows: 2, maxRows: 4 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<ModalButtonWrapper>
<Button

View File

@@ -1,4 +1,4 @@
import { ReactNode, useEffect } from 'react';
import React, { ReactNode, useEffect } from 'react';
import { UseQueryResult } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Collapse, Flex, Space, Table, TableProps, Tag, Tooltip } from 'antd';
@@ -18,8 +18,9 @@ import { defaultTo } from 'lodash-es';
import { CalendarClock, PenLine, Trash2 } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { showErrorNotification } from 'utils/error';
import { DeepPartial } from 'utils/types';
import { showErrorNotification } from '../../utils/error';
import {
formatDateTime,
getAlertOptionsFromIds,
@@ -27,7 +28,6 @@ import {
getEndTime,
recurrenceInfo,
} from './PlannedDowntimeutils';
import './PlannedDowntime.styles.scss';
const { Panel } = Collapse;
@@ -136,7 +136,7 @@ export function CollapseListContent({
created_at?: string;
created_by_name?: string;
created_by_email?: string;
timeframe: [string | undefined | null, string | undefined | null];
timeframe: [string | undefined, string | undefined];
repeats?: RuletypesRecurrenceDTO | null;
updated_at?: string;
updated_by_name?: string;
@@ -192,7 +192,12 @@ export function CollapseListContent({
),
)}
{renderItems('Timezone', <Typography>{timezone || '-'}</Typography>)}
{renderItems('Repeats', <Typography>{recurrenceInfo(repeats)}</Typography>)}
{renderItems(
'Repeats',
<Typography>
{recurrenceInfo(timeframe[0], timeframe[1], repeats)}
</Typography>,
)}
{renderItems(
'Alerts silenced',
alertOptions?.length ? (
@@ -202,7 +207,7 @@ export function CollapseListContent({
selectedTags={alertOptions}
/>
) : (
'-'
<Tag className="all-alerts-tag">All alert rules</Tag>
),
)}
</Flex>
@@ -212,7 +217,7 @@ export function CollapseListContent({
export function CustomCollapseList(
props: DowntimeSchedulesTableData & {
setInitialValues: React.Dispatch<
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
React.SetStateAction<DeepPartial<RuletypesPlannedMaintenanceDTO>>
>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteDowntime: (id: string, name: string) => void;
@@ -283,9 +288,7 @@ export function CustomCollapseList(
schedule?.startTime?.toString(),
typeof endTime === 'string' ? endTime : endTime?.toString(),
]}
repeats={
schedule?.recurrence as RuletypesRecurrenceDTO | null | undefined
}
repeats={schedule?.recurrence}
updated_at={updatedAt ? dayjs(updatedAt).toISOString() : ''}
updated_by_name={defaultTo(updatedBy, '')}
alertOptions={alertOptions}
@@ -320,7 +323,7 @@ export function PlannedDowntimeList({
>;
alertOptions: DefaultOptionType[];
setInitialValues: React.Dispatch<
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
React.SetStateAction<DeepPartial<RuletypesPlannedMaintenanceDTO>>
>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteDowntime: (id: string, name: string) => void;

View File

@@ -2,7 +2,7 @@ import { UseMutateAsyncFunction } from 'react-query';
import type { NotificationInstance } from 'antd/es/notification/interface';
import type { DefaultOptionType } from 'antd/es/select';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import type {
import {
DeleteDowntimeScheduleByIDPathParameters,
RenderErrorResponseDTO,
RuletypesPlannedMaintenanceDTO,
@@ -14,6 +14,7 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { isEmpty, isEqual } from 'lodash-es';
import APIError from 'types/api/error';
import { DeepPartial } from 'utils/types';
type DateTimeString = string | null | undefined;
@@ -60,13 +61,15 @@ export const getAlertOptionsFromIds = (
);
export const recurrenceInfo = (
startTime?: string,
endTime?: string,
recurrence?: RuletypesRecurrenceDTO | null,
): string => {
if (!recurrence) {
return 'No';
}
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
const { duration, repeatOn, repeatType } = recurrence;
const formattedStartTime = startTime
? formatDateTime(dayjs(startTime).toISOString())
@@ -80,7 +83,7 @@ export const recurrenceInfo = (
return `Repeats - ${repeatType} ${weeklyRepeatString} from ${formattedStartTime} ${formattedEndTime} ${durationString}`;
};
export const defautlInitialValues: Partial<
export const defaultInitialValues: DeepPartial<
RuletypesPlannedMaintenanceDTO & { editMode: boolean }
> = {
name: '',
@@ -210,39 +213,17 @@ export const recurrenceOptionWithSubmenu: Option[] = [
recurrenceOptions.monthly,
];
export const getRecurrenceOptionFromValue = (
value?: string | Option | null,
): Option | null | undefined => {
if (!value) {
return null;
}
if (typeof value === 'string') {
return Object.values(recurrenceOptions).find(
(option) => option.value === value,
);
}
return value;
};
export const getEndTime = ({
kind,
schedule,
}: Partial<
}: DeepPartial<
RuletypesPlannedMaintenanceDTO & {
editMode: boolean;
}
>): string | dayjs.Dayjs => {
if (kind === 'fixed') {
return schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
}
return schedule?.recurrence?.endTime
? dayjs(schedule.recurrence.endTime).toISOString()
: '';
};
>): string | dayjs.Dayjs =>
schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
export const isScheduleRecurring = (
schedule?: RuletypesPlannedMaintenanceDTO['schedule'] | null,
schedule?: DeepPartial<RuletypesPlannedMaintenanceDTO['schedule']> | null,
): boolean => (schedule ? !isEmpty(schedule?.recurrence) : false);
function convertUtcOffsetToTimezoneOffset(offsetMinutes: number): string {
@@ -272,7 +253,6 @@ export function handleTimeConversion(
dateValue: string | dayjs.Dayjs,
timezoneInit?: string,
timezone?: string,
shouldKeepLocalTime?: boolean,
): string {
const timezoneChanged = !isEqual(timezoneInit, timezone);
const initialTime = dayjs(dateValue).tz(timezoneInit);
@@ -280,5 +260,5 @@ export function handleTimeConversion(
const formattedTime = formatWithTimezone(initialTime, timezone);
return timezoneChanged
? formattedTime
: dayjs(dateValue).tz(timezone, shouldKeepLocalTime).format();
: dayjs(dateValue).tz(timezone).format();
}

View File

@@ -8,7 +8,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
export const buildSchedule = (
schedule: Partial<RuletypesScheduleDTO>,
schedule: RuletypesScheduleDTO,
): RuletypesScheduleDTO => ({
timezone: schedule?.timezone ?? '',
startTime: schedule?.startTime,
@@ -17,16 +17,13 @@ export const buildSchedule = (
});
export const createMockDowntime = (
overrides: Partial<RuletypesPlannedMaintenanceDTO>,
overrides: Partial<RuletypesPlannedMaintenanceDTO> &
Pick<RuletypesPlannedMaintenanceDTO, 'schedule'>,
): RuletypesPlannedMaintenanceDTO => ({
id: overrides.id ?? '0',
name: overrides.name ?? '',
description: overrides.description ?? '',
schedule: buildSchedule({
timezone: 'UTC',
startTime: new Date('2024-01-01'),
...overrides.schedule,
}),
schedule: overrides.schedule,
alertIds: overrides.alertIds ?? [],
createdAt: overrides.createdAt,
createdBy: overrides.createdBy ?? '',

View File

@@ -218,7 +218,7 @@ function SpanLogs({
<Virtuoso
className="span-logs-virtuoso"
key="span-logs-virtuoso"
style={{ height: '100%' }}
style={logs.length <= 35 ? { height: `calc(${logs.length} * 22px)` } : {}}
data={logs}
totalCount={logs.length}
itemContent={getItemContent}

View File

@@ -1,15 +1,12 @@
.span-logs {
margin-inline: 16px;
height: 100%;
display: flex;
flex-direction: column;
height: calc(100% - 64px - 55px - 56px);
&-virtuoso {
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
}
&-list-container {
flex: 1;
min-height: 0;
height: 100%;
.logs-loading-skeleton {
height: 100%;

View File

@@ -68,10 +68,6 @@
border-left: unset;
border-radius: 0px 4px 4px 0px;
}
.new-view-btn {
margin-left: 8px;
}
}
.second-row {

View File

@@ -1,12 +1,8 @@
import { useMemo } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom';
import { Skeleton, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Button, Skeleton, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import removeLocalStorageKey from 'api/browser/localstorage/remove';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import dayjs from 'dayjs';
import history from 'lib/history';
@@ -64,51 +60,18 @@ function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
}
};
const isOnOldRoute = !!useRouteMatch({
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
});
const location = useLocation();
const handleSwitchToNewView = (): void => {
removeLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW);
history.replace({
pathname: `/trace/${traceID}`,
search: location.search,
hash: location.hash,
state: location.state,
});
};
return (
<div className="trace-metadata">
<section className="metadata-info">
<div className="first-row">
<Button
variant="solid"
color="secondary"
size="icon"
className="previous-btn"
prefix={<ArrowLeft size={14} />}
onClick={handlePreviousBtnClick}
/>
<Button className="previous-btn" onClick={handlePreviousBtnClick}>
<ArrowLeft size={14} />
</Button>
<div className="trace-name">
<DraftingCompass size={14} className="drafting" />
<Typography.Text className="trace-id">Trace ID</Typography.Text>
</div>
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
{isOnOldRoute && (
<Button
variant="solid"
color="primary"
size="md"
className="new-view-btn"
onClick={handleSwitchToNewView}
>
New view
</Button>
)}
</div>
{isDataLoading && (

View File

@@ -1,5 +1,14 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import setLocalStorageApi from 'api/browser/localstorage/set';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import { LOCALSTORAGE } from 'constants/localStorage';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import { useLocalStorage } from '../useLocalStorage';
@@ -10,25 +19,59 @@ interface UsePinnedAttributesReturn {
}
/**
* V2 trace-details pinned-attributes hook. localStorage-only.
* Hook for managing pinned span attributes with backend persistence.
* Falls back to localStorage during initial load and handles migration.
*
* NOTE: V2 used to also persist to the user-pref API
* (`span_details_pinned_attributes`) but V3 now owns that key with a different
* (nested-path) format. V2 is isolated to localStorage so it doesn't fight V3
* over the same backend value. See `useMigratePinnedAttributes` in V3.
* @param availableAttributes - Object keys of the current span's flattened attributes
* @returns Object with pinned state, toggle function, and check function
*/
export function usePinnedAttributes(
availableAttributes: string[],
): UsePinnedAttributesReturn {
const [pinnedKeys, setPinnedKeys] = useLocalStorage<string[]>(
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { notifications } = useNotifications();
// API mutation for updating preferences
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
{
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
// Local state for optimistic updates
const [pinnedKeys, setPinnedKeys] = useState<string[]>([]);
// Get localStorage fallback for initial load
const [localStoragePinnedKeys] = useLocalStorage<string[]>(
LOCALSTORAGE.SPAN_DETAILS_PINNED_ATTRIBUTES,
[],
);
// Initialize from user preferences when loaded
useEffect(() => {
if (userPreferences !== null) {
const preference = userPreferences.find(
(pref) => pref.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
);
if (preference?.value) {
// use backend data
setPinnedKeys(preference.value as string[]);
} else if (localStoragePinnedKeys.length > 0) {
// use local storage data
setPinnedKeys(localStoragePinnedKeys);
}
}
}, [userPreferences, localStoragePinnedKeys]);
// Create pinned attributes state from stored keys, filtering by available attributes
const pinnedAttributes = useMemo(
(): Record<string, boolean> =>
pinnedKeys.reduce(
(acc, key) => {
// Only include if the attribute exists in the current span
if (availableAttributes.includes(key)) {
acc[key] = true;
}
@@ -39,17 +82,38 @@ export function usePinnedAttributes(
[pinnedKeys, availableAttributes],
);
// Toggle pin state for an attribute
const togglePin = useCallback(
(attributeKey: string): void => {
setPinnedKeys((prev) =>
prev.includes(attributeKey)
? prev.filter((k) => k !== attributeKey)
: [...prev, attributeKey],
const currentlyPinned = pinnedKeys.includes(attributeKey);
const newPinnedKeys = currentlyPinned
? pinnedKeys.filter((key) => key !== attributeKey)
: [...pinnedKeys, attributeKey];
// Optimistically update local state for instant UI feedback
setPinnedKeys(newPinnedKeys);
updateUserPreferenceInContext({
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
value: newPinnedKeys,
} as UserPreference);
// Save to localStorage immediately for offline resilience
setLocalStorageApi(
LOCALSTORAGE.SPAN_DETAILS_PINNED_ATTRIBUTES,
JSON.stringify(newPinnedKeys),
);
// Make the API call in the background
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
value: newPinnedKeys,
});
},
[setPinnedKeys],
[pinnedKeys, updateUserPreferenceInContext, updateUserPreferenceMutation],
);
// Check if an attribute is pinned
const isPinned = useCallback(
(attributeKey: string): boolean => pinnedAttributes[attributeKey] === true,
[pinnedAttributes],

View File

@@ -12,11 +12,8 @@ const useGetTraceFlamegraph = (
): UseLicense =>
useQuery({
queryFn: () => getTraceFlamegraph(props),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
props.traceId,
props.selectFields,
],
// if any of the props changes then we need to trigger an API call as the older data will be obsolete
queryKey: [REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH, props.traceId],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,

View File

@@ -15,7 +15,6 @@ const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
props.traceId,
props.selectedSpanId,
props.isSelectedSpanIDUnCollapsed,
props.aggregations,
],
enabled: !!props.traceId,
keepPreviousData: true,

View File

@@ -1,25 +1,5 @@
import { Redirect, useParams } from 'react-router-dom';
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import TraceDetailsV3 from '../TraceDetailsV3';
export default function TraceDetailV3Page(): JSX.Element {
const { id } = useParams<TraceDetailV2URLProps>();
const preferOld =
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true';
if (preferOld) {
return (
<Redirect
to={{
pathname: `/trace-old/${id}`,
search: window.location.search,
}}
/>
);
}
return <TraceDetailsV3 />;
}

View File

@@ -10,14 +10,16 @@ import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import { useTraceContext } from '../../contexts/TraceContext';
import { AGGREGATIONS } from '../../utils/aggregations';
import './AnalyticsPanel.styles.scss';
interface AnalyticsPanelProps {
isOpen: boolean;
onClose: () => void;
serviceExecTime?: Record<string, number>;
traceStartTime?: number;
traceEndTime?: number;
// TODO: Re-enable when backend provides per-service span counts
// spans?: Span[];
}
const PANEL_WIDTH = 350;
@@ -28,46 +30,41 @@ const PANEL_MARGIN_BOTTOM = 50;
function AnalyticsPanel({
isOpen,
onClose,
serviceExecTime = {},
traceStartTime = 0,
traceEndTime = 0,
}: AnalyticsPanelProps): JSX.Element | null {
const { getAggregationMap } = useTraceContext();
const execTimePct = useMemo(
() => getAggregationMap(AGGREGATIONS.EXEC_TIME_PCT),
[getAggregationMap],
);
const spanCounts = useMemo(
() => getAggregationMap(AGGREGATIONS.SPAN_COUNT),
[getAggregationMap],
);
const spread = traceEndTime - traceStartTime;
const execTimeRows = useMemo(() => {
if (!execTimePct) {
if (spread <= 0) {
return [];
}
return Object.entries(execTimePct)
.map(([group, percentage]) => ({
group,
percentage,
color: generateColor(group, themeColors.traceDetailColorsV3),
return Object.entries(serviceExecTime)
.map(([service, duration]) => ({
service,
percentage: (duration * 100) / spread,
color: generateColor(service, themeColors.traceDetailColorsV3),
}))
.sort((a, b) => b.percentage - a.percentage);
}, [execTimePct]);
}, [serviceExecTime, spread]);
const spanCountRows = useMemo(() => {
if (!spanCounts) {
return [];
}
const max = Math.max(...Object.values(spanCounts), 1);
return Object.entries(spanCounts)
.map(([group, count]) => ({
group,
count,
max,
color: generateColor(group, themeColors.traceDetailColorsV3),
}))
.sort((a, b) => b.count - a.count);
}, [spanCounts]);
// const spanCountRows = useMemo(() => {
// const counts: Record<string, number> = {};
// for (const span of spans) {
// const name = span.serviceName || 'unknown';
// counts[name] = (counts[name] || 0) + 1;
// }
// return Object.entries(counts)
// .map(([service, count]) => ({
// service,
// count,
// color: generateColor(service, themeColors.traceDetailColorsV3),
// }))
// .sort((a, b) => b.count - a.count);
// }, [spans]);
// const maxSpanCount = spanCountRows[0]?.count || 1;
if (!isOpen) {
return null;
@@ -106,9 +103,11 @@ function AnalyticsPanel({
<TabsTrigger value="exec-time" variant="secondary">
% exec time
</TabsTrigger>
{/* TODO: Enable when backend provides per-service span counts
<TabsTrigger value="spans" variant="secondary">
Spans
</TabsTrigger>
*/}
</TabsList>
<div className="analytics-panel__tabs-scroll">
@@ -117,17 +116,17 @@ function AnalyticsPanel({
{execTimeRows.map((row) => (
<>
<div
key={`${row.group}-dot`}
key={`${row.service}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.group}-name`}
key={`${row.service}-name`}
className="analytics-panel__service-name"
>
{row.group}
{row.service}
</span>
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
@@ -146,27 +145,28 @@ function AnalyticsPanel({
</div>
</TabsContent>
{/* TODO: Enable when backend provides per-service span counts
<TabsContent value="spans">
<div className="analytics-panel__list">
{spanCountRows.map((row) => (
<>
<div
key={`${row.group}-dot`}
key={`${row.service}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.group}-name`}
key={`${row.service}-name`}
className="analytics-panel__service-name"
>
{row.group}
{row.service}
</span>
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
style={{
width: `${(row.count / row.max) * 100}%`,
width: `${(row.count / maxSpanCount) * 100}%`,
backgroundColor: row.color,
}}
/>
@@ -179,6 +179,7 @@ function AnalyticsPanel({
))}
</div>
</TabsContent>
*/}
</div>
</TabsRoot>
</div>

View File

@@ -64,13 +64,9 @@
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
display: flex;
flex-direction: column;
[role='tabpanel'] {
padding: 0;
flex: 1;
min-height: 0;
}
}
@@ -116,8 +112,7 @@
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
background: var(--success-500);
}
&__trace-id {
@@ -128,17 +123,6 @@
display: block;
}
&__trace-id-copy {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
height: auto;
justify-content: flex-start;
}
&__key-attributes {
display: flex;
flex-direction: column;

View File

@@ -41,12 +41,7 @@ import Events from 'container/SpanDetailsDrawer/Events/Events';
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
import dayjs from 'dayjs';
import { useMigratePinnedAttributes } from 'pages/TraceDetailsV3/hooks/useMigratePinnedAttributes';
import {
getSpanAttribute,
getSpanDisplayData,
hasInfraMetadata,
} from 'pages/TraceDetailsV3/utils';
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
import { DataViewer } from 'periscope/components/DataViewer';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
@@ -64,7 +59,6 @@ import {
VISIBLE_ACTIONS,
} from './constants';
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
import {
LinkedSpansPanel,
LinkedSpansToggle,
@@ -83,6 +77,7 @@ interface SpanDetailsPanelProps {
onVariantChange?: (variant: SpanDetailVariant) => void;
traceStartTime?: number;
traceEndTime?: number;
serviceExecTime?: Record<string, number>;
}
function SpanDetailsContent({
@@ -99,17 +94,6 @@ function SpanDetailsContent({
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
// One-time conversion of any V2-format value still living in the
// `span_details_pinned_attributes` user pref into V3 nested-path format.
useMigratePinnedAttributes(selectedSpan);
const { value: pinnedFieldsValue, onChange: onPinnedFieldsChange } =
useTracePinnedFields();
const spanDisplayData = useMemo(
() => getSpanDisplayData(selectedSpan),
[selectedSpan],
);
// Map span attribute actions to PrettyView actions format.
// Use the last key in fieldKeyPath (the actual attribute key), not the full display path.
const prettyViewCustomActions = useMemo(
@@ -407,14 +391,12 @@ function SpanDetailsContent({
<div className="span-details-panel__tabs-scroll">
<TabsContent value="overview">
<DataViewer
data={spanDisplayData}
data={selectedSpan}
drawerKey="trace-details"
prettyViewProps={{
showPinned: true,
actions: prettyViewCustomActions,
visibleActions: VISIBLE_ACTIONS,
pinnedFieldsValue,
onPinnedFieldsChange,
}}
/>
</TabsContent>
@@ -469,6 +451,7 @@ function SpanDetailsPanel({
onVariantChange,
traceStartTime,
traceEndTime,
serviceExecTime,
}: SpanDetailsPanelProps): JSX.Element {
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
@@ -575,6 +558,9 @@ function SpanDetailsPanel({
<AnalyticsPanel
isOpen={isAnalyticsOpen}
onClose={(): void => setIsAnalyticsOpen(false)}
serviceExecTime={serviceExecTime}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
);
@@ -608,7 +594,6 @@ function SpanDetailsPanel({
isOpen={panelState.isOpen}
className="span-details-panel"
width={PANEL_WIDTH}
minWidth={480}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
defaultPosition={{
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,

View File

@@ -1,59 +0,0 @@
import { Link, useRouteMatch } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import ROUTES from 'constants/routes';
import { SpanV3 } from 'types/api/trace/getTraceV3';
interface TraceIdFieldProps {
span: SpanV3;
}
/**
* Renders a span's trace id. When the user is already on the trace detail
* page for this trace, clicking the id copies it to the clipboard (the
* "navigate" affordance would be a no-op). Otherwise, falls back to the
* existing link to the trace detail page.
*/
export function TraceIdField({ span }: TraceIdFieldProps): JSX.Element {
const match = useRouteMatch<{ id: string }>({
path: ROUTES.TRACE_DETAIL,
exact: true,
});
const [, setCopy] = useCopyToClipboard();
const isCurrentTrace = match?.params.id === span.trace_id;
if (isCurrentTrace) {
const handleCopy = (): void => {
setCopy(span.trace_id);
toast.success('Trace ID copied to clipboard', {
position: 'top-right',
});
};
return (
<Button
variant="link"
color="secondary"
className="span-details-panel__trace-id-copy"
onClick={handleCopy}
title="Click to copy trace ID"
>
{span.trace_id}
</Button>
);
}
return (
<Link
to={{
pathname: `/trace/${span.trace_id}`,
search: window.location.search,
}}
className="span-details-panel__trace-id"
>
{span.trace_id}
</Link>
);
}

View File

@@ -1,9 +1,8 @@
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { Badge } from '@signozhq/ui/badge';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceIdField } from './TraceIdField';
interface HighlightedOption {
key: string;
label: string;
@@ -34,7 +33,17 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
key: 'traceId',
label: 'TRACE ID',
render: (span): ReactNode | null =>
span.trace_id ? <TraceIdField span={span} /> : null,
span.trace_id ? (
<Link
to={{
pathname: `/trace/${span.trace_id}`,
search: window.location.search,
}}
className="span-details-panel__trace-id"
>
{span.trace_id}
</Link>
) : null,
},
{
key: 'spanKind',
@@ -42,12 +51,4 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
render: (span): ReactNode | null =>
span.kind_string ? <Badge color="vanilla">{span.kind_string}</Badge> : null,
},
{
key: 'statusMessage',
label: 'STATUS MESSAGE',
render: (span): ReactNode | null =>
span.status_message ? (
<Badge color="vanilla">{span.status_message}</Badge>
) : null,
},
];

View File

@@ -1,51 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useMutation } from 'react-query';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { isV3PinnedAttribute } from 'pages/TraceDetailsV3/utils';
import { useAppContext } from 'providers/App/App';
interface UseTracePinnedFieldsReturn {
value: string[];
onChange: (next: string[]) => void;
}
/**
* Reads/writes V3 trace-details pinned attributes from the user-preference
* `span_details_pinned_attributes` (cross-device sync). Drops legacy V2-format
* entries from the rendered set so PrettyView never tries to render an
* un-parseable path while the migration is in flight.
*
* Migration of V2 → V3 format is handled separately by
* `useMigratePinnedAttributes`.
*/
export function useTracePinnedFields(): UseTracePinnedFieldsReturn {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate } = useMutation(updateUserPreferenceAPI);
const value = useMemo<string[]>(() => {
const pref = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
);
const arr = (pref?.value as string[] | undefined) ?? [];
return arr.filter(isV3PinnedAttribute);
}, [userPreferences]);
const onChange = useCallback(
(next: string[]) => {
const existing = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
);
if (existing) {
updateUserPreferenceInContext({ ...existing, value: next });
}
mutate({
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
value: next,
});
},
[userPreferences, updateUserPreferenceInContext, mutate],
);
return { value, onChange };
}

View File

@@ -29,7 +29,7 @@ export function EventTooltipContent({
{eventName}
</div>
<div className="event-tooltip-content__time">
{toFixed(time, 2)} {timeUnitName} since span start
{toFixed(time, 2)} {timeUnitName} from start
</div>
{Object.keys(attributeMap).length > 0 && (
<>

View File

@@ -1,23 +1,26 @@
// Invisible 1 px anchor mounted inside the scrollable waterfall body. Its
// `top` is updated by the parent to track the hovered row's Y; its `left`
// is the sidebar/timeline boundary so the popover always opens at the same
// X regardless of which row is hovered.
.span-hover-card-anchor {
position: absolute;
width: 1px;
pointer-events: none;
.span-hover-card-wrapper {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
}
.span-hover-card-popover {
// Hover card may be rendered while the SpanDetailsPanel is docked as
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
// Event-dot tooltip is rendered while the SpanDetailsPanel may be docked as
// a FloatingPanel (z-index 999); bump above the default tooltip z-index 50.
--tooltip-z-index: 1000;
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
color: var(--l1-foreground);
.ant-popover-inner {
background-color: var(--l1-background);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 1px solid var(--l2-border);
}
.ant-popover-inner-content {
padding: 0;
}
}
// Flamegraph tooltip — rendered as a portal, uses same semantic tokens.

View File

@@ -1,33 +1,16 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { memo, ReactNode, useCallback, useRef, useState } from 'react';
import { Popover } from 'antd';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { useMemo } from 'react';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
/**
* Span-level fields that the tooltip always shows (as the colored title or
* one of the status/start/duration rows). Preview rows for these keys are
* filtered out to avoid duplication.
*/
export const RESERVED_PREVIEW_KEYS: ReadonlySet<string> = new Set([
'name',
'has_error',
'timestamp',
'duration_nano',
]);
export interface SpanPreviewRow {
key: string;
value: string;
interface ITraceMetadata {
startTime: number;
endTime: number;
}
export interface SpanTooltipContentProps {
@@ -36,7 +19,6 @@ export interface SpanTooltipContentProps {
hasError: boolean;
relativeStartMs: number;
durationMs: number;
previewRows?: SpanPreviewRow[];
}
export function SpanTooltipContent({
@@ -45,7 +27,6 @@ export function SpanTooltipContent({
hasError,
relativeStartMs,
durationMs,
previewRows,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
@@ -56,118 +37,104 @@ export function SpanTooltipContent({
{spanName}
</div>
<div className="span-hover-card-content__row">
status: {hasError ? 'error' : 'ok'}
Status: {hasError ? 'error' : 'ok'}
</div>
<div className="span-hover-card-content__row">
start: {toFixed(relativeStartMs, 2)} ms
Start: {toFixed(relativeStartMs, 2)} ms
</div>
<div className="span-hover-card-content__row">
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
Duration: {toFixed(formattedDuration, 2)} {timeUnitName}
</div>
{previewRows && previewRows.length > 0 && (
<div className="span-hover-card-content__preview">
{previewRows.map((row) => (
<div key={row.key} className="span-hover-card-content__row">
<span className="span-hover-card-content__preview-key">{row.key}:</span>{' '}
<span className="span-hover-card-content__preview-value">
{row.value}
</span>
</div>
))}
</div>
)}
</div>
);
}
/**
* Single hover card anchored at a fixed X (sidebar/timeline boundary). The
* Y of the anchor is derived from the hovered span's index in the list,
* so the card slides vertically in place rather than jumping with the cursor.
*
* Mount this inside the scrollable waterfall body so `anchorTop` is in
* content coordinates — Radix portals the content layer out automatically.
*/
export interface SpanHoverCardProps {
hoveredSpanId: string | null;
onOpenChange: (open: boolean) => void;
anchorLeft: number;
rowHeight: number;
spans: SpanV3[];
traceStartTime: number;
interface SpanHoverCardProps {
span: SpanV3;
traceMetadata: ITraceMetadata;
children: ReactNode;
}
export function SpanHoverCard({
hoveredSpanId,
onOpenChange,
anchorLeft,
rowHeight,
spans,
traceStartTime,
/**
* Lazy hover card — only mounts the expensive antd Popover when the user
* actually hovers over the element (after a short delay). During fast scrolling,
* rows mount and unmount without ever creating a Popover instance, avoiding
* expensive DOM/effect overhead from antd Tooltip/Trigger internals.
*/
const SpanHoverCard = memo(function SpanHoverCard({
span,
traceMetadata,
children,
}: SpanHoverCardProps): JSX.Element {
const { previewFields, resolveSpanColor } = useTraceContext();
const [showPopover, setShowPopover] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hoverCardData = useMemo(() => {
if (!hoveredSpanId) {
return null;
}
const idx = spans.findIndex((s) => s.span_id === hoveredSpanId);
if (idx === -1) {
return null;
}
const span = spans[idx];
const previewRows: SpanPreviewRow[] = previewFields
.filter((f) => !RESERVED_PREVIEW_KEYS.has(f.key))
.map((f) => {
const value = getSpanAttribute(span, f.key);
return value !== undefined && value !== ''
? { key: f.key, value: String(value) }
: null;
})
.filter((r): r is SpanPreviewRow => r !== null);
const handleMouseEnter = useCallback((): void => {
timerRef.current = setTimeout(() => {
setShowPopover(true);
}, 200);
}, []);
return {
anchorTop: idx * rowHeight,
tooltip: {
spanName: span.name,
color: resolveSpanColor(span),
hasError: span.has_error,
relativeStartMs: span.timestamp - traceStartTime,
durationMs: span.duration_nano / 1e6,
previewRows,
},
};
}, [
hoveredSpanId,
spans,
previewFields,
resolveSpanColor,
rowHeight,
traceStartTime,
]);
const handleMouseLeave = useCallback((): void => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
setShowPopover(false);
}, []);
if (!showPopover) {
return (
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
<span
className="span-hover-card-wrapper"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
</span>
);
}
const durationMs = span.duration_nano / 1e6;
const relativeStartMs = span.timestamp - traceMetadata.startTime;
let color = generateColor(
span['service.name'],
themeColors.traceDetailColorsV3,
);
if (span.has_error) {
color = 'var(--bg-cherry-500)';
}
return (
<TooltipProvider>
<Tooltip open={hoverCardData !== null} onOpenChange={onOpenChange}>
<TooltipTrigger asChild>
<div
className="span-hover-card-anchor"
style={{
top: hoverCardData?.anchorTop ?? 0,
left: anchorLeft,
height: rowHeight,
}}
/>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
sideOffset={8}
className="span-hover-card-popover"
>
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Popover
open
content={
<SpanTooltipContent
spanName={span.name}
color={color}
hasError={span.has_error}
relativeStartMs={relativeStartMs}
durationMs={durationMs}
/>
}
trigger="hover"
rootClassName="span-hover-card-popover"
autoAdjustOverflow
arrow={false}
onOpenChange={(open): void => {
if (!open) {
setShowPopover(false);
}
}}
>
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
<span className="span-hover-card-wrapper" onMouseLeave={handleMouseLeave}>
{children}
</span>
</Popover>
);
}
});
export default SpanHoverCard;

View File

@@ -1,8 +1,3 @@
.trace-details-header-wrapper {
flex-shrink: 0;
position: relative;
}
.trace-details-header {
display: flex;
align-items: center;
@@ -21,53 +16,13 @@
&.trace-v3-filter-row {
padding: 0;
}
max-width: 850px;
flex: 1;
min-width: 0;
&:not(&--expanded) {
margin-left: auto;
}
&--expanded {
max-width: none;
flex: 1;
}
}
&__old-view-btn {
margin-left: auto;
flex-shrink: 0;
}
&__sub-header {
display: flex;
align-items: center;
gap: 16px;
padding: 4px 16px 8px;
font-size: 13px;
color: var(--l2-foreground);
}
&__sub-item {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
&__separator {
color: var(--l2-foreground);
opacity: 0.5;
}
&__entry-point-badge {
padding: 2px 8px;
border: 1px solid var(--l2-border);
border-radius: 4px;
font-size: 12px;
}
&__skeleton .ant-skeleton-input {
width: 160px !important;
min-height: 20px !important;
height: 20px !important;
}
}

View File

@@ -1,27 +1,15 @@
import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Skeleton } from 'antd';
import setLocalStorageKey from 'api/browser/localstorage/set';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
import history from 'lib/history';
import { ArrowLeft, CalendarClock, Server, Timer } from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import { ArrowLeft } from '@signozhq/icons';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import { DataSource } from 'types/common/queryBuilder';
import FieldsSettings from '../components/FieldsSettings/FieldsSettings';
import { useTraceContext } from '../contexts/TraceContext';
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
import TraceOptionsMenu from './TraceOptionsMenu';
import './TraceDetailsHeader.styles.scss';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
interface FilterMetadata {
startTime: number;
@@ -29,53 +17,20 @@ interface FilterMetadata {
traceId: string;
}
export interface TraceMetadataForHeader {
startTimestampMillis: number;
endTimestampMillis: number;
rootServiceName: string;
rootServiceEntryPoint: string;
rootSpanStatusCode: string;
}
interface TraceDetailsHeaderProps {
filterMetadata: FilterMetadata;
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
isDataLoaded?: boolean;
traceMetadata?: TraceMetadataForHeader;
}
const SKELETON_COUNT = 3;
function DetailsLoader(): JSX.Element {
return (
<>
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
<Skeleton.Input
// eslint-disable-next-line react/no-array-index-key
key={i}
active
size="small"
className="trace-details-header__skeleton"
/>
))}
</>
);
noData?: boolean;
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
isDataLoaded,
traceMetadata,
noData,
}: TraceDetailsHeaderProps): JSX.Element {
const { id: traceID } = useParams<TraceDetailV2URLProps>();
const [showTraceDetails, setShowTraceDetails] = useState(true);
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
const { previewFields, setPreviewFields } = useTraceContext();
const handleSwitchToOldView = useCallback((): void => {
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW, 'true');
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
history.replace(oldUrl);
}, [traceID]);
@@ -94,127 +49,42 @@ function TraceDetailsHeader({
}
}, []);
const handleToggleTraceDetails = useCallback((): void => {
setShowTraceDetails((prev) => !prev);
}, []);
const durationMs = traceMetadata
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
: 0;
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
return (
<div className="trace-details-header-wrapper">
<div className="trace-details-header">
{!isFilterExpanded && (
<>
<Button
variant="solid"
color="secondary"
size="md"
className="trace-details-header__back-btn"
onClick={handlePreviousBtnClick}
>
<ArrowLeft size={14} />
</Button>
<KeyValueLabel
badgeKey="Trace ID"
badgeValue={traceID || ''}
maxCharacters={100}
<div className="trace-details-header">
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__back-btn"
onClick={handlePreviousBtnClick}
>
<ArrowLeft size={14} />
</Button>
<KeyValueLabel
badgeKey="Trace ID"
badgeValue={traceID || ''}
maxCharacters={100}
/>
{!noData && (
<>
<div className="trace-details-header__filter">
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
/>
</>
)}
{isDataLoaded && (
<>
<div
className={`trace-details-header__filter${
isFilterExpanded ? ' trace-details-header__filter--expanded' : ''
}`}
>
<Filters
startTime={filterMetadata.startTime}
endTime={filterMetadata.endTime}
traceID={filterMetadata.traceId}
onFilteredSpansChange={onFilteredSpansChange}
isExpanded={isFilterExpanded}
onExpand={(): void => setIsFilterExpanded(true)}
onCollapse={(): void => setIsFilterExpanded(false)}
/>
</div>
{!isFilterExpanded && (
<>
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
<TraceOptionsMenu
showTraceDetails={showTraceDetails}
onToggleTraceDetails={handleToggleTraceDetails}
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
/>
</>
)}
</>
)}
</div>
{showTraceDetails && (
<div className="trace-details-header__sub-header">
{traceMetadata ? (
<>
<span className="trace-details-header__sub-item">
<Server size={13} />
{traceMetadata.rootServiceName}
<span className="trace-details-header__separator"></span>
<span className="trace-details-header__entry-point-badge">
{traceMetadata.rootServiceEntryPoint}
</span>
</span>
<span className="trace-details-header__sub-item">
<Timer size={13} />
{parseFloat(formattedDuration.toFixed(2))} {timeUnitName}
</span>
<span className="trace-details-header__sub-item">
<CalendarClock size={13} />
{dayjs(traceMetadata.startTimestampMillis).format(
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
)}
</span>
{traceMetadata.rootSpanStatusCode && (
<HttpStatusBadge statusCode={traceMetadata.rootSpanStatusCode} />
)}
</>
) : (
<DetailsLoader />
)}
</div>
)}
{isPreviewFieldsOpen && (
<FloatingPanel
isOpen
width={350}
height={window.innerHeight - 100}
defaultPosition={{
x: window.innerWidth - 350 - 100,
y: 50,
}}
enableResizing={false}
>
<FieldsSettings
title="Preview fields"
fields={previewFields}
onFieldsChange={setPreviewFields}
onClose={(): void => setIsPreviewFieldsOpen(false)}
dataSource={DataSource.TRACES}
/>
</FloatingPanel>
</div>
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
</>
)}
</div>
);

View File

@@ -1,93 +0,0 @@
import { useMemo } from 'react';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { Ellipsis } from '@signozhq/icons';
import { useTraceContext } from '../contexts/TraceContext';
interface TraceOptionsMenuProps {
showTraceDetails: boolean;
onToggleTraceDetails: () => void;
onOpenPreviewFields: () => void;
}
function TraceOptionsMenu({
showTraceDetails,
onToggleTraceDetails,
onOpenPreviewFields,
}: TraceOptionsMenuProps): JSX.Element {
const { colorByField, setColorByField, availableColorByOptions } =
useTraceContext();
const menuItems: MenuItem[] = useMemo(() => {
const items: MenuItem[] = [
{
key: 'toggle-trace-details',
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
onClick: onToggleTraceDetails,
},
{
key: 'preview-fields',
label: 'Preview fields',
onClick: onOpenPreviewFields,
},
];
// Only show the "Colour by" submenu if there's an actual choice to make.
if (availableColorByOptions.length > 1) {
items.push({
key: 'colour-by',
label: 'Colour by',
children: [
{
type: 'group',
label: 'COLOUR BY',
children: [
{
type: 'radio-group',
value: colorByField.name,
onChange: (name: string): void => {
const next = availableColorByOptions.find(
(o) => o.field.name === name,
);
if (next) {
setColorByField(next.field);
}
},
children: availableColorByOptions.map((opt) => ({
type: 'radio',
key: opt.field.name,
label: opt.label,
value: opt.field.name,
})),
},
],
},
],
});
}
return items;
}, [
showTraceDetails,
onToggleTraceDetails,
onOpenPreviewFields,
colorByField.name,
setColorByField,
availableColorByOptions,
]);
return (
<Dropdown menu={{ items: menuItems }}>
<Button
variant="solid"
color="secondary"
size="sm"
prefix={<Ellipsis size={14} />}
/>
</Dropdown>
);
}
export default TraceOptionsMenu;

View File

@@ -48,10 +48,6 @@
color: var(--l2-foreground);
}
&__collapse-count-errors {
color: var(--destructive);
}
&__flame-collapse {
flex-shrink: 0;

View File

@@ -214,7 +214,6 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
hasError={tooltipContent.status === 'error'}
relativeStartMs={tooltipContent.startMs}
durationMs={tooltipContent.durationMs}
previewRows={tooltipContent.previewRows}
/>
)}
</div>,

View File

@@ -5,13 +5,7 @@ import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useUrlQuery from 'hooks/useUrlQuery';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import { COLOR_BY_FIELDS } from '../constants';
import { useTraceContext } from '../contexts/TraceContext';
import Error from '../TraceWaterfall/TraceWaterfallStates/Error/Error';
import {
mergeTelemetryFieldKeys,
toTelemetryFieldKey,
} from '../utils/previewFields';
import { FLAMEGRAPH_SPAN_LIMIT } from './constants';
import FlamegraphCanvas from './FlamegraphCanvas';
import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker';
@@ -50,19 +44,6 @@ function TraceFlamegraph({
[history, search],
);
const { previewFields } = useTraceContext();
// Color-by fields baseline + user-picked preview fields. De-duped by `name`,
// color-by entries first so their canonical metadata wins on collision.
const flamegraphSelectFields = useMemo(
() =>
mergeTelemetryFieldKeys(
COLOR_BY_FIELDS,
previewFields.map(toTelemetryFieldKey),
),
[previewFields],
);
const {
data,
isFetching,
@@ -71,7 +52,6 @@ function TraceFlamegraph({
traceId,
// selectedSpanId: firstSpanAtFetchLevel,
limit: FLAMEGRAPH_SPAN_LIMIT,
selectFields: flamegraphSelectFields,
});
const spans = useMemo(

View File

@@ -1,25 +1,15 @@
import React from 'react';
import type React from 'react';
import { act, renderHook } from '@testing-library/react';
import { AllTheProviders } from 'tests/test-utils';
import { TraceProvider } from '../../contexts/TraceContext';
import { useFlamegraphHover } from '../hooks/useFlamegraphHover';
import type { SpanRect } from '../types';
import { MOCK_SPAN, MOCK_TRACE_METADATA } from './testUtils';
function wrapper({ children }: { children: React.ReactNode }): JSX.Element {
return (
<AllTheProviders>
<TraceProvider aggregations={undefined}>{children}</TraceProvider>
</AllTheProviders>
);
}
function createMockCanvas(): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 400;
jest.spyOn(canvas, 'getBoundingClientRect').mockImplementation(
canvas.getBoundingClientRect = jest.fn(
(): DOMRect =>
({
left: 0,
@@ -69,9 +59,7 @@ describe('useFlamegraphHover', () => {
});
it('sets hoveredSpanId and tooltipContent when hovering on span', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
@@ -88,9 +76,7 @@ describe('useFlamegraphHover', () => {
});
it('clears hover when moving to empty area', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
@@ -113,9 +99,7 @@ describe('useFlamegraphHover', () => {
});
it('clears hover on mouse leave', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
@@ -133,9 +117,7 @@ describe('useFlamegraphHover', () => {
});
it('suppresses click when drag distance exceeds threshold', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleMouseDownForClick({
@@ -155,9 +137,7 @@ describe('useFlamegraphHover', () => {
});
it('calls onSpanClick when clicking on span', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleClick({
@@ -170,9 +150,7 @@ describe('useFlamegraphHover', () => {
});
it('uses clientX/clientY for tooltip positioning', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
act(() => {
result.current.handleHoverMouseMove({
@@ -186,9 +164,7 @@ describe('useFlamegraphHover', () => {
});
it('does not update hover during drag', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
defaultArgs.isDraggingRef.current = true;
act(() => {

View File

@@ -1,6 +1,4 @@
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { getFlamegraphSpanGroupValue, getSpanColor } from '../utils';
import { getSpanColor } from '../utils';
import { MOCK_SPAN } from './testUtils';
const mockGenerateColor = jest.fn();
@@ -10,17 +8,6 @@ jest.mock('lib/uPlotLib/utils/generateColor', () => ({
mockGenerateColor(key, colorMap),
}));
const SERVICE_FIELD: TelemetryFieldKey = {
name: 'service.name',
fieldContext: 'resource',
fieldDataType: 'string',
};
const HOST_FIELD: TelemetryFieldKey = {
name: 'host.name',
fieldContext: 'resource',
fieldDataType: 'string',
};
describe('Presentation / Styling Utils', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -28,17 +15,16 @@ describe('Presentation / Styling Utils', () => {
});
describe('getSpanColor', () => {
it('uses generated colour from groupValue for normal span', () => {
it('uses generated service color for normal span', () => {
mockGenerateColor.mockReturnValue('#1890ff');
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: false },
isDarkMode: false,
groupValue: 'my-bucket',
});
expect(mockGenerateColor).toHaveBeenCalledWith(
'my-bucket',
MOCK_SPAN.serviceName,
expect.any(Object),
);
expect(color).toBe('#1890ff');
@@ -50,7 +36,6 @@ describe('Presentation / Styling Utils', () => {
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: true },
isDarkMode: false,
groupValue: 'my-bucket',
});
expect(color).toBe('rgb(220, 38, 38)');
@@ -62,50 +47,21 @@ describe('Presentation / Styling Utils', () => {
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: true },
isDarkMode: true,
groupValue: 'my-bucket',
});
expect(color).toBe('rgb(239, 68, 68)');
});
});
describe('getFlamegraphSpanGroupValue', () => {
it('returns resource[field.name] when present', () => {
const value = getFlamegraphSpanGroupValue(
{
serviceName: 'legacy',
resource: { 'service.name': 'svc-from-resource' },
},
SERVICE_FIELD,
);
expect(value).toBe('svc-from-resource');
});
it('passes serviceName to generateColor', () => {
getSpanColor({
span: { ...MOCK_SPAN, serviceName: 'my-service' },
isDarkMode: false,
});
it('falls back to top-level serviceName for service.name when resource is empty', () => {
const value = getFlamegraphSpanGroupValue(
{ serviceName: 'svc-legacy', resource: {} },
SERVICE_FIELD,
expect(mockGenerateColor).toHaveBeenCalledWith(
'my-service',
expect.any(Object),
);
expect(value).toBe('svc-legacy');
});
it('returns "unknown" for non-service fields when resource is missing', () => {
const value = getFlamegraphSpanGroupValue(
{ serviceName: 'svc', resource: {} },
HOST_FIELD,
);
expect(value).toBe('unknown');
});
it('reads host.name from resource when present', () => {
const value = getFlamegraphSpanGroupValue(
{
serviceName: 'svc',
resource: { 'host.name': 'host-1' },
},
HOST_FIELD,
);
expect(value).toBe('host-1');
});
});
});

View File

@@ -6,9 +6,6 @@ export interface ConnectorLine {
childRow: number;
timestampMs: number;
serviceName: string;
// Snapshot of the child span's resource so draw-time can resolve the
// `colorByField` group value without crossing the worker boundary.
resource?: Record<string, string>;
}
export interface VisualLayout {
@@ -360,7 +357,6 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
childRow,
timestampMs: child.timestamp,
serviceName: child.serviceName,
resource: child.resource,
});
}
}

View File

@@ -1,9 +1,7 @@
import React, { RefObject, useCallback, useMemo, useRef } from 'react';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { ConnectorLine } from '../computeVisualLayout';
import { EventRect, SpanRect } from '../types';
@@ -12,7 +10,6 @@ import {
drawSpanBar,
FlamegraphRowMetrics,
getFlamegraphRowMetrics,
getFlamegraphSpanGroupValue,
getSpanColor,
} from '../utils';
@@ -54,7 +51,6 @@ interface DrawLevelArgs {
selectedSpanId: string | undefined;
hoveredSpanId: string;
isDarkMode: boolean;
colorByField: TelemetryFieldKey;
spanRectsArray: SpanRect[];
eventRectsArray: EventRect[];
metrics: FlamegraphRowMetrics;
@@ -75,7 +71,6 @@ function drawLevel(args: DrawLevelArgs): void {
selectedSpanId,
hoveredSpanId,
isDarkMode,
colorByField,
spanRectsArray,
eventRectsArray,
metrics,
@@ -117,8 +112,7 @@ function drawLevel(args: DrawLevelArgs): void {
// Minimum 1px width so tiny spans remain visible
width = clamp(width, 1, Infinity);
const groupValue = getFlamegraphSpanGroupValue(span, colorByField);
const color = getSpanColor({ span, isDarkMode, groupValue });
const color = getSpanColor({ span, isDarkMode });
const isDimmedByFilter =
!!isFilterActiveInLevel &&
@@ -154,7 +148,6 @@ interface DrawConnectorLinesArgs {
cssWidth: number;
viewportHeight: number;
metrics: FlamegraphRowMetrics;
colorByField: TelemetryFieldKey;
}
function drawConnectorLines(args: DrawConnectorLinesArgs): void {
@@ -167,7 +160,6 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
cssWidth,
viewportHeight,
metrics,
colorByField,
} = args;
ctx.save();
@@ -193,11 +185,10 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
continue;
}
const groupValue = getFlamegraphSpanGroupValue(
{ serviceName: conn.serviceName, resource: conn.resource },
colorByField,
const color = generateColor(
conn.serviceName,
themeColors.traceDetailColorsV3,
);
const color = generateColor(groupValue, themeColors.traceDetailColorsV3);
ctx.strokeStyle = color;
const x = clamp(xFrac * cssWidth, 0, cssWidth);
@@ -237,10 +228,11 @@ export function useFlamegraphDraw(
const eventRectsRefInternal = useRef<EventRect[]>([]);
const eventRectsRef = eventRectsRefProp ?? eventRectsRefInternal;
const { colorByField } = useTraceContext();
const filteredSpanIdsSet = useMemo(
() => (isFilterActive && filteredSpanIds ? new Set(filteredSpanIds) : null),
() =>
isFilterActive && filteredSpanIds && filteredSpanIds.length > 0
? new Set(filteredSpanIds)
: null,
[filteredSpanIds, isFilterActive],
);
@@ -293,7 +285,6 @@ export function useFlamegraphDraw(
cssWidth,
viewportHeight,
metrics,
colorByField,
});
const spanRectsArray: SpanRect[] = [];
@@ -318,7 +309,6 @@ export function useFlamegraphDraw(
selectedSpanId,
hoveredSpanId,
isDarkMode,
colorByField,
spanRectsArray,
eventRectsArray,
metrics,
@@ -345,7 +335,6 @@ export function useFlamegraphDraw(
hoveredSpanId,
hoveredEventKey,
isDarkMode,
colorByField,
filteredSpanIdsSet,
isFilterActive,
]);

View File

@@ -8,18 +8,11 @@ import {
useRef,
useState,
} from 'react';
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
import { RESERVED_PREVIEW_KEYS } from 'pages/TraceDetailsV3/SpanHoverCard/SpanHoverCard';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { EventRect, SpanRect } from '../types';
import { ITraceMetadata } from '../types';
import {
getFlamegraphServiceName,
getFlamegraphSpanGroupValue,
getSpanColor,
} from '../utils';
import { getSpanColor } from '../utils';
function getCanvasPointer(
canvas: HTMLCanvasElement,
@@ -76,11 +69,6 @@ export interface EventTooltipData {
attributeMap: Record<string, string>;
}
export interface SpanPreviewRowData {
key: string;
value: string;
}
export interface TooltipContent {
serviceName: string;
spanName: string;
@@ -90,7 +78,6 @@ export interface TooltipContent {
clientX: number;
clientY: number;
spanColor: string;
previewRows?: SpanPreviewRowData[];
event?: EventTooltipData;
}
@@ -138,25 +125,6 @@ export function useFlamegraphHover(
null,
);
const { colorByField, previewFields } = useTraceContext();
const buildPreviewRows = useCallback(
(span: FlamegraphSpan): SpanPreviewRowData[] =>
previewFields
.filter((field) => !RESERVED_PREVIEW_KEYS.has(field.key))
.map((field) => {
const value = getSpanAttribute(
{ resource: span.resource, attributes: span.attributes },
field.key,
);
return value !== undefined && value !== ''
? { key: field.key, value: String(value) }
: null;
})
.filter((r): r is SpanPreviewRowData => r !== null),
[previewFields],
);
const isZoomed =
viewStartTs !== traceMetadata.startTime ||
viewEndTs !== traceMetadata.endTime;
@@ -203,18 +171,14 @@ export function useFlamegraphHover(
setHoveredEventKey(`${span.spanId}-${event.name}-${event.timeUnixNano}`);
setHoveredSpanId(span.spanId);
setTooltipContent({
serviceName: getFlamegraphServiceName(span),
serviceName: span.serviceName || '',
spanName: span.name || 'unknown',
status: span.hasError ? 'error' : 'ok',
startMs: span.timestamp - traceMetadata.startTime,
durationMs: span.durationNano / 1e6,
clientX: e.clientX,
clientY: e.clientY,
spanColor: getSpanColor({
span,
isDarkMode,
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
}),
spanColor: getSpanColor({ span, isDarkMode }),
event: {
name: event.name,
timeOffsetMs: eventTimeMs - span.timestamp,
@@ -236,19 +200,14 @@ export function useFlamegraphHover(
setHoveredEventKey(null);
setHoveredSpanId(span.spanId);
setTooltipContent({
serviceName: getFlamegraphServiceName(span),
serviceName: span.serviceName || '',
spanName: span.name || 'unknown',
status: span.hasError ? 'error' : 'ok',
startMs: span.timestamp - traceMetadata.startTime,
durationMs: span.durationNano / 1e6,
clientX: e.clientX,
clientY: e.clientY,
spanColor: getSpanColor({
span,
isDarkMode,
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
}),
previewRows: buildPreviewRows(span),
spanColor: getSpanColor({ span, isDarkMode }),
});
updateCursor(canvas, span);
} else {
@@ -266,8 +225,6 @@ export function useFlamegraphHover(
isDraggingRef,
updateCursor,
isDarkMode,
colorByField,
buildPreviewRows,
],
);

View File

@@ -2,9 +2,7 @@
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import {
DASHED_BORDER_LINE_DASH,
@@ -68,47 +66,14 @@ export function getFlamegraphRowMetrics(
};
}
/**
* Resolve the displayed service.name for a flamegraph span. Used by tooltips
* (service identity, independent of the active colour-by field). Prefers
* `resource['service.name']` with legacy top-level `serviceName` fallback.
*/
export function getFlamegraphServiceName(
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
): string {
return getSpanAttribute(span, 'service.name') || span.serviceName || '';
}
/**
* Resolve the value used to bucket a flamegraph span by colour for the given
* field. Prefers `resource[field.name]` (new contract from `selectFields`).
* For `service.name`, falls back to the legacy top-level `serviceName` when
* resource is empty (backward-compat with backends that haven't shipped
* `selectFields` yet). For other fields, falls back to `'unknown'`.
*/
export function getFlamegraphSpanGroupValue(
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
field: TelemetryFieldKey,
): string {
const fromAttribute = getSpanAttribute(span, field.name);
if (fromAttribute) {
return fromAttribute;
}
if (field.name === 'service.name') {
return span.serviceName || 'unknown';
}
return 'unknown';
}
interface GetSpanColorArgs {
span: FlamegraphSpan;
isDarkMode: boolean;
groupValue: string;
}
export function getSpanColor(args: GetSpanColorArgs): string {
const { span, isDarkMode, groupValue } = args;
let color = generateColor(groupValue, themeColors.traceDetailColorsV3);
const { span, isDarkMode } = args;
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
if (span.hasError) {
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
@@ -246,7 +211,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
// Alpha is applied to bar + events only; label is drawn after restoring alpha to 1
// so text stays readable against the faded bar.
if (shouldDim) {
ctx.globalAlpha = 0.15;
ctx.globalAlpha = 0.4;
}
ctx.beginPath();

View File

@@ -11,10 +11,14 @@ import NoData from './TraceWaterfallStates/NoData/NoData';
import Success from './TraceWaterfallStates/Success/Success';
import { getVisibleSpans } from './utils';
import { IInterestedSpan } from './types';
import './TraceWaterfall.styles.scss';
export interface IInterestedSpan {
spanId: string;
isUncollapsed: boolean;
scrollToSpan?: boolean;
}
interface ITraceWaterfallProps {
traceId: string;
uncollapsedNodes: string[];

View File

@@ -3,114 +3,18 @@
align-items: center;
gap: 12px;
&.expanded {
flex: 1;
}
.filter-search-container {
flex: 1;
min-width: 0;
}
.query-builder-search-v2 {
width: 100%;
}
// --- Collapsed pill ---
.filter-pill {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--l1-border);
border-radius: 4px;
cursor: pointer;
max-width: 220px;
min-width: 120px;
height: 32px;
background: var(--l1-background);
&:hover {
border-color: var(--l3-border);
}
&__text {
font-size: 12px;
color: var(--l2-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
&__indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary-background);
flex-shrink: 0;
}
}
// --- Collapsed pill popover ---
.filter-pill-popover {
max-width: 400px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
&__expression {
font-family: 'Geist Mono', 'Fira Code', monospace;
font-size: 12px;
color: var(--l1-foreground);
word-break: break-all;
padding: 6px 8px;
background: var(--l2-background);
border-radius: 4px;
}
}
// --- ToggleGroup override: size to content, don't stretch items ---
[class*='toggle-group'] {
flex-shrink: 0;
[class*='toggle-group-item'] {
flex: 0 0 auto;
}
}
// --- Collapse button ---
.filter-collapse-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-shadow: none;
}
// --- Highlight errors toggle ---
.highlight-errors-toggle {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
white-space: nowrap;
}
// --- Prev/next navigation ---
.pre-next-toggle {
display: flex;
flex-shrink: 0;
gap: 12px;
&__count {
.ant-typography {
display: flex;
align-items: center;
margin: auto;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
@@ -126,20 +30,14 @@
}
}
.filter-status {
.no-results {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
font-weight: 400;
line-height: 18px;
&--error {
color: var(--destructive);
cursor: help;
}
}
}

View File

@@ -1,46 +1,19 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import {
ChevronDown,
ChevronUp,
Copy,
Info,
Loader,
Search,
X,
} from '@signozhq/icons';
import { Switch } from '@signozhq/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { toast } from '@signozhq/ui/sonner';
import { Button } from '@signozhq/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Button, Spin, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { AxiosError } from 'axios';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { uniqBy } from 'lodash-es';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import {
DataSource,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { LoaderCircle, Info, ChevronDown, ChevronUp } from '@signozhq/icons';
import { BASE_FILTER_QUERY } from './constants';
import { useHighlightErrors } from './hooks/useHighlightErrors';
import {
SpanCategory,
useSpanCategoryFilter,
} from './hooks/useSpanCategoryFilter';
import './Filters.styles.scss';
@@ -71,7 +44,6 @@ function prepareQuery(filters: TagFilter, traceID: string): Query {
},
],
},
selectColumns: [],
},
],
},
@@ -83,87 +55,31 @@ function Filters({
endTime,
traceID,
onFilteredSpansChange = (): void => {},
isExpanded,
onExpand,
onCollapse,
}: {
startTime: number;
endTime: number;
traceID: string;
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
isExpanded: boolean;
onExpand: () => void;
onCollapse: () => void;
}): JSX.Element {
const [, setCopy] = useCopyToClipboard();
const [filters, setFilters] = useState<TagFilter>(
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
);
const [expression, setExpression] = useState<string>('');
const [noData, setNoData] = useState<boolean>(false);
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
const expressionRef = useRef<string>('');
const containerRef = useRef<HTMLDivElement>(null);
const runQuery = useCallback(
(value: string): void => {
const items = convertExpressionToFilters(value);
setFilters({ items, op: 'AND' });
// Clear results when expression produces no filters
if (items.length === 0) {
const handleFilterChange = useCallback(
(value: TagFilter): void => {
if (value.items.length === 0) {
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
setFilters(value);
},
[onFilteredSpansChange],
);
// onChange fires on every keystroke — only store the expression, don't trigger API
const handleExpressionChange = useCallback(
(value: string): void => {
setExpression(value);
expressionRef.current = value;
// Clear results when expression is emptied
if (!value.trim()) {
setFilters({ items: [], op: 'AND' });
setFilteredSpanIds([]);
onFilteredSpansChange?.([], false);
setCurrentSearchedIndex(0);
setNoData(false);
}
},
[onFilteredSpansChange],
);
// onRun fires on Ctrl+Enter
const handleRunQuery = useCallback(
(value: string): void => {
runQuery(value);
},
[runQuery],
);
// Run query on blur (click outside the filter input)
const handleBlur = useCallback((): void => {
runQuery(expressionRef.current);
}, [runQuery]);
// Expression-based filter hooks
const filterProps = {
expression,
filters,
setExpression,
expressionRef,
runQuery,
};
const { isHighlightErrors, handleToggle: handleToggleHighlightErrors } =
useHighlightErrors(filterProps);
const { selectedCategory, categories, handleCategoryChange } =
useSpanCategoryFilter(filterProps);
const { search } = useLocation();
const history = useHistory();
@@ -194,14 +110,14 @@ function Filters({
tableParams: {
pagination: {
offset: 0,
limit: 10000,
limit: 200,
},
selectColumns: [
{
key: 'spanID',
key: 'name',
dataType: 'string',
type: 'tag',
id: 'spanId--string--tag--true',
id: 'name--string--tag--true',
isIndexed: false,
},
],
@@ -231,187 +147,58 @@ function Filters({
setCurrentSearchedIndex(0);
}
},
onError: () => {
const isFilterActive = filters.items.length > 0;
setNoData(false);
setFilteredSpanIds([]);
onFilteredSpansChange?.([], isFilterActive);
setCurrentSearchedIndex(0);
},
},
);
const highlightErrorsToggle = (
<div className="highlight-errors-toggle">
<Typography.Text>Highlight errors</Typography.Text>
<Switch
color="cherry"
value={isHighlightErrors}
onChange={handleToggleHighlightErrors}
/>
</div>
);
const statusIndicators = (
<>
{isFetching && <Loader className="animate-spin" />}
{error && (
<Tooltip>
<TooltipTrigger asChild>
<span className="filter-status filter-status--error">
<Info />
API error
</span>
</TooltipTrigger>
<TooltipContent>
{(error as AxiosError)?.message || 'Something went wrong'}
</TooltipContent>
</Tooltip>
)}
{!error && noData && (
<Typography.Text className="filter-status">
No results found
</Typography.Text>
)}
</>
);
// --- COLLAPSED VIEW ---
if (!isExpanded) {
const pill = (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div className="filter-pill" onClick={onExpand}>
<Search size={12} />
<span className="filter-pill__text">{expression || 'Search...'}</span>
{expression && <span className="filter-pill__indicator" />}
</div>
);
return (
<TooltipProvider>
<div className="trace-v3-filter-row collapsed">
{expression ? (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<div className="filter-pill-popover">
<div className="filter-pill-popover__header">
<Typography.Text>Search query</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void => {
setCopy(expression);
toast.success('Copied to clipboard', {
richColors: false,
position: 'top-right',
});
}}
>
<Copy size={12} />
</Button>
</div>
<div className="filter-pill-popover__expression">{expression}</div>
</div>
</TooltipContent>
</Tooltip>
) : (
pill
)}
{highlightErrorsToggle}
{statusIndicators}
</div>
</TooltipProvider>
);
}
// --- EXPANDED VIEW ---
return (
<TooltipProvider>
<div className="trace-v3-filter-row expanded">
<ToggleGroup
type="single"
value={selectedCategory}
onChange={(value): void => {
if (value) {
handleCategoryChange(value as SpanCategory);
}
}}
size="sm"
>
{categories.map((category) => (
<ToggleGroupItem key={category} value={category}>
{category}
</ToggleGroupItem>
))}
</ToggleGroup>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div
className="filter-search-container"
ref={containerRef}
onBlur={(e): void => {
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
handleBlur();
}
}}
>
<QuerySearch
queryData={{
...BASE_FILTER_QUERY,
filters,
filter: { expression },
<div className="trace-v3-filter-row">
<QueryBuilderSearchV2
query={{
...BASE_FILTER_QUERY,
filters,
}}
onChange={handleFilterChange}
hideSpanScopeSelector={false}
skipQueryBuilderRedirect
selectProps={{ listHeight: 125 }}
/>
{filteredSpanIds.length > 0 && (
<div className="pre-next-toggle">
<Typography.Text>
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
icon={<ChevronUp size={14} />}
disabled={currentSearchedIndex === 0}
type="text"
onClick={(): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}}
/>
<Button
icon={<ChevronDown size={14} />}
type="text"
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
onClick={(): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}}
onChange={handleExpressionChange}
onRun={handleRunQuery}
dataSource={DataSource.TRACES}
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
/>
</div>
{filteredSpanIds.length > 0 && (
<div className="pre-next-toggle">
<Typography.Text className="pre-next-toggle__count">
{currentSearchedIndex + 1} / {filteredSpanIds.length}
</Typography.Text>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentSearchedIndex === 0}
onClick={(): void => {
handlePrevNext(currentSearchedIndex - 1);
setCurrentSearchedIndex((prev) => prev - 1);
}}
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
color="secondary"
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
onClick={(): void => {
handlePrevNext(currentSearchedIndex + 1);
setCurrentSearchedIndex((prev) => prev + 1);
}}
>
<ChevronDown size={14} />
</Button>
</div>
)}
<Button
variant="ghost"
size="icon"
color="secondary"
className="filter-collapse-btn"
onClick={onCollapse}
>
<X size={14} />
</Button>
{highlightErrorsToggle}
{statusIndicators}
</div>
</TooltipProvider>
)}
{isFetching && (
<Spin indicator={<LoaderCircle className="animate-spin" />} size="small" />
)}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<Info size={14} />
</Tooltip>
)}
{noData && (
<Typography.Text className="no-results">No results found</Typography.Text>
)}
</div>
);
}

View File

@@ -1,30 +0,0 @@
import { MutableRefObject } from 'react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
/**
* Shared props for expression-based filter hooks.
* Each hook reads the current expression + derived filters,
* and manipulates the expression via remove/add pattern.
*/
export interface ExpressionFilterProps {
expression: string;
filters: TagFilter;
setExpression: (expr: string) => void;
expressionRef: MutableRefObject<string>;
runQuery: (expr: string) => void;
}
/**
* Helper: update expression state, ref, and trigger query.
*/
export function applyExpression(
newExpression: string,
props: Pick<
ExpressionFilterProps,
'setExpression' | 'expressionRef' | 'runQuery'
>,
): void {
props.setExpression(newExpression);
props.expressionRef.current = newExpression;
props.runQuery(newExpression);
}

View File

@@ -1,45 +0,0 @@
import { useCallback, useMemo } from 'react';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import { applyExpression, ExpressionFilterProps } from './types';
interface UseHighlightErrorsReturn {
isHighlightErrors: boolean;
handleToggle: (checked: boolean) => void;
}
const ERROR_KEY = 'has_error';
export function useHighlightErrors(
props: ExpressionFilterProps,
): UseHighlightErrorsReturn {
const { expression, filters, setExpression, expressionRef, runQuery } = props;
// Derive from filters (only updates after runQuery, not on every keystroke)
const isHighlightErrors = useMemo(
() =>
filters.items.some(
(item) =>
item.key?.key === ERROR_KEY &&
(item.value === true || item.value === 'true'),
),
[filters],
);
const handleToggle = useCallback(
(checked: boolean): void => {
// Always remove existing has_error first (whatever its value)
let newExpr = removeKeysFromExpression(expression, [ERROR_KEY]);
// Add back if turning ON
if (checked) {
newExpr = newExpr.trim()
? `${newExpr.trim()} AND has_error = true`
: `has_error = true`;
}
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
},
[expression, setExpression, expressionRef, runQuery],
);
return { isHighlightErrors, handleToggle };
}

View File

@@ -1,84 +0,0 @@
import { useCallback, useMemo } from 'react';
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import { applyExpression, ExpressionFilterProps } from './types';
export type SpanCategory =
| 'All'
| 'Database'
| 'Functions'
| 'HTTP'
| 'Jobs'
| 'LLM';
export const SPAN_CATEGORIES: readonly SpanCategory[] = [
'All',
'Database',
'Functions',
'HTTP',
'Jobs',
'LLM',
];
// Map each category to the attribute key it filters on
const CATEGORY_KEYS: Record<Exclude<SpanCategory, 'All'>, string> = {
Database: 'db.system',
HTTP: 'http.method',
Functions: 'kind_string',
Jobs: 'messaging.system',
LLM: 'gen_ai.request.model',
};
// All category keys — used for bulk removal when switching categories
const ALL_CATEGORY_KEYS = Object.values(CATEGORY_KEYS);
// The expression clause to add for each category
const CATEGORY_EXPRESSIONS: Record<Exclude<SpanCategory, 'All'>, string> = {
Database: 'db.system exists',
HTTP: 'http.method exists',
Functions: "kind_string = 'Internal'",
Jobs: 'messaging.system exists',
LLM: 'gen_ai.request.model exists',
};
interface UseSpanCategoryFilterReturn {
selectedCategory: SpanCategory;
categories: readonly SpanCategory[];
handleCategoryChange: (category: SpanCategory) => void;
}
export function useSpanCategoryFilter(
props: ExpressionFilterProps,
): UseSpanCategoryFilterReturn {
const { expression, filters, setExpression, expressionRef, runQuery } = props;
// Derive active category from filters (only updates after runQuery)
const selectedCategory = useMemo((): SpanCategory => {
for (const [category, key] of Object.entries(CATEGORY_KEYS)) {
if (filters.items.some((item) => item.key?.key === key)) {
return category as SpanCategory;
}
}
return 'All';
}, [filters]);
const handleCategoryChange = useCallback(
(category: SpanCategory): void => {
// Remove ALL category keys first
let newExpr = removeKeysFromExpression(expression, ALL_CATEGORY_KEYS);
// Add the selected category clause (unless "All")
if (category !== 'All') {
const clause = CATEGORY_EXPRESSIONS[category];
newExpr = newExpr.trim() ? `${newExpr.trim()} AND ${clause}` : clause;
}
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
},
[expression, setExpression, expressionRef, runQuery],
);
return {
selectedCategory,
categories: SPAN_CATEGORIES,
handleCategoryChange,
};
}

View File

@@ -131,24 +131,6 @@
position: relative;
}
// Invisible IntersectionObserver targets pinned at the top and bottom of
// the virtualized content. See `useBoundaryPagination`.
.waterfall-load-more-sentinel {
position: absolute;
left: 0;
right: 0;
height: 1px;
pointer-events: none;
&--top {
top: 0;
}
&--bottom {
bottom: 0;
}
}
.waterfall-sidebar {
overflow-x: auto;
overflow-y: hidden;
@@ -261,7 +243,7 @@
}
&.dimmed-span {
opacity: 0.15;
opacity: 0.4;
}
}
}
@@ -333,7 +315,7 @@
.tree-connector {
position: absolute;
width: 14px;
width: 11px;
height: 50%;
border-left: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
@@ -341,16 +323,6 @@
pointer-events: none;
}
// Reserved horizontal space for the chevron — present on every row,
// filled only when the span has children. Keeps sibling icons aligned.
.tree-arrow-slot {
width: 18px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.tree-arrow {
display: inline-flex;
align-items: center;
@@ -367,18 +339,6 @@
}
}
// Reserved horizontal space for the subtree-count badge — same reason.
// Right-aligns the badge inside so single-digit counts don't push the
// icon left of where multi-digit counts would put it.
.subtree-count-slot {
min-width: 20px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: flex-end;
padding-right: 4px;
}
.subtree-count {
display: inline-flex;
align-items: center;
@@ -579,7 +539,7 @@
}
.dimmed-span {
opacity: 0.15;
opacity: 0.4;
}
.highlighted-span {
opacity: 1;

View File

@@ -27,11 +27,12 @@ import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
import cx from 'classnames';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import TimelineV3 from 'components/TimelineV3/TimelineV3';
import { themeColors } from 'constants/theme';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import { colorToRgb, generateColor } from 'lib/uPlotLib/utils/generateColor';
import {
ArrowUpRight,
ChevronDown,
@@ -40,17 +41,15 @@ import {
Link,
ListPlus,
} from '@signozhq/icons';
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
import { useCrosshair } from 'pages/TraceDetailsV3/hooks/useCrosshair';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { EventV3, SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
import SpanHoverCard from '../../../SpanHoverCard/SpanHoverCard';
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
import { IInterestedSpan } from '../../types';
import { IInterestedSpan } from '../../TraceWaterfall';
import './Success.styles.scss';
@@ -77,7 +76,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = useCallback((): void => {
timerRef.current = setTimeout(() => setShowPopover(true), 200);
timerRef.current = setTimeout(() => setShowPopover(true), 150);
}, []);
const handleMouseLeave = useCallback((): void => {
@@ -134,7 +133,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
});
// css config
const CONNECTOR_WIDTH = 30;
const CONNECTOR_WIDTH = 20;
const VERTICAL_CONNECTOR_WIDTH = 1;
interface SpanStateClasses {
@@ -195,9 +194,8 @@ const SpanOverview = memo(function SpanOverview({
selectedSpan,
filteredSpanIds,
isFilterActive,
traceMetadata,
onAddSpanToFunnel,
onHoverEnter,
onHoverLeave,
}: {
span: SpanV3;
isSpanCollapsed: boolean;
@@ -206,15 +204,19 @@ const SpanOverview = memo(function SpanOverview({
handleSpanClick: (span: SpanV3) => void;
filteredSpanIds: string[];
isFilterActive: boolean;
traceMetadata: ITraceMetadata;
onAddSpanToFunnel: (span: SpanV3) => void;
onHoverEnter: (spanId: string) => void;
onHoverLeave: () => void;
}): JSX.Element {
const isRootSpan = span.level === 0;
const { onSpanCopy } = useCopySpanLink(span);
const { resolveSpanColor } = useTraceContext();
const color = resolveSpanColor(span);
let color = generateColor(
span['service.name'],
themeColors.traceDetailColorsV3,
);
if (span.has_error) {
color = `var(--bg-cherry-500)`;
}
// Smart highlighting logic
const {
@@ -230,9 +232,6 @@ const SpanOverview = memo(function SpanOverview({
isFilterActive,
);
// All siblings at the same level share the same indent so the "same X =
// same level" visual rule holds. Parent/child distinction is conveyed by
// the chevron and the L-connector, not by an icon-X offset.
const indentWidth = isRootSpan ? 0 : span.level * CONNECTOR_WIDTH;
const handleFunnelClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
@@ -241,128 +240,126 @@ const SpanOverview = memo(function SpanOverview({
};
return (
<div
className={cx('span-overview', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
onMouseEnter={(): void => onHoverEnter(span.span_id)}
onMouseLeave={(): void => onHoverLeave()}
>
{/* Tree connector lines — always draw vertical lines at all ancestor levels + L-connector */}
{!isRootSpan &&
Array.from({ length: span.level }, (_, i) => {
const lvl = i + 1;
const xPos = (lvl - 1) * CONNECTOR_WIDTH + 9;
if (lvl < span.level) {
// Stop the line at 50% for the last child's parent level
const isLastChildParentLine = !span.has_sibling && lvl === span.level - 1;
return (
<div
key={lvl}
className="tree-line"
style={{
left: xPos,
top: 0,
width: 1,
height: isLastChildParentLine ? '50%' : '100%',
}}
/>
);
}
return (
<div key={lvl}>
<div
className="tree-line"
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
/>
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
</div>
);
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className={cx('span-overview', {
'interested-span': isSelected && (!isFilterActive || isMatching),
'highlighted-span': isHighlighted,
'selected-non-matching-span': isSelectedNonMatching,
'dimmed-span': isDimmed,
})}
onClick={(): void => handleSpanClick(span)}
>
{/* Tree connector lines — always draw vertical lines at all ancestor levels + L-connector */}
{!isRootSpan &&
Array.from({ length: span.level }, (_, i) => {
const lvl = i + 1;
const xPos = (lvl - 1) * CONNECTOR_WIDTH + 9;
if (lvl < span.level) {
// Stop the line at 50% for the last child's parent level
const isLastChildParentLine =
!span.has_sibling && lvl === span.level - 1;
return (
<div
key={lvl}
className="tree-line"
style={{
left: xPos,
top: 0,
width: 1,
height: isLastChildParentLine ? '50%' : '100%',
}}
/>
);
}
return (
<div key={lvl}>
<div
className="tree-line"
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
/>
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
</div>
);
})}
{/* Indent spacer */}
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
{/* Indent spacer */}
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
{/* Expand/collapse arrow + child count slots — always render the
slots, fill them only when the span has children. Reserving the
horizontal space on leaf rows aligns sibling icons regardless
of whether each sibling is a parent or a leaf. */}
<span className="tree-arrow-slot">
{/* Expand/collapse arrow + child count (only for spans with children) */}
{span.has_children && (
<span
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
handleCollapseUncollapse(span.span_id, !isSpanCollapsed);
}}
>
{isSpanCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
</span>
<>
<span
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
onClick={(event): void => {
event.stopPropagation();
event.preventDefault();
handleCollapseUncollapse(span.span_id, !isSpanCollapsed);
}}
>
{isSpanCollapsed ? (
<ChevronRight size={14} />
) : (
<ChevronDown size={14} />
)}
</span>
<span className="subtree-count">
<Badge color="vanilla">{span.sub_tree_node_count}</Badge>
</span>
</>
)}
</span>
<span className="subtree-count-slot">
{span.has_children && (
<span className="subtree-count">
<Badge color="vanilla">{span.sub_tree_node_count}</Badge>
</span>
)}
</span>
{/* Colored service dot */}
<span
className={cx('tree-icon', { 'is-error': span.has_error })}
style={{ backgroundColor: color }}
/>
{/* Colored service dot */}
<span
className={cx('tree-icon', { 'is-error': span.has_error })}
style={{ backgroundColor: color }}
/>
{/* Span name + service name */}
<span className="tree-label">
{span.name}
<span className="tree-service-name">{span['service.name']}</span>
</span>
{/* Span name + service name */}
<span className="tree-label">
{span.name}
<span className="tree-service-name">{span['service.name']}</span>
</span>
{/* Action buttons — shown on hover via CSS, right-aligned */}
<span className="span-row-actions">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={onSpanCopy}
>
<Link size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Copy Span Link
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Add to Trace Funnel
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</div>
{/* Action buttons — shown on hover via CSS, right-aligned */}
<span className="span-row-actions">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={onSpanCopy}
>
<Link size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Copy Span Link
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
className="span-action-btn"
onClick={handleFunnelClick}
>
<ListPlus size={12} />
</Button>
</TooltipTrigger>
<TooltipContent className="span-action-tooltip">
Add to Trace Funnel
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</div>
</SpanHoverCard>
);
});
@@ -389,10 +386,16 @@ export const SpanDuration = memo(function SpanDuration({
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
const width = (span.duration_nano * 1e2) / (spread * 1e6);
const { resolveSpanColor } = useTraceContext();
const color = resolveSpanColor(span);
// `resolveSpanColor` returns a CSS variable for errors; `colorToRgb` can't parse it.
const rgbColor = span.has_error ? '239, 68, 68' : colorToRgb(color);
let color = generateColor(
span['service.name'],
themeColors.traceDetailColorsV3,
);
let rgbColor = colorToRgb(color);
if (span.has_error) {
color = `var(--bg-cherry-500)`;
rgbColor = '239, 68, 68';
}
const {
isSelected,
@@ -417,25 +420,27 @@ export const SpanDuration = memo(function SpanDuration({
})}
onClick={(): void => handleSpanClick(span)}
>
<div
className="span-bar"
style={
{
left: `${leftOffset}%`,
width: `${width}%`,
'--span-color': color,
'--span-color-rgb': rgbColor,
} as React.CSSProperties
}
>
<span className="span-info">
<span className="span-name">{span.name}</span>
<span className="span-duration-text">{`${toFixed(
time,
2,
)} ${timeUnitName}`}</span>
</span>
</div>
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
<div
className="span-bar"
style={
{
left: `${leftOffset}%`,
width: `${width}%`,
'--span-color': color,
'--span-color-rgb': rgbColor,
} as React.CSSProperties
}
>
<span className="span-info">
<span className="span-name">{span.name}</span>
<span className="span-duration-text">{`${toFixed(
time,
2,
)} ${timeUnitName}`}</span>
</span>
</div>
</SpanHoverCard>
{span.events?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const spanDurationMs = span.duration_nano / 1e6;
@@ -501,17 +506,6 @@ function Success(props: ISuccessProps): JSX.Element {
const prevHoveredSpanIdRef = useRef<string | null>(null);
const autoScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
topSentinelRef: loadMoreTopSentinelRef,
bottomSentinelRef: loadMoreBottomSentinelRef,
} = useBoundaryPagination({
scrollContainerRef,
spans,
isFetching,
isFullDataLoaded,
setInterestedSpanId,
});
const {
cursorXPercent,
cursorX,
@@ -537,32 +531,15 @@ function Success(props: ISuccessProps): JSX.Element {
prevHoveredSpanIdRef.current = spanId;
}, []);
// Hover-card state — single popover anchored at the sidebar/timeline
// boundary, Y tracks the hovered row. Set after a 500 ms debounce so fast
// scrolls/cursor sweeps don't fire the card.
const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
const hoverDelayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleRowMouseEnter = useCallback(
(spanId: string): void => {
applyHoverClass(spanId);
if (hoverDelayTimerRef.current) {
clearTimeout(hoverDelayTimerRef.current);
}
hoverDelayTimerRef.current = setTimeout(() => {
setHoveredSpanId(spanId);
}, 500);
},
[applyHoverClass],
);
const handleRowMouseLeave = useCallback((): void => {
applyHoverClass(null);
if (hoverDelayTimerRef.current) {
clearTimeout(hoverDelayTimerRef.current);
hoverDelayTimerRef.current = null;
}
setHoveredSpanId(null);
}, [applyHoverClass]);
const handleCollapseUncollapse = useCallback(
@@ -578,15 +555,14 @@ function Success(props: ISuccessProps): JSX.Element {
}
return next;
});
} else {
// Backend mode: trigger API call (current behavior)
setInterestedSpanId({
spanId,
isUncollapsed: !collapse,
scrollToSpan: false,
});
}
// Backend mode: trigger API call (current behavior)
// keeping this for both mode to support scroll to view to function well.
// interestedspan would not make api call in frontend mode so it is safe to use for both mode.
setInterestedSpanId({
spanId,
isUncollapsed: !collapse,
scrollToSpan: false,
});
},
[isFullDataLoaded, setLocalUncollapsedNodes, setInterestedSpanId],
);
@@ -639,8 +615,32 @@ function Success(props: ISuccessProps): JSX.Element {
}
}, 20);
}
// In frontend mode all data is already loaded, no need to fetch more.
// In backend mode, skip auto-fetch when under 500 spans (nothing more to paginate).
if (isFullDataLoaded || spans.length < 500) {
return;
}
if (range?.startIndex === 0 && instance.isScrolling) {
// do not trigger for trace root as nothing to fetch above
if (spans[0].level !== 0) {
setInterestedSpanId({
spanId: spans[0].span_id,
isUncollapsed: false,
});
}
return;
}
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
setInterestedSpanId({
spanId: spans[spans.length - 1].span_id,
isUncollapsed: false,
});
}
},
[spans],
[spans, setInterestedSpanId],
);
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] =
@@ -685,11 +685,10 @@ function Success(props: ISuccessProps): JSX.Element {
}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
traceMetadata={traceMetadata}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
onAddSpanToFunnel={handleAddSpanToFunnel}
onHoverEnter={handleRowMouseEnter}
onHoverLeave={handleRowMouseLeave}
/>
),
}),
@@ -699,13 +698,12 @@ function Success(props: ISuccessProps): JSX.Element {
uncollapsedNodes,
isFullDataLoaded,
localUncollapsedNodes,
traceMetadata,
selectedSpan,
handleSpanClick,
filteredSpanIds,
isFilterActive,
handleAddSpanToFunnel,
handleRowMouseEnter,
handleRowMouseLeave,
],
);
@@ -782,12 +780,6 @@ function Success(props: ISuccessProps): JSX.Element {
const virtualItems = virtualizer.getVirtualItems();
const leftRows = leftTable.getRowModel().rows;
const handleHoverCardOpenChange = useCallback((open: boolean): void => {
if (!open) {
setHoveredSpanId(null);
}
}, []);
return (
<div className="success-content">
{traceMetadata.hasMissingSpans && (
@@ -841,24 +833,6 @@ function Success(props: ISuccessProps): JSX.Element {
height: '100%',
}}
>
{/* Top / bottom sentinels: each transition into the viewport
fires a load-more via useBoundaryPagination. */}
<div
ref={loadMoreTopSentinelRef}
className="waterfall-load-more-sentinel waterfall-load-more-sentinel--top"
/>
<div
ref={loadMoreBottomSentinelRef}
className="waterfall-load-more-sentinel waterfall-load-more-sentinel--bottom"
/>
<SpanHoverCard
hoveredSpanId={hoveredSpanId}
onOpenChange={handleHoverCardOpenChange}
anchorLeft={sidebarWidth}
rowHeight={ROW_HEIGHT}
spans={spans}
traceStartTime={traceMetadata.startTime}
/>
{/* Left panel - table with horizontal scroll */}
<ResizableBox
direction="horizontal"
@@ -968,8 +942,8 @@ function Success(props: ISuccessProps): JSX.Element {
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
onMouseEnter={(): void => applyHoverClass(span.span_id)}
onMouseLeave={(): void => applyHoverClass(null)}
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
onMouseLeave={handleRowMouseLeave}
>
<SpanDuration
span={span}

View File

@@ -2,12 +2,8 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { fireEvent, render, screen } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceProvider } from '../../../../contexts/TraceContext';
import { SpanDuration } from '../Success';
const renderWithTraceProvider: typeof render = (ui, options) =>
render(<TraceProvider aggregations={undefined}>{ui}</TraceProvider>, options);
// Constants to avoid string duplication
const SPAN_DURATION_TEXT = '1.16 ms';
const SPAN_DURATION_CLASS = '.span-duration';
@@ -23,9 +19,6 @@ jest.mock('components/TimelineV3/TimelineV3', () => ({
// Mock the hooks
jest.mock('hooks/useUrlQuery');
jest.mock('@signozhq/ui', () => ({
Badge: jest.fn(),
}));
const mockSpan: SpanV3 = {
span_id: 'test-span-id',
@@ -95,7 +88,7 @@ describe('SpanDuration', () => {
it('calls handleSpanClick when clicked', () => {
const mockHandleSpanClick = jest.fn();
renderWithTraceProvider(
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -115,7 +108,7 @@ describe('SpanDuration', () => {
});
it('shows action buttons on hover', () => {
renderWithTraceProvider(
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -136,7 +129,7 @@ describe('SpanDuration', () => {
});
it('applies interested-span class when span is selected', () => {
renderWithTraceProvider(
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -154,7 +147,7 @@ describe('SpanDuration', () => {
});
it('applies highlighted-span class when span matches filter', () => {
renderWithTraceProvider(
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -173,7 +166,7 @@ describe('SpanDuration', () => {
});
it('applies dimmed-span class when span does not match filter', () => {
renderWithTraceProvider(
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -192,7 +185,7 @@ describe('SpanDuration', () => {
});
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
renderWithTraceProvider(
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -212,7 +205,7 @@ describe('SpanDuration', () => {
});
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
renderWithTraceProvider(
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -233,7 +226,7 @@ describe('SpanDuration', () => {
});
it('applies interested-span class when span is selected and no filter is active', () => {
renderWithTraceProvider(
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -254,7 +247,7 @@ describe('SpanDuration', () => {
});
it('dims span when filter is active but no matches found', () => {
renderWithTraceProvider(
render(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}

View File

@@ -2,16 +2,8 @@ import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceProvider } from '../../../../contexts/TraceContext';
import Success from '../Success';
const renderWithTraceProvider: typeof render = (ui, options, customOptions) =>
render(
<TraceProvider aggregations={undefined}>{ui}</TraceProvider>,
options,
customOptions,
);
// Mock the required hooks with proper typing
const mockSafeNavigate = jest.fn() as jest.MockedFunction<
(params: { search: string }) => void
@@ -230,7 +222,7 @@ describe('Span Click User Flows', () => {
it('clicking span updates URL with spanId parameter', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithTraceProvider(
render(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
@@ -269,7 +261,7 @@ describe('Span Click User Flows', () => {
it('clicking span duration visually selects the span', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithTraceProvider(<TestComponent />, undefined, {
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
@@ -308,7 +300,7 @@ describe('Span Click User Flows', () => {
it('both click areas produce the same visual result', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithTraceProvider(<TestComponent />, undefined, {
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
@@ -368,7 +360,7 @@ describe('Span Click User Flows', () => {
it('clicking different spans updates selection correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderWithTraceProvider(<TestComponent />, undefined, {
render(<TestComponent />, undefined, {
initialRoute: '/trace',
});
@@ -412,7 +404,7 @@ describe('Span Click User Flows', () => {
mockUrlQuery.set('existingParam', 'existingValue');
mockUrlQuery.set('anotherParam', 'anotherValue');
renderWithTraceProvider(
render(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}

View File

@@ -1,111 +0,0 @@
import { RefObject, useEffect, useRef } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { IInterestedSpan } from '../types';
const MIN_SPANS_FOR_PAGINATION = 500;
interface UseBoundaryPaginationProps {
scrollContainerRef: RefObject<HTMLDivElement>;
spans: SpanV3[];
isFetching: boolean | undefined;
isFullDataLoaded: boolean;
setInterestedSpanId: (next: IInterestedSpan) => void;
}
interface UseBoundaryPaginationResult {
topSentinelRef: RefObject<HTMLDivElement>;
bottomSentinelRef: RefObject<HTMLDivElement>;
}
/**
* Drives load-more on a virtualized list via two `IntersectionObserver`
* sentinels (top + bottom of the inner content). The observer is created
* once and reads live state through refs — recreating it would re-fire
* IO's mandatory initial-intersection callback for sentinels still in view
* and produce a fetch spiral on every data update.
*
* Returns the two refs the caller must attach to its sentinel `<div>`s.
*/
export function useBoundaryPagination({
scrollContainerRef,
spans,
isFetching,
isFullDataLoaded,
setInterestedSpanId,
}: UseBoundaryPaginationProps): UseBoundaryPaginationResult {
const topSentinelRef = useRef<HTMLDivElement | null>(null);
const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
const spansRef = useRef<SpanV3[]>(spans);
const isFetchingRef = useRef<boolean | undefined>(isFetching);
const isFullDataLoadedRef = useRef<boolean>(isFullDataLoaded);
useEffect(() => {
spansRef.current = spans;
}, [spans]);
useEffect(() => {
isFetchingRef.current = isFetching;
}, [isFetching]);
useEffect(() => {
isFullDataLoadedRef.current = isFullDataLoaded;
}, [isFullDataLoaded]);
useEffect(() => {
const root = scrollContainerRef.current;
if (!root) {
return undefined;
}
const seenInitial = new WeakSet<Element>();
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!seenInitial.has(entry.target)) {
seenInitial.add(entry.target);
return;
}
if (
!entry.isIntersecting ||
isFetchingRef.current ||
isFullDataLoadedRef.current ||
spansRef.current.length < MIN_SPANS_FOR_PAGINATION
) {
return;
}
if (entry.target === bottomSentinelRef.current) {
const lastSpan = spansRef.current[spansRef.current.length - 1];
if (lastSpan) {
setInterestedSpanId({
spanId: lastSpan.span_id,
isUncollapsed: false,
});
}
} else if (entry.target === topSentinelRef.current) {
const firstSpan = spansRef.current[0];
if (firstSpan && firstSpan.level !== 0) {
setInterestedSpanId({
spanId: firstSpan.span_id,
isUncollapsed: false,
});
}
}
});
},
{ root, threshold: 0 },
);
if (bottomSentinelRef.current) {
observer.observe(bottomSentinelRef.current);
}
if (topSentinelRef.current) {
observer.observe(topSentinelRef.current);
}
return (): void => observer.disconnect();
}, [scrollContainerRef, setInterestedSpanId]);
return { topSentinelRef, bottomSentinelRef };
}

View File

@@ -1,5 +0,0 @@
export interface IInterestedSpan {
spanId: string;
isUncollapsed: boolean;
scrollToSpan?: boolean;
}

View File

@@ -1,131 +0,0 @@
import { useMemo } from 'react';
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Button } from '@signozhq/ui/button';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { GripVertical } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
function SortableField({
field,
onRemove,
allowDrag,
}: {
field: BaseAutocompleteData;
onRemove: (field: BaseAutocompleteData) => void;
allowDrag: boolean;
}): JSX.Element {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: field.key });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div
ref={setNodeRef}
style={style}
className={`fs-field-item ${allowDrag ? 'drag-enabled' : 'drag-disabled'}`}
>
<div {...attributes} {...listeners} className="drag-handle">
{allowDrag && <GripVertical size={14} />}
<span className="fs-field-key">{field.key}</span>
</div>
<Button
className="remove-field-btn periscope-btn"
variant="outlined"
color="destructive"
size="sm"
onClick={(): void => onRemove(field)}
>
Remove
</Button>
</div>
);
}
interface AddedFieldsProps {
inputValue: string;
fields: BaseAutocompleteData[];
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
}
function AddedFields({
inputValue,
fields,
onFieldsChange,
}: AddedFieldsProps): JSX.Element {
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = (event: DragEndEvent): void => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = fields.findIndex((f) => f.key === active.id);
const newIndex = fields.findIndex((f) => f.key === over.id);
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
}
};
const filteredFields = useMemo(
() =>
fields.filter((f) => f.key.toLowerCase().includes(inputValue.toLowerCase())),
[fields, inputValue],
);
const handleRemove = (field: BaseAutocompleteData): void => {
onFieldsChange(fields.filter((f) => f.key !== field.key));
};
const allowDrag = inputValue.length === 0;
return (
<div className="fs-section fs-added">
<div className="fs-section-header">ADDED FIELDS</div>
<div className="fs-added-list">
<OverlayScrollbar>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
{filteredFields.length === 0 ? (
<div className="fs-no-values">No values found</div>
) : (
<SortableContext
items={fields.map((f) => f.key)}
strategy={verticalListSortingStrategy}
disabled={!allowDrag}
>
{filteredFields.map((field) => (
<SortableField
key={field.key}
field={field}
onRemove={handleRemove}
allowDrag={allowDrag}
/>
))}
</SortableContext>
)}
</DndContext>
</OverlayScrollbar>
</div>
</div>
);
}
export default AddedFields;

View File

@@ -1,161 +0,0 @@
.fields-settings {
display: flex;
flex-direction: column;
height: 100%;
background: var(--l1-background);
color: var(--l1-foreground);
overflow: hidden;
.fs-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--l1-border);
.fs-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 500;
}
.fs-close-icon {
cursor: pointer;
color: var(--l2-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}
.fs-search {
.fs-search-input {
background-color: var(--l1-background);
height: 40px;
border-radius: 0;
border-left: none;
border-right: none;
}
}
.fs-section {
display: flex;
flex-direction: column;
&.fs-added {
max-height: 40%;
border-bottom: 1px solid var(--l1-border);
}
&.fs-other {
flex: 1;
min-height: 0;
}
}
.fs-section-header {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
padding: 8px 12px;
}
.fs-added-list {
overflow: hidden;
}
.fs-other-list {
flex: 1;
min-height: 0;
overflow: hidden;
.ant-skeleton-input {
width: 300px;
margin: 8px 12px;
}
}
.fs-no-values {
padding: 8px;
text-align: center;
color: var(--l2-foreground);
font-size: 12px;
}
.fs-limit-hint {
padding: 8px 12px;
text-align: center;
color: var(--muted-foreground);
font-size: 11px;
}
}
.fs-field-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-radius: 4px;
user-select: none;
font-size: 13px;
.drag-handle {
display: flex;
align-items: center;
gap: 8px;
flex-grow: 1;
}
.fs-field-key {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.drag-enabled {
cursor: grab;
&:active {
cursor: grabbing;
}
}
&.drag-disabled {
padding: 6px 12px;
}
&.other-field-item {
height: 32px;
}
.remove-field-btn,
.add-field-btn {
padding: 4px 10px;
opacity: 0;
transition: opacity 0.15s ease-in-out;
flex-shrink: 0;
}
&:hover {
background-color: var(--l2-background);
.remove-field-btn,
.add-field-btn {
opacity: 1;
}
}
}
.fs-footer {
display: flex;
gap: 12px;
padding: 12px;
border-top: 1px solid var(--l1-border);
justify-content: space-between;
}

View File

@@ -1,149 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import AddedFields from './AddedFields';
import OtherFields from './OtherFields';
import './FieldsSettings.styles.scss';
const MAX_FIELDS_DEFAULT = 10;
interface FieldsSettingsProps {
title: string;
// Picker's native shape (`BaseAutocompleteData`) is preserved end-to-end so
// downstream consumers (flamegraph `selectFields`, hover popovers) get full
// field metadata without a lossy conversion at add-time.
fields: BaseAutocompleteData[];
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
onClose: () => void;
dataSource: DataSource;
maxFields?: number;
}
function FieldsSettings({
title,
fields,
onFieldsChange,
onClose,
dataSource,
maxFields = MAX_FIELDS_DEFAULT,
}: FieldsSettingsProps): JSX.Element {
// Local draft state — changes here don't persist until Save
const [draftFields, setDraftFields] = useState<BaseAutocompleteData[]>(fields);
const [inputValue, setInputValue] = useState('');
const [debouncedInputValue, setDebouncedInputValue] = useState('');
const debouncedUpdate = useDebouncedFn((value) => {
setDebouncedInputValue(value as string);
}, 400);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
const value = e.target.value.trim().toLowerCase();
setInputValue(value);
debouncedUpdate(value);
},
[debouncedUpdate],
);
const handleAdd = useCallback(
(field: BaseAutocompleteData): void => {
if (draftFields.length >= maxFields) {
return;
}
if (draftFields.some((f) => f.key === field.key)) {
return;
}
setDraftFields((prev) => [...prev, field]);
},
[draftFields, maxFields],
);
const handleSave = useCallback((): void => {
onFieldsChange(draftFields);
toast.success('Saved successfully', {
position: 'top-right',
});
onClose();
}, [draftFields, onFieldsChange, onClose]);
const handleDiscard = useCallback((): void => {
setDraftFields(fields);
}, [fields]);
const hasUnsavedChanges = useMemo(
() =>
!(
draftFields.length === fields.length &&
draftFields.every((f, i) => f.key === fields[i]?.key)
),
[draftFields, fields],
);
const isAtLimit = draftFields.length >= maxFields;
return (
<div className="fields-settings">
<div className="fs-header">
<div className="fs-title">
<TableColumnsSplit size={16} />
{title}
</div>
<X className="fs-close-icon" size={16} onClick={onClose} />
</div>
<section className="fs-search">
<Input
className="fs-search-input"
type="text"
value={inputValue}
placeholder="Search for a field..."
onChange={handleInputChange}
/>
</section>
<AddedFields
inputValue={inputValue}
fields={draftFields}
onFieldsChange={setDraftFields}
/>
<OtherFields
dataSource={dataSource}
debouncedInputValue={debouncedInputValue}
addedFields={draftFields}
onAdd={handleAdd}
isAtLimit={isAtLimit}
/>
{hasUnsavedChanges && (
<div className="fs-footer">
<Button
variant="outlined"
color="secondary"
onClick={handleDiscard}
prefix={<X width={14} height={14} />}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
onClick={handleSave}
prefix={<Check width={14} height={14} />}
>
Save changes
</Button>
</div>
)}
</div>
);
}
export default FieldsSettings;

View File

@@ -1,99 +0,0 @@
import { useMemo } from 'react';
import { Button } from '@signozhq/ui/button';
import { Skeleton } from 'antd';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
interface OtherFieldsProps {
dataSource: DataSource;
debouncedInputValue: string;
addedFields: BaseAutocompleteData[];
onAdd: (field: BaseAutocompleteData) => void;
isAtLimit: boolean;
}
function OtherFields({
dataSource,
debouncedInputValue,
addedFields,
onAdd,
isAtLimit,
}: OtherFieldsProps): JSX.Element {
// API call to get available attribute keys
const { data, isFetching } = useGetAggregateKeys(
{
searchText: debouncedInputValue,
dataSource,
aggregateOperator: 'noop',
aggregateAttribute: '',
tagType: '',
},
{
queryKey: [
REACT_QUERY_KEY.GET_OTHER_FILTERS,
'preview-fields',
debouncedInputValue,
],
enabled: true,
},
);
// Filter out already-added fields, match on .key from API response objects
const otherFields = useMemo(() => {
const attributes = data?.payload?.attributeKeys || [];
const addedKeys = new Set(addedFields.map((f) => f.key));
return attributes.filter((attr) => !addedKeys.has(attr.key));
}, [data, addedFields]);
if (isFetching) {
return (
<div className="fs-section fs-other">
<div className="fs-section-header">OTHER FIELDS</div>
<div className="fs-other-list">
{Array.from({ length: 5 }).map((_, i) => (
// eslint-disable-next-line react/no-array-index-key
<Skeleton.Input active size="small" key={i} />
))}
</div>
</div>
);
}
return (
<div className="fs-section fs-other">
<div className="fs-section-header">OTHER FIELDS</div>
<div className="fs-other-list">
<OverlayScrollbar>
<>
{otherFields.length === 0 ? (
<div className="fs-no-values">No values found</div>
) : (
otherFields.map((attr) => (
<div key={attr.key} className="fs-field-item other-field-item">
<span className="fs-field-key">{attr.key}</span>
{!isAtLimit && (
<Button
className="add-field-btn periscope-btn"
variant="outlined"
color="secondary"
size="sm"
onClick={(): void => onAdd(attr)}
>
Add
</Button>
)}
</div>
))
)}
{isAtLimit && <div className="fs-limit-hint">Maximum 10 fields</div>}
</>
</OverlayScrollbar>
</div>
</div>
);
}
export default OtherFields;

View File

@@ -1,55 +0,0 @@
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export interface ColorByOption {
field: TelemetryFieldKey;
label: string;
}
export const COLOR_BY_OPTIONS: ColorByOption[] = [
{
field: {
name: 'service.name',
fieldContext: 'resource',
fieldDataType: 'string',
},
label: 'Service',
},
{
field: {
name: 'service.namespace',
fieldContext: 'resource',
fieldDataType: 'string',
},
label: 'Namespace',
},
{
field: {
name: 'host.name',
fieldContext: 'resource',
fieldDataType: 'string',
},
label: 'Host',
},
{
field: {
name: 'k8s.node.name',
fieldContext: 'resource',
fieldDataType: 'string',
},
label: 'Node',
},
{
field: {
name: 'k8s.container.name',
fieldContext: 'resource',
fieldDataType: 'string',
},
label: 'Container',
},
];
export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
(o) => o.field,
);
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];

View File

@@ -1,220 +0,0 @@
import {
// oxlint-disable-next-line no-restricted-imports
createContext,
ReactNode,
useCallback,
// oxlint-disable-next-line no-restricted-imports
useContext,
useMemo,
} from 'react';
import { useMutation } from 'react-query';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { themeColors } from 'constants/theme';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { useAppContext } from 'providers/App/App';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import {
SpanV3,
WaterfallAggregationResponse,
WaterfallAggregationType,
} from 'types/api/trace/getTraceV3';
import {
ColorByOption,
COLOR_BY_FIELDS,
COLOR_BY_OPTIONS,
DEFAULT_COLOR_BY_FIELD,
} from '../constants';
import { getSpanAttribute } from '../utils';
import {
AGGREGATIONS,
getAggregationMap as findAggregationMap,
} from '../utils/aggregations';
interface TraceContextValue {
colorByField: TelemetryFieldKey;
setColorByField: (field: TelemetryFieldKey) => void;
aggregations: WaterfallAggregationResponse[] | undefined;
getAggregationMap: (
type: WaterfallAggregationType,
) => Record<string, number> | undefined;
getSpanGroupValue: (span: SpanV3) => string;
resolveSpanColor: (span: SpanV3) => string;
/**
* Subset of COLOR_BY_OPTIONS whose data is populated on the current trace.
* `service.name` is always included; host/container only when their
* aggregation `value` map has entries.
*/
availableColorByOptions: ColorByOption[];
/**
* Per-user preview fields (selected via the floating "Preview fields"
* panel). Stored in `span_details_preview_attributes` user pref. Will be
* consumed by the flamegraph `selectFields` request and the waterfall +
* flamegraph hover popovers in follow-up phases.
*/
previewFields: BaseAutocompleteData[];
setPreviewFields: (next: BaseAutocompleteData[]) => void;
}
const TraceContext = createContext<TraceContextValue | null>(null);
export function TraceProvider({
aggregations,
children,
}: {
aggregations: WaterfallAggregationResponse[] | undefined;
children: ReactNode;
}): JSX.Element | null {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreferenceAPI,
);
// Source of truth: user-preferences API (loaded once on app init via
// AppProvider). Render nothing until it resolves so we never paint the
// default colour first and then swap to the user's persisted choice.
// AppProvider fires the prefs query as soon as the user is logged in, so
// this is usually already settled by the time TraceDetailsV3 mounts.
const persistedColorByField = useMemo<TelemetryFieldKey>(() => {
const pref = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
);
const name = (pref?.value as string) || '';
return COLOR_BY_FIELDS.find((f) => f.name === name) ?? DEFAULT_COLOR_BY_FIELD;
}, [userPreferences]);
const setColorByField = useCallback(
(field: TelemetryFieldKey): void => {
// Optimistically reflect the choice in the in-memory cache so the UI
// reacts immediately (the GET /user/preferences response on app init
// always includes the registered key, so `existing` will be defined).
const existing = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
);
if (existing) {
updateUserPreferenceInContext({ ...existing, value: field.name });
}
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
value: field.name,
});
},
[
userPreferences,
updateUserPreferenceInContext,
updateUserPreferenceMutation,
],
);
const previewFields = useMemo<BaseAutocompleteData[]>(() => {
const pref = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
);
const raw = (pref?.value as BaseAutocompleteData[] | undefined) ?? [];
// Defensive: keep only entries that have a string `key`.
return raw.filter(
(f): f is BaseAutocompleteData =>
typeof f === 'object' &&
f !== null &&
typeof (f as { key?: unknown }).key === 'string',
);
}, [userPreferences]);
const setPreviewFields = useCallback(
(next: BaseAutocompleteData[]): void => {
const existing = userPreferences?.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
);
if (existing) {
updateUserPreferenceInContext({ ...existing, value: next });
}
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
value: next,
});
},
[
userPreferences,
updateUserPreferenceInContext,
updateUserPreferenceMutation,
],
);
const value = useMemo<TraceContextValue>(() => {
const isFieldAvailable = (fieldName: string): boolean => {
if (fieldName === DEFAULT_COLOR_BY_FIELD.name) {
return true;
}
// Pick any aggregation type — if execution_time_percentage is empty,
// span_count for the same field will be too (both are derived from
// the same set of spans).
const map = findAggregationMap(
aggregations,
AGGREGATIONS.EXEC_TIME_PCT,
fieldName,
);
return !!map && Object.keys(map).length > 0;
};
const availableColorByOptions = COLOR_BY_OPTIONS.filter((opt) =>
isFieldAvailable(opt.field.name),
);
const colorByField =
aggregations === undefined || isFieldAvailable(persistedColorByField.name)
? persistedColorByField
: DEFAULT_COLOR_BY_FIELD;
const getAggregationMap = (
type: WaterfallAggregationType,
): Record<string, number> | undefined =>
findAggregationMap(aggregations, type, colorByField.name);
const getSpanGroupValue = (span: SpanV3): string =>
getSpanAttribute(span, colorByField.name) || 'unknown';
const resolveSpanColor = (span: SpanV3): string => {
if (span.has_error) {
return 'var(--bg-cherry-500)';
}
return generateColor(
getSpanGroupValue(span),
themeColors.traceDetailColorsV3,
);
};
return {
colorByField,
setColorByField,
aggregations,
getAggregationMap,
getSpanGroupValue,
resolveSpanColor,
availableColorByOptions,
previewFields,
setPreviewFields,
};
}, [
persistedColorByField,
aggregations,
setColorByField,
previewFields,
setPreviewFields,
]);
if (!userPreferences) {
return null;
}
return <TraceContext.Provider value={value}>{children}</TraceContext.Provider>;
}
export function useTraceContext(): TraceContextValue {
const ctx = useContext(TraceContext);
if (!ctx) {
throw new Error('useTraceContext must be used inside TraceProvider');
}
return ctx;
}

View File

@@ -1,106 +0,0 @@
import { useEffect, useRef } from 'react';
import { useMutation } from 'react-query';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { isV3PinnedAttribute } from 'pages/TraceDetailsV3/utils';
import { serializeKeyPath } from 'periscope/components/PrettyView/utils';
import { useAppContext } from 'providers/App/App';
import { SpanV3 } from 'types/api/trace/getTraceV3';
/**
* V2 stored pinned attributes as flat strings (`["http.method"]`).
* V3 stores nested key paths (`['["attributes","http.method"]']`).
*
* On first load with both `userPreferences` and `selectedSpan` available,
* detect a V2-format value in the backend pref and convert it to V3 paths
* using the loaded span's shape. Idempotent: once written in V3 format the
* format check on subsequent loads short-circuits.
*
* Unmappable keys (not present on the loaded span) are dropped — we can't
* determine whether they belong under `attributes`, `resource`, or top-level.
*/
export function useMigratePinnedAttributes(
selectedSpan: SpanV3 | undefined,
): void {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate } = useMutation(updateUserPreferenceAPI);
const ranRef = useRef(false);
useEffect(() => {
if (ranRef.current) {
return;
}
if (!userPreferences || !selectedSpan) {
return;
}
const pref = userPreferences.find(
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
);
const value = (pref?.value as string[] | undefined) ?? [];
if (value.length === 0) {
ranRef.current = true;
return;
}
// Walk every entry — the array may be mixed (e.g. some legacy V2 flat
// keys saved alongside V3 paths). V3 entries pass through unchanged;
// V2 entries get converted via the loaded span; unmappable V2 entries
// are dropped. We only persist when at least one V2 entry was found
// (otherwise the input is already V3-clean).
const next: string[] = [];
let hadV2Entry = false;
for (const entry of value) {
if (isV3PinnedAttribute(entry)) {
next.push(entry);
continue;
}
hadV2Entry = true;
const path = v2KeyToPath(entry, selectedSpan);
if (path) {
next.push(serializeKeyPath(path));
}
// else: unmappable on the loaded span — drop
}
if (!hadV2Entry) {
ranRef.current = true;
return;
}
if (pref) {
updateUserPreferenceInContext({ ...pref, value: next });
}
mutate(
{
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
value: next,
},
{
onSuccess: () => {
ranRef.current = true;
},
onError: () => {
// Roll back the optimistic context update
if (pref) {
updateUserPreferenceInContext({ ...pref, value });
}
},
},
);
}, [userPreferences, selectedSpan, updateUserPreferenceInContext, mutate]);
}
function v2KeyToPath(key: string, span: SpanV3): (string | number)[] | null {
if (span.attributes && key in span.attributes) {
return ['attributes', key];
}
if (span.resource && key in span.resource) {
return ['resource', key];
}
if (key in (span as unknown as Record<string, unknown>)) {
return [key];
}
return null;
}

View File

@@ -12,23 +12,16 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import NoData from 'pages/TraceDetailV2/NoData/NoData';
import { ResizableBox } from 'periscope/components/ResizableBox';
import {
SpanV3,
TraceDetailV3URLProps,
WaterfallAggregationRequest,
} from 'types/api/trace/getTraceV3';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { COLOR_BY_FIELDS } from './constants';
import { TraceProvider } from './contexts/TraceContext';
import { AGGREGATIONS } from './utils/aggregations';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from './TraceWaterfall/types';
import TraceWaterfall, {
IInterestedSpan,
} from './TraceWaterfall/TraceWaterfall';
import { getAncestorSpanIds } from './TraceWaterfall/utils';
import './TraceDetailsV3.styles.scss';
@@ -86,17 +79,6 @@ function TraceDetailsV3(): JSX.Element {
});
}, [urlQuery]);
// Hardcoded for now — fetch aggregations for all 3 candidate color-by fields
// upfront so a future color-by-field switch doesn't need to refetch.
const waterfallAggregationsRequest = useMemo<WaterfallAggregationRequest[]>(
() =>
COLOR_BY_FIELDS.flatMap((field) => [
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
]),
[],
);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
@@ -104,7 +86,6 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
});
const queryParams = fullDataLoadedRef.current
@@ -113,7 +94,6 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
const {
@@ -125,7 +105,6 @@ function TraceDetailsV3(): JSX.Element {
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
aggregations: queryParams.aggregations,
});
const allSpans = traceData?.payload?.spans || [];
@@ -140,7 +119,6 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
}
@@ -235,23 +213,6 @@ function TraceDetailsV3(): JSX.Element {
],
);
const traceMetadataForHeader = useMemo(():
| TraceMetadataForHeader
| undefined => {
const payload = traceData?.payload;
if (!payload) {
return undefined;
}
const rootSpan = payload.spans?.find((s) => s.level === 0);
return {
startTimestampMillis: payload.startTimestampMillis,
endTimestampMillis: payload.endTimestampMillis,
rootServiceName: payload.rootServiceName,
rootServiceEntryPoint: payload.rootServiceEntryPoint,
rootSpanStatusCode: rootSpan?.response_status_code || '',
};
}, [traceData?.payload]);
const showNoData =
!isFetchingTraceData &&
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
@@ -285,116 +246,104 @@ function TraceDetailsV3(): JSX.Element {
);
return (
<TraceProvider aggregations={traceData?.payload?.aggregations}>
<div className="trace-details-v3">
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
traceMetadata={traceMetadataForHeader}
/>
<div className="trace-details-v3">
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
noData={showNoData}
/>
{showNoData ? (
<NoData />
) : (
<>
<div className="trace-details-v3__content">
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
className="trace-details-v3__flame-collapse"
items={[
{
key: 'flame',
label: (
<div className="trace-details-v3__collapse-label">
<span>Flame Graph</span>
{traceData?.payload?.totalSpansCount ? (
<span className="trace-details-v3__collapse-count">
<span>Spans: {traceData.payload.totalSpansCount}</span>
<span
className={
traceData.payload.totalErrorSpansCount > 0
? 'trace-details-v3__collapse-count-errors'
: undefined
}
>
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
</span>
{traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
placement="bottomRight"
autoAdjustOverflow={false}
/>
)}
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
),
},
]}
/>
{showNoData ? (
<NoData />
) : (
<>
<div className="trace-details-v3__content">
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'flame')}
onChange={(): void => handleCollapseChange('flame')}
size="small"
className="trace-details-v3__flame-collapse"
items={[
{
key: 'flame',
label: (
<div className="trace-details-v3__collapse-label">
<span>Flame Graph</span>
{traceData?.payload?.totalSpansCount ? (
<span className="trace-details-v3__collapse-count">
{traceData.payload.totalSpansCount} spans
{traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
<WarningPopover
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans."
placement="bottomRight"
autoAdjustOverflow={false}
/>
)}
</span>
) : null}
</div>
),
children: (
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
<TraceFlamegraph
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
</ResizableBox>
),
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
className={`trace-details-v3__waterfall-collapse${
isWaterfallDocked
? ' trace-details-v3__waterfall-collapse--docked'
: ''
}`}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
<Collapse
// @ts-expect-error motion is passed through to rc-collapse to disable animation
motion={false}
activeKey={activeKeys.filter((k) => k === 'waterfall')}
onChange={(): void => handleCollapseChange('waterfall')}
size="small"
className={`trace-details-v3__waterfall-collapse${
isWaterfallDocked ? ' trace-details-v3__waterfall-collapse--docked' : ''
}`}
items={[
{
key: 'waterfall',
label: 'Waterfall',
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
},
]}
/>
{panelState.isOpen && isDocked && (
<div className="trace-details-v3__docked-span-details">
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
</div>
)}
</div>
{panelState.isOpen && !isDocked && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
/>
{panelState.isOpen && isDocked && (
<div className="trace-details-v3__docked-span-details">
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DOCKED}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap}
/>
</div>
)}
</>
)}
</div>
</TraceProvider>
</div>
{panelState.isOpen && !isDocked && (
<SpanDetailsPanel
panelState={panelState}
selectedSpan={selectedSpan}
variant={SpanDetailVariant.DIALOG}
onVariantChange={handleVariantChange}
traceStartTime={traceData?.payload?.startTimestampMillis}
traceEndTime={traceData?.payload?.endTimestampMillis}
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap}
/>
)}
</>
)}
</div>
);
}

View File

@@ -3,17 +3,10 @@ import { SpanV3 } from 'types/api/trace/getTraceV3';
/**
* Look up an attribute from both `resource` and `attributes` on a span.
* Resources are checked first (service.name, k8s.* etc. live there).
*
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
* structurally — the only fields touched are `resource` and `attributes`.
*
* TODO: Remove tagMap fallback when phasing out V2
*/
export function getSpanAttribute(
span: {
resource?: Record<string, string>;
attributes?: Record<string, any>;
},
span: SpanV3,
key: string,
): string | undefined {
return (
@@ -38,40 +31,3 @@ export function hasInfraMetadata(span: SpanV3 | undefined): boolean {
}
return INFRA_METADATA_KEYS.some((key) => getSpanAttribute(span, key));
}
// Top-level fields that exist on the API response only to support waterfall
// rendering. They have no value in the Span Details DataViewer. Drop the whole
// constant + helper once the backend stops emitting them.
const HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW: ReadonlySet<string> = new Set([
'sub_tree_node_count',
'has_children',
'level',
'service.name',
]);
/**
* Shallow-copies the span with waterfall-only fields stripped, for display in
* the Span Details DataViewer.
*/
export function getSpanDisplayData(span: SpanV3): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(span)) {
if (!HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW.has(key)) {
result[key] = value;
}
}
return result;
}
/**
* V3 pinned-attribute entries are JSON-stringified arrays (e.g.
* `'["attributes","http.method"]'`). Legacy V2 entries are flat strings.
* Used to distinguish V3 from V2 entries when reading the persisted value.
*/
export function isV3PinnedAttribute(entry: string): boolean {
try {
return Array.isArray(JSON.parse(entry));
} catch {
return false;
}
}

View File

@@ -1,20 +0,0 @@
import {
WaterfallAggregationResponse,
WaterfallAggregationType,
} from 'types/api/trace/getTraceV3';
export const AGGREGATIONS = {
EXEC_TIME_PCT: 'execution_time_percentage',
SPAN_COUNT: 'span_count',
DURATION: 'duration',
} as const satisfies Record<string, WaterfallAggregationType>;
export function getAggregationMap(
aggregations: WaterfallAggregationResponse[] | undefined,
type: WaterfallAggregationType,
fieldName: string,
): Record<string, number> | undefined {
return aggregations?.find(
(a) => a.aggregation === type && a.field.name === fieldName,
)?.value;
}

View File

@@ -1,77 +0,0 @@
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
FieldContext,
FieldDataType,
TelemetryFieldKey,
} from 'types/api/v5/queryRange';
/**
* Map the picker's `BaseAutocompleteData.type` (`'tag' | 'resource' | '' | null`)
* to the API's `FieldContext`. Unknown values fall back to `'attribute'`.
*/
function mapFieldContext(type: BaseAutocompleteData['type']): FieldContext {
if (type === 'resource') {
return 'resource';
}
return 'attribute';
}
const DATA_TYPE_MAP: Record<DataTypes, FieldDataType> = {
[DataTypes.String]: 'string',
[DataTypes.bool]: 'bool',
[DataTypes.Int64]: 'int64',
[DataTypes.Float64]: 'float64',
[DataTypes.ArrayString]: '[]string',
[DataTypes.ArrayBool]: '[]bool',
[DataTypes.ArrayInt64]: '[]int64',
[DataTypes.ArrayFloat64]: '[]float64',
[DataTypes.EMPTY]: 'string',
};
function mapFieldDataType(
dataType: BaseAutocompleteData['dataType'],
): FieldDataType {
if (!dataType) {
return 'string';
}
return DATA_TYPE_MAP[dataType] ?? 'string';
}
/**
* Convert a picker-shaped field to the API's `TelemetryFieldKey` shape used
* for `selectFields` on the flamegraph request.
*/
export function toTelemetryFieldKey(
field: BaseAutocompleteData,
): TelemetryFieldKey {
return {
name: field.key,
fieldContext: mapFieldContext(field.type),
fieldDataType: mapFieldDataType(field.dataType),
};
}
/**
* Merge two `TelemetryFieldKey` lists, de-duping by `name`. Earlier entries win
* (so callers can pass user-controlled fields after a baseline list and have
* the baseline's metadata be preserved).
*/
export function mergeTelemetryFieldKeys(
...lists: TelemetryFieldKey[][]
): TelemetryFieldKey[] {
const seen = new Set<string>();
const out: TelemetryFieldKey[] = [];
for (const list of lists) {
for (const f of list) {
if (seen.has(f.name)) {
continue;
}
seen.add(f.name);
out.push(f);
}
}
return out;
}

View File

@@ -23,14 +23,7 @@ const editorOptions: EditorProps['options'] = {
lineHeight: 18,
colorDecorators: true,
scrollBeyondLastLine: false,
scrollbar: {
vertical: 'hidden',
horizontal: 'hidden',
// Once the editor can't scroll any further, release the wheel event so
// the parent container picks it up. Without this Monaco swallows the
// event at the boundary and outer scroll feels stuck.
alwaysConsumeMouseWheel: false,
},
scrollbar: { vertical: 'hidden', horizontal: 'hidden' },
folding: false,
};

View File

@@ -56,13 +56,6 @@ export interface PrettyViewProps {
searchable?: boolean;
showPinned?: boolean;
drawerKey?: string;
/**
* Controlled list of pinned key paths (each entry is `JSON.stringify(path)`).
* When provided, PrettyView delegates persistence to the caller via
* `onPinnedFieldsChange` and skips its own localStorage I/O.
*/
pinnedFieldsValue?: string[];
onPinnedFieldsChange?: (next: string[]) => void;
}
function PrettyView({
@@ -72,8 +65,6 @@ function PrettyView({
searchable = true,
showPinned = false,
drawerKey = 'default',
pinnedFieldsValue,
onPinnedFieldsChange,
}: PrettyViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [, setCopy] = useCopyToClipboard();
@@ -84,10 +75,7 @@ function PrettyView({
pinnedEntries,
pinnedData,
displayKeyToForwardPath,
} = usePinnedFields(data, drawerKey, {
value: pinnedFieldsValue,
onChange: onPinnedFieldsChange,
});
} = usePinnedFields(data, drawerKey);
const filteredPinnedData = useMemo(() => {
const trimmed = searchQuery.trim();

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
@@ -42,58 +42,17 @@ export interface UsePinnedFieldsReturn {
displayKeyToForwardPath: Record<string, (string | number)[]>;
}
export interface UsePinnedFieldsOptions {
/**
* Initial / controlled list of serialized key paths.
* When provided, overrides the default localStorage read.
*/
value?: string[];
/**
* Called whenever the pin set changes. When provided, the caller is
* responsible for persistence (e.g. backend user preference).
*/
onChange?: (next: string[]) => void;
}
/**
* Persistence behavior:
* - Controlled (`options.value`/`options.onChange` provided) → caller drives
* state and persistence. localStorage is not touched, regardless of
* `drawerKey`.
* - Uncontrolled with `drawerKey` → reads/writes `pinnedFields:${drawerKey}`
* in localStorage.
* - Uncontrolled without `drawerKey` → in-memory only (no persistence).
*/
function usePinnedFields(
data: AnyRecord,
drawerKey?: string,
options?: UsePinnedFieldsOptions,
drawerKey: string,
): UsePinnedFieldsReturn {
const controlledValue = options?.value;
const onChange = options?.onChange;
const isControlled = controlledValue !== undefined || onChange !== undefined;
const storageKey =
!isControlled && drawerKey ? `${STORAGE_PREFIX}:${drawerKey}` : null;
const storageKey = `${STORAGE_PREFIX}:${drawerKey}`;
// Stored as serialized keyPath arrays (JSON strings)
const [pinnedSerializedKeys, setPinnedSerializedKeys] = useState<Set<string>>(
() => {
if (controlledValue) {
return new Set(controlledValue);
}
if (storageKey) {
return new Set(loadFromStorage(storageKey));
}
return new Set();
},
() => new Set(loadFromStorage(storageKey)),
);
// Sync state with the controlled value when it changes externally.
useEffect(() => {
if (controlledValue) {
setPinnedSerializedKeys(new Set(controlledValue));
}
}, [controlledValue]);
const togglePin = useCallback(
(forwardPath: (string | number)[]): void => {
const serialized = serializeKeyPath(forwardPath);
@@ -104,17 +63,11 @@ function usePinnedFields(
} else {
next.add(serialized);
}
const arr = Array.from(next);
if (storageKey) {
saveToStorage(storageKey, arr);
}
if (onChange) {
onChange(arr);
}
saveToStorage(storageKey, Array.from(next));
return next;
});
},
[storageKey, onChange],
[storageKey],
);
const isPinned = useCallback(

View File

@@ -1,5 +1,3 @@
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export interface TraceDetailFlamegraphURLProps {
id: string;
}
@@ -8,7 +6,6 @@ export interface GetTraceFlamegraphPayloadProps {
traceId: string;
selectedSpanId?: string;
limit?: number;
selectFields?: TelemetryFieldKey[];
}
export interface Event {
@@ -29,8 +26,6 @@ export interface FlamegraphSpan {
name: string;
level: number;
event: Event[];
resource?: Record<string, string>;
attributes?: Record<string, any>;
}
export interface GetTraceFlamegraphSuccessResponse {

View File

@@ -1,26 +1,9 @@
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export type WaterfallAggregationType =
| 'span_count'
| 'execution_time_percentage'
| 'duration';
export interface WaterfallAggregationRequest {
field: TelemetryFieldKey;
aggregation: WaterfallAggregationType;
}
export interface WaterfallAggregationResponse extends WaterfallAggregationRequest {
value: Record<string, number>;
}
export interface GetTraceV3PayloadProps {
traceId: string;
selectedSpanId: string;
uncollapsedSpans: string[];
isSelectedSpanIDUnCollapsed: boolean;
limit?: number; // Optional limit for number of spans to fetch, default can be set in API
aggregations?: WaterfallAggregationRequest[];
}
export interface TraceDetailV3URLProps {
@@ -98,5 +81,5 @@ export interface GetTraceV3SuccessResponse {
totalErrorSpansCount: number;
rootServiceName: string;
rootServiceEntryPoint: string;
aggregations?: WaterfallAggregationResponse[];
serviceNameToTotalDurationMap: Record<string, number>;
}

View File

@@ -19,7 +19,6 @@ export type ServerError = 500;
export type SuccessStatusCode = Created | Success | SuccessNoContent;
export type ErrorStatusCode =
| Forbidden
| Forbidden
| Unauthorized
| NotFound

View File

@@ -34,7 +34,7 @@ describe('extractQueryPairs', () => {
valueEnd: undefined,
valueStart: undefined,
},
isComplete: true,
isComplete: false,
},
{
key: 'name',
@@ -204,7 +204,7 @@ describe('extractQueryPairs', () => {
key: 'active',
operator: 'EXISTS',
value: undefined,
isComplete: true,
isComplete: false,
}),
]);
});
@@ -388,7 +388,7 @@ describe('extractQueryPairs', () => {
expect(result[0].operator).toBe('exists');
expect(result[0].value).toBeUndefined();
expect(result[0].valuesPosition).toStrictEqual([]);
expect(result[0].isComplete).toBe(true);
expect(result[0].isComplete).toBe(false);
expect(result[1].key).toBe('service.name');
expect(result[1].operator).toBe('contains');
expect(result[1].value).toBe('"test"');

View File

@@ -1208,7 +1208,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
isComplete: !!(
currentPair.key &&
currentPair.operator &&
(currentPair.value || isNonValueOperator(currentPair.operator))
currentPair.value
),
} as IQueryPair);
}
@@ -1369,7 +1369,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
isComplete: !!(
currentPair.key &&
currentPair.operator &&
(currentPair.value || isNonValueOperator(currentPair.operator))
currentPair.value
),
} as IQueryPair);
@@ -1414,7 +1414,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
isComplete: !!(
currentPair.key &&
currentPair.operator &&
(currentPair.value || isNonValueOperator(currentPair.operator))
currentPair.value
),
} as IQueryPair);
}

View File

@@ -0,0 +1,22 @@
type Builtin =
| string
| number
| boolean
| bigint
| symbol
| null
| undefined
| Function // eslint-disable-line
| Date
| RegExp
| Error;
export type DeepPartial<T> = T extends Builtin
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;

View File

@@ -24,6 +24,7 @@ import (
"github.com/prometheus/alertmanager/dispatch"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/provider/mem"
promTypes "github.com/prometheus/alertmanager/types"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
@@ -364,7 +365,7 @@ route:
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
route := dispatch.NewRoute(conf.Route, nil)
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
marker := promTypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)
@@ -637,7 +638,7 @@ route:
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
route := dispatch.NewRoute(conf.Route, nil)
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
marker := promTypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)
@@ -896,7 +897,7 @@ route:
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
route := dispatch.NewRoute(conf.Route, nil)
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
marker := promTypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)
@@ -1158,7 +1159,7 @@ func newAlert(labels model.LabelSet) *alertmanagertypes.Alert {
func TestDispatcherRace(t *testing.T) {
logger := promslog.NewNopLogger()
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
marker := promTypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)
@@ -1194,7 +1195,7 @@ route:
route := dispatch.NewRoute(conf.Route, nil)
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
marker := promTypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)
@@ -1264,7 +1265,7 @@ route:
func TestDispatcher_DoMaintenance(t *testing.T) {
r := prometheus.NewRegistry()
marker := alertmanagertypes.NewMarker(r)
marker := promTypes.NewMarker(r)
alerts, err := mem.NewAlerts(context.Background(), marker, time.Minute, 0, nil, promslog.NewNopLogger(), prometheus.NewRegistry(), nil)
if err != nil {
@@ -1370,7 +1371,7 @@ route:
providerSettings := createTestProviderSettings()
logger := providerSettings.Logger
route := dispatch.NewRoute(conf.Route, nil)
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
marker := promTypes.NewMarker(prometheus.NewRegistry())
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
if err != nil {
t.Fatal(err)

View File

@@ -0,0 +1,96 @@
package alertmanagerserver
import (
"context"
"log/slog"
"sync"
"time"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
// MaintenanceMuter implements types.Muter for maintenance windows.
// It suppresses alerts whose ruleId label matches an active maintenance schedule.
// Results are cached for cacheTTL to avoid a DB query on every per-alert check.
type MaintenanceMuter struct {
maintenanceStore alertmanagertypes.MaintenanceStore
orgID string
logger *slog.Logger
mu sync.RWMutex
cached []*alertmanagertypes.PlannedMaintenance
cacheExpiry time.Time
}
const maintenanceCacheTTL = 30 * time.Second
func NewMaintenanceMuter(store alertmanagertypes.MaintenanceStore, orgID string, logger *slog.Logger) *MaintenanceMuter {
return &MaintenanceMuter{
maintenanceStore: store,
orgID: orgID,
logger: logger,
}
}
func (m *MaintenanceMuter) Mutes(ctx context.Context, lset model.LabelSet) bool {
ruleID := string(lset[ruletypes.AlertRuleIDLabel])
if ruleID == "" {
return false
}
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now, lset) {
return true
}
}
return false
}
// MutedBy returns the IDs of all active maintenance windows currently
// suppressing the alert identified by lset. It is used to populate the
// `mutedBy` field on the v2 API alert response so that maintenance-suppressed
// alerts surface as `state=suppressed` in GetAlerts responses.
func (m *MaintenanceMuter) MutedBy(ctx context.Context, lset model.LabelSet) []string {
ruleID := string(lset[ruletypes.AlertRuleIDLabel])
if ruleID == "" {
return nil
}
var ids []string
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now, lset) {
ids = append(ids, mw.ID.String())
}
}
return ids
}
func (m *MaintenanceMuter) getMaintenances(ctx context.Context) []*alertmanagertypes.PlannedMaintenance {
m.mu.RLock()
if time.Now().Before(m.cacheExpiry) {
cached := m.cached
m.mu.RUnlock()
return cached
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
// Double-check after acquiring write lock.
if time.Now().Before(m.cacheExpiry) {
return m.cached
}
mws, err := m.maintenanceStore.ListPlannedMaintenance(ctx, m.orgID)
if err != nil {
m.logger.ErrorContext(ctx, "failed to list planned maintenance windows; alerts will not be suppressed", slog.String("org_id", m.orgID))
return m.cached // return stale (potentially empty) cache on error
}
m.cached = mws
m.cacheExpiry = time.Now().Add(maintenanceCacheTTL)
return m.cached
}

View File

@@ -0,0 +1,246 @@
package alertmanagerserver
import (
"context"
"errors"
"log/slog"
"sort"
"sync"
"testing"
"time"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// fakeMaintenanceStore implements alertmanagertypes.MaintenanceStore with an in-memory list.
// It counts ListPlannedMaintenance calls so tests can assert caching behavior.
type fakeMaintenanceStore struct {
mu sync.Mutex
items []*alertmanagertypes.PlannedMaintenance
err error
calls int
}
func (s *fakeMaintenanceStore) ListPlannedMaintenance(_ context.Context, _ string) ([]*alertmanagertypes.PlannedMaintenance, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls++
if s.err != nil {
return nil, s.err
}
return s.items, nil
}
func (s *fakeMaintenanceStore) callCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.calls
}
func (s *fakeMaintenanceStore) setError(err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.err = err
}
// Remaining MaintenanceStore methods — unused by MaintenanceMuter.
func (s *fakeMaintenanceStore) CreatePlannedMaintenance(context.Context, *alertmanagertypes.PostablePlannedMaintenance) (*alertmanagertypes.PlannedMaintenance, error) {
return nil, nil
}
func (s *fakeMaintenanceStore) DeletePlannedMaintenance(context.Context, valuer.UUID) error {
return nil
}
func (s *fakeMaintenanceStore) GetPlannedMaintenanceByID(context.Context, valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
return nil, nil
}
func (s *fakeMaintenanceStore) UpdatePlannedMaintenance(context.Context, *alertmanagertypes.PostablePlannedMaintenance, valuer.UUID) error {
return nil
}
func newMuter(t *testing.T, items ...*alertmanagertypes.PlannedMaintenance) (*MaintenanceMuter, *fakeMaintenanceStore) {
t.Helper()
store := &fakeMaintenanceStore{items: items}
muter := NewMaintenanceMuter(store, "org-1", slog.New(slog.DiscardHandler))
return muter, store
}
// activeFixed builds a fixed-time maintenance window that brackets now.
// ruleIDs scope the window; an empty slice matches every rule.
func activeFixed(ruleIDs ...string) *alertmanagertypes.PlannedMaintenance {
now := time.Now().UTC()
return &alertmanagertypes.PlannedMaintenance{
ID: valuer.GenerateUUID(),
Schedule: &alertmanagertypes.Schedule{
Timezone: "UTC",
StartTime: now.Add(-time.Hour),
EndTime: now.Add(time.Hour),
},
RuleIDs: ruleIDs,
}
}
// futureFixed builds a fixed-time maintenance window that starts in the future.
func futureFixed(ruleIDs ...string) *alertmanagertypes.PlannedMaintenance {
now := time.Now().UTC()
return &alertmanagertypes.PlannedMaintenance{
ID: valuer.GenerateUUID(),
Schedule: &alertmanagertypes.Schedule{
Timezone: "UTC",
StartTime: now.Add(time.Hour),
EndTime: now.Add(2 * time.Hour),
},
RuleIDs: ruleIDs,
}
}
func labelsFor(ruleID string) model.LabelSet {
return model.LabelSet{model.LabelName(ruletypes.AlertRuleIDLabel): model.LabelValue(ruleID)}
}
func TestMutes_EmptyRuleIDLabel(t *testing.T) {
muter, store := newMuter(t, activeFixed())
assert.False(t, muter.Mutes(context.Background(), model.LabelSet{}))
// Short-circuit: no store lookup needed when the label is missing.
assert.Equal(t, 0, store.callCount())
}
func TestMutes_NoMaintenanceWindows(t *testing.T) {
muter, _ := newMuter(t)
assert.False(t, muter.Mutes(context.Background(), labelsFor("rule-1")))
}
func TestMutes_MatchingRule(t *testing.T) {
mw := activeFixed("rule-1", "rule-2")
muter, _ := newMuter(t, mw)
assert.True(t, muter.Mutes(context.Background(), labelsFor("rule-1")))
assert.True(t, muter.Mutes(context.Background(), labelsFor("rule-2")))
}
func TestMutes_NonMatchingRule(t *testing.T) {
muter, _ := newMuter(t, activeFixed("rule-1"))
assert.False(t, muter.Mutes(context.Background(), labelsFor("rule-other")))
}
func TestMutes_EmptyRuleIDsMatchesAllRules(t *testing.T) {
// A maintenance with no RuleIDs is treated as scoping every rule.
muter, _ := newMuter(t, activeFixed())
assert.True(t, muter.Mutes(context.Background(), labelsFor("any-rule")))
}
func TestMutes_FutureWindowDoesNotMute(t *testing.T) {
muter, _ := newMuter(t, futureFixed("rule-1"))
assert.False(t, muter.Mutes(context.Background(), labelsFor("rule-1")))
}
func TestMutes_AnyOfMultipleWindowsMatches(t *testing.T) {
muter, _ := newMuter(t,
futureFixed("rule-1"),
activeFixed("rule-1"),
)
assert.True(t, muter.Mutes(context.Background(), labelsFor("rule-1")))
}
func TestMutedBy_EmptyRuleIDLabel(t *testing.T) {
muter, store := newMuter(t, activeFixed())
assert.Nil(t, muter.MutedBy(context.Background(), model.LabelSet{}))
assert.Equal(t, 0, store.callCount())
}
func TestMutedBy_NoMatches(t *testing.T) {
muter, _ := newMuter(t, activeFixed("rule-1"), futureFixed("rule-1"))
assert.Nil(t, muter.MutedBy(context.Background(), labelsFor("rule-other")))
}
func TestMutedBy_ReturnsIDsOfAllActiveMatchingWindows(t *testing.T) {
mw1 := activeFixed("rule-1")
mw2 := activeFixed() // matches all rules
mw3 := futureFixed("rule-1")
mw4 := activeFixed("rule-other")
muter, _ := newMuter(t, mw1, mw2, mw3, mw4)
ids := muter.MutedBy(context.Background(), labelsFor("rule-1"))
want := []string{mw1.ID.String(), mw2.ID.String()}
sort.Strings(want)
sort.Strings(ids)
assert.Equal(t, want, ids)
}
func TestCache_RepeatedCallsHitStoreOnce(t *testing.T) {
muter, store := newMuter(t, activeFixed("rule-1"))
ctx := context.Background()
for i := 0; i < 5; i++ {
require.True(t, muter.Mutes(ctx, labelsFor("rule-1")))
}
assert.Equal(t, 1, store.callCount(), "cached results should suppress further store lookups within TTL")
}
func TestCache_StoreErrorReturnsStaleCache(t *testing.T) {
mw := activeFixed("rule-1")
muter, store := newMuter(t, mw)
ctx := context.Background()
// First call populates the cache from a working store.
require.True(t, muter.Mutes(ctx, labelsFor("rule-1")))
require.Equal(t, 1, store.callCount())
// Force cache to be considered expired so the next call re-fetches.
muter.mu.Lock()
muter.cacheExpiry = time.Time{}
muter.mu.Unlock()
// Store now errors. The muter should fall back to the previously cached value
// (i.e. still mute rule-1) rather than returning false.
store.setError(errors.New("boom"))
assert.True(t, muter.Mutes(ctx, labelsFor("rule-1")),
"on store error, muter should keep using the last known cache to avoid losing suppression")
assert.Equal(t, 2, store.callCount())
}
func TestCache_ExpiredCacheRefetchesUpdatedData(t *testing.T) {
mw := activeFixed("rule-1")
muter, store := newMuter(t, mw)
ctx := context.Background()
require.True(t, muter.Mutes(ctx, labelsFor("rule-1")))
require.Equal(t, 1, store.callCount())
// Drop the maintenance window from the store and expire the cache.
store.mu.Lock()
store.items = nil
store.mu.Unlock()
muter.mu.Lock()
muter.cacheExpiry = time.Time{}
muter.mu.Unlock()
assert.False(t, muter.Mutes(ctx, labelsFor("rule-1")))
assert.Equal(t, 2, store.callCount())
}
func TestMutes_IsConcurrencySafe(t *testing.T) {
muter, store := newMuter(t, activeFixed("rule-1"))
ctx := context.Background()
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < 50; j++ {
_ = muter.Mutes(ctx, labelsFor("rule-1"))
_ = muter.MutedBy(ctx, labelsFor("rule-1"))
}
}()
}
wg.Wait()
// Even under contention the cache must collapse the load to a single fetch.
assert.Equal(t, 1, store.callCount())
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2026 SigNoz, Inc.
// Copyright 2015 Prometheus Team
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package alertmanagerserver
import (
"time"
"github.com/prometheus/alertmanager/featurecontrol"
"github.com/prometheus/alertmanager/inhibit"
"github.com/prometheus/alertmanager/nflog/nflogpb"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/silence"
"github.com/prometheus/alertmanager/timeinterval"
"github.com/prometheus/alertmanager/types"
"github.com/prometheus/client_golang/prometheus"
)
// pipelineBuilder is a local copy of notify.PipelineBuilder that injects
// the maintenance mute stage immediately before the receiver stage.
//
// We maintain our own copy so we can control exactly where in the pipeline
// the maintenance stage runs (between the silence stage and the receiver),
// which is not possible by wrapping the output of the upstream builder.
//
// Upstream pipeline order:
// GossipSettle → Inhibit → TimeActive → TimeMute → Silence → [mms] → Receiver
type pipelineBuilder struct {
metrics *notify.Metrics
ff featurecontrol.Flagger
}
func newPipelineBuilder(
r prometheus.Registerer,
ff featurecontrol.Flagger,
) *pipelineBuilder {
return &pipelineBuilder{
metrics: notify.NewMetrics(r, ff),
ff: ff,
}
}
// New returns a map of receivers to Stages, mirroring notify.PipelineBuilder.New
// but inserting a maintenanceMuteStage between the silence stage and the receiver.
func (pb *pipelineBuilder) New(
receivers map[string][]notify.Integration,
wait func() time.Duration,
inhibitor *inhibit.Inhibitor,
silencer *silence.Silencer,
intervener *timeinterval.Intervener,
marker types.GroupMarker,
muter *MaintenanceMuter,
notificationLog notify.NotificationLog,
peer notify.Peer,
) notify.RoutingStage {
rs := make(notify.RoutingStage, len(receivers))
ms := notify.NewGossipSettleStage(peer)
is := notify.NewMuteStage(inhibitor, pb.metrics)
tas := notify.NewTimeActiveStage(intervener, marker, pb.metrics)
tms := notify.NewTimeMuteStage(intervener, marker, pb.metrics)
ss := notify.NewMuteStage(silencer, pb.metrics)
mms := notify.NewMuteStage(muter, pb.metrics)
for name := range receivers {
stages := notify.MultiStage{ms, is, tas, tms, ss, mms}
stages = append(stages, createReceiverStage(name, receivers[name], wait, notificationLog, pb.metrics))
rs[name] = stages
}
pb.metrics.InitializeFor(receivers)
return rs
}
// createReceiverStage is a copy of notify.createReceiverStage (unexported upstream).
func createReceiverStage(
name string,
integrations []notify.Integration,
wait func() time.Duration,
notificationLog notify.NotificationLog,
metrics *notify.Metrics,
) notify.Stage {
var fs notify.FanoutStage
for i := range integrations {
recv := &nflogpb.Receiver{
GroupName: name,
Integration: integrations[i].Name(),
Idx: uint32(integrations[i].Index()),
}
var s notify.MultiStage
s = append(s, notify.NewWaitStage(wait))
s = append(s, notify.NewDedupStage(&integrations[i], notificationLog, recv))
s = append(s, notify.NewRetryStage(integrations[i], name, metrics))
s = append(s, notify.NewSetNotifiesStage(notificationLog, recv))
fs = append(fs, s)
}
return fs
}

View File

@@ -28,12 +28,10 @@ import (
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
)
var (
// This is not a real file and will never be used. We need this placeholder to ensure maintenance runs on shutdown. See
// https://github.com/prometheus/server/blob/3ee2cd0f1271e277295c02b6160507b4d193dde2/silence/silence.go#L435-L438
// and https://github.com/prometheus/server/blob/3b06b97af4d146e141af92885a185891eb79a5b0/nflog/nflog.go#L362.
snapfnoop string = "snapfnoop"
)
// This is not a real snapshot file and will never be used. We need this placeholder to ensure maintenance runs on shutdown.
// See https://github.com/prometheus/alertmanager/blob/3ee2cd0f1271e277295c02b6160507b4d193dde2/silence/silence.go#L435-L438
// and https://github.com/prometheus/alertmanager/blob/3b06b97af4d146e141af92885a185891eb79a5b0/nflog/nflog.go#L362.
var snapfnoop string = "snapfnoop"
type Server struct {
// logger is the logger for the alertmanager
@@ -63,15 +61,25 @@ type Server struct {
silencer *silence.Silencer
silences *silence.Silences
timeIntervals map[string][]timeinterval.TimeInterval
pipelineBuilder *notify.PipelineBuilder
marker *alertmanagertypes.MemMarker
pipelineBuilder *pipelineBuilder
muter *MaintenanceMuter
marker *types.MemMarker
tmpl *template.Template
wg sync.WaitGroup
stopc chan struct{}
notificationManager nfmanager.NotificationManager
}
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore, nfManager nfmanager.NotificationManager) (*Server, error) {
func New(
ctx context.Context,
logger *slog.Logger,
registry prometheus.Registerer,
srvConfig Config,
orgID string,
stateStore alertmanagertypes.StateStore,
nfManager nfmanager.NotificationManager,
maintenanceStore alertmanagertypes.MaintenanceStore,
) (*Server, error) {
server := &Server{
logger: logger.With(slog.String("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver")),
registry: registry,
@@ -84,7 +92,7 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
signozRegisterer := prometheus.WrapRegistererWithPrefix("signoz_", registry)
signozRegisterer = prometheus.WrapRegistererWith(prometheus.Labels{"org_id": server.orgID}, signozRegisterer)
// initialize marker
server.marker = alertmanagertypes.NewMarker(signozRegisterer)
server.marker = types.NewMarker(signozRegisterer)
// get silences for initial state
state, err := server.stateStore.Get(ctx, server.orgID)
@@ -160,7 +168,6 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
return c, server.stateStore.Set(ctx, storableSilences)
})
}()
// Start maintenance for notification logs
@@ -196,17 +203,25 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
return nil, err
}
server.pipelineBuilder = notify.NewPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
server.muter = NewMaintenanceMuter(maintenanceStore, orgID, server.logger)
server.pipelineBuilder = newPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
server.dispatcherMetrics = NewDispatcherMetrics(false, signozRegisterer)
return server, nil
}
func (server *Server) GetAlerts(ctx context.Context, params alertmanagertypes.GettableAlertsParams) (alertmanagertypes.GettableAlerts, error) {
return alertmanagertypes.NewGettableAlertsFromAlertProvider(server.alerts, server.alertmanagerConfig, server.marker.Status, func(labels model.LabelSet) {
server.inhibitor.Mutes(ctx, labels)
server.silencer.Mutes(ctx, labels)
}, params)
return alertmanagertypes.NewGettableAlertsFromAlertProvider(
server.alerts, server.alertmanagerConfig, server.marker.Status,
func(labels model.LabelSet) {
server.inhibitor.Mutes(ctx, labels)
server.silencer.Mutes(ctx, labels)
},
func(labels model.LabelSet) []string {
return server.muter.MutedBy(ctx, labels)
},
params,
)
}
func (server *Server) PutAlerts(ctx context.Context, postableAlerts alertmanagertypes.PostableAlerts) error {
@@ -290,6 +305,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
server.silencer,
intervener,
server.marker,
server.muter,
server.nflog,
pipelinePeer,
)

View File

@@ -7,6 +7,10 @@ import (
"testing"
"time"
sqlmock "github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/prometheus/alertmanager/dispatch"
@@ -90,7 +94,8 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.DiscardHandler)
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual))
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager, maintenanceStore)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)

View File

@@ -10,7 +10,11 @@ import (
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
"github.com/go-openapi/strfmt"
@@ -23,9 +27,14 @@ import (
"github.com/stretchr/testify/require"
)
func newTestMaintenanceStore() alertmanagertypes.MaintenanceStore {
ss := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
return sqlalertmanagerstore.NewMaintenanceStore(ss)
}
func TestServerSetConfigAndStop(t *testing.T) {
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, newTestMaintenanceStore())
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -37,7 +46,7 @@ func TestServerSetConfigAndStop(t *testing.T) {
func TestServerTestReceiverTypeWebhook(t *testing.T) {
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, newTestMaintenanceStore())
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
@@ -85,7 +94,7 @@ func TestServerPutAlerts(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -133,7 +142,7 @@ func TestServerTestAlert(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -238,7 +247,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
srvCfg := NewConfig()
srvCfg.Route.GroupInterval = 1 * time.Second
notificationManager := nfmanagertest.NewMock()
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")

View File

@@ -1,4 +1,4 @@
package sqlrulestore
package sqlalertmanagerstore
import (
"context"
@@ -7,8 +7,8 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -16,12 +16,12 @@ type maintenance struct {
sqlstore sqlstore.SQLStore
}
func NewMaintenanceStore(store sqlstore.SQLStore) ruletypes.MaintenanceStore {
func NewMaintenanceStore(store sqlstore.SQLStore) alertmanagertypes.MaintenanceStore {
return &maintenance{sqlstore: store}
}
func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string) ([]*ruletypes.PlannedMaintenance, error) {
gettableMaintenancesRules := make([]*ruletypes.PlannedMaintenanceWithRules, 0)
func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.PlannedMaintenance, error) {
gettableMaintenancesRules := make([]*alertmanagertypes.PlannedMaintenanceWithRules, 0)
err := r.sqlstore.
BunDB().
NewSelect().
@@ -33,7 +33,7 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
return nil, err
}
gettablePlannedMaintenance := make([]*ruletypes.PlannedMaintenance, 0)
gettablePlannedMaintenance := make([]*alertmanagertypes.PlannedMaintenance, 0)
for _, gettableMaintenancesRule := range gettableMaintenancesRules {
gettablePlannedMaintenance = append(gettablePlannedMaintenance, gettableMaintenancesRule.ToPlannedMaintenance())
}
@@ -41,8 +41,8 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
return gettablePlannedMaintenance, nil
}
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*ruletypes.PlannedMaintenance, error) {
storableMaintenanceRule := new(ruletypes.PlannedMaintenanceWithRules)
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
storableMaintenanceRule := new(alertmanagertypes.PlannedMaintenanceWithRules)
err := r.sqlstore.
BunDB().
NewSelect().
@@ -57,13 +57,13 @@ func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.U
return storableMaintenanceRule.ToPlannedMaintenance(), nil
}
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance *ruletypes.PostablePlannedMaintenance) (*ruletypes.PlannedMaintenance, error) {
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance) (*alertmanagertypes.PlannedMaintenance, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
storablePlannedMaintenance := ruletypes.StorablePlannedMaintenance{
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
@@ -75,20 +75,21 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
CreatedBy: claims.Email,
UpdatedBy: claims.Email,
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
LabelExpression: maintenance.LabelExpression,
}
maintenanceRules := make([]*ruletypes.StorablePlannedMaintenanceRule, 0)
maintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
for _, ruleIDStr := range maintenance.AlertIds {
ruleID, err := valuer.NewUUID(ruleIDStr)
if err != nil {
return nil, err
}
maintenanceRules = append(maintenanceRules, &ruletypes.StorablePlannedMaintenanceRule{
maintenanceRules = append(maintenanceRules, &alertmanagertypes.StorablePlannedMaintenanceRule{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
@@ -113,7 +114,6 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
NewInsert().
Model(&maintenanceRules).
Exec(ctx)
if err != nil {
return err
}
@@ -125,16 +125,17 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
return nil, err
}
return &ruletypes.PlannedMaintenance{
ID: storablePlannedMaintenance.ID,
Name: storablePlannedMaintenance.Name,
Description: storablePlannedMaintenance.Description,
Schedule: storablePlannedMaintenance.Schedule,
RuleIDs: maintenance.AlertIds,
CreatedAt: storablePlannedMaintenance.CreatedAt,
CreatedBy: storablePlannedMaintenance.CreatedBy,
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
UpdatedBy: storablePlannedMaintenance.UpdatedBy,
return &alertmanagertypes.PlannedMaintenance{
ID: storablePlannedMaintenance.ID,
Name: storablePlannedMaintenance.Name,
Description: storablePlannedMaintenance.Description,
Schedule: storablePlannedMaintenance.Schedule,
RuleIDs: maintenance.AlertIds,
LabelExpression: maintenance.LabelExpression,
CreatedAt: storablePlannedMaintenance.CreatedAt,
CreatedBy: storablePlannedMaintenance.CreatedBy,
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
UpdatedBy: storablePlannedMaintenance.UpdatedBy,
}, nil
}
@@ -142,7 +143,7 @@ func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UU
_, err := r.sqlstore.
BunDB().
NewDelete().
Model(new(ruletypes.StorablePlannedMaintenance)).
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
@@ -152,7 +153,7 @@ func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UU
return nil
}
func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance *ruletypes.PostablePlannedMaintenance, id valuer.UUID) error {
func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance, id valuer.UUID) error {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return err
@@ -163,7 +164,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
return err
}
storablePlannedMaintenance := ruletypes.StorablePlannedMaintenance{
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: id,
},
@@ -175,20 +176,21 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
CreatedBy: existing.CreatedBy,
UpdatedBy: claims.Email,
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
LabelExpression: maintenance.LabelExpression,
}
storablePlannedMaintenanceRules := make([]*ruletypes.StorablePlannedMaintenanceRule, 0)
storablePlannedMaintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
for _, ruleIDStr := range maintenance.AlertIds {
ruleID, err := valuer.NewUUID(ruleIDStr)
if err != nil {
return err
}
storablePlannedMaintenanceRules = append(storablePlannedMaintenanceRules, &ruletypes.StorablePlannedMaintenanceRule{
storablePlannedMaintenanceRules = append(storablePlannedMaintenanceRules, &alertmanagertypes.StorablePlannedMaintenanceRule{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
@@ -211,10 +213,9 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
_, err = r.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(ruletypes.StorablePlannedMaintenanceRule)).
Model(new(alertmanagertypes.StorablePlannedMaintenanceRule)).
Where("planned_maintenance_id = ?", storablePlannedMaintenance.ID.StringValue()).
Exec(ctx)
if err != nil {
return err
}
@@ -231,7 +232,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
}
return nil
})
if err != nil {
return err

View File

@@ -39,16 +39,18 @@ type Service struct {
serversMtx sync.RWMutex
notificationManager nfmanager.NotificationManager
maintenanceStore alertmanagertypes.MaintenanceStore
}
func New(
ctx context.Context,
settings factory.ScopedProviderSettings,
config alertmanagerserver.Config,
stateStore alertmanagertypes.StateStore,
configStore alertmanagertypes.ConfigStore,
orgGetter organization.Getter,
nfManager nfmanager.NotificationManager,
maintenanceStore alertmanagertypes.MaintenanceStore,
) *Service {
service := &Service{
config: config,
@@ -59,6 +61,7 @@ func New(
servers: make(map[string]*alertmanagerserver.Server),
serversMtx: sync.RWMutex{},
notificationManager: nfManager,
maintenanceStore: maintenanceStore,
}
return service
@@ -177,7 +180,10 @@ func (service *Service) newServer(ctx context.Context, orgID string) (*alertmana
return nil, err
}
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore, service.notificationManager)
server, err := alertmanagerserver.New(
ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID,
service.stateStore, service.notificationManager, service.maintenanceStore,
)
if err != nil {
return nil, err
}

View File

@@ -4,11 +4,8 @@ import (
"context"
"time"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
amConfig "github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
@@ -16,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
@@ -30,35 +28,49 @@ type provider struct {
configStore alertmanagertypes.ConfigStore
stateStore alertmanagertypes.StateStore
notificationManager nfmanager.NotificationManager
maintenanceStore alertmanagertypes.MaintenanceStore
stopC chan struct{}
}
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
func NewFactory(
sqlstore sqlstore.SQLStore,
orgGetter organization.Getter,
notificationManager nfmanager.NotificationManager,
maintenanceStore alertmanagertypes.MaintenanceStore,
) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, settings factory.ProviderSettings, config alertmanager.Config) (alertmanager.Alertmanager, error) {
return New(ctx, settings, config, sqlstore, orgGetter, notificationManager)
return New(settings, config, sqlstore, orgGetter, notificationManager, maintenanceStore)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) (*provider, error) {
func New(
providerSettings factory.ProviderSettings,
config alertmanager.Config,
sqlstore sqlstore.SQLStore,
orgGetter organization.Getter,
notificationManager nfmanager.NotificationManager,
maintenanceStore alertmanagertypes.MaintenanceStore,
) (*provider, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager")
configStore := sqlalertmanagerstore.NewConfigStore(sqlstore)
stateStore := sqlalertmanagerstore.NewStateStore(sqlstore)
p := &provider{
service: alertmanager.New(
ctx,
settings,
config.Signoz.Config,
stateStore,
configStore,
orgGetter,
notificationManager,
maintenanceStore,
),
settings: settings,
config: config,
configStore: configStore,
stateStore: stateStore,
notificationManager: notificationManager,
maintenanceStore: maintenanceStore,
stopC: make(chan struct{}),
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/gorilla/mux"
)
@@ -115,13 +116,29 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/rules/{id}/mute", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.MuteRule), handler.OpenAPIDef{
ID: "MuteRule",
Tags: []string{"rules"},
Summary: "Mute alert rule",
Description: "This endpoint mutes an alert rule until the provided endTime by creating a fixed-window planned maintenance scoped to that rule. Unmute by deleting the returned downtime schedule.",
Request: new(alertmanagertypes.PostableMuteRule),
RequestContentType: "application/json",
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
ID: "ListDowntimeSchedules",
Tags: []string{"downtimeschedules"},
Summary: "List downtime schedules",
Description: "This endpoint lists all planned maintenance / downtime schedules",
RequestQuery: new(ruletypes.ListPlannedMaintenanceParams),
Response: make([]*ruletypes.PlannedMaintenance, 0),
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
@@ -134,7 +151,7 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
Tags: []string{"downtimeschedules"},
Summary: "Get downtime schedule by ID",
Description: "This endpoint returns a downtime schedule by ID",
Response: new(ruletypes.PlannedMaintenance),
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
@@ -148,9 +165,9 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
Tags: []string{"downtimeschedules"},
Summary: "Create downtime schedule",
Description: "This endpoint creates a new planned maintenance / downtime schedule",
Request: new(ruletypes.PostablePlannedMaintenance),
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
Response: new(ruletypes.PlannedMaintenance),
Response: new(alertmanagertypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
@@ -164,7 +181,7 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
Tags: []string{"downtimeschedules"},
Summary: "Update downtime schedule",
Description: "This endpoint updates a downtime schedule by ID",
Request: new(ruletypes.PostablePlannedMaintenance),
Request: new(alertmanagertypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},

View File

@@ -2,6 +2,8 @@ package envprovider
import (
"context"
"os"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/config"
@@ -9,7 +11,21 @@ import (
"github.com/stretchr/testify/require"
)
// clearSignozEnv unsets all existing SIGNOZ_* env vars for the duration of the test.
func clearSignozEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, prefix) {
key := strings.SplitN(kv, "=", 2)[0]
orig, _ := os.LookupEnv(key)
os.Unsetenv(key)
t.Cleanup(func() { os.Setenv(key, orig) })
}
}
}
func TestGetWithStrings(t *testing.T) {
clearSignozEnv(t)
t.Setenv("SIGNOZ_K1_K2", "string")
t.Setenv("SIGNOZ_K3__K4", "string")
t.Setenv("SIGNOZ_K5__K6_K7__K8", "string")
@@ -31,6 +47,7 @@ func TestGetWithStrings(t *testing.T) {
}
func TestGetWithNoPrefix(t *testing.T) {
clearSignozEnv(t)
t.Setenv("K1_K2", "string")
t.Setenv("K3_K4", "string")
expected := map[string]any{}
@@ -43,6 +60,7 @@ func TestGetWithNoPrefix(t *testing.T) {
}
func TestGetWithGoTypes(t *testing.T) {
clearSignozEnv(t)
t.Setenv("SIGNOZ_BOOL", "true")
t.Setenv("SIGNOZ_STRING", "string")
t.Setenv("SIGNOZ_INT", "1")

View File

@@ -32,30 +32,28 @@ import (
)
type PrepareTaskOptions struct {
Rule *ruletypes.PostableRule
TaskName string
RuleStore ruletypes.RuleStore
MaintenanceStore ruletypes.MaintenanceStore
Querier querier.Querier
Logger *slog.Logger
Cache cache.Cache
ManagerOpts *ManagerOptions
NotifyFunc NotifyFunc
SQLStore sqlstore.SQLStore
OrgID valuer.UUID
Rule *ruletypes.PostableRule
TaskName string
RuleStore ruletypes.RuleStore
Querier querier.Querier
Logger *slog.Logger
Cache cache.Cache
ManagerOpts *ManagerOptions
NotifyFunc NotifyFunc
SQLStore sqlstore.SQLStore
OrgID valuer.UUID
}
type PrepareTestRuleOptions struct {
Rule *ruletypes.PostableRule
RuleStore ruletypes.RuleStore
MaintenanceStore ruletypes.MaintenanceStore
Querier querier.Querier
Logger *slog.Logger
Cache cache.Cache
ManagerOpts *ManagerOptions
NotifyFunc NotifyFunc
SQLStore sqlstore.SQLStore
OrgID valuer.UUID
Rule *ruletypes.PostableRule
RuleStore ruletypes.RuleStore
Querier querier.Querier
Logger *slog.Logger
Cache cache.Cache
ManagerOpts *ManagerOptions
NotifyFunc NotifyFunc
SQLStore sqlstore.SQLStore
OrgID valuer.UUID
}
const taskNameSuffix = "webAppEditor"
@@ -89,7 +87,7 @@ type ManagerOptions struct {
Alertmanager alertmanager.Alertmanager
OrgGetter organization.Getter
RuleStore ruletypes.RuleStore
MaintenanceStore ruletypes.MaintenanceStore
MaintenanceStore alertmanagertypes.MaintenanceStore
SQLStore sqlstore.SQLStore
QueryParser queryparser.QueryParser
}
@@ -103,7 +101,7 @@ type Manager struct {
block chan struct{}
// datastore to store alert definitions
ruleStore ruletypes.RuleStore
maintenanceStore ruletypes.MaintenanceStore
maintenanceStore alertmanagertypes.MaintenanceStore
logger *slog.Logger
cache cache.Cache
@@ -134,7 +132,6 @@ func defaultOptions(o *ManagerOptions) *ManagerOptions {
}
func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
rules := make([]Rule, 0)
var task Task
@@ -159,7 +156,6 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
WithMetadataStore(opts.ManagerOpts.MetadataStore),
WithRuleStateHistoryModule(opts.ManagerOpts.RuleStateHistoryModule),
)
if err != nil {
return task, err
}
@@ -167,7 +163,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
rules = append(rules, tr)
// create ch rule task for evaluation
task = newTask(TaskTypeCh, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(TaskTypeCh, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -183,7 +179,6 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
WithMetadataStore(opts.ManagerOpts.MetadataStore),
WithRuleStateHistoryModule(opts.ManagerOpts.RuleStateHistoryModule),
)
if err != nil {
return task, err
}
@@ -191,7 +186,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
rules = append(rules, pr)
// create promql rule task for evaluation
task = newTask(TaskTypeProm, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(TaskTypeProm, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
} else {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
@@ -237,10 +232,11 @@ func (m *Manager) RuleStore() ruletypes.RuleStore {
return m.ruleStore
}
func (m *Manager) MaintenanceStore() ruletypes.MaintenanceStore {
func (m *Manager) MaintenanceStore() alertmanagertypes.MaintenanceStore {
return m.maintenanceStore
}
// TODO(jatinderjit): remove (unused)?
func (m *Manager) Pause(b bool) {
m.mtx.Lock()
defer m.mtx.Unlock()
@@ -430,19 +426,17 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes
m.logger.Debug("editing a rule task", "name", taskName)
newTask, err := m.prepareTaskFunc(PrepareTaskOptions{
Rule: rule,
TaskName: taskName,
RuleStore: m.ruleStore,
MaintenanceStore: m.maintenanceStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.notifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
Rule: rule,
TaskName: taskName,
RuleStore: m.ruleStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.notifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
})
if err != nil {
m.logger.Error("loading tasks failed", errors.Attr(err))
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error preparing rule with given parameters, previous rule set restored")
@@ -643,19 +637,17 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
m.logger.Debug("adding a new rule task", "name", taskName)
newTask, err := m.prepareTaskFunc(PrepareTaskOptions{
Rule: rule,
TaskName: taskName,
RuleStore: m.ruleStore,
MaintenanceStore: m.maintenanceStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.notifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
Rule: rule,
TaskName: taskName,
RuleStore: m.ruleStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.notifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
})
if err != nil {
m.logger.Error("creating rule task failed", "name", taskName, errors.Attr(err))
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error loading rules, previous rule set restored")
@@ -703,7 +695,6 @@ func (m *Manager) RuleTasks() []Task {
// RuleTasksWithoutLock returns the list of manager's rule tasks without
// acquiring a lock on the manager.
func (m *Manager) RuleTasksWithoutLock() []Task {
rgs := make([]Task, 0, len(m.tasks))
for _, g := range m.tasks {
rgs = append(rgs, g)
@@ -897,7 +888,6 @@ func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.Getta
// the task state. For example - if a stored rule is disabled, then
// there is no task running against it.
func (m *Manager) syncRuleStateWithTask(ctx context.Context, orgID valuer.UUID, taskName string, rule *ruletypes.PostableRule) error {
if rule.Disabled {
// check if rule has any task running
if _, ok := m.tasks[taskName]; ok {
@@ -1029,16 +1019,15 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
}
alertCount, err := m.prepareTestRuleFunc(PrepareTestRuleOptions{
Rule: &parsedRule,
RuleStore: m.ruleStore,
MaintenanceStore: m.maintenanceStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.testNotifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
Rule: &parsedRule,
RuleStore: m.ruleStore,
Querier: m.opts.Querier,
Logger: m.opts.Logger,
Cache: m.cache,
ManagerOpts: m.opts,
NotifyFunc: m.testNotifyFunc,
SQLStore: m.sqlstore,
OrgID: orgID,
})
return alertCount, err

View File

@@ -15,7 +15,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// PromRuleTask is a promql rule executor
@@ -39,14 +38,11 @@ type PromRuleTask struct {
pause bool
logger *slog.Logger
notify NotifyFunc
maintenanceStore ruletypes.MaintenanceStore
orgID valuer.UUID
}
// NewPromRuleTask holds rules that have promql condition
// and evaluates the rule at a given frequency
func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) *PromRuleTask {
func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) *PromRuleTask {
opts.Logger.Info("initiating a new rule group", "name", name, "frequency", frequency)
if frequency == 0 {
@@ -63,10 +59,8 @@ func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, o
seriesInPreviousEval: make([]map[string]plabels.Labels, len(rules)),
done: make(chan struct{}),
terminated: make(chan struct{}),
notify: notify,
maintenanceStore: maintenanceStore,
logger: opts.Logger,
orgID: orgID,
notify: notify,
logger: opts.Logger,
}
}
@@ -330,30 +324,12 @@ func (g *PromRuleTask) Eval(ctx context.Context, ts time.Time) {
}()
g.logger.InfoContext(ctx, "promql rule task", "name", g.name, "eval_started_at", ts)
maintenance, err := g.maintenanceStore.ListPlannedMaintenance(ctx, g.orgID.StringValue())
if err != nil {
g.logger.ErrorContext(ctx, "error in processing sql query", errors.Attr(err))
}
for i, rule := range g.rules {
if rule == nil {
continue
}
shouldSkip := false
for _, m := range maintenance {
g.logger.InfoContext(ctx, "checking if rule should be skipped", slog.String("rule.id", rule.ID()), slog.Any("maintenance", m))
if m.ShouldSkip(rule.ID(), ts) {
shouldSkip = true
break
}
}
if shouldSkip {
g.logger.InfoContext(ctx, "rule should be skipped", slog.String("rule.id", rule.ID()))
continue
}
select {
case <-g.done:
return

View File

@@ -2,20 +2,18 @@ package rules
import (
"context"
"log/slog"
"runtime/debug"
"sort"
"sync"
"time"
"log/slog"
opentracing "github.com/opentracing/opentracing-go"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// RuleTask holds a rule (with composite queries)
@@ -37,34 +35,28 @@ type RuleTask struct {
pause bool
notify NotifyFunc
maintenanceStore ruletypes.MaintenanceStore
orgID valuer.UUID
}
const DefaultFrequency = 1 * time.Minute
// NewRuleTask makes a new RuleTask with the given name, options, and rules.
func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) *RuleTask {
func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) *RuleTask {
if frequency == 0 {
frequency = DefaultFrequency
}
opts.Logger.Info("initiating a new rule task", "name", name, "frequency", frequency)
return &RuleTask{
name: name,
file: file,
pause: false,
frequency: frequency,
rules: rules,
opts: opts,
logger: opts.Logger,
done: make(chan struct{}),
terminated: make(chan struct{}),
notify: notify,
maintenanceStore: maintenanceStore,
orgID: orgID,
name: name,
file: file,
pause: false,
frequency: frequency,
rules: rules,
opts: opts,
logger: opts.Logger,
done: make(chan struct{}),
terminated: make(chan struct{}),
notify: notify,
}
}
@@ -72,6 +64,7 @@ func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts
func (g *RuleTask) Name() string { return g.name }
// Key returns the group key
// TODO(jatinderjit): remove (unused)?
func (g *RuleTask) Key() string {
return g.name + ";" + g.file
}
@@ -83,7 +76,7 @@ func (g *RuleTask) Type() TaskType { return TaskTypeCh }
func (g *RuleTask) Rules() []Rule { return g.rules }
// Interval returns the group's interval.
// TODO: remove (unused)?
// TODO(jatinderjit): remove (unused)?
func (g *RuleTask) Interval() time.Duration { return g.frequency }
func (g *RuleTask) Pause(b bool) {
@@ -261,7 +254,6 @@ func nameAndLabels(rule Rule) string {
// Rules are matched based on their name and labels. If there are duplicates, the
// first is matched with the first, second with the second etc.
func (g *RuleTask) CopyState(fromTask Task) error {
from, ok := fromTask.(*RuleTask)
if !ok {
return errors.NewInternalf(errors.CodeInternal, "invalid from task for copy")
@@ -306,7 +298,6 @@ func (g *RuleTask) CopyState(fromTask Task) error {
// Eval runs a single evaluation cycle in which all rules are evaluated sequentially.
func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
defer func() {
if r := recover(); r != nil {
g.logger.ErrorContext(
@@ -318,31 +309,11 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
g.logger.DebugContext(ctx, "rule task eval started", "name", g.name, "start_time", ts)
maintenance, err := g.maintenanceStore.ListPlannedMaintenance(ctx, g.orgID.StringValue())
if err != nil {
g.logger.ErrorContext(ctx, "error in processing sql query", errors.Attr(err))
}
for i, rule := range g.rules {
if rule == nil {
continue
}
shouldSkip := false
for _, m := range maintenance {
g.logger.InfoContext(ctx, "checking if rule should be skipped", slog.String("rule.id", rule.ID()), slog.Any("maintenance", m))
if m.ShouldSkip(rule.ID(), ts) {
shouldSkip = true
break
}
}
if shouldSkip {
g.logger.InfoContext(ctx, "rule should be skipped", slog.String("rule.id", rule.ID()))
continue
}
select {
case <-g.done:
return
@@ -382,7 +353,6 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
}
rule.SendAlerts(ctx, ts, g.opts.ResendDelay, g.frequency, g.notify)
}(i, rule)
}
}

View File

@@ -3,9 +3,6 @@ package rules
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type TaskType string
@@ -32,9 +29,9 @@ type Task interface {
// newTask returns an appropriate group for
// rule type
func newTask(taskType TaskType, name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) Task {
func newTask(taskType TaskType, name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) Task {
if taskType == TaskTypeCh {
return NewRuleTask(name, file, frequency, rules, opts, notify, maintenanceStore, orgID)
return NewRuleTask(name, file, frequency, rules, opts, notify)
}
return NewPromRuleTask(name, file, frequency, rules, opts, notify, maintenanceStore, orgID)
return NewPromRuleTask(name, file, frequency, rules, opts, notify)
}

View File

@@ -10,6 +10,7 @@ type Handler interface {
DeleteRuleByID(http.ResponseWriter, *http.Request)
PatchRuleByID(http.ResponseWriter, *http.Request)
TestRule(http.ResponseWriter, *http.Request)
MuteRule(http.ResponseWriter, *http.Request)
ListDowntimeSchedules(http.ResponseWriter, *http.Request)
GetDowntimeScheduleByID(http.ResponseWriter, *http.Request)

View File

@@ -5,6 +5,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/statsreporter"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
@@ -44,5 +45,5 @@ type Ruler interface {
// MaintenanceStore returns the store for planned maintenance / downtime schedules.
// TODO: expose downtime CRUD as methods on Ruler directly instead of leaking the
// store interface. The handler should not call store methods directly.
MaintenanceStore() ruletypes.MaintenanceStore
MaintenanceStore() alertmanagertypes.MaintenanceStore
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/ruler"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -184,6 +185,53 @@ func (handler *handler) TestRule(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, ruletypes.GettableTestRule{AlertCount: alertCount, Message: "notification sent"})
}
// MuteRule mutes an alert rule until the provided endTime by creating a
// fixed-window planned maintenance scoped to that rule. The created
// PlannedMaintenance is returned so the client can later unmute by deleting
// it via DELETE /api/v1/downtime_schedules/{id}.
func (handler *handler) MuteRule(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
rule, err := handler.ruler.GetRule(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
payload := new(ruletypes.PostableMuteRule)
if err := binding.JSON.BindBody(req.Body, payload); err != nil {
render.Error(rw, err)
return
}
now := time.Now()
if err := payload.Validate(now); err != nil {
render.Error(rw, err)
return
}
schedule := payload.ToPostablePlannedMaintenance(id.StringValue(), rule.AlertName, now)
if err := schedule.Validate(); err != nil {
render.Error(rw, err)
return
}
created, err := handler.ruler.MaintenanceStore().CreatePlannedMaintenance(ctx, schedule)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, created)
}
func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
@@ -194,7 +242,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
return
}
var params ruletypes.ListPlannedMaintenanceParams
var params alertmanagertypes.ListPlannedMaintenanceParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
render.Error(rw, err)
return
@@ -207,7 +255,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
}
if params.Active != nil {
activeSchedules := make([]*ruletypes.PlannedMaintenance, 0)
activeSchedules := make([]*alertmanagertypes.PlannedMaintenance, 0)
for _, schedule := range schedules {
now := time.Now().In(time.FixedZone(schedule.Schedule.Timezone, 0))
if schedule.IsActive(now) == *params.Active {
@@ -218,7 +266,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
}
if params.Recurring != nil {
recurringSchedules := make([]*ruletypes.PlannedMaintenance, 0)
recurringSchedules := make([]*alertmanagertypes.PlannedMaintenance, 0)
for _, schedule := range schedules {
if schedule.IsRecurring() == *params.Recurring {
recurringSchedules = append(recurringSchedules, schedule)
@@ -253,7 +301,7 @@ func (handler *handler) CreateDowntimeSchedule(rw http.ResponseWriter, req *http
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
schedule := new(ruletypes.PostablePlannedMaintenance)
schedule := new(alertmanagertypes.PostablePlannedMaintenance)
if err := binding.JSON.BindBody(req.Body, schedule); err != nil {
render.Error(rw, err)
return
@@ -283,7 +331,7 @@ func (handler *handler) UpdateDowntimeScheduleByID(rw http.ResponseWriter, req *
return
}
schedule := new(ruletypes.PostablePlannedMaintenance)
schedule := new(alertmanagertypes.PostablePlannedMaintenance)
if err := binding.JSON.BindBody(req.Body, schedule); err != nil {
render.Error(rw, err)
return

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
@@ -16,6 +17,7 @@ import (
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -44,7 +46,7 @@ func NewFactory(
) factory.ProviderFactory[ruler.Ruler, ruler.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config ruler.Config) (ruler.Ruler, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
managerOpts := &rules.ManagerOptions{
TelemetryStore: telemetryStore,
@@ -129,6 +131,6 @@ func (provider *provider) TestNotification(ctx context.Context, orgID valuer.UUI
return provider.manager.TestNotification(ctx, orgID, ruleStr)
}
func (provider *provider) MaintenanceStore() ruletypes.MaintenanceStore {
func (provider *provider) MaintenanceStore() alertmanagertypes.MaintenanceStore {
return provider.manager.MaintenanceStore()
}

Some files were not shown because too many files have changed in this diff Show More