@@ -46,6 +46,10 @@ const DEFAULT_PAGE_SIZE = 50;
4646export type ErrorsList = Awaited < ReturnType < ErrorsListPresenter [ "call" ] > > ;
4747export type ErrorGroup = ErrorsList [ "errorGroups" ] [ 0 ] ;
4848export type ErrorsListAppliedFilters = ErrorsList [ "filters" ] ;
49+ export type ErrorHourlyOccurrences = Awaited <
50+ ReturnType < ErrorsListPresenter [ "getHourlyOccurrences" ] >
51+ > ;
52+ export type ErrorHourlyActivity = ErrorHourlyOccurrences [ string ] ;
4953
5054// Cursor for error groups pagination
5155type ErrorGroupCursor = {
@@ -76,6 +80,15 @@ function decodeCursor(cursor: string): ErrorGroupCursor | null {
7680 }
7781}
7882
83+ function parseClickHouseDateTime ( value : string ) : Date {
84+ const asNum = Number ( value ) ;
85+ if ( ! isNaN ( asNum ) && asNum > 1e12 ) {
86+ return new Date ( asNum ) ;
87+ }
88+ // ClickHouse returns 'YYYY-MM-DD HH:mm:ss.SSS' in UTC
89+ return new Date ( value . replace ( " " , "T" ) + "Z" ) ;
90+ }
91+
7992function escapeClickHouseString ( val : string ) : string {
8093 return val . replace ( / \\ / g, "\\\\" ) . replace ( / \/ / g, "\\/" ) . replace ( / % / g, "\\%" ) . replace ( / _ / g, "\\_" ) ;
8194}
@@ -156,18 +169,19 @@ export class ErrorsListPresenter extends BasePresenter {
156169 queryBuilder . where ( "project_id = {projectId: String}" , { projectId } ) ;
157170 queryBuilder . where ( "environment_id = {environmentId: String}" , { environmentId } ) ;
158171
159- // Group by error_fingerprint to merge partial aggregations
160- queryBuilder . groupBy ( "error_fingerprint" ) ;
161-
162- // Apply HAVING filters (filters on aggregated columns)
163- // Time range filter - use last_seen_date regular column instead of aggregate
164- queryBuilder . having ( "max(last_seen_date) >= now() - INTERVAL {days: Int64} DAY" , { days : daysAgo } ) ;
165-
166- // Task filter
172+ // Task filter (task_identifier is part of the key, so use WHERE)
167173 if ( tasks && tasks . length > 0 ) {
168- queryBuilder . having ( "anyMerge(sample_task_identifier) IN {tasks: Array(String)}", { tasks } ) ;
174+ queryBuilder . where ( "task_identifier IN {tasks: Array(String)}", { tasks } ) ;
169175 }
170176
177+ // Group by key columns to merge partial aggregations
178+ queryBuilder . groupBy ( "error_fingerprint, task_identifier" ) ;
179+
180+ // Time range filter
181+ queryBuilder . having ( "max(last_seen_date) >= now() - INTERVAL {days: Int64} DAY" , {
182+ days : daysAgo ,
183+ } ) ;
184+
171185 // Search filter - searches in error type and message
172186 if ( search && search . trim ( ) !== "" ) {
173187 const searchTerm = escapeClickHouseString ( search . trim ( ) ) . toLowerCase ( ) ;
@@ -219,13 +233,12 @@ export class ErrorsListPresenter extends BasePresenter {
219233 errorType : error . error_type ,
220234 errorMessage : error . error_message ,
221235 fingerprint : error . error_fingerprint ,
222- firstSeen : new Date ( parseInt ( error . first_seen ) * 1000 ) ,
223- lastSeen : new Date ( parseInt ( error . last_seen ) * 1000 ) ,
236+ taskIdentifier : error . task_identifier ,
237+ firstSeen : parseClickHouseDateTime ( error . first_seen ) ,
238+ lastSeen : parseClickHouseDateTime ( error . last_seen ) ,
224239 count : error . occurrence_count ,
225- affectedTasks : error . affected_tasks ,
226240 sampleRunId : error . sample_run_id ,
227241 sampleFriendlyId : error . sample_friendly_id ,
228- sampleTaskIdentifier : error . sample_task_identifier ,
229242 } ) ) ;
230243
231244 return {
@@ -244,4 +257,59 @@ export class ErrorsListPresenter extends BasePresenter {
244257 } ,
245258 } ;
246259 }
260+
261+ public async getHourlyOccurrences (
262+ organizationId : string ,
263+ projectId : string ,
264+ environmentId : string ,
265+ fingerprints : string [ ]
266+ ) : Promise < Record < string , Array < { date : Date ; count : number } > > > {
267+ if ( fingerprints . length === 0 ) {
268+ return { } ;
269+ }
270+
271+ const hours = 24 ;
272+
273+ const [ queryError , records ] = await this . clickhouse . errors . getHourlyOccurrences ( {
274+ organizationId,
275+ projectId,
276+ environmentId,
277+ fingerprints,
278+ hours,
279+ } ) ;
280+
281+ if ( queryError ) {
282+ throw queryError ;
283+ }
284+
285+ // Build 24 hourly buckets as epoch seconds (UTC, floored to hour)
286+ const buckets : number [ ] = [ ] ;
287+ const nowMs = Date . now ( ) ;
288+ for ( let i = hours - 1 ; i >= 0 ; i -- ) {
289+ const hourStart = Math . floor ( ( nowMs - i * 3_600_000 ) / 3_600_000 ) * 3_600 ;
290+ buckets . push ( hourStart ) ;
291+ }
292+
293+ // Index ClickHouse results by fingerprint → epoch → count
294+ const grouped = new Map < string , Map < number , number > > ( ) ;
295+ for ( const row of records ?? [ ] ) {
296+ let byHour = grouped . get ( row . error_fingerprint ) ;
297+ if ( ! byHour ) {
298+ byHour = new Map ( ) ;
299+ grouped . set ( row . error_fingerprint , byHour ) ;
300+ }
301+ byHour . set ( row . hour_epoch , row . count ) ;
302+ }
303+
304+ const result : Record < string , Array < { date : Date ; count : number } > > = { } ;
305+ for ( const fp of fingerprints ) {
306+ const byHour = grouped . get ( fp ) ;
307+ result [ fp ] = buckets . map ( ( epoch ) => ( {
308+ date : new Date ( epoch * 1000 ) ,
309+ count : byHour ?. get ( epoch ) ?? 0 ,
310+ } ) ) ;
311+ }
312+
313+ return result ;
314+ }
247315}
0 commit comments