Skip to content

Commit c97fe88

Browse files
committed
Time filtering on the error detail page
1 parent 9b463db commit c97fe88

File tree

6 files changed

+216
-37
lines changed

6 files changed

+216
-37
lines changed

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

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,34 @@ const errorGroupGranularity = new TimeGranularity([
1111
{ max: "Infinity", granularity: "30d" },
1212
]);
1313
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
14+
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
1415
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
1516
import { ServiceValidationError } from "~/v3/services/baseService.server";
1617
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
1718
import {
1819
NextRunListPresenter,
1920
type NextRunList,
2021
} from "~/presenters/v3/NextRunListPresenter.server";
22+
import { sortVersionsDescending } from "~/utils/semver";
2123

2224
export type ErrorGroupOptions = {
2325
userId?: string;
2426
projectId: string;
2527
fingerprint: string;
2628
runsPageSize?: number;
29+
period?: string;
30+
from?: number;
31+
to?: number;
2732
};
2833

2934
export const ErrorGroupOptionsSchema = z.object({
3035
userId: z.string().optional(),
3136
projectId: z.string(),
3237
fingerprint: z.string(),
3338
runsPageSize: z.number().int().positive().max(1000).optional(),
39+
period: z.string().optional(),
40+
from: z.number().int().nonnegative().optional(),
41+
to: z.number().int().nonnegative().optional(),
3442
});
3543

3644
const DEFAULT_RUNS_PAGE_SIZE = 25;
@@ -53,6 +61,7 @@ export type ErrorGroupSummary = {
5361
count: number;
5462
firstSeen: Date;
5563
lastSeen: Date;
64+
affectedVersions: string[];
5665
};
5766

5867
export type ErrorGroupOccurrences = Awaited<ReturnType<ErrorGroupPresenter["getOccurrences"]>>;
@@ -75,6 +84,9 @@ export class ErrorGroupPresenter extends BasePresenter {
7584
projectId,
7685
fingerprint,
7786
runsPageSize = DEFAULT_RUNS_PAGE_SIZE,
87+
period,
88+
from,
89+
to,
7890
}: ErrorGroupOptions
7991
) {
8092
const displayableEnvironment = await findDisplayableEnvironment(environmentId, userId);
@@ -83,19 +95,37 @@ export class ErrorGroupPresenter extends BasePresenter {
8395
throw new ServiceValidationError("No environment found");
8496
}
8597

86-
const [summary, runList] = await Promise.all([
98+
const time = timeFilterFromTo({
99+
period,
100+
from,
101+
to,
102+
defaultPeriod: "7d",
103+
});
104+
105+
const [summary, affectedVersions, runList] = await Promise.all([
87106
this.getSummary(organizationId, projectId, environmentId, fingerprint),
107+
this.getAffectedVersions(organizationId, projectId, environmentId, fingerprint),
88108
this.getRunList(organizationId, environmentId, {
89109
userId,
90110
projectId,
91111
fingerprint,
92112
pageSize: runsPageSize,
113+
from: time.from.getTime(),
114+
to: time.to.getTime(),
93115
}),
94116
]);
95117

118+
if (summary) {
119+
summary.affectedVersions = affectedVersions;
120+
}
121+
96122
return {
97123
errorGroup: summary,
98124
runList,
125+
filters: {
126+
from: time.from,
127+
to: time.to,
128+
},
99129
};
100130
}
101131

@@ -194,9 +224,40 @@ export class ErrorGroupPresenter extends BasePresenter {
194224
count: record.occurrence_count,
195225
firstSeen: parseClickHouseDateTime(record.first_seen),
196226
lastSeen: parseClickHouseDateTime(record.last_seen),
227+
affectedVersions: [],
197228
};
198229
}
199230

231+
/**
232+
* Returns the most recent distinct task_version values for an error fingerprint,
233+
* sorted by semantic version descending (newest first).
234+
* Queries error_occurrences_v1 where task_version is part of the ORDER BY key.
235+
*/
236+
private async getAffectedVersions(
237+
organizationId: string,
238+
projectId: string,
239+
environmentId: string,
240+
fingerprint: string
241+
): Promise<string[]> {
242+
const queryBuilder = this.logsClickhouse.errors.affectedVersionsQueryBuilder();
243+
244+
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
245+
queryBuilder.where("project_id = {projectId: String}", { projectId });
246+
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
247+
queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint });
248+
queryBuilder.where("task_version != ''");
249+
queryBuilder.limit(100);
250+
251+
const [queryError, records] = await queryBuilder.execute();
252+
253+
if (queryError || !records) {
254+
return [];
255+
}
256+
257+
const versions = records.map((r) => r.task_version).filter((v) => v.length > 0);
258+
return sortVersionsDescending(versions).slice(0, 5);
259+
}
260+
200261
private async getRunList(
201262
organizationId: string,
202263
environmentId: string,
@@ -205,6 +266,8 @@ export class ErrorGroupPresenter extends BasePresenter {
205266
projectId: string;
206267
fingerprint: string;
207268
pageSize: number;
269+
from?: number;
270+
to?: number;
208271
}
209272
): Promise<NextRunList | undefined> {
210273
const runListPresenter = new NextRunListPresenter(this.replica, this.clickhouse);
@@ -214,6 +277,8 @@ export class ErrorGroupPresenter extends BasePresenter {
214277
projectId: options.projectId,
215278
errorFingerprint: options.fingerprint,
216279
pageSize: options.pageSize,
280+
from: options.from,
281+
to: options.to,
217282
});
218283

219284
if (result.runs.length === 0) {

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

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
2929
import { DateTime } from "~/components/primitives/DateTime";
3030
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
3131
import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound";
32+
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
33+
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
3234

3335
export const meta: MetaFunction<typeof loader> = ({ data }) => {
3436
return [
@@ -59,13 +61,23 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
5961
throw new Response("Environment not found", { status: 404 });
6062
}
6163

64+
const url = new URL(request.url);
65+
const period = url.searchParams.get("period") ?? undefined;
66+
const fromStr = url.searchParams.get("from");
67+
const toStr = url.searchParams.get("to");
68+
const from = fromStr ? parseInt(fromStr, 10) : undefined;
69+
const to = toStr ? parseInt(toStr, 10) : undefined;
70+
6271
const presenter = new ErrorGroupPresenter($replica, logsClickhouseClient, clickhouseClient);
6372

6473
const detailPromise = presenter
6574
.call(project.organizationId, environment.id, {
6675
userId,
6776
projectId: project.id,
6877
fingerprint,
78+
period,
79+
from,
80+
to,
6981
})
7082
.catch((error) => {
7183
if (error instanceof ServiceValidationError) {
@@ -74,17 +86,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
7486
throw error;
7587
});
7688

77-
const now = new Date();
78-
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
89+
const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d" });
7990

8091
const activityPromise = presenter
8192
.getOccurrences(
8293
project.organizationId,
8394
project.id,
8495
environment.id,
8596
fingerprint,
86-
sevenDaysAgo,
87-
now
97+
time.from,
98+
time.to
8899
)
89100
.catch(() => ({ data: [] as ErrorGroupActivity }));
90101

@@ -102,11 +113,25 @@ export default function Page() {
102113
const { data, activity, organizationSlug, projectParam, envParam, fingerprint } =
103114
useTypedLoaderData<typeof loader>();
104115

105-
const errorsPath = v3ErrorsPath(
106-
{ slug: organizationSlug },
107-
{ slug: projectParam },
108-
{ slug: envParam }
109-
);
116+
const location = useOptimisticLocation();
117+
const searchParams = new URLSearchParams(location.search);
118+
119+
const errorsPath = useMemo(() => {
120+
const base = v3ErrorsPath(
121+
{ slug: organizationSlug },
122+
{ slug: projectParam },
123+
{ slug: envParam }
124+
);
125+
const carry = new URLSearchParams();
126+
const period = searchParams.get("period");
127+
const from = searchParams.get("from");
128+
const to = searchParams.get("to");
129+
if (period) carry.set("period", period);
130+
if (from) carry.set("from", from);
131+
if (to) carry.set("to", to);
132+
const qs = carry.toString();
133+
return qs ? `${base}?${qs}` : base;
134+
}, [organizationSlug, projectParam, envParam, searchParams.toString()]);
110135

111136
return (
112137
<PageContainer>
@@ -203,6 +228,10 @@ function ErrorGroupDetail({
203228
<div className="border-b border-grid-bright p-4">
204229
<Header2 className="mb-4">{errorGroup.errorMessage}</Header2>
205230

231+
<div className="mb-4">
232+
<TimeFilter defaultPeriod="7d" labelName="Occurred" />
233+
</div>
234+
206235
<div className="grid grid-cols-3 gap-x-12 gap-y-1">
207236
<Property.Table>
208237
<Property.Item>
@@ -224,6 +253,16 @@ function ErrorGroupDetail({
224253
<Property.Label>Occurrences</Property.Label>
225254
<Property.Value>{formatNumberCompact(errorGroup.count)}</Property.Value>
226255
</Property.Item>
256+
{errorGroup.affectedVersions.length > 0 && (
257+
<Property.Item>
258+
<Property.Label>Affected versions</Property.Label>
259+
<Property.Value>
260+
<span className="font-mono text-xs">
261+
{errorGroup.affectedVersions.join(", ")}
262+
</span>
263+
</Property.Value>
264+
</Property.Item>
265+
)}
227266
</Property.Table>
228267

229268
<Property.Table>
@@ -241,22 +280,11 @@ function ErrorGroupDetail({
241280
</Property.Item>
242281
</Property.Table>
243282
</div>
244-
245-
{errorGroup.stackTrace && (
246-
<div className="mt-4 rounded-md bg-charcoal-900 p-4">
247-
<Paragraph variant="small" className="mb-2 font-semibold text-text-bright">
248-
Stack Trace
249-
</Paragraph>
250-
<pre className="overflow-x-auto text-xs text-text-dimmed">
251-
<code>{errorGroup.stackTrace}</code>
252-
</pre>
253-
</div>
254-
)}
255283
</div>
256284

257-
{/* Activity over past 7 days */}
285+
{/* Activity chart */}
258286
<div className="flex flex-col overflow-hidden border-b border-grid-bright px-4 py-3">
259-
<Header3 className="mb-2 shrink-0">Activity (past 7 days)</Header3>
287+
<Header3 className="mb-2 shrink-0">Activity</Header3>
260288
<Suspense fallback={<ActivityChartBlankState />}>
261289
<TypedAwait resolve={activity} errorElement={<ActivityChartBlankState />}>
262290
{(result) =>

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

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { XMarkIcon } from "@heroicons/react/20/solid";
22
import { Form, type MetaFunction } from "@remix-run/react";
33
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
44
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
5-
import { Suspense } from "react";
5+
import { Suspense, useMemo } from "react";
66
import {
77
Bar,
88
BarChart,
@@ -243,7 +243,11 @@ function FiltersBar({
243243
{list ? (
244244
<>
245245
<LogsTaskFilter possibleTasks={list.filters.possibleTasks} />
246-
<TimeFilter defaultPeriod={defaultPeriod} maxPeriodDays={retentionLimitDays} />
246+
<TimeFilter
247+
defaultPeriod={defaultPeriod}
248+
maxPeriodDays={retentionLimitDays}
249+
labelName="Occurred"
250+
/>
247251
<LogsSearchInput placeholder="Search errors..." />
248252
{hasFilters && (
249253
<Form className="h-6">
@@ -344,12 +348,26 @@ function ErrorGroupRow({
344348
projectParam: string;
345349
envParam: string;
346350
}) {
347-
const errorPath = v3ErrorPath(
348-
{ slug: organizationSlug },
349-
{ slug: projectParam },
350-
{ slug: envParam },
351-
{ fingerprint: errorGroup.fingerprint }
352-
);
351+
const location = useOptimisticLocation();
352+
const searchParams = new URLSearchParams(location.search);
353+
354+
const errorPath = useMemo(() => {
355+
const base = v3ErrorPath(
356+
{ slug: organizationSlug },
357+
{ slug: projectParam },
358+
{ slug: envParam },
359+
{ fingerprint: errorGroup.fingerprint }
360+
);
361+
const carry = new URLSearchParams();
362+
const period = searchParams.get("period");
363+
const from = searchParams.get("from");
364+
const to = searchParams.get("to");
365+
if (period) carry.set("period", period);
366+
if (from) carry.set("from", from);
367+
if (to) carry.set("to", to);
368+
const qs = carry.toString();
369+
return qs ? `${base}?${qs}` : base;
370+
}, [organizationSlug, projectParam, envParam, errorGroup.fingerprint, searchParams.toString()]);
353371

354372
const errorMessage = `${errorGroup.errorMessage}`;
355373

@@ -387,11 +405,7 @@ function ErrorGroupRow({
387405
);
388406
}
389407

390-
function ErrorActivityGraph({
391-
activity,
392-
}: {
393-
activity: ErrorOccurrenceActivity;
394-
}) {
408+
function ErrorActivityGraph({ activity }: { activity: ErrorOccurrenceActivity }) {
395409
const maxCount = Math.max(...activity.map((d) => d.count));
396410

397411
return (

apps/webapp/app/utils/semver.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Parses a version string into comparable numeric parts.
3+
* Handles formats like "1.2.3", "20240115.1", "v1.0.0", plain timestamps, etc.
4+
* Non-numeric pre-release suffixes (e.g. "-beta.1") are stripped for ordering purposes.
5+
*/
6+
function parseVersionParts(version: string): number[] {
7+
const cleaned = version.replace(/^v/i, "").replace(/[-+].*$/, "");
8+
return cleaned.split(".").map((p) => {
9+
const n = parseInt(p, 10);
10+
return isNaN(n) ? 0 : n;
11+
});
12+
}
13+
14+
/**
15+
* Compares two version strings using numeric segment comparison (descending).
16+
* Falls back to lexicographic comparison when segments are equal.
17+
* Returns a negative number if `a` should come before `b` (i.e. `a` is newer).
18+
*/
19+
export function compareVersionsDescending(a: string, b: string): number {
20+
const partsA = parseVersionParts(a);
21+
const partsB = parseVersionParts(b);
22+
const maxLen = Math.max(partsA.length, partsB.length);
23+
24+
for (let i = 0; i < maxLen; i++) {
25+
const segA = partsA[i] ?? 0;
26+
const segB = partsB[i] ?? 0;
27+
if (segA !== segB) {
28+
return segB - segA;
29+
}
30+
}
31+
32+
return b.localeCompare(a);
33+
}
34+
35+
/**
36+
* Sorts an array of version strings in descending order (newest first).
37+
* Non-destructive – returns a new array.
38+
*/
39+
export function sortVersionsDescending(versions: string[]): string[] {
40+
return [...versions].sort(compareVersionsDescending);
41+
}

0 commit comments

Comments
 (0)