Files
Mihai Popescu c8686b5f1c feat: tri-6738 Create aggregated logs page (#2862)
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>
2026-01-13 11:21:13 +00:00

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}`
);
}
}