Skip to content

Commit 6bfb56b

Browse files
committed
First commit for Errors feature
1 parent 8003923 commit 6bfb56b

File tree

18 files changed

+1997
-6
lines changed

18 files changed

+1997
-6
lines changed

apps/webapp/app/components/logs/LogsSearchInput.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import { motion } from "framer-motion";
33
import { useCallback, useEffect, useRef, useState } from "react";
44
import { Input } from "~/components/primitives/Input";
55
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
6-
import { cn } from "~/utils/cn";
7-
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
86
import { useSearchParams } from "~/hooks/useSearchParam";
7+
import { cn } from "~/utils/cn";
8+
9+
export type LogsSearchInputProps = {
10+
placeholder?: string;
11+
};
912

10-
export function LogsSearchInput() {
11-
const location = useOptimisticLocation();
13+
export function LogsSearchInput({ placeholder = "Search logs…" }: LogsSearchInputProps) {
1214
const inputRef = useRef<HTMLInputElement>(null);
1315

1416
const { value, replace, del } = useSearchParams();
@@ -61,7 +63,7 @@ export function LogsSearchInput() {
6163
type="text"
6264
ref={inputRef}
6365
variant="secondary-small"
64-
placeholder="Search logs…"
66+
placeholder={placeholder}
6567
value={text}
6668
onChange={(e) => setText(e.target.value)}
6769
fullWidth

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
v3EnvironmentPath,
7474
v3EnvironmentVariablesPath,
7575
v3LogsPath,
76+
v3ErrorsPath,
7677
v3ProjectAlertsPath,
7778
v3ProjectPath,
7879
v3ProjectSettingsGeneralPath,
@@ -474,6 +475,15 @@ export function SideMenu({
474475
isCollapsed={isCollapsed}
475476
/>
476477
)}
478+
<SideMenuItem
479+
name="Errors"
480+
icon={ExclamationTriangleIcon}
481+
activeIconColor="text-rose-500"
482+
inactiveIconColor="text-rose-500"
483+
to={v3ErrorsPath(organization, project, environment)}
484+
data-action="errors"
485+
isCollapsed={isCollapsed}
486+
/>
477487
<SideMenuItem
478488
name="Query"
479489
icon={TableCellsIcon}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { z } from "zod";
2+
import { type ClickHouse } from "@internal/clickhouse";
3+
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
4+
import { type Direction } from "~/components/ListPagination";
5+
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
6+
import { ServiceValidationError } from "~/v3/services/baseService.server";
7+
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
8+
9+
export type ErrorGroupOptions = {
10+
userId?: string;
11+
projectId: string;
12+
fingerprint: string;
13+
// pagination
14+
direction?: Direction;
15+
cursor?: string;
16+
pageSize?: number;
17+
};
18+
19+
export const ErrorGroupOptionsSchema = z.object({
20+
userId: z.string().optional(),
21+
projectId: z.string(),
22+
fingerprint: z.string(),
23+
direction: z.enum(["forward", "backward"]).optional(),
24+
cursor: z.string().optional(),
25+
pageSize: z.number().int().positive().max(1000).optional(),
26+
});
27+
28+
const DEFAULT_PAGE_SIZE = 50;
29+
30+
export type ErrorGroupDetail = Awaited<ReturnType<ErrorGroupPresenter["call"]>>;
31+
export type ErrorInstance = ErrorGroupDetail["instances"][0];
32+
33+
// Cursor for error instances pagination
34+
type ErrorInstanceCursor = {
35+
createdAt: string;
36+
runId: string;
37+
};
38+
39+
const ErrorInstanceCursorSchema = z.object({
40+
createdAt: z.string(),
41+
runId: z.string(),
42+
});
43+
44+
function encodeCursor(cursor: ErrorInstanceCursor): string {
45+
return Buffer.from(JSON.stringify(cursor)).toString("base64");
46+
}
47+
48+
function decodeCursor(cursor: string): ErrorInstanceCursor | null {
49+
try {
50+
const decoded = Buffer.from(cursor, "base64").toString("utf-8");
51+
const parsed = JSON.parse(decoded);
52+
const validated = ErrorInstanceCursorSchema.safeParse(parsed);
53+
if (!validated.success) {
54+
return null;
55+
}
56+
return validated.data as ErrorInstanceCursor;
57+
} catch {
58+
return null;
59+
}
60+
}
61+
62+
export class ErrorGroupPresenter extends BasePresenter {
63+
constructor(
64+
private readonly replica: PrismaClientOrTransaction,
65+
private readonly clickhouse: ClickHouse
66+
) {
67+
super(undefined, replica);
68+
}
69+
70+
public async call(
71+
organizationId: string,
72+
environmentId: string,
73+
{
74+
userId,
75+
projectId,
76+
fingerprint,
77+
cursor,
78+
pageSize = DEFAULT_PAGE_SIZE,
79+
}: ErrorGroupOptions
80+
) {
81+
const displayableEnvironment = await findDisplayableEnvironment(environmentId, userId);
82+
83+
if (!displayableEnvironment) {
84+
throw new ServiceValidationError("No environment found");
85+
}
86+
87+
// Use the error instances query builder
88+
const queryBuilder = this.clickhouse.errors.instancesQueryBuilder();
89+
90+
// Apply filters
91+
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
92+
queryBuilder.where("project_id = {projectId: String}", { projectId });
93+
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
94+
queryBuilder.where("error_fingerprint = {errorFingerprint: String}", {
95+
errorFingerprint: fingerprint,
96+
});
97+
queryBuilder.where("_is_deleted = 0");
98+
99+
// Cursor-based pagination
100+
const decodedCursor = cursor ? decodeCursor(cursor) : null;
101+
if (decodedCursor) {
102+
queryBuilder.where(
103+
`(created_at < {cursorCreatedAt: String} OR (created_at = {cursorCreatedAt: String} AND run_id < {cursorRunId: String}))`,
104+
{
105+
cursorCreatedAt: decodedCursor.createdAt,
106+
cursorRunId: decodedCursor.runId,
107+
}
108+
);
109+
}
110+
111+
queryBuilder.orderBy("created_at DESC, run_id DESC");
112+
queryBuilder.limit(pageSize + 1);
113+
114+
const [queryError, records] = await queryBuilder.execute();
115+
116+
if (queryError) {
117+
throw queryError;
118+
}
119+
120+
const results = records || [];
121+
const hasMore = results.length > pageSize;
122+
const instances = results.slice(0, pageSize);
123+
124+
// Build next cursor from the last item
125+
let nextCursor: string | undefined;
126+
if (hasMore && instances.length > 0) {
127+
const lastInstance = instances[instances.length - 1];
128+
nextCursor = encodeCursor({
129+
createdAt: lastInstance.created_at,
130+
runId: lastInstance.run_id,
131+
});
132+
}
133+
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+
182+
return {
183+
errorGroup,
184+
instances: transformedInstances,
185+
pagination: {
186+
hasMore,
187+
nextCursor,
188+
},
189+
};
190+
}
191+
}

0 commit comments

Comments
 (0)