Skip to content

Commit c5b966d

Browse files
committed
Added chart to the error page
1 parent abfceaa commit c5b966d

File tree

7 files changed

+868
-636
lines changed

7 files changed

+868
-636
lines changed

apps/webapp/app/components/primitives/DateTime.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
22
import { useRouteLoaderData } from "@remix-run/react";
3+
import { formatDistanceToNow } from "date-fns";
34
import { Laptop } from "lucide-react";
45
import { memo, type ReactNode, useMemo, useSyncExternalStore } from "react";
56
import { CopyButton } from "./CopyButton";
@@ -357,6 +358,39 @@ function formatDateTimeAccurate(
357358
return `${datePart} ${timePart}`;
358359
}
359360

361+
type RelativeDateTimeProps = {
362+
date: Date | string;
363+
timeZone?: string;
364+
};
365+
366+
export const RelativeDateTime = ({ date, timeZone }: RelativeDateTimeProps) => {
367+
const locales = useLocales();
368+
const userTimeZone = useUserTimeZone();
369+
370+
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
371+
372+
const relativeText = useMemo(() => {
373+
const text = formatDistanceToNow(realDate, { addSuffix: true });
374+
return text.charAt(0).toUpperCase() + text.slice(1);
375+
}, [realDate]);
376+
377+
return (
378+
<SimpleTooltip
379+
button={<span suppressHydrationWarning>{relativeText}</span>}
380+
content={
381+
<TooltipContent
382+
realDate={realDate}
383+
timeZone={timeZone}
384+
localTimeZone={userTimeZone}
385+
locales={locales}
386+
/>
387+
}
388+
side="right"
389+
asChild={true}
390+
/>
391+
);
392+
};
393+
360394
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
361395
const locales = useLocales();
362396
const userTimeZone = useUserTimeZone();

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

Lines changed: 163 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ function decodeCursor(cursor: string): ErrorInstanceCursor | null {
5959
}
6060
}
6161

62+
function parseClickHouseDateTime(value: string): Date {
63+
const asNum = Number(value);
64+
if (!isNaN(asNum) && asNum > 1e12) {
65+
return new Date(asNum);
66+
}
67+
return new Date(value.replace(" ", "T") + "Z");
68+
}
69+
70+
export type ErrorGroupSummary = {
71+
fingerprint: string;
72+
errorType: string;
73+
errorMessage: string;
74+
stackTrace?: string;
75+
taskIdentifier: string;
76+
count: number;
77+
firstSeen: Date;
78+
lastSeen: Date;
79+
};
80+
81+
export type ErrorGroupHourlyActivity = Array<{ date: Date; count: number }>;
82+
6283
export class ErrorGroupPresenter extends BasePresenter {
6384
constructor(
6485
private readonly replica: PrismaClientOrTransaction,
@@ -70,24 +91,156 @@ export class ErrorGroupPresenter extends BasePresenter {
7091
public async call(
7192
organizationId: string,
7293
environmentId: string,
73-
{
74-
userId,
75-
projectId,
76-
fingerprint,
77-
cursor,
78-
pageSize = DEFAULT_PAGE_SIZE,
79-
}: ErrorGroupOptions
94+
{ userId, projectId, fingerprint, cursor, pageSize = DEFAULT_PAGE_SIZE }: ErrorGroupOptions
8095
) {
8196
const displayableEnvironment = await findDisplayableEnvironment(environmentId, userId);
8297

8398
if (!displayableEnvironment) {
8499
throw new ServiceValidationError("No environment found");
85100
}
86101

87-
// Use the error instances query builder
102+
// Run summary (aggregated) and instances queries in parallel
103+
const [summary, instancesResult] = await Promise.all([
104+
this.getSummary(organizationId, projectId, environmentId, fingerprint),
105+
this.getInstances(organizationId, projectId, environmentId, fingerprint, cursor, pageSize),
106+
]);
107+
108+
// Get stack trace from the most recent instance
109+
let stackTrace: string | undefined;
110+
if (instancesResult.instances.length > 0) {
111+
const firstInstance = instancesResult.instances[0];
112+
try {
113+
const errorData = JSON.parse(firstInstance.error_text) as Record<string, unknown>;
114+
stackTrace = (errorData.stack || errorData.stacktrace) as string | undefined;
115+
} catch {
116+
// no stack trace available
117+
}
118+
}
119+
120+
// Build error group combining aggregated summary with instance stack trace
121+
let errorGroup: ErrorGroupSummary | undefined;
122+
if (summary) {
123+
errorGroup = {
124+
...summary,
125+
stackTrace,
126+
};
127+
}
128+
129+
// Transform instances
130+
const transformedInstances = instancesResult.instances.map((instance) => {
131+
let parsedError: any;
132+
try {
133+
parsedError = JSON.parse(instance.error_text);
134+
} catch {
135+
parsedError = { message: instance.error_text };
136+
}
137+
138+
return {
139+
runId: instance.run_id,
140+
friendlyId: instance.friendly_id,
141+
taskIdentifier: instance.task_identifier,
142+
createdAt: new Date(parseInt(instance.created_at) * 1000),
143+
status: instance.status,
144+
error: parsedError,
145+
traceId: instance.trace_id,
146+
taskVersion: instance.task_version,
147+
};
148+
});
149+
150+
return {
151+
errorGroup,
152+
instances: transformedInstances,
153+
runFriendlyIds: transformedInstances.map((i) => i.friendlyId),
154+
pagination: instancesResult.pagination,
155+
};
156+
}
157+
158+
public async getHourlyOccurrences(
159+
organizationId: string,
160+
projectId: string,
161+
environmentId: string,
162+
fingerprint: string
163+
): Promise<ErrorGroupHourlyActivity> {
164+
const hours = 168; // 7 days
165+
166+
const [queryError, records] = await this.clickhouse.errors.getHourlyOccurrences({
167+
organizationId,
168+
projectId,
169+
environmentId,
170+
fingerprints: [fingerprint],
171+
hours,
172+
});
173+
174+
if (queryError) {
175+
throw queryError;
176+
}
177+
178+
const buckets: number[] = [];
179+
const nowMs = Date.now();
180+
for (let i = hours - 1; i >= 0; i--) {
181+
const hourStart = Math.floor((nowMs - i * 3_600_000) / 3_600_000) * 3_600;
182+
buckets.push(hourStart);
183+
}
184+
185+
const byHour = new Map<number, number>();
186+
for (const row of records ?? []) {
187+
byHour.set(row.hour_epoch, row.count);
188+
}
189+
190+
return buckets.map((epoch) => ({
191+
date: new Date(epoch * 1000),
192+
count: byHour.get(epoch) ?? 0,
193+
}));
194+
}
195+
196+
private async getSummary(
197+
organizationId: string,
198+
projectId: string,
199+
environmentId: string,
200+
fingerprint: string
201+
): Promise<Omit<ErrorGroupSummary, "stackTrace"> | undefined> {
202+
const queryBuilder = this.clickhouse.errors.listQueryBuilder();
203+
204+
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
205+
queryBuilder.where("project_id = {projectId: String}", { projectId });
206+
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
207+
queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint });
208+
209+
queryBuilder.groupBy("error_fingerprint, task_identifier");
210+
queryBuilder.limit(1);
211+
212+
const [queryError, records] = await queryBuilder.execute();
213+
214+
if (queryError) {
215+
throw queryError;
216+
}
217+
218+
if (!records || records.length === 0) {
219+
return undefined;
220+
}
221+
222+
const record = records[0];
223+
return {
224+
fingerprint: record.error_fingerprint,
225+
errorType: record.error_type,
226+
errorMessage: record.error_message,
227+
taskIdentifier: record.task_identifier,
228+
count: record.occurrence_count,
229+
firstSeen: parseClickHouseDateTime(record.first_seen),
230+
lastSeen: parseClickHouseDateTime(record.last_seen),
231+
};
232+
}
233+
234+
private async getInstances(
235+
organizationId: string,
236+
projectId: string,
237+
environmentId: string,
238+
fingerprint: string,
239+
cursor: string | undefined,
240+
pageSize: number
241+
) {
88242
const queryBuilder = this.clickhouse.errors.instancesQueryBuilder();
89243

90-
// Apply filters
91244
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
92245
queryBuilder.where("project_id = {projectId: String}", { projectId });
93246
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
@@ -96,7 +249,6 @@ export class ErrorGroupPresenter extends BasePresenter {
96249
});
97250
queryBuilder.where("_is_deleted = 0");
98251

99-
// Cursor-based pagination
100252
const decodedCursor = cursor ? decodeCursor(cursor) : null;
101253
if (decodedCursor) {
102254
queryBuilder.where(
@@ -121,7 +273,6 @@ export class ErrorGroupPresenter extends BasePresenter {
121273
const hasMore = results.length > pageSize;
122274
const instances = results.slice(0, pageSize);
123275

124-
// Build next cursor from the last item
125276
let nextCursor: string | undefined;
126277
if (hasMore && instances.length > 0) {
127278
const lastInstance = instances[instances.length - 1];
@@ -131,57 +282,8 @@ export class ErrorGroupPresenter extends BasePresenter {
131282
});
132283
}
133284

134-
// Get error group summary from the first instance
135-
let errorGroup:
136-
| {
137-
errorType: string;
138-
errorMessage: string;
139-
stackTrace?: string;
140-
}
141-
| undefined;
142-
143-
if (instances.length > 0) {
144-
const firstInstance = instances[0];
145-
try {
146-
const errorData = JSON.parse(firstInstance.error_text);
147-
errorGroup = {
148-
errorType: errorData.type || errorData.name || "Error",
149-
errorMessage: errorData.message || "Unknown error",
150-
stackTrace: errorData.stack || errorData.stacktrace,
151-
};
152-
} catch {
153-
// If parsing fails, use fallback
154-
errorGroup = {
155-
errorType: "Error",
156-
errorMessage: firstInstance.error_text.substring(0, 200),
157-
};
158-
}
159-
}
160-
161-
// Transform results
162-
const transformedInstances = instances.map((instance) => {
163-
let parsedError: any;
164-
try {
165-
parsedError = JSON.parse(instance.error_text);
166-
} catch {
167-
parsedError = { message: instance.error_text };
168-
}
169-
170-
return {
171-
runId: instance.run_id,
172-
friendlyId: instance.friendly_id,
173-
taskIdentifier: instance.task_identifier,
174-
createdAt: new Date(parseInt(instance.created_at) * 1000),
175-
status: instance.status,
176-
error: parsedError,
177-
traceId: instance.trace_id,
178-
taskVersion: instance.task_version,
179-
};
180-
});
181-
182285
return {
183-
errorGroup,
184-
instances: transformedInstances,
286+
instances,
185287
pagination: {
186288
hasMore,
187289
nextCursor,

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@ export type ErrorHourlyActivity = ErrorHourlyOccurrences[string];
5353

5454
// Cursor for error groups pagination
5555
type ErrorGroupCursor = {
56-
lastSeen: string;
56+
occurrenceCount: number;
5757
fingerprint: string;
5858
};
5959

6060
const ErrorGroupCursorSchema = z.object({
61-
lastSeen: z.string(),
61+
occurrenceCount: z.number(),
6262
fingerprint: z.string(),
6363
});
6464

@@ -193,19 +193,19 @@ export class ErrorsListPresenter extends BasePresenter {
193193
);
194194
}
195195

196-
// Cursor-based pagination
196+
// Cursor-based pagination (sorted by occurrence_count DESC)
197197
const decodedCursor = cursor ? decodeCursor(cursor) : null;
198198
if (decodedCursor) {
199199
queryBuilder.having(
200-
"(last_seen < {cursorLastSeen: String} OR (last_seen = {cursorLastSeen: String} AND error_fingerprint < {cursorFingerprint: String}))",
200+
"(occurrence_count < {cursorOccurrenceCount: UInt64} OR (occurrence_count = {cursorOccurrenceCount: UInt64} AND error_fingerprint < {cursorFingerprint: String}))",
201201
{
202-
cursorLastSeen: decodedCursor.lastSeen,
202+
cursorOccurrenceCount: decodedCursor.occurrenceCount,
203203
cursorFingerprint: decodedCursor.fingerprint,
204204
}
205205
);
206206
}
207207

208-
queryBuilder.orderBy("last_seen DESC, error_fingerprint DESC");
208+
queryBuilder.orderBy("occurrence_count DESC, error_fingerprint DESC");
209209
queryBuilder.limit(pageSize + 1);
210210

211211
const [queryError, records] = await queryBuilder.execute();
@@ -223,7 +223,7 @@ export class ErrorsListPresenter extends BasePresenter {
223223
if (hasMore && errorGroups.length > 0) {
224224
const lastError = errorGroups[errorGroups.length - 1];
225225
nextCursor = encodeCursor({
226-
lastSeen: lastError.last_seen,
226+
occurrenceCount: lastError.occurrence_count,
227227
fingerprint: lastError.error_fingerprint,
228228
});
229229
}

0 commit comments

Comments
 (0)