chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
import { z } from "zod";
|
||||
import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse";
|
||||
import { TimeGranularity } from "~/utils/timeGranularity";
|
||||
|
||||
const errorsListGranularity = new TimeGranularity([
|
||||
{ max: "2h", granularity: "1m" },
|
||||
{ max: "2d", granularity: "1h" },
|
||||
{ max: "2w", granularity: "1d" },
|
||||
{ max: "3 months", granularity: "1w" },
|
||||
{ max: "Infinity", granularity: "30d" },
|
||||
]);
|
||||
import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
import { type Direction } from "~/components/ListPagination";
|
||||
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
|
||||
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { getTaskIdentifiers } from "~/models/task.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
|
||||
|
||||
export type ErrorsListOptions = {
|
||||
userId?: string;
|
||||
projectId: string;
|
||||
// filters
|
||||
tasks?: string[];
|
||||
versions?: string[];
|
||||
statuses?: ErrorGroupStatus[];
|
||||
period?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
defaultPeriod?: string;
|
||||
retentionLimitDays?: number;
|
||||
// search
|
||||
search?: string;
|
||||
// pagination
|
||||
direction?: Direction;
|
||||
cursor?: string;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export const ErrorsListOptionsSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
tasks: z.array(z.string()).optional(),
|
||||
versions: z.array(z.string()).optional(),
|
||||
statuses: z.array(z.enum(["UNRESOLVED", "RESOLVED", "IGNORED"])).optional(),
|
||||
period: z.string().optional(),
|
||||
from: z.number().int().nonnegative().optional(),
|
||||
to: z.number().int().nonnegative().optional(),
|
||||
defaultPeriod: z.string().optional(),
|
||||
retentionLimitDays: z.number().int().positive().optional(),
|
||||
search: z.string().max(1000).optional(),
|
||||
direction: z.enum(["forward", "backward"]).optional(),
|
||||
cursor: z.string().optional(),
|
||||
pageSize: z.number().int().positive().max(1000).optional(),
|
||||
});
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25;
|
||||
|
||||
export type ErrorsList = Awaited<ReturnType<ErrorsListPresenter["call"]>>;
|
||||
export type ErrorGroup = ErrorsList["errorGroups"][0];
|
||||
export type ErrorsListAppliedFilters = ErrorsList["filters"];
|
||||
export type ErrorOccurrences = Awaited<ReturnType<ErrorsListPresenter["getOccurrences"]>>;
|
||||
export type ErrorOccurrenceActivity = ErrorOccurrences["data"][string];
|
||||
|
||||
type ErrorGroupCursor = {
|
||||
occurrenceCount: number;
|
||||
fingerprint: string;
|
||||
taskIdentifier: string;
|
||||
};
|
||||
|
||||
const ErrorGroupCursorSchema = z.object({
|
||||
occurrenceCount: z.number(),
|
||||
fingerprint: z.string(),
|
||||
taskIdentifier: z.string(),
|
||||
});
|
||||
|
||||
function encodeCursor(cursor: ErrorGroupCursor): string {
|
||||
return Buffer.from(JSON.stringify(cursor)).toString("base64");
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): ErrorGroupCursor | null {
|
||||
try {
|
||||
const decoded = Buffer.from(cursor, "base64").toString("utf-8");
|
||||
const parsed = JSON.parse(decoded);
|
||||
const validated = ErrorGroupCursorSchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
return null;
|
||||
}
|
||||
return validated.data as ErrorGroupCursor;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cursorFromRow(row: {
|
||||
occurrence_count: number;
|
||||
error_fingerprint: string;
|
||||
task_identifier: string;
|
||||
}): string {
|
||||
return encodeCursor({
|
||||
occurrenceCount: row.occurrence_count,
|
||||
fingerprint: row.error_fingerprint,
|
||||
taskIdentifier: row.task_identifier,
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
function escapeClickHouseString(val: string): string {
|
||||
return val.replace(/\\/g, "\\\\").replace(/\//g, "\\/").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
||||
}
|
||||
|
||||
export class ErrorsListPresenter extends BasePresenter {
|
||||
constructor(
|
||||
private readonly replica: PrismaClientOrTransaction,
|
||||
private readonly clickhouse: ClickHouse
|
||||
) {
|
||||
super(undefined, replica);
|
||||
}
|
||||
|
||||
public async call(
|
||||
organizationId: string,
|
||||
environmentId: string,
|
||||
{
|
||||
userId,
|
||||
projectId,
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
period,
|
||||
search,
|
||||
from,
|
||||
to,
|
||||
direction,
|
||||
cursor,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
defaultPeriod,
|
||||
retentionLimitDays,
|
||||
}: ErrorsListOptions
|
||||
) {
|
||||
const time = timeFilterFromTo({
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
defaultPeriod: defaultPeriod ?? "1d",
|
||||
});
|
||||
|
||||
let effectiveFrom = time.from;
|
||||
let effectiveTo = time.to;
|
||||
|
||||
let wasClampedByRetention = false;
|
||||
if (retentionLimitDays !== undefined && effectiveFrom) {
|
||||
const retentionCutoffDate = new Date(Date.now() - retentionLimitDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
if (effectiveFrom < retentionCutoffDate) {
|
||||
effectiveFrom = retentionCutoffDate;
|
||||
wasClampedByRetention = true;
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters =
|
||||
(tasks !== undefined && tasks.length > 0) ||
|
||||
(versions !== undefined && versions.length > 0) ||
|
||||
(search !== undefined && search !== "") ||
|
||||
(statuses !== undefined && statuses.length > 0);
|
||||
|
||||
const possibleTasksAsync = getTaskIdentifiers(environmentId);
|
||||
|
||||
// Pre-filter by status: since status lives in Postgres (ErrorGroupState) and the error
|
||||
// list comes from ClickHouse, we resolve inclusion/exclusion sets upfront so that
|
||||
// ClickHouse pagination operates on the correctly filtered dataset.
|
||||
const statusFilterAsync = this.resolveStatusFilter(environmentId, statuses);
|
||||
|
||||
const [possibleTasks, displayableEnvironment, statusFilter] = await Promise.all([
|
||||
possibleTasksAsync,
|
||||
findDisplayableEnvironment(environmentId, userId),
|
||||
statusFilterAsync,
|
||||
]);
|
||||
|
||||
if (!displayableEnvironment) {
|
||||
throw new ServiceValidationError("No environment found");
|
||||
}
|
||||
|
||||
if (statusFilter.empty) {
|
||||
return {
|
||||
errorGroups: [],
|
||||
pagination: {
|
||||
next: undefined,
|
||||
previous: undefined,
|
||||
},
|
||||
filters: {
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
search,
|
||||
period: time,
|
||||
from: effectiveFrom,
|
||||
to: effectiveTo,
|
||||
hasFilters,
|
||||
possibleTasks,
|
||||
wasClampedByRetention,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Query the per-minute error_occurrences_v1 table for time-scoped counts
|
||||
const queryBuilder = this.clickhouse.errors.occurrencesListQueryBuilder();
|
||||
|
||||
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
|
||||
queryBuilder.where("project_id = {projectId: String}", { projectId });
|
||||
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
|
||||
|
||||
// Precise time range filtering via WHERE on the minute column
|
||||
queryBuilder.where("minute >= toStartOfMinute(fromUnixTimestamp64Milli({fromTimeMs: Int64}))", {
|
||||
fromTimeMs: effectiveFrom.getTime(),
|
||||
});
|
||||
queryBuilder.where("minute <= toStartOfMinute(fromUnixTimestamp64Milli({toTimeMs: Int64}))", {
|
||||
toTimeMs: effectiveTo.getTime(),
|
||||
});
|
||||
|
||||
if (tasks && tasks.length > 0) {
|
||||
queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks });
|
||||
}
|
||||
|
||||
if (versions && versions.length > 0) {
|
||||
queryBuilder.where("task_version IN {versions: Array(String)}", { versions });
|
||||
}
|
||||
|
||||
if (statusFilter.includeKeys) {
|
||||
queryBuilder.where(
|
||||
"concat(task_identifier, '::', error_fingerprint) IN {statusIncludeKeys: Array(String)}",
|
||||
{ statusIncludeKeys: statusFilter.includeKeys }
|
||||
);
|
||||
}
|
||||
if (statusFilter.excludeKeys) {
|
||||
queryBuilder.where(
|
||||
"concat(task_identifier, '::', error_fingerprint) NOT IN {statusExcludeKeys: Array(String)}",
|
||||
{ statusExcludeKeys: statusFilter.excludeKeys }
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder.groupBy("error_fingerprint, task_identifier");
|
||||
|
||||
// Text search via HAVING (operates on aggregated values)
|
||||
if (search && search.trim() !== "") {
|
||||
const searchTerm = escapeClickHouseString(search.trim()).toLowerCase();
|
||||
queryBuilder.having(
|
||||
"(lower(error_type) like {searchPattern: String} OR lower(error_message) like {searchPattern: String})",
|
||||
{
|
||||
searchPattern: `%${searchTerm}%`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const isBackward = direction === "backward";
|
||||
const decodedCursor = cursor ? decodeCursor(cursor) : null;
|
||||
|
||||
if (decodedCursor) {
|
||||
const cmp = isBackward ? ">" : "<";
|
||||
queryBuilder.having(
|
||||
`(occurrence_count ${cmp} {cursorOccurrenceCount: UInt64}
|
||||
OR (occurrence_count = {cursorOccurrenceCount: UInt64} AND error_fingerprint ${cmp} {cursorFingerprint: String})
|
||||
OR (occurrence_count = {cursorOccurrenceCount: UInt64} AND error_fingerprint = {cursorFingerprint: String} AND task_identifier ${cmp} {cursorTaskIdentifier: String}))`,
|
||||
{
|
||||
cursorOccurrenceCount: decodedCursor.occurrenceCount,
|
||||
cursorFingerprint: decodedCursor.fingerprint,
|
||||
cursorTaskIdentifier: decodedCursor.taskIdentifier,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const sortDir = isBackward ? "ASC" : "DESC";
|
||||
queryBuilder.orderBy(
|
||||
`occurrence_count ${sortDir}, error_fingerprint ${sortDir}, task_identifier ${sortDir}`
|
||||
);
|
||||
queryBuilder.limit(pageSize + 1);
|
||||
|
||||
const [queryError, records] = await queryBuilder.execute();
|
||||
|
||||
if (queryError) {
|
||||
throw queryError;
|
||||
}
|
||||
|
||||
const results = records || [];
|
||||
const hasMore = results.length > pageSize;
|
||||
const page = results.slice(0, pageSize);
|
||||
|
||||
if (isBackward) {
|
||||
page.reverse();
|
||||
}
|
||||
|
||||
let nextCursor: string | undefined;
|
||||
let previousCursor: string | undefined;
|
||||
|
||||
if (isBackward) {
|
||||
previousCursor = hasMore && page.length > 0 ? cursorFromRow(page[0]) : undefined;
|
||||
nextCursor = page.length > 0 ? cursorFromRow(page[page.length - 1]) : undefined;
|
||||
} else {
|
||||
previousCursor = decodedCursor && page.length > 0 ? cursorFromRow(page[0]) : undefined;
|
||||
nextCursor = hasMore && page.length > 0 ? cursorFromRow(page[page.length - 1]) : undefined;
|
||||
}
|
||||
|
||||
const errorGroups = page;
|
||||
|
||||
// Fetch global first_seen / last_seen from the errors_v1 summary table
|
||||
const fingerprints = errorGroups.map((e) => e.error_fingerprint);
|
||||
const [globalSummaryMap, stateMap] = await Promise.all([
|
||||
this.getGlobalSummary(organizationId, projectId, environmentId, fingerprints),
|
||||
this.getErrorGroupStates(environmentId, errorGroups),
|
||||
]);
|
||||
|
||||
let transformedErrorGroups = errorGroups.map((error) => {
|
||||
const global = globalSummaryMap.get(error.error_fingerprint);
|
||||
const state = stateMap.get(`${error.task_identifier}:${error.error_fingerprint}`);
|
||||
return {
|
||||
errorType: error.error_type,
|
||||
errorMessage: error.error_message,
|
||||
fingerprint: error.error_fingerprint,
|
||||
taskIdentifier: error.task_identifier,
|
||||
firstSeen: global?.firstSeen ?? new Date(),
|
||||
lastSeen: global?.lastSeen ?? new Date(),
|
||||
count: error.occurrence_count,
|
||||
status: state?.status ?? "UNRESOLVED",
|
||||
resolvedAt: state?.resolvedAt ?? null,
|
||||
ignoredUntil: state?.ignoredUntil ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
errorGroups: transformedErrorGroups,
|
||||
pagination: {
|
||||
next: nextCursor,
|
||||
previous: previousCursor,
|
||||
},
|
||||
filters: {
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
search,
|
||||
period: time,
|
||||
from: effectiveFrom,
|
||||
to: effectiveTo,
|
||||
hasFilters,
|
||||
possibleTasks,
|
||||
wasClampedByRetention,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns bucketed occurrence counts for the given fingerprints over a time range.
|
||||
* Granularity is determined automatically from the range span.
|
||||
*/
|
||||
public async getOccurrences(
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
environmentId: string,
|
||||
fingerprints: string[],
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
data: Record<string, Array<{ date: Date; count: number }>>;
|
||||
}> {
|
||||
if (fingerprints.length === 0) {
|
||||
return { data: {} };
|
||||
}
|
||||
|
||||
const granularityMs = errorsListGranularity.getTimeGranularityMs(from, to);
|
||||
const intervalExpr = msToClickHouseInterval(granularityMs);
|
||||
|
||||
const queryBuilder = this.clickhouse.errors.createOccurrencesQueryBuilder(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 IN {fingerprints: Array(String)}", { fingerprints });
|
||||
queryBuilder.where("minute >= toStartOfMinute(fromUnixTimestamp64Milli({fromTimeMs: Int64}))", {
|
||||
fromTimeMs: from.getTime(),
|
||||
});
|
||||
queryBuilder.where("minute <= toStartOfMinute(fromUnixTimestamp64Milli({toTimeMs: Int64}))", {
|
||||
toTimeMs: to.getTime(),
|
||||
});
|
||||
|
||||
queryBuilder.groupBy("error_fingerprint, bucket_epoch");
|
||||
queryBuilder.orderBy("error_fingerprint ASC, 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);
|
||||
}
|
||||
|
||||
// Index results by fingerprint -> epoch -> count
|
||||
const grouped = new Map<string, Map<number, number>>();
|
||||
for (const row of records ?? []) {
|
||||
let byBucket = grouped.get(row.error_fingerprint);
|
||||
if (!byBucket) {
|
||||
byBucket = new Map();
|
||||
grouped.set(row.error_fingerprint, byBucket);
|
||||
}
|
||||
byBucket.set(row.bucket_epoch, (byBucket.get(row.bucket_epoch) ?? 0) + row.count);
|
||||
}
|
||||
|
||||
const data: Record<string, Array<{ date: Date; count: number }>> = {};
|
||||
for (const fp of fingerprints) {
|
||||
const byBucket = grouped.get(fp);
|
||||
data[fp] = buckets.map((epoch) => ({
|
||||
date: new Date(epoch * 1000),
|
||||
count: byBucket?.get(epoch) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return { data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which (task, fingerprint) pairs to include or exclude from the ClickHouse
|
||||
* query based on the requested status filter. Since status lives in Postgres and errors
|
||||
* live in ClickHouse, we resolve the filter set here so ClickHouse pagination is correct.
|
||||
*
|
||||
* - UNRESOLVED is the default (no ErrorGroupState row), so filtering FOR it means
|
||||
* excluding groups with non-matching explicit statuses.
|
||||
* - RESOLVED/IGNORED are explicit, so filtering for them means including only matching groups.
|
||||
*/
|
||||
private async resolveStatusFilter(
|
||||
environmentId: string,
|
||||
statuses?: ErrorGroupStatus[]
|
||||
): Promise<{
|
||||
includeKeys?: string[];
|
||||
excludeKeys?: string[];
|
||||
empty: boolean;
|
||||
}> {
|
||||
if (!statuses || statuses.length === 0) {
|
||||
return { empty: false };
|
||||
}
|
||||
|
||||
const allStatuses: ErrorGroupStatus[] = ["UNRESOLVED", "RESOLVED", "IGNORED"];
|
||||
const excludedStatuses = allStatuses.filter((s) => !statuses.includes(s));
|
||||
|
||||
if (excludedStatuses.length === 0) {
|
||||
return { empty: false };
|
||||
}
|
||||
|
||||
if (statuses.includes("UNRESOLVED")) {
|
||||
const excluded = await this.replica.errorGroupState.findMany({
|
||||
where: { environmentId, status: { in: excludedStatuses } },
|
||||
select: { taskIdentifier: true, errorFingerprint: true },
|
||||
});
|
||||
if (excluded.length === 0) {
|
||||
return { empty: false };
|
||||
}
|
||||
return {
|
||||
excludeKeys: excluded.map((g) => `${g.taskIdentifier}::${g.errorFingerprint}`),
|
||||
empty: false,
|
||||
};
|
||||
}
|
||||
|
||||
const included = await this.replica.errorGroupState.findMany({
|
||||
where: { environmentId, status: { in: statuses } },
|
||||
select: { taskIdentifier: true, errorFingerprint: true },
|
||||
});
|
||||
if (included.length === 0) {
|
||||
return { empty: true };
|
||||
}
|
||||
return {
|
||||
includeKeys: included.map((g) => `${g.taskIdentifier}::${g.errorFingerprint}`),
|
||||
empty: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-fetch ErrorGroupState rows from Postgres for the given ClickHouse error groups.
|
||||
* Returns a map keyed by `${taskIdentifier}:${errorFingerprint}`.
|
||||
*/
|
||||
private async getErrorGroupStates(
|
||||
environmentId: string,
|
||||
errorGroups: Array<{ task_identifier: string; error_fingerprint: string }>
|
||||
) {
|
||||
type StateValue = {
|
||||
status: ErrorGroupStatus;
|
||||
resolvedAt: Date | null;
|
||||
ignoredUntil: Date | null;
|
||||
};
|
||||
|
||||
const result = new Map<string, StateValue>();
|
||||
if (errorGroups.length === 0) return result;
|
||||
|
||||
const states = await this.replica.errorGroupState.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
OR: errorGroups.map((e) => ({
|
||||
taskIdentifier: e.task_identifier,
|
||||
errorFingerprint: e.error_fingerprint,
|
||||
})),
|
||||
},
|
||||
select: {
|
||||
taskIdentifier: true,
|
||||
errorFingerprint: true,
|
||||
status: true,
|
||||
resolvedAt: true,
|
||||
ignoredUntil: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const state of states) {
|
||||
result.set(`${state.taskIdentifier}:${state.errorFingerprint}`, {
|
||||
status: state.status,
|
||||
resolvedAt: state.resolvedAt,
|
||||
ignoredUntil: state.ignoredUntil,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches global first_seen / last_seen for a set of fingerprints from errors_v1.
|
||||
*/
|
||||
private async getGlobalSummary(
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
environmentId: string,
|
||||
fingerprints: string[]
|
||||
): Promise<Map<string, { firstSeen: Date; lastSeen: Date }>> {
|
||||
const result = new Map<string, { firstSeen: Date; lastSeen: Date }>();
|
||||
if (fingerprints.length === 0) return result;
|
||||
|
||||
const queryBuilder = this.clickhouse.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 IN {fingerprints: Array(String)}", { fingerprints });
|
||||
queryBuilder.groupBy("error_fingerprint, task_identifier");
|
||||
|
||||
const [queryError, records] = await queryBuilder.execute();
|
||||
|
||||
if (queryError || !records) return result;
|
||||
|
||||
for (const record of records) {
|
||||
const firstSeen = parseClickHouseDateTime(record.first_seen);
|
||||
const lastSeen = parseClickHouseDateTime(record.last_seen);
|
||||
const existing = result.get(record.error_fingerprint);
|
||||
|
||||
if (existing) {
|
||||
if (firstSeen < existing.firstSeen) existing.firstSeen = firstSeen;
|
||||
if (lastSeen > existing.lastSeen) existing.lastSeen = lastSeen;
|
||||
} else {
|
||||
result.set(record.error_fingerprint, { firstSeen, lastSeen });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user