Compare commits

..

20 Commits

Author SHA1 Message Date
Piyush Singariya
dafa81f3b4 Merge branch 'main' into traceop-returnspansfrom 2026-05-12 21:03:16 +05:30
Piyush Singariya
a992a13f56 revert: unused test 2026-05-12 20:58:17 +05:30
Piyush Singariya
79b36abbd7 chore: comments and test 2026-05-12 20:57:00 +05:30
Aditya Singh
7f6bdcbb8c Feat/trace details pending (#11170)
* fix: style fix

* fix: update color

* feat: bg color for selected and hover spans

* feat: remove unnecessary props

* feat: minor comment added

* feat: add test cases for flamegraph

* feat: add test utils

* feat: waterfall init

* feat: decouple waterfall left (span tree) and right (timeline bars) panels

Split the waterfall into two independent panels with a shared virtualizer
so deeply nested span names are visible via horizontal scroll in the left
panel. Left panel uses useReactTable + <table> for future column
extensibility; right panel uses plain divs for timeline bars. A draggable
resize handle separates the two panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add TimelineV3 ruler to waterfall header with padding fix

Add the TimelineV3 component to the sticky header of the waterfall's
right panel so timeline tick marks are visible. Add horizontal padding
to both the timeline header and span duration bars to prevent label
overflow/clipping at the edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: match span style

* feat: fix hover option overflow

* feat: span hover popover sync

* feat: row based flamegraph

* feat: subtree segregated tree

* feat: subtree segregated tree

* feat: subtree segregated tree

* feat: move to service worker

* feat: connector line ux

* feat: event dots in trace details

* feat: waterfall resizable

* feat: span details init

* feat: span details header

* feat: details field component

* feat: added span percentile

* feat: key attr section added

* feat: added pretty view

* feat: update yarn lock

* feat: minor change

* feat: search in pretty view

* feat: refactor

* feat: style fix

* feat: json viewer with select dropdown added

* feat: span details floating drawer added

* feat: span details folder rename

* feat: replace draggable package

* feat: fix pinning. fix drag on top

* feat: add bound to drags while floating

* feat: add collapsible sections in trace details

* feat: use resizable for waterfall table as well

* feat: copy link change and url clear on span close

* feat: fix span details headr

* feat: key value label style fixes

* feat: linked spans

* feat: style fixes

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* feat: api integration

* feat: add limit

* feat: minor change

* feat: supress click

* chore: generate openapi spec for v3 waterfall

* feat: fix test

* feat: fix test

* feat: lint fix

* feat: span details ux

* feat: analytics

* feat: add icons

* feat: added loading to flamegraph and timeout to webworker

* feat: sync error and loading state for flamegraph for n/w and computation logic

* feat: auto scroll horizontally to span

* feat: show total span count

* feat: disable anaytics span tab for now

* feat: add span details loader

* feat: prevent api call on closing span detail

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: make filter and search work with flamegraph

* feat: filter ui fix

* feat: remove trace header

* feat: new filter ui

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* feat: api integration

* feat: automatically scroll left on vertical scroll

* feat: reduce time

* feat: set limit to 100k for flamegraph

* feat: show child count in waterfall

* fix: align timeline and span length in flamegraph and waterfall

* feat: fix flamegraph and waterfall bg color

* feat: show caution on sampled flamegraph

* feat: api integration v3

* feat: disable scroll to view for collapse and uncollapse

* feat: setup types and interface for waterfall v3

v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service

* refactor: move type conversion logic to types pkg

* chore: add reason for using snake case in response

* fix: update span.attributes to map of string to any

To support otel format of diffrent types of attributes

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* refactor: convert waterfall api to modules format

* chore: add same test cases as for old waterfall api

* chore: avoid sorting on every traversal

* fix: remove unused fields and rename span type

To avoid confusing with otel span

* fix: rename timestamp to milli for readability

* fix: add timeout to module context

* fix: use typed paramter field in logs

* chore: generate openapi spec for v3 waterfall

* fix: remove timeout since waterfall take longer

* fix: use int16 for status code as per db schema

* fix: update openapi specs

* refactor: break down GetWaterfall method for readability

* chore: avoid returning nil, nil

* refactor: move type creation and constants to types package

- Move DB/table/cache/windowing constants to tracedetailtypes package
- Add NewWaterfallTrace and NewWaterfallResponse constructors in types
- Use constructors in module.go instead of inline struct literals
- Reorder waterfall.go so public functions precede private ones

* refactor: extract ClickHouse queries into a store abstraction

Move GetTraceSummary and GetTraceSpans out of module.go into a
traceStore interface backed by clickhouseTraceStore in store.go.
The module struct now holds a traceStore instead of a raw
telemetrystore.TelemetryStore, keeping DB access separate from
business logic.

* refactor: move error to types as well

* refactor: separate out store calls and computations

* refactor: breakdown GetSelectedSpans for readability

* refactor: return 404 on missing trace and other cleanup

* refactor: use same method for cache key creation

* chore: remove unused duration nano field

* chore: use sqlbuilder in clickhouse store where possible

* feat: dropdown added to span details

* feat: fix color duplications

* feat: no data screen

* feat: old trace btn added

* feat: minor fix

* feat: rename copy to copy value

* feat: delete unused file

* feat: use semantic tokens

* feat: use semantic tokens

* feat: add crosshair

* feat: fix test

* feat: disable crosshair in waterfall

* feat: fix colors

* feat: minor fix

* feat: add status codes

* feat: load all spans in waterfall under limit

* feat: uncollapse spans on select from flamegraph

* feat: style fix

* feat: add service name

* feat: open in new tab

* feat: add trace details header

* feat: add trace details header styles

* feat: add trace details header styles

* feat: minor changes

* feat: floating fields set

* feat: filters init

* feat: filter toggle added

* feat: fix color

* fix: scroll to span in frontend mode

* feat: delete waterfall go

* feat: minor change

* feat: minor change

* feat: lint fix

* feat: analytics spans

* feat: color by field

* feat: save color by pref in user pref

* feat: migrate v2 pinned attr

* feat: preview fields

* feat: minor refactors

* feat: minor refactors

* feat: v3 behind feature flag

* feat: minor refactors

* feat: packages remove

* feat: packages remove

* feat: remove common component

* feat: remove antd component usage

* feat: leaf node indent fix

* feat: fix mouse wheel in json view

* feat: update signoz ui

* feat: remove feature flag

* feat: fixed the waterfall span hover card

* feat: fix hidden filters

* feat: trace details always visible

* feat: correct status code

* fix: pagination calls in waterfall

* feat: fix failing test

* feat: show error count

* feat: fix waterfall child sibling indent

* feat: change how we show span hover data in waterfall

* feat: fix logs in span details styles

* feat: minor fixes

* feat: make trace id copyable

* feat: add status message to highlight section

* feat: persist user choosing old view

* feat: add more fields in color by

* feat: add llm as fast filter

* feat: show api error correctly

* feat: update test cases

* feat: revert route change

* feat: replace antd btns

* feat: allow removing all fields in preview

* feat: additional check

* feat: minor fix

* feat: minor fix

* feat: dont use antd button and tooltip

* feat: dont use antd button and tooltip

* feat: update icons

* feat: minor change

* feat: minor change

* feat: minor fix

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Nikhil Soni <nikhil.soni@signoz.io>
2026-05-12 15:13:39 +00:00
Piyush Singariya
181c307d1a Merge branch 'main' into traceop-returnspansfrom 2026-05-12 18:14:09 +05:30
Piyush Singariya
becdd4d3b4 revert: build list query 2026-05-12 18:11:35 +05:30
Piyush Singariya
de0311201a revert: double select 2026-05-12 17:15:41 +05:30
Piyush Singariya
1804bfe802 fix: return spans from 2026-05-12 16:53:31 +05:30
Piyush Singariya
357444c94e Merge branch 'main' into traceop 2026-05-11 20:53:51 +05:30
Piyush Singariya
a8598f3bfa fix: alias all core columns 2026-05-11 20:53:09 +05:30
Piyush Singariya
bca71f9a33 chore: remove comments 2026-05-11 16:04:32 +05:30
Piyush Singariya
c93660357d chore: fmt python 2026-05-11 16:02:18 +05:30
Piyush Singariya
5651e3b7a8 Merge branch 'main' into traceop 2026-05-11 14:28:58 +05:30
Piyush Singariya
cf2cfbc7d4 fix: remove specific of timestamp 2026-05-11 14:27:01 +05:30
Piyush Singariya
a969c38224 chore: fmtlint 2026-05-07 13:53:12 +05:30
Piyush Singariya
b892a0f0a5 chore: file rename 2026-05-07 13:51:22 +05:30
Piyush Singariya
4d47762eba chore: separate e2e test file 2026-05-07 13:50:11 +05:30
Piyush Singariya
77396a0bb3 Merge branch 'main' into traceop 2026-05-07 12:56:59 +05:30
Piyush Singariya
28c05e1bab Merge branch 'main' into traceop 2026-05-04 14:27:19 +05:30
Piyush Singariya
2b9e383994 fix: trace raw export e2e 2026-04-30 15:25:43 +05:30
108 changed files with 4566 additions and 1752 deletions

View File

@@ -4893,6 +4893,10 @@ components:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/RuletypesRepeatOn'
@@ -4900,7 +4904,11 @@ components:
type: array
repeatType:
$ref: '#/components/schemas/RuletypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
@@ -5064,7 +5072,6 @@ components:
type: string
required:
- timezone
- startTime
type: object
RuletypesScheduleType:
enum:

View File

@@ -13,6 +13,7 @@ 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) {
@@ -48,7 +49,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)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -72,7 +73,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)
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -95,7 +96,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)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
@@ -209,9 +210,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) baserules.Task {
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 {
if taskType == baserules.TaskTypeCh {
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify)
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
}
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify)
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
}

View File

@@ -29,6 +29,18 @@ 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

@@ -7142,12 +7142,23 @@ 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 {
@@ -7329,7 +7340,7 @@ export interface RuletypesScheduleDTO {
* @type string
* @format date-time
*/
startTime: Date;
startTime?: Date;
/**
* @type string
*/

View File

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

View File

@@ -38,6 +38,7 @@ 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,5 +3,7 @@ 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

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

View File

@@ -14,7 +14,6 @@ import {
} 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,
@@ -40,7 +39,6 @@ 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';
@@ -72,13 +70,14 @@ interface PlannedDowntimeFormData {
endTime: dayjs.Dayjs | string;
recurrence?: RuletypesRecurrenceDTO | null;
alertRules: DefaultOptionType[];
recurrenceSelect?: RuletypesRecurrenceDTO;
timezone?: string;
}
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
interface PlannedDowntimeFormProps {
initialValues: DeepPartial<
initialValues: Partial<
RuletypesPlannedMaintenanceDTO & {
editMode: boolean;
}
@@ -90,7 +89,7 @@ interface PlannedDowntimeFormProps {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
refetchAllSchedules: () => void;
isEditMode: boolean;
form: FormInstance;
form: FormInstance<any>;
}
export function PlannedDowntimeForm(
@@ -134,13 +133,14 @@ export function PlannedDowntimeForm(
const { notifications } = useNotifications();
const { showErrorModal } = useErrorModal();
const datePickerFooter: DatePickerProps['renderExtraFooter'] = (mode) =>
const datePickerFooter = (mode: any): any =>
mode === 'time' ? (
<span style={{ color: 'gray' }}>Please select the time</span>
) : null;
const saveHandler = useCallback(
const saveHanlder = useCallback(
async (values: PlannedDowntimeFormData) => {
const shouldKeepLocalTime = !isEditMode;
const data: RuletypesPostablePlannedMaintenanceDTO = {
alertIds: values.alertRules
.map((alert) => alert.value)
@@ -152,6 +152,7 @@ export function PlannedDowntimeForm(
values.startTime,
timezoneInitialValue,
values.timezone,
shouldKeepLocalTime,
),
),
timezone: values.timezone as string,
@@ -161,6 +162,7 @@ export function PlannedDowntimeForm(
values.endTime,
timezoneInitialValue,
values.timezone,
shouldKeepLocalTime,
),
)
: undefined,
@@ -201,24 +203,38 @@ export function PlannedDowntimeForm(
],
);
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
const { recurrence } = values;
const recurrenceData =
!recurrence ||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
values?.recurrence?.repeatType === recurrenceOptions.doesNotRepeat.value
? undefined
: {
duration: recurrence.duration
? `${recurrence.duration}${durationUnit}`
duration: values.recurrence?.duration
? `${values.recurrence?.duration}${durationUnit}`
: undefined,
repeatOn: recurrence.repeatOn?.length ? recurrence.repeatOn : undefined,
repeatType: recurrence.repeatType,
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,
};
const payloadValues = {
...values,
recurrence: recurrenceData as RuletypesRecurrenceDTO | undefined,
};
await saveHandler(payloadValues);
await saveHanlder(payloadValues);
};
const formValidationRules = [
@@ -271,7 +287,7 @@ export function PlannedDowntimeForm(
: '',
recurrence: {
...initialValues.schedule?.recurrence,
repeatType: (!isScheduleRecurring(initialValues.schedule)
repeatType: (!isScheduleRecurring(initialValues?.schedule)
? recurrenceOptions.doesNotRepeat.value
: initialValues.schedule?.recurrence
?.repeatType) as RuletypesRecurrenceDTO['repeatType'],
@@ -301,6 +317,7 @@ export function PlannedDowntimeForm(
const getTimezoneFormattedTime = (
time: string | dayjs.Dayjs,
timeZone?: string,
isEditMode?: boolean,
format?: string,
): string => {
if (!time) {
@@ -309,11 +326,20 @@ export function PlannedDowntimeForm(
if (!timeZone) {
return dayjs(time).format(format);
}
return dayjs(time).tz(timeZone).format(format);
return dayjs(time).tz(timeZone, isEditMode).format(format);
};
const startTimeText = useMemo((): string => {
let startTime = formData.startTime;
let startTime = formData?.startTime;
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
startTime =
(formData?.recurrence?.startTime
? dayjs(formData.recurrence.startTime).toISOString()
: '') ||
formData?.startTime ||
'';
}
if (!startTime) {
return '';
}
@@ -323,6 +349,7 @@ export function PlannedDowntimeForm(
startTime,
timezoneInitialValue,
formData?.timezone,
!isEditMode,
);
}
const daysOfWeek = formData?.recurrence?.repeatOn;
@@ -330,16 +357,21 @@ 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,
);
@@ -356,10 +388,21 @@ export function PlannedDowntimeForm(
default:
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
}
}, [formData, recurrenceType, timezoneInitialValue]);
}, [formData, recurrenceType, isEditMode, 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 '';
}
@@ -369,21 +412,25 @@ 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, timezoneInitialValue]);
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
return (
<Modal
@@ -418,7 +465,7 @@ export function PlannedDowntimeForm(
name="startTime"
rules={formValidationRules}
className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value) => ({
getValueProps={(value): any => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})}
>
@@ -499,7 +546,7 @@ export function PlannedDowntimeForm(
},
]}
className={!isEmpty(endTimeText) ? 'formItemWithBullet' : ''}
getValueProps={(value) => ({
getValueProps={(value): any => ({
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
})}
>

View File

@@ -1,4 +1,4 @@
import React, { ReactNode, useEffect } from 'react';
import { 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,9 +18,8 @@ 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,
@@ -28,6 +27,7 @@ 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, string | undefined];
timeframe: [string | undefined | null, string | undefined | null];
repeats?: RuletypesRecurrenceDTO | null;
updated_at?: string;
updated_by_name?: string;
@@ -192,12 +192,7 @@ export function CollapseListContent({
),
)}
{renderItems('Timezone', <Typography>{timezone || '-'}</Typography>)}
{renderItems(
'Repeats',
<Typography>
{recurrenceInfo(timeframe[0], timeframe[1], repeats)}
</Typography>,
)}
{renderItems('Repeats', <Typography>{recurrenceInfo(repeats)}</Typography>)}
{renderItems(
'Alerts silenced',
alertOptions?.length ? (
@@ -217,7 +212,7 @@ export function CollapseListContent({
export function CustomCollapseList(
props: DowntimeSchedulesTableData & {
setInitialValues: React.Dispatch<
React.SetStateAction<DeepPartial<RuletypesPlannedMaintenanceDTO>>
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
handleDeleteDowntime: (id: string, name: string) => void;
@@ -288,7 +283,9 @@ export function CustomCollapseList(
schedule?.startTime?.toString(),
typeof endTime === 'string' ? endTime : endTime?.toString(),
]}
repeats={schedule?.recurrence}
repeats={
schedule?.recurrence as RuletypesRecurrenceDTO | null | undefined
}
updated_at={updatedAt ? dayjs(updatedAt).toISOString() : ''}
updated_by_name={defaultTo(updatedBy, '')}
alertOptions={alertOptions}
@@ -323,7 +320,7 @@ export function PlannedDowntimeList({
>;
alertOptions: DefaultOptionType[];
setInitialValues: React.Dispatch<
React.SetStateAction<DeepPartial<RuletypesPlannedMaintenanceDTO>>
React.SetStateAction<Partial<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 {
import type {
DeleteDowntimeScheduleByIDPathParameters,
RenderErrorResponseDTO,
RuletypesPlannedMaintenanceDTO,
@@ -14,7 +14,6 @@ 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;
@@ -61,15 +60,13 @@ export const getAlertOptionsFromIds = (
);
export const recurrenceInfo = (
startTime?: string,
endTime?: string,
recurrence?: RuletypesRecurrenceDTO | null,
): string => {
if (!recurrence) {
return 'No';
}
const { duration, repeatOn, repeatType } = recurrence;
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
const formattedStartTime = startTime
? formatDateTime(dayjs(startTime).toISOString())
@@ -83,7 +80,7 @@ export const recurrenceInfo = (
return `Repeats - ${repeatType} ${weeklyRepeatString} from ${formattedStartTime} ${formattedEndTime} ${durationString}`;
};
export const defaultInitialValues: DeepPartial<
export const defautlInitialValues: Partial<
RuletypesPlannedMaintenanceDTO & { editMode: boolean }
> = {
name: '',
@@ -213,17 +210,39 @@ 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,
}: DeepPartial<
}: Partial<
RuletypesPlannedMaintenanceDTO & {
editMode: boolean;
}
>): string | dayjs.Dayjs =>
schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
>): string | dayjs.Dayjs => {
if (kind === 'fixed') {
return schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
}
return schedule?.recurrence?.endTime
? dayjs(schedule.recurrence.endTime).toISOString()
: '';
};
export const isScheduleRecurring = (
schedule?: DeepPartial<RuletypesPlannedMaintenanceDTO['schedule']> | null,
schedule?: RuletypesPlannedMaintenanceDTO['schedule'] | null,
): boolean => (schedule ? !isEmpty(schedule?.recurrence) : false);
function convertUtcOffsetToTimezoneOffset(offsetMinutes: number): string {
@@ -253,6 +272,7 @@ 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);
@@ -260,5 +280,5 @@ export function handleTimeConversion(
const formattedTime = formatWithTimezone(initialTime, timezone);
return timezoneChanged
? formattedTime
: dayjs(dateValue).tz(timezone).format();
: dayjs(dateValue).tz(timezone, shouldKeepLocalTime).format();
}

View File

@@ -8,7 +8,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
export const buildSchedule = (
schedule: RuletypesScheduleDTO,
schedule: Partial<RuletypesScheduleDTO>,
): RuletypesScheduleDTO => ({
timezone: schedule?.timezone ?? '',
startTime: schedule?.startTime,
@@ -17,13 +17,16 @@ export const buildSchedule = (
});
export const createMockDowntime = (
overrides: Partial<RuletypesPlannedMaintenanceDTO> &
Pick<RuletypesPlannedMaintenanceDTO, 'schedule'>,
overrides: Partial<RuletypesPlannedMaintenanceDTO>,
): RuletypesPlannedMaintenanceDTO => ({
id: overrides.id ?? '0',
name: overrides.name ?? '',
description: overrides.description ?? '',
schedule: overrides.schedule,
schedule: buildSchedule({
timezone: 'UTC',
startTime: new Date('2024-01-01'),
...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={logs.length <= 35 ? { height: `calc(${logs.length} * 22px)` } : {}}
style={{ height: '100%' }}
data={logs}
totalCount={logs.length}
itemContent={getItemContent}

View File

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

View File

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

View File

@@ -1,8 +1,12 @@
import { useMemo } from 'react';
import { Button, Skeleton, Tooltip } from 'antd';
import { useLocation, useRouteMatch } from 'react-router-dom';
import { Skeleton, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
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';
@@ -60,18 +64,51 @@ 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 className="previous-btn" onClick={handlePreviousBtnClick}>
<ArrowLeft size={14} />
</Button>
<Button
variant="solid"
color="secondary"
size="icon"
className="previous-btn"
prefix={<ArrowLeft size={14} />}
onClick={handlePreviousBtnClick}
/>
<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,14 +1,5 @@
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 { useCallback, useMemo } from 'react';
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';
@@ -19,59 +10,25 @@ interface UsePinnedAttributesReturn {
}
/**
* Hook for managing pinned span attributes with backend persistence.
* Falls back to localStorage during initial load and handles migration.
* V2 trace-details pinned-attributes hook. localStorage-only.
*
* @param availableAttributes - Object keys of the current span's flattened attributes
* @returns Object with pinned state, toggle function, and check function
* 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.
*/
export function usePinnedAttributes(
availableAttributes: string[],
): UsePinnedAttributesReturn {
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[]>(
const [pinnedKeys, setPinnedKeys] = 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;
}
@@ -82,38 +39,17 @@ export function usePinnedAttributes(
[pinnedKeys, availableAttributes],
);
// Toggle pin state for an attribute
const togglePin = useCallback(
(attributeKey: string): void => {
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),
setPinnedKeys((prev) =>
prev.includes(attributeKey)
? prev.filter((k) => k !== attributeKey)
: [...prev, attributeKey],
);
// Make the API call in the background
updateUserPreferenceMutation({
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
value: newPinnedKeys,
});
},
[pinnedKeys, updateUserPreferenceInContext, updateUserPreferenceMutation],
[setPinnedKeys],
);
// Check if an attribute is pinned
const isPinned = useCallback(
(attributeKey: string): boolean => pinnedAttributes[attributeKey] === true,
[pinnedAttributes],

View File

@@ -12,8 +12,11 @@ const useGetTraceFlamegraph = (
): UseLicense =>
useQuery({
queryFn: () => getTraceFlamegraph(props),
// 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],
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
props.traceId,
props.selectFields,
],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,

View File

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

View File

@@ -1,5 +1,25 @@
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,16 +10,14 @@ 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;
@@ -30,41 +28,46 @@ const PANEL_MARGIN_BOTTOM = 50;
function AnalyticsPanel({
isOpen,
onClose,
serviceExecTime = {},
traceStartTime = 0,
traceEndTime = 0,
}: AnalyticsPanelProps): JSX.Element | null {
const spread = traceEndTime - traceStartTime;
const { getAggregationMap } = useTraceContext();
const execTimePct = useMemo(
() => getAggregationMap(AGGREGATIONS.EXEC_TIME_PCT),
[getAggregationMap],
);
const spanCounts = useMemo(
() => getAggregationMap(AGGREGATIONS.SPAN_COUNT),
[getAggregationMap],
);
const execTimeRows = useMemo(() => {
if (spread <= 0) {
if (!execTimePct) {
return [];
}
return Object.entries(serviceExecTime)
.map(([service, duration]) => ({
service,
percentage: (duration * 100) / spread,
color: generateColor(service, themeColors.traceDetailColorsV3),
return Object.entries(execTimePct)
.map(([group, percentage]) => ({
group,
percentage,
color: generateColor(group, themeColors.traceDetailColorsV3),
}))
.sort((a, b) => b.percentage - a.percentage);
}, [serviceExecTime, spread]);
}, [execTimePct]);
// 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;
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]);
if (!isOpen) {
return null;
@@ -103,11 +106,9 @@ 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">
@@ -116,17 +117,17 @@ function AnalyticsPanel({
{execTimeRows.map((row) => (
<>
<div
key={`${row.service}-dot`}
key={`${row.group}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.service}-name`}
key={`${row.group}-name`}
className="analytics-panel__service-name"
>
{row.service}
{row.group}
</span>
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
@@ -145,28 +146,27 @@ 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.service}-dot`}
key={`${row.group}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.service}-name`}
key={`${row.group}-name`}
className="analytics-panel__service-name"
>
{row.service}
{row.group}
</span>
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
style={{
width: `${(row.count / maxSpanCount) * 100}%`,
width: `${(row.count / row.max) * 100}%`,
backgroundColor: row.color,
}}
/>
@@ -179,7 +179,6 @@ function AnalyticsPanel({
))}
</div>
</TabsContent>
*/}
</div>
</TabsRoot>
</div>

View File

@@ -64,9 +64,13 @@
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
display: flex;
flex-direction: column;
[role='tabpanel'] {
padding: 0;
flex: 1;
min-height: 0;
}
}
@@ -112,7 +116,8 @@
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success-500);
background: var(--accent-forest);
flex-shrink: 0;
}
&__trace-id {
@@ -123,6 +128,17 @@
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,7 +41,12 @@ 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 { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
import { useMigratePinnedAttributes } from 'pages/TraceDetailsV3/hooks/useMigratePinnedAttributes';
import {
getSpanAttribute,
getSpanDisplayData,
hasInfraMetadata,
} from 'pages/TraceDetailsV3/utils';
import { DataViewer } from 'periscope/components/DataViewer';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
@@ -59,6 +64,7 @@ import {
VISIBLE_ACTIONS,
} from './constants';
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
import {
LinkedSpansPanel,
LinkedSpansToggle,
@@ -77,7 +83,6 @@ interface SpanDetailsPanelProps {
onVariantChange?: (variant: SpanDetailVariant) => void;
traceStartTime?: number;
traceEndTime?: number;
serviceExecTime?: Record<string, number>;
}
function SpanDetailsContent({
@@ -94,6 +99,17 @@ 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(
@@ -391,12 +407,14 @@ function SpanDetailsContent({
<div className="span-details-panel__tabs-scroll">
<TabsContent value="overview">
<DataViewer
data={selectedSpan}
data={spanDisplayData}
drawerKey="trace-details"
prettyViewProps={{
showPinned: true,
actions: prettyViewCustomActions,
visibleActions: VISIBLE_ACTIONS,
pinnedFieldsValue,
onPinnedFieldsChange,
}}
/>
</TabsContent>
@@ -451,7 +469,6 @@ function SpanDetailsPanel({
onVariantChange,
traceStartTime,
traceEndTime,
serviceExecTime,
}: SpanDetailsPanelProps): JSX.Element {
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
@@ -558,9 +575,6 @@ function SpanDetailsPanel({
<AnalyticsPanel
isOpen={isAnalyticsOpen}
onClose={(): void => setIsAnalyticsOpen(false)}
serviceExecTime={serviceExecTime}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
);
@@ -594,6 +608,7 @@ 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

@@ -0,0 +1,59 @@
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,8 +1,9 @@
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;
@@ -33,17 +34,7 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
key: 'traceId',
label: 'TRACE ID',
render: (span): ReactNode | 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,
span.trace_id ? <TraceIdField span={span} /> : null,
},
{
key: 'spanKind',
@@ -51,4 +42,12 @@ 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

@@ -0,0 +1,51 @@
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} from start
{toFixed(time, 2)} {timeUnitName} since span start
</div>
{Object.keys(attributeMap).length > 0 && (
<>

View File

@@ -1,26 +1,23 @@
.span-hover-card-wrapper {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
// 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-popover {
// 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.
// Hover card may be rendered while the SpanDetailsPanel is docked as
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
--tooltip-z-index: 1000;
.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;
}
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);
}
// Flamegraph tooltip — rendered as a portal, uses same semantic tokens.

View File

@@ -1,16 +1,33 @@
import { memo, ReactNode, useCallback, useRef, useState } from 'react';
import { Popover } from 'antd';
import { themeColors } from 'constants/theme';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { useMemo } from 'react';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { toFixed } from 'utils/toFixed';
import './SpanHoverCard.styles.scss';
interface ITraceMetadata {
startTime: number;
endTime: number;
/**
* 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;
}
export interface SpanTooltipContentProps {
@@ -19,6 +36,7 @@ export interface SpanTooltipContentProps {
hasError: boolean;
relativeStartMs: number;
durationMs: number;
previewRows?: SpanPreviewRow[];
}
export function SpanTooltipContent({
@@ -27,6 +45,7 @@ export function SpanTooltipContent({
hasError,
relativeStartMs,
durationMs,
previewRows,
}: SpanTooltipContentProps): JSX.Element {
const { time: formattedDuration, timeUnitName } =
convertTimeToRelevantUnit(durationMs);
@@ -37,104 +56,118 @@ 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>
);
}
interface SpanHoverCardProps {
span: SpanV3;
traceMetadata: ITraceMetadata;
children: ReactNode;
/**
* 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;
}
/**
* 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,
export function SpanHoverCard({
hoveredSpanId,
onOpenChange,
anchorLeft,
rowHeight,
spans,
traceStartTime,
}: SpanHoverCardProps): JSX.Element {
const [showPopover, setShowPopover] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { previewFields, resolveSpanColor } = useTraceContext();
const handleMouseEnter = useCallback((): void => {
timerRef.current = setTimeout(() => {
setShowPopover(true);
}, 200);
}, []);
const handleMouseLeave = useCallback((): void => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
const hoverCardData = useMemo(() => {
if (!hoveredSpanId) {
return null;
}
setShowPopover(false);
}, []);
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);
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 {
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,
]);
return (
<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>
<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>
);
});
export default SpanHoverCard;
}

View File

@@ -1,3 +1,8 @@
.trace-details-header-wrapper {
flex-shrink: 0;
position: relative;
}
.trace-details-header {
display: flex;
align-items: center;
@@ -16,13 +21,53 @@
&.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,15 +1,27 @@
import { useCallback } from 'react';
import { useCallback, useState } 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 } from '@signozhq/icons';
import { ArrowLeft, CalendarClock, Server, Timer } from '@signozhq/icons';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
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;
@@ -17,20 +29,53 @@ 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;
noData?: boolean;
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"
/>
))}
</>
);
}
function TraceDetailsHeader({
filterMetadata,
onFilteredSpansChange,
noData,
isDataLoaded,
traceMetadata,
}: 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]);
@@ -49,42 +94,127 @@ 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">
<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}
<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>
<Button
variant="solid"
color="secondary"
size="sm"
className="trace-details-header__old-view-btn"
onClick={handleSwitchToOldView}
>
Old View
</Button>
</>
</>
)}
{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>
);

View File

@@ -0,0 +1,93 @@
import { useMemo } from 'react';
import type { MenuItem } from '@signozhq/ui';
import { Button } from '@signozhq/ui';
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,6 +48,10 @@
color: var(--l2-foreground);
}
&__collapse-count-errors {
color: var(--destructive);
}
&__flame-collapse {
flex-shrink: 0;

View File

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

View File

@@ -5,7 +5,13 @@ 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';
@@ -44,6 +50,19 @@ 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,
@@ -52,6 +71,7 @@ function TraceFlamegraph({
traceId,
// selectedSpanId: firstSpanAtFetchLevel,
limit: FLAMEGRAPH_SPAN_LIMIT,
selectFields: flamegraphSelectFields,
});
const spans = useMemo(

View File

@@ -1,15 +1,25 @@
import type React from 'react';
import 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;
canvas.getBoundingClientRect = jest.fn(
jest.spyOn(canvas, 'getBoundingClientRect').mockImplementation(
(): DOMRect =>
({
left: 0,
@@ -59,7 +69,9 @@ describe('useFlamegraphHover', () => {
});
it('sets hoveredSpanId and tooltipContent when hovering on span', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleHoverMouseMove({
@@ -76,7 +88,9 @@ describe('useFlamegraphHover', () => {
});
it('clears hover when moving to empty area', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleHoverMouseMove({
@@ -99,7 +113,9 @@ describe('useFlamegraphHover', () => {
});
it('clears hover on mouse leave', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleHoverMouseMove({
@@ -117,7 +133,9 @@ describe('useFlamegraphHover', () => {
});
it('suppresses click when drag distance exceeds threshold', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleMouseDownForClick({
@@ -137,7 +155,9 @@ describe('useFlamegraphHover', () => {
});
it('calls onSpanClick when clicking on span', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleClick({
@@ -150,7 +170,9 @@ describe('useFlamegraphHover', () => {
});
it('uses clientX/clientY for tooltip positioning', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
act(() => {
result.current.handleHoverMouseMove({
@@ -164,7 +186,9 @@ describe('useFlamegraphHover', () => {
});
it('does not update hover during drag', () => {
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
wrapper,
});
defaultArgs.isDraggingRef.current = true;
act(() => {

View File

@@ -1,4 +1,6 @@
import { getSpanColor } from '../utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { getFlamegraphSpanGroupValue, getSpanColor } from '../utils';
import { MOCK_SPAN } from './testUtils';
const mockGenerateColor = jest.fn();
@@ -8,6 +10,17 @@ 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();
@@ -15,16 +28,17 @@ describe('Presentation / Styling Utils', () => {
});
describe('getSpanColor', () => {
it('uses generated service color for normal span', () => {
it('uses generated colour from groupValue for normal span', () => {
mockGenerateColor.mockReturnValue('#1890ff');
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: false },
isDarkMode: false,
groupValue: 'my-bucket',
});
expect(mockGenerateColor).toHaveBeenCalledWith(
MOCK_SPAN.serviceName,
'my-bucket',
expect.any(Object),
);
expect(color).toBe('#1890ff');
@@ -36,6 +50,7 @@ describe('Presentation / Styling Utils', () => {
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: true },
isDarkMode: false,
groupValue: 'my-bucket',
});
expect(color).toBe('rgb(220, 38, 38)');
@@ -47,21 +62,50 @@ describe('Presentation / Styling Utils', () => {
const color = getSpanColor({
span: { ...MOCK_SPAN, hasError: true },
isDarkMode: true,
groupValue: 'my-bucket',
});
expect(color).toBe('rgb(239, 68, 68)');
});
});
it('passes serviceName to generateColor', () => {
getSpanColor({
span: { ...MOCK_SPAN, serviceName: 'my-service' },
isDarkMode: false,
});
expect(mockGenerateColor).toHaveBeenCalledWith(
'my-service',
expect.any(Object),
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('falls back to top-level serviceName for service.name when resource is empty', () => {
const value = getFlamegraphSpanGroupValue(
{ serviceName: 'svc-legacy', resource: {} },
SERVICE_FIELD,
);
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,6 +6,9 @@ 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 {
@@ -357,6 +360,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
childRow,
timestampMs: child.timestamp,
serviceName: child.serviceName,
resource: child.resource,
});
}
}

View File

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

View File

@@ -8,11 +8,18 @@ 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 { getSpanColor } from '../utils';
import {
getFlamegraphServiceName,
getFlamegraphSpanGroupValue,
getSpanColor,
} from '../utils';
function getCanvasPointer(
canvas: HTMLCanvasElement,
@@ -69,6 +76,11 @@ export interface EventTooltipData {
attributeMap: Record<string, string>;
}
export interface SpanPreviewRowData {
key: string;
value: string;
}
export interface TooltipContent {
serviceName: string;
spanName: string;
@@ -78,6 +90,7 @@ export interface TooltipContent {
clientX: number;
clientY: number;
spanColor: string;
previewRows?: SpanPreviewRowData[];
event?: EventTooltipData;
}
@@ -125,6 +138,25 @@ 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;
@@ -171,14 +203,18 @@ export function useFlamegraphHover(
setHoveredEventKey(`${span.spanId}-${event.name}-${event.timeUnixNano}`);
setHoveredSpanId(span.spanId);
setTooltipContent({
serviceName: span.serviceName || '',
serviceName: getFlamegraphServiceName(span),
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 }),
spanColor: getSpanColor({
span,
isDarkMode,
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
}),
event: {
name: event.name,
timeOffsetMs: eventTimeMs - span.timestamp,
@@ -200,14 +236,19 @@ export function useFlamegraphHover(
setHoveredEventKey(null);
setHoveredSpanId(span.spanId);
setTooltipContent({
serviceName: span.serviceName || '',
serviceName: getFlamegraphServiceName(span),
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 }),
spanColor: getSpanColor({
span,
isDarkMode,
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
}),
previewRows: buildPreviewRows(span),
});
updateCursor(canvas, span);
} else {
@@ -225,6 +266,8 @@ export function useFlamegraphHover(
isDraggingRef,
updateCursor,
isDarkMode,
colorByField,
buildPreviewRows,
],
);

View File

@@ -2,7 +2,9 @@
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,
@@ -66,14 +68,47 @@ 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 } = args;
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
const { span, isDarkMode, groupValue } = args;
let color = generateColor(groupValue, themeColors.traceDetailColorsV3);
if (span.hasError) {
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
@@ -211,7 +246,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.4;
ctx.globalAlpha = 0.15;
}
ctx.beginPath();

View File

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

View File

@@ -3,18 +3,114 @@
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;
.ant-typography {
&__count {
display: flex;
align-items: center;
margin: auto;
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 12px;
@@ -30,14 +126,20 @@
}
}
.no-results {
.filter-status {
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,19 +1,44 @@
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { Button, Spin, Tooltip } from 'antd';
import { useCopyToClipboard } from 'react-use';
import {
ChevronDown,
ChevronUp,
Copy,
Info,
Loader,
Search,
X,
} from '@signozhq/icons';
import { Switch, ToggleGroup, ToggleGroupItem, toast } from '@signozhq/ui';
import { Button } from '@signozhq/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
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 { TracesAggregatorOperator } from 'types/common/queryBuilder';
import { LoaderCircle, Info, ChevronDown, ChevronUp } from '@signozhq/icons';
import {
DataSource,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { BASE_FILTER_QUERY } from './constants';
import { useHighlightErrors } from './hooks/useHighlightErrors';
import {
SpanCategory,
useSpanCategoryFilter,
} from './hooks/useSpanCategoryFilter';
import './Filters.styles.scss';
@@ -44,6 +69,7 @@ function prepareQuery(filters: TagFilter, traceID: string): Query {
},
],
},
selectColumns: [],
},
],
},
@@ -55,31 +81,87 @@ 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 handleFilterChange = useCallback(
(value: TagFilter): void => {
if (value.items.length === 0) {
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) {
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();
@@ -110,14 +192,14 @@ function Filters({
tableParams: {
pagination: {
offset: 0,
limit: 200,
limit: 10000,
},
selectColumns: [
{
key: 'name',
key: 'spanID',
dataType: 'string',
type: 'tag',
id: 'name--string--tag--true',
id: 'spanId--string--tag--true',
isIndexed: false,
},
],
@@ -147,58 +229,187 @@ function Filters({
setCurrentSearchedIndex(0);
}
},
onError: () => {
const isFilterActive = filters.items.length > 0;
setNoData(false);
setFilteredSpanIds([]);
onFilteredSpansChange?.([], isFilterActive);
setCurrentSearchedIndex(0);
},
},
);
return (
<div className="trace-v3-filter-row">
<QueryBuilderSearchV2
query={{
...BASE_FILTER_QUERY,
filters,
}}
onChange={handleFilterChange}
hideSpanScopeSelector={false}
skipQueryBuilderRedirect
selectProps={{ listHeight: 125 }}
const highlightErrorsToggle = (
<div className="highlight-errors-toggle">
<Typography.Text>Highlight errors</Typography.Text>
<Switch
color="cherry"
value={isHighlightErrors}
onChange={handleToggleHighlightErrors}
/>
{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);
}}
/>
</div>
)}
{isFetching && (
<Spin indicator={<LoaderCircle className="animate-spin" />} size="small" />
)}
</div>
);
const statusIndicators = (
<>
{isFetching && <Loader className="animate-spin" />}
{error && (
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
<Info size={14} />
<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>
)}
{noData && (
<Typography.Text className="no-results">No results found</Typography.Text>
{!error && noData && (
<Typography.Text className="filter-status">
No results found
</Typography.Text>
)}
</div>
</>
);
// --- 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 },
}}
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>
);
}

View File

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,45 @@
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

@@ -0,0 +1,84 @@
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,6 +131,24 @@
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;
@@ -243,7 +261,7 @@
}
&.dimmed-span {
opacity: 0.4;
opacity: 0.15;
}
}
}
@@ -315,7 +333,7 @@
.tree-connector {
position: absolute;
width: 11px;
width: 14px;
height: 50%;
border-left: 1px solid var(--l2-border);
border-bottom: 1px solid var(--l2-border);
@@ -323,6 +341,16 @@
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;
@@ -339,6 +367,18 @@
}
}
// 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;
@@ -539,7 +579,7 @@
}
.dimmed-span {
opacity: 0.4;
opacity: 0.15;
}
.highlighted-span {
opacity: 1;

View File

@@ -27,12 +27,11 @@ 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, generateColor } from 'lib/uPlotLib/utils/generateColor';
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
import {
ArrowUpRight,
ChevronDown,
@@ -41,15 +40,17 @@ 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 '../../TraceWaterfall';
import { IInterestedSpan } from '../../types';
import './Success.styles.scss';
@@ -76,7 +77,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleMouseEnter = useCallback((): void => {
timerRef.current = setTimeout(() => setShowPopover(true), 150);
timerRef.current = setTimeout(() => setShowPopover(true), 200);
}, []);
const handleMouseLeave = useCallback((): void => {
@@ -133,7 +134,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
});
// css config
const CONNECTOR_WIDTH = 20;
const CONNECTOR_WIDTH = 30;
const VERTICAL_CONNECTOR_WIDTH = 1;
interface SpanStateClasses {
@@ -194,8 +195,9 @@ const SpanOverview = memo(function SpanOverview({
selectedSpan,
filteredSpanIds,
isFilterActive,
traceMetadata,
onAddSpanToFunnel,
onHoverEnter,
onHoverLeave,
}: {
span: SpanV3;
isSpanCollapsed: boolean;
@@ -204,19 +206,15 @@ 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();
let color = generateColor(
span['service.name'],
themeColors.traceDetailColorsV3,
);
if (span.has_error) {
color = `var(--bg-cherry-500)`;
}
const color = resolveSpanColor(span);
// Smart highlighting logic
const {
@@ -232,6 +230,9 @@ 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 => {
@@ -240,126 +241,128 @@ const SpanOverview = memo(function SpanOverview({
};
return (
<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%',
}}
/>
);
}
<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}>
<div
className="tree-line"
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
/>
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
</div>
<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 (only for spans with children) */}
{/* 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">
{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="subtree-count">
<Badge color="vanilla">{span.sub_tree_node_count}</Badge>
</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>
<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>
</SpanHoverCard>
{/* 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>
);
});
@@ -386,16 +389,10 @@ export const SpanDuration = memo(function SpanDuration({
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
const width = (span.duration_nano * 1e2) / (spread * 1e6);
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 { 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);
const {
isSelected,
@@ -420,27 +417,25 @@ export const SpanDuration = memo(function SpanDuration({
})}
onClick={(): void => handleSpanClick(span)}
>
<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>
<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>
{span.events?.map((event) => {
const eventTimeMs = event.timeUnixNano / 1e6;
const spanDurationMs = span.duration_nano / 1e6;
@@ -506,6 +501,17 @@ 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,
@@ -531,15 +537,32 @@ 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(
@@ -555,14 +578,15 @@ 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],
);
@@ -615,32 +639,8 @@ 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, setInterestedSpanId],
[spans],
);
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] =
@@ -685,10 +685,11 @@ function Success(props: ISuccessProps): JSX.Element {
}
selectedSpan={selectedSpan}
handleSpanClick={handleSpanClick}
traceMetadata={traceMetadata}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
onAddSpanToFunnel={handleAddSpanToFunnel}
onHoverEnter={handleRowMouseEnter}
onHoverLeave={handleRowMouseLeave}
/>
),
}),
@@ -698,12 +699,13 @@ function Success(props: ISuccessProps): JSX.Element {
uncollapsedNodes,
isFullDataLoaded,
localUncollapsedNodes,
traceMetadata,
selectedSpan,
handleSpanClick,
filteredSpanIds,
isFilterActive,
handleAddSpanToFunnel,
handleRowMouseEnter,
handleRowMouseLeave,
],
);
@@ -780,6 +782,12 @@ 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 && (
@@ -833,6 +841,24 @@ 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"
@@ -942,8 +968,8 @@ function Success(props: ISuccessProps): JSX.Element {
height: ROW_HEIGHT,
transform: `translateY(${virtualRow.start}px)`,
}}
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
onMouseLeave={handleRowMouseLeave}
onMouseEnter={(): void => applyHoverClass(span.span_id)}
onMouseLeave={(): void => applyHoverClass(null)}
>
<SpanDuration
span={span}

View File

@@ -2,8 +2,12 @@ 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';
@@ -19,6 +23,9 @@ 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',
@@ -88,7 +95,7 @@ describe('SpanDuration', () => {
it('calls handleSpanClick when clicked', () => {
const mockHandleSpanClick = jest.fn();
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -108,7 +115,7 @@ describe('SpanDuration', () => {
});
it('shows action buttons on hover', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -129,7 +136,7 @@ describe('SpanDuration', () => {
});
it('applies interested-span class when span is selected', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -147,7 +154,7 @@ describe('SpanDuration', () => {
});
it('applies highlighted-span class when span matches filter', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -166,7 +173,7 @@ describe('SpanDuration', () => {
});
it('applies dimmed-span class when span does not match filter', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -185,7 +192,7 @@ describe('SpanDuration', () => {
});
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -205,7 +212,7 @@ describe('SpanDuration', () => {
});
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -226,7 +233,7 @@ describe('SpanDuration', () => {
});
it('applies interested-span class when span is selected and no filter is active', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}
@@ -247,7 +254,7 @@ describe('SpanDuration', () => {
});
it('dims span when filter is active but no matches found', () => {
render(
renderWithTraceProvider(
<SpanDuration
span={mockSpan}
traceMetadata={mockTraceMetadata}

View File

@@ -2,8 +2,16 @@ 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
@@ -222,7 +230,7 @@ describe('Span Click User Flows', () => {
it('clicking span updates URL with spanId parameter', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
renderWithTraceProvider(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}
@@ -261,7 +269,7 @@ describe('Span Click User Flows', () => {
it('clicking span duration visually selects the span', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
renderWithTraceProvider(<TestComponent />, undefined, {
initialRoute: '/trace',
});
@@ -300,7 +308,7 @@ describe('Span Click User Flows', () => {
it('both click areas produce the same visual result', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
renderWithTraceProvider(<TestComponent />, undefined, {
initialRoute: '/trace',
});
@@ -360,7 +368,7 @@ describe('Span Click User Flows', () => {
it('clicking different spans updates selection correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestComponent />, undefined, {
renderWithTraceProvider(<TestComponent />, undefined, {
initialRoute: '/trace',
});
@@ -404,7 +412,7 @@ describe('Span Click User Flows', () => {
mockUrlQuery.set('existingParam', 'existingValue');
mockUrlQuery.set('anotherParam', 'anotherValue');
render(
renderWithTraceProvider(
<Success
spans={mockSpans}
traceMetadata={mockTraceMetadata}

View File

@@ -0,0 +1,111 @@
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

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

View File

@@ -0,0 +1,131 @@
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

@@ -0,0 +1,161 @@
.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

@@ -0,0 +1,149 @@
import { useCallback, useMemo, useState } from 'react';
import { toast } from '@signozhq/ui';
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

@@ -0,0 +1,99 @@
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

@@ -0,0 +1,55 @@
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

@@ -0,0 +1,220 @@
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

@@ -0,0 +1,106 @@
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,16 +12,23 @@ 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 } from 'types/api/trace/getTraceV3';
import {
SpanV3,
TraceDetailV3URLProps,
WaterfallAggregationRequest,
} 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, {
IInterestedSpan,
} from './TraceWaterfall/TraceWaterfall';
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from './TraceWaterfall/types';
import { getAncestorSpanIds } from './TraceWaterfall/utils';
import './TraceDetailsV3.styles.scss';
@@ -79,6 +86,17 @@ 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);
@@ -86,6 +104,7 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
});
const queryParams = fullDataLoadedRef.current
@@ -94,6 +113,7 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
const {
@@ -105,6 +125,7 @@ function TraceDetailsV3(): JSX.Element {
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
aggregations: queryParams.aggregations,
});
const allSpans = traceData?.payload?.spans || [];
@@ -119,6 +140,7 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
}
@@ -213,6 +235,23 @@ 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);
@@ -246,104 +285,116 @@ function TraceDetailsV3(): JSX.Element {
);
return (
<div className="trace-details-v3">
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
noData={showNoData}
/>
<TraceProvider aggregations={traceData?.payload?.aggregations}>
<div className="trace-details-v3">
<TraceDetailsHeader
filterMetadata={filterMetadata}
onFilteredSpansChange={handleFilteredSpansChange}
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
traceMetadata={traceMetadataForHeader}
/>
{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>
),
},
]}
/>
{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>
),
},
]}
/>
<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}
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap}
/>
</div>
{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}
/>
)}
</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>
</>
)}
</div>
</TraceProvider>
);
}

View File

@@ -3,10 +3,17 @@ 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: SpanV3,
span: {
resource?: Record<string, string>;
attributes?: Record<string, any>;
},
key: string,
): string | undefined {
return (
@@ -31,3 +38,40 @@ 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

@@ -0,0 +1,20 @@
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

@@ -0,0 +1,77 @@
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,7 +23,14 @@ const editorOptions: EditorProps['options'] = {
lineHeight: 18,
colorDecorators: true,
scrollBeyondLastLine: false,
scrollbar: { vertical: 'hidden', horizontal: 'hidden' },
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,
},
folding: false,
};

View File

@@ -56,6 +56,13 @@ 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({
@@ -65,6 +72,8 @@ function PrettyView({
searchable = true,
showPinned = false,
drawerKey = 'default',
pinnedFieldsValue,
onPinnedFieldsChange,
}: PrettyViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [, setCopy] = useCopyToClipboard();
@@ -75,7 +84,10 @@ function PrettyView({
pinnedEntries,
pinnedData,
displayKeyToForwardPath,
} = usePinnedFields(data, drawerKey);
} = usePinnedFields(data, drawerKey, {
value: pinnedFieldsValue,
onChange: onPinnedFieldsChange,
});
const filteredPinnedData = useMemo(() => {
const trimmed = searchQuery.trim();

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
@@ -42,17 +42,58 @@ 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,
drawerKey?: string,
options?: UsePinnedFieldsOptions,
): UsePinnedFieldsReturn {
const storageKey = `${STORAGE_PREFIX}:${drawerKey}`;
const controlledValue = options?.value;
const onChange = options?.onChange;
const isControlled = controlledValue !== undefined || onChange !== undefined;
const storageKey =
!isControlled && drawerKey ? `${STORAGE_PREFIX}:${drawerKey}` : null;
// Stored as serialized keyPath arrays (JSON strings)
const [pinnedSerializedKeys, setPinnedSerializedKeys] = useState<Set<string>>(
() => new Set(loadFromStorage(storageKey)),
() => {
if (controlledValue) {
return new Set(controlledValue);
}
if (storageKey) {
return new Set(loadFromStorage(storageKey));
}
return new Set();
},
);
// 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);
@@ -63,11 +104,17 @@ function usePinnedFields(
} else {
next.add(serialized);
}
saveToStorage(storageKey, Array.from(next));
const arr = Array.from(next);
if (storageKey) {
saveToStorage(storageKey, arr);
}
if (onChange) {
onChange(arr);
}
return next;
});
},
[storageKey],
[storageKey, onChange],
);
const isPinned = useCallback(

View File

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

View File

@@ -1,9 +1,26 @@
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 {
@@ -81,5 +98,5 @@ export interface GetTraceV3SuccessResponse {
totalErrorSpansCount: number;
rootServiceName: string;
rootServiceEntryPoint: string;
serviceNameToTotalDurationMap: Record<string, number>;
aggregations?: WaterfallAggregationResponse[];
}

View File

@@ -19,6 +19,7 @@ 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: false,
isComplete: true,
},
{
key: 'name',
@@ -204,7 +204,7 @@ describe('extractQueryPairs', () => {
key: 'active',
operator: 'EXISTS',
value: undefined,
isComplete: false,
isComplete: true,
}),
]);
});
@@ -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(false);
expect(result[0].isComplete).toBe(true);
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
(currentPair.value || isNonValueOperator(currentPair.operator))
),
} as IQueryPair);
}
@@ -1369,7 +1369,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
isComplete: !!(
currentPair.key &&
currentPair.operator &&
currentPair.value
(currentPair.value || isNonValueOperator(currentPair.operator))
),
} as IQueryPair);
@@ -1414,7 +1414,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
isComplete: !!(
currentPair.key &&
currentPair.operator &&
currentPair.value
(currentPair.value || isNonValueOperator(currentPair.operator))
),
} as IQueryPair);
}

View File

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

@@ -1,96 +0,0 @@
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) {
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) {
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

@@ -1,246 +0,0 @@
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

@@ -1,109 +0,0 @@
// 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,10 +28,12 @@ import (
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
)
// 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"
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"
)
type Server struct {
// logger is the logger for the alertmanager
@@ -61,25 +63,15 @@ type Server struct {
silencer *silence.Silencer
silences *silence.Silences
timeIntervals map[string][]timeinterval.TimeInterval
pipelineBuilder *pipelineBuilder
muter *MaintenanceMuter
marker *types.MemMarker
pipelineBuilder *notify.PipelineBuilder
marker *alertmanagertypes.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,
maintenanceStore alertmanagertypes.MaintenanceStore,
) (*Server, error) {
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore, nfManager nfmanager.NotificationManager) (*Server, error) {
server := &Server{
logger: logger.With(slog.String("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver")),
registry: registry,
@@ -92,7 +84,7 @@ func New(
signozRegisterer := prometheus.WrapRegistererWithPrefix("signoz_", registry)
signozRegisterer = prometheus.WrapRegistererWith(prometheus.Labels{"org_id": server.orgID}, signozRegisterer)
// initialize marker
server.marker = types.NewMarker(signozRegisterer)
server.marker = alertmanagertypes.NewMarker(signozRegisterer)
// get silences for initial state
state, err := server.stateStore.Get(ctx, server.orgID)
@@ -168,6 +160,7 @@ func New(
return c, server.stateStore.Set(ctx, storableSilences)
})
}()
// Start maintenance for notification logs
@@ -203,25 +196,17 @@ func New(
return nil, err
}
server.muter = NewMaintenanceMuter(maintenanceStore, orgID, server.logger)
server.pipelineBuilder = newPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
server.pipelineBuilder = notify.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)
},
func(labels model.LabelSet) []string {
return server.muter.MutedBy(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)
}, params)
}
func (server *Server) PutAlerts(ctx context.Context, postableAlerts alertmanagertypes.PostableAlerts) error {
@@ -305,7 +290,6 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
server.silencer,
intervener,
server.marker,
server.muter,
server.nflog,
pipelinePeer,
)

View File

@@ -7,10 +7,6 @@ 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"
@@ -94,8 +90,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
stateStore := alertmanagertypestest.NewStateStore()
registry := prometheus.NewRegistry()
logger := slog.New(slog.DiscardHandler)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual))
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager, maintenanceStore)
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
require.NoError(t, err)

View File

@@ -10,11 +10,7 @@ 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"
@@ -27,14 +23,9 @@ 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, newTestMaintenanceStore())
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
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")
@@ -46,7 +37,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, newTestMaintenanceStore())
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
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")
@@ -94,7 +85,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, newTestMaintenanceStore())
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -142,7 +133,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, newTestMaintenanceStore())
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
@@ -247,7 +238,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, newTestMaintenanceStore())
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
require.NoError(t, err)
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")

View File

@@ -39,18 +39,16 @@ 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,
@@ -61,7 +59,6 @@ func New(
servers: make(map[string]*alertmanagerserver.Server),
serversMtx: sync.RWMutex{},
notificationManager: nfManager,
maintenanceStore: maintenanceStore,
}
return service
@@ -180,10 +177,7 @@ 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, service.maintenanceStore,
)
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore, service.notificationManager)
if err != nil {
return nil, err
}

View File

@@ -4,16 +4,18 @@ import (
"context"
"time"
amConfig "github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
amConfig "github.com/prometheus/alertmanager/config"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"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"
@@ -28,49 +30,35 @@ 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,
maintenanceStore alertmanagertypes.MaintenanceStore,
) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) 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(settings, config, sqlstore, orgGetter, notificationManager, maintenanceStore)
return New(ctx, settings, config, sqlstore, orgGetter, notificationManager)
})
}
func New(
providerSettings factory.ProviderSettings,
config alertmanager.Config,
sqlstore sqlstore.SQLStore,
orgGetter organization.Getter,
notificationManager nfmanager.NotificationManager,
maintenanceStore alertmanagertypes.MaintenanceStore,
) (*provider, error) {
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) (*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,7 +5,6 @@ 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"
)
@@ -121,8 +120,8 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
Tags: []string{"downtimeschedules"},
Summary: "List downtime schedules",
Description: "This endpoint lists all planned maintenance / downtime schedules",
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
RequestQuery: new(ruletypes.ListPlannedMaintenanceParams),
Response: make([]*ruletypes.PlannedMaintenance, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
@@ -135,7 +134,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(alertmanagertypes.PlannedMaintenance),
Response: new(ruletypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
@@ -149,9 +148,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(alertmanagertypes.PostablePlannedMaintenance),
Request: new(ruletypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
Response: new(alertmanagertypes.PlannedMaintenance),
Response: new(ruletypes.PlannedMaintenance),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
@@ -165,7 +164,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(alertmanagertypes.PostablePlannedMaintenance),
Request: new(ruletypes.PostablePlannedMaintenance),
RequestContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},

View File

@@ -32,28 +32,30 @@ import (
)
type PrepareTaskOptions struct {
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
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
}
type PrepareTestRuleOptions struct {
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
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
}
const taskNameSuffix = "webAppEditor"
@@ -87,7 +89,7 @@ type ManagerOptions struct {
Alertmanager alertmanager.Alertmanager
OrgGetter organization.Getter
RuleStore ruletypes.RuleStore
MaintenanceStore alertmanagertypes.MaintenanceStore
MaintenanceStore ruletypes.MaintenanceStore
SQLStore sqlstore.SQLStore
QueryParser queryparser.QueryParser
}
@@ -101,7 +103,7 @@ type Manager struct {
block chan struct{}
// datastore to store alert definitions
ruleStore ruletypes.RuleStore
maintenanceStore alertmanagertypes.MaintenanceStore
maintenanceStore ruletypes.MaintenanceStore
logger *slog.Logger
cache cache.Cache
@@ -132,6 +134,7 @@ func defaultOptions(o *ManagerOptions) *ManagerOptions {
}
func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
rules := make([]Rule, 0)
var task Task
@@ -156,6 +159,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
WithMetadataStore(opts.ManagerOpts.MetadataStore),
WithRuleStateHistoryModule(opts.ManagerOpts.RuleStateHistoryModule),
)
if err != nil {
return task, err
}
@@ -163,7 +167,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)
task = newTask(TaskTypeCh, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -179,6 +183,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
WithMetadataStore(opts.ManagerOpts.MetadataStore),
WithRuleStateHistoryModule(opts.ManagerOpts.RuleStateHistoryModule),
)
if err != nil {
return task, err
}
@@ -186,7 +191,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)
task = newTask(TaskTypeProm, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
@@ -232,11 +237,10 @@ func (m *Manager) RuleStore() ruletypes.RuleStore {
return m.ruleStore
}
func (m *Manager) MaintenanceStore() alertmanagertypes.MaintenanceStore {
func (m *Manager) MaintenanceStore() ruletypes.MaintenanceStore {
return m.maintenanceStore
}
// TODO(jatinderjit): remove (unused)?
func (m *Manager) Pause(b bool) {
m.mtx.Lock()
defer m.mtx.Unlock()
@@ -426,17 +430,19 @@ 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,
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,
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,
})
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")
@@ -637,17 +643,19 @@ 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,
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,
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,
})
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")
@@ -695,6 +703,7 @@ 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)
@@ -888,6 +897,7 @@ 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 {
@@ -1019,15 +1029,16 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
}
alertCount, err := m.prepareTestRuleFunc(PrepareTestRuleOptions{
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,
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,
})
return alertCount, err

View File

@@ -15,6 +15,7 @@ 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
@@ -38,11 +39,14 @@ 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) *PromRuleTask {
func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) *PromRuleTask {
opts.Logger.Info("initiating a new rule group", "name", name, "frequency", frequency)
if frequency == 0 {
@@ -59,8 +63,10 @@ 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,
logger: opts.Logger,
notify: notify,
maintenanceStore: maintenanceStore,
logger: opts.Logger,
orgID: orgID,
}
}
@@ -324,12 +330,30 @@ 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,18 +2,20 @@ 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)
@@ -35,28 +37,34 @@ 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) *RuleTask {
func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) *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,
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,
}
}
@@ -64,7 +72,6 @@ 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
}
@@ -76,7 +83,7 @@ func (g *RuleTask) Type() TaskType { return TaskTypeCh }
func (g *RuleTask) Rules() []Rule { return g.rules }
// Interval returns the group's interval.
// TODO(jatinderjit): remove (unused)?
// TODO: remove (unused)?
func (g *RuleTask) Interval() time.Duration { return g.frequency }
func (g *RuleTask) Pause(b bool) {
@@ -254,6 +261,7 @@ 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")
@@ -298,6 +306,7 @@ 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(
@@ -309,11 +318,31 @@ 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
@@ -353,6 +382,7 @@ 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,6 +3,9 @@ package rules
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type TaskType string
@@ -29,9 +32,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) Task {
func newTask(taskType TaskType, name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) Task {
if taskType == TaskTypeCh {
return NewRuleTask(name, file, frequency, rules, opts, notify)
return NewRuleTask(name, file, frequency, rules, opts, notify, maintenanceStore, orgID)
}
return NewPromRuleTask(name, file, frequency, rules, opts, notify)
return NewPromRuleTask(name, file, frequency, rules, opts, notify, maintenanceStore, orgID)
}

View File

@@ -5,7 +5,6 @@ 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"
)
@@ -45,5 +44,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() alertmanagertypes.MaintenanceStore
MaintenanceStore() ruletypes.MaintenanceStore
}

View File

@@ -1,4 +1,4 @@
package sqlalertmanagerstore
package sqlrulestore
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) alertmanagertypes.MaintenanceStore {
func NewMaintenanceStore(store sqlstore.SQLStore) ruletypes.MaintenanceStore {
return &maintenance{sqlstore: store}
}
func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.PlannedMaintenance, error) {
gettableMaintenancesRules := make([]*alertmanagertypes.PlannedMaintenanceWithRules, 0)
func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string) ([]*ruletypes.PlannedMaintenance, error) {
gettableMaintenancesRules := make([]*ruletypes.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([]*alertmanagertypes.PlannedMaintenance, 0)
gettablePlannedMaintenance := make([]*ruletypes.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) (*alertmanagertypes.PlannedMaintenance, error) {
storableMaintenanceRule := new(alertmanagertypes.PlannedMaintenanceWithRules)
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*ruletypes.PlannedMaintenance, error) {
storableMaintenanceRule := new(ruletypes.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 *alertmanagertypes.PostablePlannedMaintenance) (*alertmanagertypes.PlannedMaintenance, error) {
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance *ruletypes.PostablePlannedMaintenance) (*ruletypes.PlannedMaintenance, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
storablePlannedMaintenance := ruletypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
@@ -81,14 +81,14 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
OrgID: claims.OrgID,
}
maintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
maintenanceRules := make([]*ruletypes.StorablePlannedMaintenanceRule, 0)
for _, ruleIDStr := range maintenance.AlertIds {
ruleID, err := valuer.NewUUID(ruleIDStr)
if err != nil {
return nil, err
}
maintenanceRules = append(maintenanceRules, &alertmanagertypes.StorablePlannedMaintenanceRule{
maintenanceRules = append(maintenanceRules, &ruletypes.StorablePlannedMaintenanceRule{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
@@ -125,7 +125,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
return nil, err
}
return &alertmanagertypes.PlannedMaintenance{
return &ruletypes.PlannedMaintenance{
ID: storablePlannedMaintenance.ID,
Name: storablePlannedMaintenance.Name,
Description: storablePlannedMaintenance.Description,
@@ -142,7 +142,7 @@ func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UU
_, err := r.sqlstore.
BunDB().
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
Model(new(ruletypes.StorablePlannedMaintenance)).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
@@ -152,7 +152,7 @@ func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UU
return nil
}
func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance, id valuer.UUID) error {
func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance *ruletypes.PostablePlannedMaintenance, id valuer.UUID) error {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return err
@@ -163,7 +163,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
return err
}
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
storablePlannedMaintenance := ruletypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: id,
},
@@ -181,14 +181,14 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
OrgID: claims.OrgID,
}
storablePlannedMaintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
storablePlannedMaintenanceRules := make([]*ruletypes.StorablePlannedMaintenanceRule, 0)
for _, ruleIDStr := range maintenance.AlertIds {
ruleID, err := valuer.NewUUID(ruleIDStr)
if err != nil {
return err
}
storablePlannedMaintenanceRules = append(storablePlannedMaintenanceRules, &alertmanagertypes.StorablePlannedMaintenanceRule{
storablePlannedMaintenanceRules = append(storablePlannedMaintenanceRules, &ruletypes.StorablePlannedMaintenanceRule{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
@@ -211,7 +211,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
_, err = r.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenanceRule)).
Model(new(ruletypes.StorablePlannedMaintenanceRule)).
Where("planned_maintenance_id = ?", storablePlannedMaintenance.ID.StringValue()).
Exec(ctx)

View File

@@ -10,7 +10,6 @@ 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"
@@ -195,7 +194,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
return
}
var params alertmanagertypes.ListPlannedMaintenanceParams
var params ruletypes.ListPlannedMaintenanceParams
if err := binding.Query.BindQuery(req.URL.Query(), &params); err != nil {
render.Error(rw, err)
return
@@ -208,7 +207,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
}
if params.Active != nil {
activeSchedules := make([]*alertmanagertypes.PlannedMaintenance, 0)
activeSchedules := make([]*ruletypes.PlannedMaintenance, 0)
for _, schedule := range schedules {
now := time.Now().In(time.FixedZone(schedule.Schedule.Timezone, 0))
if schedule.IsActive(now) == *params.Active {
@@ -219,7 +218,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
}
if params.Recurring != nil {
recurringSchedules := make([]*alertmanagertypes.PlannedMaintenance, 0)
recurringSchedules := make([]*ruletypes.PlannedMaintenance, 0)
for _, schedule := range schedules {
if schedule.IsRecurring() == *params.Recurring {
recurringSchedules = append(recurringSchedules, schedule)
@@ -254,7 +253,7 @@ func (handler *handler) CreateDowntimeSchedule(rw http.ResponseWriter, req *http
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
schedule := new(alertmanagertypes.PostablePlannedMaintenance)
schedule := new(ruletypes.PostablePlannedMaintenance)
if err := binding.JSON.BindBody(req.Body, schedule); err != nil {
render.Error(rw, err)
return
@@ -284,7 +283,7 @@ func (handler *handler) UpdateDowntimeScheduleByID(rw http.ResponseWriter, req *
return
}
schedule := new(alertmanagertypes.PostablePlannedMaintenance)
schedule := new(ruletypes.PostablePlannedMaintenance)
if err := binding.JSON.BindBody(req.Body, schedule); err != nil {
render.Error(rw, err)
return

View File

@@ -4,7 +4,6 @@ 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"
@@ -17,7 +16,6 @@ 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"
@@ -46,7 +44,7 @@ func NewFactory(
) factory.ProviderFactory[ruler.Ruler, ruler.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config ruler.Config) (ruler.Ruler, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
managerOpts := &rules.ManagerOptions{
TelemetryStore: telemetryStore,
@@ -131,6 +129,6 @@ func (provider *provider) TestNotification(ctx context.Context, orgID valuer.UUI
return provider.manager.TestNotification(ctx, orgID, ruleStr)
}
func (provider *provider) MaintenanceStore() alertmanagertypes.MaintenanceStore {
func (provider *provider) MaintenanceStore() ruletypes.MaintenanceStore {
return provider.manager.MaintenanceStore()
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@@ -40,8 +39,7 @@ func TestNewHandlers(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)
emailing := emailingtest.New()

View File

@@ -7,7 +7,6 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
@@ -41,8 +40,7 @@ func TestNewModules(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)
emailing := emailingtest.New()

View File

@@ -226,14 +226,9 @@ func NewNotificationManagerProviderFactories(routeStore alertmanagertypes.RouteS
)
}
func NewAlertmanagerProviderFactories(
sqlstore sqlstore.SQLStore,
orgGetter organization.Getter,
nfManager nfmanager.NotificationManager,
maintenanceStore alertmanagertypes.MaintenanceStore,
) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, nfManager nfmanager.NotificationManager) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
return factory.MustNewNamedMap(
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager, maintenanceStore),
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager),
)
}

View File

@@ -5,7 +5,6 @@ import (
"testing"
"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/analytics"
"github.com/SigNoz/signoz/pkg/flagger"
@@ -60,11 +59,9 @@ func TestNewProviderFactories(t *testing.T) {
})
assert.NotPanics(t, func() {
store := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
orgGetter := implorganization.NewGetter(implorganization.NewStore(store), nil)
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
notificationManager := nfmanagertest.NewMock()
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(store)
NewAlertmanagerProviderFactories(store, orgGetter, notificationManager, maintenanceStore)
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter, notificationManager)
})
assert.NotPanics(t, func() {

View File

@@ -5,7 +5,6 @@ import (
"log/slog"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/sqlroutingstore"
"github.com/SigNoz/signoz/pkg/analytics"
@@ -370,14 +369,12 @@ func New(
return nil, err
}
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
// Initialize alertmanager from the available alertmanager provider factories
alertmanager, err := factory.NewProviderFromNamedMap(
ctx,
providerSettings,
config.Alertmanager,
NewAlertmanagerProviderFactories(sqlstore, orgGetter, nfManager, maintenanceStore),
NewAlertmanagerProviderFactories(sqlstore, orgGetter, nfManager),
config.Alertmanager.Provider,
)
if err != nil {

View File

@@ -11,7 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
@@ -61,7 +61,7 @@ type existingMaintenance struct {
Name string `bun:"name,type:text,notnull"`
Description string `bun:"description,type:text"`
AlertIDs *AlertIds `bun:"alert_ids,type:text"`
Schedule *alertmanagertypes.Schedule `bun:"schedule,type:text,notnull"`
Schedule *ruletypes.Schedule `bun:"schedule,type:text,notnull"`
CreatedAt time.Time `bun:"created_at,type:datetime,notnull"`
CreatedBy string `bun:"created_by,type:text,notnull"`
UpdatedAt time.Time `bun:"updated_at,type:datetime,notnull"`
@@ -75,7 +75,7 @@ type newMaintenance struct {
types.UserAuditable
Name string `bun:"name,type:text,notnull"`
Description string `bun:"description,type:text"`
Schedule *alertmanagertypes.Schedule `bun:"schedule,type:text,notnull"`
Schedule *ruletypes.Schedule `bun:"schedule,type:text,notnull"`
OrgID string `bun:"org_id,type:text"`
}

View File

@@ -70,12 +70,39 @@ func (b *traceOperatorCTEBuilder) build(ctx context.Context, requestType qbtypes
selectFromCTE := rootCTEName
if b.operator.ReturnSpansFrom != "" {
selectFromCTE = b.queryToCTEName[b.operator.ReturnSpansFrom]
if selectFromCTE == "" {
sourceQueryCTE := b.queryToCTEName[b.operator.ReturnSpansFrom]
if sourceQueryCTE == "" {
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
"returnSpansFrom references query '%s' which has no corresponding CTE",
b.operator.ReturnSpansFrom)
}
filteredCTEName := fmt.Sprintf("__return_from_%s", b.operator.ReturnSpansFrom)
// DISTINCT is essential here. The operator CTE (rootCTEName) holds one row
// per matching *span*, not one row per matching *trace*. A single trace can
// satisfy the operator through multiple spans — e.g. for "A -> B", every
// A-span that is an indirect ancestor of any B-span appears as a separate
// row. If we joined sourceQueryCTE directly against rootCTEName on trace_id,
// each source span would be duplicated once for every operator-matching span
// on the same trace. DISTINCT collapses rootCTEName to one row per trace_id,
// making the join a clean membership test with no fan-out.
matchingTracedSB := sqlbuilder.NewSelectBuilder()
matchingTracedSB.Select("DISTINCT trace_id")
matchingTracedSB.From(rootCTEName)
matchedTracesSQL, matchedTracesArgs := matchingTracedSB.BuildWithFlavor(sqlbuilder.ClickHouse)
filteredSB := sqlbuilder.NewSelectBuilder()
filteredSB.Select("src.*")
filteredSB.From(fmt.Sprintf("%s AS src", sourceQueryCTE))
filteredSB.JoinWithOption(
sqlbuilder.InnerJoin,
fmt.Sprintf("(%s) AS matched_traces", matchedTracesSQL),
"src.trace_id = matched_traces.trace_id",
)
filteredSQL, filteredArgs := filteredSB.BuildWithFlavor(sqlbuilder.ClickHouse, matchedTracesArgs...)
b.addCTE(filteredCTEName, filteredSQL, filteredArgs, []string{sourceQueryCTE, rootCTEName})
selectFromCTE = filteredCTEName
}
finalStmt, err := b.buildFinalQuery(ctx, selectFromCTE, requestType)

View File

@@ -385,6 +385,82 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
expectedErr: nil,
},
{
name: "returnSpansFrom B: A -> B return B spans filtered by operator",
requestType: qbtypes.RequestTypeRaw,
operator: qbtypes.QueryBuilderTraceOperator{
Expression: "A -> B",
ReturnSpansFrom: "B",
Limit: 10,
},
compositeQuery: &qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "service.name = 'gateway'"},
},
},
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "service.name = 'database'"},
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __return_from_B AS (SELECT src.* FROM B AS src INNER JOIN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B) AS matched_traces ON src.trace_id = matched_traces.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
},
{
name: "returnSpansFrom C: (A -> B) && C return C spans filtered by operator",
requestType: qbtypes.RequestTypeRaw,
operator: qbtypes.QueryBuilderTraceOperator{
Expression: "(A -> B) && C",
ReturnSpansFrom: "C",
Limit: 10,
},
compositeQuery: &qbtypes.CompositeQuery{
Queries: []qbtypes.QueryEnvelope{
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "A",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "service.name = 'gateway'"},
},
},
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "service.name = 'database'"},
},
},
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "C",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "service.name = 'auth'"},
},
},
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B_AND_C AS (SELECT l.* FROM A_INDIR_DESC_B AS l INNER JOIN C AS r ON l.trace_id = r.trace_id), __return_from_C AS (SELECT src.* FROM C AS src INNER JOIN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B_AND_C) AS matched_traces ON src.trace_id = matched_traces.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_C ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "auth", "%service.name%", "%service.name\":\"auth%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
},
}
fm := NewFieldMapper()

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