@@ -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+
6283export 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,
0 commit comments