Skip to content

Commit 9b8332e

Browse files
committed
Stacked bars for the versions
1 parent 6ac165a commit 9b8332e

File tree

5 files changed

+110
-31
lines changed

5 files changed

+110
-31
lines changed

apps/webapp/app/components/primitives/charts/ChartRoot.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export type ChartRootProps = {
4040
onViewAllLegendItems?: () => void;
4141
/** When true, constrains legend to max 50% height with scrolling */
4242
legendScrollable?: boolean;
43+
/** Additional className for the legend */
44+
legendClassName?: string;
4345
/** When true, chart fills its parent container height and distributes space between chart and legend */
4446
fillContainer?: boolean;
4547
/** Content rendered between the chart and the legend */
@@ -87,6 +89,7 @@ export function ChartRoot({
8789
legendValueFormatter,
8890
onViewAllLegendItems,
8991
legendScrollable = false,
92+
legendClassName,
9093
fillContainer = false,
9194
beforeLegend,
9295
children,
@@ -114,6 +117,7 @@ export function ChartRoot({
114117
legendValueFormatter={legendValueFormatter}
115118
onViewAllLegendItems={onViewAllLegendItems}
116119
legendScrollable={legendScrollable}
120+
legendClassName={legendClassName}
117121
fillContainer={fillContainer}
118122
beforeLegend={beforeLegend}
119123
>
@@ -133,6 +137,7 @@ type ChartRootInnerProps = {
133137
legendValueFormatter?: (value: number) => string;
134138
onViewAllLegendItems?: () => void;
135139
legendScrollable?: boolean;
140+
legendClassName?: string;
136141
fillContainer?: boolean;
137142
beforeLegend?: React.ReactNode;
138143
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
@@ -148,6 +153,7 @@ function ChartRootInner({
148153
legendValueFormatter,
149154
onViewAllLegendItems,
150155
legendScrollable = false,
156+
legendClassName,
151157
fillContainer = false,
152158
beforeLegend,
153159
children,
@@ -193,6 +199,7 @@ function ChartRootInner({
193199
valueFormatter={legendValueFormatter}
194200
onViewAllLegendItems={onViewAllLegendItems}
195201
scrollable={legendScrollable}
202+
className={legendClassName}
196203
/>
197204
)}
198205
</div>

apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export type ErrorGroupSummary = {
7474

7575
export type ErrorGroupOccurrences = Awaited<ReturnType<ErrorGroupPresenter["getOccurrences"]>>;
7676
export type ErrorGroupActivity = ErrorGroupOccurrences["data"];
77+
export type ErrorGroupActivityVersions = ErrorGroupOccurrences["versions"];
7778

7879
export class ErrorGroupPresenter extends BasePresenter {
7980
constructor(
@@ -144,8 +145,8 @@ export class ErrorGroupPresenter extends BasePresenter {
144145
}
145146

146147
/**
147-
* Returns bucketed occurrence counts for a single fingerprint over a time range.
148-
* Granularity is determined automatically from the range span.
148+
* Returns bucketed occurrence counts for a single fingerprint over a time range,
149+
* grouped by task_version for stacked charts.
149150
*/
150151
public async getOccurrences(
151152
organizationId: string,
@@ -156,12 +157,14 @@ export class ErrorGroupPresenter extends BasePresenter {
156157
to: Date,
157158
versions?: string[]
158159
): Promise<{
159-
data: Array<{ date: Date; count: number }>;
160+
data: Array<Record<string, number | Date>>;
161+
versions: string[];
160162
}> {
161163
const granularityMs = errorGroupGranularity.getTimeGranularityMs(from, to);
162164
const intervalExpr = msToClickHouseInterval(granularityMs);
163165

164-
const queryBuilder = this.logsClickhouse.errors.createOccurrencesQueryBuilder(intervalExpr);
166+
const queryBuilder =
167+
this.logsClickhouse.errors.createOccurrencesByVersionQueryBuilder(intervalExpr);
165168

166169
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
167170
queryBuilder.where("project_id = {projectId: String}", { projectId });
@@ -178,7 +181,7 @@ export class ErrorGroupPresenter extends BasePresenter {
178181
queryBuilder.where("task_version IN {versions: Array(String)}", { versions });
179182
}
180183

181-
queryBuilder.groupBy("error_fingerprint, bucket_epoch");
184+
queryBuilder.groupBy("error_fingerprint, task_version, bucket_epoch");
182185
queryBuilder.orderBy("bucket_epoch ASC");
183186

184187
const [queryError, records] = await queryBuilder.execute();
@@ -195,17 +198,27 @@ export class ErrorGroupPresenter extends BasePresenter {
195198
buckets.push(epoch);
196199
}
197200

198-
const byBucket = new Map<number, number>();
201+
// Collect distinct versions and index results by (epoch, version)
202+
const versionSet = new Set<string>();
203+
const byBucketVersion = new Map<string, number>();
199204
for (const row of records ?? []) {
200-
byBucket.set(row.bucket_epoch, (byBucket.get(row.bucket_epoch) ?? 0) + row.count);
205+
const version = row.task_version || "unknown";
206+
versionSet.add(version);
207+
const key = `${row.bucket_epoch}:${version}`;
208+
byBucketVersion.set(key, (byBucketVersion.get(key) ?? 0) + row.count);
201209
}
202210

203-
return {
204-
data: buckets.map((epoch) => ({
205-
date: new Date(epoch * 1000),
206-
count: byBucket.get(epoch) ?? 0,
207-
})),
208-
};
211+
const sortedVersions = sortVersionsDescending([...versionSet]);
212+
213+
const data = buckets.map((epoch) => {
214+
const point: Record<string, number | Date> = { date: new Date(epoch * 1000) };
215+
for (const version of sortedVersions) {
216+
point[version] = byBucketVersion.get(`${epoch}:${version}`) ?? 0;
217+
}
218+
return point;
219+
});
220+
221+
return { data, versions: sortedVersions };
209222
}
210223

211224
private async getSummary(

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
1414
import {
1515
ErrorGroupPresenter,
1616
type ErrorGroupActivity,
17+
type ErrorGroupActivityVersions,
1718
type ErrorGroupOccurrences,
1819
type ErrorGroupSummary,
1920
} from "~/presenters/v3/ErrorGroupPresenter.server";
@@ -43,10 +44,11 @@ import { useOrganization } from "~/hooks/useOrganizations";
4344
import { useProject } from "~/hooks/useProject";
4445
import { useEnvironment } from "~/hooks/useEnvironment";
4546
import { RunsIcon } from "~/assets/icons/RunsIcon";
46-
import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
47+
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
4748
import { useSearchParams } from "~/hooks/useSearchParam";
4849
import { CopyableText } from "~/components/primitives/CopyableText";
4950
import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter";
51+
import { getSeriesColor } from "~/components/code/chartColors";
5052

5153
export const meta: MetaFunction<typeof loader> = ({ data }) => {
5254
return [
@@ -121,7 +123,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
121123
time.to,
122124
versions.length > 0 ? versions : undefined
123125
)
124-
.catch(() => ({ data: [] as ErrorGroupActivity }));
126+
.catch(() => ({ data: [] as ErrorGroupActivity, versions: [] as string[] }));
125127

126128
return typeddefer({
127129
data: detailPromise,
@@ -334,13 +336,12 @@ function ErrorGroupDetail({
334336

335337
<Suspense fallback={<ActivityChartBlankState />}>
336338
<TypedAwait resolve={activity} errorElement={<ActivityChartBlankState />}>
337-
{(result) =>
338-
result.data.length > 0 ? (
339-
<ActivityChart activity={result.data} />
340-
) : (
341-
<ActivityChartBlankState />
342-
)
343-
}
339+
{(result) => {
340+
if (result.data.length > 0 && result.versions.length > 0) {
341+
return <ActivityChart activity={result.data} versions={result.versions} />;
342+
}
343+
return <ActivityChartBlankState />;
344+
}}
344345
</TypedAwait>
345346
</Suspense>
346347
</div>
@@ -402,14 +403,24 @@ function ErrorGroupDetail({
402403
);
403404
}
404405

405-
const activityChartConfig: ChartConfig = {
406-
count: {
407-
label: "Occurrences",
408-
color: "#6366F1",
409-
},
410-
};
406+
function ActivityChart({
407+
activity,
408+
versions,
409+
}: {
410+
activity: ErrorGroupActivity;
411+
versions: ErrorGroupActivityVersions;
412+
}) {
413+
const chartConfig = useMemo(() => {
414+
const cfg: ChartConfig = {};
415+
for (let i = 0; i < versions.length; i++) {
416+
cfg[versions[i]] = {
417+
label: versions[i],
418+
color: getSeriesColor(i),
419+
};
420+
}
421+
return cfg;
422+
}, [versions]);
411423

412-
function ActivityChart({ activity }: { activity: ErrorGroupActivity }) {
413424
const data = useMemo(
414425
() =>
415426
activity.map((d) => ({
@@ -463,13 +474,22 @@ function ActivityChart({ activity }: { activity: ErrorGroupActivity }) {
463474

464475
return (
465476
<Chart.Root
466-
config={activityChartConfig}
477+
config={chartConfig}
467478
data={data}
468479
dataKey="__timestamp"
469-
series={["count"]}
480+
series={versions}
470481
fillContainer
482+
showLegend={versions.length > 1}
483+
legendScrollable
484+
legendClassName="w-48 shrink-0 pt-0 max-h-full"
485+
className={
486+
versions.length > 1
487+
? "!flex-row gap-x-3 [&>div:first-child]:min-w-0"
488+
: undefined
489+
}
471490
>
472491
<Chart.Bar
492+
stackId="versions"
473493
xAxisProps={{
474494
tickFormatter: xAxisFormatter,
475495
ticks: midnightTicks,

internal-packages/clickhouse/src/errors.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,3 +314,39 @@ export function createErrorOccurrencesQueryBuilder(
314314
settings
315315
);
316316
}
317+
318+
export const ErrorOccurrencesByVersionQueryResult = z.object({
319+
error_fingerprint: z.string(),
320+
task_version: z.string(),
321+
bucket_epoch: z.number(),
322+
count: z.number(),
323+
});
324+
325+
export type ErrorOccurrencesByVersionQueryResult = z.infer<
326+
typeof ErrorOccurrencesByVersionQueryResult
327+
>;
328+
329+
/**
330+
* Creates a query builder for bucketed error occurrence counts grouped by task_version.
331+
* Used for stacked-by-version activity charts on the error detail page.
332+
*/
333+
export function createErrorOccurrencesByVersionQueryBuilder(
334+
ch: ClickhouseReader,
335+
intervalExpr: string,
336+
settings?: ClickHouseSettings
337+
): ClickhouseQueryBuilder<ErrorOccurrencesByVersionQueryResult> {
338+
return new ClickhouseQueryBuilder(
339+
"getErrorOccurrencesByVersion",
340+
`
341+
SELECT
342+
error_fingerprint,
343+
task_version,
344+
toUnixTimestamp(toStartOfInterval(minute, ${intervalExpr})) as bucket_epoch,
345+
sum(count) as count
346+
FROM trigger_dev.error_occurrences_v1
347+
`,
348+
ch,
349+
ErrorOccurrencesByVersionQueryResult,
350+
settings
351+
);
352+
}

internal-packages/clickhouse/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
getErrorHourlyOccurrences,
3535
getErrorOccurrencesListQueryBuilder,
3636
createErrorOccurrencesQueryBuilder,
37+
createErrorOccurrencesByVersionQueryBuilder,
3738
getErrorAffectedVersionsQueryBuilder,
3839
} from "./errors.js";
3940
export { msToClickHouseInterval } from "./intervals.js";
@@ -251,6 +252,8 @@ export class ClickHouse {
251252
occurrencesListQueryBuilder: getErrorOccurrencesListQueryBuilder(this.reader),
252253
createOccurrencesQueryBuilder: (intervalExpr: string) =>
253254
createErrorOccurrencesQueryBuilder(this.reader, intervalExpr),
255+
createOccurrencesByVersionQueryBuilder: (intervalExpr: string) =>
256+
createErrorOccurrencesByVersionQueryBuilder(this.reader, intervalExpr),
254257
};
255258
}
256259
}

0 commit comments

Comments
 (0)