Files
2026-07-13 13:32:57 +08:00

492 lines
16 KiB
TypeScript

import {
type ClickHouse,
type LogsSearchListResult,
type WhereCondition,
} from "@internal/clickhouse";
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
import { z } from "zod";
import { EVENT_STORE_TYPES, getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
import { type Direction } from "~/components/ListPagination";
import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { env } from "~/env.server";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
import { kindToLevel, type LogLevel, LogLevelSchema } from "~/utils/logUtils";
import {
convertClickhouseDateTime64ToJsDate,
convertDateToClickhouseDateTime,
} from "~/v3/eventRepository/clickhouseEventRepository.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
export type { LogLevel };
type ErrorAttributes = {
error?: {
message?: unknown;
};
[key: string]: unknown;
};
function escapeClickHouseString(val: string): string {
return val.replace(/\\/g, "\\\\").replace(/\//g, "\\/").replace(/%/g, "\\%").replace(/_/g, "\\_");
}
export type LogsListOptions = {
userId?: string;
projectId: string;
// filters
tasks?: string[];
runId?: string;
period?: string;
from?: number;
to?: number;
levels?: LogLevel[];
defaultPeriod?: string;
retentionLimitDays?: number;
// search
search?: string;
// pagination
direction?: Direction;
cursor?: string;
pageSize?: number;
};
export const LogsListOptionsSchema = z.object({
userId: z.string().optional(),
projectId: z.string(),
tasks: z.array(z.string()).optional(),
runId: z.string().optional(),
period: z.string().optional(),
from: z.number().int().nonnegative().optional(),
to: z.number().int().nonnegative().optional(),
levels: z.array(LogLevelSchema).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 DAY_MS = 24 * 60 * 60 * 1000;
export type LogsList = Awaited<ReturnType<LogsListPresenter["call"]>>;
export type LogEntry = LogsList["logs"][0];
export type LogsListAppliedFilters = LogsList["filters"];
// Bump when the cursor shape changes so stale cursors are ignored (reset to the first page)
// rather than misparsed.
const LOG_CURSOR_VERSION = 2;
// Cursor is a base64 encoded JSON of the pagination keys
type LogCursor = {
v: number;
organizationId: string;
environmentId: string;
triggeredTimestamp: string; // DateTime64(9) string
traceId: string;
spanId: string;
};
const LogCursorSchema = z.object({
v: z.literal(LOG_CURSOR_VERSION),
organizationId: z.string(),
environmentId: z.string(),
triggeredTimestamp: z.string(),
traceId: z.string(),
spanId: z.string(),
});
function encodeCursor(cursor: LogCursor): string {
return Buffer.from(JSON.stringify(cursor)).toString("base64");
}
function decodeCursor(cursor: string): LogCursor | null {
try {
const decoded = Buffer.from(cursor, "base64").toString("utf-8");
const parsed = JSON.parse(decoded);
const validated = LogCursorSchema.safeParse(parsed);
if (!validated.success) {
return null;
}
return validated.data;
} catch {
return null;
}
}
// Ordered list of lower bounds to try, narrowest (most recent) first, ending at the user's
// requested floor (or undefined for an unbounded-below window). Because rows are returned
// newest-first, a narrow window that already fills a page returns the exact same top rows the
// full window would, so widening only happens when a page comes back short.
function buildProbeFloors(
ceil: Date,
hardFloor: Date | undefined,
stepDays: number[]
): (Date | undefined)[] {
const floors: (Date | undefined)[] = [];
for (const days of stepDays) {
let candidate = new Date(ceil.getTime() - days * DAY_MS);
if (hardFloor && candidate <= hardFloor) {
candidate = hardFloor;
}
floors.push(candidate);
if (hardFloor && candidate.getTime() === hardFloor.getTime()) {
// Reached the requested floor; nothing wider left to probe.
return floors;
}
}
// Final probe always covers the full requested window (or unbounded if no floor was given).
floors.push(hardFloor);
return floors;
}
// Convert display level to ClickHouse kinds and statuses
function levelToKindsAndStatuses(level: LogLevel): { kinds?: string[]; statuses?: string[] } {
switch (level) {
case "TRACE":
return { kinds: ["SPAN"] };
case "DEBUG":
return { kinds: ["LOG_DEBUG"] };
case "INFO":
return { kinds: ["LOG_INFO", "LOG_LOG"] };
case "WARN":
return { kinds: ["LOG_WARN"] };
case "ERROR":
return { kinds: ["LOG_ERROR", "SPAN_EVENT"], statuses: ["ERROR"] };
}
}
export class LogsListPresenter extends BasePresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
) {
super(undefined, replica);
}
public async call(
organizationId: string,
environmentId: string,
{
userId,
projectId,
tasks,
runId,
period,
levels,
search,
from,
to,
cursor,
pageSize = env.LOGS_LIST_DEFAULT_PAGE_SIZE,
defaultPeriod,
retentionLimitDays,
}: LogsListOptions
) {
const time = timeFilterFromTo({
period,
from,
to,
defaultPeriod: defaultPeriod ?? "1h",
});
let effectiveFrom = time.from;
let effectiveTo = time.to;
// Apply retention limit if provided
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) ||
(runId !== undefined && runId !== "") ||
(levels !== undefined && levels.length > 0) ||
(search !== undefined && search !== "") ||
!time.isDefault;
const possibleTasksAsync = getTaskIdentifiers(environmentId);
const bulkActionsAsync = this.replica.bulkActionGroup.findMany({
select: {
friendlyId: true,
type: true,
createdAt: true,
name: true,
},
where: {
projectId: projectId,
environmentId,
},
orderBy: {
createdAt: "desc",
},
take: 20,
});
const [possibleTasks, bulkActions, displayableEnvironment] = await Promise.all([
possibleTasksAsync,
bulkActionsAsync,
findDisplayableEnvironment(environmentId, userId),
]);
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
// Determine which store to use based on organization configuration
const { store } = await getConfiguredEventRepository(organizationId);
// Throw error if postgres is detected
if (store === EVENT_STORE_TYPES.POSTGRES) {
throw new ServiceValidationError(
"Logs are not available for PostgreSQL event store. Please contact support."
);
}
if (store === EVENT_STORE_TYPES.CLICKHOUSE) {
throw new ServiceValidationError(
"Logs are not available for ClickHouse event store. Please contact support."
);
}
const effectivePageSize = Math.min(pageSize, env.LOGS_LIST_MAX_PAGE_SIZE);
// Only honor a cursor scoped to this org+env; one copied from another scope would shift the
// pagination anchor instead of resetting to the first page.
const parsedCursor = cursor ? decodeCursor(cursor) : null;
const decodedCursor =
parsedCursor &&
parsedCursor.organizationId === organizationId &&
parsedCursor.environmentId === environmentId
? parsedCursor
: null;
// Effective upper bound, always clamped to now so a probe never runs [floor, +inf).
const now = new Date();
const clampedTo = effectiveTo !== undefined ? (effectiveTo > now ? now : effectiveTo) : now;
const searchTerm =
search && search.trim() !== ""
? escapeClickHouseString(search.trim()).toLowerCase()
: undefined;
// Runs the full list query restricted to a single [floor, ceil] window. The recent-first
// probe loop below calls this with progressively wider floors.
const runProbe = (floor: Date | undefined) => {
const queryBuilder = this.clickhouse.taskEventsSearch.logsListQueryBuilder();
// The materialized view excludes events without a trace_id; this guards the legacy tail.
queryBuilder.where("trace_id != ''");
queryBuilder.where("environment_id = {environmentId: String}", { environmentId });
queryBuilder.where("organization_id = {organizationId: String}", { organizationId });
queryBuilder.where("project_id = {projectId: String}", { projectId });
if (clampedTo) {
queryBuilder.where("triggered_timestamp <= {triggeredAtEnd: DateTime64(3)}", {
triggeredAtEnd: convertDateToClickhouseDateTime(clampedTo),
});
}
if (floor) {
queryBuilder.where("triggered_timestamp >= {triggeredAtStart: DateTime64(3)}", {
triggeredAtStart: convertDateToClickhouseDateTime(floor),
});
}
// Task filter (applies directly to ClickHouse)
if (tasks && tasks.length > 0) {
queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks });
}
// Run ID filter
if (runId && runId !== "") {
queryBuilder.where("run_id = {runId: String}", { runId });
}
// Case-insensitive search in message and attributes
if (searchTerm !== undefined) {
queryBuilder.where(
"(lower(message) like {searchPattern: String} OR lower(attributes_text) like {searchPattern: String})",
{ searchPattern: `%${searchTerm}%` }
);
}
if (levels && levels.length > 0) {
const conditions: WhereCondition[] = [];
for (let i = 0; i < levels.length; i++) {
const filter = levelToKindsAndStatuses(levels[i]);
if (filter.kinds && filter.kinds.length > 0) {
conditions.push({
clause: `kind IN {kinds_${i}: Array(String)} AND status NOT IN {excluded_statuses: Array(String)}`,
params: {
[`kinds_${i}`]: filter.kinds,
excluded_statuses: ["ERROR", "CANCELLED"],
},
});
}
if (filter.statuses && filter.statuses.length > 0) {
conditions.push({
clause: `status IN {statuses_${i}: Array(String)}`,
params: { [`statuses_${i}`]: filter.statuses },
});
}
}
queryBuilder.whereOr(conditions);
}
// Keyset pagination over the full sort key. ORDER BY is DESC, so the next page is the rows
// that sort after the cursor (strictly less-than). (triggered_timestamp, trace_id) is not
// unique because spans of a trace share both, so span_id is the final tiebreaker; without
// it rows at a tie boundary could be skipped or duplicated across pages.
if (decodedCursor) {
queryBuilder.where(
`(triggered_timestamp < {cursorTriggeredTimestamp: String}
OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String})
OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String}))`,
{
cursorTriggeredTimestamp: decodedCursor.triggeredTimestamp,
cursorTraceId: decodedCursor.traceId,
cursorSpanId: decodedCursor.spanId,
}
);
}
queryBuilder.orderBy("triggered_timestamp DESC, trace_id DESC, span_id DESC");
// Limit + 1 to check if there are more results
queryBuilder.limit(effectivePageSize + 1);
return queryBuilder.execute();
};
// Page ceiling: the cursor (deeper pages) or the requested upper bound. Widen the lower
// bound only when a recent window doesn't fill the page.
const ceil = decodedCursor
? convertClickhouseDateTime64ToJsDate(decodedCursor.triggeredTimestamp)
: (clampedTo ?? new Date());
const probeFloors = buildProbeFloors(
ceil,
effectiveFrom ?? undefined,
env.LOGS_LIST_RECENT_FIRST_PROBE_DAYS
);
let records: LogsSearchListResult[] = [];
for (const floor of probeFloors) {
const [queryError, probeRecords] = await runProbe(floor);
if (queryError) {
throw queryError;
}
records = probeRecords ?? [];
if (records.length > effectivePageSize) {
// Page is full from this window; older rows can't be in the top page, stop widening.
break;
}
}
const results = records;
const hasMore = results.length > effectivePageSize;
const logs = results.slice(0, effectivePageSize);
// Build next cursor from the last item
let nextCursor: string | undefined;
if (hasMore && logs.length > 0) {
const lastLog = logs[logs.length - 1];
nextCursor = encodeCursor({
v: LOG_CURSOR_VERSION,
organizationId,
environmentId,
triggeredTimestamp: lastLog.triggered_timestamp,
traceId: lastLog.trace_id,
spanId: lastLog.span_id,
});
}
// Transform results
// Use :: as separator since dash conflicts with date format in start_time
const transformedLogs = logs.map((log) => {
let displayMessage = log.message;
// For error logs with status ERROR, try to extract error message from attributes
if (log.status === "ERROR" && log.attributes_text) {
try {
const attributes = JSON.parse(log.attributes_text) as ErrorAttributes;
if (attributes?.error?.message && typeof attributes.error.message === "string") {
displayMessage = attributes.error.message;
}
} catch {
// If attributes parsing fails, use the regular message
}
}
return {
id: `${log.trace_id}::${log.span_id}::${log.run_id}::${log.start_time}`,
runId: log.run_id,
taskIdentifier: log.task_identifier,
startTime: convertClickhouseDateTime64ToJsDate(log.start_time).toISOString(),
triggeredTimestamp: convertClickhouseDateTime64ToJsDate(
log.triggered_timestamp
).toISOString(),
traceId: log.trace_id,
spanId: log.span_id,
parentSpanId: log.parent_span_id || null,
message: displayMessage,
kind: log.kind,
status: log.status,
duration: typeof log.duration === "number" ? log.duration : Number(log.duration),
level: kindToLevel(log.kind, log.status),
};
});
return {
logs: transformedLogs,
pagination: {
next: nextCursor,
previous: undefined, // For now, only support forward pagination
},
possibleTasks,
bulkActions: bulkActions.map((bulkAction) => ({
id: bulkAction.friendlyId,
type: bulkAction.type,
createdAt: bulkAction.createdAt,
name: bulkAction.name || bulkAction.friendlyId,
})),
filters: {
tasks: tasks || [],
levels: levels || [],
from: effectiveFrom,
to: effectiveTo,
},
hasFilters,
hasAnyLogs: transformedLogs.length > 0,
searchTerm: search,
retention:
retentionLimitDays !== undefined
? {
limitDays: retentionLimitDays,
wasClamped: wasClampedByRetention,
}
: undefined,
};
}
}