mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-25 21:00:45 +01:00
Compare commits
5 Commits
feat/alert
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f5ddeb04d | ||
|
|
8e2da68fc6 | ||
|
|
8371a70801 | ||
|
|
9d9b0e194a | ||
|
|
2a7f4fd603 |
File diff suppressed because it is too large
Load Diff
@@ -179,6 +179,7 @@ The `handler.New` function ties the HTTP handler to OpenAPI metadata via `OpenAP
|
||||
- **SuccessStatusCode**: The HTTP status for successful responses (for example, `http.StatusOK`, `http.StatusCreated`, `http.StatusNoContent`).
|
||||
- **ErrorStatusCodes**: Additional error status codes beyond the standard ones automatically added by `handler.New`.
|
||||
- **SecuritySchemes**: Auth mechanisms and scopes required by the operation.
|
||||
- **Stability**: Maturity marker (`handler.StabilityDevelopment`, `handler.StabilityAlpha`, `handler.StabilityBeta`, `handler.StabilityStable`, the OpenTelemetry Collector levels) emitted as the `x-signoz-stability` extension on every operation. Unset is emitted as `alpha`.
|
||||
|
||||
The generic handler:
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ const mockPaths = {
|
||||
const mockTzDate = jest.fn(
|
||||
(date: Date, _timezone: string) => new Date(date.getTime()),
|
||||
);
|
||||
const mockOrient = jest.fn();
|
||||
|
||||
// Mock uPlot constructor - this needs to be a proper constructor function
|
||||
function MockUPlot(
|
||||
@@ -61,6 +62,9 @@ function MockUPlot(
|
||||
// Add static methods to the constructor
|
||||
MockUPlot.tzDate = mockTzDate;
|
||||
MockUPlot.paths = mockPaths;
|
||||
MockUPlot.orient = mockOrient;
|
||||
// Pinned so canvas-space maths in path builders is deterministic under jsdom.
|
||||
MockUPlot.pxRatio = 1;
|
||||
|
||||
// Export the constructor as default
|
||||
export default MockUPlot;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Surface matches the shared Tooltip: same tokens, same radius, no shadow.
|
||||
// Padding lives on the sections so a footer can reach the container edges.
|
||||
.container {
|
||||
font-family: 'Inter';
|
||||
font-size: 12px;
|
||||
background: var(--l2-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: var(--l2-foreground);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 200px;
|
||||
|
||||
&.pinned {
|
||||
border-color: var(--ring);
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--l2-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
}
|
||||
|
||||
// Matches the legend row's marker.
|
||||
.marker {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-vanilla-100);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
// The group values name the point; the channels are what it says.
|
||||
.rowMuted {
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.rowLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rowValue {
|
||||
flex: 0 0 auto;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
111
frontend/src/lib/uPlotV2/components/Tooltip/ScatterTooltip.tsx
Normal file
111
frontend/src/lib/uPlotV2/components/Tooltip/ScatterTooltip.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
|
||||
import { ScatterTooltipProps } from '../types';
|
||||
import { buildChannelRows, resolveHoveredPoint } from './scatterTooltipContent';
|
||||
|
||||
import Styles from './ScatterTooltip.module.scss';
|
||||
|
||||
/**
|
||||
* One point, its channels, then the group values that name it. Purpose-built
|
||||
* rather than composed from the shared `Tooltip`, whose list is one row per
|
||||
* series at a shared x; a scatter point has no such neighbours.
|
||||
*/
|
||||
export default function ScatterTooltip({
|
||||
uPlotInstance,
|
||||
dataIndexes,
|
||||
seriesIndex,
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
decimalPrecision,
|
||||
isPinned,
|
||||
dismiss,
|
||||
renderTooltipFooter,
|
||||
}: ScatterTooltipProps): JSX.Element | null {
|
||||
const point = useMemo(
|
||||
() => resolveHoveredPoint(uPlotInstance, seriesIndex, dataIndexes),
|
||||
[uPlotInstance, seriesIndex, dataIndexes],
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
() => (point ? buildChannelRows(point, channels, decimalPrecision) : []),
|
||||
[point, channels, decimalPrecision],
|
||||
);
|
||||
|
||||
const labels = useMemo(
|
||||
() =>
|
||||
point
|
||||
? (resolvePointLabels?.(point.seriesIndex, point.dataIndex) ?? [])
|
||||
: [],
|
||||
[point, resolvePointLabels],
|
||||
);
|
||||
|
||||
if (!point) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(Styles.container, { [Styles.pinned]: isPinned })}
|
||||
data-pinned={isPinned}
|
||||
data-testid="scatter-tooltip"
|
||||
>
|
||||
<div className={Styles.header}>
|
||||
<span className={Styles.marker} style={{ backgroundColor: point.color }} />
|
||||
<span
|
||||
className={Styles.title}
|
||||
title={point.label}
|
||||
data-testid="scatter-tooltip-title"
|
||||
>
|
||||
{point.label}
|
||||
</span>
|
||||
{isPinned && (
|
||||
<span className={Styles.status} data-testid="scatter-tooltip-status">
|
||||
<Pin size={12} />
|
||||
<span>Pinned</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className={Styles.divider} />
|
||||
|
||||
<div className={Styles.rows}>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className={Styles.row}
|
||||
data-testid="scatter-tooltip-row"
|
||||
>
|
||||
<span className={Styles.rowLabel}>{row.label}</span>
|
||||
<span className={Styles.rowValue}>{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{labels.length > 0 && (
|
||||
<>
|
||||
<span className={Styles.divider} />
|
||||
<div className={Styles.rows}>
|
||||
{labels.map((label) => (
|
||||
<div
|
||||
key={label.key}
|
||||
className={cx(Styles.row, Styles.rowMuted)}
|
||||
data-testid="scatter-tooltip-label"
|
||||
>
|
||||
<span className={Styles.rowLabel} title={label.key}>
|
||||
{label.key}
|
||||
</span>
|
||||
<span className={Styles.rowValue} title={label.value}>
|
||||
{label.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderTooltipFooter?.({ isPinned, dismiss })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import {
|
||||
buildChannelRows,
|
||||
resolveHoveredPoint,
|
||||
ScatterHoveredPoint,
|
||||
} from '../scatterTooltipContent';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getToolTipValue: jest.fn((value: number | string, unit?: string) =>
|
||||
`${value} ${unit ?? ''}`.trim(),
|
||||
),
|
||||
}));
|
||||
|
||||
const plot = {
|
||||
data: [
|
||||
null,
|
||||
[
|
||||
[10, 20],
|
||||
[100, 200],
|
||||
[5, null],
|
||||
],
|
||||
[[30], [300]],
|
||||
],
|
||||
series: [
|
||||
{},
|
||||
{ label: 'cart', stroke: '#ff0000' },
|
||||
{ label: 'checkout', stroke: (): string => '#00ff00' },
|
||||
],
|
||||
} as unknown as uPlot;
|
||||
|
||||
describe('resolveHoveredPoint', () => {
|
||||
it('reads the focused series at its own data index', () => {
|
||||
expect(resolveHoveredPoint(plot, 1, [null, 1, null])).toStrictEqual({
|
||||
seriesIndex: 1,
|
||||
dataIndex: 1,
|
||||
label: 'cart',
|
||||
color: '#ff0000',
|
||||
x: 20,
|
||||
y: 200,
|
||||
size: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the size column when present and resolves function strokes', () => {
|
||||
expect(resolveHoveredPoint(plot, 1, [null, 0, null])?.size).toBe(5);
|
||||
expect(resolveHoveredPoint(plot, 2, [null, null, 0])).toMatchObject({
|
||||
label: 'checkout',
|
||||
color: '#00ff00',
|
||||
size: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('is null without a focused series or an index for it', () => {
|
||||
expect(resolveHoveredPoint(plot, null, [null, 0, null])).toBeNull();
|
||||
expect(resolveHoveredPoint(plot, 0, [0, 0, null])).toBeNull();
|
||||
expect(resolveHoveredPoint(plot, 1, [null, null, null])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildChannelRows', () => {
|
||||
const point: ScatterHoveredPoint = {
|
||||
seriesIndex: 1,
|
||||
dataIndex: 0,
|
||||
label: 'cart',
|
||||
color: '#f00',
|
||||
x: 12,
|
||||
y: 340,
|
||||
size: 7,
|
||||
};
|
||||
|
||||
it('formats x and y with their own units', () => {
|
||||
const rows = buildChannelRows(point, {
|
||||
x: { label: 'Throughput', unit: 'reqps' },
|
||||
y: { label: 'p99', unit: 'ms' },
|
||||
});
|
||||
|
||||
expect(rows).toStrictEqual([
|
||||
{ label: 'Throughput', value: '12 reqps' },
|
||||
{ label: 'p99', value: '340 ms' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds the size row only when the channel is mapped and the point has one', () => {
|
||||
const channels = {
|
||||
x: { label: 'x' },
|
||||
y: { label: 'y' },
|
||||
size: { label: 'Errors' },
|
||||
};
|
||||
|
||||
expect(buildChannelRows(point, channels)).toHaveLength(3);
|
||||
expect(buildChannelRows({ ...point, size: null }, channels)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type {
|
||||
ScatterChannel,
|
||||
ScatterChannels,
|
||||
ScatterSeriesData,
|
||||
} from '../../plugins/ScatterPlugin/types';
|
||||
import { resolveSeriesColor } from './utils';
|
||||
|
||||
export interface ScatterHoveredPoint {
|
||||
seriesIndex: number;
|
||||
dataIndex: number;
|
||||
label: string;
|
||||
color: string;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
export interface ScatterTooltipRow {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** The point the cursor resolved to: the focused series' own index into its columns. */
|
||||
export function resolveHoveredPoint(
|
||||
u: uPlot,
|
||||
seriesIndex: number | null,
|
||||
dataIndexes: Array<number | null>,
|
||||
): ScatterHoveredPoint | null {
|
||||
if (seriesIndex == null || seriesIndex < 1) {
|
||||
return null;
|
||||
}
|
||||
const dataIndex = dataIndexes[seriesIndex];
|
||||
const series = u.series[seriesIndex];
|
||||
const columns = u.data[seriesIndex] as unknown as
|
||||
| ScatterSeriesData
|
||||
| undefined;
|
||||
if (dataIndex == null || !series || !columns) {
|
||||
return null;
|
||||
}
|
||||
const x = columns[0][dataIndex];
|
||||
const y = columns[1][dataIndex];
|
||||
if (x == null || y == null) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
seriesIndex,
|
||||
dataIndex,
|
||||
label: String(series.label ?? ''),
|
||||
color: resolveSeriesColor(series.stroke, u, seriesIndex),
|
||||
x,
|
||||
y,
|
||||
size: columns[2]?.[dataIndex] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatChannel(
|
||||
value: number,
|
||||
channel: ScatterChannel,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): string {
|
||||
return getToolTipValue(value, channel.unit, decimalPrecision);
|
||||
}
|
||||
|
||||
export function buildChannelRows(
|
||||
point: ScatterHoveredPoint,
|
||||
channels: ScatterChannels,
|
||||
decimalPrecision?: PrecisionOption,
|
||||
): ScatterTooltipRow[] {
|
||||
const rows: ScatterTooltipRow[] = [
|
||||
{
|
||||
label: channels.x.label,
|
||||
value: formatChannel(point.x, channels.x, decimalPrecision),
|
||||
},
|
||||
{
|
||||
label: channels.y.label,
|
||||
value: formatChannel(point.y, channels.y, decimalPrecision),
|
||||
},
|
||||
];
|
||||
if (channels.size && point.size != null) {
|
||||
rows.push({
|
||||
label: channels.size.label,
|
||||
value: formatChannel(point.size, channels.size, decimalPrecision),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import uPlot from 'uplot';
|
||||
|
||||
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||
import { LegendItem } from '../config/types';
|
||||
import type {
|
||||
ScatterChannels,
|
||||
ScatterPointLabel,
|
||||
} from '../plugins/ScatterPlugin/types';
|
||||
import { SyncTooltipFilterMode } from '../plugins/TooltipPlugin/types';
|
||||
|
||||
/**
|
||||
@@ -103,6 +107,17 @@ export interface BarTooltipProps extends BaseTooltipProps, TooltipRenderArgs {
|
||||
export interface HistogramTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {}
|
||||
|
||||
/** Not part of `TooltipProps`: it describes one point's channels, not a series list. */
|
||||
export interface ScatterTooltipProps
|
||||
extends BaseTooltipProps, TooltipRenderArgs {
|
||||
channels: ScatterChannels;
|
||||
/** The group values behind a point, e.g. `service.name` → `cart`. */
|
||||
resolvePointLabels?: (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
) => ScatterPointLabel[];
|
||||
}
|
||||
|
||||
export type TooltipProps =
|
||||
| TimeSeriesTooltipProps
|
||||
| BarTooltipProps
|
||||
|
||||
@@ -58,32 +58,49 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build values formatter for X-axis (time)
|
||||
* Build values formatter for X-axis: time, or a value axis when a unit or
|
||||
* precision is given (scatter). Neither leaves uPlot's numeric default.
|
||||
*/
|
||||
private buildXAxisValuesFormatter(): uPlot.Axis.Values | undefined {
|
||||
const { isTimeAxis } = this.props;
|
||||
const { isTimeAxis, yAxisUnit, decimalPrecision } = this.props;
|
||||
|
||||
if (isTimeAxis) {
|
||||
return uPlotXAxisValuesFormat as uPlot.Axis.Values;
|
||||
}
|
||||
|
||||
if (yAxisUnit !== undefined || decimalPrecision !== undefined) {
|
||||
return this.buildValueAxisFormatter();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build values formatter for Y-axis (values with units)
|
||||
* Build values formatter for a value axis (values with units). A split outside
|
||||
* the scale's range gets no label: uPlot's arcsinh splits always include
|
||||
* ±threshold, and it would draw that label past the plot's edge.
|
||||
*/
|
||||
private buildYAxisValuesFormatter(): uPlot.Axis.Values {
|
||||
const { yAxisUnit, decimalPrecision } = this.props;
|
||||
private buildValueAxisFormatter(): uPlot.Axis.Values {
|
||||
const { yAxisUnit, decimalPrecision, scaleKey } = this.props;
|
||||
|
||||
return (_, t): string[] =>
|
||||
t.map((v) => {
|
||||
if (v === null || v === undefined || Number.isNaN(v)) {
|
||||
return (u, t): string[] => {
|
||||
const scale = u?.scales?.[scaleKey];
|
||||
const min = scale?.min ?? -Infinity;
|
||||
const max = scale?.max ?? Infinity;
|
||||
return t.map((v) => {
|
||||
if (
|
||||
v === null ||
|
||||
v === undefined ||
|
||||
Number.isNaN(v) ||
|
||||
v < min ||
|
||||
v > max
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
const value = getToolTipValue(v.toString(), yAxisUnit, decimalPrecision);
|
||||
return `${value}`;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,7 +118,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
return scaleKey === 'x'
|
||||
? this.buildXAxisValuesFormatter()
|
||||
: scaleKey === 'y'
|
||||
? this.buildYAxisValuesFormatter()
|
||||
? this.buildValueAxisFormatter()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ConfigBuilder,
|
||||
ConfigBuilderProps,
|
||||
LegendItem,
|
||||
PlotMode,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from './types';
|
||||
@@ -65,6 +66,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private stackMode: StackMode = StackMode.None;
|
||||
|
||||
private mode: PlotMode = PlotMode.Aligned;
|
||||
|
||||
private cursor: Cursor | undefined;
|
||||
|
||||
private hooks: Hooks.Arrays = {};
|
||||
@@ -160,6 +163,15 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
return this.stackMode;
|
||||
}
|
||||
|
||||
/** Faceted series carry their own x column each; see `SeriesProps.facets`. */
|
||||
setMode(mode: PlotMode): void {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
getMode(): PlotMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or merge a scale configuration
|
||||
*/
|
||||
@@ -512,6 +524,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
{} as Record<string, uPlot.Scale>,
|
||||
);
|
||||
|
||||
if (this.mode === PlotMode.Faceted) {
|
||||
config.mode = this.mode as number as uPlot.Mode;
|
||||
}
|
||||
|
||||
config.hooks = this.hooks;
|
||||
config.select = this.select;
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
time,
|
||||
distr,
|
||||
logBase,
|
||||
asinhThreshold: this.props.asinhThreshold,
|
||||
});
|
||||
|
||||
const { rangeConfig, hardMinOnly, hardMaxOnly, hasFixedRange } =
|
||||
|
||||
@@ -87,6 +87,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
lineConfig.fill = finalFillColor;
|
||||
} else if (this.props.drawStyle === DrawStyle.Histogram) {
|
||||
lineConfig.fill = `${finalFillColor}40`;
|
||||
} else if (this.props.drawStyle === DrawStyle.Scatter) {
|
||||
lineConfig.fill = `${finalFillColor}${toAlphaHex(resolveFillOpacity(fillOpacity))}`;
|
||||
} else if (fillMode && fillMode !== FillMode.None) {
|
||||
const resolvedOpacity = resolveFillOpacity(fillOpacity);
|
||||
if (fillMode === FillMode.Solid) {
|
||||
@@ -122,7 +124,8 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
return { paths: pathBuilder };
|
||||
}
|
||||
|
||||
if (drawStyle === DrawStyle.Points) {
|
||||
// Scatter without a `pathBuilder` has nothing to draw its discs with.
|
||||
if (drawStyle === DrawStyle.Points || drawStyle === DrawStyle.Scatter) {
|
||||
return { paths: (): null => null };
|
||||
}
|
||||
|
||||
@@ -194,6 +197,10 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
if (drawStyle === DrawStyle.Points) {
|
||||
return true;
|
||||
}
|
||||
// The discs are the series path; uPlot's own points would double-draw them.
|
||||
if (drawStyle === DrawStyle.Scatter) {
|
||||
return false;
|
||||
}
|
||||
return !!showPoints;
|
||||
}
|
||||
|
||||
@@ -218,7 +225,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
}
|
||||
|
||||
getConfig(): ExtendedSeries {
|
||||
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
|
||||
const { scaleKey, label, spanGaps, show = true, metric, facets } = this.props;
|
||||
|
||||
const resolvedLineColor = this.getLineColor();
|
||||
|
||||
@@ -246,6 +253,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
|
||||
...pathConfig,
|
||||
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
|
||||
metric,
|
||||
...(facets && { facets }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,3 +376,51 @@ describe('UPlotAxisBuilder', () => {
|
||||
expect(config.values).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotAxisBuilder value x axis', () => {
|
||||
it('formats a non-time x axis with its unit', () => {
|
||||
(getToolTipValue as jest.Mock).mockReturnValue('1.2K req/s');
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'x', isTimeAxis: false, yAxisUnit: 'reqps' }),
|
||||
).getConfig();
|
||||
|
||||
const values = (config.values as uPlot.Axis.DynamicValues)(
|
||||
{} as uPlot,
|
||||
[1200],
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(values).toStrictEqual(['1.2K req/s']);
|
||||
expect(getToolTipValue).toHaveBeenCalledWith('1200', 'reqps', undefined);
|
||||
});
|
||||
|
||||
it('leaves a non-time x axis to uPlot when nothing says how to format it', () => {
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'x', isTimeAxis: false }),
|
||||
).getConfig();
|
||||
|
||||
expect(config.values).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotAxisBuilder out-of-range splits', () => {
|
||||
it('leaves a split the scale cannot place unlabelled', () => {
|
||||
(getToolTipValue as jest.Mock).mockImplementation((v: string) => `${v} ms`);
|
||||
const config = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', yAxisUnit: 'ms' }),
|
||||
).getConfig();
|
||||
const u = { scales: { y: { min: 0, max: 1000 } } } as unknown as uPlot;
|
||||
|
||||
const values = (config.values as uPlot.Axis.DynamicValues)(
|
||||
u,
|
||||
[-10, 0, 500, 5000],
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(values).toStrictEqual(['', '0 ms', '500 ms', '']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,12 @@ import {
|
||||
STEP_INTERVAL_MULTIPLIER,
|
||||
} from '../../constants';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
|
||||
import {
|
||||
DrawStyle,
|
||||
PlotMode,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
@@ -651,3 +656,15 @@ describe('UPlotConfigBuilder stacking', () => {
|
||||
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotConfigBuilder plot mode', () => {
|
||||
it('leaves mode unset for aligned data and emits 2 when faceted', () => {
|
||||
const aligned = new UPlotConfigBuilder({ id: 'aligned' });
|
||||
expect(aligned.getConfig().mode).toBeUndefined();
|
||||
|
||||
const faceted = new UPlotConfigBuilder({ id: 'faceted' });
|
||||
faceted.setMode(PlotMode.Faceted);
|
||||
expect(faceted.getMode()).toBe(PlotMode.Faceted);
|
||||
expect(faceted.getConfig().mode).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -399,3 +399,39 @@ describe('UPlotSeriesBuilder', () => {
|
||||
expect(builder.getConfig().fill).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotSeriesBuilder scatter', () => {
|
||||
it('draws through the given path builder and hides uPlot points', () => {
|
||||
const pathBuilder = jest.fn();
|
||||
const config = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Scatter,
|
||||
pathBuilder,
|
||||
facets: [{ scale: 'x' }, { scale: 'y' }],
|
||||
lineColor: '#ff0000',
|
||||
fillOpacity: 0.5,
|
||||
lineWidth: 1,
|
||||
pointSize: 8,
|
||||
}),
|
||||
).getConfig();
|
||||
|
||||
expect(config.paths).toBe(pathBuilder);
|
||||
expect(config.facets).toStrictEqual([{ scale: 'x' }, { scale: 'y' }]);
|
||||
expect(config.points?.show).toBe(false);
|
||||
expect(config.points?.size).toBe(8);
|
||||
expect(config.stroke).toBe('#ff0000');
|
||||
expect(config.width).toBe(1);
|
||||
expect(config.fill).toBe('#ff000080');
|
||||
});
|
||||
|
||||
it('draws nothing without a path builder', () => {
|
||||
const config = new UPlotSeriesBuilder(
|
||||
createBaseProps({ drawStyle: DrawStyle.Scatter }),
|
||||
).getConfig();
|
||||
|
||||
expect(
|
||||
(config.paths as uPlot.Series.PathBuilder)({} as uPlot, 1, 0, 0),
|
||||
).toBeNull();
|
||||
expect(config.facets).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,7 +88,8 @@ export interface AxisProps {
|
||||
isDarkMode?: boolean;
|
||||
/** Axis is on a log scale — thins the grid lines to keep dense decades readable. */
|
||||
isLogScale?: boolean;
|
||||
/** Unit the y axis ticks are formatted in (`spec.formatting.unit`). */
|
||||
/** Unit the value ticks are formatted in (`spec.formatting.unit`). Named for the
|
||||
* y axis, the only value axis until scatter; a non-time x axis reads it too. */
|
||||
yAxisUnit?: string;
|
||||
/**
|
||||
* X axis carries timestamps, so its ticks format as dates/times. Declared by the caller
|
||||
@@ -107,6 +108,15 @@ export interface AxisProps {
|
||||
export enum DistributionType {
|
||||
Linear = 'linear',
|
||||
Logarithmic = 'logarithmic',
|
||||
/** arcsinh: linear within ±`asinhThreshold`, logarithmic beyond. Takes zero and
|
||||
* negatives, which a plain log cannot place. */
|
||||
SymmetricLog = 'symlog',
|
||||
}
|
||||
|
||||
/** uPlot's data layout: one shared x per chart, or per-series x/y columns. */
|
||||
export enum PlotMode {
|
||||
Aligned = 1,
|
||||
Faceted = 2,
|
||||
}
|
||||
|
||||
export interface ScaleProps {
|
||||
@@ -123,6 +133,8 @@ export interface ScaleProps {
|
||||
auto?: boolean;
|
||||
logBase?: uPlot.Scale.LogBase;
|
||||
distribution?: DistributionType;
|
||||
/** Half-width of a `SymmetricLog` scale's linear band around zero. Default 1. */
|
||||
asinhThreshold?: number;
|
||||
}
|
||||
|
||||
export enum DisconnectedValuesMode {
|
||||
@@ -144,6 +156,8 @@ export enum DrawStyle {
|
||||
Points = 'points',
|
||||
Bar = 'bar',
|
||||
Histogram = 'histogram',
|
||||
/** Faceted (mode 2) discs at per-series x/y, drawn by the caller's `pathBuilder`. */
|
||||
Scatter = 'scatter',
|
||||
}
|
||||
|
||||
export enum LineInterpolation {
|
||||
@@ -227,6 +241,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
|
||||
isDarkMode?: boolean;
|
||||
stepInterval?: number;
|
||||
metric?: { [key: string]: string };
|
||||
/** Mode 2 only: the scales the series' own x and y columns are read against. */
|
||||
facets?: Series.Facet[];
|
||||
}
|
||||
|
||||
export interface LegendItem {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import { Quadtree } from '../../../utils/quadtree';
|
||||
import {
|
||||
resolveHit,
|
||||
resolvePointDiameter,
|
||||
resolveSizeDomain,
|
||||
} from '../geometry';
|
||||
import { ScatterHit, ScatterPointSize } from '../types';
|
||||
|
||||
const POINT_SIZE: ScatterPointSize = { fixed: 6, min: 4, max: 20 };
|
||||
|
||||
const asData = (columns: unknown[]): uPlot.AlignedData =>
|
||||
columns as unknown as uPlot.AlignedData;
|
||||
|
||||
describe('resolveSizeDomain', () => {
|
||||
it('spans the size columns of every series, skipping nulls', () => {
|
||||
const data = asData([
|
||||
null,
|
||||
[
|
||||
[1, 2],
|
||||
[1, 2],
|
||||
[10, null],
|
||||
],
|
||||
[[3], [3], [40]],
|
||||
]);
|
||||
|
||||
expect(resolveSizeDomain(data)).toStrictEqual({ min: 10, max: 40 });
|
||||
});
|
||||
|
||||
it('is null when no series carries sizes', () => {
|
||||
expect(resolveSizeDomain(asData([null, [[1], [1]]]))).toBeNull();
|
||||
expect(resolveSizeDomain(asData([null, [[1], [1], [null]]]))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePointDiameter', () => {
|
||||
it('uses the fixed diameter without a size or a domain', () => {
|
||||
expect(resolvePointDiameter(null, { min: 0, max: 10 }, POINT_SIZE)).toBe(6);
|
||||
expect(resolvePointDiameter(5, null, POINT_SIZE)).toBe(6);
|
||||
});
|
||||
|
||||
it('maps the domain ends to min and max', () => {
|
||||
const domain = { min: 0, max: 100 };
|
||||
expect(resolvePointDiameter(0, domain, POINT_SIZE)).toBe(4);
|
||||
expect(resolvePointDiameter(100, domain, POINT_SIZE)).toBe(20);
|
||||
});
|
||||
|
||||
it('scales by area, not diameter', () => {
|
||||
const midArea = (4 ** 2 + 20 ** 2) / 2;
|
||||
expect(
|
||||
resolvePointDiameter(50, { min: 0, max: 100 }, POINT_SIZE),
|
||||
).toBeCloseTo(Math.sqrt(midArea));
|
||||
});
|
||||
|
||||
it('clamps values outside the domain', () => {
|
||||
const domain = { min: 10, max: 20 };
|
||||
expect(resolvePointDiameter(-5, domain, POINT_SIZE)).toBe(4);
|
||||
expect(resolvePointDiameter(500, domain, POINT_SIZE)).toBe(20);
|
||||
});
|
||||
|
||||
it('uses the midpoint when every size is the same', () => {
|
||||
expect(resolvePointDiameter(7, { min: 7, max: 7 }, POINT_SIZE)).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveHit', () => {
|
||||
const hit = (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
x: number,
|
||||
y: number,
|
||||
d: number,
|
||||
): ScatterHit => ({ seriesIndex, dataIndex, x, y, w: d, h: d });
|
||||
|
||||
it('returns the disc under the cursor', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 6));
|
||||
tree.add(hit(2, 3, 50, 50, 6));
|
||||
|
||||
expect(resolveHit(tree, 13, 13, 0)).toMatchObject({
|
||||
seriesIndex: 1,
|
||||
dataIndex: 0,
|
||||
});
|
||||
expect(resolveHit(tree, 52, 52, 0)).toMatchObject({
|
||||
seriesIndex: 2,
|
||||
dataIndex: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('is null when the cursor is off every disc', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 6));
|
||||
|
||||
expect(resolveHit(tree, 30, 30, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('tolerance widens each disc', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 6));
|
||||
|
||||
expect(resolveHit(tree, 18, 13, 0)).toBeNull();
|
||||
expect(resolveHit(tree, 18, 13, 3)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the disc whose centre is nearest when they overlap', () => {
|
||||
const tree = new Quadtree<ScatterHit>(0, 0, 100, 100);
|
||||
tree.add(hit(1, 0, 10, 10, 10));
|
||||
tree.add(hit(1, 1, 14, 10, 10));
|
||||
|
||||
expect(resolveHit(tree, 13, 15, 0)?.dataIndex).toBe(0);
|
||||
expect(resolveHit(tree, 21, 15, 0)?.dataIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { PlotMode } from '../../../config/types';
|
||||
import { UPlotConfigBuilder } from '../../../config/UPlotConfigBuilder';
|
||||
import {
|
||||
applyScatterPlugin,
|
||||
createScatterPlugin,
|
||||
SCATTER_FACETS,
|
||||
} from '../scatterPlugin';
|
||||
|
||||
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}));
|
||||
|
||||
/** jsdom has no Path2D; the builder only needs something that takes the calls. */
|
||||
class FakePath2D {
|
||||
moveTo = jest.fn();
|
||||
arc = jest.fn();
|
||||
}
|
||||
|
||||
type OrientCallback = Parameters<typeof uPlot.orient>[2];
|
||||
|
||||
interface FakePlotArgs {
|
||||
series: Array<{ xs: number[]; ys: number[]; sizes?: Array<number | null> }>;
|
||||
cursor?: { left: number; top: number };
|
||||
scaleX?: { min: number; max: number };
|
||||
scaleY?: { min: number; max: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* A 100×100 plot at the canvas origin with identity scales: value 10 draws at
|
||||
* pixel 10 on x, and at 100 − 10 on y (uPlot's y grows downward).
|
||||
*/
|
||||
function createFakePlot({
|
||||
series,
|
||||
cursor = { left: -1, top: -1 },
|
||||
scaleX = { min: 0, max: 100 },
|
||||
scaleY = { min: 0, max: 100 },
|
||||
}: FakePlotArgs): uPlot {
|
||||
const data = [
|
||||
null,
|
||||
...series.map((entry) =>
|
||||
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
|
||||
),
|
||||
];
|
||||
return {
|
||||
data,
|
||||
series: [{}, ...series.map((_, index) => ({ label: `s${index + 1}` }))],
|
||||
bbox: { left: 0, top: 0, width: 100, height: 100 },
|
||||
cursor,
|
||||
scales: { x: scaleX, y: scaleY },
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
/** Stands in for `uPlot.orient`: identity x, flipped y, an `arc` that records. */
|
||||
function orientWithIdentityScales(
|
||||
u: uPlot,
|
||||
seriesIdx: number,
|
||||
cb: OrientCallback,
|
||||
): void {
|
||||
const columns = (u.data as unknown as Array<number[][] | null>)[seriesIdx];
|
||||
if (!columns) {
|
||||
return;
|
||||
}
|
||||
const scaleX = (u.scales as unknown as Record<string, uPlot.Scale>).x;
|
||||
const scaleY = (u.scales as unknown as Record<string, uPlot.Scale>).y;
|
||||
const valToPosX = (value: number): number => value;
|
||||
const valToPosY = (value: number): number => 100 - value;
|
||||
// Real uPlot's `arc` helper forwards to the path; the test counts those calls.
|
||||
const arc = (path: FakePath2D, ...args: number[]): void => {
|
||||
path.arc(...args);
|
||||
};
|
||||
cb(
|
||||
u.series[seriesIdx],
|
||||
columns[0],
|
||||
columns[1],
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX as unknown as uPlot.ValToPos,
|
||||
valToPosY as unknown as uPlot.ValToPos,
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
100,
|
||||
jest.fn() as never,
|
||||
jest.fn() as never,
|
||||
jest.fn() as never,
|
||||
arc as never,
|
||||
jest.fn() as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('createScatterPlugin', () => {
|
||||
beforeAll(() => {
|
||||
(globalThis as { Path2D?: unknown }).Path2D = FakePath2D;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(uPlot.orient as jest.Mock).mockImplementation(orientWithIdentityScales);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(uPlot.orient as jest.Mock).mockReset();
|
||||
});
|
||||
|
||||
function drawAll(
|
||||
u: uPlot,
|
||||
plugin: ReturnType<typeof createScatterPlugin>,
|
||||
): void {
|
||||
plugin.hooks.drawClear(u);
|
||||
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
|
||||
const columns = (u.data as unknown as number[][][])[seriesIdx];
|
||||
plugin.pathBuilder(u, seriesIdx, 0, columns[0].length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs the cursor scan the way uPlot does: every data series, in order. */
|
||||
function scan(
|
||||
u: uPlot,
|
||||
plugin: ReturnType<typeof createScatterPlugin>,
|
||||
): Array<number | null> {
|
||||
const dataIdx = plugin.cursor.dataIdx as NonNullable<uPlot.Cursor['dataIdx']>;
|
||||
const indexes: Array<number | null> = [null];
|
||||
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx++) {
|
||||
indexes.push(dataIdx(u, seriesIdx, 0, 0));
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
it('returns one path that strokes and fills the same discs', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({ series: [{ xs: [10, 20], ys: [10, 20] }] });
|
||||
plugin.hooks.drawClear(u);
|
||||
|
||||
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
|
||||
|
||||
expect(paths.stroke).toBeInstanceOf(FakePath2D);
|
||||
expect(paths.fill).toBe(paths.stroke);
|
||||
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resolves the hovered point to its own series and index', () => {
|
||||
const plugin = createScatterPlugin({
|
||||
pointSize: { fixed: 6, min: 4, max: 20 },
|
||||
});
|
||||
const u = createFakePlot({
|
||||
series: [
|
||||
{ xs: [10, 50], ys: [10, 50] },
|
||||
{ xs: [80], ys: [80] },
|
||||
],
|
||||
// Over the second series' only point: x 80, y drawn at 100 − 80.
|
||||
cursor: { left: 80, top: 20 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
|
||||
expect(scan(u, plugin)).toStrictEqual([null, null, 0]);
|
||||
expect(plugin.getHit()).toMatchObject({ seriesIndex: 2, dataIndex: 0 });
|
||||
});
|
||||
|
||||
it('returns null for every series when the cursor is off the plot or off any disc', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({
|
||||
series: [{ xs: [10], ys: [10] }],
|
||||
cursor: { left: -1, top: -1 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
|
||||
expect(scan(u, plugin)).toStrictEqual([null, null]);
|
||||
|
||||
(u.cursor as { left: number; top: number }).left = 50;
|
||||
(u.cursor as { left: number; top: number }).top = 50;
|
||||
expect(scan(u, plugin)).toStrictEqual([null, null]);
|
||||
});
|
||||
|
||||
it('skips points outside the visible scale range', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({
|
||||
series: [{ xs: [10, 500], ys: [10, 10] }],
|
||||
cursor: { left: 10, top: 90 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
|
||||
const paths = plugin.pathBuilder(u, 1, 0, 1) as uPlot.Series.Paths;
|
||||
expect((paths.fill as unknown as FakePath2D).arc).toHaveBeenCalledTimes(1);
|
||||
expect(scan(u, plugin)).toStrictEqual([null, 0]);
|
||||
});
|
||||
|
||||
it('sizes the hover marker from the hit disc, in CSS pixels', () => {
|
||||
const plugin = createScatterPlugin({
|
||||
pointSize: { fixed: 8, min: 4, max: 20 },
|
||||
});
|
||||
const u = createFakePlot({
|
||||
series: [{ xs: [10], ys: [10] }],
|
||||
cursor: { left: 10, top: 90 },
|
||||
});
|
||||
drawAll(u, plugin);
|
||||
scan(u, plugin);
|
||||
|
||||
const bbox = plugin.cursor.points?.bbox;
|
||||
expect(bbox?.(u, 1)).toStrictEqual({ left: 6, top: 86, width: 8, height: 8 });
|
||||
expect(bbox?.(u, 2)).toMatchObject({ width: 0, height: 0 });
|
||||
});
|
||||
|
||||
it('drawClear drops cached paths on data series only', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
const u = createFakePlot({ series: [{ xs: [1], ys: [1] }] });
|
||||
const [xSeries, dataSeries] = u.series as Array<{ _paths?: unknown }>;
|
||||
xSeries._paths = 'x';
|
||||
dataSeries._paths = 'cached';
|
||||
|
||||
plugin.hooks.drawClear(u);
|
||||
|
||||
expect(xSeries._paths).toBe('x');
|
||||
expect(dataSeries._paths).toBeNull();
|
||||
});
|
||||
|
||||
it('focus distance is zero, so the hit series wins focus', () => {
|
||||
const plugin = createScatterPlugin();
|
||||
expect(plugin.cursor.focus?.dist?.({} as uPlot, 1, 0, 0, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyScatterPlugin', () => {
|
||||
it('switches the builder to faceted mode and disables drag selection', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'scatter' });
|
||||
const plugin = createScatterPlugin();
|
||||
|
||||
applyScatterPlugin(builder, plugin);
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(builder.getMode()).toBe(PlotMode.Faceted);
|
||||
expect(config.mode).toBe(2);
|
||||
expect(config.cursor?.drag).toMatchObject({
|
||||
x: false,
|
||||
y: false,
|
||||
setScale: false,
|
||||
});
|
||||
expect(config.hooks?.drawClear).toHaveLength(1);
|
||||
expect(config.hooks?.destroy).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('facets read x and y against the shared scales', () => {
|
||||
expect(SCATTER_FACETS).toStrictEqual([
|
||||
{ scale: 'x', auto: true },
|
||||
{ scale: 'y', auto: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
96
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/geometry.ts
Normal file
96
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/geometry.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { Quadtree } from '../../utils/quadtree';
|
||||
import type { ScatterHit, ScatterPointSize, ScatterSeriesData } from './types';
|
||||
|
||||
export interface SizeDomain {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extent of the size column across every series, so equal values draw equal
|
||||
* discs whichever group they belong to. `null` when nothing carries a size.
|
||||
*/
|
||||
export function resolveSizeDomain(data: uPlot.AlignedData): SizeDomain | null {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
for (let seriesIndex = 1; seriesIndex < data.length; seriesIndex++) {
|
||||
const sizes = (data[seriesIndex] as unknown as ScatterSeriesData)[2];
|
||||
if (!sizes) {
|
||||
continue;
|
||||
}
|
||||
for (const size of sizes) {
|
||||
if (size == null || !Number.isFinite(size)) {
|
||||
continue;
|
||||
}
|
||||
min = Math.min(min, size);
|
||||
max = Math.max(max, size);
|
||||
}
|
||||
}
|
||||
return min <= max ? { min, max } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disc diameter for a size value. Area, not diameter, follows the value: a
|
||||
* point worth twice as much should look twice as big.
|
||||
*/
|
||||
export function resolvePointDiameter(
|
||||
size: number | null | undefined,
|
||||
domain: SizeDomain | null,
|
||||
pointSize: ScatterPointSize,
|
||||
): number {
|
||||
if (size == null || domain == null || !Number.isFinite(size)) {
|
||||
return pointSize.fixed;
|
||||
}
|
||||
if (domain.max === domain.min) {
|
||||
return (pointSize.min + pointSize.max) / 2;
|
||||
}
|
||||
const t = Math.min(
|
||||
1,
|
||||
Math.max(0, (size - domain.min) / (domain.max - domain.min)),
|
||||
);
|
||||
const minArea = pointSize.min ** 2;
|
||||
const maxArea = pointSize.max ** 2;
|
||||
return Math.sqrt(minArea + (maxArea - minArea) * t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest disc under the cursor, or `null`. Overlapping discs resolve to the one
|
||||
* whose centre is closest; `tolerance` widens every disc so thin points stay
|
||||
* hoverable.
|
||||
*/
|
||||
export function resolveHit(
|
||||
tree: Quadtree<ScatterHit>,
|
||||
cx: number,
|
||||
cy: number,
|
||||
tolerance: number,
|
||||
): ScatterHit | null {
|
||||
let best: ScatterHit | null = null;
|
||||
let bestDistance = Infinity;
|
||||
|
||||
tree.get(
|
||||
cx - tolerance,
|
||||
cy - tolerance,
|
||||
tolerance * 2,
|
||||
tolerance * 2,
|
||||
(hit) => {
|
||||
const left = hit.x - tolerance;
|
||||
const top = hit.y - tolerance;
|
||||
const right = hit.x + hit.w + tolerance;
|
||||
const bottom = hit.y + hit.h + tolerance;
|
||||
if (cx < left || cx > right || cy < top || cy > bottom) {
|
||||
return;
|
||||
}
|
||||
const dx = cx - (hit.x + hit.w / 2);
|
||||
const dy = cy - (hit.y + hit.h / 2);
|
||||
const distance = dx * dx + dy * dy;
|
||||
if (distance < bestDistance) {
|
||||
best = hit;
|
||||
bestDistance = distance;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return best;
|
||||
}
|
||||
206
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin.ts
Normal file
206
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import uPlot, { Series } from 'uplot';
|
||||
|
||||
import { DEFAULT_FOCUS_PROXIMITY_VALUE } from '../../constants';
|
||||
import { PlotMode } from '../../config/types';
|
||||
import type { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
|
||||
import { Quadtree } from '../../utils/quadtree';
|
||||
import {
|
||||
resolveHit,
|
||||
resolvePointDiameter,
|
||||
resolveSizeDomain,
|
||||
SizeDomain,
|
||||
} from './geometry';
|
||||
import {
|
||||
DEFAULT_HOVER_TOLERANCE_PX,
|
||||
DEFAULT_SCATTER_POINT_SIZE,
|
||||
ScatterHit,
|
||||
ScatterPluginOptions,
|
||||
ScatterSeriesData,
|
||||
} from './types';
|
||||
|
||||
/** Every scatter series reads its own x and y columns against the shared scales. */
|
||||
export const SCATTER_FACETS: Series.Facet[] = [
|
||||
{ scale: 'x', auto: true },
|
||||
{ scale: 'y', auto: true },
|
||||
];
|
||||
|
||||
const HIDDEN_BBOX: uPlot.BBox = { left: -10, top: -10, width: 0, height: 0 };
|
||||
|
||||
const TWO_PI = 2 * Math.PI;
|
||||
|
||||
/** uPlot caches built paths on the series; the field is internal to it. */
|
||||
type SeriesWithPaths = Series & { _paths?: Series.Paths | null };
|
||||
|
||||
export interface ScatterPlugin {
|
||||
/** Draws every point of a series as one path and indexes the discs for hover. */
|
||||
pathBuilder: Series.PathBuilder;
|
||||
/** Hover by disc rather than by nearest x: mode 2 has no shared x to scan. */
|
||||
cursor: uPlot.Cursor;
|
||||
hooks: {
|
||||
drawClear: (u: uPlot) => void;
|
||||
destroy: (u: uPlot) => void;
|
||||
};
|
||||
getHit: () => ScatterHit | null;
|
||||
}
|
||||
|
||||
export function createScatterPlugin({
|
||||
pointSize = DEFAULT_SCATTER_POINT_SIZE,
|
||||
hoverTolerance = DEFAULT_HOVER_TOLERANCE_PX,
|
||||
}: ScatterPluginOptions = {}): ScatterPlugin {
|
||||
let tree: Quadtree<ScatterHit> | null = null;
|
||||
let hit: ScatterHit | null = null;
|
||||
|
||||
// The domain spans every series, so it is resolved once per dataset rather than
|
||||
// once per series path.
|
||||
let cachedData: uPlot.AlignedData | null = null;
|
||||
let cachedDomain: SizeDomain | null = null;
|
||||
|
||||
function getSizeDomain(u: uPlot): SizeDomain | null {
|
||||
if (cachedData !== u.data) {
|
||||
cachedDomain = resolveSizeDomain(u.data);
|
||||
cachedData = u.data;
|
||||
}
|
||||
return cachedDomain;
|
||||
}
|
||||
|
||||
const pathBuilder: Series.PathBuilder = (u, seriesIdx, idx0, idx1) => {
|
||||
const path = new Path2D();
|
||||
const sizes = (u.data[seriesIdx] as unknown as ScatterSeriesData)[2];
|
||||
const domain = getSizeDomain(u);
|
||||
const { pxRatio } = uPlot;
|
||||
|
||||
uPlot.orient(
|
||||
u,
|
||||
seriesIdx,
|
||||
(
|
||||
_series,
|
||||
dataX,
|
||||
dataY,
|
||||
scaleX,
|
||||
scaleY,
|
||||
valToPosX,
|
||||
valToPosY,
|
||||
xOff,
|
||||
yOff,
|
||||
xDim,
|
||||
yDim,
|
||||
_moveTo,
|
||||
_lineTo,
|
||||
_rect,
|
||||
arc,
|
||||
) => {
|
||||
const xMin = scaleX.min ?? -Infinity;
|
||||
const xMax = scaleX.max ?? Infinity;
|
||||
const yMin = scaleY.min ?? -Infinity;
|
||||
const yMax = scaleY.max ?? Infinity;
|
||||
|
||||
for (let i = idx0; i <= idx1; i++) {
|
||||
const x = dataX[i];
|
||||
const y = dataY[i];
|
||||
if (
|
||||
x == null ||
|
||||
y == null ||
|
||||
x < xMin ||
|
||||
x > xMax ||
|
||||
y < yMin ||
|
||||
y > yMax
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const diameter =
|
||||
resolvePointDiameter(sizes?.[i], domain, pointSize) * pxRatio;
|
||||
const radius = diameter / 2;
|
||||
const cx = valToPosX(x, scaleX, xDim, xOff);
|
||||
const cy = valToPosY(y, scaleY, yDim, yOff);
|
||||
|
||||
path.moveTo(cx + radius, cy);
|
||||
arc(path, cx, cy, radius, 0, TWO_PI);
|
||||
|
||||
tree?.add({
|
||||
x: cx - radius - u.bbox.left,
|
||||
y: cy - radius - u.bbox.top,
|
||||
w: diameter,
|
||||
h: diameter,
|
||||
seriesIndex: seriesIdx,
|
||||
dataIndex: i,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return { stroke: path, fill: path, clip: null };
|
||||
};
|
||||
|
||||
const cursor: uPlot.Cursor = {
|
||||
// Selection would set the dashboard time range; neither axis is time here.
|
||||
drag: { x: false, y: false, setScale: false },
|
||||
dataIdx: (u, seriesIdx): number | null => {
|
||||
// uPlot asks series 1..n in order on every cursor move; resolve once.
|
||||
if (seriesIdx === 1) {
|
||||
const { left = -1, top = -1 } = u.cursor;
|
||||
const { pxRatio } = uPlot;
|
||||
hit =
|
||||
tree && left >= 0 && top >= 0
|
||||
? resolveHit(
|
||||
tree,
|
||||
left * pxRatio,
|
||||
top * pxRatio,
|
||||
hoverTolerance * pxRatio,
|
||||
)
|
||||
: null;
|
||||
}
|
||||
return hit?.seriesIndex === seriesIdx ? hit.dataIndex : null;
|
||||
},
|
||||
points: {
|
||||
bbox: (_u, seriesIdx): uPlot.BBox => {
|
||||
if (hit?.seriesIndex !== seriesIdx) {
|
||||
return HIDDEN_BBOX;
|
||||
}
|
||||
const { pxRatio } = uPlot;
|
||||
return {
|
||||
left: hit.x / pxRatio,
|
||||
top: hit.y / pxRatio,
|
||||
width: hit.w / pxRatio,
|
||||
height: hit.h / pxRatio,
|
||||
};
|
||||
},
|
||||
},
|
||||
// uPlot only measures series that returned a data index, i.e. the hit one.
|
||||
focus: { prox: DEFAULT_FOCUS_PROXIMITY_VALUE, dist: (): number => 0 },
|
||||
};
|
||||
|
||||
return {
|
||||
pathBuilder,
|
||||
cursor,
|
||||
hooks: {
|
||||
drawClear: (u: uPlot): void => {
|
||||
tree = new Quadtree<ScatterHit>(0, 0, u.bbox.width, u.bbox.height);
|
||||
// The tree only knows what the path builder last drew, so cached paths
|
||||
// must be rebuilt alongside it.
|
||||
u.series.forEach((series, index) => {
|
||||
if (index > 0) {
|
||||
(series as SeriesWithPaths)._paths = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
destroy: (): void => {
|
||||
tree = null;
|
||||
hit = null;
|
||||
cachedData = null;
|
||||
cachedDomain = null;
|
||||
},
|
||||
},
|
||||
getHit: (): ScatterHit | null => hit,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyScatterPlugin(
|
||||
builder: UPlotConfigBuilder,
|
||||
plugin: ScatterPlugin,
|
||||
): void {
|
||||
builder.setMode(PlotMode.Faceted);
|
||||
builder.setCursor(plugin.cursor);
|
||||
builder.addHook('drawClear', plugin.hooks.drawClear);
|
||||
builder.addHook('destroy', plugin.hooks.destroy);
|
||||
}
|
||||
58
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/types.ts
Normal file
58
frontend/src/lib/uPlotV2/plugins/ScatterPlugin/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { QuadtreeRect } from '../../utils/quadtree';
|
||||
|
||||
/** Diameters in CSS pixels. `min`/`max` bound the area scale when a size column is mapped. */
|
||||
export interface ScatterPointSize {
|
||||
fixed: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCATTER_POINT_SIZE: ScatterPointSize = {
|
||||
fixed: 6,
|
||||
min: 4,
|
||||
max: 24,
|
||||
};
|
||||
|
||||
/** CSS pixels around a point's disc that still register as a hover. */
|
||||
export const DEFAULT_HOVER_TOLERANCE_PX = 3;
|
||||
|
||||
export interface ScatterPluginOptions {
|
||||
pointSize?: ScatterPointSize;
|
||||
hoverTolerance?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One faceted series: parallel columns, one point per index. Sizes are in the
|
||||
* caller's units and mapped to `pointSize` at draw time; `null` draws at `fixed`.
|
||||
*/
|
||||
export type ScatterSeriesData = [
|
||||
xs: number[],
|
||||
ys: number[],
|
||||
sizes?: Array<number | null>,
|
||||
];
|
||||
|
||||
/** Mode-2 data: series 0 is uPlot's x placeholder and carries nothing. */
|
||||
export type ScatterChartData = [null, ...ScatterSeriesData[]];
|
||||
|
||||
/** A drawn point's disc, in canvas pixels relative to the plot area. */
|
||||
export interface ScatterHit extends QuadtreeRect {
|
||||
seriesIndex: number;
|
||||
dataIndex: number;
|
||||
}
|
||||
|
||||
export interface ScatterChannel {
|
||||
label: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/** What each visual channel plots, for the tooltip and axes. */
|
||||
export interface ScatterChannels {
|
||||
x: ScatterChannel;
|
||||
y: ScatterChannel;
|
||||
size?: ScatterChannel;
|
||||
}
|
||||
|
||||
export interface ScatterPointLabel {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
70
frontend/src/lib/uPlotV2/utils/__tests__/quadtree.test.ts
Normal file
70
frontend/src/lib/uPlotV2/utils/__tests__/quadtree.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Quadtree, QuadtreeRect } from '../quadtree';
|
||||
|
||||
interface Item extends QuadtreeRect {
|
||||
id: number;
|
||||
}
|
||||
|
||||
function collect(
|
||||
tree: Quadtree<Item>,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
): Set<number> {
|
||||
const ids = new Set<number>();
|
||||
tree.get(x, y, w, h, (item) => ids.add(item.id));
|
||||
return ids;
|
||||
}
|
||||
|
||||
describe('Quadtree', () => {
|
||||
it('returns items in the queried region and not those far from it', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
|
||||
tree.add({ id: 2, x: 80, y: 80, w: 5, h: 5 });
|
||||
|
||||
// Below the split threshold every item is visited; callers refine the hit.
|
||||
expect(collect(tree, 9, 9, 8, 8)).toStrictEqual(new Set([1, 2]));
|
||||
});
|
||||
|
||||
it('splits past the object limit and still finds every item', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
const total = 50;
|
||||
for (let id = 0; id < total; id++) {
|
||||
tree.add({ id, x: (id % 10) * 10, y: Math.floor(id / 10) * 10, w: 4, h: 4 });
|
||||
}
|
||||
|
||||
expect(collect(tree, 0, 0, 100, 100).size).toBe(total);
|
||||
});
|
||||
|
||||
it('after a split, a query in one quadrant skips items confined to another', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
for (let id = 0; id < 20; id++) {
|
||||
// All in the north-west quadrant.
|
||||
tree.add({ id, x: 1 + id, y: 1, w: 2, h: 2 });
|
||||
}
|
||||
tree.add({ id: 99, x: 90, y: 90, w: 2, h: 2 });
|
||||
|
||||
const northWest = collect(tree, 0, 0, 10, 10);
|
||||
expect(northWest.has(99)).toBe(false);
|
||||
expect(collect(tree, 85, 85, 10, 10).has(99)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports an item straddling the midline from either side', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
for (let id = 0; id < 20; id++) {
|
||||
tree.add({ id, x: 1, y: 1 + id, w: 2, h: 2 });
|
||||
}
|
||||
tree.add({ id: 99, x: 48, y: 48, w: 4, h: 4 });
|
||||
|
||||
expect(collect(tree, 40, 40, 5, 5).has(99)).toBe(true);
|
||||
expect(collect(tree, 55, 55, 5, 5).has(99)).toBe(true);
|
||||
});
|
||||
|
||||
it('clear empties the tree', () => {
|
||||
const tree = new Quadtree<Item>(0, 0, 100, 100);
|
||||
tree.add({ id: 1, x: 10, y: 10, w: 5, h: 5 });
|
||||
tree.clear();
|
||||
|
||||
expect(collect(tree, 0, 0, 100, 100).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -195,3 +195,40 @@ describe('scale utils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('symmetric log scale', () => {
|
||||
it('maps to uPlot arcsinh with the given linear threshold', () => {
|
||||
expect(
|
||||
scaleUtils.getDistributionConfig({
|
||||
time: false,
|
||||
distr: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 0.01,
|
||||
}),
|
||||
).toStrictEqual({ distr: 4, log: 10, asinh: 0.01 });
|
||||
|
||||
expect(
|
||||
scaleUtils.getDistributionConfig({
|
||||
time: false,
|
||||
distr: DistributionType.SymmetricLog,
|
||||
}).asinh,
|
||||
).toBe(scaleUtils.DEFAULT_ASINH_THRESHOLD);
|
||||
});
|
||||
|
||||
it('ranges a distr 4 scale through uPlot.rangeAsinh', () => {
|
||||
const rangeAsinh = jest.fn(() => [-10, 1000] as uPlot.Range.MinMax);
|
||||
Object.assign(uPlot, { rangeAsinh });
|
||||
|
||||
const rangeFn = scaleUtils.createRangeFunction({
|
||||
rangeConfig: {} as uPlot.Range.Config,
|
||||
hardMinOnly: false,
|
||||
hardMaxOnly: false,
|
||||
hasFixedRange: false,
|
||||
min: null,
|
||||
max: null,
|
||||
});
|
||||
const u = { scales: { y: { distr: 4, log: 10 } } } as unknown as uPlot;
|
||||
|
||||
expect(rangeFn(u, -3, 700, 'y')).toStrictEqual([-10, 1000]);
|
||||
expect(rangeAsinh).toHaveBeenCalledWith(-3, 700, 10, true);
|
||||
});
|
||||
});
|
||||
|
||||
110
frontend/src/lib/uPlotV2/utils/quadtree.ts
Normal file
110
frontend/src/lib/uPlotV2/utils/quadtree.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
export interface QuadtreeRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
const MAX_OBJECTS = 10;
|
||||
const MAX_LEVELS = 4;
|
||||
|
||||
/**
|
||||
* Spatial index over axis-aligned rectangles, for answering "what is under the
|
||||
* cursor" on charts whose marks have no shared x order to binary-search. An item
|
||||
* straddling a quadrant boundary lives in every quadrant it touches, so `get` can
|
||||
* report it more than once.
|
||||
*/
|
||||
export class Quadtree<T extends QuadtreeRect = QuadtreeRect> {
|
||||
private items: T[] = [];
|
||||
private quadrants: Quadtree<T>[] | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly x: number,
|
||||
private readonly y: number,
|
||||
private readonly w: number,
|
||||
private readonly h: number,
|
||||
private readonly level = 0,
|
||||
) {}
|
||||
|
||||
add(item: T): void {
|
||||
if (this.quadrants) {
|
||||
this.forEachQuadrant(item, (quadrant) => quadrant.add(item));
|
||||
return;
|
||||
}
|
||||
|
||||
this.items.push(item);
|
||||
|
||||
if (this.items.length > MAX_OBJECTS && this.level < MAX_LEVELS) {
|
||||
this.split();
|
||||
const items = this.items;
|
||||
this.items = [];
|
||||
for (const existing of items) {
|
||||
this.forEachQuadrant(existing, (quadrant) => quadrant.add(existing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Visits every item whose quadrant overlaps the rectangle; callers refine the test. */
|
||||
get(
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
visit: (item: T) => void,
|
||||
): void {
|
||||
for (const item of this.items) {
|
||||
visit(item);
|
||||
}
|
||||
if (this.quadrants) {
|
||||
this.forEachQuadrant({ x, y, w, h }, (quadrant) =>
|
||||
quadrant.get(x, y, w, h, visit),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.items = [];
|
||||
this.quadrants = null;
|
||||
}
|
||||
|
||||
private split(): void {
|
||||
const w = this.w / 2;
|
||||
const h = this.h / 2;
|
||||
const level = this.level + 1;
|
||||
// North-east, north-west, south-west, south-east.
|
||||
this.quadrants = [
|
||||
new Quadtree<T>(this.x + w, this.y, w, h, level),
|
||||
new Quadtree<T>(this.x, this.y, w, h, level),
|
||||
new Quadtree<T>(this.x, this.y + h, w, h, level),
|
||||
new Quadtree<T>(this.x + w, this.y + h, w, h, level),
|
||||
];
|
||||
}
|
||||
|
||||
private forEachQuadrant(
|
||||
rect: QuadtreeRect,
|
||||
visit: (quadrant: Quadtree<T>) => void,
|
||||
): void {
|
||||
if (!this.quadrants) {
|
||||
return;
|
||||
}
|
||||
const midX = this.x + this.w / 2;
|
||||
const midY = this.y + this.h / 2;
|
||||
const startsNorth = rect.y < midY;
|
||||
const startsWest = rect.x < midX;
|
||||
const endsEast = rect.x + rect.w > midX;
|
||||
const endsSouth = rect.y + rect.h > midY;
|
||||
|
||||
if (startsNorth && endsEast) {
|
||||
visit(this.quadrants[0]);
|
||||
}
|
||||
if (startsWest && startsNorth) {
|
||||
visit(this.quadrants[1]);
|
||||
}
|
||||
if (startsWest && endsSouth) {
|
||||
visit(this.quadrants[2]);
|
||||
}
|
||||
if (endsEast && endsSouth) {
|
||||
visit(this.quadrants[3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,18 +58,23 @@ function normalizeLogLimit(
|
||||
return logBase ** exp;
|
||||
}
|
||||
|
||||
export const DEFAULT_ASINH_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* Returns uPlot scale distribution options for the Y axis.
|
||||
* Time (X) scale gets no distr/log; Y scale gets distr 1 (linear) or 3 (log) and log base 2 or 10.
|
||||
* Returns uPlot scale distribution options for a value axis.
|
||||
* Time scales get no distr/log; value scales get distr 1 (linear), 3 (log) or
|
||||
* 4 (arcsinh, uPlot's symmetric log) and log base 2 or 10.
|
||||
*/
|
||||
export function getDistributionConfig({
|
||||
time,
|
||||
distr,
|
||||
logBase,
|
||||
asinhThreshold,
|
||||
}: {
|
||||
time: ScaleProps['time'];
|
||||
distr?: DistributionType;
|
||||
logBase?: number;
|
||||
asinhThreshold?: number;
|
||||
}): Partial<Scale> {
|
||||
if (time) {
|
||||
return {};
|
||||
@@ -77,6 +82,14 @@ export function getDistributionConfig({
|
||||
|
||||
const resolvedLogBase = (logBase ?? 10) === 2 ? 2 : 10;
|
||||
|
||||
if (distr === DistributionType.SymmetricLog) {
|
||||
return {
|
||||
distr: 4,
|
||||
log: resolvedLogBase,
|
||||
asinh: asinhThreshold ?? DEFAULT_ASINH_THRESHOLD,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
distr: distr === DistributionType.Logarithmic ? 3 : 1,
|
||||
log: resolvedLogBase,
|
||||
@@ -197,6 +210,33 @@ function getLogScaleRange(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the arcsinh-scale range using uPlot.rangeAsinh, which pads to whole
|
||||
* magnitudes on either side of zero and pins an edge that sits exactly on zero.
|
||||
*/
|
||||
function getAsinhScaleRange(
|
||||
minMax: Range.MinMax,
|
||||
params: RangeFunctionParams,
|
||||
dataMin: number | null,
|
||||
dataMax: number | null,
|
||||
logBase?: uPlot.Scale['log'],
|
||||
): Range.MinMax {
|
||||
const { min, max } = params;
|
||||
const resolvedMin = min ?? dataMin;
|
||||
const resolvedMax = max ?? dataMax;
|
||||
|
||||
if (resolvedMin == null || resolvedMax == null) {
|
||||
return minMax;
|
||||
}
|
||||
|
||||
return uPlot.rangeAsinh(
|
||||
resolvedMin,
|
||||
resolvedMax,
|
||||
(logBase ?? 10) as 2 | 10,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snaps log-scale [min, max] to exact powers of logBase (nearest magnitude below/above).
|
||||
* If min and max would be equal after snapping, max is increased by one magnitude so the range is valid.
|
||||
@@ -299,6 +339,8 @@ export function createRangeFunction(
|
||||
minMax = getLogScaleRange(minMax, params, dataMin, dataMax, logBase);
|
||||
const logFn = scale.log === 2 ? Math.log2 : Math.log10;
|
||||
minMax = adjustLogRange(minMax, (logBase ?? 10) as number, logFn);
|
||||
} else if (scale.distr === 4) {
|
||||
minMax = getAsinhScaleRange(minMax, params, dataMin, dataMax, logBase);
|
||||
}
|
||||
|
||||
minMax = applyHardLimits(minMax, params, scale.distr ?? 1);
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import type { Threshold } from 'lib/uPlotV2/hooks/types';
|
||||
import type { ScatterPointLabel } from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
|
||||
import Scatter from './Scatter';
|
||||
import {
|
||||
buildScatterConfig,
|
||||
prepareScatterChartData,
|
||||
ScatterSeries,
|
||||
} from './utils';
|
||||
|
||||
const SERVICES = [
|
||||
'frontend',
|
||||
'cart',
|
||||
'checkout',
|
||||
'payment',
|
||||
'shipping',
|
||||
'currency',
|
||||
'email',
|
||||
'recommendation',
|
||||
'ads',
|
||||
'product-catalog',
|
||||
];
|
||||
|
||||
type Shape = 'spread' | 'single' | 'sameX';
|
||||
|
||||
interface ScatterStoryProps {
|
||||
groups: number;
|
||||
pointsPerGroup: number;
|
||||
/** Adds an error-count size column. */
|
||||
sized: boolean;
|
||||
xLog: boolean;
|
||||
yLog: boolean;
|
||||
/** Zeroes a share of y values, which forces the symmetric log. */
|
||||
withZeros: boolean;
|
||||
shape: Shape;
|
||||
thresholds: boolean;
|
||||
pointSize: number;
|
||||
/** 0–1. */
|
||||
fillOpacity: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Deterministic, so a story renders the same points on every run. */
|
||||
function createRng(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return (): number => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
function buildSeries({
|
||||
groups,
|
||||
pointsPerGroup,
|
||||
sized,
|
||||
withZeros,
|
||||
shape,
|
||||
}: ScatterStoryProps): ScatterSeries[] {
|
||||
const rng = createRng(42);
|
||||
return Array.from({ length: groups }, (_, groupIndex) => {
|
||||
const label = SERVICES[groupIndex % SERVICES.length];
|
||||
// Each service sits in its own throughput/latency band, so groups are telling
|
||||
// apart rather than one cloud.
|
||||
const baseThroughput = 20 * 2 ** (groupIndex % 5);
|
||||
const baseLatency = 40 + 60 * (groupIndex % 4);
|
||||
|
||||
const count = shape === 'single' ? 1 : pointsPerGroup;
|
||||
const xs: number[] = [];
|
||||
const ys: number[] = [];
|
||||
const sizes: Array<number | null> = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const throughput =
|
||||
shape === 'sameX' ? baseThroughput : baseThroughput * (0.5 + rng() * 1.5);
|
||||
// Latency grows with load, plus noise; the odd outlier keeps the axis honest.
|
||||
const outlier = rng() < 0.03 ? 4 + rng() * 6 : 1;
|
||||
let latency =
|
||||
baseLatency *
|
||||
(0.8 + (throughput / baseThroughput) * 0.4 + rng() * 0.3) *
|
||||
outlier;
|
||||
if (withZeros && rng() < 0.2) {
|
||||
latency = 0;
|
||||
}
|
||||
xs.push(Number(throughput.toFixed(2)));
|
||||
ys.push(Number(latency.toFixed(2)));
|
||||
sizes.push(rng() < 0.1 ? null : Math.round(rng() * rng() * 500));
|
||||
}
|
||||
|
||||
return sized ? { label, xs, ys, sizes } : { label, xs, ys };
|
||||
});
|
||||
}
|
||||
|
||||
const THRESHOLDS: Threshold[] = [
|
||||
{
|
||||
thresholdValue: 300,
|
||||
thresholdUnit: 'ms',
|
||||
thresholdColor: '#E5484D',
|
||||
thresholdLabel: 'p99 SLO',
|
||||
},
|
||||
];
|
||||
|
||||
function ScatterStory(props: ScatterStoryProps): JSX.Element {
|
||||
const {
|
||||
xLog,
|
||||
yLog,
|
||||
sized,
|
||||
thresholds,
|
||||
pointSize,
|
||||
fillOpacity,
|
||||
width,
|
||||
height,
|
||||
} = props;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [drawMs, setDrawMs] = useState<number | null>(null);
|
||||
|
||||
const series = useMemo(() => buildSeries(props), [props]);
|
||||
const pointCount = series.reduce((sum, entry) => sum + entry.xs.length, 0);
|
||||
const drawLabel = drawMs === null ? '—' : `${drawMs.toFixed(1)} ms`;
|
||||
|
||||
const config = useMemo(() => {
|
||||
const builder = buildScatterConfig({
|
||||
id: 'scatter-story',
|
||||
series,
|
||||
isDarkMode,
|
||||
x: { unit: 'reqps', isLogScale: xLog },
|
||||
y: { unit: 'ms', isLogScale: yLog },
|
||||
pointSize: { fixed: pointSize, min: 4, max: pointSize * 4 },
|
||||
fillOpacity,
|
||||
thresholds: thresholds ? THRESHOLDS : undefined,
|
||||
});
|
||||
let started = 0;
|
||||
builder.addHook('drawClear', (): void => {
|
||||
started = performance.now();
|
||||
});
|
||||
builder.addHook('draw', (): void => {
|
||||
setDrawMs(performance.now() - started);
|
||||
});
|
||||
return builder;
|
||||
}, [series, isDarkMode, xLog, yLog, pointSize, fillOpacity, thresholds]);
|
||||
|
||||
const data = useMemo(() => prepareScatterChartData(series), [series]);
|
||||
|
||||
const resolvePointLabels = (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
): ScatterPointLabel[] => [
|
||||
{ key: 'service.name', value: series[seriesIndex - 1]?.label ?? '' },
|
||||
{
|
||||
key: 'k8s.pod.name',
|
||||
value: `pod-${dataIndex.toString().padStart(3, '0')}`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ width, padding: 16 }}>
|
||||
<Scatter
|
||||
config={config}
|
||||
data={data}
|
||||
width={width}
|
||||
height={height}
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
channels={{
|
||||
x: { label: 'Throughput', unit: 'reqps' },
|
||||
y: { label: 'p99 latency', unit: 'ms' },
|
||||
...(sized && { size: { label: 'Errors', unit: 'short' } }),
|
||||
}}
|
||||
resolvePointLabels={resolvePointLabels}
|
||||
canPinTooltip
|
||||
/>
|
||||
<p style={{ fontFamily: 'var(--font-mono)', fontSize: 12, opacity: 0.7 }}>
|
||||
{`${pointCount.toLocaleString()} points · last draw ${drawLabel}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Charts/Scatter',
|
||||
component: ScatterStory,
|
||||
parameters: { layout: 'padded' },
|
||||
args: {
|
||||
groups: 1,
|
||||
pointsPerGroup: 10,
|
||||
sized: false,
|
||||
xLog: false,
|
||||
yLog: false,
|
||||
withZeros: false,
|
||||
shape: 'spread',
|
||||
thresholds: false,
|
||||
pointSize: 6,
|
||||
fillOpacity: 0.7,
|
||||
width: 800,
|
||||
height: 420,
|
||||
},
|
||||
argTypes: {
|
||||
shape: { control: 'radio', options: ['spread', 'single', 'sameX'] },
|
||||
fillOpacity: { control: { type: 'range', min: 0, max: 1, step: 0.05 } },
|
||||
pointSize: { control: { type: 'range', min: 2, max: 16, step: 1 } },
|
||||
},
|
||||
} satisfies Meta<ScatterStoryProps>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<ScatterStoryProps>;
|
||||
|
||||
/** One service, ten points: axes formatted with units, hover picks the right point. */
|
||||
export const Basic: Story = {};
|
||||
|
||||
/** Five services, one legend entry each; toggling a row hides its points. */
|
||||
export const Grouped: Story = {
|
||||
args: { groups: 5, pointsPerGroup: 40 },
|
||||
};
|
||||
|
||||
/** Error count as disc area, between the configured min and max diameters. */
|
||||
export const Sized: Story = {
|
||||
args: { groups: 5, pointsPerGroup: 40, sized: true, pointSize: 5 },
|
||||
};
|
||||
|
||||
/** Log x; a fifth of the latencies are 0, so y falls back to the symmetric log. */
|
||||
export const LogAxes: Story = {
|
||||
args: {
|
||||
groups: 5,
|
||||
pointsPerGroup: 60,
|
||||
xLog: true,
|
||||
yLog: true,
|
||||
withZeros: true,
|
||||
},
|
||||
};
|
||||
|
||||
/** A single point still gets a padded range rather than an empty plot. */
|
||||
export const SinglePoint: Story = {
|
||||
args: { shape: 'single' },
|
||||
};
|
||||
|
||||
/** Fifty points sharing one x collide on nothing: no shared x array to align. */
|
||||
export const SameX: Story = {
|
||||
args: { groups: 3, pointsPerGroup: 50, shape: 'sameX' },
|
||||
};
|
||||
|
||||
/** Horizontal line with label on the y axis; the scale stretches to include it. */
|
||||
export const Thresholds: Story = {
|
||||
args: { groups: 3, pointsPerGroup: 40, thresholds: true },
|
||||
};
|
||||
|
||||
/** Perf harness: raise `pointsPerGroup` and read the draw time under the chart. */
|
||||
export const Dense: Story = {
|
||||
args: { groups: 5, pointsPerGroup: 1000, pointSize: 4, fillOpacity: 0.5 },
|
||||
};
|
||||
65
frontend/src/lib/visualization/charts/Scatter/Scatter.tsx
Normal file
65
frontend/src/lib/visualization/charts/Scatter/Scatter.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useCallback } from 'react';
|
||||
import ChartWrapper from 'lib/visualization/charts/ChartWrapper/ChartWrapper';
|
||||
import ScatterTooltip from 'lib/uPlotV2/components/Tooltip/ScatterTooltip';
|
||||
import {
|
||||
ScatterTooltipProps,
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ScatterChartProps } from 'lib/visualization/charts/types';
|
||||
|
||||
// Faceted uPlot reads series 1's facets at init, so a chart with no series cannot
|
||||
// mount; empty aligned data makes the shell show its no-data state instead.
|
||||
const EMPTY_ALIGNED_DATA: uPlot.AlignedData = [[]];
|
||||
|
||||
export default function Scatter(props: ScatterChartProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
customTooltip,
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
pinnedTooltipElement,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customTooltip) {
|
||||
return customTooltip(args);
|
||||
}
|
||||
const tooltipProps: ScatterTooltipProps = {
|
||||
...args,
|
||||
id: rest.config.getId(),
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
return <ScatterTooltip {...tooltipProps} />;
|
||||
},
|
||||
[
|
||||
customTooltip,
|
||||
channels,
|
||||
resolvePointLabels,
|
||||
rest.config,
|
||||
rest.decimalPrecision,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
);
|
||||
|
||||
const hasSeries = rest.data.length > 1;
|
||||
|
||||
return (
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
data={hasSeries ? rest.data : EMPTY_ALIGNED_DATA}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
{children}
|
||||
</ChartWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { DistributionType } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import {
|
||||
buildScatterConfig,
|
||||
prepareScatterChartData,
|
||||
resolveAxisDistribution,
|
||||
ScatterSeries,
|
||||
} from '../utils';
|
||||
|
||||
jest.mock('lib/visualization/panels/utils/legendVisibilityUtils', () => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}));
|
||||
|
||||
const SERIES: ScatterSeries[] = [
|
||||
{ label: 'cart', xs: [10, 20], ys: [100, 200], sizes: [1, null] },
|
||||
{ label: 'checkout', xs: [30], ys: [0] },
|
||||
];
|
||||
|
||||
describe('prepareScatterChartData', () => {
|
||||
it('lays series out as facets behind an empty x slot', () => {
|
||||
expect(prepareScatterChartData(SERIES)).toStrictEqual([
|
||||
null,
|
||||
[
|
||||
[10, 20],
|
||||
[100, 200],
|
||||
[1, null],
|
||||
],
|
||||
[[30], [0]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAxisDistribution', () => {
|
||||
it('is linear unless log is asked for', () => {
|
||||
expect(resolveAxisDistribution([0, 1], false)).toStrictEqual({
|
||||
distribution: DistributionType.Linear,
|
||||
});
|
||||
});
|
||||
|
||||
it('is a plain log when every value is positive', () => {
|
||||
expect(resolveAxisDistribution([1, 100], true)).toStrictEqual({
|
||||
distribution: DistributionType.Logarithmic,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to a symmetric log around the smallest magnitude when zero is present', () => {
|
||||
expect(resolveAxisDistribution([0, 0.05, 300], true)).toStrictEqual({
|
||||
distribution: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 0.01,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a unit threshold when nothing is positive', () => {
|
||||
expect(resolveAxisDistribution([0, -5], true)).toStrictEqual({
|
||||
distribution: DistributionType.SymmetricLog,
|
||||
asinhThreshold: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildScatterConfig', () => {
|
||||
const build = (
|
||||
overrides: Partial<Parameters<typeof buildScatterConfig>[0]> = {},
|
||||
): ReturnType<typeof buildScatterConfig> =>
|
||||
buildScatterConfig({
|
||||
id: 'scatter',
|
||||
series: SERIES,
|
||||
isDarkMode: true,
|
||||
x: { unit: 'reqps' },
|
||||
y: { unit: 'ms', isLogScale: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('emits a faceted plot with two value scales', () => {
|
||||
const config = build().getConfig();
|
||||
|
||||
expect(config.mode).toBe(2);
|
||||
expect(config.scales?.x).toMatchObject({ time: false, distr: 1 });
|
||||
// The y column has a 0, so log becomes the symmetric variant.
|
||||
expect(config.scales?.y).toMatchObject({ time: false, distr: 4 });
|
||||
});
|
||||
|
||||
it('draws one faceted series per group with the plugin path builder', () => {
|
||||
const config = build().getConfig();
|
||||
const [, cart, checkout] = config.series ?? [];
|
||||
|
||||
expect(config.series).toHaveLength(3);
|
||||
expect(cart).toMatchObject({
|
||||
label: 'cart',
|
||||
facets: [
|
||||
{ scale: 'x', auto: true },
|
||||
{ scale: 'y', auto: true },
|
||||
],
|
||||
});
|
||||
expect(typeof cart?.paths).toBe('function');
|
||||
expect(cart?.paths).toBe(checkout?.paths);
|
||||
expect(cart?.points?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('formats both axes with their units', () => {
|
||||
const config = build().getConfig();
|
||||
const [xAxis, yAxis] = config.axes ?? [];
|
||||
|
||||
expect(xAxis).toMatchObject({ scale: 'x', side: 2, space: 90 });
|
||||
expect(yAxis).toMatchObject({ scale: 'y', side: 3 });
|
||||
expect(typeof xAxis?.values).toBe('function');
|
||||
expect(typeof yAxis?.values).toBe('function');
|
||||
});
|
||||
|
||||
it('registers a y threshold draw hook when thresholds are given', () => {
|
||||
const config = build({
|
||||
thresholds: [{ thresholdValue: 300, thresholdUnit: 'ms' }],
|
||||
}).getConfig();
|
||||
|
||||
expect(config.hooks?.draw).toHaveLength(1);
|
||||
expect(build().getConfig().hooks?.draw).toBeUndefined();
|
||||
});
|
||||
});
|
||||
209
frontend/src/lib/visualization/charts/Scatter/utils.ts
Normal file
209
frontend/src/lib/visualization/charts/Scatter/utils.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
DistributionType,
|
||||
DrawStyle,
|
||||
SelectionPreferencesSource,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { Threshold } from 'lib/uPlotV2/hooks/types';
|
||||
import {
|
||||
applyScatterPlugin,
|
||||
createScatterPlugin,
|
||||
SCATTER_FACETS,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/scatterPlugin';
|
||||
import {
|
||||
DEFAULT_SCATTER_POINT_SIZE,
|
||||
ScatterChartData,
|
||||
ScatterPointSize,
|
||||
ScatterSeriesData,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
/** Circle outline; the fill carries the colour. */
|
||||
const POINT_STROKE_WIDTH = 1;
|
||||
|
||||
/** Unit-suffixed x labels are wider than uPlot's 50px default assumes. */
|
||||
const X_AXIS_TICK_SPACE_PX = 90;
|
||||
const X_AXIS_END_LABEL_PADDING_PX = 40;
|
||||
|
||||
export interface ScatterSeries {
|
||||
/** Group label, as the legend names it. */
|
||||
label: string;
|
||||
xs: number[];
|
||||
ys: number[];
|
||||
/** Optional third channel, in the caller's units. */
|
||||
sizes?: Array<number | null>;
|
||||
}
|
||||
|
||||
export interface ScatterAxisOptions {
|
||||
unit?: string;
|
||||
softMin?: number | null;
|
||||
softMax?: number | null;
|
||||
isLogScale?: boolean;
|
||||
}
|
||||
|
||||
export interface BuildScatterConfigArgs {
|
||||
id: string;
|
||||
series: ScatterSeries[];
|
||||
isDarkMode: boolean;
|
||||
x: ScatterAxisOptions;
|
||||
y: ScatterAxisOptions;
|
||||
pointSize?: ScatterPointSize;
|
||||
/** 0–1. */
|
||||
fillOpacity?: number;
|
||||
colorMapping?: Record<string, string>;
|
||||
/** Drawn on the y axis. */
|
||||
thresholds?: Threshold[];
|
||||
decimalPrecision?: PrecisionOption;
|
||||
selectionPreferencesSource?: SelectionPreferencesSource;
|
||||
shouldSaveSelectionPreference?: boolean;
|
||||
}
|
||||
|
||||
/** `[null, [xs, ys, sizes?], …]`: uPlot's faceted layout, series 0 empty. */
|
||||
export function prepareScatterChartData(
|
||||
series: ScatterSeries[],
|
||||
): uPlot.AlignedData {
|
||||
const data: ScatterChartData = [
|
||||
null,
|
||||
...series.map(
|
||||
(entry): ScatterSeriesData =>
|
||||
entry.sizes ? [entry.xs, entry.ys, entry.sizes] : [entry.xs, entry.ys],
|
||||
),
|
||||
];
|
||||
return data as unknown as uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export interface AxisDistribution {
|
||||
distribution: DistributionType;
|
||||
asinhThreshold?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A log axis needs every value above zero; a rate that is sometimes 0 would drop
|
||||
* those points. Zero or negatives switch to a symmetric log whose linear band
|
||||
* ends at the smallest non-zero magnitude, so nothing is lost and the small
|
||||
* values still spread out.
|
||||
*/
|
||||
export function resolveAxisDistribution(
|
||||
values: number[],
|
||||
isLogScale?: boolean,
|
||||
): AxisDistribution {
|
||||
if (!isLogScale) {
|
||||
return { distribution: DistributionType.Linear };
|
||||
}
|
||||
let minPositive = Infinity;
|
||||
let needsSymmetric = false;
|
||||
for (const value of values) {
|
||||
if (!Number.isFinite(value)) {
|
||||
continue;
|
||||
}
|
||||
if (value <= 0) {
|
||||
needsSymmetric = true;
|
||||
} else {
|
||||
minPositive = Math.min(minPositive, value);
|
||||
}
|
||||
}
|
||||
if (!needsSymmetric) {
|
||||
return { distribution: DistributionType.Logarithmic };
|
||||
}
|
||||
const asinhThreshold = Number.isFinite(minPositive)
|
||||
? 10 ** Math.floor(Math.log10(minPositive))
|
||||
: 1;
|
||||
return { distribution: DistributionType.SymmetricLog, asinhThreshold };
|
||||
}
|
||||
|
||||
export function buildScatterConfig({
|
||||
id,
|
||||
series,
|
||||
isDarkMode,
|
||||
x,
|
||||
y,
|
||||
pointSize = DEFAULT_SCATTER_POINT_SIZE,
|
||||
fillOpacity,
|
||||
colorMapping = {},
|
||||
thresholds,
|
||||
decimalPrecision,
|
||||
selectionPreferencesSource,
|
||||
shouldSaveSelectionPreference,
|
||||
}: BuildScatterConfigArgs): UPlotConfigBuilder {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id,
|
||||
selectionPreferencesSource,
|
||||
shouldSaveSelectionPreference,
|
||||
});
|
||||
|
||||
const plugin = createScatterPlugin({ pointSize });
|
||||
applyScatterPlugin(builder, plugin);
|
||||
// The last x label is centred on the plot's right edge; room for its unit.
|
||||
builder.setPadding([16, X_AXIS_END_LABEL_PADDING_PX, 8, 8]);
|
||||
|
||||
const xDistribution = resolveAxisDistribution(
|
||||
series.flatMap((entry) => entry.xs),
|
||||
x.isLogScale,
|
||||
);
|
||||
const yDistribution = resolveAxisDistribution(
|
||||
series.flatMap((entry) => entry.ys),
|
||||
y.isLogScale,
|
||||
);
|
||||
|
||||
const yThresholds =
|
||||
thresholds && thresholds.length > 0
|
||||
? { scaleKey: 'y', thresholds, yAxisUnit: y.unit }
|
||||
: undefined;
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: false,
|
||||
softMin: x.softMin ?? undefined,
|
||||
softMax: x.softMax ?? undefined,
|
||||
...xDistribution,
|
||||
});
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
softMin: y.softMin ?? undefined,
|
||||
softMax: y.softMax ?? undefined,
|
||||
thresholds: yThresholds,
|
||||
...yDistribution,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
isTimeAxis: false,
|
||||
yAxisUnit: x.unit ?? '',
|
||||
decimalPrecision,
|
||||
isLogScale: xDistribution.distribution !== DistributionType.Linear,
|
||||
space: X_AXIS_TICK_SPACE_PX,
|
||||
});
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit: y.unit ?? '',
|
||||
decimalPrecision,
|
||||
isLogScale: yDistribution.distribution !== DistributionType.Linear,
|
||||
});
|
||||
|
||||
series.forEach((entry) => {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label: entry.label,
|
||||
colorMapping,
|
||||
drawStyle: DrawStyle.Scatter,
|
||||
pathBuilder: plugin.pathBuilder,
|
||||
facets: SCATTER_FACETS,
|
||||
lineWidth: POINT_STROKE_WIDTH,
|
||||
pointSize: pointSize.fixed,
|
||||
fillOpacity,
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
if (yThresholds) {
|
||||
builder.addThresholds(yThresholds);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type {
|
||||
ScatterChannels,
|
||||
ScatterPointLabel,
|
||||
} from 'lib/uPlotV2/plugins/ScatterPlugin/types';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
@@ -74,6 +78,15 @@ export interface HistogramChartProps extends ChartWrapperProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
/** `data` is mode-2 (`prepareScatterChartData`); `config` comes from `buildScatterConfig`. */
|
||||
export interface ScatterChartProps extends ChartWrapperProps {
|
||||
channels: ScatterChannels;
|
||||
resolvePointLabels?: (
|
||||
seriesIndex: number,
|
||||
dataIndex: number,
|
||||
) => ScatterPointLabel[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -98,17 +98,30 @@ export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
},
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
QuickfiltertypesSourceDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { VIEWS } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
@@ -24,7 +25,10 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
fieldValuesResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
|
||||
|
||||
import {
|
||||
@@ -317,6 +321,21 @@ export const apiMonitoringMocks = defineStoryMocks({
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json((req) =>
|
||||
fieldKeysResponse(
|
||||
groupByAttributeKeys(req.url.searchParams.get('searchText') ?? '').map(
|
||||
({ key }) => key,
|
||||
),
|
||||
{
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.attribute,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -59,6 +59,35 @@ export const PortDomain: Story = {
|
||||
/** The page fetches before it renders a filter, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const openQuickFiltersSettings = async (): Promise<void> => {
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(() => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
expect(settings).toBeEnabled();
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/**
|
||||
* The quick-filter panel has no test id of its own, and it only mounts once the
|
||||
* workspace's filters have answered.
|
||||
@@ -143,3 +172,24 @@ export const NoExternalCalls: Story = {
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/** The editable quick-filter settings panel. */
|
||||
export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
@@ -146,11 +146,39 @@ export const Failed: Story = {
|
||||
parameters: { allowConsoleErrors: true },
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/** The editable quick-filter settings panel. */
|
||||
export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** A quick-filter value selected against the LLM span query. */
|
||||
export const QuickFilterSelected: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
|
||||
@@ -166,18 +166,31 @@ export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
},
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, screen, userEvent, waitFor } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -18,6 +19,7 @@ const pageStory = storyMocks(meterMocks, { layout: 'app' });
|
||||
*/
|
||||
const meta = {
|
||||
title: 'Pages/Metering/Cost Meter',
|
||||
tags: ['play'],
|
||||
component: MeterExplorerPage,
|
||||
...pageStory,
|
||||
parameters: { ...pageStory.parameters },
|
||||
@@ -27,6 +29,38 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<MeterArgs>;
|
||||
|
||||
/** The page fetches before it renders its filters, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const openQuickFiltersSettings = async (): Promise<void> => {
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(() => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
expect(settings).toBeEnabled();
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/**
|
||||
* The Meter tab over the last day: what the workspace ingested in total, then
|
||||
* the hourly count and size of log records, of spans, and the metric datapoints
|
||||
@@ -88,3 +122,26 @@ export const ExplorerWithoutQuickFilters: Story = {
|
||||
export const ViewsEmpty: Story = {
|
||||
args: { tab: 'views', savedViews: 0 },
|
||||
};
|
||||
|
||||
/** The editable quick-filter settings panel, which lives on the Explorer tab. */
|
||||
export const QuickFiltersSettings: Story = {
|
||||
args: { tab: 'explorer' },
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
args: { tab: 'explorer' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { tab: 'explorer', banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
@@ -116,16 +116,29 @@ export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
},
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
@@ -145,6 +145,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityDevelopment,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -173,6 +174,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityDevelopment,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -199,6 +201,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityDevelopment,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -226,6 +229,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityDevelopment,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -253,6 +257,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityDevelopment,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -281,6 +286,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityDevelopment,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
@@ -308,6 +314,7 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
Stability: handler.StabilityDevelopment,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
|
||||
75
pkg/http/handler/handler_test.go
Normal file
75
pkg/http/handler/handler_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/swaggest/openapi-go"
|
||||
"github.com/swaggest/openapi-go/openapi3"
|
||||
)
|
||||
|
||||
type bespokeOpenAPIHandler struct{}
|
||||
|
||||
func (bespokeOpenAPIHandler) ServeHTTP(http.ResponseWriter, *http.Request) {}
|
||||
|
||||
func (bespokeOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
opCtx.SetID("Bespoke")
|
||||
opCtx.AddRespStructure(nil, openapi.WithHTTPStatus(http.StatusOK))
|
||||
}
|
||||
|
||||
func (bespokeOpenAPIHandler) ResourceDefs() []ResourceDef { return nil }
|
||||
|
||||
func TestAttachStabilities(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
router.Handle("/development", New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: "Development", SuccessStatusCode: http.StatusOK, Stability: StabilityDevelopment})).Methods(http.MethodGet)
|
||||
router.Handle("/beta/{id}", New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: "Beta", SuccessStatusCode: http.StatusOK, Stability: StabilityBeta})).Methods(http.MethodPut)
|
||||
router.Handle("/unset", New(func(http.ResponseWriter, *http.Request) {}, OpenAPIDef{ID: "Unset", SuccessStatusCode: http.StatusOK})).Methods(http.MethodGet)
|
||||
router.Handle("/bespoke", bespokeOpenAPIHandler{}).Methods(http.MethodGet)
|
||||
|
||||
reflector := openapi3.NewReflector()
|
||||
collector := NewOpenAPICollector(reflector)
|
||||
require.NoError(t, router.Walk(collector.Walker))
|
||||
collector.AttachStabilities(reflector.Spec)
|
||||
|
||||
testCases := []struct {
|
||||
subtestName string
|
||||
path string
|
||||
method string
|
||||
expectedExtensionValue any
|
||||
}{
|
||||
{
|
||||
subtestName: "development handler",
|
||||
path: "/development",
|
||||
method: "get",
|
||||
expectedExtensionValue: "development",
|
||||
},
|
||||
{
|
||||
subtestName: "beta handler with path parameter",
|
||||
path: "/beta/{id}",
|
||||
method: "put",
|
||||
expectedExtensionValue: "beta",
|
||||
},
|
||||
{
|
||||
subtestName: "unset handler defaults to alpha",
|
||||
path: "/unset",
|
||||
method: "get",
|
||||
expectedExtensionValue: "alpha",
|
||||
},
|
||||
{
|
||||
subtestName: "handler built outside New defaults to alpha",
|
||||
path: "/bespoke",
|
||||
method: "get",
|
||||
expectedExtensionValue: "alpha",
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.subtestName, func(t *testing.T) {
|
||||
operation := reflector.Spec.Paths.MapOfPathItemValues[testCase.path].MapOfOperationValues[testCase.method]
|
||||
assert.Equal(t, testCase.expectedExtensionValue, operation.MapOfAnything["x-signoz-stability"])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,37 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
openapigo "github.com/swaggest/openapi-go"
|
||||
"github.com/swaggest/openapi-go/openapi3"
|
||||
"github.com/swaggest/rest/openapi"
|
||||
)
|
||||
|
||||
const signozStabilityKey string = "x-signoz-stability"
|
||||
|
||||
var (
|
||||
StabilityDevelopment = Stability{valuer.NewString("development")}
|
||||
StabilityAlpha = Stability{valuer.NewString("alpha")}
|
||||
StabilityBeta = Stability{valuer.NewString("beta")}
|
||||
StabilityStable = Stability{valuer.NewString("stable")}
|
||||
)
|
||||
|
||||
// Stability is emitted as the x-signoz-stability extension on every operation; unset means alpha.
|
||||
type Stability struct{ valuer.String }
|
||||
|
||||
func (stability Stability) StringValue() string {
|
||||
if stability.IsZero() {
|
||||
return StabilityAlpha.String.StringValue()
|
||||
}
|
||||
|
||||
return stability.String.StringValue()
|
||||
}
|
||||
|
||||
// OpenAPIExample is a named example for an OpenAPI operation.
|
||||
type OpenAPIExample struct {
|
||||
Name string
|
||||
@@ -32,6 +55,7 @@ type OpenAPIDef struct {
|
||||
SuccessStatusCode int
|
||||
ErrorStatusCodes []int
|
||||
Deprecated bool
|
||||
Stability Stability
|
||||
SecuritySchemes []OpenAPISecurityScheme
|
||||
}
|
||||
|
||||
@@ -42,14 +66,16 @@ type OpenAPISecurityScheme struct {
|
||||
|
||||
// OpenAPICollector is a collector for OpenAPI operations.
|
||||
type OpenAPICollector struct {
|
||||
collector *openapi.Collector
|
||||
collector *openapi.Collector
|
||||
stabilities map[operationKey]Stability
|
||||
}
|
||||
|
||||
func NewOpenAPICollector(reflector openapigo.Reflector) *OpenAPICollector {
|
||||
c := openapi.NewCollector(reflector)
|
||||
|
||||
return &OpenAPICollector{
|
||||
collector: c,
|
||||
collector: c,
|
||||
stabilities: make(map[operationKey]Stability),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +103,9 @@ func (c *OpenAPICollector) Walker(route *mux.Route, _ *mux.Router, _ []*mux.Rout
|
||||
if err := c.collector.CollectOperation(method, path, c.collect(method, path, handler.ServeOpenAPI)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.recordStability(method, path, httpHandler); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -84,6 +113,17 @@ func (c *OpenAPICollector) Walker(route *mux.Route, _ *mux.Router, _ []*mux.Rout
|
||||
return nil
|
||||
}
|
||||
|
||||
// AttachStabilities stamps every operation in spec, so handlers built outside New
|
||||
// carry the unset stability rather than none.
|
||||
func (c *OpenAPICollector) AttachStabilities(spec *openapi3.Spec) {
|
||||
for path, pathItem := range spec.Paths.MapOfPathItemValues {
|
||||
for method, operation := range pathItem.MapOfOperationValues {
|
||||
operation.WithMapOfAnythingItem(signozStabilityKey, c.stabilities[operationKey{method: method, path: path}].StringValue())
|
||||
pathItem.MapOfOperationValues[method] = operation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OpenAPICollector) collect(method string, path string, serveOpenAPIFunc ServeOpenAPIFunc) func(oc openapigo.OperationContext) error {
|
||||
return func(oc openapigo.OperationContext) error {
|
||||
// Serve the OpenAPI documentation for the handler
|
||||
@@ -117,3 +157,23 @@ func (c *OpenAPICollector) collect(method string, path string, serveOpenAPIFunc
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OpenAPICollector) recordStability(method string, path string, httpHandler http.Handler) error {
|
||||
generic, ok := httpHandler.(*handler)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanMethod, cleanPath, _, err := openapigo.SanitizeMethodPath(method, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.stabilities[operationKey{method: cleanMethod, path: cleanPath}] = generic.openAPIDef.Stability
|
||||
return nil
|
||||
}
|
||||
|
||||
type operationKey struct {
|
||||
method string
|
||||
path string
|
||||
}
|
||||
|
||||
@@ -94,10 +94,10 @@ func ExistsExpression(columns []*schema.Column, key *telemetrytypes.TelemetryFie
|
||||
|
||||
switch valueType := column.Type.(schema.MapColumnType).ValueType; valueType.GetType() {
|
||||
case schema.ColumnTypeEnumString, schema.ColumnTypeEnumBool, schema.ColumnTypeEnumFloat64:
|
||||
leftOperand := fmt.Sprintf("mapContains(%s, %s)", column.Name, clickhousesql.StringLiteral(key.Name))
|
||||
if key.Materialized {
|
||||
leftOperand = telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key)
|
||||
return telemetrytypes.FieldKeyToMaterializedExistsCondition(key, exists), nil
|
||||
}
|
||||
leftOperand := fmt.Sprintf("mapContains(%s, %s)", column.Name, clickhousesql.StringLiteral(key.Name))
|
||||
if exists {
|
||||
return leftOperand, nil
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ func (openapi *OpenAPI) CreateAndWrite(path string) error {
|
||||
}
|
||||
|
||||
attachDiscriminators(openapi.reflector.Spec)
|
||||
openapi.collector.AttachStabilities(openapi.reflector.Spec)
|
||||
|
||||
// The library's MarshalYAML does a JSON round-trip that converts all numbers
|
||||
// to float64, causing large integers (e.g. epoch millisecond timestamps) to
|
||||
|
||||
@@ -237,13 +237,13 @@ func TestBuild_FullSQL_TraceList_MaterializedColumns(t *testing.T) {
|
||||
assertSQLEqual(t, `
|
||||
WITH matched AS (
|
||||
SELECT trace_id,
|
||||
maxIf(timestamp, (attribute_string_gen_ai$$request$$model_exists OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time
|
||||
maxIf(timestamp, (attribute_string_gen_ai$$request$$model_exists = true OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND ((attribute_string_gen_ai$$request$$model_exists OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name')))
|
||||
AND ((attribute_string_gen_ai$$request$$model_exists = true OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name')))
|
||||
GROUP BY trace_id
|
||||
ORDER BY last_activity_time DESC, trace_id DESC
|
||||
LIMIT 20
|
||||
@@ -268,16 +268,16 @@ SELECT trace_id,
|
||||
count() AS span_count,
|
||||
anyIf(name, parent_span_id = '') AS root_span_name,
|
||||
any(multiIf(resource.service.name IS NOT NULL, resource.service.name::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS service.name,
|
||||
countIf(attribute_string_gen_ai$$request$$model_exists) AS llm_call_count,
|
||||
countIf(attribute_string_gen_ai$$request$$model_exists = true) AS llm_call_count,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.tool.name')) AS tool_call_count,
|
||||
uniqIf(multiIf(mapContains(attributes_string, 'gen_ai.tool.name'), attributes_string['gen_ai.tool.name'], NULL), mapContains(attributes_string, 'gen_ai.tool.name')) AS distinct_tool_count,
|
||||
sum(multiIf(attribute_number_gen_ai$$usage$$input_tokens_exists, toFloat64(attribute_number_gen_ai$$usage$$input_tokens), NULL)) AS input_tokens,
|
||||
sum(multiIf(attribute_number_gen_ai$$usage$$input_tokens_exists = true, toFloat64(attribute_number_gen_ai$$usage$$input_tokens), NULL)) AS input_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens,
|
||||
coalesce(sum(multiIf(attribute_number_gen_ai$$usage$$input_tokens_exists, toFloat64(attribute_number_gen_ai$$usage$$input_tokens), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens,
|
||||
coalesce(sum(multiIf(attribute_number_gen_ai$$usage$$input_tokens_exists = true, toFloat64(attribute_number_gen_ai$$usage$$input_tokens), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens,
|
||||
sum(multiIf(mapContains(attributes_number, 'signoz.gen_ai.usage.tokens.cost'), toFloat64(attributes_number['signoz.gen_ai.usage.tokens.cost']), NULL)) AS estimated_total_cost,
|
||||
maxIf(duration_nano, attribute_string_gen_ai$$request$$model_exists) AS max_llm_duration_nano,
|
||||
maxIf(duration_nano, attribute_string_gen_ai$$request$$model_exists = true) AS max_llm_duration_nano,
|
||||
countIf(has_error = true) AS error_count,
|
||||
maxIf(timestamp, (attribute_string_gen_ai$$request$$model_exists OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time,
|
||||
maxIf(timestamp, (attribute_string_gen_ai$$request$$model_exists = true OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AS last_activity_time,
|
||||
argMinIf(multiIf(mapContains(attributes_string, 'gen_ai.input.messages'), attributes_string['gen_ai.input.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.input.messages')) AS input,
|
||||
argMaxIf(multiIf(mapContains(attributes_string, 'gen_ai.output.messages'), attributes_string['gen_ai.output.messages'], NULL), timestamp, mapContains(attributes_string, 'gen_ai.output.messages')) AS output
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
Limit: 100,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$principal$$id` = ? AND `attribute_string_signoz$$audit$$principal$$id_exists`) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$principal$$id` = ? AND `attribute_string_signoz$$audit$$principal$$id_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"019a-1234-abcd-5678", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
|
||||
},
|
||||
},
|
||||
@@ -109,7 +109,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
Limit: 100,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists`) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
|
||||
},
|
||||
},
|
||||
@@ -143,7 +143,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
Limit: 100,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists`) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_audit.distributed_logs_resource WHERE (simpleJSONExtractString(labels, 'signoz.audit.resource.kind') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"dashboard", "%signoz.audit.resource.kind%", "%signoz.audit.resource.kind\":\"dashboard%", uint64(1747945619), uint64(1747983448), "delete", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
|
||||
},
|
||||
},
|
||||
@@ -160,7 +160,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
Limit: 100,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$principal$$type` = ? AND `attribute_string_signoz$$audit$$principal$$type_exists`) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, event_name, attributes_string, attributes_number, attributes_bool, resource, scope_string FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$principal$$type` = ? AND `attribute_string_signoz$$audit$$principal$$type_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"service_account", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 100},
|
||||
},
|
||||
},
|
||||
@@ -180,7 +180,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT count() AS __result_0 FROM signoz_audit.distributed_logs WHERE ((`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists`) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists`)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
|
||||
Query: "SELECT count() AS __result_0 FROM signoz_audit.distributed_logs WHERE ((`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND (`attribute_string_signoz$$audit$$action` = ? AND `attribute_string_signoz$$audit$$action_exists` = true)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
|
||||
Args: []any{"failure", "update", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
@@ -204,7 +204,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
Limit: 5,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(`attribute_string_signoz$$audit$$principal$$email_exists`, `attribute_string_signoz$$audit$$principal$$email`, NULL)) AS `signoz.audit.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists`) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `signoz.audit.principal.email` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$principal$$email_exists`, `attribute_string_signoz$$audit$$principal$$email`, NULL)) AS `signoz.audit.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists`) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`signoz.audit.principal.email`) GLOBAL IN (SELECT `signoz.audit.principal.email` FROM __limit_cte) GROUP BY ts, `signoz.audit.principal.email`",
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(`attribute_string_signoz$$audit$$principal$$email_exists` = true, `attribute_string_signoz$$audit$$principal$$email`, NULL)) AS `signoz.audit.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `signoz.audit.principal.email` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 60 SECOND) AS ts, toString(multiIf(`attribute_string_signoz$$audit$$principal$$email_exists` = true, `attribute_string_signoz$$audit$$principal$$email`, NULL)) AS `signoz.audit.principal.email`, count() AS __result_0 FROM signoz_audit.distributed_logs WHERE (`attribute_string_signoz$$audit$$outcome` = ? AND `attribute_string_signoz$$audit$$outcome_exists` = true) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`signoz.audit.principal.email`) GLOBAL IN (SELECT `signoz.audit.principal.email` FROM __limit_cte) GROUP BY ts, `signoz.audit.principal.email`",
|
||||
Args: []any{"failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 5, "failure", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(`attribute_string_materialized$$key$$name_exists`, `attribute_string_materialized$$key$$name`, NULL)) AS `__GROUP_BY_KEY_0_materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_materialized.key.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(`attribute_string_materialized$$key$$name_exists`, `attribute_string_materialized$$key$$name`, NULL)) AS `__GROUP_BY_KEY_0_materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_materialized.key.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_materialized.key.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_materialized.key.name`",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(`attribute_string_materialized$$key$$name_exists` = true, `attribute_string_materialized$$key$$name`, NULL)) AS `__GROUP_BY_KEY_0_materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_materialized.key.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, toString(multiIf(`attribute_string_materialized$$key$$name_exists` = true, `attribute_string_materialized$$key$$name`, NULL)) AS `__GROUP_BY_KEY_0_materialized.key.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_materialized.key.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_materialized.key.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_materialized.key.name`",
|
||||
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1705397400), uint64(1705485600), "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600), 10, "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
},
|
||||
@@ -203,7 +203,7 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists`) OR (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists`)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts",
|
||||
Query: "SELECT toStartOfInterval(fromUnixTimestamp64Nano(timestamp), INTERVAL 30 SECOND) AS ts, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists` = true) OR (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists` = true)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY ts",
|
||||
Args: []any{"redis.*", "memcached", "1705399200000000000", uint64(1705397400), "1705485600000000000", uint64(1705485600)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -300,7 +300,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY multiIf(`attribute_string_materialized$$key$$name_exists`, `attribute_string_materialized$$key$$name`, NULL) desc LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY multiIf(`attribute_string_materialized$$key$$name_exists` = true, `attribute_string_materialized$$key$$name`, NULL) desc LIMIT ?",
|
||||
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -328,7 +328,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists`) OR (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists`)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY multiIf(`attribute_string_materialized$$key$$name_exists`, `attribute_string_materialized$$key$$name`, NULL) desc LIMIT ?",
|
||||
Query: "SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists` = true) OR (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists` = true)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY multiIf(`attribute_string_materialized$$key$$name_exists` = true, `attribute_string_materialized$$key$$name`, NULL) desc LIMIT ?",
|
||||
Args: []any{"redis.*", "memcached", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -442,7 +442,7 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND match(LOWER(body), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY multiIf(`attribute_string_materialized$$key$$name_exists`, `attribute_string_materialized$$key$$name`, NULL) desc LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND match(LOWER(body), LOWER(?)) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY multiIf(`attribute_string_materialized$$key$$name_exists` = true, `attribute_string_materialized$$key$$name`, NULL) desc LIMIT ?",
|
||||
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "hello", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -666,7 +666,7 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND LOWER(body) LIKE LOWER(?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY multiIf(`attribute_string_materialized$$key$$name_exists`, `attribute_string_materialized$$key$$name`, NULL) desc LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp, id, trace_id, span_id, trace_flags, severity_text, severity_number, scope_name, scope_version, body, attributes_string, attributes_number, attributes_bool, resources_string, scope_string FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND LOWER(body) LIKE LOWER(?) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY multiIf(`attribute_string_materialized$$key$$name_exists` = true, `attribute_string_materialized$$key$$name`, NULL) desc LIMIT ?",
|
||||
Args: []any{"cartservice", "%service.name%", "%service.name\":\"cartservice%", uint64(1747945619), uint64(1747983448), "%error%", "1747947419000000000", uint64(1747945619), "1747983448000000000", uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
|
||||
@@ -129,7 +129,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists`) OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists`) OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`",
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists` = true) OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists` = true) OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`",
|
||||
Args: []any{"redis-manual", "redis-manual", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "redis-manual", "redis-manual", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -268,7 +268,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists`, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists`, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = true, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = true, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -307,7 +307,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists`, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY `__GROUP_BY_KEY_0_service.name` desc LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists`, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name` ORDER BY `__GROUP_BY_KEY_0_service.name` desc, ts desc",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), __limit_cte AS (SELECT toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = true, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY `__GROUP_BY_KEY_0_service.name` desc LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL)) AS `__GROUP_BY_KEY_0_service.name`, sum(multiIf(`attribute_number_cart$$items_count_exists` = true, toFloat64(`attribute_number_cart$$items_count`), NULL)) AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name` ORDER BY `__GROUP_BY_KEY_0_service.name` desc, ts desc",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -552,7 +552,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `__SELECT_KEY_4_service.name`, duration_nano AS `__SELECT_KEY_5_duration_nano`, multiIf(`attribute_number_cart$$items_count_exists`, `attribute_number_cart$$items_count`, NULL) AS `__SELECT_KEY_6_cart.items_count` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `__SELECT_KEY_4_service.name`, duration_nano AS `__SELECT_KEY_5_duration_nano`, multiIf(`attribute_number_cart$$items_count_exists` = true, `attribute_number_cart$$items_count`, NULL) AS `__SELECT_KEY_6_cart.items_count` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -669,7 +669,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, resource_string_service$$name AS `__SELECT_KEY_4_serviceName`, duration_nano AS `__SELECT_KEY_5_durationNano`, http_method AS `__SELECT_KEY_6_httpMethod`, multiIf(`attribute_string_mixed$$materialization$$key_exists`, `attribute_string_mixed$$materialization$$key`, multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL) IS NOT NULL, multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL), NULL) AS `__SELECT_KEY_7_mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, resource_string_service$$name AS `__SELECT_KEY_4_serviceName`, duration_nano AS `__SELECT_KEY_5_durationNano`, http_method AS `__SELECT_KEY_6_httpMethod`, multiIf(`attribute_string_mixed$$materialization$$key_exists` = true, `attribute_string_mixed$$materialization$$key`, multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL) IS NOT NULL, multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL), NULL) AS `__SELECT_KEY_7_mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -714,7 +714,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, resource_string_service$$name AS `__SELECT_KEY_4_serviceName`, duration_nano AS `__SELECT_KEY_5_durationNano`, http_method AS `__SELECT_KEY_6_httpMethod`, multiIf(`attribute_string_mixed$$materialization$$key_exists`, `attribute_string_mixed$$materialization$$key`, NULL) AS `__SELECT_KEY_7_mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, name AS `__SELECT_KEY_3_name`, resource_string_service$$name AS `__SELECT_KEY_4_serviceName`, duration_nano AS `__SELECT_KEY_5_durationNano`, http_method AS `__SELECT_KEY_6_httpMethod`, multiIf(`attribute_string_mixed$$materialization$$key_exists` = true, `attribute_string_mixed$$materialization$$key`, NULL) AS `__SELECT_KEY_7_mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -1178,7 +1178,7 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __toe AS (SELECT trace_id FROM signoz_traces.distributed_signoz_index_v3 WHERE (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists`) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __toe_duration_sorted AS (SELECT trace_id, duration_nano, resource_string_service$$name as `service.name`, name FROM signoz_traces.distributed_signoz_index_v3 WHERE parent_span_id = '' AND trace_id GLOBAL IN __toe AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY duration_nano DESC LIMIT 1 BY trace_id) SELECT __toe_duration_sorted.`service.name` AS `service.name`, __toe_duration_sorted.name AS `name`, count() AS span_count, __toe_duration_sorted.duration_nano AS `duration_nano`, __toe_duration_sorted.trace_id AS `trace_id` FROM __toe INNER JOIN __toe_duration_sorted ON __toe.trace_id = __toe_duration_sorted.trace_id GROUP BY trace_id, duration_nano, name, `service.name` ORDER BY duration_nano DESC LIMIT 1 BY trace_id LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH __toe AS (SELECT trace_id FROM signoz_traces.distributed_signoz_index_v3 WHERE (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists` = true) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __toe_duration_sorted AS (SELECT trace_id, duration_nano, resource_string_service$$name as `service.name`, name FROM signoz_traces.distributed_signoz_index_v3 WHERE parent_span_id = '' AND trace_id GLOBAL IN __toe AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY duration_nano DESC LIMIT 1 BY trace_id) SELECT __toe_duration_sorted.`service.name` AS `service.name`, __toe_duration_sorted.name AS `name`, count() AS span_count, __toe_duration_sorted.duration_nano AS `duration_nano`, __toe_duration_sorted.trace_id AS `trace_id` FROM __toe INNER JOIN __toe_duration_sorted ON __toe.trace_id = __toe_duration_sorted.trace_id GROUP BY trace_id, duration_nano, name, `service.name` ORDER BY duration_nano DESC LIMIT 1 BY trace_id LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"redis-manual", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -1194,7 +1194,7 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __toe AS (SELECT trace_id FROM signoz_traces.distributed_signoz_index_v3 WHERE ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists`) OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __toe_duration_sorted AS (SELECT trace_id, duration_nano, resource_string_service$$name as `service.name`, name FROM signoz_traces.distributed_signoz_index_v3 WHERE parent_span_id = '' AND trace_id GLOBAL IN __toe AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY duration_nano DESC LIMIT 1 BY trace_id) SELECT __toe_duration_sorted.`service.name` AS `service.name`, __toe_duration_sorted.name AS `name`, count() AS span_count, __toe_duration_sorted.duration_nano AS `duration_nano`, __toe_duration_sorted.trace_id AS `trace_id` FROM __toe INNER JOIN __toe_duration_sorted ON __toe.trace_id = __toe_duration_sorted.trace_id GROUP BY trace_id, duration_nano, name, `service.name` ORDER BY duration_nano DESC LIMIT 1 BY trace_id LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH __toe AS (SELECT trace_id FROM signoz_traces.distributed_signoz_index_v3 WHERE ((match(`attribute_string_materialized$$key$$name`, ?) AND `attribute_string_materialized$$key$$name_exists` = true) OR multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = ?) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __toe_duration_sorted AS (SELECT trace_id, duration_nano, resource_string_service$$name as `service.name`, name FROM signoz_traces.distributed_signoz_index_v3 WHERE parent_span_id = '' AND trace_id GLOBAL IN __toe AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY duration_nano DESC LIMIT 1 BY trace_id) SELECT __toe_duration_sorted.`service.name` AS `service.name`, __toe_duration_sorted.name AS `name`, count() AS span_count, __toe_duration_sorted.duration_nano AS `duration_nano`, __toe_duration_sorted.trace_id AS `trace_id` FROM __toe INNER JOIN __toe_duration_sorted ON __toe.trace_id = __toe_duration_sorted.trace_id GROUP BY trace_id, duration_nano, name, `service.name` ORDER BY duration_nano DESC LIMIT 1 BY trace_id LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"redis-manual", "redis-manual", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
@@ -1240,7 +1240,7 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __toe AS (SELECT trace_id FROM signoz_traces.distributed_signoz_index_v3 WHERE (((name, resource_string_service$$name) GLOBAL IN (SELECT DISTINCT name, serviceName from signoz_traces.distributed_top_level_operations WHERE time >= toDateTime(1747947419))) AND parent_span_id != '' OR (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists`)) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __toe_duration_sorted AS (SELECT trace_id, duration_nano, resource_string_service$$name as `service.name`, name FROM signoz_traces.distributed_signoz_index_v3 WHERE parent_span_id = '' AND trace_id GLOBAL IN __toe AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY duration_nano DESC LIMIT 1 BY trace_id) SELECT __toe_duration_sorted.`service.name` AS `service.name`, __toe_duration_sorted.name AS `name`, count() AS span_count, __toe_duration_sorted.duration_nano AS `duration_nano`, __toe_duration_sorted.trace_id AS `trace_id` FROM __toe INNER JOIN __toe_duration_sorted ON __toe.trace_id = __toe_duration_sorted.trace_id GROUP BY trace_id, duration_nano, name, `service.name` ORDER BY duration_nano DESC LIMIT 1 BY trace_id LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Query: "WITH __toe AS (SELECT trace_id FROM signoz_traces.distributed_signoz_index_v3 WHERE (((name, resource_string_service$$name) GLOBAL IN (SELECT DISTINCT name, serviceName from signoz_traces.distributed_top_level_operations WHERE time >= toDateTime(1747947419))) AND parent_span_id != '' OR (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists` = true)) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __toe_duration_sorted AS (SELECT trace_id, duration_nano, resource_string_service$$name as `service.name`, name FROM signoz_traces.distributed_signoz_index_v3 WHERE parent_span_id = '' AND trace_id GLOBAL IN __toe AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY duration_nano DESC LIMIT 1 BY trace_id) SELECT __toe_duration_sorted.`service.name` AS `service.name`, __toe_duration_sorted.name AS `name`, count() AS span_count, __toe_duration_sorted.duration_nano AS `duration_nano`, __toe_duration_sorted.trace_id AS `trace_id` FROM __toe INNER JOIN __toe_duration_sorted ON __toe.trace_id = __toe_duration_sorted.trace_id GROUP BY trace_id, duration_nano, name, `service.name` ORDER BY duration_nano DESC LIMIT 1 BY trace_id LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"redis-manual", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
|
||||
@@ -461,7 +461,7 @@ func TestConditionFor(t *testing.T) {
|
||||
evolutions: mockEvolution,
|
||||
operator: qbtypes.FilterOperatorRegexp,
|
||||
value: "frontend-.*",
|
||||
expectedSQL: "WHERE (match(`resource_string_service$$name`, ?) AND `resource_string_service$$name_exists`)",
|
||||
expectedSQL: "WHERE (match(`resource_string_service$$name`, ?) AND `resource_string_service$$name_exists` = true)",
|
||||
expectedArgs: []any{"frontend-.*"},
|
||||
expectedError: nil,
|
||||
},
|
||||
|
||||
@@ -1596,7 +1596,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
category: "Materialized key",
|
||||
query: "materialized.key.name=\"test\"",
|
||||
shouldPass: true,
|
||||
expectedQuery: "WHERE (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists`)",
|
||||
expectedQuery: "WHERE (`attribute_string_materialized$$key$$name` = ? AND `attribute_string_materialized$$key$$name_exists` = true)",
|
||||
expectedArgs: []any{"test"},
|
||||
expectedErrorContains: "",
|
||||
},
|
||||
|
||||
@@ -182,7 +182,7 @@ func (m *storage) read(_ context.Context, q qbtypes.QueryInfo, key *telemetrytyp
|
||||
// a key could have been materialized, if so return the materialized column name
|
||||
if key.Materialized {
|
||||
exprs = append(exprs, telemetrytypes.FieldKeyToMaterializedColumnName(key))
|
||||
existExpr = append(existExpr, telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key))
|
||||
existExpr = append(existExpr, telemetrytypes.FieldKeyToMaterializedExistsCondition(key, true))
|
||||
} else {
|
||||
exprs = append(exprs, fmt.Sprintf("%s[%s]", columnName, clickhousesql.StringLiteral(key.Name)))
|
||||
existExpr = append(existExpr, fmt.Sprintf("mapContains(%s, %s)", columnName, clickhousesql.StringLiteral(key.Name)))
|
||||
|
||||
@@ -580,7 +580,7 @@ func TestFieldForWithMaterialized(t *testing.T) {
|
||||
name: "Multi evolution - both columns (JSON + materialized)",
|
||||
start: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||
end: time.Date(2024, 4, 2, 0, 0, 0, 0, time.UTC),
|
||||
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, `resource_string_service$$name_exists`, `resource_string_service$$name`, NULL)",
|
||||
expectedResult: "multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, `resource_string_service$$name_exists` = true, `resource_string_service$$name`, NULL)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ func (m *storage) resolveColumnExprs(
|
||||
// a key could have been materialized, if so return the materialized column name
|
||||
if key.Materialized {
|
||||
exprs = append(exprs, telemetrytypes.FieldKeyToMaterializedColumnName(key))
|
||||
existExprs = append(existExprs, telemetrytypes.FieldKeyToMaterializedColumnNameForExists(key))
|
||||
existExprs = append(existExprs, telemetrytypes.FieldKeyToMaterializedExistsCondition(key, true))
|
||||
} else {
|
||||
exprs = append(exprs, fmt.Sprintf("%s[%s]", columnName, clickhousesql.StringLiteral(key.Name)))
|
||||
existExprs = append(existExprs, fmt.Sprintf("mapContains(%s, %s)", columnName, clickhousesql.StringLiteral(key.Name)))
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
Materialized: true,
|
||||
Evolutions: mockEvolution,
|
||||
},
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`, `resource_string_deployment$$environment`, NULL)",
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists` = true, `resource_string_deployment$$environment`, NULL)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
@@ -228,7 +228,7 @@ func TestFieldForResourceWithEvolution(t *testing.T) {
|
||||
},
|
||||
tsStart: uint64(time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
tsEnd: uint64(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC).UnixNano()),
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`, `resource_string_deployment$$environment`, NULL)",
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists` = true, `resource_string_deployment$$environment`, NULL)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,12 @@ func FieldKeyToMaterializedColumnNameForExists(key *TelemetryFieldKey) string {
|
||||
))
|
||||
}
|
||||
|
||||
// FieldKeyToMaterializedExistsCondition compares the exists column explicitly: a bare bool
|
||||
// column defeats skip-index pruning across OR.
|
||||
func FieldKeyToMaterializedExistsCondition(key *TelemetryFieldKey, exists bool) string {
|
||||
return fmt.Sprintf("%s = %t", FieldKeyToMaterializedColumnNameForExists(key), exists)
|
||||
}
|
||||
|
||||
type TelemetryFieldValues struct {
|
||||
StringValues []string `json:"stringValues,omitempty"`
|
||||
BoolValues []bool `json:"boolValues,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user