Skip to content

Commit 441f06e

Browse files
authored
feat: trace stats chart (#1017)
* feat: add traces statistics chart * feat: fix comments * feat: update colors * feat: type array * feat: add colors * feat: remove max value, and response check * feat: update compact date range filter * feat: refactor * fix: comments * feat: parametrize * feat: fix comments * feat: unify component * feat: refactor absolute date range filter * feat: add validation * feat: fix realtime
1 parent 1fa686d commit 441f06e

File tree

21 files changed

+1254
-425
lines changed

21 files changed

+1254
-425
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { NextRequest } from "next/server";
2+
import { prettifyError, ZodError } from "zod/v4";
3+
4+
import { parseUrlParams } from "@/lib/actions/common/utils";
5+
import { getTraceStats, GetTraceStatsSchema } from "@/lib/actions/traces/stats";
6+
7+
export async function GET(req: NextRequest, props: { params: Promise<{ projectId: string }> }): Promise<Response> {
8+
const params = await props.params;
9+
const projectId = params.projectId;
10+
11+
const parseResult = parseUrlParams(req.nextUrl.searchParams, GetTraceStatsSchema.omit({ projectId: true }));
12+
13+
if (!parseResult.success) {
14+
return Response.json({ error: prettifyError(parseResult.error) }, { status: 400 });
15+
}
16+
17+
try {
18+
const result = await getTraceStats({ ...parseResult.data, projectId });
19+
return Response.json(result);
20+
} catch (error) {
21+
if (error instanceof ZodError) {
22+
return Response.json({ error: prettifyError(error) }, { status: 400 });
23+
}
24+
return Response.json(
25+
{ error: error instanceof Error ? error.message : "Failed to fetch trace stats." },
26+
{ status: 500 }
27+
);
28+
}
29+
}

frontend/app/globals.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@
4141
--color-destructive: hsl(var(--destructive));
4242
--color-destructive-foreground: hsl(var(--destructive-foreground));
4343

44+
--color-success-bright: hsl(var(--success-bright));
45+
--color-destructive-bright: hsl(var(--destructive-bright));
46+
4447
--color-muted: hsl(var(--muted));
4548
--color-muted-foreground: hsl(var(--muted-foreground));
4649

@@ -116,9 +119,11 @@
116119

117120
--destructive: 0 60% 50%;
118121
--destructive-foreground: 210 40% 98%;
122+
--destructive-bright: 0 72% 60%;
119123

120124
--success: 142.1 76.2% 36.3%;
121125
--success-foreground: 355.7 100% 97.3%;
126+
--success-bright: 158 64% 52%;
122127

123128
--border: 240 6% 18%;
124129
--input: 240 6% 18%;

frontend/components/chart-builder/charts/utils.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { scaleUtc } from "d3-scale";
12
import { format, isValid, parseISO } from "date-fns";
23
import { isNil } from "lodash";
34

@@ -16,27 +17,24 @@ export const chartColors = [
1617
"hsl(var(--chart-5))",
1718
];
1819

19-
const tryFormatAsDate = (value: any, formatPattern: string = "M/dd"): string => {
20-
const toUtcString = (str: string) => (str.includes("T") && !str.endsWith("Z") ? str + "Z" : str);
21-
22-
const parseValue = (val: any) =>
23-
val instanceof Date
24-
? val
25-
: typeof val === "string"
26-
? parseISO(toUtcString(val))
27-
: typeof val === "number"
28-
? new Date(val)
29-
: null;
30-
20+
const tryFormatAsDate = (value: string | number | Date, formatPattern: string = "M/dd"): string => {
3121
try {
32-
const date = parseValue(value);
22+
const date =
23+
value instanceof Date
24+
? value
25+
: typeof value === "string"
26+
? parseISO(value.includes("T") && !value.endsWith("Z") ? value + "Z" : value)
27+
: typeof value === "number"
28+
? new Date(value)
29+
: null;
30+
3331
return date && isValid(date) ? format(date, formatPattern) : String(value);
3432
} catch {
3533
return String(value);
3634
}
3735
};
3836

39-
const getOptimalDateFormat = (data: Record<string, any>[], dataKey: string): string => {
37+
const getOptimalDateFormat = (data: Record<string, unknown>[], dataKey: string): string => {
4038
try {
4139
const dates = data
4240
.map((row) => {
@@ -45,7 +43,7 @@ const getOptimalDateFormat = (data: Record<string, any>[], dataKey: string): str
4543
if (typeof value === "string" && value.includes("T")) {
4644
return parseISO(value);
4745
}
48-
return new Date(value);
46+
return new Date(value as string | number | Date);
4947
} catch {
5048
return null;
5149
}
@@ -69,15 +67,15 @@ const getOptimalDateFormat = (data: Record<string, any>[], dataKey: string): str
6967
}
7068
};
7169

72-
export const createAxisFormatter = (data: Record<string, any>[], dataKey: string) => {
70+
export const createAxisFormatter = (data: Record<string, unknown>[], dataKey: string) => {
7371
const dateFormat = getOptimalDateFormat(data, dataKey);
7472

75-
return (value: any) => {
73+
return (value: string | number | Date) => {
7674
if (typeof value === "number") {
7775
return numberFormatter.format(value);
7876
}
7977

80-
if (typeof value === "string") {
78+
if (typeof value === "string" || value instanceof Date) {
8179
const dateFormatted = tryFormatAsDate(value, dateFormat);
8280
if (dateFormatted !== value) {
8381
return dateFormatted;
@@ -88,6 +86,38 @@ export const createAxisFormatter = (data: Record<string, any>[], dataKey: string
8886
};
8987
};
9088

89+
export const selectNiceTicksFromData = (
90+
dataTimestamps: string[],
91+
targetTickCount: number = 8
92+
): { ticks: string[]; formatter: (value: string) => string } | null => {
93+
if (dataTimestamps.length === 0) return null;
94+
95+
const toUtc = (s: string) => (s.includes("T") && !s.endsWith("Z") ? s + "Z" : s);
96+
97+
const startDate = new Date(toUtc(dataTimestamps[0]));
98+
const endDate = new Date(toUtc(dataTimestamps[dataTimestamps.length - 1]));
99+
100+
if (!isValid(startDate) || !isValid(endDate)) return null;
101+
102+
const scale = scaleUtc().domain([startDate, endDate]);
103+
const idealTicks = scale.ticks(targetTickCount);
104+
const formatTick = scale.tickFormat();
105+
106+
const findClosestTimestamp = (targetTime: number) =>
107+
dataTimestamps.reduce((closest, current) => {
108+
const closestDiff = Math.abs(new Date(toUtc(closest)).getTime() - targetTime);
109+
const currentDiff = Math.abs(new Date(toUtc(current)).getTime() - targetTime);
110+
return currentDiff < closestDiff ? current : closest;
111+
});
112+
113+
const tickLabels = new Map(idealTicks.map((tick) => [findClosestTimestamp(tick.getTime()), formatTick(tick)]));
114+
115+
return {
116+
ticks: Array.from(tickLabels.keys()),
117+
formatter: (value: string) => tickLabels.get(value) || value,
118+
};
119+
};
120+
91121
export const generateChartConfig = (columns: string[]): ChartConfig =>
92122
columns.reduce((config, columnName, index) => {
93123
config[columnName] = {

frontend/components/traces/spans-table/columns.tsx

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { ColumnDef } from "@tanstack/react-table";
22
import { capitalize } from "lodash";
3-
import { Check, X } from "lucide-react";
43

54
import ClientTimestampFormatter from "@/components/client-timestamp-formatter";
65
import SpanTypeIcon, { createSpanTypeIcon } from "@/components/traces/span-type-icon";
@@ -10,7 +9,7 @@ import JsonTooltip from "@/components/ui/json-tooltip.tsx";
109
import Mono from "@/components/ui/mono";
1110
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
1211
import { SpanRow, SpanType } from "@/lib/traces/types";
13-
import { TIME_SECONDS_FORMAT } from "@/lib/utils";
12+
import { cn, TIME_SECONDS_FORMAT } from "@/lib/utils";
1413

1514
const format = new Intl.NumberFormat("en-US", {
1615
style: "currency",
@@ -95,18 +94,18 @@ export const filters: ColumnFilter[] = [
9594
export const columns: ColumnDef<SpanRow, any>[] = [
9695
{
9796
cell: (row) => (
98-
<div className="flex h-full justify-center items-center w-10">
99-
{row.getValue() === "error" ? (
100-
<X className="self-center text-destructive" size={18} />
101-
) : (
102-
<Check className="text-success" size={18} />
103-
)}
104-
</div>
97+
<div
98+
className={cn("min-h-6 w-1.5 rounded-[2.5px] bg-success-bright", {
99+
"bg-destructive-bright": row.getValue() === "error",
100+
"": row.getValue() === "info", // temporary color values
101+
"bg-yellow-400": row.getValue() === "warning", // temporary color values
102+
})}
103+
/>
105104
),
106105
accessorKey: "status",
107-
header: "Status",
106+
header: () => <div />,
108107
id: "status",
109-
size: 70,
108+
size: 40,
110109
},
111110
{
112111
cell: (row) => <Mono>{row.getValue()}</Mono>,
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { isNil } from "lodash";
2+
import React from "react";
3+
4+
import { chartConfig } from "@/components/traces/traces-chart/utils.ts";
5+
import { TracesStatsDataPoint } from "@/lib/actions/traces/stats.ts";
6+
7+
interface CustomBarProps {
8+
fill?: string;
9+
x?: number;
10+
y?: number;
11+
width?: number;
12+
height?: number;
13+
payload?: TracesStatsDataPoint;
14+
}
15+
16+
const MIN_BAR_HEIGHT = 3;
17+
18+
// custom shape for rounded bars
19+
const RoundedBar = (props: CustomBarProps) => {
20+
const { fill, x, y, width, height = 0, payload } = props;
21+
22+
if (isNil(x) || isNil(y) || isNil(width) || !fill || !payload) return <></>;
23+
24+
const isSuccess = fill === chartConfig.successCount.color;
25+
const hasSuccess = payload.successCount > 0;
26+
const hasError = payload.errorCount > 0;
27+
28+
if (isSuccess && !hasSuccess) return <></>;
29+
if (!isSuccess && !hasError) return <></>;
30+
31+
const hasBoth = hasSuccess && hasError;
32+
33+
const barHeight = height > 0 && height < MIN_BAR_HEIGHT ? MIN_BAR_HEIGHT : height;
34+
const barY = barHeight > height ? y - (barHeight - height) : y;
35+
36+
const radius = isSuccess ? (hasBoth ? [0, 0, 4, 4] : [4, 4, 4, 4]) : hasBoth ? [4, 4, 0, 0] : [4, 4, 4, 4];
37+
38+
const [tl, tr, br, bl] = radius;
39+
40+
return (
41+
<path
42+
d={`
43+
M ${x} ${barY + tl}
44+
Q ${x} ${barY}, ${x + tl} ${barY}
45+
L ${x + width - tr} ${barY}
46+
Q ${x + width} ${barY}, ${x + width} ${barY + tr}
47+
L ${x + width} ${barY + barHeight - br}
48+
Q ${x + width} ${barY + barHeight}, ${x + width - br} ${barY + barHeight}
49+
L ${x + bl} ${barY + barHeight}
50+
Q ${x} ${barY + barHeight}, ${x} ${barY + barHeight - bl}
51+
Z
52+
`}
53+
fill={fill}
54+
/>
55+
);
56+
};
57+
58+
export default RoundedBar;

0 commit comments

Comments
 (0)