mirror of
https://github.com/SigNoz/signoz.git
synced 2026-04-25 13:20:24 +01:00
Compare commits
17 Commits
ns/color-b
...
feat/toolt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a17b617424 | ||
|
|
810f4005bc | ||
|
|
5edf86acae | ||
|
|
a2479382c6 | ||
|
|
681809d8c1 | ||
|
|
a8822ae2d4 | ||
|
|
c2e9ca7c68 | ||
|
|
032a2dc458 | ||
|
|
16cc7b8ab9 | ||
|
|
95e57d90a5 | ||
|
|
b9bca0f9af | ||
|
|
ee87a70a4c | ||
|
|
d5f4f50e26 | ||
|
|
f8240f4d20 | ||
|
|
241d70ca69 | ||
|
|
8e1916daa6 | ||
|
|
7eb8806c0f |
@@ -11,8 +11,6 @@ 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:
|
||||
|
||||
@@ -2369,13 +2369,6 @@ 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:
|
||||
@@ -4603,11 +4596,6 @@ components:
|
||||
type: object
|
||||
TracedetailtypesGettableWaterfallTrace:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/TracedetailtypesSpanAggregationResult'
|
||||
nullable: true
|
||||
type: array
|
||||
endTimestampMillis:
|
||||
minimum: 0
|
||||
type: integer
|
||||
@@ -4647,11 +4635,6 @@ components:
|
||||
type: object
|
||||
TracedetailtypesPostableWaterfall:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/TracedetailtypesSpanAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
limit:
|
||||
minimum: 0
|
||||
type: integer
|
||||
@@ -4663,32 +4646,6 @@ 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:
|
||||
|
||||
@@ -3125,17 +3125,12 @@ export interface GlobaltypesConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
external_url: string;
|
||||
external_url?: string;
|
||||
identN?: GlobaltypesIdentNConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
ingestion_url: string;
|
||||
/**
|
||||
* @type string
|
||||
* @nullable true
|
||||
*/
|
||||
mcp_url: string | null;
|
||||
ingestion_url?: string;
|
||||
}
|
||||
|
||||
export interface GlobaltypesIdentNConfigDTO {
|
||||
@@ -5593,11 +5588,6 @@ export type TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationM
|
||||
{ [key: string]: number } | null;
|
||||
|
||||
export interface TracedetailtypesGettableWaterfallTraceDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
aggregations?: TracedetailtypesSpanAggregationResultDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -5652,11 +5642,6 @@ export interface TracedetailtypesGettableWaterfallTraceDTO {
|
||||
}
|
||||
|
||||
export interface TracedetailtypesPostableWaterfallDTO {
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
aggregations?: TracedetailtypesSpanAggregationDTO[] | null;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
@@ -5673,33 +5658,6 @@ 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
|
||||
*/
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function ChartWrapper({
|
||||
children,
|
||||
layoutChildren,
|
||||
yAxisUnit,
|
||||
groupBy,
|
||||
customTooltip,
|
||||
pinnedTooltipElement,
|
||||
'data-testid': testId,
|
||||
@@ -68,8 +69,9 @@ export default function ChartWrapper({
|
||||
const syncMetadata = useMemo(
|
||||
() => ({
|
||||
yAxisUnit,
|
||||
groupBy,
|
||||
}),
|
||||
[yAxisUnit],
|
||||
[yAxisUnit, groupBy],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DashboardCursorSync,
|
||||
TooltipClickData,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
@@ -38,6 +39,7 @@ interface UPlotBasedChartProps {
|
||||
interface UPlotChartDataProps {
|
||||
yAxisUnit?: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
|
||||
@@ -113,6 +113,10 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
uPlotRef.current = plot;
|
||||
}, []);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return widget.query.builder.queryData[0].groupBy;
|
||||
}, [widget.query]);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
@@ -128,6 +132,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
groupBy={groupBy}
|
||||
isStackedBarChart={widget.stackedBarChart ?? false}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
|
||||
@@ -105,6 +105,7 @@ export function prepareBarPanelConfig({
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
isDarkMode,
|
||||
stepInterval: currentStepInterval,
|
||||
metric: series.metric,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -104,6 +104,10 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
widget.decimalPrecision,
|
||||
]);
|
||||
|
||||
const groupBy = useMemo(() => {
|
||||
return widget.query.builder.queryData[0].groupBy;
|
||||
}, [widget.query]);
|
||||
|
||||
return (
|
||||
<div className="panel-container" ref={graphRef}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
@@ -117,6 +121,7 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
data={chartData as uPlot.AlignedData}
|
||||
groupBy={groupBy}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
|
||||
@@ -131,6 +131,7 @@ export const prepareUPlotConfig = ({
|
||||
pointSize: 5,
|
||||
fillMode: widget.fillMode || FillMode.None,
|
||||
isDarkMode,
|
||||
metric: series.metric,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
yAxisUnit: props.yAxisUnit ?? '',
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
isStackedBarChart: props.isStackedBarChart,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -24,6 +25,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
props.yAxisUnit,
|
||||
props.decimalPrecision,
|
||||
props.isStackedBarChart,
|
||||
props.syncedSeriesIndexes,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function HistogramTooltip(
|
||||
uPlotInstance: props.uPlotInstance,
|
||||
yAxisUnit: props.yAxisUnit ?? '',
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -24,6 +25,7 @@ export default function HistogramTooltip(
|
||||
props.dataIndexes,
|
||||
props.yAxisUnit,
|
||||
props.decimalPrecision,
|
||||
props.syncedSeriesIndexes,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function TimeSeriesTooltip(
|
||||
uPlotInstance: props.uPlotInstance,
|
||||
yAxisUnit: props.yAxisUnit ?? '',
|
||||
decimalPrecision: props.decimalPrecision,
|
||||
syncedSeriesIndexes: props.syncedSeriesIndexes,
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
@@ -24,6 +25,7 @@ export default function TimeSeriesTooltip(
|
||||
props.dataIndexes,
|
||||
props.yAxisUnit,
|
||||
props.decimalPrecision,
|
||||
props.syncedSeriesIndexes,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ export function buildTooltipContent({
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
isStackedBarChart,
|
||||
syncedSeriesIndexes,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
series: Series[];
|
||||
@@ -71,18 +72,34 @@ export function buildTooltipContent({
|
||||
yAxisUnit: string;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
isStackedBarChart?: boolean;
|
||||
syncedSeriesIndexes?: number[] | null;
|
||||
}): TooltipContentItem[] {
|
||||
const items: TooltipContentItem[] = [];
|
||||
const allowedIndexes =
|
||||
syncedSeriesIndexes != null ? new Set(syncedSeriesIndexes) : null;
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < series.length; seriesIndex += 1) {
|
||||
const seriesItem = series[seriesIndex];
|
||||
if (!seriesItem?.show) {
|
||||
continue;
|
||||
}
|
||||
if (allowedIndexes != null && !allowedIndexes.has(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dataIndex = dataIndexes[seriesIndex];
|
||||
// Skip series with no data at the current cursor position
|
||||
const isSync = allowedIndexes != null;
|
||||
|
||||
if (dataIndex === null) {
|
||||
if (isSync) {
|
||||
items.push({
|
||||
label: String(seriesItem.label ?? ''),
|
||||
value: 0,
|
||||
tooltipValue: 'No Data',
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: false,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -102,6 +119,14 @@ export function buildTooltipContent({
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: seriesIndex === activeSeriesIndex,
|
||||
});
|
||||
} else if (isSync) {
|
||||
items.push({
|
||||
label: String(seriesItem.label ?? ''),
|
||||
value: 0,
|
||||
tooltipValue: 'No Data',
|
||||
color: resolveSeriesColor(seriesItem.stroke, uPlotInstance, seriesIndex),
|
||||
isActive: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ export interface TooltipRenderArgs {
|
||||
isPinned: boolean;
|
||||
dismiss: () => void;
|
||||
viaSync: boolean;
|
||||
/** In Tooltip sync mode, limits which series are rendered in the receiver tooltip.
|
||||
* null = no filtering; [] = no matches (tooltip hidden upstream); [...] = allowed indexes */
|
||||
syncedSeriesIndexes?: number[] | null;
|
||||
}
|
||||
|
||||
export interface BaseTooltipProps {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
BarAlignment,
|
||||
ConfigBuilder,
|
||||
DrawStyle,
|
||||
ExtendedSeries,
|
||||
FillMode,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
@@ -27,7 +28,10 @@ let builders: PathBuilders | null = null;
|
||||
|
||||
const DEFAULT_LINE_WIDTH = 2;
|
||||
export const POINT_SIZE_FACTOR = 2.5;
|
||||
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
SeriesProps,
|
||||
ExtendedSeries
|
||||
> {
|
||||
constructor(props: SeriesProps) {
|
||||
super(props);
|
||||
const pathBuilders = uPlot.paths;
|
||||
@@ -205,8 +209,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
);
|
||||
}
|
||||
|
||||
getConfig(): Series {
|
||||
const { scaleKey, label, spanGaps, show = true } = this.props;
|
||||
getConfig(): ExtendedSeries {
|
||||
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
|
||||
|
||||
const resolvedLineColor = this.getLineColor();
|
||||
|
||||
@@ -233,6 +237,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
...lineConfig,
|
||||
...pathConfig,
|
||||
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
|
||||
metric,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,10 @@ export enum FillMode {
|
||||
None = 'none',
|
||||
}
|
||||
|
||||
export type ExtendedSeries = Series & {
|
||||
metric?: { [key: string]: string };
|
||||
};
|
||||
|
||||
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
scaleKey: string;
|
||||
label?: string;
|
||||
@@ -194,6 +198,7 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
fillMode?: FillMode;
|
||||
isDarkMode?: boolean;
|
||||
stepInterval?: number;
|
||||
metric?: { [key: string]: string };
|
||||
}
|
||||
|
||||
export interface LegendItem {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import cx from 'classnames';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { syncCursorRegistry } from './syncCursorRegistry';
|
||||
import { createSyncDisplayHook } from './syncDisplayHook';
|
||||
import {
|
||||
createInitialControllerState,
|
||||
createSetCursorHandler,
|
||||
@@ -104,32 +104,16 @@ export default function TooltipPlugin({
|
||||
|
||||
// Enable uPlot's built-in cursor sync when requested so that
|
||||
// crosshair / tooltip can follow the dashboard-wide cursor.
|
||||
let removeSyncDisplayHook: (() => void) | null = null;
|
||||
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
|
||||
config.setCursor({
|
||||
sync: { key: syncKey, scales: ['x', 'y'] },
|
||||
});
|
||||
|
||||
// Show the horizontal crosshair only when the receiving panel shares
|
||||
// the same y-axis unit as the source panel. When this panel is the
|
||||
// source (cursor.event != null) the line is always shown and this
|
||||
// panel's metadata is written to the registry so receivers can read it.
|
||||
config.addHook('setCursor', (u: uPlot): void => {
|
||||
const yCursorEl = u.root.querySelector<HTMLElement>('.u-cursor-y');
|
||||
if (!yCursorEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (u.cursor.event != null) {
|
||||
// This panel is the source — publish metadata and always show line.
|
||||
syncCursorRegistry.setMetadata(syncKey, syncMetadata);
|
||||
yCursorEl.style.display = '';
|
||||
} else {
|
||||
// This panel is receiving sync — show only if units match.
|
||||
const sourceMeta = syncCursorRegistry.getMetadata(syncKey);
|
||||
yCursorEl.style.display =
|
||||
sourceMeta?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
|
||||
}
|
||||
});
|
||||
removeSyncDisplayHook = config.addHook(
|
||||
'setCursor',
|
||||
createSyncDisplayHook(syncKey, syncMetadata, controller),
|
||||
);
|
||||
}
|
||||
|
||||
// Dismiss the tooltip when the user clicks / presses a key
|
||||
@@ -137,7 +121,12 @@ export default function TooltipPlugin({
|
||||
const onOutsideInteraction = (event: Event): void => {
|
||||
const target = event.target as Node;
|
||||
if (!containerRef.current?.contains(target)) {
|
||||
dismissTooltip();
|
||||
// Don't dismiss if the click landed inside any other pinned tooltip.
|
||||
const isInsideAnyPinnedTooltip =
|
||||
(target as Element).closest?.('[data-pinned="true"]') != null;
|
||||
if (!isInsideAnyPinnedTooltip) {
|
||||
dismissTooltip();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,7 +145,7 @@ export default function TooltipPlugin({
|
||||
function updateCursorLock(): void {
|
||||
const plot = getPlot(controller);
|
||||
if (plot) {
|
||||
// @ts-ignore uPlot cursor lock is not working as expected
|
||||
// @ts-expect-error uPlot cursor lock is not working as expected
|
||||
plot.cursor._lock = controller.pinned;
|
||||
}
|
||||
}
|
||||
@@ -203,6 +192,16 @@ export default function TooltipPlugin({
|
||||
if (!controller.hoverActive || !plot) {
|
||||
return null;
|
||||
}
|
||||
// In Tooltip sync mode, suppress the receiver tooltip entirely when
|
||||
// no receiver series match the source panel's focused series.
|
||||
if (
|
||||
syncTooltipWithDashboard &&
|
||||
controller.cursorDrivenBySync &&
|
||||
Array.isArray(controller.syncedSeriesIndexes) &&
|
||||
controller.syncedSeriesIndexes.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return renderRef.current({
|
||||
uPlotInstance: plot,
|
||||
dataIndexes: controller.seriesIndexes,
|
||||
@@ -210,6 +209,7 @@ export default function TooltipPlugin({
|
||||
isPinned: controller.pinned,
|
||||
dismiss: dismissTooltip,
|
||||
viaSync: controller.cursorDrivenBySync,
|
||||
syncedSeriesIndexes: controller.syncedSeriesIndexes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -431,6 +431,7 @@ export default function TooltipPlugin({
|
||||
removeSetSeriesHook();
|
||||
removeSetLegendHook();
|
||||
removeSetCursorHook();
|
||||
removeSyncDisplayHook?.();
|
||||
if (overClickHandler) {
|
||||
const plot = getPlot(controller);
|
||||
plot?.over.removeEventListener('click', overClickHandler);
|
||||
@@ -493,7 +494,7 @@ export default function TooltipPlugin({
|
||||
isHovering,
|
||||
contents,
|
||||
]);
|
||||
const isTooltipVisible = isHovering || tooltipBody != null;
|
||||
const isTooltipVisible = tooltipBody != null;
|
||||
|
||||
if (!hasPlot) {
|
||||
return null;
|
||||
|
||||
@@ -9,9 +9,13 @@ import type { TooltipSyncMetadata } from './types';
|
||||
*
|
||||
* Receivers use this to make decisions such as:
|
||||
* - Whether to show the horizontal crosshair line (matching yAxisUnit)
|
||||
* - Future: what to render inside the tooltip (matching groupBy, etc.)
|
||||
* - Which series to highlight when panels share the same groupBy
|
||||
*/
|
||||
const metadataBySyncKey = new Map<string, TooltipSyncMetadata | undefined>();
|
||||
const activeSeriesMetricBySyncKey = new Map<
|
||||
string,
|
||||
Record<string, string> | null
|
||||
>();
|
||||
|
||||
export const syncCursorRegistry = {
|
||||
setMetadata(syncKey: string, metadata: TooltipSyncMetadata | undefined): void {
|
||||
@@ -21,4 +25,15 @@ export const syncCursorRegistry = {
|
||||
getMetadata(syncKey: string): TooltipSyncMetadata | undefined {
|
||||
return metadataBySyncKey.get(syncKey);
|
||||
},
|
||||
|
||||
setActiveSeriesMetric(
|
||||
syncKey: string,
|
||||
metric: Record<string, string> | null,
|
||||
): void {
|
||||
activeSeriesMetricBySyncKey.set(syncKey, metric);
|
||||
},
|
||||
|
||||
getActiveSeriesMetric(syncKey: string): Record<string, string> | null {
|
||||
return activeSeriesMetricBySyncKey.get(syncKey) ?? null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { ExtendedSeries } from '../../config/types';
|
||||
import { syncCursorRegistry } from './syncCursorRegistry';
|
||||
import type { TooltipControllerState, TooltipSyncMetadata } from './types';
|
||||
|
||||
/**
|
||||
* Returns the dimension keys present in both groupBy arrays.
|
||||
* An empty result means no overlap — series highlighting should not run.
|
||||
*
|
||||
* exact [A, B] vs [A, B] → [A, B] one match
|
||||
* subset [A] vs [A, B] → [A] multiple receiver series may match
|
||||
* superset [A, B] vs [A] → [A] one receiver series matches
|
||||
* partial [A, B] vs [B, C] → [B]
|
||||
*/
|
||||
function getCommonGroupByKeys(
|
||||
a: TooltipSyncMetadata['groupBy'],
|
||||
b: TooltipSyncMetadata['groupBy'],
|
||||
): string[] {
|
||||
if (!Array.isArray(a) || a.length === 0 || !Array.isArray(b) || b.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const bKeys = new Set(b.map((g) => g.key));
|
||||
return a.filter((g) => bKeys.has(g.key)).map((g) => g.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the 1-based indexes of every series whose metric matches
|
||||
* sourceMetric on all commonKeys.
|
||||
*/
|
||||
function findMatchingSeriesIndexes(
|
||||
series: uPlot.Series[],
|
||||
sourceMetric: Record<string, string>,
|
||||
commonKeys: string[],
|
||||
): number[] {
|
||||
return series.reduce<number[]>((acc, s, i) => {
|
||||
if (i === 0) {return acc;}
|
||||
const metric = (s as ExtendedSeries).metric;
|
||||
if (
|
||||
metric != null &&
|
||||
commonKeys.every((key) => metric[key] === sourceMetric[key])
|
||||
) {
|
||||
acc.push(i);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function applySourceSync({
|
||||
uPlotInstance,
|
||||
syncKey,
|
||||
syncMetadata,
|
||||
focusedSeriesIndex,
|
||||
}: {
|
||||
uPlotInstance: uPlot;
|
||||
syncKey: string;
|
||||
syncMetadata: TooltipSyncMetadata | undefined;
|
||||
focusedSeriesIndex: number | null;
|
||||
}): void {
|
||||
syncCursorRegistry.setMetadata(syncKey, syncMetadata);
|
||||
const focusedSeries =
|
||||
focusedSeriesIndex != null
|
||||
? (uPlotInstance.series[focusedSeriesIndex] as ExtendedSeries)
|
||||
: null;
|
||||
syncCursorRegistry.setActiveSeriesMetric(syncKey, focusedSeries?.metric ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns:
|
||||
* null – no groupBy filtering configured or cursor off-chart (no-op for tooltip)
|
||||
* [] – groupBy configured but no receiver series match the source (hide synced tooltip)
|
||||
* number[] – 1-based indexes of matching receiver series (show only these)
|
||||
*/
|
||||
function applyReceiverSync({
|
||||
uPlotInstance,
|
||||
yCrosshairEl,
|
||||
syncKey,
|
||||
syncMetadata,
|
||||
sourceMetadata,
|
||||
commonKeys,
|
||||
}: {
|
||||
uPlotInstance: uPlot;
|
||||
yCrosshairEl: HTMLElement;
|
||||
syncKey: string;
|
||||
syncMetadata: TooltipSyncMetadata | undefined;
|
||||
sourceMetadata: TooltipSyncMetadata | undefined;
|
||||
commonKeys: string[];
|
||||
}): number[] | null {
|
||||
yCrosshairEl.style.display =
|
||||
sourceMetadata?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
|
||||
|
||||
if (commonKeys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((uPlotInstance.cursor.left ?? -1) < 0) {
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceSeriesMetric = syncCursorRegistry.getActiveSeriesMetric(syncKey);
|
||||
if (sourceSeriesMetric == null) {
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
}
|
||||
|
||||
const matchingIdxs = findMatchingSeriesIndexes(
|
||||
uPlotInstance.series,
|
||||
sourceSeriesMetric,
|
||||
commonKeys,
|
||||
);
|
||||
|
||||
if (matchingIdxs.length === 0) {
|
||||
uPlotInstance.setSeries(null, { focus: false });
|
||||
return [];
|
||||
}
|
||||
|
||||
uPlotInstance.setSeries(matchingIdxs[0], { focus: true });
|
||||
|
||||
return matchingIdxs;
|
||||
}
|
||||
|
||||
export function createSyncDisplayHook(
|
||||
syncKey: string,
|
||||
syncMetadata: TooltipSyncMetadata | undefined,
|
||||
controller: TooltipControllerState,
|
||||
): (u: uPlot) => void {
|
||||
// Cached once — avoids a DOM query on every cursor move.
|
||||
let yCrosshairEl: HTMLElement | null = null;
|
||||
|
||||
// groupBy on both panels is stable (set at config time). Recompute the
|
||||
// intersection only when the source panel's groupBy reference changes.
|
||||
let lastSourceGroupBy: TooltipSyncMetadata['groupBy'];
|
||||
let cachedCommonKeys: string[] = [];
|
||||
|
||||
return (u: uPlot): void => {
|
||||
yCrosshairEl ??= u.root.querySelector<HTMLElement>('.u-cursor-y');
|
||||
if (!yCrosshairEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (u.cursor.event != null) {
|
||||
controller.syncedSeriesIndexes = null;
|
||||
applySourceSync({
|
||||
uPlotInstance: u,
|
||||
syncKey,
|
||||
syncMetadata,
|
||||
focusedSeriesIndex: controller.focusedSeriesIndex,
|
||||
});
|
||||
yCrosshairEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Read metadata once and pass it down — avoids a second registry lookup
|
||||
// inside applyReceiverSync.
|
||||
const sourceMetadata = syncCursorRegistry.getMetadata(syncKey);
|
||||
|
||||
if (sourceMetadata?.groupBy !== lastSourceGroupBy) {
|
||||
lastSourceGroupBy = sourceMetadata?.groupBy;
|
||||
cachedCommonKeys = getCommonGroupByKeys(
|
||||
sourceMetadata?.groupBy,
|
||||
syncMetadata?.groupBy,
|
||||
);
|
||||
}
|
||||
|
||||
controller.syncedSeriesIndexes = applyReceiverSync({
|
||||
uPlotInstance: u,
|
||||
yCrosshairEl,
|
||||
syncKey,
|
||||
syncMetadata,
|
||||
sourceMetadata,
|
||||
commonKeys: cachedCommonKeys,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export function createInitialControllerState(): TooltipControllerState {
|
||||
verticalOffset: 0,
|
||||
seriesIndexes: [],
|
||||
focusedSeriesIndex: null,
|
||||
syncedSeriesIndexes: null,
|
||||
cursorDrivenBySync: false,
|
||||
plotWithinViewport: false,
|
||||
windowWidth: window.innerWidth - WINDOW_OFFSET,
|
||||
@@ -184,7 +185,7 @@ export function createSetLegendHandler(
|
||||
return;
|
||||
}
|
||||
|
||||
const newSeriesIndexes = plot.cursor.idxs.slice();
|
||||
const newSeriesIndexes = [...plot.cursor.idxs];
|
||||
const isAnySeriesActive = newSeriesIndexes.some((v, i) => i > 0 && v != null);
|
||||
|
||||
const previousCursorDrivenBySync = controller.cursorDrivenBySync;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
ReactNode,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { TooltipRenderArgs } from '../../components/types';
|
||||
@@ -39,6 +40,7 @@ export interface TooltipLayoutInfo {
|
||||
|
||||
export interface TooltipSyncMetadata {
|
||||
yAxisUnit?: string;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
}
|
||||
|
||||
export interface TooltipPluginProps {
|
||||
@@ -95,6 +97,11 @@ export interface TooltipControllerState {
|
||||
verticalOffset: number;
|
||||
seriesIndexes: Array<number | null>;
|
||||
focusedSeriesIndex: number | null;
|
||||
/** Receiver-side series filtering for Tooltip sync mode.
|
||||
* null = no filtering (source panel or no groupBy configured)
|
||||
* [] = no matching series found → hide the synced tooltip
|
||||
* [...] = only these 1-based series indexes should appear in the synced tooltip */
|
||||
syncedSeriesIndexes: number[] | null;
|
||||
cursorDrivenBySync: boolean;
|
||||
plotWithinViewport: boolean;
|
||||
windowWidth: number;
|
||||
|
||||
@@ -17,7 +17,6 @@ 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 {
|
||||
|
||||
@@ -31,14 +31,8 @@ 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(), mcpURL),
|
||||
globaltypes.NewEndpoint(provider.config.ExternalURL.String(), provider.config.IngestionURL.String()),
|
||||
globaltypes.NewIdentNConfig(
|
||||
globaltypes.TokenizerConfig{Enabled: provider.identNConfig.Tokenizer.Enabled},
|
||||
globaltypes.APIKeyConfig{Enabled: provider.identNConfig.APIKeyConfig.Enabled},
|
||||
|
||||
@@ -25,11 +25,6 @@ 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)
|
||||
|
||||
@@ -37,12 +37,7 @@ func (m *module) GetWaterfall(ctx context.Context, traceID string, req *tracedet
|
||||
m.config.Waterfall.MaxDepthToAutoExpand,
|
||||
)
|
||||
|
||||
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
|
||||
return tracedetailtypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans), nil
|
||||
}
|
||||
|
||||
// getTraceData returns the waterfall cache for the given traceID with fallback on DB.
|
||||
|
||||
@@ -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, nil)
|
||||
traceRespnose := tracedetailtypes.NewGettableWaterfallTrace(trace, spans, nil, false)
|
||||
|
||||
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, nil)
|
||||
traceResponse := tracedetailtypes.NewGettableWaterfallTrace(trace, spans, nil, true)
|
||||
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)
|
||||
|
||||
@@ -1154,13 +1154,7 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
|
||||
if err != nil {
|
||||
r.logger.Info("cache miss for getFlamegraphSpansForTrace", "traceID", traceID)
|
||||
|
||||
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)
|
||||
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))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1199,10 +1193,6 @@ 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 {
|
||||
|
||||
@@ -2,8 +2,6 @@ package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type InstantQueryMetricsParams struct {
|
||||
@@ -339,11 +337,10 @@ type GetWaterfallSpansForTraceWithMetadataParams struct {
|
||||
}
|
||||
|
||||
type GetFlamegraphSpansForTraceParams struct {
|
||||
SelectedSpanID string `json:"selectedSpanId"`
|
||||
Limit uint `json:"limit"`
|
||||
BoundaryStartTS uint64 `json:"boundaryStartTsMilli"`
|
||||
BoundaryEndTS uint64 `json:"boundarEndTsMilli"`
|
||||
RequiredFields []telemetrytypes.TelemetryFieldKey `json:"requiredFields"`
|
||||
SelectedSpanID string `json:"selectedSpanId"`
|
||||
Limit uint `json:"limit"`
|
||||
BoundaryStartTS uint64 `json:"boundaryStartTsMilli"`
|
||||
BoundaryEndTS uint64 `json:"boundarEndTsMilli"`
|
||||
}
|
||||
|
||||
type SpanFilterParams struct {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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"
|
||||
@@ -315,30 +314,6 @@ 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 {
|
||||
|
||||
@@ -29,17 +29,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
package globaltypes
|
||||
|
||||
type Endpoint struct {
|
||||
ExternalURL string `json:"external_url" required:"true"`
|
||||
IngestionURL string `json:"ingestion_url" required:"true"`
|
||||
MCPURL *string `json:"mcp_url" required:"true" nullable:"true"`
|
||||
ExternalURL string `json:"external_url"`
|
||||
IngestionURL string `json:"ingestion_url"`
|
||||
}
|
||||
|
||||
func NewEndpoint(externalURL, ingestionURL string, mcpURL *string) Endpoint {
|
||||
func NewEndpoint(externalURL, ingestionURL string) Endpoint {
|
||||
return Endpoint{
|
||||
ExternalURL: externalURL,
|
||||
IngestionURL: ingestionURL,
|
||||
MCPURL: mcpURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,11 @@ package tracedetailtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -23,27 +21,9 @@ 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"`
|
||||
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
|
||||
SelectedSpanID string `json:"selectedSpanId"`
|
||||
UncollapsedSpans []string `json:"uncollapsedSpans"`
|
||||
Limit uint `json:"limit"`
|
||||
}
|
||||
|
||||
// Event represents a span event.
|
||||
@@ -180,24 +160,7 @@ func (ws *WaterfallSpan) GetSubtreeNodeCount() uint64 {
|
||||
return count
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// getPreOrderedSpans returns spans in pre-order, uncollapsedSpanIDs must be pre-computed.
|
||||
func (ws *WaterfallSpan) getPreOrderedSpans(uncollapsedSpanIDs map[string]struct{}, selectAll bool, level uint64) []*WaterfallSpan {
|
||||
result := []*WaterfallSpan{ws.GetWithoutChildren(level)}
|
||||
_, isUncollapsed := uncollapsedSpanIDs[ws.SpanID]
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/cachetypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type TraceSummary struct {
|
||||
@@ -32,19 +31,17 @@ 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"`
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// NewWaterfallTrace constructs a WaterfallTrace from processed span data.
|
||||
@@ -243,13 +240,12 @@ func (wt *WaterfallTrace) UnmarshalBinary(data []byte) error {
|
||||
return json.Unmarshal(data, wt)
|
||||
}
|
||||
|
||||
// NewGettableWaterfallTrace constructs a GettableWaterfallTrace from processed trace data and selected spans.
|
||||
// NewGettableWaterfallTrace constructs a WaterfallResponse 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 {
|
||||
@@ -267,15 +263,6 @@ 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,
|
||||
@@ -288,7 +275,6 @@ func NewGettableWaterfallTrace(
|
||||
ServiceNameToTotalDurationMap: serviceDurationsMillis,
|
||||
HasMissingSpans: traceData.HasMissingSpans,
|
||||
HasMore: !selectAllSpans,
|
||||
Aggregations: aggregations,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,82 +307,29 @@ func calculateServiceTime(spanIDToSpanNodeMap map[string]*WaterfallSpan) map[str
|
||||
|
||||
totalTimes := make(map[string]uint64)
|
||||
for service, spans := range serviceSpans {
|
||||
totalTimes[service] = mergeSpanIntervals(spans)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user