c8686b5f1c
Closes #<issue> ## ✅ Checklist - [x] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) - [x] The PR title follows the convention. - [x] I ran and tested the code works --- ## Testing - Verified log detail view displays correctly with message, metadata, and attributes - Tested search highlighting functionality in log messages (escapes special regex characters) - Confirmed tabs (Details/Run) switch properly with keyboard shortcuts (d/r) - Verified run information loads via async fetcher in Run tab - Tested close button and Escape key for dismissing the panel - Verified log details display correct information: level badges, kind badges, timestamps, trace IDs, span IDs - Confirmed links to parent spans and run pages work correctly - Tested with various log levels (ERROR, WARN, INFO, DEBUG, TRACE) and kinds (SPAN, SPAN_EVENT, LOG_*) - Verified admin-only fields display correctly when user has admin access - Tested data loading states and error states (log not found, run not found) --- ## Changelog Created new Logs page. The information shown is gathered from the spans from each run. The feature supports all run filters with two new filters for level and logs text search. --- ## Screenshots <img width="2059" height="1196" alt="Logs page preview" src="https://github.com/user-attachments/assets/70b667b4-98cc-4728-855a-2766dd5c1aa5" /> 💯 --------- Co-authored-by: James Ritchie <james@trigger.dev>
345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
import { RunId } from "@trigger.dev/core/v3/isomorphic";
|
|
import { Prisma } from "@trigger.dev/database";
|
|
import { sqlDatabaseSchema } from "~/db.server";
|
|
import {
|
|
type FilterRunsOptions,
|
|
type IRunsRepository,
|
|
type ListRunsOptions,
|
|
type ListedRun,
|
|
type RunListInputOptions,
|
|
type RunsRepositoryOptions,
|
|
type TagListOptions,
|
|
convertRunListInputOptionsToFilterRunsOptions,
|
|
} from "./runsRepository.server";
|
|
|
|
export class PostgresRunsRepository implements IRunsRepository {
|
|
constructor(private readonly options: RunsRepositoryOptions) {}
|
|
|
|
get name() {
|
|
return "postgres";
|
|
}
|
|
|
|
async listRunIds(options: ListRunsOptions) {
|
|
const filterOptions = await convertRunListInputOptionsToFilterRunsOptions(
|
|
options,
|
|
this.options.prisma
|
|
);
|
|
|
|
const query = this.#buildRunIdsQuery(filterOptions, options.page);
|
|
const runs = await this.options.prisma.$queryRaw<{ id: string }[]>(query);
|
|
|
|
return runs.map((run) => run.id);
|
|
}
|
|
|
|
async listFriendlyRunIds(options: ListRunsOptions) {
|
|
const filterOptions = await convertRunListInputOptionsToFilterRunsOptions(
|
|
options,
|
|
this.options.prisma
|
|
);
|
|
|
|
const query = this.#buildFriendlyRunIdsQuery(filterOptions, options.page);
|
|
const runs = await this.options.prisma.$queryRaw<{ friendlyId: string }[]>(query);
|
|
|
|
return runs.map((run) => run.friendlyId);
|
|
}
|
|
|
|
async listRuns(options: ListRunsOptions) {
|
|
const filterOptions = await convertRunListInputOptionsToFilterRunsOptions(
|
|
options,
|
|
this.options.prisma
|
|
);
|
|
|
|
const query = this.#buildRunsQuery(filterOptions, options.page);
|
|
const runs = await this.options.prisma.$queryRaw<ListedRun[]>(query);
|
|
|
|
// If there are more runs than the page size, we need to fetch the next page
|
|
const hasMore = runs.length > options.page.size;
|
|
|
|
let nextCursor: string | null = null;
|
|
let previousCursor: string | null = null;
|
|
|
|
// Get cursors for next and previous pages
|
|
const direction = options.page.direction ?? "forward";
|
|
switch (direction) {
|
|
case "forward": {
|
|
previousCursor = options.page.cursor ? runs.at(0)?.id ?? null : null;
|
|
if (hasMore) {
|
|
// The next cursor should be the last run ID from this page
|
|
nextCursor = runs[options.page.size - 1]?.id ?? null;
|
|
}
|
|
break;
|
|
}
|
|
case "backward": {
|
|
const reversedRuns = [...runs].reverse();
|
|
if (hasMore) {
|
|
previousCursor = reversedRuns.at(1)?.id ?? null;
|
|
nextCursor = reversedRuns.at(options.page.size)?.id ?? null;
|
|
} else {
|
|
nextCursor = reversedRuns.at(options.page.size - 1)?.id ?? null;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
const runsToReturn =
|
|
options.page.direction === "backward" && hasMore
|
|
? runs.slice(1, options.page.size + 1)
|
|
: runs.slice(0, options.page.size);
|
|
|
|
// ClickHouse is slightly delayed, so we're going to do in-memory status filtering too
|
|
let filteredRuns = runsToReturn;
|
|
if (options.statuses && options.statuses.length > 0) {
|
|
filteredRuns = runsToReturn.filter((run) => options.statuses!.includes(run.status));
|
|
}
|
|
|
|
return {
|
|
runs: filteredRuns,
|
|
pagination: {
|
|
nextCursor,
|
|
previousCursor,
|
|
},
|
|
};
|
|
}
|
|
|
|
async countRuns(options: RunListInputOptions) {
|
|
const filterOptions = await convertRunListInputOptionsToFilterRunsOptions(
|
|
options,
|
|
this.options.prisma
|
|
);
|
|
|
|
const query = this.#buildCountQuery(filterOptions);
|
|
const result = await this.options.prisma.$queryRaw<{ count: bigint }[]>(query);
|
|
|
|
if (result.length === 0) {
|
|
throw new Error("No count rows returned");
|
|
}
|
|
|
|
return Number(result[0].count);
|
|
}
|
|
|
|
async listTags({ projectId, query, offset, limit }: TagListOptions) {
|
|
const tags = await this.options.prisma.taskRunTag.findMany({
|
|
select: {
|
|
name: true,
|
|
},
|
|
where: {
|
|
projectId,
|
|
name: query
|
|
? {
|
|
startsWith: query,
|
|
mode: "insensitive",
|
|
}
|
|
: undefined,
|
|
},
|
|
orderBy: {
|
|
id: "desc",
|
|
},
|
|
take: limit + 1,
|
|
skip: offset,
|
|
});
|
|
|
|
return {
|
|
tags: tags.map((tag) => tag.name),
|
|
};
|
|
}
|
|
|
|
#buildRunIdsQuery(
|
|
filterOptions: FilterRunsOptions,
|
|
page: { size: number; cursor?: string; direction?: "forward" | "backward" }
|
|
) {
|
|
const whereConditions = this.#buildWhereConditions(filterOptions, page.cursor, page.direction);
|
|
|
|
return Prisma.sql`
|
|
SELECT tr.id
|
|
FROM ${sqlDatabaseSchema}."TaskRun" tr
|
|
WHERE ${whereConditions}
|
|
ORDER BY ${page.direction === "backward" ? Prisma.sql`tr.id ASC` : Prisma.sql`tr.id DESC`}
|
|
LIMIT ${page.size + 1}
|
|
`;
|
|
}
|
|
|
|
#buildFriendlyRunIdsQuery(
|
|
filterOptions: FilterRunsOptions,
|
|
page: { size: number; cursor?: string; direction?: "forward" | "backward" }
|
|
) {
|
|
const whereConditions = this.#buildWhereConditions(filterOptions, page.cursor, page.direction);
|
|
|
|
return Prisma.sql`
|
|
SELECT tr."friendlyId"
|
|
FROM ${sqlDatabaseSchema}."TaskRun" tr
|
|
WHERE ${whereConditions}
|
|
ORDER BY ${page.direction === "backward" ? Prisma.sql`tr.id ASC` : Prisma.sql`tr.id DESC`}
|
|
LIMIT ${page.size + 1}
|
|
`;
|
|
}
|
|
|
|
#buildRunsQuery(
|
|
filterOptions: FilterRunsOptions,
|
|
page: { size: number; cursor?: string; direction?: "forward" | "backward" }
|
|
) {
|
|
const whereConditions = this.#buildWhereConditions(filterOptions, page.cursor, page.direction);
|
|
|
|
return Prisma.sql`
|
|
SELECT
|
|
tr.id,
|
|
tr."friendlyId",
|
|
tr."taskIdentifier",
|
|
tr."taskVersion",
|
|
tr."runtimeEnvironmentId",
|
|
tr.status,
|
|
tr."createdAt",
|
|
tr."startedAt",
|
|
tr."lockedAt",
|
|
tr."delayUntil",
|
|
tr."updatedAt",
|
|
tr."completedAt",
|
|
tr."isTest",
|
|
tr."spanId",
|
|
tr."idempotencyKey",
|
|
tr."ttl",
|
|
tr."expiredAt",
|
|
tr."costInCents",
|
|
tr."baseCostInCents",
|
|
tr."usageDurationMs",
|
|
tr."runTags",
|
|
tr."depth",
|
|
tr."rootTaskRunId",
|
|
tr."batchId",
|
|
tr."metadata",
|
|
tr."metadataType",
|
|
tr."machinePreset",
|
|
tr."queue"
|
|
FROM ${sqlDatabaseSchema}."TaskRun" tr
|
|
WHERE ${whereConditions}
|
|
ORDER BY ${page.direction === "backward" ? Prisma.sql`tr.id ASC` : Prisma.sql`tr.id DESC`}
|
|
LIMIT ${page.size + 1}
|
|
`;
|
|
}
|
|
|
|
#buildCountQuery(filterOptions: FilterRunsOptions) {
|
|
const whereConditions = this.#buildWhereConditions(filterOptions);
|
|
|
|
return Prisma.sql`
|
|
SELECT COUNT(*) as count
|
|
FROM ${sqlDatabaseSchema}."TaskRun" tr
|
|
WHERE ${whereConditions}
|
|
`;
|
|
}
|
|
|
|
#buildWhereConditions(
|
|
filterOptions: FilterRunsOptions,
|
|
cursor?: string,
|
|
direction?: "forward" | "backward"
|
|
) {
|
|
const conditions: Prisma.Sql[] = [];
|
|
|
|
// Environment filter
|
|
conditions.push(Prisma.sql`tr."runtimeEnvironmentId" = ${filterOptions.environmentId}`);
|
|
|
|
// Cursor pagination
|
|
if (cursor) {
|
|
if (direction === "forward" || !direction) {
|
|
conditions.push(Prisma.sql`tr.id < ${cursor}`);
|
|
} else {
|
|
conditions.push(Prisma.sql`tr.id > ${cursor}`);
|
|
}
|
|
}
|
|
|
|
// Task filters
|
|
if (filterOptions.tasks && filterOptions.tasks.length > 0) {
|
|
conditions.push(Prisma.sql`tr."taskIdentifier" IN (${Prisma.join(filterOptions.tasks)})`);
|
|
}
|
|
|
|
// Version filters
|
|
if (filterOptions.versions && filterOptions.versions.length > 0) {
|
|
conditions.push(Prisma.sql`tr."taskVersion" IN (${Prisma.join(filterOptions.versions)})`);
|
|
}
|
|
|
|
// Status filters
|
|
if (filterOptions.statuses && filterOptions.statuses.length > 0) {
|
|
conditions.push(
|
|
Prisma.sql`tr.status = ANY(ARRAY[${Prisma.join(
|
|
filterOptions.statuses
|
|
)}]::"TaskRunStatus"[])`
|
|
);
|
|
}
|
|
|
|
// Tag filters
|
|
if (filterOptions.tags && filterOptions.tags.length > 0) {
|
|
conditions.push(
|
|
Prisma.sql`tr."runTags" && ARRAY[${Prisma.join(filterOptions.tags)}]::text[]`
|
|
);
|
|
}
|
|
|
|
// Schedule filter
|
|
if (filterOptions.scheduleId) {
|
|
conditions.push(Prisma.sql`tr."scheduleId" = ${filterOptions.scheduleId}`);
|
|
}
|
|
|
|
// Time period filter
|
|
if (filterOptions.period) {
|
|
conditions.push(
|
|
Prisma.sql`tr."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${filterOptions.period}`
|
|
);
|
|
}
|
|
|
|
// From date filter
|
|
if (filterOptions.from) {
|
|
conditions.push(
|
|
Prisma.sql`tr."createdAt" >= ${new Date(filterOptions.from).toISOString()}::timestamp`
|
|
);
|
|
}
|
|
|
|
// To date filter
|
|
if (filterOptions.to) {
|
|
const toDate = new Date(filterOptions.to);
|
|
const now = new Date();
|
|
const clampedDate = toDate > now ? now : toDate;
|
|
conditions.push(Prisma.sql`tr."createdAt" <= ${clampedDate.toISOString()}::timestamp`);
|
|
}
|
|
|
|
// Test filter
|
|
if (typeof filterOptions.isTest === "boolean") {
|
|
conditions.push(Prisma.sql`tr."isTest" = ${filterOptions.isTest}`);
|
|
}
|
|
|
|
// Root only filter
|
|
if (filterOptions.rootOnly) {
|
|
conditions.push(Prisma.sql`tr."rootTaskRunId" IS NULL`);
|
|
}
|
|
|
|
// Batch filter
|
|
if (filterOptions.batchId) {
|
|
conditions.push(Prisma.sql`tr."batchId" = ${filterOptions.batchId}`);
|
|
}
|
|
|
|
// Bulk action filter
|
|
if (filterOptions.bulkId) {
|
|
conditions.push(
|
|
Prisma.sql`tr."bulkActionGroupIds" && ARRAY[${filterOptions.bulkId}]::text[]`
|
|
);
|
|
}
|
|
|
|
// Run ID filter
|
|
if (filterOptions.runId && filterOptions.runId.length > 0) {
|
|
const friendlyIds = filterOptions.runId.map((runId) => RunId.toFriendlyId(runId));
|
|
conditions.push(Prisma.sql`tr."friendlyId" IN (${Prisma.join(friendlyIds)})`);
|
|
}
|
|
|
|
// Queue filter
|
|
if (filterOptions.queues && filterOptions.queues.length > 0) {
|
|
conditions.push(Prisma.sql`tr."queue" IN (${Prisma.join(filterOptions.queues)})`);
|
|
}
|
|
|
|
// Machine preset filter
|
|
if (filterOptions.machines && filterOptions.machines.length > 0) {
|
|
conditions.push(Prisma.sql`tr."machinePreset" IN (${Prisma.join(filterOptions.machines)})`);
|
|
}
|
|
|
|
// Combine all conditions with AND
|
|
return conditions.reduce((acc, condition) =>
|
|
acc === null ? condition : Prisma.sql`${acc} AND ${condition}`
|
|
);
|
|
}
|
|
}
|