Compare commits

..

8 Commits

Author SHA1 Message Date
Nikhil Soni
0cbd4e0a95 fix: remove overlapping time from duration aggregation 2026-04-24 19:57:04 +05:30
Nikhil Soni
19254f84cc chore: simplify getting attribute value for span 2026-04-24 19:55:52 +05:30
Nikhil Soni
261a5888ad feat: add support to request telemetry fields in flamegraph 2026-04-24 19:43:03 +05:30
Nikhil Soni
8f1aab8e40 chore: update openapi specs 2026-04-24 19:40:44 +05:30
Nikhil Soni
cc8064c5a6 chore: rename analytics to aggregations 2026-04-24 19:29:09 +05:30
Nikhil Soni
1df7795386 chore: add tests for aggregation logic 2026-04-24 19:17:01 +05:30
Nikhil Soni
62bfb0d5bd feat: add customer aggregation support in waterfall 2026-04-24 19:16:36 +05:30
Pandey
7e7d7ab570 feat(global): add mcp_url to global config (#11085)
* feat(global): add mcp_url to global config

Adds an optional mcp_url field to the global config so the frontend can
gate the MCP settings page on its presence. When unset the API returns
"mcp_url": null (pointer + nullable:"true"); when set it emits the
parsed URL as a string.

* feat(global): surface mcp_url in frontend types

Adds mcp_url to the manual GlobalConfigData type and refreshes the
generated OpenAPI client so consumers can read the new field.

* docs(global): use <unset> placeholder for mcp_url example

Matches the style of external_url and ingestion_url above it.

* style(global): separate mcp_url prep from return in GetConfig

Adds a blank line between the nullable-conversion block and the return
statement so the two logical phases read as distinct blocks.

* feat(global): mark endpoint fields as required in the API schema

The backend always emits external_url, ingestion_url and mcp_url on
GET /api/v1/global/config (mcp_url as literal null when unset), so the
JSON keys are always present. Add required:"true" to all three and
regenerate the OpenAPI + frontend client so consumers get non-optional
types.

* revert(global): drop mcp_url from legacy GlobalConfigData type

The legacy hand-written type for the non-Orval getGlobalConfig client
should be left alone; consumers that need mcp_url go through the
generated Orval client.
2026-04-24 10:24:21 +00:00
24 changed files with 736 additions and 497 deletions

View File

@@ -11,6 +11,8 @@ global:
external_url: <unset>
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
ingestion_url: <unset>
# the url of the SigNoz MCP server. when unset, the MCP settings page is hidden in the frontend.
# mcp_url: <unset>
##################### Version #####################
version:

View File

@@ -2369,6 +2369,13 @@ components:
$ref: '#/components/schemas/GlobaltypesIdentNConfig'
ingestion_url:
type: string
mcp_url:
nullable: true
type: string
required:
- external_url
- ingestion_url
- mcp_url
type: object
GlobaltypesIdentNConfig:
properties:
@@ -4596,6 +4603,11 @@ components:
type: object
TracedetailtypesGettableWaterfallTrace:
properties:
aggregations:
items:
$ref: '#/components/schemas/TracedetailtypesSpanAggregationResult'
nullable: true
type: array
endTimestampMillis:
minimum: 0
type: integer
@@ -4635,6 +4647,11 @@ components:
type: object
TracedetailtypesPostableWaterfall:
properties:
aggregations:
items:
$ref: '#/components/schemas/TracedetailtypesSpanAggregation'
nullable: true
type: array
limit:
minimum: 0
type: integer
@@ -4646,6 +4663,32 @@ components:
nullable: true
type: array
type: object
TracedetailtypesSpanAggregation:
properties:
aggregation:
$ref: '#/components/schemas/TracedetailtypesSpanAggregationType'
field:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: object
TracedetailtypesSpanAggregationResult:
properties:
aggregation:
$ref: '#/components/schemas/TracedetailtypesSpanAggregationType'
field:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
value:
additionalProperties:
minimum: 0
type: integer
nullable: true
type: object
type: object
TracedetailtypesSpanAggregationType:
enum:
- spanCount
- executionTimePercentage
- duration
type: string
TracedetailtypesWaterfallSpan:
properties:
attributes:

View File

@@ -3125,12 +3125,17 @@ export interface GlobaltypesConfigDTO {
/**
* @type string
*/
external_url?: string;
external_url: string;
identN?: GlobaltypesIdentNConfigDTO;
/**
* @type string
*/
ingestion_url?: string;
ingestion_url: string;
/**
* @type string
* @nullable true
*/
mcp_url: string | null;
}
export interface GlobaltypesIdentNConfigDTO {
@@ -5588,6 +5593,11 @@ export type TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationM
{ [key: string]: number } | null;
export interface TracedetailtypesGettableWaterfallTraceDTO {
/**
* @type array
* @nullable true
*/
aggregations?: TracedetailtypesSpanAggregationResultDTO[] | null;
/**
* @type integer
* @minimum 0
@@ -5642,6 +5652,11 @@ export interface TracedetailtypesGettableWaterfallTraceDTO {
}
export interface TracedetailtypesPostableWaterfallDTO {
/**
* @type array
* @nullable true
*/
aggregations?: TracedetailtypesSpanAggregationDTO[] | null;
/**
* @type integer
* @minimum 0
@@ -5658,6 +5673,33 @@ export interface TracedetailtypesPostableWaterfallDTO {
uncollapsedSpans?: string[] | null;
}
export interface TracedetailtypesSpanAggregationDTO {
aggregation?: TracedetailtypesSpanAggregationTypeDTO;
field?: TelemetrytypesTelemetryFieldKeyDTO;
}
/**
* @nullable
*/
export type TracedetailtypesSpanAggregationResultDTOValue = {
[key: string]: number;
} | null;
export interface TracedetailtypesSpanAggregationResultDTO {
aggregation?: TracedetailtypesSpanAggregationTypeDTO;
field?: TelemetrytypesTelemetryFieldKeyDTO;
/**
* @type object
* @nullable true
*/
value?: TracedetailtypesSpanAggregationResultDTOValue;
}
export enum TracedetailtypesSpanAggregationTypeDTO {
spanCount = 'spanCount',
executionTimePercentage = 'executionTimePercentage',
duration = 'duration',
}
/**
* @nullable
*/

View File

@@ -104,12 +104,7 @@ export const usePanelContextMenu = ({
}
if (data && data?.record?.queryName) {
onClick(data.coord, {
...data.record,
label: data.label,
seriesColor: data.seriesColor,
timeRange,
});
onClick(data.coord, { ...data.record, label: data.label, timeRange });
}
},
[onClick, queryResponse],

View File

@@ -1,282 +0,0 @@
import { renderHook } from '@testing-library/react';
import ROUTES from 'constants/routes';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getViewQuery } from '../drilldownUtils';
import { AggregateData } from '../useAggregateDrilldown';
import useBaseDrilldownNavigate, {
buildDrilldownUrl,
getRoute,
} from '../useBaseDrilldownNavigate';
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: typeof mockSafeNavigate } => ({ safeNavigate: mockSafeNavigate }),
}));
jest.mock('../drilldownUtils', () => ({
...jest.requireActual('../drilldownUtils'),
getViewQuery: jest.fn(),
}));
const mockGetViewQuery = getViewQuery as jest.Mock;
// ─── Fixtures ────────────────────────────────────────────────────────────────
const MOCK_QUERY: Query = {
id: 'q1',
queryType: 'builder' as any,
builder: {
queryData: [
{
queryName: 'A',
dataSource: 'metrics' as any,
groupBy: [],
expression: '',
disabled: false,
functions: [],
legend: '',
having: [],
limit: null,
stepInterval: undefined,
orderBy: [],
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
};
const MOCK_VIEW_QUERY: Query = {
...MOCK_QUERY,
builder: {
...MOCK_QUERY.builder,
queryData: [
{
...MOCK_QUERY.builder.queryData[0],
filters: { items: [], op: 'AND' },
},
],
},
};
const MOCK_AGGREGATE_DATA: AggregateData = {
queryName: 'A',
filters: [{ filterKey: 'service_name', filterValue: 'auth', operator: '=' }],
timeRange: { startTime: 1000000, endTime: 2000000 },
};
// ─── getRoute ─────────────────────────────────────────────────────────────────
describe('getRoute', () => {
it.each([
['view_logs', ROUTES.LOGS_EXPLORER],
['view_metrics', ROUTES.METRICS_EXPLORER],
['view_traces', ROUTES.TRACES_EXPLORER],
])('maps %s to the correct explorer route', (key, expected) => {
expect(getRoute(key)).toBe(expected);
});
it('returns empty string for an unknown key', () => {
expect(getRoute('view_dashboard')).toBe('');
});
});
// ─── buildDrilldownUrl ────────────────────────────────────────────────────────
describe('buildDrilldownUrl', () => {
beforeEach(() => {
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
});
afterEach(() => {
jest.clearAllMocks();
});
it('returns null for an unknown drilldown key', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_dashboard');
expect(url).toBeNull();
});
it('returns null when getViewQuery returns null', () => {
mockGetViewQuery.mockReturnValue(null);
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toBeNull();
});
it('returns a URL starting with the logs explorer route for view_logs', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).not.toBeNull();
expect(url).toContain(ROUTES.LOGS_EXPLORER);
});
it('returns a URL starting with the traces explorer route for view_traces', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_traces');
expect(url).toContain(ROUTES.TRACES_EXPLORER);
});
it('includes compositeQuery param in the URL', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toContain('compositeQuery=');
});
it('includes startTime and endTime when aggregateData has a timeRange', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).toContain('startTime=1000000');
expect(url).toContain('endTime=2000000');
});
it('omits startTime and endTime when aggregateData has no timeRange', () => {
const { timeRange: _, ...withoutTimeRange } = MOCK_AGGREGATE_DATA;
const url = buildDrilldownUrl(MOCK_QUERY, withoutTimeRange, 'view_logs');
expect(url).not.toContain('startTime=');
expect(url).not.toContain('endTime=');
});
it('includes summaryFilters param for view_metrics', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_metrics');
expect(url).toContain(ROUTES.METRICS_EXPLORER);
expect(url).toContain('summaryFilters=');
});
it('does not include summaryFilters param for non-metrics routes', () => {
const url = buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(url).not.toContain('summaryFilters=');
});
it('handles null aggregateData by passing empty filters and empty queryName', () => {
const url = buildDrilldownUrl(MOCK_QUERY, null, 'view_logs');
expect(url).not.toBeNull();
expect(mockGetViewQuery).toHaveBeenCalledWith(MOCK_QUERY, [], 'view_logs', '');
});
it('passes aggregateData filters and queryName to getViewQuery', () => {
buildDrilldownUrl(MOCK_QUERY, MOCK_AGGREGATE_DATA, 'view_logs');
expect(mockGetViewQuery).toHaveBeenCalledWith(
MOCK_QUERY,
MOCK_AGGREGATE_DATA.filters,
'view_logs',
MOCK_AGGREGATE_DATA.queryName,
);
});
});
// ─── useBaseDrilldownNavigate ─────────────────────────────────────────────────
describe('useBaseDrilldownNavigate', () => {
beforeEach(() => {
mockGetViewQuery.mockReturnValue(MOCK_VIEW_QUERY);
});
afterEach(() => {
jest.clearAllMocks();
});
it('calls safeNavigate with the built URL on a valid key', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_logs');
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
const [url] = mockSafeNavigate.mock.calls[0];
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain('compositeQuery=');
});
it('opens the explorer in a new tab', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_traces');
expect(mockSafeNavigate).toHaveBeenCalledWith(
expect.any(String),
{ newTab: true },
);
});
it('calls callback after successful navigation', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_logs');
expect(callback).toHaveBeenCalledTimes(1);
});
it('does not call safeNavigate for an unknown key', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
}),
);
result.current('view_dashboard');
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('still calls callback when the key is unknown', () => {
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_dashboard');
expect(callback).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('still calls callback when getViewQuery returns null', () => {
mockGetViewQuery.mockReturnValue(null);
const callback = jest.fn();
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: MOCK_AGGREGATE_DATA,
callback,
}),
);
result.current('view_logs');
expect(callback).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).not.toHaveBeenCalled();
});
it('handles null aggregateData without throwing', () => {
const { result } = renderHook(() =>
useBaseDrilldownNavigate({
resolvedQuery: MOCK_QUERY,
aggregateData: null,
}),
);
expect(() => result.current('view_logs')).not.toThrow();
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
});
});

View File

@@ -166,7 +166,7 @@ export const getAggregateColumnHeader = (
};
};
export const getFiltersFromMetric = (metric: any): FilterData[] =>
const getFiltersFromMetric = (metric: any): FilterData[] =>
Object.keys(metric).map((key) => ({
filterKey: key,
filterValue: metric[key],
@@ -196,7 +196,6 @@ export const getUplotClickData = ({
coord: { x: number; y: number };
record: { queryName: string; filters: FilterData[] };
label: string | React.ReactNode;
seriesColor?: string;
} | null => {
if (!queryData?.queryName || !metric) {
return null;
@@ -209,7 +208,6 @@ export const getUplotClickData = ({
// Generate label from focusedSeries data
let label: string | React.ReactNode = '';
const seriesColor = focusedSeries?.color;
if (focusedSeries && focusedSeries.seriesName) {
label = (
<span style={{ color: focusedSeries.color }}>
@@ -225,7 +223,6 @@ export const getUplotClickData = ({
},
record,
label,
seriesColor,
};
};
@@ -240,7 +237,6 @@ export const getPieChartClickData = (
queryName: string;
filters: FilterData[];
label: string | React.ReactNode;
seriesColor?: string;
} | null => {
const { metric, queryName } = arc.data.record;
if (!queryName || !metric) {
@@ -252,7 +248,6 @@ export const getPieChartClickData = (
queryName,
filters: getFiltersFromMetric(metric), // TODO: add where clause query as well.
label,
seriesColor: arc.data.color,
};
};

View File

@@ -22,7 +22,6 @@ export interface AggregateData {
endTime: number;
};
label?: string | React.ReactNode;
seriesColor?: string;
}
const useAggregateDrilldown = ({

View File

@@ -2,10 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { LinkOutlined, LoadingOutlined } from '@ant-design/icons';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import useContextVariables from 'hooks/dashboard/useContextVariables';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { ContextLinksData } from 'types/api/dashboard/getAll';
@@ -14,10 +18,9 @@ import { openInNewTab } from 'utils/navigation';
import { ContextMenuItem } from './contextConfig';
import { getDataLinks } from './dataLinksUtils';
import { getAggregateColumnHeader } from './drilldownUtils';
import { getAggregateColumnHeader, getViewQuery } from './drilldownUtils';
import { getBaseContextConfig } from './menuOptions';
import { AggregateData } from './useAggregateDrilldown';
import useBaseDrilldownNavigate from './useBaseDrilldownNavigate';
interface UseBaseAggregateOptionsProps {
query: Query;
@@ -35,6 +38,19 @@ interface BaseAggregateOptionsConfig {
items?: ContextMenuItem;
}
const getRoute = (key: string): string => {
switch (key) {
case 'view_logs':
return ROUTES.LOGS_EXPLORER;
case 'view_metrics':
return ROUTES.METRICS_EXPLORER;
case 'view_traces':
return ROUTES.TRACES_EXPLORER;
default:
return '';
}
};
const useBaseAggregateOptions = ({
query,
onClose,
@@ -47,8 +63,10 @@ const useBaseAggregateOptions = ({
baseAggregateOptionsConfig: BaseAggregateOptionsConfig;
} => {
const [resolvedQuery, setResolvedQuery] = useState<Query>(query);
const { getUpdatedQuery, isLoading: isResolveQueryLoading } =
useUpdatedQuery();
const {
getUpdatedQuery,
isLoading: isResolveQueryLoading,
} = useUpdatedQuery();
const { dashboardData } = useDashboardStore();
useEffect(() => {
@@ -70,6 +88,8 @@ const useBaseAggregateOptions = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, aggregateData, panelType]);
const { safeNavigate } = useSafeNavigate();
// Use the new useContextVariables hook
const { processedVariables } = useContextVariables({
maxValues: 2,
@@ -103,16 +123,50 @@ const useBaseAggregateOptions = ({
{label}
</ContextMenu.Item>
));
} catch {
} catch (error) {
return [];
}
}, [contextLinks, processedVariables, onClose, aggregateData, query]);
const handleBaseDrilldown = useBaseDrilldownNavigate({
resolvedQuery,
aggregateData,
callback: onClose,
});
const handleBaseDrilldown = useCallback(
(key: string): void => {
const route = getRoute(key);
const timeRange = aggregateData?.timeRange;
const filtersToAdd = aggregateData?.filters || [];
const viewQuery = getViewQuery(
resolvedQuery,
filtersToAdd,
key,
aggregateData?.queryName || '',
);
let queryParams = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange?.startTime.toString(),
[QueryParams.endTime]: timeRange?.endTime.toString(),
}),
} as Record<string, string>;
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery?.builder.queryData[0].filters,
),
};
}
if (route) {
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
newTab: true,
});
}
onClose();
},
[resolvedQuery, safeNavigate, onClose, aggregateData],
);
const { pathname } = useLocation();
@@ -176,15 +230,7 @@ const useBaseAggregateOptions = ({
return (
<ContextMenu.Item
key={key}
icon={
isLoading ? (
<LoadingOutlined spin />
) : (
<span style={{ color: aggregateData?.seriesColor }}>
{icon}
</span>
)
}
icon={isLoading ? <LoadingOutlined spin /> : icon}
onClick={(): void => onClick()}
disabled={isLoading}
>

View File

@@ -1,117 +0,0 @@
import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getViewQuery } from './drilldownUtils';
import { AggregateData } from './useAggregateDrilldown';
type DrilldownKey = 'view_logs' | 'view_metrics' | 'view_traces';
const DRILLDOWN_ROUTE_MAP: Record<DrilldownKey, string> = {
view_logs: ROUTES.LOGS_EXPLORER,
view_metrics: ROUTES.METRICS_EXPLORER,
view_traces: ROUTES.TRACES_EXPLORER,
};
const getRoute = (key: string): string =>
DRILLDOWN_ROUTE_MAP[key as DrilldownKey] ?? '';
interface UseBaseDrilldownNavigateProps {
resolvedQuery: Query;
aggregateData: AggregateData | null;
callback?: () => void;
}
const useBaseDrilldownNavigate = ({
resolvedQuery,
aggregateData,
callback,
}: UseBaseDrilldownNavigateProps): ((key: string) => void) => {
const { safeNavigate } = useSafeNavigate();
return useCallback(
(key: string): void => {
const route = getRoute(key);
const viewQuery = getViewQuery(
resolvedQuery,
aggregateData?.filters ?? [],
key,
aggregateData?.queryName ?? '',
);
if (!viewQuery || !route) {
callback?.();
return;
}
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
}),
};
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery.builder.queryData[0].filters,
),
};
}
safeNavigate(`${route}?${createQueryParams(queryParams)}`, {
newTab: true,
});
callback?.();
},
[resolvedQuery, safeNavigate, callback, aggregateData],
);
};
export function buildDrilldownUrl(
resolvedQuery: Query,
aggregateData: AggregateData | null,
key: string,
): string | null {
const route = getRoute(key);
const viewQuery = getViewQuery(
resolvedQuery,
aggregateData?.filters ?? [],
key,
aggregateData?.queryName ?? '',
);
if (!viewQuery || !route) {
return null;
}
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
}),
};
if (route === ROUTES.METRICS_EXPLORER) {
queryParams = {
...queryParams,
[QueryParams.summaryFilters]: JSON.stringify(
viewQuery.builder.queryData[0].filters,
),
};
}
return `${route}?${createQueryParams(queryParams)}`;
}
export { getRoute };
export default useBaseDrilldownNavigate;

View File

@@ -4,7 +4,7 @@
gap: 8px;
padding: 8px;
cursor: pointer;
color: var(--muted-foreground);
color: var(--foreground);
font-family: Inter;
font-size: var(--font-size-sm);
font-weight: 600;
@@ -20,10 +20,13 @@
overflow: hidden;
text-overflow: ellipsis;
&:hover,
&:hover {
background-color: var(--l1-background);
}
&:focus {
outline: none;
background-color: var(--l2-background-hover);
background-color: var(--l1-background);
}
&.disabled {
@@ -44,8 +47,7 @@
}
&:hover {
background-color: var(--danger-background);
color: var(--l1-foreground);
background-color: var(--bg-cherry-100);
}
}
@@ -72,24 +74,73 @@
}
.context-menu-header {
padding: 8px 12px;
border-bottom: 1px solid var(--l2-border);
padding-bottom: 4px;
border-bottom: 1px solid var(--l1-border);
color: var(--muted-foreground);
}
// Target the popover inner specifically for context menu
.context-menu .ant-popover-inner {
padding: 0;
border-radius: 6px;
max-width: 300px;
background: var(--l2-background) !important;
border: 1px solid var(--l2-border) !important;
padding: 12px 8px !important;
// max-height: 254px !important;
max-width: 300px !important;
}
// Dark mode support
.darkMode {
.context-menu-item {
color: var(--muted-foreground);
&:hover,
&:focus {
background-color: var(--l2-background);
}
&.danger {
color: var(--bg-cherry-400);
.icon {
color: var(--bg-cherry-400);
}
&:hover {
background-color: var(--danger-background);
color: var(--l1-foreground);
}
}
.icon {
color: var(--bg-robin-400);
}
}
.context-menu-header {
border-bottom: 1px solid var(--l1-border);
color: var(--muted-foreground);
}
// Set the menu popover background
.context-menu .ant-popover-inner {
background: var(--l1-background) !important;
border: 1px solid var(--border) !important;
}
}
// Context menu backdrop overlay
.context-menu-backdrop {
position: fixed;
inset: 0;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 9999;
background: transparent;
cursor: default;
// Prevent any pointer events from reaching elements behind
pointer-events: auto;
// Ensure it covers the entire viewport including any scrollable areas
position: fixed !important;
inset: 0;
}

View File

@@ -17,6 +17,7 @@ var (
type Config struct {
ExternalURL *url.URL `mapstructure:"external_url"`
IngestionURL *url.URL `mapstructure:"ingestion_url"`
MCPURL *url.URL `mapstructure:"mcp_url"`
}
func NewConfigFactory() factory.ConfigFactory {

View File

@@ -31,8 +31,14 @@ func newProvider(_ context.Context, providerSettings factory.ProviderSettings, c
}
func (provider *provider) GetConfig(context.Context) *globaltypes.Config {
var mcpURL *string
if provider.config.MCPURL != nil {
s := provider.config.MCPURL.String()
mcpURL = &s
}
return globaltypes.NewConfig(
globaltypes.NewEndpoint(provider.config.ExternalURL.String(), provider.config.IngestionURL.String()),
globaltypes.NewEndpoint(provider.config.ExternalURL.String(), provider.config.IngestionURL.String(), mcpURL),
globaltypes.NewIdentNConfig(
globaltypes.TokenizerConfig{Enabled: provider.identNConfig.Tokenizer.Enabled},
globaltypes.APIKeyConfig{Enabled: provider.identNConfig.APIKeyConfig.Enabled},

View File

@@ -25,6 +25,11 @@ func (h *handler) GetWaterfall(rw http.ResponseWriter, r *http.Request) {
return
}
if err := req.Validate(); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.GetWaterfall(r.Context(), mux.Vars(r)["traceID"], req)
if err != nil {
render.Error(rw, err)

View File

@@ -37,7 +37,12 @@ func (m *module) GetWaterfall(ctx context.Context, traceID string, req *tracedet
m.config.Waterfall.MaxDepthToAutoExpand,
)
return tracedetailtypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans), nil
aggregationResults := make([]tracedetailtypes.SpanAggregationResult, 0, len(req.Aggregations))
for _, a := range req.Aggregations {
aggregationResults = append(aggregationResults, waterfallTrace.GetSpanAggregation(a.Aggregation, a.Field))
}
return tracedetailtypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans, aggregationResults), nil
}
// getTraceData returns the waterfall cache for the given traceID with fallback on DB.

View File

@@ -260,7 +260,7 @@ func TestGetSelectedSpans_MultipleRoots(t *testing.T) {
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root1, root2}, spanMap)
spans, _ := trace.GetSelectedSpans([]string{"root1", "root2"}, "root1", 500, 5)
traceRespnose := tracedetailtypes.NewGettableWaterfallTrace(trace, spans, nil, false)
traceRespnose := tracedetailtypes.NewGettableWaterfallTrace(trace, spans, nil, false, nil)
assert.Equal(t, []string{"root1", "child1", "root2", "child2"}, spanIDs(spans), "root1 subtree must precede root2 subtree")
assert.Equal(t, "svc-a", traceRespnose.RootServiceName, "metadata comes from first root")
@@ -567,7 +567,7 @@ func TestGetAllSpans(t *testing.T) {
)
trace := getWaterfallTrace([]*tracedetailtypes.WaterfallSpan{root}, nil)
spans := trace.GetAllSpans()
traceResponse := tracedetailtypes.NewGettableWaterfallTrace(trace, spans, nil, true)
traceResponse := tracedetailtypes.NewGettableWaterfallTrace(trace, spans, nil, true, nil)
assert.ElementsMatch(t, spanIDs(spans), []string{"root", "childA", "grandchildA", "leafA", "childB", "grandchildB", "leafB"})
assert.Equal(t, "svc", traceResponse.RootServiceName)
assert.Equal(t, "root-op", traceResponse.RootServiceEntryPoint)

View File

@@ -1154,7 +1154,13 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
if err != nil {
r.logger.Info("cache miss for getFlamegraphSpansForTrace", "traceID", traceID)
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, fmt.Sprintf("SELECT timestamp, duration_nano, span_id, trace_id, has_error,links as references, resource_string_service$$name, name, events FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", r.TraceDB, r.traceTableName))
selectCols := "timestamp, duration_nano, span_id, trace_id, has_error, links as references, resource_string_service$$name, name, events"
if len(req.RequiredFields) > 0 {
selectCols += ", attributes_string, attributes_number, attributes_bool, resources_string"
}
flamegraphQuery := fmt.Sprintf("SELECT %s FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", selectCols, r.TraceDB, r.traceTableName)
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, flamegraphQuery)
if err != nil {
return nil, err
}
@@ -1193,6 +1199,10 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
Children: make([]*model.FlamegraphSpan, 0),
}
if len(req.RequiredFields) > 0 {
jsonItem.SetRequestedFields(item, req.RequiredFields)
}
// metadata calculation
startTimeUnixNano := uint64(item.TimeUnixNano.UnixNano())
if startTime == 0 || startTimeUnixNano < startTime {

View File

@@ -2,6 +2,8 @@ package model
import (
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
type InstantQueryMetricsParams struct {
@@ -337,10 +339,11 @@ type GetWaterfallSpansForTraceWithMetadataParams struct {
}
type GetFlamegraphSpansForTraceParams struct {
SelectedSpanID string `json:"selectedSpanId"`
Limit uint `json:"limit"`
BoundaryStartTS uint64 `json:"boundaryStartTsMilli"`
BoundaryEndTS uint64 `json:"boundarEndTsMilli"`
SelectedSpanID string `json:"selectedSpanId"`
Limit uint `json:"limit"`
BoundaryStartTS uint64 `json:"boundaryStartTsMilli"`
BoundaryEndTS uint64 `json:"boundarEndTsMilli"`
RequiredFields []telemetrytypes.TelemetryFieldKey `json:"requiredFields"`
}
type SpanFilterParams struct {

View File

@@ -7,6 +7,7 @@ import (
"strconv"
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/pkg/errors"
"github.com/prometheus/prometheus/promql/parser"
"github.com/prometheus/prometheus/util/stats"
@@ -314,6 +315,30 @@ type FlamegraphSpan struct {
Events []Event `json:"event"`
References []OtelSpanRef `json:"references,omitempty"`
Children []*FlamegraphSpan `json:"children"`
Attributes map[string]any `json:"attributes,omitempty"`
Resource map[string]string `json:"resource,omitempty"`
}
// SetRequestedFields extracts the requested attribute/resource fields from item into s.
func (s *FlamegraphSpan) SetRequestedFields(item SpanItemV2, fields []telemetrytypes.TelemetryFieldKey) {
for _, field := range fields {
switch field.FieldContext {
case telemetrytypes.FieldContextResource:
if v, ok := item.Resources_string[field.Name]; ok && v != "" {
if s.Resource == nil {
s.Resource = make(map[string]string)
}
s.Resource[field.Name] = v
}
case telemetrytypes.FieldContextAttribute:
if v := item.AttributeValue(field.Name); v != nil {
if s.Attributes == nil {
s.Attributes = make(map[string]any)
}
s.Attributes[field.Name] = v
}
}
}
}
type GetWaterfallSpansForTraceWithMetadataResponse struct {

View File

@@ -29,3 +29,17 @@ type TraceSummary struct {
End time.Time `ch:"end"`
NumSpans uint64 `ch:"num_spans"`
}
// AttributeValue looks up an attribute across string, number, and bool maps in priority order.
func (s SpanItemV2) AttributeValue(name string) any {
if v, ok := s.Attributes_string[name]; ok && v != "" {
return v
}
if v, ok := s.Attributes_number[name]; ok {
return v
}
if v, ok := s.Attributes_bool[name]; ok {
return v
}
return nil
}

View File

@@ -1,13 +1,15 @@
package globaltypes
type Endpoint struct {
ExternalURL string `json:"external_url"`
IngestionURL string `json:"ingestion_url"`
ExternalURL string `json:"external_url" required:"true"`
IngestionURL string `json:"ingestion_url" required:"true"`
MCPURL *string `json:"mcp_url" required:"true" nullable:"true"`
}
func NewEndpoint(externalURL, ingestionURL string) Endpoint {
func NewEndpoint(externalURL, ingestionURL string, mcpURL *string) Endpoint {
return Endpoint{
ExternalURL: externalURL,
IngestionURL: ingestionURL,
MCPURL: mcpURL,
}
}

View File

@@ -0,0 +1,50 @@
package tracedetailtypes
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const maxAggregationItems = 10
var ErrTooManyAggregationItems = errors.NewInvalidInputf(errors.CodeInvalidInput, "aggregations request exceeds maximum of %d items", maxAggregationItems)
// SpanAggregationType defines the aggregation to compute over spans grouped by a field.
type SpanAggregationType string
const (
SpanAggregationSpanCount SpanAggregationType = "spanCount"
SpanAggregationExecutionTimePercentage SpanAggregationType = "executionTimePercentage"
SpanAggregationDuration SpanAggregationType = "duration"
)
// SpanAggregation is a single aggregation request item: which field to group by and how.
type SpanAggregation struct {
Field telemetrytypes.TelemetryFieldKey `json:"field"`
Aggregation SpanAggregationType `json:"aggregation"`
}
// SpanAggregationResult is the computed result for one aggregation request item.
// Duration values are in milliseconds.
type SpanAggregationResult struct {
Field telemetrytypes.TelemetryFieldKey `json:"field"`
Aggregation SpanAggregationType `json:"aggregation"`
Value map[string]uint64 `json:"value" nullable:"true"`
}
func (s SpanAggregationType) Enum() []any {
return []any{
SpanAggregationSpanCount,
SpanAggregationExecutionTimePercentage,
SpanAggregationDuration,
}
}
func (s SpanAggregationType) isValid() bool {
for _, v := range s.Enum() {
if v == s {
return true
}
}
return false
}

View File

@@ -0,0 +1,240 @@
package tracedetailtypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
)
// mkASpan builds a WaterfallSpan with timing and field data for analytics tests.
func mkASpan(id string, resource map[string]string, attributes map[string]any, startNs, durationNs uint64) *WaterfallSpan {
return &WaterfallSpan{
SpanID: id,
Resource: resource,
Attributes: attributes,
TimeUnixNano: startNs,
DurationNano: durationNs,
Children: make([]*WaterfallSpan, 0),
}
}
func buildTraceFromSpans(spans ...*WaterfallSpan) *WaterfallTrace {
spanMap := make(map[string]*WaterfallSpan, len(spans))
var startTime, endTime uint64
initialized := false
for _, s := range spans {
spanMap[s.SpanID] = s
if !initialized || s.TimeUnixNano < startTime {
startTime = s.TimeUnixNano
initialized = true
}
if end := s.TimeUnixNano + s.DurationNano; end > endTime {
endTime = end
}
}
return NewWaterfallTrace(startTime, endTime, uint64(len(spanMap)), 0, spanMap, nil, nil, false)
}
var (
fieldServiceName = telemetrytypes.TelemetryFieldKey{
Name: "service.name",
FieldContext: telemetrytypes.FieldContextResource,
}
fieldHTTPMethod = telemetrytypes.TelemetryFieldKey{
Name: "http.method",
FieldContext: telemetrytypes.FieldContextAttribute,
}
fieldCached = telemetrytypes.TelemetryFieldKey{
Name: "db.cached",
FieldContext: telemetrytypes.FieldContextAttribute,
}
)
func TestGetSpanAggregation_SpanCount(t *testing.T) {
tests := []struct {
name string
trace *WaterfallTrace
field telemetrytypes.TelemetryFieldKey
want map[string]uint64
}{
{
name: "counts by resource field",
trace: buildTraceFromSpans(
mkASpan("s1", map[string]string{"service.name": "frontend"}, nil, 0, 10),
mkASpan("s2", map[string]string{"service.name": "frontend"}, nil, 10, 5),
mkASpan("s3", map[string]string{"service.name": "backend"}, nil, 20, 8),
),
field: fieldServiceName,
want: map[string]uint64{"frontend": 2, "backend": 1},
},
{
name: "counts by string attribute field",
trace: buildTraceFromSpans(
mkASpan("s1", nil, map[string]any{"http.method": "GET"}, 0, 10),
mkASpan("s2", nil, map[string]any{"http.method": "POST"}, 10, 5),
mkASpan("s3", nil, map[string]any{"http.method": "GET"}, 20, 8),
),
field: fieldHTTPMethod,
want: map[string]uint64{"GET": 2, "POST": 1},
},
{
name: "counts by boolean attribute field",
trace: buildTraceFromSpans(
mkASpan("s1", nil, map[string]any{"db.cached": true}, 0, 10),
mkASpan("s2", nil, map[string]any{"db.cached": false}, 10, 5),
mkASpan("s3", nil, map[string]any{"db.cached": true}, 20, 8),
),
field: fieldCached,
want: map[string]uint64{"true": 2, "false": 1},
},
{
name: "spans missing the field are excluded",
trace: buildTraceFromSpans(
mkASpan("s1", map[string]string{"service.name": "frontend"}, nil, 0, 10),
mkASpan("s2", map[string]string{}, nil, 10, 5), // no service.name
mkASpan("s3", map[string]string{"service.name": "backend"}, nil, 20, 8),
),
field: fieldServiceName,
want: map[string]uint64{"frontend": 1, "backend": 1},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := tc.trace.GetSpanAggregation(SpanAggregationSpanCount, tc.field)
assert.Equal(t, tc.field, result.Field)
assert.Equal(t, SpanAggregationSpanCount, result.Aggregation)
assert.Equal(t, tc.want, result.Value)
})
}
}
func TestGetSpanAggregation_Duration(t *testing.T) {
tests := []struct {
name string
trace *WaterfallTrace
field telemetrytypes.TelemetryFieldKey
want map[string]uint64
}{
{
name: "non-overlapping spans — merged equals sum",
trace: buildTraceFromSpans(
mkASpan("s1", map[string]string{"service.name": "frontend"}, nil, 0, 100),
mkASpan("s2", map[string]string{"service.name": "frontend"}, nil, 100, 50),
mkASpan("s3", map[string]string{"service.name": "backend"}, nil, 0, 80),
),
field: fieldServiceName,
want: map[string]uint64{"frontend": 150, "backend": 80},
},
{
name: "non-overlapping attribute groups — merged equals sum",
trace: buildTraceFromSpans(
mkASpan("s1", nil, map[string]any{"http.method": "GET"}, 0, 30),
mkASpan("s2", nil, map[string]any{"http.method": "GET"}, 50, 20),
mkASpan("s3", nil, map[string]any{"http.method": "POST"}, 0, 70),
),
field: fieldHTTPMethod,
want: map[string]uint64{"GET": 50, "POST": 70},
},
{
name: "overlapping spans — non-overlapping interval merge",
trace: buildTraceFromSpans(
mkASpan("s1", map[string]string{"service.name": "svc"}, nil, 0, 10),
mkASpan("s2", map[string]string{"service.name": "svc"}, nil, 5, 10),
),
field: fieldServiceName,
want: map[string]uint64{"svc": 15}, // [0,10] [5,15] = [0,15]
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := tc.trace.GetSpanAggregation(SpanAggregationDuration, tc.field)
assert.Equal(t, tc.field, result.Field)
assert.Equal(t, SpanAggregationDuration, result.Aggregation)
assert.Equal(t, tc.want, result.Value)
})
}
}
func TestGetSpanAggregation_ExecutionTimePercentage(t *testing.T) {
tests := []struct {
name string
trace *WaterfallTrace
field telemetrytypes.TelemetryFieldKey
want map[string]uint64
}{
{
// trace [0,30]: svc occupies [0,10]+[20,30]=20 → 20*100/30 = 66%
name: "non-overlapping spans",
trace: buildTraceFromSpans(
mkASpan("s1", map[string]string{"service.name": "svc"}, nil, 0, 10),
mkASpan("s2", map[string]string{"service.name": "svc"}, nil, 20, 10),
),
field: fieldServiceName,
want: map[string]uint64{"svc": 66},
},
{
// trace [0,15]: svc [0,15]=15 → 100%
name: "partially overlapping spans",
trace: buildTraceFromSpans(
mkASpan("s1", map[string]string{"service.name": "svc"}, nil, 0, 10),
mkASpan("s2", map[string]string{"service.name": "svc"}, nil, 5, 10),
),
field: fieldServiceName,
want: map[string]uint64{"svc": 100},
},
{
// trace [0,20]: outer absorbs inner → 100%
name: "fully contained span",
trace: buildTraceFromSpans(
mkASpan("outer", map[string]string{"service.name": "svc"}, nil, 0, 20),
mkASpan("inner", map[string]string{"service.name": "svc"}, nil, 5, 5),
),
field: fieldServiceName,
want: map[string]uint64{"svc": 100},
},
{
// trace [0,30]: svc [0,15]+[20,30]=25 → 25*100/30 = 83%
name: "three spans with two merges",
trace: buildTraceFromSpans(
mkASpan("s1", map[string]string{"service.name": "svc"}, nil, 0, 10),
mkASpan("s2", map[string]string{"service.name": "svc"}, nil, 5, 10),
mkASpan("s3", map[string]string{"service.name": "svc"}, nil, 20, 10),
),
field: fieldServiceName,
want: map[string]uint64{"svc": 83},
},
{
// trace [0,28]: frontend [0,15]=15 → 53%, backend [0,5]+[20,28]=13 → 46%
name: "independent groups are computed separately",
trace: buildTraceFromSpans(
mkASpan("a1", map[string]string{"service.name": "frontend"}, nil, 0, 10),
mkASpan("a2", map[string]string{"service.name": "frontend"}, nil, 5, 10),
mkASpan("b1", map[string]string{"service.name": "backend"}, nil, 0, 5),
mkASpan("b2", map[string]string{"service.name": "backend"}, nil, 20, 8),
),
field: fieldServiceName,
want: map[string]uint64{"frontend": 53, "backend": 46},
},
{
// trace [100,150]: svc [100,150]=50 → 100%
name: "single span",
trace: buildTraceFromSpans(
mkASpan("s1", map[string]string{"service.name": "svc"}, nil, 100, 50),
),
field: fieldServiceName,
want: map[string]uint64{"svc": 100},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := tc.trace.GetSpanAggregation(SpanAggregationExecutionTimePercentage, tc.field)
assert.Equal(t, tc.field, result.Field)
assert.Equal(t, SpanAggregationExecutionTimePercentage, result.Aggregation)
assert.Equal(t, tc.want, result.Value)
})
}
}

View File

@@ -2,11 +2,13 @@ package tracedetailtypes
import (
"encoding/json"
"fmt"
"maps"
"sort"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
@@ -21,9 +23,27 @@ var ErrTraceNotFound = errors.NewNotFoundf(errors.CodeNotFound, "trace not found
// PostableWaterfall is the request body for the v3 waterfall API.
type PostableWaterfall struct {
SelectedSpanID string `json:"selectedSpanId"`
UncollapsedSpans []string `json:"uncollapsedSpans"`
Limit uint `json:"limit"`
SelectedSpanID string `json:"selectedSpanId"`
UncollapsedSpans []string `json:"uncollapsedSpans"`
Limit uint `json:"limit"`
Aggregations []SpanAggregation `json:"aggregations"`
}
func (p *PostableWaterfall) Validate() error {
if len(p.Aggregations) > maxAggregationItems {
return ErrTooManyAggregationItems
}
for _, a := range p.Aggregations {
if !a.Aggregation.isValid() {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unknown aggregation type: %q", a.Aggregation)
}
fc := a.Field.FieldContext
if fc != telemetrytypes.FieldContextResource && fc != telemetrytypes.FieldContextAttribute {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "aggregation field context must be %q or %q, got %q",
telemetrytypes.FieldContextResource, telemetrytypes.FieldContextAttribute, fc)
}
}
return nil
}
// Event represents a span event.
@@ -160,7 +180,24 @@ func (ws *WaterfallSpan) GetSubtreeNodeCount() uint64 {
return count
}
// getPreOrderedSpans returns spans in pre-order, uncollapsedSpanIDs must be pre-computed.
// FieldValue returns the string representation of field's value on this span for grouping.
// The bool reports whether the field was present with a non-empty value.
func (ws *WaterfallSpan) FieldValue(field telemetrytypes.TelemetryFieldKey) (string, bool) {
switch field.FieldContext {
case telemetrytypes.FieldContextResource:
v := ws.Resource[field.Name]
return v, v != ""
case telemetrytypes.FieldContextAttribute:
v, ok := ws.Attributes[field.Name]
if !ok {
return "", false
}
str := fmt.Sprintf("%v", v)
return str, str != ""
}
return "", false
}
func (ws *WaterfallSpan) getPreOrderedSpans(uncollapsedSpanIDs map[string]struct{}, selectAll bool, level uint64) []*WaterfallSpan {
result := []*WaterfallSpan{ws.GetWithoutChildren(level)}
_, isUncollapsed := uncollapsedSpanIDs[ws.SpanID]

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/types/cachetypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
type TraceSummary struct {
@@ -31,17 +32,19 @@ type WaterfallTrace struct {
// GettableWaterfallTrace is the response for the v3 waterfall API.
type GettableWaterfallTrace struct {
StartTimestampMillis uint64 `json:"startTimestampMillis"`
EndTimestampMillis uint64 `json:"endTimestampMillis"`
RootServiceName string `json:"rootServiceName"`
RootServiceEntryPoint string `json:"rootServiceEntryPoint"`
TotalSpansCount uint64 `json:"totalSpansCount"`
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount"`
ServiceNameToTotalDurationMap map[string]uint64 `json:"serviceNameToTotalDurationMap"`
Spans []*WaterfallSpan `json:"spans"`
HasMissingSpans bool `json:"hasMissingSpans"`
UncollapsedSpans []string `json:"uncollapsedSpans"`
HasMore bool `json:"hasMore"`
StartTimestampMillis uint64 `json:"startTimestampMillis"`
EndTimestampMillis uint64 `json:"endTimestampMillis"`
RootServiceName string `json:"rootServiceName"`
RootServiceEntryPoint string `json:"rootServiceEntryPoint"`
TotalSpansCount uint64 `json:"totalSpansCount"`
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount"`
// Deprecated: use Aggregations with SpanAggregationExecutionTimePercentage on the service.name field instead.
ServiceNameToTotalDurationMap map[string]uint64 `json:"serviceNameToTotalDurationMap"`
Spans []*WaterfallSpan `json:"spans"`
HasMissingSpans bool `json:"hasMissingSpans"`
UncollapsedSpans []string `json:"uncollapsedSpans"`
HasMore bool `json:"hasMore"`
Aggregations []SpanAggregationResult `json:"aggregations"`
}
// NewWaterfallTrace constructs a WaterfallTrace from processed span data.
@@ -240,12 +243,13 @@ func (wt *WaterfallTrace) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, wt)
}
// NewGettableWaterfallTrace constructs a WaterfallResponse from processed trace data and selected spans.
// NewGettableWaterfallTrace constructs a GettableWaterfallTrace from processed trace data and selected spans.
func NewGettableWaterfallTrace(
traceData *WaterfallTrace,
selectedSpans []*WaterfallSpan,
uncollapsedSpans []string,
selectAllSpans bool,
aggregations []SpanAggregationResult,
) *GettableWaterfallTrace {
var rootServiceName, rootServiceEntryPoint string
if len(traceData.TraceRoots) > 0 {
@@ -263,6 +267,15 @@ func NewGettableWaterfallTrace(
span.TimeUnixNano = span.TimeUnixNano / 1_000_000
}
// duration values are in nanoseconds; convert in-place to milliseconds.
for i := range aggregations {
if aggregations[i].Aggregation == SpanAggregationDuration {
for k, v := range aggregations[i].Value {
aggregations[i].Value[k] = v / 1_000_000
}
}
}
return &GettableWaterfallTrace{
Spans: selectedSpans,
UncollapsedSpans: uncollapsedSpans,
@@ -275,6 +288,7 @@ func NewGettableWaterfallTrace(
ServiceNameToTotalDurationMap: serviceDurationsMillis,
HasMissingSpans: traceData.HasMissingSpans,
HasMore: !selectAllSpans,
Aggregations: aggregations,
}
}
@@ -307,29 +321,82 @@ func calculateServiceTime(spanIDToSpanNodeMap map[string]*WaterfallSpan) map[str
totalTimes := make(map[string]uint64)
for service, spans := range serviceSpans {
sort.Slice(spans, func(i, j int) bool {
return spans[i].TimeUnixNano < spans[j].TimeUnixNano
})
currentStart := spans[0].TimeUnixNano
currentEnd := currentStart + spans[0].DurationNano
total := uint64(0)
for _, span := range spans[1:] {
startNano := span.TimeUnixNano
endNano := startNano + span.DurationNano
if currentEnd >= startNano {
if endNano > currentEnd {
currentEnd = endNano
}
} else {
total += currentEnd - currentStart
currentStart = startNano
currentEnd = endNano
}
}
total += currentEnd - currentStart
totalTimes[service] = total
totalTimes[service] = mergeSpanIntervals(spans)
}
return totalTimes
}
// mergeSpanIntervals computes non-overlapping execution time for a set of spans.
func mergeSpanIntervals(spans []*WaterfallSpan) uint64 {
if len(spans) == 0 {
return 0
}
sort.Slice(spans, func(i, j int) bool {
return spans[i].TimeUnixNano < spans[j].TimeUnixNano
})
currentStart := spans[0].TimeUnixNano
currentEnd := currentStart + spans[0].DurationNano
total := uint64(0)
for _, span := range spans[1:] {
startNano := span.TimeUnixNano
endNano := startNano + span.DurationNano
if currentEnd >= startNano {
if endNano > currentEnd {
currentEnd = endNano
}
} else {
total += currentEnd - currentStart
currentStart = startNano
currentEnd = endNano
}
}
return total + (currentEnd - currentStart)
}
// GetSpanAggregation computes one aggregation result over all spans in the trace.
// Duration values are returned in nanoseconds; callers convert to milliseconds as needed.
func (wt *WaterfallTrace) GetSpanAggregation(aggregation SpanAggregationType, field telemetrytypes.TelemetryFieldKey) SpanAggregationResult {
result := SpanAggregationResult{
Field: field,
Aggregation: aggregation,
Value: make(map[string]uint64),
}
switch aggregation {
case SpanAggregationSpanCount:
for _, span := range wt.SpanIDToSpanNodeMap {
if key, ok := span.FieldValue(field); ok {
result.Value[key]++
}
}
case SpanAggregationDuration:
spansByField := make(map[string][]*WaterfallSpan)
for _, span := range wt.SpanIDToSpanNodeMap {
if key, ok := span.FieldValue(field); ok {
spansByField[key] = append(spansByField[key], span)
}
}
for key, spans := range spansByField {
result.Value[key] = mergeSpanIntervals(spans)
}
case SpanAggregationExecutionTimePercentage:
traceDuration := wt.EndTime - wt.StartTime
spansByField := make(map[string][]*WaterfallSpan)
for _, span := range wt.SpanIDToSpanNodeMap {
if key, ok := span.FieldValue(field); ok {
spansByField[key] = append(spansByField[key], span)
}
}
if traceDuration > 0 {
for key, spans := range spansByField {
result.Value[key] = mergeSpanIntervals(spans) * 100 / traceDuration
}
}
}
return result
}