import { z } from "zod"; import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse"; import { TimeGranularity } from "~/utils/timeGranularity"; import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { type Direction, DirectionSchema } from "~/components/ListPagination"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { BasePresenter } from "~/presenters/v3/basePresenter.server"; import { NextRunListPresenter, type NextRunList, } from "~/presenters/v3/NextRunListPresenter.server"; import { sortVersionsDescending } from "~/utils/semver"; const errorGroupGranularity = new TimeGranularity([ { max: "1h", granularity: "1m" }, { max: "1d", granularity: "20m" }, { max: "1w", granularity: "2h" }, { max: "31d", granularity: "12h" }, { max: "60d", granularity: "1w" }, { max: "Infinity", granularity: "30d" }, ]); export type ErrorGroupOptions = { userId?: string; projectId: string; fingerprint: string; versions?: string[]; runsPageSize?: number; period?: string; from?: number; to?: number; cursor?: string; direction?: Direction; }; export const ErrorGroupOptionsSchema = z.object({ userId: z.string().optional(), projectId: z.string(), fingerprint: z.string(), versions: z.array(z.string()).optional(), runsPageSize: z.number().int().positive().max(1000).optional(), period: z.string().optional(), from: z.number().int().nonnegative().optional(), to: z.number().int().nonnegative().optional(), cursor: z.string().optional(), direction: DirectionSchema.optional(), }); const DEFAULT_RUNS_PAGE_SIZE = 25; export type ErrorGroupDetail = Awaited>; function parseClickHouseDateTime(value: string): Date { const asNum = Number(value); if (!isNaN(asNum) && asNum > 1e12) { return new Date(asNum); } return new Date(value.replace(" ", "T") + "Z"); } export type ErrorGroupState = { status: ErrorGroupStatus; resolvedAt: Date | null; resolvedInVersion: string | null; resolvedBy: string | null; ignoredAt: Date | null; ignoredUntil: Date | null; ignoredReason: string | null; ignoredByUserId: string | null; ignoredByUserDisplayName: string | null; ignoredUntilOccurrenceRate: number | null; ignoredUntilTotalOccurrences: number | null; ignoredAtOccurrenceCount: number | null; }; export type ErrorGroupSummary = { fingerprint: string; errorType: string; errorMessage: string; taskIdentifier: string; count: number; firstSeen: Date; lastSeen: Date; affectedVersions: string[]; state: ErrorGroupState; }; export type ErrorGroupOccurrences = Awaited>; export type ErrorGroupActivity = ErrorGroupOccurrences["data"]; export type ErrorGroupActivityVersions = ErrorGroupOccurrences["versions"]; export class ErrorGroupPresenter extends BasePresenter { constructor( private readonly replica: PrismaClientOrTransaction, private readonly logsClickhouse: ClickHouse, private readonly clickhouse: ClickHouse ) { super(undefined, replica); } public async call( organizationId: string, environmentId: string, { userId, projectId, fingerprint, versions, runsPageSize = DEFAULT_RUNS_PAGE_SIZE, period, from, to, cursor, direction, }: ErrorGroupOptions ) { const displayableEnvironment = await findDisplayableEnvironment(environmentId, userId); if (!displayableEnvironment) { throw new ServiceValidationError("No environment found"); } const time = timeFilterFromTo({ period, from, to, defaultPeriod: "7d", }); const summary = await this.getSummary(organizationId, projectId, environmentId, fingerprint); const [affectedVersions, runList, stateRow] = await Promise.all([ this.getAffectedVersions(organizationId, projectId, environmentId, fingerprint), this.getRunList(organizationId, environmentId, { userId, projectId, fingerprint, versions, pageSize: runsPageSize, from: time.from.getTime(), to: time.to.getTime(), cursor, direction, }), this.getState(environmentId, summary?.taskIdentifier, fingerprint), ]); if (summary) { summary.affectedVersions = affectedVersions; summary.state = stateRow ?? { status: "UNRESOLVED", resolvedAt: null, resolvedInVersion: null, resolvedBy: null, ignoredAt: null, ignoredUntil: null, ignoredReason: null, ignoredByUserId: null, ignoredByUserDisplayName: null, ignoredUntilOccurrenceRate: null, ignoredUntilTotalOccurrences: null, ignoredAtOccurrenceCount: null, }; } return { errorGroup: summary, runList, filters: { from: time.from, to: time.to, }, }; } /** * Returns bucketed occurrence counts for a single fingerprint over a time range, * grouped by task_version for stacked charts. */ public async getOccurrences( organizationId: string, projectId: string, environmentId: string, fingerprint: string, from: Date, to: Date, versions?: string[] ): Promise<{ data: Array>; versions: string[]; }> { const granularityMs = errorGroupGranularity.getTimeGranularityMs(from, to); const intervalExpr = msToClickHouseInterval(granularityMs); const queryBuilder = this.logsClickhouse.errors.createOccurrencesByVersionQueryBuilder(intervalExpr); queryBuilder.where("organization_id = {organizationId: String}", { organizationId }); queryBuilder.where("project_id = {projectId: String}", { projectId }); queryBuilder.where("environment_id = {environmentId: String}", { environmentId }); queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint }); queryBuilder.where("minute >= toStartOfMinute(fromUnixTimestamp64Milli({fromTimeMs: Int64}))", { fromTimeMs: from.getTime(), }); queryBuilder.where("minute <= toStartOfMinute(fromUnixTimestamp64Milli({toTimeMs: Int64}))", { toTimeMs: to.getTime(), }); if (versions && versions.length > 0) { queryBuilder.where("task_version IN {versions: Array(String)}", { versions }); } queryBuilder.groupBy("error_fingerprint, task_version, bucket_epoch"); queryBuilder.orderBy("bucket_epoch ASC"); const [queryError, records] = await queryBuilder.execute(); if (queryError) { throw queryError; } // Build time buckets covering the full range const buckets: number[] = []; const startEpoch = Math.floor(from.getTime() / granularityMs) * (granularityMs / 1000); const endEpoch = Math.ceil(to.getTime() / 1000); for (let epoch = startEpoch; epoch <= endEpoch; epoch += granularityMs / 1000) { buckets.push(epoch); } // Collect distinct versions and index results by (epoch, version) const versionSet = new Set(); const byBucketVersion = new Map(); for (const row of records ?? []) { const version = row.task_version || "unknown"; versionSet.add(version); const key = `${row.bucket_epoch}:${version}`; byBucketVersion.set(key, (byBucketVersion.get(key) ?? 0) + row.count); } const sortedVersions = sortVersionsDescending([...versionSet]); // Build the data for the graph // For each time bucket, if a value exists for a version set the value (don't add zeros) const data = buckets.map((epoch) => { const point: Record = { date: new Date(epoch * 1000) }; for (const version of sortedVersions) { const versionValue = byBucketVersion.get(`${epoch}:${version}`); if (versionValue) { point[version] = versionValue; } } return point; }); return { data, versions: sortedVersions }; } private async getSummary( organizationId: string, projectId: string, environmentId: string, fingerprint: string ): Promise { const queryBuilder = this.logsClickhouse.errors.listQueryBuilder(); queryBuilder.where("organization_id = {organizationId: String}", { organizationId }); queryBuilder.where("project_id = {projectId: String}", { projectId }); queryBuilder.where("environment_id = {environmentId: String}", { environmentId }); queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint }); queryBuilder.groupBy("error_fingerprint, task_identifier"); queryBuilder.limit(1); const [queryError, records] = await queryBuilder.execute(); if (queryError) { throw queryError; } if (!records || records.length === 0) { return undefined; } const record = records[0]; return { fingerprint: record.error_fingerprint, errorType: record.error_type, errorMessage: record.error_message, taskIdentifier: record.task_identifier, count: record.occurrence_count, firstSeen: parseClickHouseDateTime(record.first_seen), lastSeen: parseClickHouseDateTime(record.last_seen), affectedVersions: [], state: { status: "UNRESOLVED" as const, resolvedAt: null, resolvedInVersion: null, resolvedBy: null, ignoredAt: null, ignoredUntil: null, ignoredReason: null, ignoredByUserId: null, ignoredByUserDisplayName: null, ignoredUntilOccurrenceRate: null, ignoredUntilTotalOccurrences: null, ignoredAtOccurrenceCount: null, }, }; } /** * Returns the most recent distinct task_version values for an error fingerprint, * sorted by semantic version descending (newest first). * Queries error_occurrences_v1 where task_version is part of the ORDER BY key. */ private async getAffectedVersions( organizationId: string, projectId: string, environmentId: string, fingerprint: string ): Promise { const queryBuilder = this.logsClickhouse.errors.affectedVersionsQueryBuilder(); queryBuilder.where("organization_id = {organizationId: String}", { organizationId }); queryBuilder.where("project_id = {projectId: String}", { projectId }); queryBuilder.where("environment_id = {environmentId: String}", { environmentId }); queryBuilder.where("error_fingerprint = {fingerprint: String}", { fingerprint }); queryBuilder.where("task_version != ''"); queryBuilder.limit(100); const [queryError, records] = await queryBuilder.execute(); if (queryError || !records) { return []; } const versions = records.map((r) => r.task_version).filter((v) => v.length > 0); return sortVersionsDescending(versions).slice(0, 5); } private async getState( environmentId: string, taskIdentifier: string | undefined, fingerprint: string ): Promise { const row = await this.replica.errorGroupState.findFirst({ where: { environmentId, ...(taskIdentifier ? { taskIdentifier } : {}), errorFingerprint: fingerprint, }, select: { status: true, resolvedAt: true, resolvedInVersion: true, resolvedBy: true, ignoredAt: true, ignoredUntil: true, ignoredReason: true, ignoredByUserId: true, ignoredUntilOccurrenceRate: true, ignoredUntilTotalOccurrences: true, ignoredAtOccurrenceCount: true, }, }); if (!row) { return null; } let ignoredByUserDisplayName: string | null = null; if (row.ignoredByUserId) { const user = await this.replica.user.findFirst({ where: { id: row.ignoredByUserId }, select: { displayName: true, name: true, email: true }, }); if (user) { ignoredByUserDisplayName = user.displayName ?? user.name ?? user.email; } } return { status: row.status, resolvedAt: row.resolvedAt, resolvedInVersion: row.resolvedInVersion, resolvedBy: row.resolvedBy, ignoredAt: row.ignoredAt, ignoredUntil: row.ignoredUntil, ignoredReason: row.ignoredReason, ignoredByUserId: row.ignoredByUserId, ignoredByUserDisplayName, ignoredUntilOccurrenceRate: row.ignoredUntilOccurrenceRate, ignoredUntilTotalOccurrences: row.ignoredUntilTotalOccurrences, ignoredAtOccurrenceCount: row.ignoredAtOccurrenceCount ? Number(row.ignoredAtOccurrenceCount) : null, }; } private async getRunList( organizationId: string, environmentId: string, options: { userId?: string; projectId: string; fingerprint: string; versions?: string[]; pageSize: number; from?: number; to?: number; cursor?: string; direction?: Direction; } ): Promise { const runListPresenter = new NextRunListPresenter(this.replica, this.clickhouse); const result = await runListPresenter.call(organizationId, environmentId, { userId: options.userId, projectId: options.projectId, rootOnly: false, errorId: ErrorId.toFriendlyId(options.fingerprint), versions: options.versions, pageSize: options.pageSize, from: options.from, to: options.to, cursor: options.cursor, direction: options.direction, }); if (result.runs.length === 0) { return undefined; } return result; } }