|
1 | 1 | import { z } from "zod"; |
2 | | -import { type ClickHouse } from "@internal/clickhouse"; |
| 2 | +import { |
| 3 | + type ClickHouse, |
| 4 | + type TimeGranularity, |
| 5 | + detectTimeGranularity, |
| 6 | + granularityToInterval, |
| 7 | + granularityToStepMs, |
| 8 | +} from "@internal/clickhouse"; |
3 | 9 | import { type PrismaClientOrTransaction } from "@trigger.dev/database"; |
4 | 10 | import { type Direction } from "~/components/ListPagination"; |
5 | 11 | import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; |
@@ -78,7 +84,8 @@ export type ErrorGroupSummary = { |
78 | 84 | lastSeen: Date; |
79 | 85 | }; |
80 | 86 |
|
81 | | -export type ErrorGroupHourlyActivity = Array<{ date: Date; count: number }>; |
| 87 | +export type ErrorGroupOccurrences = Awaited<ReturnType<ErrorGroupPresenter["getOccurrences"]>>; |
| 88 | +export type ErrorGroupActivity = ErrorGroupOccurrences["data"]; |
82 | 89 |
|
83 | 90 | export class ErrorGroupPresenter extends BasePresenter { |
84 | 91 | constructor( |
@@ -155,42 +162,67 @@ export class ErrorGroupPresenter extends BasePresenter { |
155 | 162 | }; |
156 | 163 | } |
157 | 164 |
|
158 | | - public async getHourlyOccurrences( |
| 165 | + /** |
| 166 | + * Returns bucketed occurrence counts for a single fingerprint over a time range. |
| 167 | + * Granularity is determined automatically from the range span. |
| 168 | + */ |
| 169 | + public async getOccurrences( |
159 | 170 | organizationId: string, |
160 | 171 | projectId: string, |
161 | 172 | 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, |
| 173 | + fingerprint: string, |
| 174 | + from: Date, |
| 175 | + to: Date |
| 176 | + ): Promise<{ |
| 177 | + granularity: TimeGranularity; |
| 178 | + data: Array<{ date: Date; count: number }>; |
| 179 | + }> { |
| 180 | + const granularity = detectTimeGranularity(from, to); |
| 181 | + const intervalExpr = granularityToInterval(granularity); |
| 182 | + const stepMs = granularityToStepMs(granularity); |
| 183 | + |
| 184 | + const queryBuilder = this.clickhouse.errors.createOccurrencesQueryBuilder(intervalExpr); |
| 185 | + |
| 186 | + queryBuilder.where("organization_id = {organizationId: String}", { organizationId }); |
| 187 | + queryBuilder.where("project_id = {projectId: String}", { projectId }); |
| 188 | + queryBuilder.where("environment_id = {environmentId: String}", { environmentId }); |
| 189 | + queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint }); |
| 190 | + queryBuilder.where("minute >= toStartOfMinute(fromUnixTimestamp64Milli({fromTimeMs: Int64}))", { |
| 191 | + fromTimeMs: from.getTime(), |
172 | 192 | }); |
| 193 | + queryBuilder.where("minute <= toStartOfMinute(fromUnixTimestamp64Milli({toTimeMs: Int64}))", { |
| 194 | + toTimeMs: to.getTime(), |
| 195 | + }); |
| 196 | + |
| 197 | + queryBuilder.groupBy("error_fingerprint, bucket_epoch"); |
| 198 | + queryBuilder.orderBy("bucket_epoch ASC"); |
| 199 | + |
| 200 | + const [queryError, records] = await queryBuilder.execute(); |
173 | 201 |
|
174 | 202 | if (queryError) { |
175 | 203 | throw queryError; |
176 | 204 | } |
177 | 205 |
|
| 206 | + // Build time buckets covering the full range |
178 | 207 | 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); |
| 208 | + const startEpoch = Math.floor(from.getTime() / stepMs) * (stepMs / 1000); |
| 209 | + const endEpoch = Math.ceil(to.getTime() / 1000); |
| 210 | + for (let epoch = startEpoch; epoch <= endEpoch; epoch += stepMs / 1000) { |
| 211 | + buckets.push(epoch); |
183 | 212 | } |
184 | 213 |
|
185 | | - const byHour = new Map<number, number>(); |
| 214 | + const byBucket = new Map<number, number>(); |
186 | 215 | for (const row of records ?? []) { |
187 | | - byHour.set(row.hour_epoch, row.count); |
| 216 | + byBucket.set(row.bucket_epoch, (byBucket.get(row.bucket_epoch) ?? 0) + row.count); |
188 | 217 | } |
189 | 218 |
|
190 | | - return buckets.map((epoch) => ({ |
191 | | - date: new Date(epoch * 1000), |
192 | | - count: byHour.get(epoch) ?? 0, |
193 | | - })); |
| 219 | + return { |
| 220 | + granularity, |
| 221 | + data: buckets.map((epoch) => ({ |
| 222 | + date: new Date(epoch * 1000), |
| 223 | + count: byBucket.get(epoch) ?? 0, |
| 224 | + })), |
| 225 | + }; |
194 | 226 | } |
195 | 227 |
|
196 | 228 | private async getSummary( |
|
0 commit comments