Skip to content

Commit 9b463db

Browse files
committed
Better time bucketing and nicer presenter logic
1 parent 14e42de commit 9b463db

File tree

12 files changed

+186
-299
lines changed

12 files changed

+186
-299
lines changed
Lines changed: 59 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,41 @@
11
import { z } from "zod";
2-
import {
3-
type ClickHouse,
4-
type TimeGranularity,
5-
detectTimeGranularity,
6-
granularityToInterval,
7-
granularityToStepMs,
8-
} from "@internal/clickhouse";
2+
import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse";
3+
import { TimeGranularity } from "~/utils/timeGranularity";
4+
5+
const errorGroupGranularity = new TimeGranularity([
6+
{ max: "1h", granularity: "1m" },
7+
{ max: "1d", granularity: "30m" },
8+
{ max: "1w", granularity: "8h" },
9+
{ max: "31d", granularity: "1d" },
10+
{ max: "45d", granularity: "1w" },
11+
{ max: "Infinity", granularity: "30d" },
12+
]);
913
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
10-
import { type Direction } from "~/components/ListPagination";
1114
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
1215
import { ServiceValidationError } from "~/v3/services/baseService.server";
1316
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
17+
import {
18+
NextRunListPresenter,
19+
type NextRunList,
20+
} from "~/presenters/v3/NextRunListPresenter.server";
1421

1522
export type ErrorGroupOptions = {
1623
userId?: string;
1724
projectId: string;
1825
fingerprint: string;
19-
// pagination
20-
direction?: Direction;
21-
cursor?: string;
22-
pageSize?: number;
26+
runsPageSize?: number;
2327
};
2428

2529
export const ErrorGroupOptionsSchema = z.object({
2630
userId: z.string().optional(),
2731
projectId: z.string(),
2832
fingerprint: z.string(),
29-
direction: z.enum(["forward", "backward"]).optional(),
30-
cursor: z.string().optional(),
31-
pageSize: z.number().int().positive().max(1000).optional(),
33+
runsPageSize: z.number().int().positive().max(1000).optional(),
3234
});
3335

34-
const DEFAULT_PAGE_SIZE = 50;
36+
const DEFAULT_RUNS_PAGE_SIZE = 25;
3537

3638
export type ErrorGroupDetail = Awaited<ReturnType<ErrorGroupPresenter["call"]>>;
37-
export type ErrorInstance = ErrorGroupDetail["instances"][0];
38-
39-
// Cursor for error instances pagination
40-
type ErrorInstanceCursor = {
41-
createdAt: string;
42-
runId: string;
43-
};
44-
45-
const ErrorInstanceCursorSchema = z.object({
46-
createdAt: z.string(),
47-
runId: z.string(),
48-
});
49-
50-
function encodeCursor(cursor: ErrorInstanceCursor): string {
51-
return Buffer.from(JSON.stringify(cursor)).toString("base64");
52-
}
53-
54-
function decodeCursor(cursor: string): ErrorInstanceCursor | null {
55-
try {
56-
const decoded = Buffer.from(cursor, "base64").toString("utf-8");
57-
const parsed = JSON.parse(decoded);
58-
const validated = ErrorInstanceCursorSchema.safeParse(parsed);
59-
if (!validated.success) {
60-
return null;
61-
}
62-
return validated.data as ErrorInstanceCursor;
63-
} catch {
64-
return null;
65-
}
66-
}
6739

6840
function parseClickHouseDateTime(value: string): Date {
6941
const asNum = Number(value);
@@ -77,7 +49,6 @@ export type ErrorGroupSummary = {
7749
fingerprint: string;
7850
errorType: string;
7951
errorMessage: string;
80-
stackTrace?: string;
8152
taskIdentifier: string;
8253
count: number;
8354
firstSeen: Date;
@@ -90,6 +61,7 @@ export type ErrorGroupActivity = ErrorGroupOccurrences["data"];
9061
export class ErrorGroupPresenter extends BasePresenter {
9162
constructor(
9263
private readonly replica: PrismaClientOrTransaction,
64+
private readonly logsClickhouse: ClickHouse,
9365
private readonly clickhouse: ClickHouse
9466
) {
9567
super(undefined, replica);
@@ -98,67 +70,32 @@ export class ErrorGroupPresenter extends BasePresenter {
9870
public async call(
9971
organizationId: string,
10072
environmentId: string,
101-
{ userId, projectId, fingerprint, cursor, pageSize = DEFAULT_PAGE_SIZE }: ErrorGroupOptions
73+
{
74+
userId,
75+
projectId,
76+
fingerprint,
77+
runsPageSize = DEFAULT_RUNS_PAGE_SIZE,
78+
}: ErrorGroupOptions
10279
) {
10380
const displayableEnvironment = await findDisplayableEnvironment(environmentId, userId);
10481

10582
if (!displayableEnvironment) {
10683
throw new ServiceValidationError("No environment found");
10784
}
10885

109-
// Run summary (aggregated) and instances queries in parallel
110-
const [summary, instancesResult] = await Promise.all([
86+
const [summary, runList] = await Promise.all([
11187
this.getSummary(organizationId, projectId, environmentId, fingerprint),
112-
this.getInstances(organizationId, projectId, environmentId, fingerprint, cursor, pageSize),
88+
this.getRunList(organizationId, environmentId, {
89+
userId,
90+
projectId,
91+
fingerprint,
92+
pageSize: runsPageSize,
93+
}),
11394
]);
11495

115-
// Get stack trace from the most recent instance
116-
let stackTrace: string | undefined;
117-
if (instancesResult.instances.length > 0) {
118-
const firstInstance = instancesResult.instances[0];
119-
try {
120-
const errorData = JSON.parse(firstInstance.error_text) as Record<string, unknown>;
121-
stackTrace = (errorData.stack || errorData.stacktrace) as string | undefined;
122-
} catch {
123-
// no stack trace available
124-
}
125-
}
126-
127-
// Build error group combining aggregated summary with instance stack trace
128-
let errorGroup: ErrorGroupSummary | undefined;
129-
if (summary) {
130-
errorGroup = {
131-
...summary,
132-
stackTrace,
133-
};
134-
}
135-
136-
// Transform instances
137-
const transformedInstances = instancesResult.instances.map((instance) => {
138-
let parsedError: any;
139-
try {
140-
parsedError = JSON.parse(instance.error_text);
141-
} catch {
142-
parsedError = { message: instance.error_text };
143-
}
144-
145-
return {
146-
runId: instance.run_id,
147-
friendlyId: instance.friendly_id,
148-
taskIdentifier: instance.task_identifier,
149-
createdAt: new Date(parseInt(instance.created_at) * 1000),
150-
status: instance.status,
151-
error: parsedError,
152-
traceId: instance.trace_id,
153-
taskVersion: instance.task_version,
154-
};
155-
});
156-
15796
return {
158-
errorGroup,
159-
instances: transformedInstances,
160-
runFriendlyIds: transformedInstances.map((i) => i.friendlyId),
161-
pagination: instancesResult.pagination,
97+
errorGroup: summary,
98+
runList,
16299
};
163100
}
164101

@@ -174,14 +111,12 @@ export class ErrorGroupPresenter extends BasePresenter {
174111
from: Date,
175112
to: Date
176113
): Promise<{
177-
granularity: TimeGranularity;
178114
data: Array<{ date: Date; count: number }>;
179115
}> {
180-
const granularity = detectTimeGranularity(from, to);
181-
const intervalExpr = granularityToInterval(granularity);
182-
const stepMs = granularityToStepMs(granularity);
116+
const granularityMs = errorGroupGranularity.getTimeGranularityMs(from, to);
117+
const intervalExpr = msToClickHouseInterval(granularityMs);
183118

184-
const queryBuilder = this.clickhouse.errors.createOccurrencesQueryBuilder(intervalExpr);
119+
const queryBuilder = this.logsClickhouse.errors.createOccurrencesQueryBuilder(intervalExpr);
185120

186121
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
187122
queryBuilder.where("project_id = {projectId: String}", { projectId });
@@ -205,9 +140,9 @@ export class ErrorGroupPresenter extends BasePresenter {
205140

206141
// Build time buckets covering the full range
207142
const buckets: number[] = [];
208-
const startEpoch = Math.floor(from.getTime() / stepMs) * (stepMs / 1000);
143+
const startEpoch = Math.floor(from.getTime() / granularityMs) * (granularityMs / 1000);
209144
const endEpoch = Math.ceil(to.getTime() / 1000);
210-
for (let epoch = startEpoch; epoch <= endEpoch; epoch += stepMs / 1000) {
145+
for (let epoch = startEpoch; epoch <= endEpoch; epoch += granularityMs / 1000) {
211146
buckets.push(epoch);
212147
}
213148

@@ -217,7 +152,6 @@ export class ErrorGroupPresenter extends BasePresenter {
217152
}
218153

219154
return {
220-
granularity,
221155
data: buckets.map((epoch) => ({
222156
date: new Date(epoch * 1000),
223157
count: byBucket.get(epoch) ?? 0,
@@ -230,8 +164,8 @@ export class ErrorGroupPresenter extends BasePresenter {
230164
projectId: string,
231165
environmentId: string,
232166
fingerprint: string
233-
): Promise<Omit<ErrorGroupSummary, "stackTrace"> | undefined> {
234-
const queryBuilder = this.clickhouse.errors.listQueryBuilder();
167+
): Promise<ErrorGroupSummary | undefined> {
168+
const queryBuilder = this.logsClickhouse.errors.listQueryBuilder();
235169

236170
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
237171
queryBuilder.where("project_id = {projectId: String}", { projectId });
@@ -263,63 +197,29 @@ export class ErrorGroupPresenter extends BasePresenter {
263197
};
264198
}
265199

266-
private async getInstances(
200+
private async getRunList(
267201
organizationId: string,
268-
projectId: string,
269202
environmentId: string,
270-
fingerprint: string,
271-
cursor: string | undefined,
272-
pageSize: number
273-
) {
274-
const queryBuilder = this.clickhouse.errors.instancesQueryBuilder();
275-
276-
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
277-
queryBuilder.where("project_id = {projectId: String}", { projectId });
278-
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
279-
queryBuilder.where("error_fingerprint = {errorFingerprint: String}", {
280-
errorFingerprint: fingerprint,
281-
});
282-
queryBuilder.where("_is_deleted = 0");
283-
284-
const decodedCursor = cursor ? decodeCursor(cursor) : null;
285-
if (decodedCursor) {
286-
queryBuilder.where(
287-
`(created_at < {cursorCreatedAt: String} OR (created_at = {cursorCreatedAt: String} AND run_id < {cursorRunId: String}))`,
288-
{
289-
cursorCreatedAt: decodedCursor.createdAt,
290-
cursorRunId: decodedCursor.runId,
291-
}
292-
);
293-
}
294-
295-
queryBuilder.orderBy("created_at DESC, run_id DESC");
296-
queryBuilder.limit(pageSize + 1);
297-
298-
const [queryError, records] = await queryBuilder.execute();
299-
300-
if (queryError) {
301-
throw queryError;
203+
options: {
204+
userId?: string;
205+
projectId: string;
206+
fingerprint: string;
207+
pageSize: number;
302208
}
209+
): Promise<NextRunList | undefined> {
210+
const runListPresenter = new NextRunListPresenter(this.replica, this.clickhouse);
211+
212+
const result = await runListPresenter.call(organizationId, environmentId, {
213+
userId: options.userId,
214+
projectId: options.projectId,
215+
errorFingerprint: options.fingerprint,
216+
pageSize: options.pageSize,
217+
});
303218

304-
const results = records || [];
305-
const hasMore = results.length > pageSize;
306-
const instances = results.slice(0, pageSize);
307-
308-
let nextCursor: string | undefined;
309-
if (hasMore && instances.length > 0) {
310-
const lastInstance = instances[instances.length - 1];
311-
nextCursor = encodeCursor({
312-
createdAt: lastInstance.created_at,
313-
runId: lastInstance.run_id,
314-
});
219+
if (result.runs.length === 0) {
220+
return undefined;
315221
}
316222

317-
return {
318-
instances,
319-
pagination: {
320-
hasMore,
321-
nextCursor,
322-
},
323-
};
223+
return result;
324224
}
325225
}

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

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { z } from "zod";
2-
import {
3-
type ClickHouse,
4-
type TimeGranularity,
5-
detectTimeGranularity,
6-
granularityToInterval,
7-
granularityToStepMs,
8-
} from "@internal/clickhouse";
2+
import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse";
3+
import { TimeGranularity } from "~/utils/timeGranularity";
4+
5+
const errorsListGranularity = new TimeGranularity([
6+
{ max: "2h", granularity: "1m" },
7+
{ max: "2d", granularity: "1h" },
8+
{ max: "2w", granularity: "1d" },
9+
{ max: "3 months", granularity: "1w" },
10+
{ max: "Infinity", granularity: "30d" },
11+
]);
912
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
1013
import { type Direction } from "~/components/ListPagination";
1114
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
@@ -275,16 +278,14 @@ export class ErrorsListPresenter extends BasePresenter {
275278
from: Date,
276279
to: Date
277280
): Promise<{
278-
granularity: TimeGranularity;
279281
data: Record<string, Array<{ date: Date; count: number }>>;
280282
}> {
281283
if (fingerprints.length === 0) {
282-
return { granularity: "hours", data: {} };
284+
return { data: {} };
283285
}
284286

285-
const granularity = detectTimeGranularity(from, to);
286-
const intervalExpr = granularityToInterval(granularity);
287-
const stepMs = granularityToStepMs(granularity);
287+
const granularityMs = errorsListGranularity.getTimeGranularityMs(from, to);
288+
const intervalExpr = msToClickHouseInterval(granularityMs);
288289

289290
const queryBuilder = this.clickhouse.errors.createOccurrencesQueryBuilder(intervalExpr);
290291

@@ -310,9 +311,9 @@ export class ErrorsListPresenter extends BasePresenter {
310311

311312
// Build time buckets covering the full range
312313
const buckets: number[] = [];
313-
const startEpoch = Math.floor(from.getTime() / stepMs) * (stepMs / 1000);
314+
const startEpoch = Math.floor(from.getTime() / granularityMs) * (granularityMs / 1000);
314315
const endEpoch = Math.ceil(to.getTime() / 1000);
315-
for (let epoch = startEpoch; epoch <= endEpoch; epoch += stepMs / 1000) {
316+
for (let epoch = startEpoch; epoch <= endEpoch; epoch += granularityMs / 1000) {
316317
buckets.push(epoch);
317318
}
318319

@@ -336,7 +337,7 @@ export class ErrorsListPresenter extends BasePresenter {
336337
}));
337338
}
338339

339-
return { granularity, data };
340+
return { data };
340341
}
341342

342343
/**

0 commit comments

Comments
 (0)