From 30a7ad10172c4e631286f2a8e9ec3f6913205063 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Sat, 20 Dec 2025 17:43:31 +0200 Subject: [PATCH 01/28] Logs page MVP --- .../app/components/logs/LogDetailView.tsx | 320 +++++++++++++ .../app/components/logs/LogsSearchInput.tsx | 135 ++++++ apps/webapp/app/components/logs/LogsTable.tsx | 279 +++++++++++ .../app/components/navigation/SideMenu.tsx | 9 + .../app/components/primitives/DateTime.tsx | 3 +- .../app/components/runs/v3/RunFilters.tsx | 4 +- .../v3/LogDetailPresenter.server.ts | 131 ++++++ .../app/presenters/v3/LogPresenter.server.ts | 0 .../presenters/v3/LogsListPresenter.server.ts | 440 ++++++++++++++++++ .../route.tsx | 298 ++++++++++++ ...projectParam.env.$envParam.logs.$logId.tsx | 74 +++ ...ojects.$projectParam.env.$envParam.logs.ts | 46 ++ .../clickhouseRunsRepository.server.ts | 23 + .../postgresRunsRepository.server.ts | 27 ++ .../runsRepository/runsRepository.server.ts | 44 ++ apps/webapp/app/utils/pathBuilder.ts | 11 + internal-packages/clickhouse/src/index.ts | 4 + .../clickhouse/src/taskEvents.ts | 91 ++++ references/nextjs-realtime/.eslintrc.json | 6 + 19 files changed, 1943 insertions(+), 2 deletions(-) create mode 100644 apps/webapp/app/components/logs/LogDetailView.tsx create mode 100644 apps/webapp/app/components/logs/LogsSearchInput.tsx create mode 100644 apps/webapp/app/components/logs/LogsTable.tsx create mode 100644 apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts create mode 100644 apps/webapp/app/presenters/v3/LogPresenter.server.ts create mode 100644 apps/webapp/app/presenters/v3/LogsListPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts create mode 100644 references/nextjs-realtime/.eslintrc.json diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx new file mode 100644 index 0000000000..0f5d1515fd --- /dev/null +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -0,0 +1,320 @@ +import { XMarkIcon, ArrowTopRightOnSquareIcon, ClockIcon } from "@heroicons/react/20/solid"; +import { Link } from "@remix-run/react"; +import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; +import { useEffect } from "react"; +import { useTypedFetcher } from "remix-typedjson"; +import { cn } from "~/utils/cn"; +import { Button } from "~/components/primitives/Buttons"; +import { DateTime } from "~/components/primitives/DateTime"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; +import { v3RunSpanPath } from "~/utils/pathBuilder"; +import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId"; + +type LogDetailViewProps = { + logId: string; + // If we have the log entry from the list, we can display it immediately + initialLog?: LogEntry; + onClose: () => void; +}; + +// Level badge color styles +function getLevelColor(level: string): string { + switch (level) { + case "ERROR": + return "text-error bg-error/10 border-error/20"; + case "WARN": + return "text-warning bg-warning/10 border-warning/20"; + case "DEBUG": + return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; + case "INFO": + return "text-blue-400 bg-blue-500/10 border-blue-500/20"; + case "TRACE": + return "text-charcoal-500 bg-charcoal-800 border-charcoal-700"; + case "LOG": + default: + return "text-text-dimmed bg-charcoal-750 border-charcoal-700"; + } +} + +// Event kind badge color styles +function getKindColor(kind: string): string { + if (kind === "SPAN") { + return "text-purple-400 bg-purple-500/10 border-purple-500/20"; + } + if (kind === "SPAN_EVENT") { + return "text-amber-400 bg-amber-500/10 border-amber-500/20"; + } + if (kind.startsWith("LOG_")) { + return "text-blue-400 bg-blue-500/10 border-blue-500/20"; + } + return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; +} + +// Get human readable kind label +function getKindLabel(kind: string): string { + switch (kind) { + case "SPAN": + return "Span"; + case "SPAN_EVENT": + return "Event"; + case "LOG_DEBUG": + return "Log"; + case "LOG_INFO": + return "Log"; + case "LOG_WARN": + return "Log"; + case "LOG_ERROR": + return "Log"; + case "LOG_LOG": + return "Log"; + case "DEBUG_EVENT": + return "Debug"; + case "ANCESTOR_OVERRIDE": + return "Override"; + default: + return kind; + } +} + +// Status badge color styles +function getStatusColor(status: string): string { + switch (status) { + case "OK": + return "text-success bg-success/10 border-success/20"; + case "ERROR": + return "text-error bg-error/10 border-error/20"; + case "CANCELLED": + return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; + case "PARTIAL": + return "text-pending bg-pending/10 border-pending/20"; + default: + return "text-text-dimmed bg-charcoal-750 border-charcoal-700"; + } +} + +export function LogDetailView({ logId, initialLog, onClose }: LogDetailViewProps) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const fetcher = useTypedFetcher(); + + // Fetch full log details when logId changes + useEffect(() => { + if (!logId) return; + + fetcher.load( + `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(logId)}` + ); + }, [organization.slug, project.slug, environment.slug, logId]); + + const isLoading = fetcher.state === "loading"; + const log = fetcher.data ?? initialLog; + + // Handle Escape key to close panel + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + if (isLoading && !log) { + return ( +
+ +
+ ); + } + + if (!log) { + return ( +
+
+ Log Details + +
+
+ Log not found +
+
+ ); + } + + const runPath = v3RunSpanPath( + organization, + project, + environment, + { friendlyId: log.runId }, + { spanId: log.spanId } + ); + + return ( +
+ {/* Header */} +
+
+ + {getKindLabel(log.kind)} + + + {log.level} + + · + +
+ +
+ + {/* Content */} +
+ {/* Message */} +
+ Message +
+
+              {log.message}
+            
+
+
+ + {/* Run Link */} +
+ Run +
+ {log.runId} + + + +
+
+ + {/* Details Grid */} +
+ Details +
+ + + + 0 + ? formatDurationNanoseconds(log.duration, { style: "short" }) + : "–" + } + icon={} + /> + + + {log.parentSpanId && ( + + )} +
+
+ + {/* Metadata - only available in full log detail */} + {"rawMetadata" in log && + (log as { rawMetadata?: string }).rawMetadata && + (log as { rawMetadata?: string }).rawMetadata !== "{}" && ( +
+ Metadata +
+
+                  {JSON.stringify(
+                    "metadata" in log
+                      ? (log as { metadata: Record }).metadata
+                      : JSON.parse((log as { rawMetadata: string }).rawMetadata),
+                    null,
+                    2
+                  )}
+                
+
+
+ )} + + {/* Attributes - only available in full log detail */} + {"rawAttributes" in log && + (log as { rawAttributes?: string }).rawAttributes && + (log as { rawAttributes?: string }).rawAttributes !== "{}" && ( +
+ Attributes +
+
+                  {JSON.stringify(
+                    "attributes" in log
+                      ? (log as { attributes: Record }).attributes
+                      : JSON.parse((log as { rawAttributes: string }).rawAttributes),
+                    null,
+                    2
+                  )}
+                
+
+
+ )} +
+
+ ); +} + +function DetailItem({ + label, + value, + mono = false, + small = false, + icon, +}: { + label: string; + value: string; + mono?: boolean; + small?: boolean; + icon?: React.ReactNode; +}) { + return ( +
+ + {label} + +
+ {icon} + + {value} + +
+
+ ); +} diff --git a/apps/webapp/app/components/logs/LogsSearchInput.tsx b/apps/webapp/app/components/logs/LogsSearchInput.tsx new file mode 100644 index 0000000000..e2e0f68040 --- /dev/null +++ b/apps/webapp/app/components/logs/LogsSearchInput.tsx @@ -0,0 +1,135 @@ +import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { useNavigate } from "@remix-run/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; +import { Input } from "~/components/primitives/Input"; +import { ShortcutKey } from "~/components/primitives/ShortcutKey"; +import { cn } from "~/utils/cn"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { AIFilterInput } from "~/components/runs/v3/AIFilterInput"; + +type SearchMode = "ai" | "text"; + +export function LogsSearchInput() { + const location = useOptimisticLocation(); + const navigate = useNavigate(); + const inputRef = useRef(null); + + // Get initial search value from URL + const searchParams = new URLSearchParams(location.search); + const initialSearch = searchParams.get("search") ?? ""; + + const [mode, setMode] = useState("text"); + const [text, setText] = useState(initialSearch); + const [isFocused, setIsFocused] = useState(false); + + // Update text when URL search param changes (only when not focused to avoid overwriting user input) + useEffect(() => { + const params = new URLSearchParams(location.search); + const urlSearch = params.get("search") ?? ""; + if (urlSearch !== text && !isFocused) { + setText(urlSearch); + } + }, [location.search]); + + const handleSubmit = useCallback(() => { + const params = new URLSearchParams(location.search); + if (text.trim()) { + params.set("search", text.trim()); + } else { + params.delete("search"); + } + // Reset cursor when searching + params.delete("cursor"); + params.delete("direction"); + navigate(`${location.pathname}?${params.toString()}`, { replace: true }); + }, [text, location.pathname, location.search, navigate]); + + const handleClear = useCallback(() => { + setText(""); + const params = new URLSearchParams(location.search); + params.delete("search"); + params.delete("cursor"); + params.delete("direction"); + navigate(`${location.pathname}?${params.toString()}`, { replace: true }); + }, [location.pathname, location.search, navigate]); + + const toggleMode = useCallback(() => { + // Clear text search when switching modes + if (mode === "text" && text.trim()) { + handleClear(); + } + setMode((prev) => (prev === "ai" ? "text" : "ai")); + }, [mode, text, handleClear]); + + return ( +
+ {/* Mode toggle button */} + + + {/* Show AI or text search based on mode */} + {mode === "ai" ? ( + + ) : ( +
+
+ setText(e.target.value)} + fullWidth + className={cn(isFocused && "placeholder:text-text-dimmed/70")} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(); + } + if (e.key === "Escape") { + e.currentTarget.blur(); + } + }} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + icon={} + accessory={ + text.length > 0 ? ( + + ) : undefined + } + /> +
+ + {text.length > 0 && ( + + )} +
+ )} +
+ ); +} diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx new file mode 100644 index 0000000000..fe14b29082 --- /dev/null +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -0,0 +1,279 @@ +import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; +import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; +import { type ReactNode, useEffect, useRef } from "react"; +import { cn } from "~/utils/cn"; +import { Button } from "~/components/primitives/Buttons"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import type { LogEntry, LogsListAppliedFilters } from "~/presenters/v3/LogsListPresenter.server"; +import { v3RunSpanPath } from "~/utils/pathBuilder"; +import { DateTime } from "../primitives/DateTime"; +import { Paragraph } from "../primitives/Paragraph"; +import { Spinner } from "../primitives/Spinner"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, + type TableVariant, +} from "../primitives/Table"; + +type LogsTableProps = { + logs: LogEntry[]; + hasFilters: boolean; + filters: LogsListAppliedFilters; + searchTerm?: string; + isLoading?: boolean; + isLoadingMore?: boolean; + hasMore?: boolean; + onLoadMore?: () => void; + variant?: TableVariant; + selectedLogId?: string; + onLogSelect?: (logId: string) => void; +}; + +// Level badge color styles +function getLevelColor(level: LogEntry["level"]): string { + switch (level) { + case "ERROR": + return "text-error bg-error/10 border-error/20"; + case "WARN": + return "text-warning bg-warning/10 border-warning/20"; + case "DEBUG": + return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; + case "INFO": + return "text-blue-400 bg-blue-500/10 border-blue-500/20"; + case "TRACE": + return "text-charcoal-500 bg-charcoal-800 border-charcoal-700"; + case "LOG": + default: + return "text-text-dimmed bg-charcoal-750 border-charcoal-700"; + } +} + +// Case-insensitive text highlighting +function highlightText( + text: string, + searchTerm: string | undefined +): ReactNode { + if (!searchTerm || searchTerm.trim() === "") { + return text; + } + + const lowerText = text.toLowerCase(); + const lowerSearch = searchTerm.toLowerCase(); + const index = lowerText.indexOf(lowerSearch); + + if (index === -1) { + return text; + } + + return ( + <> + {text.slice(0, index)} + + {text.slice(index, index + searchTerm.length)} + + {text.slice(index + searchTerm.length)} + + ); +} + +export function LogsTable({ + logs, + hasFilters, + filters, + searchTerm, + isLoading = false, + isLoadingMore = false, + hasMore = false, + onLoadMore, + variant = "dimmed", + selectedLogId, + onLogSelect, +}: LogsTableProps) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const loadMoreRef = useRef(null); + + // Intersection observer for infinite scroll + useEffect(() => { + if (!hasMore || isLoadingMore || !onLoadMore) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + onLoadMore(); + } + }, + { threshold: 0.1 } + ); + + const currentRef = loadMoreRef.current; + if (currentRef) { + observer.observe(currentRef); + } + + return () => { + if (currentRef) { + observer.unobserve(currentRef); + } + }; + }, [hasMore, isLoadingMore, onLoadMore]); + + return ( +
+ + + + Time + Run + Task + Level + Duration + Message + + + + {logs.length === 0 && !hasFilters ? ( + + {!isLoading && } + + ) : logs.length === 0 ? ( + + ) : ( + logs.map((log) => { + const isSelected = selectedLogId === log.id; + const runPath = v3RunSpanPath( + organization, + project, + environment, + { friendlyId: log.runId }, + { spanId: log.spanId } + ); + + const handleRowClick = () => onLogSelect?.(log.id); + + return ( + + + + + + + {log.runId.slice(0, 12)}... + + + + + {log.taskIdentifier} + + + + {log.level} + + + + {log.duration > 0 + ? formatDurationNanoseconds(log.duration, { style: "short" }) + : "–"} + + + + {highlightText(log.message, searchTerm)} + + + + ); + }) + )} + +
+ {/* Infinite scroll trigger */} + {hasMore && logs.length > 0 && ( +
+ {isLoadingMore && ( +
+ Loading more… +
+ )} +
+ )} + {isLoading && ( +
+ Loading… +
+ )} +
+ ); +} + +function NoLogs({ title }: { title: string }) { + return ( +
+ {title} +
+ ); +} + +function BlankState({ isLoading }: { isLoading?: boolean }) { + if (isLoading) return ; + + return ( + +
+ + No logs match your filters. Try refreshing or modifying your filters. + +
+ +
+
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index bda24a32ea..00ee4d5d18 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -8,6 +8,7 @@ import { ClockIcon, Cog8ToothIcon, CogIcon, + DocumentTextIcon, FolderIcon, FolderOpenIcon, GlobeAmericasIcon, @@ -56,6 +57,7 @@ import { v3BatchesPath, v3BillingPath, v3BulkActionsPath, + v3LogsPath, v3DeploymentsPath, v3EnvironmentPath, v3EnvironmentVariablesPath, @@ -260,6 +262,13 @@ export function SideMenu({ to={v3DeploymentsPath(organization, project, environment)} data-action="deployments" /> + ; + return ; }; export function formatDateTime( @@ -249,6 +249,7 @@ export const DateTimeAccurate = ({ button={{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}} content={tooltipContent} side="right" + asChild={true} /> ); }; diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 297d95be0b..01422f52d4 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -324,6 +324,8 @@ type RunFiltersProps = { }[]; rootOnlyDefault: boolean; hasFilters: boolean; + /** Hide the AI search input (useful when replacing with a custom search component) */ + hideSearch?: boolean; }; export function RunsFilters(props: RunFiltersProps) { @@ -344,7 +346,7 @@ export function RunsFilters(props: RunFiltersProps) { return (
- + {!props.hideSearch && } diff --git a/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts new file mode 100644 index 0000000000..321b2226b0 --- /dev/null +++ b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts @@ -0,0 +1,131 @@ +import { type ClickHouse } from "@internal/clickhouse"; +import { type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { + convertClickhouseDateTime64ToJsDate, + convertDateToClickhouseDateTime, +} from "~/v3/eventRepository/clickhouseEventRepository.server"; + +export type LogDetailOptions = { + environmentId: string; + organizationId: string; + projectId: string; + spanId: string; + traceId: string; + // Time bounds for query optimization + startTime?: Date; +}; + +export type LogDetail = Awaited>; + +// Convert ClickHouse kind to display level +function kindToLevel( + kind: string +): "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "LOG" { + switch (kind) { + case "DEBUG_EVENT": + case "LOG_DEBUG": + return "DEBUG"; + case "LOG_INFO": + return "INFO"; + case "LOG_WARN": + return "WARN"; + case "LOG_ERROR": + return "ERROR"; + case "LOG_LOG": + return "LOG"; + case "SPAN": + case "ANCESTOR_OVERRIDE": + case "SPAN_EVENT": + default: + return "TRACE"; + } +} + +export class LogDetailPresenter { + constructor( + private readonly replica: PrismaClientOrTransaction, + private readonly clickhouse: ClickHouse + ) {} + + public async call(options: LogDetailOptions) { + const { environmentId, organizationId, projectId, spanId, traceId, startTime } = options; + + // Build ClickHouse query + const queryBuilder = this.clickhouse.taskEventsV2.logDetailQueryBuilder(); + + // Required filters + queryBuilder.where("environment_id = {environmentId: String}", { + environmentId, + }); + queryBuilder.where("organization_id = {organizationId: String}", { + organizationId, + }); + queryBuilder.where("project_id = {projectId: String}", { projectId }); + queryBuilder.where("span_id = {spanId: String}", { spanId }); + queryBuilder.where("trace_id = {traceId: String}", { traceId }); + + // Add time bounds for partition pruning if available + if (startTime) { + const startTimeWithBuffer = new Date(startTime.getTime() - 60_000); // 1 minute buffer + queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", { + insertedAtStart: convertDateToClickhouseDateTime(startTimeWithBuffer), + }); + } + + queryBuilder.limit(1); + + // Execute query + const [queryError, records] = await queryBuilder.execute(); + + if (queryError) { + throw queryError; + } + + if (!records || records.length === 0) { + return null; + } + + const log = records[0]; + + // Parse metadata and attributes + let parsedMetadata: Record = {}; + let parsedAttributes: Record = {}; + + try { + if (log.metadata) { + parsedMetadata = JSON.parse(log.metadata) as Record; + } + } catch { + // Ignore parse errors + } + + try { + if (log.attributes_text) { + parsedAttributes = JSON.parse(log.attributes_text) as Record; + } + } catch { + // Ignore parse errors + } + + return { + // Use :: separator to match LogsListPresenter format + 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(), + traceId: log.trace_id, + spanId: log.span_id, + parentSpanId: log.parent_span_id || null, + message: log.message, + kind: log.kind, + status: log.status, + duration: typeof log.duration === "number" ? log.duration : Number(log.duration), + level: kindToLevel(log.kind), + metadata: parsedMetadata, + attributes: parsedAttributes, + // Raw strings for display + rawMetadata: log.metadata, + rawAttributes: log.attributes_text, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/LogPresenter.server.ts b/apps/webapp/app/presenters/v3/LogPresenter.server.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts new file mode 100644 index 0000000000..d0b9cb5254 --- /dev/null +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -0,0 +1,440 @@ +import { type ClickHouse, type LogsListV2Result } from "@internal/clickhouse"; +import { MachinePresetName } from "@trigger.dev/core/v3"; +import { + type PrismaClient, + type PrismaClientOrTransaction, + type TaskRunStatus, + TaskTriggerSource, +} from "@trigger.dev/database"; +import { type Direction } from "~/components/ListPagination"; +import { timeFilters } from "~/components/runs/v3/SharedFilters"; +import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; +import { getAllTaskIdentifiers } from "~/models/task.server"; +import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { + convertDateToClickhouseDateTime, + convertClickhouseDateTime64ToJsDate, +} from "~/v3/eventRepository/clickhouseEventRepository.server"; + +export type LogsListOptions = { + userId?: string; + projectId: string; + // filters + tasks?: string[]; + versions?: string[]; + statuses?: TaskRunStatus[]; + tags?: string[]; + scheduleId?: string; + period?: string; + bulkId?: string; + from?: number; + to?: number; + isTest?: boolean; + rootOnly?: boolean; + batchId?: string; + runId?: string[]; + queues?: string[]; + machines?: MachinePresetName[]; + // search + search?: string; + // pagination + direction?: Direction; + cursor?: string; + pageSize?: number; +}; + +const DEFAULT_PAGE_SIZE = 50; +const MAX_RUN_IDS = 5000; + +export type LogsList = Awaited>; +export type LogEntry = LogsList["logs"][0]; +export type LogsListAppliedFilters = LogsList["filters"]; + +// Cursor is a base64 encoded JSON of the pagination keys +type LogCursor = { + startTime: string; + traceId: string; + spanId: string; + runId: 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"); + return JSON.parse(decoded) as LogCursor; + } catch { + return null; + } +} + +// Convert ClickHouse kind to display level +function kindToLevel( + kind: string +): "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "LOG" { + switch (kind) { + case "DEBUG_EVENT": + case "LOG_DEBUG": + return "DEBUG"; + case "LOG_INFO": + return "INFO"; + case "LOG_WARN": + return "WARN"; + case "LOG_ERROR": + return "ERROR"; + case "LOG_LOG": + return "LOG"; + case "SPAN": + case "ANCESTOR_OVERRIDE": + case "SPAN_EVENT": + default: + return "TRACE"; + } +} + +// Convert nanoseconds to milliseconds +function convertDateToNanoseconds(date: Date): bigint { + return BigInt(date.getTime()) * 1_000_000n; +} + +export class LogsListPresenter { + constructor( + private readonly replica: PrismaClientOrTransaction, + private readonly clickhouse: ClickHouse + ) {} + + public async call( + organizationId: string, + environmentId: string, + { + userId, + projectId, + tasks, + versions, + statuses, + tags, + scheduleId, + period, + bulkId, + isTest, + rootOnly, + batchId, + runId, + queues, + machines, + search, + from, + to, + direction = "forward", + cursor, + pageSize = DEFAULT_PAGE_SIZE, + }: LogsListOptions + ) { + // Get time values from raw values (including default period) + const time = timeFilters({ + period, + from, + to, + }); + + const hasStatusFilters = statuses && statuses.length > 0; + const hasRunLevelFilters = + (versions !== undefined && versions.length > 0) || + hasStatusFilters || + (bulkId !== undefined && bulkId !== "") || + (scheduleId !== undefined && scheduleId !== "") || + (tags !== undefined && tags.length > 0) || + batchId !== undefined || + (runId !== undefined && runId.length > 0) || + (queues !== undefined && queues.length > 0) || + (machines !== undefined && machines.length > 0) || + typeof isTest === "boolean" || + rootOnly === true; + + const hasFilters = + (tasks !== undefined && tasks.length > 0) || + hasRunLevelFilters || + (search !== undefined && search !== "") || + !time.isDefault; + + // Get all possible tasks + const possibleTasksAsync = getAllTaskIdentifiers( + this.replica, + environmentId + ); + + // Get possible bulk actions + 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 the bulk action isn't in the most recent ones, add it separately + if ( + bulkId && + !bulkActions.some((bulkAction) => bulkAction.friendlyId === bulkId) + ) { + const selectedBulkAction = + await this.replica.bulkActionGroup.findFirst({ + select: { + friendlyId: true, + type: true, + createdAt: true, + name: true, + }, + where: { + friendlyId: bulkId, + projectId, + environmentId, + }, + }); + + if (selectedBulkAction) { + bulkActions.push(selectedBulkAction); + } + } + + if (!displayableEnvironment) { + throw new ServiceValidationError("No environment found"); + } + + // If we have run-level filters, we need to first get matching run IDs from Postgres + let runIds: string[] | undefined; + if (hasRunLevelFilters) { + const runsRepository = new RunsRepository({ + clickhouse: this.clickhouse, + prisma: this.replica as PrismaClient, + }); + + function clampToNow(date: Date): Date { + const now = new Date(); + return date > now ? now : date; + } + + runIds = await runsRepository.listFriendlyRunIds({ + organizationId, + environmentId, + projectId, + tasks, + versions, + statuses, + tags, + scheduleId, + period, + from: time.from ? time.from.getTime() : undefined, + to: time.to ? clampToNow(time.to).getTime() : undefined, + isTest, + rootOnly, + batchId, + runId, + bulkId, + queues, + machines, + page: { + size: MAX_RUN_IDS, + direction: "forward", + }, + }); + + // If no matching runs, return empty result + if (runIds.length === 0) { + return { + logs: [], + pagination: { + next: undefined, + previous: undefined, + }, + possibleTasks: possibleTasks + .map((task) => ({ + slug: task.slug, + triggerSource: task.triggerSource, + })) + .sort((a, b) => a.slug.localeCompare(b.slug)), + bulkActions: bulkActions.map((bulkAction) => ({ + id: bulkAction.friendlyId, + type: bulkAction.type, + createdAt: bulkAction.createdAt, + name: bulkAction.name || bulkAction.friendlyId, + })), + filters: { + tasks: tasks || [], + versions: versions || [], + statuses: statuses || [], + from: time.from, + to: time.to, + }, + hasFilters, + hasAnyLogs: false, + searchTerm: search, + }; + } + } + + // Build ClickHouse query + const queryBuilder = this.clickhouse.taskEventsV2.logsListQueryBuilder(); + + // Required filters + queryBuilder.where("environment_id = {environmentId: String}", { + environmentId, + }); + queryBuilder.where("organization_id = {organizationId: String}", { + organizationId, + }); + queryBuilder.where("project_id = {projectId: String}", { projectId }); + + // Time filter (with inserted_at for partition pruning) + if (time.from) { + const fromNs = convertDateToNanoseconds(time.from).toString(); + queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", { + insertedAtStart: convertDateToClickhouseDateTime(time.from), + }); + queryBuilder.where("start_time >= {fromTime: String}", { + fromTime: fromNs.slice(0, 10) + "." + fromNs.slice(10), + }); + } + + if (time.to) { + const clampedTo = time.to > new Date() ? new Date() : time.to; + const toNs = convertDateToNanoseconds(clampedTo).toString(); + // Add inserted_at filter for partition pruning + queryBuilder.where("inserted_at <= {insertedAtEnd: DateTime64(3)}", { + insertedAtEnd: convertDateToClickhouseDateTime(clampedTo), + }); + queryBuilder.where("start_time <= {toTime: String}", { + toTime: toNs.slice(0, 10) + "." + toNs.slice(10), + }); + } + + // Task filter (applies directly to ClickHouse) + if (tasks && tasks.length > 0) { + queryBuilder.where("task_identifier IN {tasks: Array(String)}", { + tasks, + }); + } + + // Run IDs filter (from Postgres lookup) + if (runIds && runIds.length > 0) { + queryBuilder.where("run_id IN {runIds: Array(String)}", { runIds }); + } + + // Case-insensitive contains message search using ilike + if (search && search.trim() !== "") { + queryBuilder.where("message ilike {searchPattern: String}", { + searchPattern: `%${search.trim()}%`, + }); + } + + // Cursor pagination + const decodedCursor = cursor ? decodeCursor(cursor) : null; + if (decodedCursor) { + queryBuilder.where( + "(start_time, trace_id, span_id, run_id) < ({cursorStartTime: String}, {cursorTraceId: String}, {cursorSpanId: String}, {cursorRunId: String})", + { + cursorStartTime: decodedCursor.startTime, + cursorTraceId: decodedCursor.traceId, + cursorSpanId: decodedCursor.spanId, + cursorRunId: decodedCursor.runId, + } + ); + } + + // Order by newest first + queryBuilder.orderBy("start_time DESC, trace_id DESC, span_id DESC, run_id DESC"); + + // Limit + 1 to check if there are more results + queryBuilder.limit(pageSize + 1); + + // Execute query + const [queryError, records] = await queryBuilder.execute(); + + if (queryError) { + throw queryError; + } + + const results = records || []; + const hasMore = results.length > pageSize; + const logs = results.slice(0, pageSize); + + // Build next cursor from the last item + let nextCursor: string | undefined; + if (hasMore && logs.length > 0) { + const lastLog = logs[logs.length - 1]; + nextCursor = encodeCursor({ + startTime: lastLog.start_time, + traceId: lastLog.trace_id, + spanId: lastLog.span_id, + runId: lastLog.run_id, + }); + } + + // Transform results + // Use :: as separator since dash conflicts with date format in start_time + const transformedLogs = logs.map((log) => ({ + 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(), + traceId: log.trace_id, + spanId: log.span_id, + parentSpanId: log.parent_span_id || null, + message: log.message, + kind: log.kind, + status: log.status, + duration: typeof log.duration === "number" ? log.duration : Number(log.duration), + level: kindToLevel(log.kind), + })); + + return { + logs: transformedLogs, + pagination: { + next: nextCursor, + previous: undefined, // For now, only support forward pagination + }, + possibleTasks: possibleTasks + .map((task) => ({ + slug: task.slug, + triggerSource: task.triggerSource, + })) + .sort((a, b) => a.slug.localeCompare(b.slug)), + bulkActions: bulkActions.map((bulkAction) => ({ + id: bulkAction.friendlyId, + type: bulkAction.type, + createdAt: bulkAction.createdAt, + name: bulkAction.name || bulkAction.friendlyId, + })), + filters: { + tasks: tasks || [], + versions: versions || [], + statuses: statuses || [], + from: time.from, + to: time.to, + }, + hasFilters, + hasAnyLogs: transformedLogs.length > 0, + searchTerm: search, + }; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx new file mode 100644 index 0000000000..a2679b999b --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -0,0 +1,298 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { type MetaFunction, useFetcher, useNavigation, useLocation } from "@remix-run/react"; +import { + TypedAwait, + typeddefer, + type UseDataFunctionReturn, + useTypedLoaderData, +} from "remix-typedjson"; +import { requireUserId } from "~/services/session.server"; + +import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; +import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server"; +import { $replica } from "~/db.server"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; +import { + setRootOnlyFilterPreference, + uiPreferencesStorage, +} from "~/services/preferences/uiPreferences.server"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { BookOpenIcon } from "@heroicons/react/24/solid"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { Spinner } from "~/components/primitives/Spinner"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Callout } from "~/components/primitives/Callout"; +import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { RunsFilters } from "~/components/runs/v3/RunFilters"; +import { LogsTable } from "~/components/logs/LogsTable"; +import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; +import { LogDetailView } from "~/components/logs/LogDetailView"; +import { LogsSearchInput } from "~/components/logs/LogsSearchInput"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Logs | Trigger.dev`, + }, + ]; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Error("Project not found"); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Error("Environment not found"); + } + + const filters = await getRunFiltersFromRequest(request); + + // Get search term from query params + const url = new URL(request.url); + const search = url.searchParams.get("search") ?? undefined; + + const presenter = new LogsListPresenter($replica, clickhouseClient); + const list = presenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + search, + }); + + const session = await setRootOnlyFilterPreference(filters.rootOnly, request); + const cookieValue = await uiPreferencesStorage.commitSession(session); + + return typeddefer( + { + data: list, + rootOnlyDefault: filters.rootOnly, + filters, + }, + { + headers: { + "Set-Cookie": cookieValue, + }, + } + ); +}; + +export default function Page() { + const { data, rootOnlyDefault, filters } = useTypedLoaderData(); + + return ( + + + + + + Logs docs + + + + + + +
+
+
+ + Loading logs +
+
+
+ } + > + + + Unable to load your logs. Please refresh the page or try again in a moment. + +
+ } + > + {(list) => { + return ( + + ); + }} + + + + + ); +} + +function LogsList({ + list, + rootOnlyDefault, + filters, +}: { + list: Awaited["data"]>; + rootOnlyDefault: boolean; + filters: TaskRunListSearchFilters; +}) { + const navigation = useNavigation(); + const location = useLocation(); + const fetcher = useFetcher<{ logs: LogEntry[]; pagination: { next?: string } }>(); + const isLoading = navigation.state !== "idle"; + + // Accumulated logs state + const [accumulatedLogs, setAccumulatedLogs] = useState(list.logs); + const [nextCursor, setNextCursor] = useState(list.pagination.next); + + // Selected log state - managed locally to avoid triggering navigation + const [selectedLogId, setSelectedLogId] = useState(() => { + // Initialize from URL on mount + const params = new URLSearchParams(location.search); + return params.get("log") ?? undefined; + }); + + // Reset accumulated logs when the initial list changes (e.g., filters change) + useEffect(() => { + setAccumulatedLogs(list.logs); + setNextCursor(list.pagination.next); + }, [list.logs, list.pagination.next]); + + // Append new logs when fetcher completes (with deduplication) + useEffect(() => { + if (fetcher.data && fetcher.state === "idle") { + setAccumulatedLogs((prev) => { + const existingIds = new Set(prev.map((log) => log.id)); + const newLogs = fetcher.data!.logs.filter((log) => !existingIds.has(log.id)); + return [...prev, ...newLogs]; + }); + setNextCursor(fetcher.data.pagination.next); + } + }, [fetcher.data, fetcher.state]); + + // Build resource URL for loading more + const loadMoreUrl = useMemo(() => { + if (!nextCursor) return null; + // Transform /orgs/.../logs to /resources/orgs/.../logs + const resourcePath = `/resources${location.pathname}`; + const params = new URLSearchParams(location.search); + params.set("cursor", nextCursor); + params.delete("log"); // Don't include selected log in fetch + return `${resourcePath}?${params.toString()}`; + }, [location.pathname, location.search, nextCursor]); + + // Handle loading more + const handleLoadMore = useCallback(() => { + if (loadMoreUrl && fetcher.state === "idle") { + fetcher.load(loadMoreUrl); + } + }, [loadMoreUrl, fetcher]); + + // Find the selected log in the accumulated list for initial data + const selectedLog = useMemo(() => { + if (!selectedLogId) return undefined; + return accumulatedLogs.find((log) => log.id === selectedLogId); + }, [selectedLogId, accumulatedLogs]); + + // Update URL without triggering navigation using History API + const updateUrlWithLog = useCallback( + (logId: string | undefined) => { + const url = new URL(window.location.href); + if (logId) { + url.searchParams.set("log", logId); + } else { + url.searchParams.delete("log"); + } + window.history.replaceState(null, "", url.toString()); + }, + [] + ); + + // Handle log selection + const handleLogSelect = useCallback( + (logId: string) => { + setSelectedLogId(logId); + updateUrlWithLog(logId); + }, + [updateUrlWithLog] + ); + + // Handle closing the side panel + const handleClosePanel = useCallback(() => { + setSelectedLogId(undefined); + updateUrlWithLog(undefined); + }, [updateUrlWithLog]); + + return ( + + +
+ {/* Filters */} +
+
+ + +
+
+ + {/* Table */} + +
+
+ + {/* Side panel for log details */} + {selectedLogId && ( + <> + + + + + + )} +
+ ); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.tsx new file mode 100644 index 0000000000..65fd4b47af --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.tsx @@ -0,0 +1,74 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson } from "remix-typedjson"; +import { z } from "zod"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; +import { requireUserId } from "~/services/session.server"; +import { LogDetailPresenter } from "~/presenters/v3/LogDetailPresenter.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { $replica } from "~/db.server"; + +const LogIdParamsSchema = z.object({ + organizationSlug: z.string(), + projectParam: z.string(), + envParam: z.string(), + logId: z.string(), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam, logId } = LogIdParamsSchema.parse(params); + + // Validate access to project and environment + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + // Parse logId to extract traceId, spanId, runId, and startTime + // Format: {traceId}::{spanId}::{runId}::{startTime} + // Note: startTime may be URL-encoded (spaces as %20) + const decodedLogId = decodeURIComponent(logId); + const parts = decodedLogId.split("::"); + if (parts.length !== 4) { + throw new Response("Invalid log ID format", { status: 400 }); + } + + const [traceId, spanId, runId, startTimeStr] = parts; + + const presenter = new LogDetailPresenter($replica, clickhouseClient); + + // Convert startTime string to Date (format: YYYY-MM-DD HH:mm:ss.nanoseconds) + // JavaScript Date only handles up to milliseconds, so we need to truncate nanoseconds + let startTimeDate: Date | undefined; + try { + // Remove nanoseconds (keep only up to milliseconds) and convert to ISO format + const dateStr = startTimeStr.split(".")[0].replace(" ", "T") + "Z"; + startTimeDate = new Date(dateStr); + if (isNaN(startTimeDate.getTime())) { + startTimeDate = undefined; + } + } catch { + // If parsing fails, continue without time bounds + } + + const result = await presenter.call({ + environmentId: environment.id, + organizationId: project.organizationId, + projectId: project.id, + spanId, + traceId, + startTime: startTimeDate, + }); + + if (!result) { + throw new Response("Log not found", { status: 404 }); + } + + return typedjson(result); +}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts new file mode 100644 index 0000000000..3da11528c8 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts @@ -0,0 +1,46 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/node"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; +import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server"; +import { $replica } from "~/db.server"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + const filters = await getRunFiltersFromRequest(request); + + // Get search term and cursor from query params + const url = new URL(request.url); + const search = url.searchParams.get("search") ?? undefined; + const cursor = url.searchParams.get("cursor") ?? undefined; + + const presenter = new LogsListPresenter($replica, clickhouseClient); + const result = await presenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + ...filters, + search, + cursor, + }); + + return json({ + logs: result.logs, + pagination: result.pagination, + }); +}; diff --git a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts index 8b5cee04ca..9d3a92e911 100644 --- a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts @@ -52,6 +52,29 @@ export class ClickHouseRunsRepository implements IRunsRepository { return runIds; } + async listFriendlyRunIds(options: ListRunsOptions) { + // First get internal IDs from ClickHouse + const internalIds = await this.listRunIds(options); + + if (internalIds.length === 0) { + return []; + } + + // Then get friendly IDs from Prisma + const runs = await this.options.prisma.taskRun.findMany({ + where: { + id: { + in: internalIds, + }, + }, + select: { + friendlyId: true, + }, + }); + + return runs.map((run) => run.friendlyId); + } + async listRuns(options: ListRunsOptions) { const runIds = await this.listRunIds(options); diff --git a/apps/webapp/app/services/runsRepository/postgresRunsRepository.server.ts b/apps/webapp/app/services/runsRepository/postgresRunsRepository.server.ts index 93edbd9349..eaf2242090 100644 --- a/apps/webapp/app/services/runsRepository/postgresRunsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/postgresRunsRepository.server.ts @@ -31,6 +31,18 @@ export class PostgresRunsRepository implements IRunsRepository { 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, @@ -146,6 +158,21 @@ export class PostgresRunsRepository implements IRunsRepository { `; } + #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" } diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts index 7bf81a4aa5..553938e77f 100644 --- a/apps/webapp/app/services/runsRepository/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts @@ -127,6 +127,8 @@ export type TagList = { export interface IRunsRepository { name: string; listRunIds(options: ListRunsOptions): Promise; + /** Returns friendly IDs (e.g., run_xxx) instead of internal UUIDs. Used for ClickHouse task_events queries. */ + listFriendlyRunIds(options: ListRunsOptions): Promise; listRuns(options: ListRunsOptions): Promise<{ runs: ListedRun[]; pagination: { @@ -223,6 +225,48 @@ export class RunsRepository implements IRunsRepository { ); } + async listFriendlyRunIds(options: ListRunsOptions): Promise { + const repository = await this.#getRepository(); + return startActiveSpan( + "runsRepository.listFriendlyRunIds", + async () => { + try { + return await repository.listFriendlyRunIds(options); + } catch (error) { + // If ClickHouse fails, retry with Postgres + if (repository.name === "clickhouse") { + this.logger?.warn("ClickHouse failed, retrying with Postgres", { error }); + return startActiveSpan( + "runsRepository.listFriendlyRunIds.fallback", + async () => { + return await this.postgresRunsRepository.listFriendlyRunIds(options); + }, + { + attributes: { + "repository.name": "postgres", + "fallback.reason": "clickhouse_error", + "fallback.error": error instanceof Error ? error.message : String(error), + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + throw error; + } + }, + { + attributes: { + "repository.name": repository.name, + organizationId: options.organizationId, + projectId: options.projectId, + environmentId: options.environmentId, + }, + } + ); + } + async listRuns(options: ListRunsOptions): Promise<{ runs: ListedRun[]; pagination: { diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 28da347b57..7c3154f26d 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -432,6 +432,17 @@ export function v3ProjectSettingsPath( return `${v3EnvironmentPath(organization, project, environment)}/settings`; } +export function v3LogsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + // deployment: DeploymentForPath, + // currentPage: number +) { + // const query = currentPage ? `?page=${currentPage}` : ""; + return `${v3EnvironmentPath(organization, project, environment)}/logs`; +} + export function v3DeploymentsPath( organization: OrgForPath, project: ProjectForPath, diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts index 4aceeb92d8..d0f497e04d 100644 --- a/internal-packages/clickhouse/src/index.ts +++ b/internal-packages/clickhouse/src/index.ts @@ -22,6 +22,8 @@ import { getTraceSummaryQueryBuilderV2, insertTaskEvents, insertTaskEventsV2, + getLogsListQueryBuilderV2, + getLogDetailQueryBuilderV2, } from "./taskEvents.js"; import { Logger, type LogLevel } from "@trigger.dev/core/logger"; import type { Agent as HttpAgent } from "http"; @@ -182,6 +184,8 @@ export class ClickHouse { traceSummaryQueryBuilder: getTraceSummaryQueryBuilderV2(this.reader), traceDetailedSummaryQueryBuilder: getTraceDetailedSummaryQueryBuilderV2(this.reader), spanDetailsQueryBuilder: getSpanDetailsQueryBuilderV2(this.reader), + logsListQueryBuilder: getLogsListQueryBuilderV2(this.reader), + logDetailQueryBuilder: getLogDetailQueryBuilderV2(this.reader), }; } } diff --git a/internal-packages/clickhouse/src/taskEvents.ts b/internal-packages/clickhouse/src/taskEvents.ts index d8c1b8b7f6..f831163078 100644 --- a/internal-packages/clickhouse/src/taskEvents.ts +++ b/internal-packages/clickhouse/src/taskEvents.ts @@ -230,3 +230,94 @@ export function getSpanDetailsQueryBuilderV2( settings, }); } + +// ============================================================================ +// Logs List Query Builders (for aggregated logs page) +// ============================================================================ + +export const LogsListV2Result = z.object({ + environment_id: z.string(), + organization_id: z.string(), + project_id: z.string(), + task_identifier: z.string(), + run_id: z.string(), + start_time: z.string(), + trace_id: z.string(), + span_id: z.string(), + parent_span_id: z.string(), + message: z.string(), + kind: z.string(), + status: z.string(), + duration: z.number().or(z.string()), +}); + +export type LogsListV2Result = z.output; + +export function getLogsListQueryBuilderV2(ch: ClickhouseReader, settings?: ClickHouseSettings) { + return ch.queryBuilderFast({ + name: "getLogsList", + table: "trigger_dev.task_events_v2", + columns: [ + "environment_id", + "organization_id", + "project_id", + "task_identifier", + "run_id", + "start_time", + "trace_id", + "span_id", + "parent_span_id", + { name: "message", expression: "LEFT(message, 512)" }, + "kind", + "status", + "duration", + ], + settings, + }); +} + +// Single log detail query builder (for side panel) +export const LogDetailV2Result = z.object({ + environment_id: z.string(), + organization_id: z.string(), + project_id: z.string(), + task_identifier: z.string(), + run_id: z.string(), + start_time: z.string(), + trace_id: z.string(), + span_id: z.string(), + parent_span_id: z.string(), + message: z.string(), + kind: z.string(), + status: z.string(), + duration: z.number().or(z.string()), + metadata: z.string(), + attributes_text: z.string(), +}); + +export type LogDetailV2Result = z.output; + +export function getLogDetailQueryBuilderV2(ch: ClickhouseReader, settings?: ClickHouseSettings) { + return ch.queryBuilderFast({ + name: "getLogDetail", + table: "trigger_dev.task_events_v2", + columns: [ + "environment_id", + "organization_id", + "project_id", + "task_identifier", + "run_id", + "start_time", + "trace_id", + "span_id", + "parent_span_id", + "message", + "kind", + "status", + "duration", + "metadata", + "attributes_text", + ], + settings, + }); +} diff --git a/references/nextjs-realtime/.eslintrc.json b/references/nextjs-realtime/.eslintrc.json new file mode 100644 index 0000000000..6b10a5b739 --- /dev/null +++ b/references/nextjs-realtime/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": [ + "next/core-web-vitals", + "next/typescript" + ] +} From 882154e8f488dd156d8b127e3416fd2bc0c8d73d Mon Sep 17 00:00:00 2001 From: mpcgird Date: Sat, 20 Dec 2025 23:48:03 +0200 Subject: [PATCH 02/28] Query fixes and removed span details from logs side panel --- .../app/components/logs/LogDetailView.tsx | 401 ++++++++++++++---- .../app/components/logs/LogsLevelFilter.tsx | 169 ++++++++ .../app/components/logs/LogsRunIdFilter.tsx | 161 +++++++ apps/webapp/app/components/logs/LogsTable.tsx | 53 ++- .../app/components/logs/LogsTimePresets.tsx | 55 +++ .../v3/LogDetailPresenter.server.ts | 21 +- .../presenters/v3/LogsListPresenter.server.ts | 82 +++- .../route.tsx | 21 +- ...ectParam.env.$envParam.logs.$logId.run.tsx | 136 ++++++ ...tParam.env.$envParam.logs.$logId.spans.tsx | 100 +++++ ...projectParam.env.$envParam.logs.$logId.tsx | 20 +- ...ojects.$projectParam.env.$envParam.logs.ts | 15 +- .../clickhouse/src/client/queryBuilder.ts | 24 ++ .../clickhouse/src/taskEvents.ts | 7 +- 14 files changed, 1136 insertions(+), 129 deletions(-) create mode 100644 apps/webapp/app/components/logs/LogsLevelFilter.tsx create mode 100644 apps/webapp/app/components/logs/LogsRunIdFilter.tsx create mode 100644 apps/webapp/app/components/logs/LogsTimePresets.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.spans.tsx diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index 0f5d1515fd..10698dc556 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -1,7 +1,7 @@ import { XMarkIcon, ArrowTopRightOnSquareIcon, ClockIcon } from "@heroicons/react/20/solid"; import { Link } from "@remix-run/react"; import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useTypedFetcher } from "remix-typedjson"; import { cn } from "~/utils/cn"; import { Button } from "~/components/primitives/Buttons"; @@ -9,12 +9,42 @@ import { DateTime } from "~/components/primitives/DateTime"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Spinner } from "~/components/primitives/Spinner"; +import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; import { v3RunSpanPath } from "~/utils/pathBuilder"; import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId"; +import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; +import type { TaskRunStatus } from "@trigger.dev/database"; + +// Types for the run context endpoint response +type RunContextData = { + run: { + id: string; + friendlyId: string; + taskIdentifier: string; + status: string; + createdAt: string; + startedAt?: string; + completedAt?: string; + isTest: boolean; + tags: string[]; + queue: string; + concurrencyKey: string | null; + usageDurationMs: number; + costInCents: number; + baseCostInCents: number; + machinePreset: string | null; + version?: string; + rootRun: { friendlyId: string; taskIdentifier: string } | null; + parentRun: { friendlyId: string; taskIdentifier: string } | null; + batch: { friendlyId: string } | null; + schedule: { friendlyId: string } | null; + } | null; +}; + type LogDetailViewProps = { logId: string; @@ -23,6 +53,8 @@ type LogDetailViewProps = { onClose: () => void; }; +type TabType = "details" | "run"; + // Level badge color styles function getLevelColor(level: string): string { switch (level) { @@ -103,6 +135,7 @@ export function LogDetailView({ logId, initialLog, onClose }: LogDetailViewProps const project = useProject(); const environment = useEnvironment(); const fetcher = useTypedFetcher(); + const [activeTab, setActiveTab] = useState("details"); // Fetch full log details when logId changes useEffect(() => { @@ -188,100 +221,326 @@ export function LogDetailView({ logId, initialLog, onClose }: LogDetailViewProps + {/* Tabs */} +
+ + setActiveTab("details")} + shortcut={{ key: "d" }} + > + Details + + setActiveTab("run")} + shortcut={{ key: "r" }} + > + Run + + +
+ {/* Content */}
- {/* Message */} + {activeTab === "details" && ( + + )} + {activeTab === "run" && ( + + )} +
+ + ); +} + +function DetailsTab({ log, runPath }: { log: LogEntry; runPath: string }) { + // Extract metadata and attributes - handle both parsed and raw string forms + const logWithExtras = log as LogEntry & { + metadata?: Record; + rawMetadata?: string; + attributes?: Record; + rawAttributes?: string; + }; + + // Get raw strings for display + const rawMetadata = logWithExtras.rawMetadata; + const rawAttributes = logWithExtras.rawAttributes; + + // Parse metadata + let metadata: Record | null = null; + if (logWithExtras.metadata) { + metadata = logWithExtras.metadata; + } else if (rawMetadata) { + try { + metadata = JSON.parse(rawMetadata) as Record; + } catch { + // Ignore parse errors + } + } + + // Parse attributes + let attributes: Record | null = null; + if (logWithExtras.attributes) { + attributes = logWithExtras.attributes; + } else if (rawAttributes) { + try { + attributes = JSON.parse(rawAttributes) as Record; + } catch { + // Ignore parse errors + } + } + + // Extract error info from metadata + const errorInfo = metadata?.error as { message?: string; attributes?: Record } | undefined; + + // Check if we should show metadata/attributes sections + const showMetadata = rawMetadata && rawMetadata !== "{}"; + const showAttributes = rawAttributes && rawAttributes !== "{}"; + + return ( + <> + {/* Error Details - show prominently for error status */} + {errorInfo && (
- Message + Error Details +
+ {errorInfo.message && ( +
+                {errorInfo.message}
+              
+ )} + {errorInfo.attributes && Object.keys(errorInfo.attributes).length > 0 && ( +
+ + Error Attributes + +
+                  {JSON.stringify(errorInfo.attributes, null, 2)}
+                
+
+ )} +
+
+ )} + + {/* Message */} +
+ Message +
+
+            {log.message}
+          
+
+
+ + {/* Run Link */} +
+ Run +
+ {log.runId} + + + +
+
+ + {/* Details Grid */} +
+ Details +
+ + + + 0 + ? formatDurationNanoseconds(log.duration, { style: "short" }) + : "–" + } + icon={} + /> + + + {log.parentSpanId && ( + + )} +
+
+ + {/* Metadata - only available in full log detail */} + {showMetadata && metadata && ( +
+ Metadata
-
-              {log.message}
+            
+              {JSON.stringify(metadata, null, 2)}
             
+ )} - {/* Run Link */} + {/* Attributes - only available in full log detail */} + {showAttributes && attributes && (
- Run -
- {log.runId} - - - + Attributes +
+
+              {JSON.stringify(attributes, null, 2)}
+            
+ )} + + ); +} + +function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const fetcher = useTypedFetcher(); + + // Fetch run details when tab is active + useEffect(() => { + if (!log.runId) return; + + fetcher.load( + `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(log.id)}/run?runId=${encodeURIComponent(log.runId)}` + ); + }, [organization.slug, project.slug, environment.slug, log.id, log.runId]); + + const isLoading = fetcher.state === "loading"; + const runData = fetcher.data?.run; - {/* Details Grid */} + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!runData) { + return ( + <>
- Details -
- - - - 0 - ? formatDurationNanoseconds(log.duration, { style: "short" }) - : "–" - } - icon={} - /> - - - {log.parentSpanId && ( - - )} + Run Information +
+ Run not found in database. +
+ + + +
+ + ); + } - {/* Metadata - only available in full log detail */} - {"rawMetadata" in log && - (log as { rawMetadata?: string }).rawMetadata && - (log as { rawMetadata?: string }).rawMetadata !== "{}" && ( -
- Metadata -
-
-                  {JSON.stringify(
-                    "metadata" in log
-                      ? (log as { metadata: Record }).metadata
-                      : JSON.parse((log as { rawMetadata: string }).rawMetadata),
-                    null,
-                    2
-                  )}
-                
+ return ( + <> +
+ Run Information +
+ {/* Status and Task */} +
+ + {runData.taskIdentifier} +
+ + {/* Details Grid */} +
+ + + + {runData.startedAt && ( + + )} + {runData.completedAt && ( + + )} + + {runData.machinePreset && ( + + )} + {runData.isTest && ( + + )} +
+ + {/* Tags */} + {runData.tags && runData.tags.length > 0 && ( +
+ + Tags + +
+ {runData.tags.map((tag: string) => ( + + {tag} + + ))}
)} - {/* Attributes - only available in full log detail */} - {"rawAttributes" in log && - (log as { rawAttributes?: string }).rawAttributes && - (log as { rawAttributes?: string }).rawAttributes !== "{}" && ( -
- Attributes -
-
-                  {JSON.stringify(
-                    "attributes" in log
-                      ? (log as { attributes: Record }).attributes
-                      : JSON.parse((log as { rawAttributes: string }).rawAttributes),
-                    null,
-                    2
-                  )}
-                
+ {/* Relationships */} + {(runData.parentRun || runData.rootRun || runData.batch || runData.schedule) && ( +
+ + Relationships + +
+ {runData.parentRun && ( + + )} + {runData.rootRun && ( + + )} + {runData.batch && ( + + )} + {runData.schedule && ( + + )}
)} + +
+ + + +
+
-
+ ); } diff --git a/apps/webapp/app/components/logs/LogsLevelFilter.tsx b/apps/webapp/app/components/logs/LogsLevelFilter.tsx new file mode 100644 index 0000000000..94953f2885 --- /dev/null +++ b/apps/webapp/app/components/logs/LogsLevelFilter.tsx @@ -0,0 +1,169 @@ +import * as Ariakit from "@ariakit/react"; +import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; +import { type ReactNode, useMemo } from "react"; +import { AppliedFilter } from "~/components/primitives/AppliedFilter"; +import { + ComboBox, + SelectItem, + SelectList, + SelectPopover, + SelectProvider, + SelectTrigger, + shortcutFromIndex, +} from "~/components/primitives/Select"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { FilterMenuProvider, appliedSummary } from "~/components/runs/v3/SharedFilters"; +import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server"; +import { cn } from "~/utils/cn"; + +const logLevels: { level: LogLevel; label: string; color: string }[] = [ + { level: "ERROR", label: "Error", color: "text-error" }, + { level: "WARN", label: "Warning", color: "text-warning" }, + { level: "INFO", label: "Info", color: "text-blue-400" }, + { level: "LOG", label: "Log", color: "text-text-dimmed" }, + { level: "DEBUG", label: "Debug", color: "text-charcoal-400" }, + { level: "TRACE", label: "Trace", color: "text-charcoal-500" }, +]; + +function getLevelBadgeColor(level: LogLevel): string { + switch (level) { + case "ERROR": + return "text-error bg-error/10 border-error/20"; + case "WARN": + return "text-warning bg-warning/10 border-warning/20"; + case "DEBUG": + return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; + case "INFO": + return "text-blue-400 bg-blue-500/10 border-blue-500/20"; + case "TRACE": + return "text-charcoal-500 bg-charcoal-800 border-charcoal-700"; + case "LOG": + default: + return "text-text-dimmed bg-charcoal-750 border-charcoal-700"; + } +} + +const shortcut = { key: "l" }; + +export function LogsLevelFilter() { + const { values } = useSearchParams(); + const selectedLevels = values("levels"); + const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== ""); + + if (hasLevels) { + return ; + } + + return ( + + {(search, setSearch) => ( + } + variant="secondary/small" + shortcut={shortcut} + tooltipTitle="Filter by level" + > + Level + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function LevelDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ levels: values, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return logLevels.filter((item) => + item.label.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + > + + + {filtered.map((item, index) => ( + + + {item.level} + + + ))} + + + + ); +} + +function AppliedLevelFilter() { + const { values, del } = useSearchParams(); + const levels = values("levels"); + + if (levels.length === 0 || levels.every((v) => v === "")) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + } + value={appliedSummary(levels)} + onRemove={() => del(["levels", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} diff --git a/apps/webapp/app/components/logs/LogsRunIdFilter.tsx b/apps/webapp/app/components/logs/LogsRunIdFilter.tsx new file mode 100644 index 0000000000..5c23d1a192 --- /dev/null +++ b/apps/webapp/app/components/logs/LogsRunIdFilter.tsx @@ -0,0 +1,161 @@ +import * as Ariakit from "@ariakit/react"; +import { FingerPrintIcon } from "@heroicons/react/20/solid"; +import { useCallback, useState } from "react"; +import { AppliedFilter } from "~/components/primitives/AppliedFilter"; +import { Button } from "~/components/primitives/Buttons"; +import { FormError } from "~/components/primitives/FormError"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { + SelectPopover, + SelectProvider, + SelectTrigger, +} from "~/components/primitives/Select"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters"; + +const shortcut = { key: "r" }; + +export function LogsRunIdFilter() { + const { value } = useSearchParams(); + const runIdValue = value("runId"); + + if (runIdValue) { + return ; + } + + return ( + + {(search, setSearch) => ( + } + variant="secondary/small" + shortcut={shortcut} + tooltipTitle="Filter by run ID" + > + Run ID + + } + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function RunIdDropdown({ + trigger, + clearSearchValue, + onClose, +}: { + trigger: React.ReactNode; + clearSearchValue: () => void; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const runIdValue = value("runId"); + + const [runId, setRunId] = useState(runIdValue); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + cursor: undefined, + direction: undefined, + runId: runId === "" ? undefined : runId?.toString(), + }); + + setOpen(false); + }, [runId, replace, clearSearchValue]); + + let error: string | undefined = undefined; + if (runId) { + if (!runId.startsWith("run_")) { + error = "Run IDs start with 'run_'"; + } else if (runId.length !== 25 && runId.length !== 29) { + error = "Run IDs are 25 or 29 characters long"; + } + } + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + className="max-w-[min(32ch,var(--popover-available-width))]" + > +
+
+ + setRunId(e.target.value)} + variant="small" + className="w-[27ch] font-mono" + spellCheck={false} + /> + {error ? {error} : null} +
+
+ + +
+
+
+
+ ); +} + +function AppliedRunIdFilter() { + const { value, del } = useSearchParams(); + + const runId = value("runId"); + if (!runId) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + } + value={runId} + onRemove={() => del(["runId", "cursor", "direction"])} + variant="secondary/small" + /> + + } + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index fe14b29082..d4ef356368 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -55,6 +55,39 @@ function getLevelColor(level: LogEntry["level"]): string { } } +// Status badge color styles +function getStatusColor(status: string): string { + switch (status) { + case "OK": + return "text-success bg-success/10 border-success/20"; + case "ERROR": + return "text-error bg-error/10 border-error/20"; + case "CANCELLED": + return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; + case "PARTIAL": + return "text-pending bg-pending/10 border-pending/20"; + default: + return "text-text-dimmed bg-charcoal-750 border-charcoal-700"; + } +} + +// Left border color for error highlighting +function getLevelBorderColor(level: LogEntry["level"]): string { + switch (level) { + case "ERROR": + return "border-l-error"; + case "WARN": + return "border-l-warning"; + case "INFO": + return "border-l-blue-500"; + case "DEBUG": + case "TRACE": + case "LOG": + default: + return "border-l-transparent"; + } +} + // Case-insensitive text highlighting function highlightText( text: string, @@ -135,13 +168,14 @@ export function LogsTable({ Run Task Level + Status Duration Message {logs.length === 0 && !hasFilters ? ( - + {!isLoading && } ) : logs.length === 0 ? ( @@ -163,7 +197,8 @@ export function LogsTable({ + + + {log.status} + + ; + if (isLoading) return ; return ( - +
No logs match your filters. Try refreshing or modifying your filters. diff --git a/apps/webapp/app/components/logs/LogsTimePresets.tsx b/apps/webapp/app/components/logs/LogsTimePresets.tsx new file mode 100644 index 0000000000..059be6f0f6 --- /dev/null +++ b/apps/webapp/app/components/logs/LogsTimePresets.tsx @@ -0,0 +1,55 @@ +import { Button } from "~/components/primitives/Buttons"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { cn } from "~/utils/cn"; + +// Quick preset periods for common log viewing use cases +const quickPeriods = [ + { label: "5m", value: "5m" }, + { label: "15m", value: "15m" }, + { label: "1h", value: "1h" }, + { label: "24h", value: "1d" }, + { label: "7d", value: "7d" }, +] as const; + +export function LogsTimePresets() { + const { value, replace } = useSearchParams(); + const currentPeriod = value("period"); + const hasCustomRange = value("from") || value("to"); + + // Don't show presets if custom range is active + if (hasCustomRange) { + return null; + } + + const handlePeriodClick = (period: string) => { + replace({ + period, + cursor: undefined, + direction: undefined, + from: undefined, + to: undefined, + }); + }; + + return ( +
+ {quickPeriods.map((p) => ( + + ))} +
+ ); +} diff --git a/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts index 321b2226b0..2b8d44746b 100644 --- a/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts @@ -1,9 +1,6 @@ import { type ClickHouse } from "@internal/clickhouse"; import { type PrismaClientOrTransaction } from "@trigger.dev/database"; -import { - convertClickhouseDateTime64ToJsDate, - convertDateToClickhouseDateTime, -} from "~/v3/eventRepository/clickhouseEventRepository.server"; +import { convertClickhouseDateTime64ToJsDate } from "~/v3/eventRepository/clickhouseEventRepository.server"; export type LogDetailOptions = { environmentId: string; @@ -11,8 +8,8 @@ export type LogDetailOptions = { projectId: string; spanId: string; traceId: string; - // Time bounds for query optimization - startTime?: Date; + // The exact start_time from the log id - used to uniquely identify the event + startTime: string; }; export type LogDetail = Awaited>; @@ -53,7 +50,8 @@ export class LogDetailPresenter { // Build ClickHouse query const queryBuilder = this.clickhouse.taskEventsV2.logDetailQueryBuilder(); - // Required filters + // Required filters - spanId, traceId, and startTime uniquely identify the log + // Multiple events can share the same spanId (span, span events, logs), so startTime is needed queryBuilder.where("environment_id = {environmentId: String}", { environmentId, }); @@ -63,14 +61,7 @@ export class LogDetailPresenter { queryBuilder.where("project_id = {projectId: String}", { projectId }); queryBuilder.where("span_id = {spanId: String}", { spanId }); queryBuilder.where("trace_id = {traceId: String}", { traceId }); - - // Add time bounds for partition pruning if available - if (startTime) { - const startTimeWithBuffer = new Date(startTime.getTime() - 60_000); // 1 minute buffer - queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", { - insertedAtStart: convertDateToClickhouseDateTime(startTimeWithBuffer), - }); - } + queryBuilder.where("start_time = {startTime: String}", { startTime }); queryBuilder.limit(1); diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index d0b9cb5254..f0f9380887 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -6,6 +6,7 @@ import { type TaskRunStatus, TaskTriggerSource, } from "@trigger.dev/database"; +import parseDuration from "parse-duration"; import { type Direction } from "~/components/ListPagination"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; @@ -17,6 +18,8 @@ import { convertClickhouseDateTime64ToJsDate, } from "~/v3/eventRepository/clickhouseEventRepository.server"; +export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "LOG"; + export type LogsListOptions = { userId?: string; projectId: string; @@ -36,6 +39,7 @@ export type LogsListOptions = { runId?: string[]; queues?: string[]; machines?: MachinePresetName[]; + levels?: LogLevel[]; // search search?: string; // pagination @@ -96,6 +100,24 @@ function kindToLevel( } } +// Convert display level to ClickHouse kinds +function levelToKinds(level: LogLevel): string[] { + switch (level) { + case "DEBUG": + return ["DEBUG_EVENT", "LOG_DEBUG"]; + case "INFO": + return ["LOG_INFO"]; + case "WARN": + return ["LOG_WARN"]; + case "ERROR": + return ["LOG_ERROR"]; + case "LOG": + return ["LOG_LOG"]; + case "TRACE": + return ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"]; + } +} + // Convert nanoseconds to milliseconds function convertDateToNanoseconds(date: Date): bigint { return BigInt(date.getTime()) * 1_000_000n; @@ -126,6 +148,7 @@ export class LogsListPresenter { runId, queues, machines, + levels, search, from, to, @@ -141,6 +164,19 @@ export class LogsListPresenter { to, }); + // If period is provided but from/to are not calculated, convert period to date range + // This is needed because timeFilters doesn't convert period to actual dates + let effectiveFrom = time.from; + let effectiveTo = time.to; + + if (!effectiveFrom && !effectiveTo && time.period) { + const periodMs = parseDuration(time.period); + if (periodMs) { + effectiveFrom = new Date(Date.now() - periodMs); + effectiveTo = new Date(); + } + } + const hasStatusFilters = statuses && statuses.length > 0; const hasRunLevelFilters = (versions !== undefined && versions.length > 0) || @@ -158,6 +194,7 @@ export class LogsListPresenter { const hasFilters = (tasks !== undefined && tasks.length > 0) || hasRunLevelFilters || + (levels !== undefined && levels.length > 0) || (search !== undefined && search !== "") || !time.isDefault; @@ -244,8 +281,8 @@ export class LogsListPresenter { tags, scheduleId, period, - from: time.from ? time.from.getTime() : undefined, - to: time.to ? clampToNow(time.to).getTime() : undefined, + from: effectiveFrom ? effectiveFrom.getTime() : undefined, + to: effectiveTo ? clampToNow(effectiveTo).getTime() : undefined, isTest, rootOnly, batchId, @@ -283,8 +320,9 @@ export class LogsListPresenter { tasks: tasks || [], versions: versions || [], statuses: statuses || [], - from: time.from, - to: time.to, + levels: levels || [], + from: effectiveFrom, + to: effectiveTo, }, hasFilters, hasAnyLogs: false, @@ -296,31 +334,34 @@ export class LogsListPresenter { // Build ClickHouse query const queryBuilder = this.clickhouse.taskEventsV2.logsListQueryBuilder(); - // Required filters - queryBuilder.where("environment_id = {environmentId: String}", { + // PREWHERE for primary key columns (first in ORDER BY, uses index efficiently) + queryBuilder.prewhere("environment_id = {environmentId: String}", { environmentId, }); + + // WHERE for non-indexed columns queryBuilder.where("organization_id = {organizationId: String}", { organizationId, }); queryBuilder.where("project_id = {projectId: String}", { projectId }); - // Time filter (with inserted_at for partition pruning) - if (time.from) { - const fromNs = convertDateToNanoseconds(time.from).toString(); - queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", { - insertedAtStart: convertDateToClickhouseDateTime(time.from), + // Time filters - inserted_at in PREWHERE for partition pruning, start_time in WHERE + if (effectiveFrom) { + const fromNs = convertDateToNanoseconds(effectiveFrom).toString(); + // PREWHERE for partition key (inserted_at) - dramatically reduces data scanned + queryBuilder.prewhere("inserted_at >= {insertedAtStart: DateTime64(3)}", { + insertedAtStart: convertDateToClickhouseDateTime(effectiveFrom), }); queryBuilder.where("start_time >= {fromTime: String}", { fromTime: fromNs.slice(0, 10) + "." + fromNs.slice(10), }); } - if (time.to) { - const clampedTo = time.to > new Date() ? new Date() : time.to; + if (effectiveTo) { + const clampedTo = effectiveTo > new Date() ? new Date() : effectiveTo; const toNs = convertDateToNanoseconds(clampedTo).toString(); - // Add inserted_at filter for partition pruning - queryBuilder.where("inserted_at <= {insertedAtEnd: DateTime64(3)}", { + // PREWHERE for partition key (inserted_at) - dramatically reduces data scanned + queryBuilder.prewhere("inserted_at <= {insertedAtEnd: DateTime64(3)}", { insertedAtEnd: convertDateToClickhouseDateTime(clampedTo), }); queryBuilder.where("start_time <= {toTime: String}", { @@ -347,6 +388,12 @@ export class LogsListPresenter { }); } + // Level filter (map display levels to ClickHouse kinds) + if (levels && levels.length > 0) { + const kinds = levels.flatMap(levelToKinds); + queryBuilder.where("kind IN {kinds: Array(String)}", { kinds }); + } + // Cursor pagination const decodedCursor = cursor ? decodeCursor(cursor) : null; if (decodedCursor) { @@ -429,8 +476,9 @@ export class LogsListPresenter { tasks: tasks || [], versions: versions || [], statuses: statuses || [], - from: time.from, - to: time.to, + levels: levels || [], + from: effectiveFrom, + to: effectiveTo, }, hasFilters, hasAnyLogs: transformedLogs.length > 0, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index a2679b999b..ffeed1a36b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -12,7 +12,7 @@ import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; -import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server"; +import { LogsListPresenter, type LogLevel } from "~/presenters/v3/LogsListPresenter.server"; import { $replica } from "~/db.server"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; import { @@ -33,12 +33,24 @@ import { LogsTable } from "~/components/logs/LogsTable"; import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; import { LogDetailView } from "~/components/logs/LogDetailView"; import { LogsSearchInput } from "~/components/logs/LogsSearchInput"; +import { LogsLevelFilter } from "~/components/logs/LogsLevelFilter"; +import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter"; +import { LogsTimePresets } from "~/components/logs/LogsTimePresets"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "~/components/primitives/Resizable"; +// Valid log levels for filtering +const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "LOG"]; + +function parseLevelsFromUrl(url: URL): LogLevel[] | undefined { + const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0); + if (levelParams.length === 0) return undefined; + return levelParams.filter((l): l is LogLevel => validLevels.includes(l as LogLevel)); +} + export const meta: MetaFunction = () => { return [ { @@ -63,9 +75,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const filters = await getRunFiltersFromRequest(request); - // Get search term from query params + // Get search term and levels from query params const url = new URL(request.url); const search = url.searchParams.get("search") ?? undefined; + const levels = parseLevelsFromUrl(url); const presenter = new LogsListPresenter($replica, clickhouseClient); const list = presenter.call(project.organizationId, environment.id, { @@ -73,6 +86,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { projectId: project.id, ...filters, search, + levels, }); const session = await setRootOnlyFilterPreference(filters.rootOnly, request); @@ -260,8 +274,11 @@ function LogsList({ rootOnlyDefault={rootOnlyDefault} hideSearch /> + +
+
{/* Table */} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx new file mode 100644 index 0000000000..442d767cf9 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx @@ -0,0 +1,136 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/node"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { $replica } from "~/db.server"; + +// Fetch run context for a log entry +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam, logId } = { + ...EnvironmentParamSchema.parse(params), + logId: params.logId, + }; + + if (!logId) { + throw new Response("Log ID is required", { status: 400 }); + } + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + // Parse the logId to extract runId + // Log ID format: traceId::spanId::runId::startTime (base64 encoded or plain) + const url = new URL(request.url); + const runId = url.searchParams.get("runId"); + + if (!runId) { + throw new Response("Run ID is required", { status: 400 }); + } + + // Fetch run details from Postgres + const run = await $replica.taskRun.findFirst({ + select: { + id: true, + friendlyId: true, + taskIdentifier: true, + status: true, + createdAt: true, + startedAt: true, + completedAt: true, + isTest: true, + runTags: true, + queue: true, + concurrencyKey: true, + usageDurationMs: true, + costInCents: true, + baseCostInCents: true, + machinePreset: true, + scheduleId: true, + lockedToVersion: { + select: { + version: true, + }, + }, + rootTaskRun: { + select: { + friendlyId: true, + taskIdentifier: true, + }, + }, + parentTaskRun: { + select: { + friendlyId: true, + taskIdentifier: true, + }, + }, + batch: { + select: { + friendlyId: true, + }, + }, + }, + where: { + friendlyId: runId, + runtimeEnvironmentId: environment.id, + }, + }); + + if (!run) { + return json({ run: null }); + } + + // Fetch schedule if scheduleId exists + let schedule: { friendlyId: string } | null = null; + if (run.scheduleId) { + const scheduleData = await $replica.taskSchedule.findFirst({ + select: { friendlyId: true }, + where: { id: run.scheduleId }, + }); + schedule = scheduleData; + } + + return json({ + run: { + id: run.id, + friendlyId: run.friendlyId, + taskIdentifier: run.taskIdentifier, + status: run.status, + createdAt: run.createdAt.toISOString(), + startedAt: run.startedAt?.toISOString(), + completedAt: run.completedAt?.toISOString(), + isTest: run.isTest, + tags: run.runTags, + queue: run.queue, + concurrencyKey: run.concurrencyKey, + usageDurationMs: run.usageDurationMs, + costInCents: run.costInCents, + baseCostInCents: run.baseCostInCents, + machinePreset: run.machinePreset, + version: run.lockedToVersion?.version, + rootRun: run.rootTaskRun + ? { + friendlyId: run.rootTaskRun.friendlyId, + taskIdentifier: run.rootTaskRun.taskIdentifier, + } + : null, + parentRun: run.parentTaskRun + ? { + friendlyId: run.parentTaskRun.friendlyId, + taskIdentifier: run.parentTaskRun.taskIdentifier, + } + : null, + batch: run.batch ? { friendlyId: run.batch.friendlyId } : null, + schedule: schedule, + }, + }); +}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.spans.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.spans.tsx new file mode 100644 index 0000000000..53bc655a02 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.spans.tsx @@ -0,0 +1,100 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/node"; +import { requireUserId } from "~/services/session.server"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; + +// Convert ClickHouse kind to display level +function kindToLevel( + kind: string +): "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "LOG" { + switch (kind) { + case "DEBUG_EVENT": + case "LOG_DEBUG": + return "DEBUG"; + case "LOG_INFO": + return "INFO"; + case "LOG_WARN": + return "WARN"; + case "LOG_ERROR": + return "ERROR"; + case "LOG_LOG": + return "LOG"; + case "SPAN": + case "ANCESTOR_OVERRIDE": + case "SPAN_EVENT": + default: + return "TRACE"; + } +} + +// Fetch related spans for a log entry from the same trace +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam, logId } = { + ...EnvironmentParamSchema.parse(params), + logId: params.logId, + }; + + if (!logId) { + throw new Response("Log ID is required", { status: 400 }); + } + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + // Get trace ID and run ID from query params + const url = new URL(request.url); + const traceId = url.searchParams.get("traceId"); + const runId = url.searchParams.get("runId"); + const currentSpanId = url.searchParams.get("spanId"); + + if (!traceId || !runId) { + throw new Response("Trace ID and Run ID are required", { status: 400 }); + } + + // Query ClickHouse for related spans in the same trace + const queryBuilder = clickhouseClient.taskEventsV2.logsListQueryBuilder(); + + queryBuilder.where("environment_id = {environmentId: String}", { + environmentId: environment.id, + }); + queryBuilder.where("trace_id = {traceId: String}", { traceId }); + queryBuilder.where("run_id = {runId: String}", { runId }); + + // Order by start time to show spans in chronological order + queryBuilder.orderBy("start_time ASC"); + queryBuilder.limit(50); + + const [queryError, records] = await queryBuilder.execute(); + + if (queryError) { + throw queryError; + } + + const results = records || []; + + const spans = results.map((row) => ({ + id: `${row.trace_id}::${row.span_id}::${row.run_id}::${row.start_time}`, + spanId: row.span_id, + parentSpanId: row.parent_span_id || null, + message: row.message.substring(0, 200), // Truncate for list view + kind: row.kind, + level: kindToLevel(row.kind), + status: row.status, + startTime: new Date(Number(row.start_time) / 1_000_000).toISOString(), + duration: Number(row.duration), + isCurrent: row.span_id === currentSpanId, + })); + + return json({ spans }); +}; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.tsx index 65fd4b47af..fe162fb34c 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.tsx @@ -32,38 +32,24 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // Parse logId to extract traceId, spanId, runId, and startTime // Format: {traceId}::{spanId}::{runId}::{startTime} - // Note: startTime may be URL-encoded (spaces as %20) + // All 4 parts are needed to uniquely identify a log entry (multiple events can share the same spanId) const decodedLogId = decodeURIComponent(logId); const parts = decodedLogId.split("::"); if (parts.length !== 4) { throw new Response("Invalid log ID format", { status: 400 }); } - const [traceId, spanId, runId, startTimeStr] = parts; + const [traceId, spanId, , startTime] = parts; const presenter = new LogDetailPresenter($replica, clickhouseClient); - // Convert startTime string to Date (format: YYYY-MM-DD HH:mm:ss.nanoseconds) - // JavaScript Date only handles up to milliseconds, so we need to truncate nanoseconds - let startTimeDate: Date | undefined; - try { - // Remove nanoseconds (keep only up to milliseconds) and convert to ISO format - const dateStr = startTimeStr.split(".")[0].replace(" ", "T") + "Z"; - startTimeDate = new Date(dateStr); - if (isNaN(startTimeDate.getTime())) { - startTimeDate = undefined; - } - } catch { - // If parsing fails, continue without time bounds - } - const result = await presenter.call({ environmentId: environment.id, organizationId: project.organizationId, projectId: project.id, spanId, traceId, - startTime: startTimeDate, + startTime, }); if (!result) { diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts index 3da11528c8..d38b8341a5 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts @@ -5,10 +5,19 @@ import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; -import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server"; +import { LogsListPresenter, type LogLevel } from "~/presenters/v3/LogsListPresenter.server"; import { $replica } from "~/db.server"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; +// Valid log levels for filtering +const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "LOG"]; + +function parseLevelsFromUrl(url: URL): LogLevel[] | undefined { + const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0); + if (levelParams.length === 0) return undefined; + return levelParams.filter((l): l is LogLevel => validLevels.includes(l as LogLevel)); +} + export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); @@ -25,10 +34,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const filters = await getRunFiltersFromRequest(request); - // Get search term and cursor from query params + // Get search term, cursor, and levels from query params const url = new URL(request.url); const search = url.searchParams.get("search") ?? undefined; const cursor = url.searchParams.get("cursor") ?? undefined; + const levels = parseLevelsFromUrl(url); const presenter = new LogsListPresenter($replica, clickhouseClient); const result = await presenter.call(project.organizationId, environment.id, { @@ -37,6 +47,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { ...filters, search, cursor, + levels, }); return json({ diff --git a/internal-packages/clickhouse/src/client/queryBuilder.ts b/internal-packages/clickhouse/src/client/queryBuilder.ts index 78383fd270..5f10d80daa 100644 --- a/internal-packages/clickhouse/src/client/queryBuilder.ts +++ b/internal-packages/clickhouse/src/client/queryBuilder.ts @@ -98,6 +98,7 @@ export class ClickhouseQueryFastBuilder> { private columns: Array; private reader: ClickhouseReader; private settings: ClickHouseSettings | undefined; + private prewhereClauses: string[] = []; private whereClauses: string[] = []; private params: QueryParams = {}; private orderByClause: string | null = null; @@ -118,6 +119,26 @@ export class ClickhouseQueryFastBuilder> { this.settings = settings; } + /** + * Add a PREWHERE clause - filters applied before reading columns. + * Use for primary key columns (environment_id, start_time) to reduce I/O. + * PREWHERE is evaluated before WHERE and significantly reduces data read. + */ + prewhere(clause: string, params?: QueryParams): this { + this.prewhereClauses.push(clause); + if (params) { + Object.assign(this.params, params); + } + return this; + } + + prewhereIf(condition: any, clause: string, params?: QueryParams): this { + if (condition) { + this.prewhere(clause, params); + } + return this; + } + where(clause: string, params?: QueryParams): this { this.whereClauses.push(clause); if (params) { @@ -163,6 +184,9 @@ export class ClickhouseQueryFastBuilder> { build(): { query: string; params: QueryParams } { let query = `SELECT ${this.buildColumns().join(", ")} FROM ${this.table}`; + if (this.prewhereClauses.length > 0) { + query += " PREWHERE " + this.prewhereClauses.join(" AND "); + } if (this.whereClauses.length > 0) { query += " WHERE " + this.whereClauses.join(" AND "); } diff --git a/internal-packages/clickhouse/src/taskEvents.ts b/internal-packages/clickhouse/src/taskEvents.ts index f831163078..b5545791aa 100644 --- a/internal-packages/clickhouse/src/taskEvents.ts +++ b/internal-packages/clickhouse/src/taskEvents.ts @@ -272,7 +272,12 @@ export function getLogsListQueryBuilderV2(ch: ClickhouseReader, settings?: Click "status", "duration", ], - settings, + settings: { + max_memory_usage: "2000000000", // 2GB per query limit + max_bytes_before_external_sort: "1000000000", // 1GB before spill to disk + max_threads: 4, // Limit parallelism to reduce memory + ...settings, + }, }); } From b632a3c8ea23f88811a2f1ba56718bacdf8b9a83 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Mon, 22 Dec 2025 11:23:00 +0200 Subject: [PATCH 03/28] Renaming some logs variables --- .../app/presenters/v3/LogsListPresenter.server.ts | 2 +- apps/webapp/app/utils/pathBuilder.ts | 3 --- .../clickhouse/src/client/queryBuilder.ts | 1 - internal-packages/clickhouse/src/index.ts | 8 ++++---- internal-packages/clickhouse/src/taskEvents.ts | 10 +++++----- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index f0f9380887..e4bd8a9ca7 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -1,4 +1,4 @@ -import { type ClickHouse, type LogsListV2Result } from "@internal/clickhouse"; +import { type ClickHouse, type LogsListResult } from "@internal/clickhouse"; import { MachinePresetName } from "@trigger.dev/core/v3"; import { type PrismaClient, diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 7c3154f26d..b4daad04c0 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -436,10 +436,7 @@ export function v3LogsPath( organization: OrgForPath, project: ProjectForPath, environment: EnvironmentForPath, - // deployment: DeploymentForPath, - // currentPage: number ) { - // const query = currentPage ? `?page=${currentPage}` : ""; return `${v3EnvironmentPath(organization, project, environment)}/logs`; } diff --git a/internal-packages/clickhouse/src/client/queryBuilder.ts b/internal-packages/clickhouse/src/client/queryBuilder.ts index 5f10d80daa..30aad98486 100644 --- a/internal-packages/clickhouse/src/client/queryBuilder.ts +++ b/internal-packages/clickhouse/src/client/queryBuilder.ts @@ -122,7 +122,6 @@ export class ClickhouseQueryFastBuilder> { /** * Add a PREWHERE clause - filters applied before reading columns. * Use for primary key columns (environment_id, start_time) to reduce I/O. - * PREWHERE is evaluated before WHERE and significantly reduces data read. */ prewhere(clause: string, params?: QueryParams): this { this.prewhereClauses.push(clause); diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts index d0f497e04d..2ecbc24860 100644 --- a/internal-packages/clickhouse/src/index.ts +++ b/internal-packages/clickhouse/src/index.ts @@ -22,8 +22,8 @@ import { getTraceSummaryQueryBuilderV2, insertTaskEvents, insertTaskEventsV2, - getLogsListQueryBuilderV2, - getLogDetailQueryBuilderV2, + getLogsListQueryBuilder, + getLogDetailQueryBuilder, } from "./taskEvents.js"; import { Logger, type LogLevel } from "@trigger.dev/core/logger"; import type { Agent as HttpAgent } from "http"; @@ -184,8 +184,8 @@ export class ClickHouse { traceSummaryQueryBuilder: getTraceSummaryQueryBuilderV2(this.reader), traceDetailedSummaryQueryBuilder: getTraceDetailedSummaryQueryBuilderV2(this.reader), spanDetailsQueryBuilder: getSpanDetailsQueryBuilderV2(this.reader), - logsListQueryBuilder: getLogsListQueryBuilderV2(this.reader), - logDetailQueryBuilder: getLogDetailQueryBuilderV2(this.reader), + logsListQueryBuilder: getLogsListQueryBuilder(this.reader), + logDetailQueryBuilder: getLogDetailQueryBuilder(this.reader), }; } } diff --git a/internal-packages/clickhouse/src/taskEvents.ts b/internal-packages/clickhouse/src/taskEvents.ts index b5545791aa..cc450bd064 100644 --- a/internal-packages/clickhouse/src/taskEvents.ts +++ b/internal-packages/clickhouse/src/taskEvents.ts @@ -235,7 +235,7 @@ export function getSpanDetailsQueryBuilderV2( // Logs List Query Builders (for aggregated logs page) // ============================================================================ -export const LogsListV2Result = z.object({ +export const LogsListResult = z.object({ environment_id: z.string(), organization_id: z.string(), project_id: z.string(), @@ -251,10 +251,10 @@ export const LogsListV2Result = z.object({ duration: z.number().or(z.string()), }); -export type LogsListV2Result = z.output; +export type LogsListResult = z.output; -export function getLogsListQueryBuilderV2(ch: ClickhouseReader, settings?: ClickHouseSettings) { - return ch.queryBuilderFast({ +export function getLogsListQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) { + return ch.queryBuilderFast({ name: "getLogsList", table: "trigger_dev.task_events_v2", columns: [ @@ -302,7 +302,7 @@ export const LogDetailV2Result = z.object({ export type LogDetailV2Result = z.output; -export function getLogDetailQueryBuilderV2(ch: ClickhouseReader, settings?: ClickHouseSettings) { +export function getLogDetailQueryBuilder(ch: ClickhouseReader, settings?: ClickHouseSettings) { return ch.queryBuilderFast({ name: "getLogDetail", table: "trigger_dev.task_events_v2", From 80660fced9fba888c7df09c9ea5b70f30e1f25b3 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 31 Dec 2025 14:54:52 +0000 Subject: [PATCH 04/28] Adds custom logs icon --- apps/webapp/app/assets/icons/LogsIcon.tsx | 66 +++++++++++++++++++ .../app/components/navigation/SideMenu.tsx | 10 +-- apps/webapp/tailwind.config.js | 2 + 3 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 apps/webapp/app/assets/icons/LogsIcon.tsx diff --git a/apps/webapp/app/assets/icons/LogsIcon.tsx b/apps/webapp/app/assets/icons/LogsIcon.tsx new file mode 100644 index 0000000000..05969139ae --- /dev/null +++ b/apps/webapp/app/assets/icons/LogsIcon.tsx @@ -0,0 +1,66 @@ +export function LogsIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 00ee4d5d18..06d08a67bb 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -8,7 +8,6 @@ import { ClockIcon, Cog8ToothIcon, CogIcon, - DocumentTextIcon, FolderIcon, FolderOpenIcon, GlobeAmericasIcon, @@ -24,9 +23,10 @@ import { import { Link, useNavigation } from "@remix-run/react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import simplur from "simplur"; -import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; +import { LogsIcon } from "~/assets/icons/LogsIcon"; import { RunsIconExtraSmall } from "~/assets/icons/RunsIcon"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; @@ -57,10 +57,10 @@ import { v3BatchesPath, v3BillingPath, v3BulkActionsPath, - v3LogsPath, v3DeploymentsPath, v3EnvironmentPath, v3EnvironmentVariablesPath, + v3LogsPath, v3ProjectAlertsPath, v3ProjectPath, v3ProjectSettingsPath, @@ -264,8 +264,8 @@ export function SideMenu({ /> diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index 7ca81fd8ee..9f4e4381b8 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -160,6 +160,7 @@ const batches = colors.pink[500]; const schedules = colors.yellow[500]; const queues = colors.purple[500]; const deployments = colors.green[500]; +const logs = colors.blue[500]; const tests = colors.lime[500]; const apiKeys = colors.amber[500]; const environmentVariables = colors.pink[500]; @@ -236,6 +237,7 @@ module.exports = { schedules, queues, deployments, + logs, tests, apiKeys, environmentVariables, From 2f321b151abdc3b5ece6f815e99dfa75c30eaee3 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 31 Dec 2025 15:18:02 +0000 Subject: [PATCH 05/28] Fix hyphenated properties --- apps/webapp/app/assets/icons/LogsIcon.tsx | 48 +++++++++++------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/webapp/app/assets/icons/LogsIcon.tsx b/apps/webapp/app/assets/icons/LogsIcon.tsx index 05969139ae..3178da237e 100644 --- a/apps/webapp/app/assets/icons/LogsIcon.tsx +++ b/apps/webapp/app/assets/icons/LogsIcon.tsx @@ -8,58 +8,58 @@ export function LogsIcon({ className }: { className?: string }) { ); From e9848cfe7566dbf9ac2eaabd2c33e7384e34002e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 31 Dec 2025 15:45:23 +0000 Subject: [PATCH 06/28] Adds new Table variant for the logs style --- apps/webapp/app/components/logs/LogsTable.tsx | 23 ++++-------- .../app/components/primitives/Table.tsx | 35 ++++++++++++++++--- 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index d4ef356368..3daf2e9fad 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -89,10 +89,7 @@ function getLevelBorderColor(level: LogEntry["level"]): string { } // Case-insensitive text highlighting -function highlightText( - text: string, - searchTerm: string | undefined -): ReactNode { +function highlightText(text: string, searchTerm: string | undefined): ReactNode { if (!searchTerm || searchTerm.trim() === "") { return text; } @@ -161,7 +158,7 @@ export function LogsTable({ return (
- +
Time @@ -197,11 +194,9 @@ export function LogsTable({ @@ -255,10 +250,7 @@ export function LogsTable({ : "–"} - + {highlightText(log.message, searchTerm)} @@ -270,10 +262,7 @@ export function LogsTable({
{/* Infinite scroll trigger */} {hasMore && logs.length > 0 && ( -
+
{isLoadingMore && (
Loading more… diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index a51c78cc82..d6689a6109 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -8,7 +8,10 @@ import { InfoIconTooltip } from "./Tooltip"; const variants = { bright: { header: "bg-background-bright", + headerCell: "px-3 py-2.5 pb-3 text-sm", cell: "group-hover/table-row:bg-charcoal-750 group-has-[[tabindex='0']:focus]/table-row:bg-charcoal-750", + cellSize: "px-3 py-3", + cellText: "text-xs", stickyCell: "bg-background-bright group-hover/table-row:bg-charcoal-750", menuButton: "bg-background-bright group-hover/table-row:bg-charcoal-750 group-hover/table-row:ring-charcoal-600/70 group-has-[[tabindex='0']:focus]/table-row:bg-charcoal-750", @@ -17,7 +20,22 @@ const variants = { }, dimmed: { header: "bg-background-dimmed", + headerCell: "px-3 py-2.5 pb-3 text-sm", cell: "group-hover/table-row:bg-charcoal-800 group-has-[[tabindex='0']:focus]/table-row:bg-background-bright", + cellSize: "px-3 py-3", + cellText: "text-xs", + stickyCell: "group-hover/table-row:bg-charcoal-800", + menuButton: + "bg-background-dimmed group-hover/table-row:bg-charcoal-800 group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright", + menuButtonDivider: "group-hover/table-row:border-grid-bright", + rowSelected: "bg-charcoal-750 group-hover/table-row:bg-charcoal-750", + }, + "compact/mono": { + header: "bg-background-dimmed", + headerCell: "px-2 py-1.5 text-sm", + cell: "group-hover/table-row:bg-charcoal-800 group-has-[[tabindex='0']:focus]/table-row:bg-background-bright", + cellSize: "px-2 py-1.5", + cellText: "text-xs font-mono", stickyCell: "group-hover/table-row:bg-charcoal-800", menuButton: "bg-background-dimmed group-hover/table-row:bg-charcoal-800 group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright", @@ -136,6 +154,7 @@ type TableHeaderCellProps = TableCellBasicProps & { export const TableHeaderCell = forwardRef( ({ className, alignment = "left", children, colSpan, hiddenLabel = false, tooltip }, ref) => { + const { variant } = useContext(TableContext); let alignmentClassName = "text-left"; switch (alignment) { case "center": @@ -151,7 +170,8 @@ export const TableHeaderCell = forwardRef( break; } + const { variant } = useContext(TableContext); const flexClasses = cn( - "flex w-full whitespace-nowrap px-3 py-3 items-center text-xs text-text-dimmed", + "flex w-full whitespace-nowrap items-center text-text-dimmed", + variants[variant].cellSize, + variants[variant].cellText, alignment === "left" ? "justify-start text-left" : alignment === "center" ? "justify-center text-center" : "justify-end text-right" ); - const { variant } = useContext(TableContext); return ( Date: Wed, 31 Dec 2025 15:50:11 +0000 Subject: [PATCH 07/28] Brighten the text on table row hover --- apps/webapp/app/components/primitives/Table.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index d6689a6109..cc483bdac6 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -11,7 +11,7 @@ const variants = { headerCell: "px-3 py-2.5 pb-3 text-sm", cell: "group-hover/table-row:bg-charcoal-750 group-has-[[tabindex='0']:focus]/table-row:bg-charcoal-750", cellSize: "px-3 py-3", - cellText: "text-xs", + cellText: "text-xs group-hover/table-row:text-text-bright", stickyCell: "bg-background-bright group-hover/table-row:bg-charcoal-750", menuButton: "bg-background-bright group-hover/table-row:bg-charcoal-750 group-hover/table-row:ring-charcoal-600/70 group-has-[[tabindex='0']:focus]/table-row:bg-charcoal-750", @@ -23,7 +23,7 @@ const variants = { headerCell: "px-3 py-2.5 pb-3 text-sm", cell: "group-hover/table-row:bg-charcoal-800 group-has-[[tabindex='0']:focus]/table-row:bg-background-bright", cellSize: "px-3 py-3", - cellText: "text-xs", + cellText: "text-xs group-hover/table-row:text-text-bright", stickyCell: "group-hover/table-row:bg-charcoal-800", menuButton: "bg-background-dimmed group-hover/table-row:bg-charcoal-800 group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright", @@ -35,7 +35,7 @@ const variants = { headerCell: "px-2 py-1.5 text-sm", cell: "group-hover/table-row:bg-charcoal-800 group-has-[[tabindex='0']:focus]/table-row:bg-background-bright", cellSize: "px-2 py-1.5", - cellText: "text-xs font-mono", + cellText: "text-xs font-mono group-hover/table-row:text-text-bright", stickyCell: "group-hover/table-row:bg-charcoal-800", menuButton: "bg-background-dimmed group-hover/table-row:bg-charcoal-800 group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright", From 2e8eee1e6fdc08cbe068eedb00433f0dded449c7 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 31 Dec 2025 16:30:39 +0000 Subject: [PATCH 08/28] Tightened up the padding slightly for the Simple Tooltip --- apps/webapp/app/components/primitives/Tooltip.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx index 5c681927b5..7b74a92209 100644 --- a/apps/webapp/app/components/primitives/Tooltip.tsx +++ b/apps/webapp/app/components/primitives/Tooltip.tsx @@ -5,8 +5,8 @@ import { cn } from "~/utils/cn"; const variantClasses = { basic: - "bg-background-bright border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50", - dark: "bg-background-dimmed border border-grid-bright rounded px-3 py-2 text-sm text-text-bright shadow-md fade-in-50" + "bg-background-bright border border-grid-bright rounded px-2 py-1.5 text-xs text-text-bright shadow-md fade-in-50", + dark: "bg-background-dimmed border border-grid-bright rounded px-2 py-1.5 text-xs text-text-bright shadow-md fade-in-50", }; type Variant = keyof typeof variantClasses; From 6c65cedaeae271d9e2bb0144e9907643625681ec Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 31 Dec 2025 16:31:01 +0000 Subject: [PATCH 09/28] Uses remix component instead of --- apps/webapp/app/components/logs/LogsTable.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index 3daf2e9fad..403aba16b4 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -1,4 +1,5 @@ -import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; +import { ArrowPathIcon } from "@heroicons/react/20/solid"; +import { Link } from "@remix-run/react"; import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; import { type ReactNode, useEffect, useRef } from "react"; import { cn } from "~/utils/cn"; @@ -11,6 +12,7 @@ import { v3RunSpanPath } from "~/utils/pathBuilder"; import { DateTime } from "../primitives/DateTime"; import { Paragraph } from "../primitives/Paragraph"; import { Spinner } from "../primitives/Spinner"; +import { SimpleTooltip } from "../primitives/Tooltip"; import { Table, TableBlankRow, @@ -208,14 +210,18 @@ export function LogsTable({ - - {log.runId.slice(0, 12)}... - - + + {log.runId.slice(0, 12)}… + + } + /> {log.taskIdentifier} From 31c2ea1a6164835a0ed27cde8368ecdfe7d0f382 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 31 Dec 2025 16:38:30 +0000 Subject: [PATCH 10/28] Adds hover state color to row left border --- apps/webapp/app/components/logs/LogsTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index 403aba16b4..1b9ff0ef4b 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -86,7 +86,7 @@ function getLevelBorderColor(level: LogEntry["level"]): string { case "TRACE": case "LOG": default: - return "border-l-transparent"; + return "border-l-transparent hover:border-l-charcoal-800"; } } From 10955a2c64ec699b73df1af1bb32eb6984f7f31e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 31 Dec 2025 16:48:31 +0000 Subject: [PATCH 11/28] Slightly smaller status badges --- apps/webapp/app/components/logs/LogsTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index 1b9ff0ef4b..28c85392aa 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -229,7 +229,7 @@ export function LogsTable({ @@ -239,7 +239,7 @@ export function LogsTable({ From 1dbfdce11838f749732990e4d273b88def311e40 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Tue, 6 Jan 2026 21:46:38 +0200 Subject: [PATCH 12/28] added seed reference project, mostly for generating logs right now --- references/seed/src/index.ts | 1 + references/seed/src/trigger/logSpammer.ts | 109 +++++++++++++++++++++ references/seed/src/trigger/seedTask.ts | 32 ++++++ references/seed/src/trigger/spanSpammer.ts | 42 ++++++++ references/seed/src/trigger/throwError.ts | 16 +++ references/seed/trigger.config.ts | 56 +++++++++++ references/seed/tsconfig.json | 15 +++ 7 files changed, 271 insertions(+) create mode 100644 references/seed/src/index.ts create mode 100644 references/seed/src/trigger/logSpammer.ts create mode 100644 references/seed/src/trigger/seedTask.ts create mode 100644 references/seed/src/trigger/spanSpammer.ts create mode 100644 references/seed/src/trigger/throwError.ts create mode 100644 references/seed/trigger.config.ts create mode 100644 references/seed/tsconfig.json diff --git a/references/seed/src/index.ts b/references/seed/src/index.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/references/seed/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/references/seed/src/trigger/logSpammer.ts b/references/seed/src/trigger/logSpammer.ts new file mode 100644 index 0000000000..7156b55602 --- /dev/null +++ b/references/seed/src/trigger/logSpammer.ts @@ -0,0 +1,109 @@ +import { logger, task, wait } from "@trigger.dev/sdk/v3"; + +const LONG_TEXT = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.`; + +const SEARCHABLE_TERMS = [ + "authentication_failed", + "database_connection_error", + "payment_processed", + "user_registration_complete", + "api_rate_limit_exceeded", + "cache_invalidation", + "webhook_delivery_success", + "session_expired", + "file_upload_complete", + "email_sent_successfully", +]; + +function generateLargeJson(index: number) { + return { + requestId: `req_${Date.now()}_${index}`, + timestamp: new Date().toISOString(), + metadata: { + source: "log-spammer-task", + environment: "development", + version: "1.0.0", + region: ["us-east-1", "eu-west-1", "ap-southeast-1"][index % 3], + }, + user: { + id: `user_${1000 + index}`, + email: `testuser${index}@example.com`, + name: `Test User ${index}`, + preferences: { + theme: index % 2 === 0 ? "dark" : "light", + notifications: { email: true, push: false, sms: index % 3 === 0 }, + language: ["en", "es", "fr", "de"][index % 4], + }, + }, + payload: { + items: Array.from({ length: 5 }, (_, i) => ({ + itemId: `item_${index}_${i}`, + name: `Product ${i}`, + price: Math.random() * 100, + quantity: Math.floor(Math.random() * 10) + 1, + tags: ["electronics", "sale", "featured"].slice(0, (i % 3) + 1), + })), + totals: { + subtotal: Math.random() * 500, + tax: Math.random() * 50, + shipping: Math.random() * 20, + discount: Math.random() * 30, + }, + }, + debugInfo: { + stackTrace: `Error: ${SEARCHABLE_TERMS[index % SEARCHABLE_TERMS.length]}\n at processRequest (/app/src/handlers/main.ts:${100 + index}:15)\n at handleEvent (/app/src/events/processor.ts:${50 + index}:8)\n at async Runtime.handler (/app/src/index.ts:25:3)`, + memoryUsage: { heapUsed: 45000000 + index * 1000, heapTotal: 90000000 }, + cpuTime: Math.random() * 1000, + }, + longDescription: LONG_TEXT.repeat(2), + }; +} + +export const logSpammerTask = task({ + id: "log-spammer", + maxDuration: 300, + run: async () => { + logger.info("Starting log spammer task for search testing"); + + for (let i = 0; i < 50; i++) { + const term = SEARCHABLE_TERMS[i % SEARCHABLE_TERMS.length]; + const jsonPayload = generateLargeJson(i); + + logger.log(`Processing event: ${term}`, { data: jsonPayload }); + + if (i % 5 === 0) { + logger.warn(`Warning triggered for ${term}`, { + warningCode: `WARN_${i}`, + details: jsonPayload, + longMessage: LONG_TEXT, + }); + } + + if (i % 10 === 0) { + logger.error(`Error encountered: ${term}`, { + errorCode: `ERR_${i}`, + stack: jsonPayload.debugInfo.stackTrace, + context: jsonPayload, + }); + } + + logger.debug(`Debug info for iteration ${i}`, { + iteration: i, + searchTerm: term, + fullPayload: jsonPayload, + additionalText: `${LONG_TEXT} --- Iteration ${i} complete with term ${term}`, + }); + + if (i % 10 === 0) { + await wait.for({ seconds: 0.5 }); + } + } + + logger.info("Log spammer task completed", { + totalLogs: 50 * 4, + searchableTerms: SEARCHABLE_TERMS, + }); + + return { success: true, logsGenerated: 200 }; + }, +}); diff --git a/references/seed/src/trigger/seedTask.ts b/references/seed/src/trigger/seedTask.ts new file mode 100644 index 0000000000..2fb4305492 --- /dev/null +++ b/references/seed/src/trigger/seedTask.ts @@ -0,0 +1,32 @@ +import { task, batch } from "@trigger.dev/sdk/v3"; +import { ErrorTask } from "./throwError.js"; +import { SpanSpammerTask } from "./spanSpammer.js"; +import { logSpammerTask } from "./logSpammer.js"; + +export const seedTask = task({ + id: "seed-task", + run: async (payload: any, { ctx }) => { + let tasksToRun = []; + + for (let i = 0; i < 10; i++) { + tasksToRun.push({ + id: "simple-throw-error", + payload: {}, + options: { delay: `${i}s` }, + }); + } + + tasksToRun.push({ + id: "span-spammer", + payload: {}, + }); + + tasksToRun.push({ + id: "log-spammer", + payload: {}, + }); + + await batch.triggerAndWait(tasksToRun); + return; + }, +}); diff --git a/references/seed/src/trigger/spanSpammer.ts b/references/seed/src/trigger/spanSpammer.ts new file mode 100644 index 0000000000..b16f00c4c2 --- /dev/null +++ b/references/seed/src/trigger/spanSpammer.ts @@ -0,0 +1,42 @@ +import { logger, task, wait } from "@trigger.dev/sdk/v3"; + +const CONFIG = { + delayBetweenBatchesSeconds: 0.2, + logsPerBatch: 30, + totalBatches: 100, + initialDelaySeconds: 5, +} as const; + +export const SpanSpammerTask = task({ + id: "span-spammer", + maxDuration: 300, + run: async (payload: any, { ctx }) => { + const context = { payload, ctx }; + let logCount = 0; + + logger.info("Starting span spammer task", context); + logger.warn("This will generate a lot of logs", context); + + + const emitBatch = (prefix: string) => { + logger.debug("Started spam batch emit!", context); + + for (let i = 0; i < CONFIG.logsPerBatch; i++) { + logger.log(`${prefix} ${++logCount}`, context); + } + + logger.debug('Completed spam batch emit!', context); + }; + + emitBatch("Log number"); + await wait.for({ seconds: CONFIG.initialDelaySeconds }); + + for (let batch = 0; batch < CONFIG.totalBatches; batch++) { + await wait.for({ seconds: CONFIG.delayBetweenBatchesSeconds }); + emitBatch("This is a test log!!! Log number: "); + } + + logger.info("Completed span spammer task", context); + return { message: `Created ${logCount} logs` }; + }, +}); diff --git a/references/seed/src/trigger/throwError.ts b/references/seed/src/trigger/throwError.ts new file mode 100644 index 0000000000..5f2d623a01 --- /dev/null +++ b/references/seed/src/trigger/throwError.ts @@ -0,0 +1,16 @@ +import { logger, task, wait } from "@trigger.dev/sdk/v3"; + + +export const ErrorTask = task({ + id: "simple-throw-error", + maxDuration: 60, + run: async (payload: any, { ctx }) => { + logger.log("This task is about to throw an error!", { payload, ctx }); + + await wait.for({ seconds: 9 }); + throw new Error("This is an expected test error from ErrorTask!"); + }, + onFailure: async ({ payload, error, ctx }) => { + logger.warn("ErrorTask failed!", { payload, error, ctx }); + } +}); diff --git a/references/seed/trigger.config.ts b/references/seed/trigger.config.ts new file mode 100644 index 0000000000..f87620cd78 --- /dev/null +++ b/references/seed/trigger.config.ts @@ -0,0 +1,56 @@ +import { defineConfig } from "@trigger.dev/sdk/v3"; +import { syncEnvVars } from "@trigger.dev/build/extensions/core"; +import { lightpanda } from "@trigger.dev/build/extensions/lightpanda"; + +export default defineConfig({ + compatibilityFlags: ["run_engine_v2"], + project: process.env.TRIGGER_PROJECT_REF!, + experimental_processKeepAlive: { + enabled: true, + maxExecutionsPerProcess: 20, + }, + logLevel: "debug", + maxDuration: 3600, + retries: { + enabledInDev: true, + default: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + factor: 2, + randomize: true, + }, + }, + machine: "small-2x", + build: { + extensions: [ + lightpanda(), + syncEnvVars(async (ctx) => { + return [ + { name: "SYNC_ENV", value: ctx.environment }, + { name: "BRANCH", value: ctx.branch ?? "NO_BRANCH" }, + { name: "BRANCH", value: "PARENT", isParentEnv: true }, + { name: "SECRET_KEY", value: "secret-value" }, + { name: "ANOTHER_SECRET", value: "another-secret-value" }, + ]; + }), + { + name: "npm-token", + onBuildComplete: async (context, manifest) => { + if (context.target === "dev") { + return; + } + + context.addLayer({ + id: "npm-token", + build: { + env: { + NPM_TOKEN: manifest.deploy.env?.NPM_TOKEN, + }, + }, + }); + }, + }, + ], + }, +}); diff --git a/references/seed/tsconfig.json b/references/seed/tsconfig.json new file mode 100644 index 0000000000..9a5ee0b9d6 --- /dev/null +++ b/references/seed/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "Node16", + "moduleResolution": "Node16", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "customConditions": ["@triggerdotdev/source"], + "jsx": "preserve", + "lib": ["DOM", "DOM.Iterable"], + "noEmit": true + }, + "include": ["./src/**/*.ts", "trigger.config.ts"] +} From 6be1dbd01af71f22a4aeb120dff6714271bcff5b Mon Sep 17 00:00:00 2001 From: mpcgird Date: Thu, 8 Jan 2026 20:56:04 +0200 Subject: [PATCH 13/28] removed time presets debug level only for admins removed AI helper --- .../app/components/logs/LogsLevelFilter.tsx | 24 +++- .../app/components/logs/LogsSearchInput.tsx | 115 ++++++------------ apps/webapp/app/components/logs/LogsTable.tsx | 19 +-- .../app/components/logs/LogsTimePresets.tsx | 55 --------- .../presenters/v3/LogsListPresenter.server.ts | 31 ++++- .../route.tsx | 62 ++++++---- pnpm-lock.yaml | 66 +++++++--- 7 files changed, 175 insertions(+), 197 deletions(-) delete mode 100644 apps/webapp/app/components/logs/LogsTimePresets.tsx diff --git a/apps/webapp/app/components/logs/LogsLevelFilter.tsx b/apps/webapp/app/components/logs/LogsLevelFilter.tsx index 94953f2885..5337c1bcd2 100644 --- a/apps/webapp/app/components/logs/LogsLevelFilter.tsx +++ b/apps/webapp/app/components/logs/LogsLevelFilter.tsx @@ -16,7 +16,7 @@ import { FilterMenuProvider, appliedSummary } from "~/components/runs/v3/SharedF import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server"; import { cn } from "~/utils/cn"; -const logLevels: { level: LogLevel; label: string; color: string }[] = [ +const allLogLevels: { level: LogLevel; label: string; color: string }[] = [ { level: "ERROR", label: "Error", color: "text-error" }, { level: "WARN", label: "Warning", color: "text-warning" }, { level: "INFO", label: "Info", color: "text-blue-400" }, @@ -25,6 +25,13 @@ const logLevels: { level: LogLevel; label: string; color: string }[] = [ { level: "TRACE", label: "Trace", color: "text-charcoal-500" }, ]; +function getAvailableLevels(showDebug: boolean): typeof allLogLevels { + if (showDebug) { + return allLogLevels; + } + return allLogLevels.filter((level) => level.level !== "DEBUG"); +} + function getLevelBadgeColor(level: LogLevel): string { switch (level) { case "ERROR": @@ -45,13 +52,13 @@ function getLevelBadgeColor(level: LogLevel): string { const shortcut = { key: "l" }; -export function LogsLevelFilter() { +export function LogsLevelFilter({ showDebug = false }: { showDebug?: boolean }) { const { values } = useSearchParams(); const selectedLevels = values("levels"); const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== ""); if (hasLevels) { - return ; + return ; } return ( @@ -70,6 +77,7 @@ export function LogsLevelFilter() { } searchValue={search} clearSearchValue={() => setSearch("")} + showDebug={showDebug} /> )} @@ -81,11 +89,13 @@ function LevelDropdown({ clearSearchValue, searchValue, onClose, + showDebug = false, }: { trigger: ReactNode; clearSearchValue: () => void; searchValue: string; onClose?: () => void; + showDebug?: boolean; }) { const { values, replace } = useSearchParams(); @@ -94,11 +104,12 @@ function LevelDropdown({ replace({ levels: values, cursor: undefined, direction: undefined }); }; + const availableLevels = getAvailableLevels(showDebug); const filtered = useMemo(() => { - return logLevels.filter((item) => + return availableLevels.filter((item) => item.label.toLowerCase().includes(searchValue.toLowerCase()) ); - }, [searchValue]); + }, [searchValue, availableLevels]); return ( @@ -137,7 +148,7 @@ function LevelDropdown({ ); } -function AppliedLevelFilter() { +function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) { const { values, del } = useSearchParams(); const levels = values("levels"); @@ -162,6 +173,7 @@ function AppliedLevelFilter() { } searchValue={search} clearSearchValue={() => setSearch("")} + showDebug={showDebug} /> )} diff --git a/apps/webapp/app/components/logs/LogsSearchInput.tsx b/apps/webapp/app/components/logs/LogsSearchInput.tsx index e2e0f68040..1843089660 100644 --- a/apps/webapp/app/components/logs/LogsSearchInput.tsx +++ b/apps/webapp/app/components/logs/LogsSearchInput.tsx @@ -1,14 +1,10 @@ import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { useNavigate } from "@remix-run/react"; import { useCallback, useEffect, useRef, useState } from "react"; -import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; import { Input } from "~/components/primitives/Input"; import { ShortcutKey } from "~/components/primitives/ShortcutKey"; import { cn } from "~/utils/cn"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; -import { AIFilterInput } from "~/components/runs/v3/AIFilterInput"; - -type SearchMode = "ai" | "text"; export function LogsSearchInput() { const location = useOptimisticLocation(); @@ -19,7 +15,6 @@ export function LogsSearchInput() { const searchParams = new URLSearchParams(location.search); const initialSearch = searchParams.get("search") ?? ""; - const [mode, setMode] = useState("text"); const [text, setText] = useState(initialSearch); const [isFocused, setIsFocused] = useState(false); @@ -54,81 +49,47 @@ export function LogsSearchInput() { navigate(`${location.pathname}?${params.toString()}`, { replace: true }); }, [location.pathname, location.search, navigate]); - const toggleMode = useCallback(() => { - // Clear text search when switching modes - if (mode === "text" && text.trim()) { - handleClear(); - } - setMode((prev) => (prev === "ai" ? "text" : "ai")); - }, [mode, text, handleClear]); - return (
- {/* Mode toggle button */} - - - {/* Show AI or text search based on mode */} - {mode === "ai" ? ( - - ) : ( -
-
- setText(e.target.value)} - fullWidth - className={cn(isFocused && "placeholder:text-text-dimmed/70")} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleSubmit(); - } - if (e.key === "Escape") { - e.currentTarget.blur(); - } - }} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - icon={} - accessory={ - text.length > 0 ? ( - - ) : undefined - } - /> -
+
+ setText(e.target.value)} + fullWidth + className={cn(isFocused && "placeholder:text-text-dimmed/70")} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(); + } + if (e.key === "Escape") { + e.currentTarget.blur(); + } + }} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + icon={} + accessory={ + text.length > 0 ? ( + + ) : undefined + } + /> +
- {text.length > 0 && ( - - )} -
+ {text.length > 0 && ( + )}
); diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index 28c85392aa..9a758c7d5c 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -12,7 +12,7 @@ import { v3RunSpanPath } from "~/utils/pathBuilder"; import { DateTime } from "../primitives/DateTime"; import { Paragraph } from "../primitives/Paragraph"; import { Spinner } from "../primitives/Spinner"; -import { SimpleTooltip } from "../primitives/Tooltip"; +import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue"; import { Table, TableBlankRow, @@ -209,21 +209,10 @@ export function LogsTable({ >
- - - {log.runId.slice(0, 12)}… - - } - /> + + - + {log.taskIdentifier} diff --git a/apps/webapp/app/components/logs/LogsTimePresets.tsx b/apps/webapp/app/components/logs/LogsTimePresets.tsx deleted file mode 100644 index 059be6f0f6..0000000000 --- a/apps/webapp/app/components/logs/LogsTimePresets.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Button } from "~/components/primitives/Buttons"; -import { useSearchParams } from "~/hooks/useSearchParam"; -import { cn } from "~/utils/cn"; - -// Quick preset periods for common log viewing use cases -const quickPeriods = [ - { label: "5m", value: "5m" }, - { label: "15m", value: "15m" }, - { label: "1h", value: "1h" }, - { label: "24h", value: "1d" }, - { label: "7d", value: "7d" }, -] as const; - -export function LogsTimePresets() { - const { value, replace } = useSearchParams(); - const currentPeriod = value("period"); - const hasCustomRange = value("from") || value("to"); - - // Don't show presets if custom range is active - if (hasCustomRange) { - return null; - } - - const handlePeriodClick = (period: string) => { - replace({ - period, - cursor: undefined, - direction: undefined, - from: undefined, - to: undefined, - }); - }; - - return ( -
- {quickPeriods.map((p) => ( - - ))} -
- ); -} diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index e4bd8a9ca7..44b5035a5d 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -42,6 +42,7 @@ export type LogsListOptions = { levels?: LogLevel[]; // search search?: string; + includeDebugLogs?: boolean; // pagination direction?: Direction; cursor?: string; @@ -155,6 +156,7 @@ export class LogsListPresenter { direction = "forward", cursor, pageSize = DEFAULT_PAGE_SIZE, + includeDebugLogs = true, }: LogsListOptions ) { // Get time values from raw values (including default period) @@ -381,11 +383,16 @@ export class LogsListPresenter { queryBuilder.where("run_id IN {runIds: Array(String)}", { runIds }); } - // Case-insensitive contains message search using ilike + // Case-insensitive search in message and status fields if (search && search.trim() !== "") { - queryBuilder.where("message ilike {searchPattern: String}", { - searchPattern: `%${search.trim()}%`, - }); + const searchTerm = search.trim(); + queryBuilder.where( + "(message ilike {searchPattern: String} OR status = {statusTerm: String})", + { + searchPattern: `%${searchTerm}%`, + statusTerm: searchTerm.toUpperCase(), + } + ); } // Level filter (map display levels to ClickHouse kinds) @@ -394,6 +401,22 @@ export class LogsListPresenter { queryBuilder.where("kind IN {kinds: Array(String)}", { kinds }); } + // Exclude DEBUG logs if not included + if (includeDebugLogs === false) { + queryBuilder.where("kind NOT IN {debugKinds: Array(String)}", { + debugKinds: ["DEBUG_EVENT", "LOG_DEBUG"], + }); + } + + // Exclude TRACE logs with PARTIAL status + // const traceKinds = ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"]; + // queryBuilder.where( + // "NOT (kind IN {traceKinds: Array(String)} AND status = 'PARTIAL')", + // { traceKinds } + // ); + queryBuilder.where("NOT (kind = 'SPAN' AND status = 'PARTIAL')"); + + // Cursor pagination const decodedCursor = cursor ? decodeCursor(cursor) : null; if (decodedCursor) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index ffeed1a36b..e97a80d36f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -6,7 +6,7 @@ import { type UseDataFunctionReturn, useTypedLoaderData, } from "remix-typedjson"; -import { requireUserId } from "~/services/session.server"; +import { requireUser } from "~/services/session.server"; import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; import { findProjectBySlug } from "~/models/project.server"; @@ -34,13 +34,13 @@ import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; import { LogDetailView } from "~/components/logs/LogDetailView"; import { LogsSearchInput } from "~/components/logs/LogsSearchInput"; import { LogsLevelFilter } from "~/components/logs/LogsLevelFilter"; -import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter"; -import { LogsTimePresets } from "~/components/logs/LogsTimePresets"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "~/components/primitives/Resizable"; +import { Switch } from "~/components/primitives/Switch"; +import { getUserById } from "~/models/user.server"; // Valid log levels for filtering const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "LOG"]; @@ -60,7 +60,10 @@ export const meta: MetaFunction = () => { }; export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); + const user = await requireUser(request); + const userId = user.id; + const isAdmin = user.admin || user.isImpersonating; + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); @@ -75,10 +78,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const filters = await getRunFiltersFromRequest(request); - // Get search term and levels from query params + // Get search term, levels, and showDebug from query params const url = new URL(request.url); const search = url.searchParams.get("search") ?? undefined; const levels = parseLevelsFromUrl(url); + const showDebug = url.searchParams.get("showDebug") === "true"; const presenter = new LogsListPresenter($replica, clickhouseClient); const list = presenter.call(project.organizationId, environment.id, { @@ -87,6 +91,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { ...filters, search, levels, + includeDebugLogs: isAdmin && showDebug, }); const session = await setRootOnlyFilterPreference(filters.rootOnly, request); @@ -97,6 +102,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { data: list, rootOnlyDefault: filters.rootOnly, filters, + isAdmin, + showDebug, }, { headers: { @@ -107,21 +114,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { data, rootOnlyDefault, filters } = useTypedLoaderData(); + const { data, rootOnlyDefault, filters, isAdmin, showDebug } = useTypedLoaderData(); return ( - - - Logs docs - - @@ -154,6 +152,8 @@ export default function Page() { list={list} rootOnlyDefault={rootOnlyDefault} filters={filters} + isAdmin={isAdmin} + showDebug={showDebug} /> ); }} @@ -167,11 +167,14 @@ export default function Page() { function LogsList({ list, rootOnlyDefault, - filters, + isAdmin, + showDebug, }: { list: Awaited["data"]>; rootOnlyDefault: boolean; filters: TaskRunListSearchFilters; + isAdmin: boolean; + showDebug: boolean; }) { const navigation = useNavigation(); const location = useLocation(); @@ -189,6 +192,19 @@ function LogsList({ return params.get("log") ?? undefined; }); + const handleDebugToggle = useCallback( + (checked: boolean) => { + const url = new URL(window.location.href); + if (checked) { + url.searchParams.set("showDebug", "true"); + } else { + url.searchParams.delete("showDebug"); + } + window.location.href = url.toString(); + }, + [] + ); + // Reset accumulated logs when the initial list changes (e.g., filters change) useEffect(() => { setAccumulatedLogs(list.logs); @@ -245,7 +261,6 @@ function LogsList({ [] ); - // Handle log selection const handleLogSelect = useCallback( (logId: string) => { setSelectedLogId(logId); @@ -254,7 +269,6 @@ function LogsList({ [updateUrlWithLog] ); - // Handle closing the side panel const handleClosePanel = useCallback(() => { setSelectedLogId(undefined); updateUrlWithLog(undefined); @@ -274,11 +288,17 @@ function LogsList({ rootOnlyDefault={rootOnlyDefault} hideSearch /> - - +
- + {isAdmin && ( + + )}
{/* Table */} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fa66945d4..493f9ba884 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1054,7 +1054,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -2675,6 +2675,40 @@ importers: specifier: ^5 version: 5.5.4 + references/seed: + dependencies: + '@sinclair/typebox': + specifier: ^0.34.3 + version: 0.34.38 + '@trigger.dev/build': + specifier: workspace:* + version: link:../../packages/build + '@trigger.dev/sdk': + specifier: workspace:* + version: link:../../packages/trigger-sdk + arktype: + specifier: ^2.0.0 + version: 2.1.20 + openai: + specifier: ^4.97.0 + version: 4.97.0(encoding@0.1.13)(ws@8.18.3(bufferutil@4.0.9))(zod@3.25.76) + puppeteer-core: + specifier: ^24.15.0 + version: 24.15.0(bufferutil@4.0.9) + replicate: + specifier: ^1.0.1 + version: 1.0.1 + yup: + specifier: ^1.6.1 + version: 1.7.0 + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + trigger.dev: + specifier: workspace:* + version: link:../../packages/cli-v3 + references/telemetry: dependencies: '@opentelemetry/resources': @@ -11575,9 +11609,6 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - bare-events@2.5.4: - resolution: {integrity: sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==} - bare-events@2.8.2: resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} peerDependencies: @@ -23652,7 +23683,7 @@ snapshots: '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)': dependencies: '@hono/node-server': 1.12.2(hono@4.5.11) - ws: 8.18.0(bufferutil@4.0.9) + ws: 8.18.3(bufferutil@4.0.9) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -32058,17 +32089,14 @@ snapshots: balanced-match@1.0.2: {} - bare-events@2.5.4: - optional: true - bare-events@2.8.2: optional: true bare-fs@4.5.1: dependencies: - bare-events: 2.5.4 + bare-events: 2.8.2 bare-path: 3.0.0 - bare-stream: 2.6.5(bare-events@2.5.4) + bare-stream: 2.6.5(bare-events@2.8.2) bare-url: 2.3.2 fast-fifo: 1.3.2 transitivePeerDependencies: @@ -32083,11 +32111,11 @@ snapshots: bare-os: 3.6.1 optional: true - bare-stream@2.6.5(bare-events@2.5.4): + bare-stream@2.6.5(bare-events@2.8.2): dependencies: streamx: 2.22.0 optionalDependencies: - bare-events: 2.5.4 + bare-events: 2.8.2 transitivePeerDependencies: - bare-abort-controller optional: true @@ -32140,7 +32168,7 @@ snapshots: dependencies: buffer: 5.7.1 inherits: 2.0.4 - readable-stream: 3.6.0 + readable-stream: 3.6.2 body-parser@1.20.3: dependencies: @@ -38872,7 +38900,7 @@ snapshots: react: 19.1.0 scheduler: 0.26.0 - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -38909,8 +38937,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3(bufferutil@4.0.9) - socket.io-client: 4.7.3(bufferutil@4.0.9) + socket.io: 4.7.3 + socket.io-client: 4.7.3 sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -40039,7 +40067,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3(bufferutil@4.0.9): + socket.io-client@4.7.3: dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -40068,7 +40096,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3(bufferutil@4.0.9): + socket.io@4.7.3: dependencies: accepts: 1.3.8 base64id: 2.0.0 @@ -40721,7 +40749,7 @@ snapshots: end-of-stream: 1.4.4 fs-constants: 1.0.0 inherits: 2.0.4 - readable-stream: 3.6.0 + readable-stream: 3.6.2 tar-stream@3.1.7: dependencies: From 16ee2db717bb8ea0956d3d7e286f901793288019 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Thu, 8 Jan 2026 21:46:23 +0200 Subject: [PATCH 14/28] dealy the loading spinner for logs for .5 sec keep header when scrolling in logs page fix logs table header being resized change highlight color for search --- apps/webapp/app/components/logs/LogsTable.tsx | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index 9a758c7d5c..0f7f221184 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -1,7 +1,7 @@ import { ArrowPathIcon } from "@heroicons/react/20/solid"; import { Link } from "@remix-run/react"; import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; -import { type ReactNode, useEffect, useRef } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { cn } from "~/utils/cn"; import { Button } from "~/components/primitives/Buttons"; import { useEnvironment } from "~/hooks/useEnvironment"; @@ -107,7 +107,7 @@ function highlightText(text: string, searchTerm: string | undefined): ReactNode return ( <> {text.slice(0, index)} - + {text.slice(index, index + searchTerm.length)} {text.slice(index + searchTerm.length)} @@ -132,6 +132,21 @@ export function LogsTable({ const project = useProject(); const environment = useEnvironment(); const loadMoreRef = useRef(null); + const [showLoadMoreSpinner, setShowLoadMoreSpinner] = useState(false); + + // Show load more spinner only after 0.5 seconds of loading time + useEffect(() => { + if (!isLoadingMore) { + setShowLoadMoreSpinner(false); + return; + } + + const timer = setTimeout(() => { + setShowLoadMoreSpinner(true); + }, 500); + + return () => clearTimeout(timer); + }, [isLoadingMore]); // Intersection observer for infinite scroll useEffect(() => { @@ -159,17 +174,17 @@ export function LogsTable({ }, [hasMore, isLoadingMore, onLoadMore]); return ( -
- - +
+
+ - Time - Run - Task - Level - Status - Duration - Message + Time + Run + Task + Level + Status + Duration + Message @@ -258,18 +273,13 @@ export function LogsTable({ {/* Infinite scroll trigger */} {hasMore && logs.length > 0 && (
- {isLoadingMore && ( + {showLoadMoreSpinner && (
Loading more…
)}
)} - {isLoading && ( -
- Loading… -
- )} ); } From 53274b7028fd48b284c39daa9be32db1a024c628 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Thu, 8 Jan 2026 23:48:57 +0200 Subject: [PATCH 15/28] merge kind ans status from clickhouse to log level --- .../app/components/logs/LogDetailView.tsx | 20 +---- .../app/components/logs/LogsLevelFilter.tsx | 5 +- apps/webapp/app/components/logs/LogsTable.tsx | 31 +------ .../v3/LogDetailPresenter.server.ts | 22 +++-- .../presenters/v3/LogsListPresenter.server.ts | 83 ++++++++++++++----- .../route.tsx | 2 +- ...ojects.$projectParam.env.$envParam.logs.ts | 11 ++- references/seed/.gitignore | 1 + references/seed/package.json | 23 +++++ 9 files changed, 120 insertions(+), 78 deletions(-) create mode 100644 references/seed/.gitignore create mode 100644 references/seed/package.json diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index 10698dc556..9cfe6a641a 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -68,7 +68,8 @@ function getLevelColor(level: string): string { return "text-blue-400 bg-blue-500/10 border-blue-500/20"; case "TRACE": return "text-charcoal-500 bg-charcoal-800 border-charcoal-700"; - case "LOG": + case "CANCELLED": + return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; default: return "text-text-dimmed bg-charcoal-750 border-charcoal-700"; } @@ -114,22 +115,6 @@ function getKindLabel(kind: string): string { } } -// Status badge color styles -function getStatusColor(status: string): string { - switch (status) { - case "OK": - return "text-success bg-success/10 border-success/20"; - case "ERROR": - return "text-error bg-error/10 border-error/20"; - case "CANCELLED": - return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; - case "PARTIAL": - return "text-pending bg-pending/10 border-pending/20"; - default: - return "text-text-dimmed bg-charcoal-750 border-charcoal-700"; - } -} - export function LogDetailView({ logId, initialLog, onClose }: LogDetailViewProps) { const organization = useOrganization(); const project = useProject(); @@ -359,7 +344,6 @@ function DetailsTab({ log, runPath }: { log: LogEntry; runPath: string }) {
- Run Task Level - Status Duration Message {logs.length === 0 && !hasFilters ? ( - + {!isLoading && } ) : logs.length === 0 ? ( @@ -240,16 +225,6 @@ export function LogsTable({ {log.level} - - - {log.status} - - >; // Convert ClickHouse kind to display level -function kindToLevel( - kind: string -): "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "LOG" { +type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "CANCELLED"; + +function kindToLevel(kind: string, status: string): LogLevel { + // CANCELLED status takes precedence + if (status === "CANCELLED") { + return "CANCELLED"; + } + + // ERROR can come from either kind or status + if (kind === "LOG_ERROR" || status === "ERROR") { + return "ERROR"; + } + switch (kind) { case "DEBUG_EVENT": case "LOG_DEBUG": @@ -26,10 +36,8 @@ function kindToLevel( return "INFO"; case "LOG_WARN": return "WARN"; - case "LOG_ERROR": - return "ERROR"; case "LOG_LOG": - return "LOG"; + return "INFO"; // Changed from "LOG" case "SPAN": case "ANCESTOR_OVERRIDE": case "SPAN_EVENT": @@ -111,7 +119,7 @@ export class LogDetailPresenter { kind: log.kind, status: log.status, duration: typeof log.duration === "number" ? log.duration : Number(log.duration), - level: kindToLevel(log.kind), + level: kindToLevel(log.kind, log.status), metadata: parsedMetadata, attributes: parsedAttributes, // Raw strings for display diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index 44b5035a5d..a98dcc4e57 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -18,7 +18,7 @@ import { convertClickhouseDateTime64ToJsDate, } from "~/v3/eventRepository/clickhouseEventRepository.server"; -export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "LOG"; +export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "CANCELLED"; export type LogsListOptions = { userId?: string; @@ -78,9 +78,17 @@ function decodeCursor(cursor: string): LogCursor | null { } // Convert ClickHouse kind to display level -function kindToLevel( - kind: string -): "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "LOG" { +function kindToLevel(kind: string, status: string): LogLevel { + // CANCELLED status takes precedence + if (status === "CANCELLED") { + return "CANCELLED"; + } + + // ERROR can come from either kind or status + if (kind === "LOG_ERROR" || status === "ERROR") { + return "ERROR"; + } + switch (kind) { case "DEBUG_EVENT": case "LOG_DEBUG": @@ -89,10 +97,8 @@ function kindToLevel( return "INFO"; case "LOG_WARN": return "WARN"; - case "LOG_ERROR": - return "ERROR"; case "LOG_LOG": - return "LOG"; + return "INFO"; // Changed from "LOG" case "SPAN": case "ANCESTOR_OVERRIDE": case "SPAN_EVENT": @@ -101,21 +107,23 @@ function kindToLevel( } } -// Convert display level to ClickHouse kinds -function levelToKinds(level: LogLevel): string[] { +// Convert display level to ClickHouse kinds and statuses +function levelToKindsAndStatuses( + level: LogLevel +): { kinds?: string[]; statuses?: string[] } { switch (level) { case "DEBUG": - return ["DEBUG_EVENT", "LOG_DEBUG"]; + return { kinds: ["DEBUG_EVENT", "LOG_DEBUG"] }; case "INFO": - return ["LOG_INFO"]; + return { kinds: ["LOG_INFO", "LOG_LOG"] }; case "WARN": - return ["LOG_WARN"]; + return { kinds: ["LOG_WARN"] }; case "ERROR": - return ["LOG_ERROR"]; - case "LOG": - return ["LOG_LOG"]; + return { kinds: ["LOG_ERROR"], statuses: ["ERROR"] }; + case "CANCELLED": + return { statuses: ["CANCELLED"] }; case "TRACE": - return ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"]; + return { kinds: ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"] }; } } @@ -395,10 +403,45 @@ export class LogsListPresenter { ); } - // Level filter (map display levels to ClickHouse kinds) + // Level filter (map display levels to ClickHouse kinds/statuses) if (levels && levels.length > 0) { - const kinds = levels.flatMap(levelToKinds); - queryBuilder.where("kind IN {kinds: Array(String)}", { kinds }); + const conditions: string[] = []; + const params: Record = {}; + const hasTraceLevel = levels.includes("TRACE"); + const hasErrorOrCancelledLevel = levels.includes("ERROR") || levels.includes("CANCELLED"); + + for (const level of levels) { + const filter = levelToKindsAndStatuses(level); + const levelConditions: string[] = []; + + if (filter.kinds && filter.kinds.length > 0) { + const kindsKey = `kinds_${level}`; + let kindCondition = `kind IN {${kindsKey}: Array(String)}`; + + // For TRACE: exclude error/cancelled traces if ERROR/CANCELLED not explicitly selected + if (level === "TRACE" && !hasErrorOrCancelledLevel) { + kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`; + params["excluded_statuses"] = ["ERROR", "CANCELLED"]; + } + + levelConditions.push(kindCondition); + params[kindsKey] = filter.kinds; + } + + if (filter.statuses && filter.statuses.length > 0) { + const statusesKey = `statuses_${level}`; + levelConditions.push(`status IN {${statusesKey}: Array(String)}`); + params[statusesKey] = filter.statuses; + } + + if (levelConditions.length > 0) { + conditions.push(`(${levelConditions.join(" OR ")})`); + } + } + + if (conditions.length > 0) { + queryBuilder.where(`(${conditions.join(" OR ")})`); + } } // Exclude DEBUG logs if not included @@ -474,7 +517,7 @@ export class LogsListPresenter { kind: log.kind, status: log.status, duration: typeof log.duration === "number" ? log.duration : Number(log.duration), - level: kindToLevel(log.kind), + level: kindToLevel(log.kind, log.status), })); return { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index e97a80d36f..819910da78 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -43,7 +43,7 @@ import { Switch } from "~/components/primitives/Switch"; import { getUserById } from "~/models/user.server"; // Valid log levels for filtering -const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "LOG"]; +const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"]; function parseLevelsFromUrl(url: URL): LogLevel[] | undefined { const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts index d38b8341a5..897247f435 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts @@ -8,9 +8,10 @@ import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; import { LogsListPresenter, type LogLevel } from "~/presenters/v3/LogsListPresenter.server"; import { $replica } from "~/db.server"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; +import { getUserById } from "~/models/user.server"; // Valid log levels for filtering -const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "LOG"]; +const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"]; function parseLevelsFromUrl(url: URL): LogLevel[] | undefined { const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0); @@ -32,13 +33,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw new Response("Environment not found", { status: 404 }); } + const user = await requireUserId(request); + const userRecord = await getUserById(user); + const isAdmin = userRecord?.admin || userRecord?.isImpersonating; + const filters = await getRunFiltersFromRequest(request); - // Get search term, cursor, and levels from query params + // Get search term, cursor, levels, and showDebug from query params const url = new URL(request.url); const search = url.searchParams.get("search") ?? undefined; const cursor = url.searchParams.get("cursor") ?? undefined; const levels = parseLevelsFromUrl(url); + const showDebug = url.searchParams.get("showDebug") === "true"; const presenter = new LogsListPresenter($replica, clickhouseClient); const result = await presenter.call(project.organizationId, environment.id, { @@ -48,6 +54,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { search, cursor, levels, + includeDebugLogs: isAdmin && showDebug, }); return json({ diff --git a/references/seed/.gitignore b/references/seed/.gitignore new file mode 100644 index 0000000000..6524f048dc --- /dev/null +++ b/references/seed/.gitignore @@ -0,0 +1 @@ +.trigger \ No newline at end of file diff --git a/references/seed/package.json b/references/seed/package.json new file mode 100644 index 0000000000..aa788c467a --- /dev/null +++ b/references/seed/package.json @@ -0,0 +1,23 @@ +{ + "name": "references-seed", + "private": true, + "type": "module", + "devDependencies": { + "trigger.dev": "workspace:*" + }, + "dependencies": { + "@trigger.dev/build": "workspace:*", + "@trigger.dev/sdk": "workspace:*", + "arktype": "^2.0.0", + "openai": "^4.97.0", + "puppeteer-core": "^24.15.0", + "replicate": "^1.0.1", + "yup": "^1.6.1", + "zod": "3.25.76", + "@sinclair/typebox": "^0.34.3" + }, + "scripts": { + "dev": "trigger dev", + "deploy": "trigger deploy" + } +} From 941dbaa6e85500f53fca2c69b31a87c737a35aec Mon Sep 17 00:00:00 2001 From: mpcgird Date: Fri, 9 Jan 2026 01:26:36 +0200 Subject: [PATCH 16/28] show info from attributes as message for error spans --- .../presenters/v3/LogsListPresenter.server.ts | 82 +++++++++---------- .../clickhouse/src/taskEvents.ts | 8 +- 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index a98dcc4e57..04d7bfdee5 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -79,7 +79,6 @@ function decodeCursor(cursor: string): LogCursor | null { // Convert ClickHouse kind to display level function kindToLevel(kind: string, status: string): LogLevel { - // CANCELLED status takes precedence if (status === "CANCELLED") { return "CANCELLED"; } @@ -127,7 +126,7 @@ function levelToKindsAndStatuses( } } -// Convert nanoseconds to milliseconds + function convertDateToNanoseconds(date: Date): bigint { return BigInt(date.getTime()) * 1_000_000n; } @@ -161,21 +160,17 @@ export class LogsListPresenter { search, from, to, - direction = "forward", cursor, pageSize = DEFAULT_PAGE_SIZE, includeDebugLogs = true, }: LogsListOptions ) { - // Get time values from raw values (including default period) const time = timeFilters({ period, from, to, }); - // If period is provided but from/to are not calculated, convert period to date range - // This is needed because timeFilters doesn't convert period to actual dates let effectiveFrom = time.from; let effectiveTo = time.to; @@ -208,13 +203,11 @@ export class LogsListPresenter { (search !== undefined && search !== "") || !time.isDefault; - // Get all possible tasks const possibleTasksAsync = getAllTaskIdentifiers( this.replica, environmentId ); - // Get possible bulk actions const bulkActionsAsync = this.replica.bulkActionGroup.findMany({ select: { friendlyId: true, @@ -239,7 +232,6 @@ export class LogsListPresenter { findDisplayableEnvironment(environmentId, userId), ]); - // If the bulk action isn't in the most recent ones, add it separately if ( bulkId && !bulkActions.some((bulkAction) => bulkAction.friendlyId === bulkId) @@ -306,7 +298,6 @@ export class LogsListPresenter { }, }); - // If no matching runs, return empty result if (runIds.length === 0) { return { logs: [], @@ -341,15 +332,12 @@ export class LogsListPresenter { } } - // Build ClickHouse query const queryBuilder = this.clickhouse.taskEventsV2.logsListQueryBuilder(); - // PREWHERE for primary key columns (first in ORDER BY, uses index efficiently) queryBuilder.prewhere("environment_id = {environmentId: String}", { environmentId, }); - // WHERE for non-indexed columns queryBuilder.where("organization_id = {organizationId: String}", { organizationId, }); @@ -358,7 +346,6 @@ export class LogsListPresenter { // Time filters - inserted_at in PREWHERE for partition pruning, start_time in WHERE if (effectiveFrom) { const fromNs = convertDateToNanoseconds(effectiveFrom).toString(); - // PREWHERE for partition key (inserted_at) - dramatically reduces data scanned queryBuilder.prewhere("inserted_at >= {insertedAtStart: DateTime64(3)}", { insertedAtStart: convertDateToClickhouseDateTime(effectiveFrom), }); @@ -370,7 +357,6 @@ export class LogsListPresenter { if (effectiveTo) { const clampedTo = effectiveTo > new Date() ? new Date() : effectiveTo; const toNs = convertDateToNanoseconds(clampedTo).toString(); - // PREWHERE for partition key (inserted_at) - dramatically reduces data scanned queryBuilder.prewhere("inserted_at <= {insertedAtEnd: DateTime64(3)}", { insertedAtEnd: convertDateToClickhouseDateTime(clampedTo), }); @@ -391,11 +377,11 @@ export class LogsListPresenter { queryBuilder.where("run_id IN {runIds: Array(String)}", { runIds }); } - // Case-insensitive search in message and status fields + // Case-insensitive search in message, attributes, and status fields if (search && search.trim() !== "") { const searchTerm = search.trim(); queryBuilder.where( - "(message ilike {searchPattern: String} OR status = {statusTerm: String})", + "(message ilike {searchPattern: String} OR attributes_text ilike {searchPattern: String} OR status = {statusTerm: String})", { searchPattern: `%${searchTerm}%`, statusTerm: searchTerm.toUpperCase(), @@ -403,11 +389,10 @@ export class LogsListPresenter { ); } - // Level filter (map display levels to ClickHouse kinds/statuses) + if (levels && levels.length > 0) { const conditions: string[] = []; - const params: Record = {}; - const hasTraceLevel = levels.includes("TRACE"); + const params: Record = {}; const hasErrorOrCancelledLevel = levels.includes("ERROR") || levels.includes("CANCELLED"); for (const level of levels) { @@ -440,23 +425,17 @@ export class LogsListPresenter { } if (conditions.length > 0) { - queryBuilder.where(`(${conditions.join(" OR ")})`); + queryBuilder.where(`(${conditions.join(" OR ")})`, params as any); } } - // Exclude DEBUG logs if not included + // Debug logs are available only to admins if (includeDebugLogs === false) { queryBuilder.where("kind NOT IN {debugKinds: Array(String)}", { debugKinds: ["DEBUG_EVENT", "LOG_DEBUG"], }); } - // Exclude TRACE logs with PARTIAL status - // const traceKinds = ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"]; - // queryBuilder.where( - // "NOT (kind IN {traceKinds: Array(String)} AND status = 'PARTIAL')", - // { traceKinds } - // ); queryBuilder.where("NOT (kind = 'SPAN' AND status = 'PARTIAL')"); @@ -474,13 +453,11 @@ export class LogsListPresenter { ); } - // Order by newest first queryBuilder.orderBy("start_time DESC, trace_id DESC, span_id DESC, run_id DESC"); // Limit + 1 to check if there are more results queryBuilder.limit(pageSize + 1); - // Execute query const [queryError, records] = await queryBuilder.execute(); if (queryError) { @@ -505,20 +482,37 @@ export class LogsListPresenter { // Transform results // Use :: as separator since dash conflicts with date format in start_time - const transformedLogs = logs.map((log) => ({ - 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(), - traceId: log.trace_id, - spanId: log.span_id, - parentSpanId: log.parent_span_id || null, - message: log.message, - kind: log.kind, - status: log.status, - duration: typeof log.duration === "number" ? log.duration : Number(log.duration), - level: kindToLevel(log.kind, log.status), - })); + 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) { + try { + let attributes = log.attributes as Record; + + 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(), + 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, diff --git a/internal-packages/clickhouse/src/taskEvents.ts b/internal-packages/clickhouse/src/taskEvents.ts index cc450bd064..e8a4d5fde0 100644 --- a/internal-packages/clickhouse/src/taskEvents.ts +++ b/internal-packages/clickhouse/src/taskEvents.ts @@ -249,6 +249,8 @@ export const LogsListResult = z.object({ kind: z.string(), status: z.string(), duration: z.number().or(z.string()), + metadata: z.string(), + attributes: z.any(), }); export type LogsListResult = z.output; @@ -271,6 +273,8 @@ export function getLogsListQueryBuilder(ch: ClickhouseReader, settings?: ClickHo "kind", "status", "duration", + "metadata", + "attributes" ], settings: { max_memory_usage: "2000000000", // 2GB per query limit @@ -297,7 +301,7 @@ export const LogDetailV2Result = z.object({ status: z.string(), duration: z.number().or(z.string()), metadata: z.string(), - attributes_text: z.string(), + attributes: z.any() }); export type LogDetailV2Result = z.output; @@ -321,7 +325,7 @@ export function getLogDetailQueryBuilder(ch: ClickhouseReader, settings?: ClickH "status", "duration", "metadata", - "attributes_text", + "attributes", ], settings, }); From 255bec59f2f01a8c62b399b0b5b9b52129fd148e Mon Sep 17 00:00:00 2001 From: mpcgird Date: Fri, 9 Jan 2026 03:27:35 +0200 Subject: [PATCH 17/28] Changed component for attributes and metadata --- .../app/components/logs/LogDetailView.tsx | 525 ++++++++++++------ .../v3/LogDetailPresenter.server.ts | 14 +- 2 files changed, 369 insertions(+), 170 deletions(-) diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index 9cfe6a641a..707c168123 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -1,6 +1,10 @@ -import { XMarkIcon, ArrowTopRightOnSquareIcon, ClockIcon } from "@heroicons/react/20/solid"; +import { XMarkIcon, ArrowTopRightOnSquareIcon, CheckIcon, ClockIcon } from "@heroicons/react/20/solid"; import { Link } from "@remix-run/react"; -import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; +import { + formatDurationNanoseconds, + type MachinePresetName, + formatDurationMilliseconds, +} from "@trigger.dev/core/v3"; import { useEffect, useState } from "react"; import { useTypedFetcher } from "remix-typedjson"; import { cn } from "~/utils/cn"; @@ -10,14 +14,23 @@ import { Header2, Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Spinner } from "~/components/primitives/Spinner"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; +import * as Property from "~/components/primitives/PropertyTable"; +import { TextLink } from "~/components/primitives/TextLink"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { SimpleTooltip, InfoIconTooltip } from "~/components/primitives/Tooltip"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; -import { v3RunSpanPath } from "~/utils/pathBuilder"; +import { v3RunSpanPath, v3RunsPath, v3BatchPath, v3RunPath, v3DeploymentVersionPath } from "~/utils/pathBuilder"; import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId"; -import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus"; +import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus"; +import { MachineLabelCombo } from "~/components/MachineLabelCombo"; +import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; +import { RunTag } from "~/components/runs/v3/RunTag"; +import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import type { TaskRunStatus } from "@trigger.dev/database"; +import { PacketDisplay } from "~/components/runs/v3/PacketDisplay"; // Types for the run context endpoint response type RunContextData = { @@ -115,6 +128,24 @@ function getKindLabel(kind: string): string { } } +// Helper to unescape newlines in JSON strings for better readability +function unescapeNewlines(obj: unknown): unknown { + if (typeof obj === "string") { + return obj.replace(/\\n/g, "\n"); + } + if (Array.isArray(obj)) { + return obj.map(unescapeNewlines); + } + if (obj !== null && typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = unescapeNewlines(value); + } + return result; + } + return obj; +} + export function LogDetailView({ logId, initialLog, onClose }: LogDetailViewProps) { const organization = useOrganization(); const project = useProject(); @@ -250,35 +281,41 @@ function DetailsTab({ log, runPath }: { log: LogEntry; runPath: string }) { rawAttributes?: string; }; - // Get raw strings for display const rawMetadata = logWithExtras.rawMetadata; const rawAttributes = logWithExtras.rawAttributes; - // Parse metadata let metadata: Record | null = null; + let beautifiedMetadata: string | null = null; if (logWithExtras.metadata) { metadata = logWithExtras.metadata; + const unescaped = unescapeNewlines(metadata); + beautifiedMetadata = JSON.stringify(unescaped, null, 2); } else if (rawMetadata) { try { metadata = JSON.parse(rawMetadata) as Record; + const unescaped = unescapeNewlines(metadata); + beautifiedMetadata = JSON.stringify(unescaped, null, 2); } catch { // Ignore parse errors } } - // Parse attributes let attributes: Record | null = null; + let beautifiedAttributes: string | null = null; if (logWithExtras.attributes) { attributes = logWithExtras.attributes; + const unescaped = unescapeNewlines(attributes); + beautifiedAttributes = JSON.stringify(unescaped, null, 2); } else if (rawAttributes) { try { attributes = JSON.parse(rawAttributes) as Record; + const unescaped = unescapeNewlines(attributes); + beautifiedAttributes = JSON.stringify(unescaped, null, 2); } catch { // Ignore parse errors } } - // Extract error info from metadata const errorInfo = metadata?.error as { message?: string; attributes?: Record } | undefined; // Check if we should show metadata/attributes sections @@ -338,50 +375,70 @@ function DetailsTab({ log, runPath }: { log: LogEntry; runPath: string }) {
- {/* Details Grid */} + {/* Details */}
Details -
- - - 0 - ? formatDurationNanoseconds(log.duration, { style: "short" }) - : "–" - } - icon={} - /> - - + + + Task + + {log.taskIdentifier} + + + + + Kind + {log.kind} + + + + Duration + + + + {log.duration > 0 + ? formatDurationNanoseconds(log.duration, { style: "short" }) + : "–"} + + + + + + Trace ID + + {log.traceId} + + + + + Span ID + + {log.spanId} + + + {log.parentSpanId && ( - + + Parent Span ID + + {log.parentSpanId} + + )} -
+
{/* Metadata - only available in full log detail */} - {showMetadata && metadata && ( + {showMetadata && beautifiedMetadata && (
- Metadata -
-
-              {JSON.stringify(metadata, null, 2)}
-            
-
+
)} {/* Attributes - only available in full log detail */} - {showAttributes && attributes && ( + {showAttributes && beautifiedAttributes && (
- Attributes -
-
-              {JSON.stringify(attributes, null, 2)}
-            
-
+
)} @@ -416,148 +473,282 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { if (!runData) { return ( - <> -
- Run Information -
- Run not found in database. -
- - - -
-
+
+ Run not found in database. +
+ + +
- +
); } return ( - <> -
- Run Information -
- {/* Status and Task */} -
- - {runData.taskIdentifier} -
+
+ + + Status + + } + content={descriptionForTaskRunStatus(runData.status as TaskRunStatus)} + disableHoverableContent + /> + + + + + Task + + + + + } + content={`View runs filtered by ${runData.taskIdentifier}`} + disableHoverableContent + /> + + + + {runData.rootRun && ( + + Root run + + + + + + + + } + content={`Jump to root run`} + disableHoverableContent + /> + + + )} - {/* Details Grid */} -
- - - - {runData.startedAt && ( - - )} - {runData.completedAt && ( - - )} - - {runData.machinePreset && ( - - )} - {runData.isTest && ( - + {runData.parentRun && ( + + Parent run + + + + + + + + } + content={`Jump to parent run`} + disableHoverableContent + /> + + + )} + + {runData.batch && ( + + Batch + + + + + } + content={`View batch ${runData.batch.friendlyId}`} + disableHoverableContent + /> + + + )} + + + Version + + {runData.version ? ( + environment.type === "DEVELOPMENT" ? ( + + ) : ( + + + + } + content={"Jump to deployment"} + /> + ) + ) : ( + + Never started + + )} -
+ + + + + Test run + + {runData.isTest ? : "–"} + + + + {environment && ( + + Environment + + + + + )} - {/* Tags */} - {runData.tags && runData.tags.length > 0 && ( -
- - Tags - -
+ + Queue + +
Name: {runData.queue}
+
Concurrency key: {runData.concurrencyKey ? runData.concurrencyKey : "–"}
+
+
+ + {runData.tags && runData.tags.length > 0 && ( + + Tags + +
{runData.tags.map((tag: string) => ( - - {tag} - - ))} -
-
- )} - - {/* Relationships */} - {(runData.parentRun || runData.rootRun || runData.batch || runData.schedule) && ( -
- - Relationships - -
- {runData.parentRun && ( - - )} - {runData.rootRun && ( - - )} - {runData.batch && ( - - )} - {runData.schedule && ( - - )} + ))}
-
- )} - -
- - - -
-
-
- - ); -} + + + )} -function DetailItem({ - label, - value, - mono = false, - small = false, - icon, -}: { - label: string; - value: string; - mono?: boolean; - small?: boolean; - icon?: React.ReactNode; -}) { - return ( -
- - {label} - -
- {icon} - - {value} - + + Machine + + + + + + + Run invocation cost + + {runData.baseCostInCents > 0 + ? formatCurrencyAccurate(runData.baseCostInCents / 100) + : "–"} + + + + + Compute cost + + {runData.costInCents > 0 ? formatCurrencyAccurate(runData.costInCents / 100) : "–"} + + + + + Total cost + + {runData.costInCents > 0 || runData.baseCostInCents > 0 + ? formatCurrencyAccurate((runData.baseCostInCents + runData.costInCents) / 100) + : "–"} + + + + + Usage duration + + {runData.usageDurationMs > 0 + ? formatDurationMilliseconds(runData.usageDurationMs, { style: "short" }) + : "–"} + + + + + Run ID + + + + + + +
+ + +
); } + diff --git a/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts index 973bcb6e41..78d9bef75c 100644 --- a/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts @@ -89,6 +89,7 @@ export class LogDetailPresenter { // Parse metadata and attributes let parsedMetadata: Record = {}; let parsedAttributes: Record = {}; + let rawAttributesString = ""; try { if (log.metadata) { @@ -99,8 +100,15 @@ export class LogDetailPresenter { } try { - if (log.attributes_text) { - parsedAttributes = JSON.parse(log.attributes_text) as Record; + // Handle attributes which could be a JSON object or string + if (log.attributes) { + if (typeof log.attributes === "string") { + parsedAttributes = JSON.parse(log.attributes) as Record; + rawAttributesString = log.attributes; + } else if (typeof log.attributes === "object") { + parsedAttributes = log.attributes as Record; + rawAttributesString = JSON.stringify(log.attributes); + } } } catch { // Ignore parse errors @@ -124,7 +132,7 @@ export class LogDetailPresenter { attributes: parsedAttributes, // Raw strings for display rawMetadata: log.metadata, - rawAttributes: log.attributes_text, + rawAttributes: rawAttributesString, }; } } From 1508eb6ef86423aa20aaaa7126c9a28209d32126 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Fri, 9 Jan 2026 13:39:11 +0200 Subject: [PATCH 18/28] Use packet display in logs details Update packet display to highlight text --- apps/webapp/app/components/code/CodeBlock.tsx | 52 +++++- .../app/components/logs/LogDetailView.tsx | 162 +++++++++--------- .../app/components/runs/v3/PacketDisplay.tsx | 4 + .../route.tsx | 11 +- 4 files changed, 137 insertions(+), 92 deletions(-) diff --git a/apps/webapp/app/components/code/CodeBlock.tsx b/apps/webapp/app/components/code/CodeBlock.tsx index ea1d55e5d7..924a152721 100644 --- a/apps/webapp/app/components/code/CodeBlock.tsx +++ b/apps/webapp/app/components/code/CodeBlock.tsx @@ -62,6 +62,9 @@ type CodeBlockProps = { /** Whether to show the open in modal button */ showOpenInModal?: boolean; + + /** Search term to highlight in the code */ + searchTerm?: string; }; const dimAmount = 0.5; @@ -200,6 +203,7 @@ export const CodeBlock = forwardRef( showChrome = false, fileName, rowTitle, + searchTerm, ...props }: CodeBlockProps, ref @@ -338,6 +342,7 @@ export const CodeBlock = forwardRef( className="px-2 py-3" preClassName="text-xs" isWrapped={isWrapped} + searchTerm={searchTerm} /> ) : (
{line.map((token, key) => { const tokenProps = getTokenProps({ token, key }); + + // Highlight search term matches in token + let content: React.ReactNode = token.content; + if (searchTerm && searchTerm.trim() !== "" && token.content) { + const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedSearch, "gi"); + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match; + let matchCount = 0; + + while ((match = regex.exec(token.content)) !== null) { + if (match.index > lastIndex) { + parts.push(token.content.substring(lastIndex, match.index)); + } + parts.push( + + {match[0]} + + ); + lastIndex = regex.lastIndex; + matchCount++; + } + + if (lastIndex < token.content.length) { + parts.push(token.content.substring(lastIndex)); + } + + if (parts.length > 0) { + content = parts; + } + } + return ( + > + {content} + ); })}
diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index 707c168123..a1438fb4f4 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -31,6 +31,7 @@ import { RunTag } from "~/components/runs/v3/RunTag"; import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import type { TaskRunStatus } from "@trigger.dev/database"; import { PacketDisplay } from "~/components/runs/v3/PacketDisplay"; +import type { ReactNode } from "react"; // Types for the run context endpoint response type RunContextData = { @@ -64,6 +65,7 @@ type LogDetailViewProps = { // If we have the log entry from the list, we can display it immediately initialLog?: LogEntry; onClose: () => void; + searchTerm?: string; }; type TabType = "details" | "run"; @@ -128,25 +130,61 @@ function getKindLabel(kind: string): string { } } -// Helper to unescape newlines in JSON strings for better readability -function unescapeNewlines(obj: unknown): unknown { - if (typeof obj === "string") { - return obj.replace(/\\n/g, "\n"); - } - if (Array.isArray(obj)) { - return obj.map(unescapeNewlines); +function formatStringJSON(str: string): string { + return str + .replace(/\\n/g, "\n") // Converts literal "\n" to newline + .replace(/\\t/g, "\t"); // Converts literal "\t" to tab +} + +// Highlight search term in JSON string - returns React nodes with highlights +function highlightJsonWithSearch(json: string, searchTerm: string | undefined): ReactNode { + if (!searchTerm || searchTerm.trim() === "") { + return json; } - if (obj !== null && typeof obj === "object") { - const result: Record = {}; - for (const [key, value] of Object.entries(obj)) { - result[key] = unescapeNewlines(value); + + // Escape special regex characters in the search term + const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedSearch, "gi"); + + const parts: ReactNode[] = []; + let lastIndex = 0; + let match; + let matchCount = 0; + + while ((match = regex.exec(json)) !== null) { + // Add text before match + if (match.index > lastIndex) { + parts.push(json.substring(lastIndex, match.index)); } - return result; + // Add highlighted match with inline styles + parts.push( + + {match[0]} + + ); + lastIndex = regex.lastIndex; + matchCount++; } - return obj; + + // Add remaining text + if (lastIndex < json.length) { + parts.push(json.substring(lastIndex)); + } + + return parts.length > 0 ? parts : json; } -export function LogDetailView({ logId, initialLog, onClose }: LogDetailViewProps) { + +export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDetailViewProps) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -262,7 +300,7 @@ export function LogDetailView({ logId, initialLog, onClose }: LogDetailViewProps {/* Content */}
{activeTab === "details" && ( - + )} {activeTab === "run" && ( @@ -272,89 +310,39 @@ export function LogDetailView({ logId, initialLog, onClose }: LogDetailViewProps ); } -function DetailsTab({ log, runPath }: { log: LogEntry; runPath: string }) { - // Extract metadata and attributes - handle both parsed and raw string forms +function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: string; searchTerm?: string }) { const logWithExtras = log as LogEntry & { metadata?: Record; - rawMetadata?: string; attributes?: Record; - rawAttributes?: string; }; - const rawMetadata = logWithExtras.rawMetadata; - const rawAttributes = logWithExtras.rawAttributes; let metadata: Record | null = null; let beautifiedMetadata: string | null = null; + let beautifiedAttributes: string | null = null; + if (logWithExtras.metadata) { - metadata = logWithExtras.metadata; - const unescaped = unescapeNewlines(metadata); - beautifiedMetadata = JSON.stringify(unescaped, null, 2); - } else if (rawMetadata) { - try { - metadata = JSON.parse(rawMetadata) as Record; - const unescaped = unescapeNewlines(metadata); - beautifiedMetadata = JSON.stringify(unescaped, null, 2); - } catch { - // Ignore parse errors - } + beautifiedMetadata = JSON.stringify(logWithExtras.metadata, null, 2); + beautifiedMetadata = formatStringJSON(beautifiedMetadata); } - let attributes: Record | null = null; - let beautifiedAttributes: string | null = null; if (logWithExtras.attributes) { - attributes = logWithExtras.attributes; - const unescaped = unescapeNewlines(attributes); - beautifiedAttributes = JSON.stringify(unescaped, null, 2); - } else if (rawAttributes) { - try { - attributes = JSON.parse(rawAttributes) as Record; - const unescaped = unescapeNewlines(attributes); - beautifiedAttributes = JSON.stringify(unescaped, null, 2); - } catch { - // Ignore parse errors - } + beautifiedAttributes = JSON.stringify(logWithExtras.attributes, null, 2); + beautifiedAttributes = formatStringJSON(beautifiedAttributes); } - const errorInfo = metadata?.error as { message?: string; attributes?: Record } | undefined; - - // Check if we should show metadata/attributes sections - const showMetadata = rawMetadata && rawMetadata !== "{}"; - const showAttributes = rawAttributes && rawAttributes !== "{}"; + const showMetadata = beautifiedMetadata && beautifiedMetadata !== "{}"; + const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}"; return ( <> - {/* Error Details - show prominently for error status */} - {errorInfo && ( -
- Error Details -
- {errorInfo.message && ( -
-                {errorInfo.message}
-              
- )} - {errorInfo.attributes && Object.keys(errorInfo.attributes).length > 0 && ( -
- - Error Attributes - -
-                  {JSON.stringify(errorInfo.attributes, null, 2)}
-                
-
- )} -
-
- )} - {/* Message */}
Message
-
-            {log.message}
-          
+
+ {highlightJsonWithSearch(log.message, searchTerm)} +
@@ -363,11 +351,7 @@ function DetailsTab({ log, runPath }: { log: LogEntry; runPath: string }) { Run
{log.runId} - + @@ -431,14 +415,24 @@ function DetailsTab({ log, runPath }: { log: LogEntry; runPath: string }) { {/* Metadata - only available in full log detail */} {showMetadata && beautifiedMetadata && (
- +
)} {/* Attributes - only available in full log detail */} {showAttributes && beautifiedAttributes && (
- +
)} diff --git a/apps/webapp/app/components/runs/v3/PacketDisplay.tsx b/apps/webapp/app/components/runs/v3/PacketDisplay.tsx index 24a9b66b67..2d6b6e65be 100644 --- a/apps/webapp/app/components/runs/v3/PacketDisplay.tsx +++ b/apps/webapp/app/components/runs/v3/PacketDisplay.tsx @@ -11,10 +11,12 @@ export function PacketDisplay({ data, dataType, title, + searchTerm, }: { data: string; dataType: string; title: string; + searchTerm?: string; }) { switch (dataType) { case "application/store": { @@ -51,6 +53,7 @@ export function PacketDisplay({ maxLines={20} showLineNumbers={false} showTextWrapping + searchTerm={searchTerm} /> ); } @@ -63,6 +66,7 @@ export function PacketDisplay({ maxLines={20} showLineNumbers={false} showTextWrapping + searchTerm={searchTerm} /> ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index 819910da78..20ace74b1d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -8,7 +8,7 @@ import { } from "remix-typedjson"; import { requireUser } from "~/services/session.server"; -import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; @@ -19,9 +19,7 @@ import { setRootOnlyFilterPreference, uiPreferencesStorage, } from "~/services/preferences/uiPreferences.server"; -import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; -import { LinkButton } from "~/components/primitives/Buttons"; -import { BookOpenIcon } from "@heroicons/react/24/solid"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import { Spinner } from "~/components/primitives/Spinner"; @@ -226,7 +224,6 @@ function LogsList({ // Build resource URL for loading more const loadMoreUrl = useMemo(() => { if (!nextCursor) return null; - // Transform /orgs/.../logs to /resources/orgs/.../logs const resourcePath = `/resources${location.pathname}`; const params = new URLSearchParams(location.search); params.set("cursor", nextCursor); @@ -234,7 +231,6 @@ function LogsList({ return `${resourcePath}?${params.toString()}`; }, [location.pathname, location.search, nextCursor]); - // Handle loading more const handleLoadMore = useCallback(() => { if (loadMoreUrl && fetcher.state === "idle") { fetcher.load(loadMoreUrl); @@ -326,10 +322,11 @@ function LogsList({ logId={selectedLogId} initialLog={selectedLog} onClose={handleClosePanel} + searchTerm={list.searchTerm} /> )} ); -} +} \ No newline at end of file From 6100b2d1804cbf2ab80c7e6fff6f441a62858a3a Mon Sep 17 00:00:00 2001 From: mpcgird Date: Fri, 9 Jan 2026 14:55:09 +0200 Subject: [PATCH 19/28] updated clickhouse config for logs fixed admin access to logs feature added alpha badge to logs --- apps/webapp/app/components/logs/LogsTable.tsx | 3 --- .../app/components/navigation/SideMenu.tsx | 17 ++++++++------ apps/webapp/app/env.server.ts | 12 ++++++++++ .../route.tsx | 6 ++++- ...ojects.$projectParam.env.$envParam.logs.ts | 8 +++---- .../app/services/clickhouseInstance.server.ts | 23 +++++++++++++++++++ internal-packages/clickhouse/src/index.ts | 12 ++++++++-- .../clickhouse/src/taskEvents.ts | 7 +----- 8 files changed, 64 insertions(+), 24 deletions(-) diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index a8291db75c..1ff3eac07c 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -1,5 +1,4 @@ import { ArrowPathIcon } from "@heroicons/react/20/solid"; -import { Link } from "@remix-run/react"; import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { cn } from "~/utils/cn"; @@ -104,13 +103,11 @@ function highlightText(text: string, searchTerm: string | undefined): ReactNode export function LogsTable({ logs, hasFilters, - filters, searchTerm, isLoading = false, isLoadingMore = false, hasMore = false, onLoadMore, - variant = "dimmed", selectedLogId, onLogSelect, }: LogsTableProps) { diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index aa0cf7b76a..bcd6b8ca5c 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -269,13 +269,16 @@ export function SideMenu({ to={v3DeploymentsPath(organization, project, environment)} data-action="deployments" /> - + {(isAdmin || user.isImpersonating) && ( + } + /> + )} { const userId = user.id; const isAdmin = user.admin || user.isImpersonating; + if (!isAdmin) { + throw redirect("/"); + } + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts index 897247f435..07f02888ed 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts @@ -1,6 +1,6 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/node"; -import { requireUserId } from "~/services/session.server"; +import { requireUser, requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; @@ -8,7 +8,6 @@ import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; import { LogsListPresenter, type LogLevel } from "~/presenters/v3/LogsListPresenter.server"; import { $replica } from "~/db.server"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; -import { getUserById } from "~/models/user.server"; // Valid log levels for filtering const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"]; @@ -33,9 +32,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw new Response("Environment not found", { status: 404 }); } - const user = await requireUserId(request); - const userRecord = await getUserById(user); - const isAdmin = userRecord?.admin || userRecord?.isImpersonating; + const user = await requireUser(request); + const isAdmin = user?.admin || user?.isImpersonating; const filters = await getRunFiltersFromRequest(request); diff --git a/apps/webapp/app/services/clickhouseInstance.server.ts b/apps/webapp/app/services/clickhouseInstance.server.ts index 32fc9bc0d4..156448c005 100644 --- a/apps/webapp/app/services/clickhouseInstance.server.ts +++ b/apps/webapp/app/services/clickhouseInstance.server.ts @@ -12,6 +12,28 @@ function initializeClickhouseClient() { console.log(`🗃️ Clickhouse service enabled to host ${url.host}`); + // Build logs query settings from environment variables + const logsQuerySettings = { + list: { + max_memory_usage: env.CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE.toString(), + max_bytes_before_external_sort: env.CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT.toString(), + max_threads: env.CLICKHOUSE_LOGS_LIST_MAX_THREADS, + ...(env.CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ && { + max_rows_to_read: env.CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ.toString(), + }), + ...(env.CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME && { + max_execution_time: env.CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME, + }), + }, + detail: { + max_memory_usage: env.CLICKHOUSE_LOGS_DETAIL_MAX_MEMORY_USAGE.toString(), + max_threads: env.CLICKHOUSE_LOGS_DETAIL_MAX_THREADS, + ...(env.CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME && { + max_execution_time: env.CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME, + }), + }, + }; + const clickhouse = new ClickHouse({ url: url.toString(), name: "clickhouse-instance", @@ -24,6 +46,7 @@ function initializeClickhouseClient() { request: true, }, maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS, + logsQuerySettings, }); return clickhouse; diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts index 8dc8f21e6a..ca28a0a022 100644 --- a/internal-packages/clickhouse/src/index.ts +++ b/internal-packages/clickhouse/src/index.ts @@ -46,6 +46,11 @@ export { } from "./client/tsql.js"; export type { OutputColumnMetadata } from "@internal/tsql"; +export type LogsQuerySettings = { + list?: ClickHouseSettings; + detail?: ClickHouseSettings; +}; + export type ClickhouseCommonConfig = { keepAlive?: { enabled?: boolean; @@ -60,6 +65,7 @@ export type ClickhouseCommonConfig = { response?: boolean; }; maxOpenConnections?: number; + logsQuerySettings?: LogsQuerySettings; }; export type ClickHouseConfig = @@ -83,9 +89,11 @@ export class ClickHouse { public readonly writer: ClickhouseWriter; private readonly logger: Logger; private _splitClients: boolean; + private readonly logsQuerySettings?: LogsQuerySettings; constructor(config: ClickHouseConfig) { this.logger = config.logger ?? new Logger("ClickHouse", config.logLevel ?? "debug"); + this.logsQuerySettings = config.logsQuerySettings; if (config.url) { const url = new URL(config.url); @@ -197,8 +205,8 @@ export class ClickHouse { traceSummaryQueryBuilder: getTraceSummaryQueryBuilderV2(this.reader), traceDetailedSummaryQueryBuilder: getTraceDetailedSummaryQueryBuilderV2(this.reader), spanDetailsQueryBuilder: getSpanDetailsQueryBuilderV2(this.reader), - logsListQueryBuilder: getLogsListQueryBuilder(this.reader), - logDetailQueryBuilder: getLogDetailQueryBuilder(this.reader), + logsListQueryBuilder: getLogsListQueryBuilder(this.reader, this.logsQuerySettings?.list), + logDetailQueryBuilder: getLogDetailQueryBuilder(this.reader, this.logsQuerySettings?.detail), }; } } diff --git a/internal-packages/clickhouse/src/taskEvents.ts b/internal-packages/clickhouse/src/taskEvents.ts index e8a4d5fde0..f526cdf0b6 100644 --- a/internal-packages/clickhouse/src/taskEvents.ts +++ b/internal-packages/clickhouse/src/taskEvents.ts @@ -276,12 +276,7 @@ export function getLogsListQueryBuilder(ch: ClickhouseReader, settings?: ClickHo "metadata", "attributes" ], - settings: { - max_memory_usage: "2000000000", // 2GB per query limit - max_bytes_before_external_sort: "1000000000", // 1GB before spill to disk - max_threads: 4, // Limit parallelism to reduce memory - ...settings, - }, + settings, }); } From 075175fed96355f0386122d2f541101591de01a1 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Sat, 10 Jan 2026 15:31:59 +0200 Subject: [PATCH 20/28] in run page added timestamp filtering when jumping to view logs reduced spinner delay removed execution time column removed metadata from side panel --- .../app/components/logs/LogDetailView.tsx | 57 +------------------ apps/webapp/app/components/logs/LogsTable.tsx | 14 +---- .../route.tsx | 39 +++++++++---- 3 files changed, 31 insertions(+), 79 deletions(-) diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index a1438fb4f4..54fd5e4288 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -312,26 +312,18 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: string; searchTerm?: string }) { const logWithExtras = log as LogEntry & { - metadata?: Record; attributes?: Record; }; - let metadata: Record | null = null; - let beautifiedMetadata: string | null = null; let beautifiedAttributes: string | null = null; - if (logWithExtras.metadata) { - beautifiedMetadata = JSON.stringify(logWithExtras.metadata, null, 2); - beautifiedMetadata = formatStringJSON(beautifiedMetadata); - } - if (logWithExtras.attributes) { beautifiedAttributes = JSON.stringify(logWithExtras.attributes, null, 2); beautifiedAttributes = formatStringJSON(beautifiedAttributes); } - const showMetadata = beautifiedMetadata && beautifiedMetadata !== "{}"; + // const showMetadata = beautifiedMetadata && beautifiedMetadata !== "{}"; const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}"; return ( @@ -412,18 +404,6 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri
- {/* Metadata - only available in full log detail */} - {showMetadata && beautifiedMetadata && ( -
- -
- )} - {/* Attributes - only available in full log detail */} {showAttributes && beautifiedAttributes && (
@@ -519,7 +499,7 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { {runData.rootRun && ( - Root run + Root and parent run )} - {runData.parentRun && ( - - Parent run - - - - - - - - } - content={`Jump to parent run`} - disableHoverableContent - /> - - - )} - {runData.batch && ( Batch diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index 1ff3eac07c..d8a5223618 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -117,7 +117,7 @@ export function LogsTable({ const loadMoreRef = useRef(null); const [showLoadMoreSpinner, setShowLoadMoreSpinner] = useState(false); - // Show load more spinner only after 0.5 seconds of loading time + // Show load more spinner only after 0.2 seconds of loading time useEffect(() => { if (!isLoadingMore) { setShowLoadMoreSpinner(false); @@ -126,7 +126,7 @@ export function LogsTable({ const timer = setTimeout(() => { setShowLoadMoreSpinner(true); - }, 500); + }, 200); return () => clearTimeout(timer); }, [isLoadingMore]); @@ -165,7 +165,6 @@ export function LogsTable({ Run Task Level - Duration Message @@ -222,15 +221,6 @@ export function LogsTable({ {log.level} - - {log.duration > 0 - ? formatDurationNanoseconds(log.duration, { style: "short" }) - : "–"} - {highlightText(log.message, searchTerm)} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index e8e472bfc7..edc30ee57f 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -71,6 +71,7 @@ import { docsPath, v3BatchPath, v3DeploymentVersionPath, + v3LogsPath, v3RunDownloadLogsPath, v3RunIdempotencyKeyResetPath, v3RunPath, @@ -572,7 +573,9 @@ function RunBody({
-
{run.idempotencyKey ? run.idempotencyKey : "–"}
+
+ {run.idempotencyKey ? run.idempotencyKey : "–"} +
{run.idempotencyKey && (
Expires:{" "} @@ -587,7 +590,9 @@ function RunBody({ {run.idempotencyKey && (
From 9663a05b82f211325d4c0875389bb829fd776896 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Sun, 11 Jan 2026 01:57:25 +0200 Subject: [PATCH 21/28] cleaned LogDetails view --- .../app/components/logs/LogDetailView.tsx | 136 +++++------------- apps/webapp/app/components/logs/LogsTable.tsx | 25 +++- .../app/components/primitives/Popover.tsx | 4 + 3 files changed, 56 insertions(+), 109 deletions(-) diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index 54fd5e4288..bae9df7620 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -1,7 +1,6 @@ import { XMarkIcon, ArrowTopRightOnSquareIcon, CheckIcon, ClockIcon } from "@heroicons/react/20/solid"; import { Link } from "@remix-run/react"; import { - formatDurationNanoseconds, type MachinePresetName, formatDurationMilliseconds, } from "@trigger.dev/core/v3"; @@ -267,8 +266,6 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet > {log.level} - · -
{/* Tabs */} -
+
+ + +
{/* Content */} @@ -323,87 +325,35 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri beautifiedAttributes = formatStringJSON(beautifiedAttributes); } - // const showMetadata = beautifiedMetadata && beautifiedMetadata !== "{}"; const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}"; + // Determine message to show + let message = log.message; + + if (log.status === 'ERROR'){ + message = (logWithExtras?.attributes?.error as any)?.message; + } + return ( <> + {/* Time */} +
+ Time +
+ +
+
+ {/* Message */}
Message
- {highlightJsonWithSearch(log.message, searchTerm)} + {highlightJsonWithSearch(message, searchTerm)}
- {/* Run Link */} -
- Run -
- {log.runId} - - - -
-
- - {/* Details */} -
- Details - - - Task - - {log.taskIdentifier} - - - - - Kind - {log.kind} - - - - Duration - - - - {log.duration > 0 - ? formatDurationNanoseconds(log.duration, { style: "short" }) - : "–"} - - - - - - Trace ID - - {log.traceId} - - - - - Span ID - - {log.spanId} - - - - {log.parentSpanId && ( - - Parent Span ID - - {log.parentSpanId} - - - )} - -
- {/* Attributes - only available in full log detail */} {showAttributes && beautifiedAttributes && (
@@ -463,6 +413,13 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { return (
+ + Run ID + + + + + Status @@ -477,22 +434,10 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { Task - - - - } - content={`View runs filtered by ${runData.taskIdentifier}`} - disableHoverableContent + @@ -673,22 +618,7 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { : "–"} - - - Run ID - - - - - -
- - - -
); } diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index d8a5223618..a3fdbe2057 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -1,4 +1,4 @@ -import { ArrowPathIcon } from "@heroicons/react/20/solid"; +import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { cn } from "~/utils/cn"; @@ -17,11 +17,13 @@ import { TableBlankRow, TableBody, TableCell, + TableCellMenu, TableHeader, TableHeaderCell, TableRow, type TableVariant, } from "../primitives/Table"; +import { PopoverMenuItem } from "~/components/primitives/Popover"; type LogsTableProps = { logs: LogEntry[]; @@ -161,10 +163,10 @@ export function LogsTable({
- Time - Run - Task - Level + Time + Run + Task + Level Message @@ -208,7 +210,7 @@ export function LogsTable({ - + {log.taskIdentifier} @@ -226,6 +228,17 @@ export function LogsTable({ {highlightText(log.message, searchTerm)} + + } + /> ); }) diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index 7bdf9e902e..02454864c4 100644 --- a/apps/webapp/app/components/primitives/Popover.tsx +++ b/apps/webapp/app/components/primitives/Popover.tsx @@ -65,6 +65,7 @@ const PopoverMenuItem = React.forwardRef< className?: string; onClick?: React.MouseEventHandler; disabled?: boolean; + openInNewTab?: boolean; } >( ( @@ -78,6 +79,7 @@ const PopoverMenuItem = React.forwardRef< className, onClick, disabled, + openInNewTab = false, }, ref ) => { @@ -102,6 +104,8 @@ const PopoverMenuItem = React.forwardRef< ref={ref as React.Ref} className={cn("group/button focus-custom", contentProps.fullWidth ? "w-full" : "")} onClick={onClick as any} + target={openInNewTab ? "_blank" : undefined} + rel={openInNewTab ? "noopener noreferrer" : undefined} > {title} From 2d78dde65dde17f0bb7d6288fc8cf0bc693ea293 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Sun, 11 Jan 2026 02:54:48 +0200 Subject: [PATCH 22/28] more information cleanup --- apps/webapp/app/components/code/CodeBlock.tsx | 2 +- .../app/components/logs/LogDetailView.tsx | 72 +++++-------------- 2 files changed, 17 insertions(+), 57 deletions(-) diff --git a/apps/webapp/app/components/code/CodeBlock.tsx b/apps/webapp/app/components/code/CodeBlock.tsx index 0dbe59e893..2bcd57d202 100644 --- a/apps/webapp/app/components/code/CodeBlock.tsx +++ b/apps/webapp/app/components/code/CodeBlock.tsx @@ -242,7 +242,7 @@ export const CodeBlock = forwardRef( [code] ); - code = code.trim(); + code = code?.trim() ?? ""; const lineCount = code.split("\n").length; const maxLineWidth = lineCount.toString().length; let maxHeight: string | undefined = undefined; diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index bae9df7620..e79b24826d 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -4,7 +4,7 @@ import { type MachinePresetName, formatDurationMilliseconds, } from "@trigger.dev/core/v3"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { useTypedFetcher } from "remix-typedjson"; import { cn } from "~/utils/cn"; import { Button } from "~/components/primitives/Buttons"; @@ -30,7 +30,6 @@ import { RunTag } from "~/components/runs/v3/RunTag"; import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import type { TaskRunStatus } from "@trigger.dev/database"; import { PacketDisplay } from "~/components/runs/v3/PacketDisplay"; -import type { ReactNode } from "react"; // Types for the run context endpoint response type RunContextData = { @@ -338,7 +337,7 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri <> {/* Time */}
- Time + Timestamp
@@ -346,12 +345,12 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri {/* Message */}
- Message -
-
- {highlightJsonWithSearch(message, searchTerm)} -
-
+
{/* Attributes - only available in full log detail */} @@ -399,13 +398,6 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { return (
Run not found in database. -
- - - -
); } @@ -446,30 +438,10 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { Root and parent run - - - - - - - } - content={`Jump to root run`} - disableHoverableContent + @@ -479,22 +451,10 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { Batch - - - - } - content={`View batch ${runData.batch.friendlyId}`} - disableHoverableContent + From 5350dfe8bd521496b8bc90cad550f2ff2dce9837 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Mon, 12 Jan 2026 00:08:21 +0200 Subject: [PATCH 23/28] extracted log utils for duplicated logic --- .../app/components/logs/LogDetailView.tsx | 21 +------- apps/webapp/app/components/logs/LogsTable.tsx | 21 +------- .../v3/LogDetailPresenter.server.ts | 33 +----------- .../app/presenters/v3/LogPresenter.server.ts | 0 .../presenters/v3/LogsListPresenter.server.ts | 32 +----------- .../route.tsx | 17 +++---- apps/webapp/app/utils/logUtils.ts | 50 +++++++++++++++++++ 7 files changed, 63 insertions(+), 111 deletions(-) delete mode 100644 apps/webapp/app/presenters/v3/LogPresenter.server.ts create mode 100644 apps/webapp/app/utils/logUtils.ts diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index e79b24826d..fbb8a9b06d 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -21,6 +21,7 @@ import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; +import { getLevelColor } from "~/utils/logUtils"; import { v3RunSpanPath, v3RunsPath, v3BatchPath, v3RunPath, v3DeploymentVersionPath } from "~/utils/pathBuilder"; import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId"; import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus"; @@ -68,26 +69,6 @@ type LogDetailViewProps = { type TabType = "details" | "run"; -// Level badge color styles -function getLevelColor(level: string): string { - switch (level) { - case "ERROR": - return "text-error bg-error/10 border-error/20"; - case "WARN": - return "text-warning bg-warning/10 border-warning/20"; - case "DEBUG": - return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; - case "INFO": - return "text-blue-400 bg-blue-500/10 border-blue-500/20"; - case "TRACE": - return "text-charcoal-500 bg-charcoal-800 border-charcoal-700"; - case "CANCELLED": - return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; - default: - return "text-text-dimmed bg-charcoal-750 border-charcoal-700"; - } -} - // Event kind badge color styles function getKindColor(kind: string): string { if (kind === "SPAN") { diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index a3fdbe2057..e3216c18fe 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -7,6 +7,7 @@ import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import type { LogEntry, LogsListAppliedFilters } from "~/presenters/v3/LogsListPresenter.server"; +import { getLevelColor } from "~/utils/logUtils"; import { v3RunSpanPath } from "~/utils/pathBuilder"; import { DateTime } from "../primitives/DateTime"; import { Paragraph } from "../primitives/Paragraph"; @@ -39,26 +40,6 @@ type LogsTableProps = { onLogSelect?: (logId: string) => void; }; -// Level badge color styles -function getLevelColor(level: LogEntry["level"]): string { - switch (level) { - case "ERROR": - return "text-error bg-error/10 border-error/20"; - case "WARN": - return "text-warning bg-warning/10 border-warning/20"; - case "DEBUG": - return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; - case "INFO": - return "text-blue-400 bg-blue-500/10 border-blue-500/20"; - case "TRACE": - return "text-charcoal-500 bg-charcoal-800 border-charcoal-700"; - case "CANCELLED": - return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; - default: - return "text-text-dimmed bg-charcoal-750 border-charcoal-700"; - } -} - // Left border color for error highlighting function getLevelBorderColor(level: LogEntry["level"]): string { switch (level) { diff --git a/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts index 78d9bef75c..5921090d70 100644 --- a/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts @@ -1,6 +1,7 @@ import { type ClickHouse } from "@internal/clickhouse"; import { type PrismaClientOrTransaction } from "@trigger.dev/database"; import { convertClickhouseDateTime64ToJsDate } from "~/v3/eventRepository/clickhouseEventRepository.server"; +import { kindToLevel } from "~/utils/logUtils"; export type LogDetailOptions = { environmentId: string; @@ -14,38 +15,6 @@ export type LogDetailOptions = { export type LogDetail = Awaited>; -// Convert ClickHouse kind to display level -type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "CANCELLED"; - -function kindToLevel(kind: string, status: string): LogLevel { - // CANCELLED status takes precedence - if (status === "CANCELLED") { - return "CANCELLED"; - } - - // ERROR can come from either kind or status - if (kind === "LOG_ERROR" || status === "ERROR") { - return "ERROR"; - } - - switch (kind) { - case "DEBUG_EVENT": - case "LOG_DEBUG": - return "DEBUG"; - case "LOG_INFO": - return "INFO"; - case "LOG_WARN": - return "WARN"; - case "LOG_LOG": - return "INFO"; // Changed from "LOG" - case "SPAN": - case "ANCESTOR_OVERRIDE": - case "SPAN_EVENT": - default: - return "TRACE"; - } -} - export class LogDetailPresenter { constructor( private readonly replica: PrismaClientOrTransaction, diff --git a/apps/webapp/app/presenters/v3/LogPresenter.server.ts b/apps/webapp/app/presenters/v3/LogPresenter.server.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index 04d7bfdee5..a1a21abb33 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -17,8 +17,9 @@ import { convertDateToClickhouseDateTime, convertClickhouseDateTime64ToJsDate, } from "~/v3/eventRepository/clickhouseEventRepository.server"; +import { kindToLevel, type LogLevel } from "~/utils/logUtils"; -export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "CANCELLED"; +export type { LogLevel }; export type LogsListOptions = { userId?: string; @@ -77,35 +78,6 @@ function decodeCursor(cursor: string): LogCursor | null { } } -// Convert ClickHouse kind to display level -function kindToLevel(kind: string, status: string): LogLevel { - if (status === "CANCELLED") { - return "CANCELLED"; - } - - // ERROR can come from either kind or status - if (kind === "LOG_ERROR" || status === "ERROR") { - return "ERROR"; - } - - switch (kind) { - case "DEBUG_EVENT": - case "LOG_DEBUG": - return "DEBUG"; - case "LOG_INFO": - return "INFO"; - case "LOG_WARN": - return "WARN"; - case "LOG_LOG": - return "INFO"; // Changed from "LOG" - case "SPAN": - case "ANCESTOR_OVERRIDE": - case "SPAN_EVENT": - default: - return "TRACE"; - } -} - // Convert display level to ClickHouse kinds and statuses function levelToKindsAndStatuses( level: LogLevel diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index cd9267b1f4..97b96e7a73 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -12,7 +12,8 @@ import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; -import { LogsListPresenter, type LogLevel } from "~/presenters/v3/LogsListPresenter.server"; +import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server"; +import type { LogLevel } from "~/utils/logUtils"; import { $replica } from "~/db.server"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; import { @@ -153,7 +154,6 @@ export default function Page() { @@ -174,7 +174,6 @@ function LogsList({ }: { list: Awaited["data"]>; rootOnlyDefault: boolean; - filters: TaskRunListSearchFilters; isAdmin: boolean; showDebug: boolean; }) { @@ -213,17 +212,17 @@ function LogsList({ setNextCursor(list.pagination.next); }, [list.logs, list.pagination.next]); + // Memoize existing IDs to avoid creating a new Set on every render + const existingIds = useMemo(() => new Set(accumulatedLogs.map((log) => log.id)), [accumulatedLogs]); + // Append new logs when fetcher completes (with deduplication) useEffect(() => { if (fetcher.data && fetcher.state === "idle") { - setAccumulatedLogs((prev) => { - const existingIds = new Set(prev.map((log) => log.id)); - const newLogs = fetcher.data!.logs.filter((log) => !existingIds.has(log.id)); - return [...prev, ...newLogs]; - }); + const newLogs = fetcher.data.logs.filter((log) => !existingIds.has(log.id)); + setAccumulatedLogs((prev) => [...prev, ...newLogs]); setNextCursor(fetcher.data.pagination.next); } - }, [fetcher.data, fetcher.state]); + }, [fetcher.data, fetcher.state, existingIds]); // Build resource URL for loading more const loadMoreUrl = useMemo(() => { diff --git a/apps/webapp/app/utils/logUtils.ts b/apps/webapp/app/utils/logUtils.ts new file mode 100644 index 0000000000..e35ac723d5 --- /dev/null +++ b/apps/webapp/app/utils/logUtils.ts @@ -0,0 +1,50 @@ +export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "CANCELLED"; + +// Convert ClickHouse kind to display level +export function kindToLevel(kind: string, status: string): LogLevel { + if (status === "CANCELLED") { + return "CANCELLED"; + } + + // ERROR can come from either kind or status + if (kind === "LOG_ERROR" || status === "ERROR") { + return "ERROR"; + } + + switch (kind) { + case "DEBUG_EVENT": + case "LOG_DEBUG": + return "DEBUG"; + case "LOG_INFO": + return "INFO"; + case "LOG_WARN": + return "WARN"; + case "LOG_LOG": + return "INFO"; // Changed from "LOG" + case "SPAN": + case "ANCESTOR_OVERRIDE": + case "SPAN_EVENT": + default: + return "TRACE"; + } +} + +// Level badge color styles +export function getLevelColor(level: LogLevel): string { + switch (level) { + case "ERROR": + return "text-error bg-error/10 border-error/20"; + case "WARN": + return "text-warning bg-warning/10 border-warning/20"; + case "DEBUG": + return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; + case "INFO": + return "text-blue-400 bg-blue-500/10 border-blue-500/20"; + case "TRACE": + return "text-charcoal-500 bg-charcoal-800 border-charcoal-700"; + case "CANCELLED": + return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; + default: + return "text-text-dimmed bg-charcoal-750 border-charcoal-700"; + } +} From 443f2a166f7c2aa2b7fb2039acd793f47688946e Mon Sep 17 00:00:00 2001 From: mpcgird Date: Mon, 12 Jan 2026 00:21:06 +0200 Subject: [PATCH 24/28] code review fixes --- apps/webapp/app/components/code/CodeBlock.tsx | 52 ++++++- .../app/components/logs/LogDetailView.tsx | 45 +++--- ...ectParam.env.$envParam.logs.$logId.run.tsx | 132 +++++++++++++----- .../route.tsx | 8 +- 4 files changed, 166 insertions(+), 71 deletions(-) diff --git a/apps/webapp/app/components/code/CodeBlock.tsx b/apps/webapp/app/components/code/CodeBlock.tsx index 2bcd57d202..4cfc0acdc7 100644 --- a/apps/webapp/app/components/code/CodeBlock.tsx +++ b/apps/webapp/app/components/code/CodeBlock.tsx @@ -72,6 +72,12 @@ type CodeBlockProps = { const dimAmount = 0.5; const extraLinesWhenClipping = 0.35; +const SEARCH_HIGHLIGHT_STYLES = { + backgroundColor: "#facc15", + color: "#000000", + fontWeight: "500", +} as const; + const defaultTheme: PrismTheme = { plain: { color: "#9C9AF2", @@ -365,7 +371,7 @@ export const CodeBlock = forwardRef( )} dir="ltr" > - {code} + {highlightSearchInText(code, searchTerm)}
)} @@ -407,7 +413,7 @@ export const CodeBlock = forwardRef( className="overflow-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600" >
-                  {code}
+                  {highlightSearchInText(code, searchTerm)}
                 
)} @@ -420,6 +426,42 @@ export const CodeBlock = forwardRef( CodeBlock.displayName = "CodeBlock"; +/** + * Highlights search term matches in plain text + */ +function highlightSearchInText(text: string, searchTerm: string | undefined): React.ReactNode { + if (!searchTerm || searchTerm.trim() === "") { + return text; + } + + const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedSearch, "gi"); + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match; + let matchCount = 0; + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.substring(lastIndex, match.index)); + } + parts.push( + + {match[0]} + + ); + lastIndex = regex.lastIndex; + matchCount++; + } + + if (lastIndex < text.length) { + parts.push(text.substring(lastIndex)); + } + + return parts.length > 0 ? parts : text; +} + function Chrome({ title }: { title?: string }) { return (
@@ -582,11 +624,7 @@ function HighlightCode({ parts.push( {match[0]} diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index fbb8a9b06d..422b684af6 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -31,31 +31,10 @@ import { RunTag } from "~/components/runs/v3/RunTag"; import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import type { TaskRunStatus } from "@trigger.dev/database"; import { PacketDisplay } from "~/components/runs/v3/PacketDisplay"; +import type { RunContext } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run"; -// Types for the run context endpoint response type RunContextData = { - run: { - id: string; - friendlyId: string; - taskIdentifier: string; - status: string; - createdAt: string; - startedAt?: string; - completedAt?: string; - isTest: boolean; - tags: string[]; - queue: string; - concurrencyKey: string | null; - usageDurationMs: number; - costInCents: number; - baseCostInCents: number; - machinePreset: string | null; - version?: string; - rootRun: { friendlyId: string; taskIdentifier: string } | null; - parentRun: { friendlyId: string; taskIdentifier: string } | null; - batch: { friendlyId: string } | null; - schedule: { friendlyId: string } | null; - } | null; + run: RunContext | null; }; @@ -308,10 +287,12 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}"; // Determine message to show - let message = log.message; - - if (log.status === 'ERROR'){ - message = (logWithExtras?.attributes?.error as any)?.message; + let message = log.message ?? ""; + if (log.level === "ERROR") { + const maybeErrorMessage = (logWithExtras.attributes as any)?.error?.message; + if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) { + message = maybeErrorMessage; + } } return ( @@ -354,17 +335,19 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { const project = useProject(); const environment = useEnvironment(); const fetcher = useTypedFetcher(); + const [requested, setRequested] = useState(false); // Fetch run details when tab is active useEffect(() => { if (!log.runId) return; + setRequested(true); fetcher.load( `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(log.id)}/run?runId=${encodeURIComponent(log.runId)}` ); }, [organization.slug, project.slug, environment.slug, log.id, log.runId]); - const isLoading = fetcher.state === "loading"; + const isLoading = !requested || fetcher.state === "loading"; const runData = fetcher.data?.run; if (isLoading) { @@ -522,7 +505,11 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { Machine - + {runData.machinePreset ? ( + + ) : ( + "–" + )} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx index 442d767cf9..deffb8ffbe 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx @@ -1,10 +1,73 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/node"; +import { z } from "zod"; +import { MachinePresetName } from "@trigger.dev/core/v3"; import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { $replica } from "~/db.server"; +import type { TaskRunStatus } from "@trigger.dev/database"; + +// Valid TaskRunStatus values +const VALID_TASK_RUN_STATUSES = [ + "PENDING", + "QUEUED", + "EXECUTING", + "WAITING_FOR_EXECUTION", + "WAITING", + "COMPLETED_SUCCESSFULLY", + "COMPLETED_WITH_ERRORS", + "SYSTEM_FAILURE", + "FAILURE", + "CANCELED", +] as const; + +// Schema for validating run context data +export const RunContextSchema = z.object({ + id: z.string(), + friendlyId: z.string(), + taskIdentifier: z.string(), + status: z.enum(VALID_TASK_RUN_STATUSES).catch((ctx) => { + throw new Error(`Invalid TaskRunStatus: ${ctx.input}`); + }), + createdAt: z.string().datetime(), + startedAt: z.string().datetime().optional(), + completedAt: z.string().datetime().optional(), + isTest: z.boolean(), + tags: z.array(z.string()), + queue: z.string(), + concurrencyKey: z.string().nullable(), + usageDurationMs: z.number(), + costInCents: z.number(), + baseCostInCents: z.number(), + machinePreset: MachinePresetName.nullable(), + version: z.string().optional(), + rootRun: z + .object({ + friendlyId: z.string(), + taskIdentifier: z.string(), + }) + .nullable(), + parentRun: z + .object({ + friendlyId: z.string(), + taskIdentifier: z.string(), + }) + .nullable(), + batch: z + .object({ + friendlyId: z.string(), + }) + .nullable(), + schedule: z + .object({ + friendlyId: z.string(), + }) + .nullable(), +}); + +export type RunContext = z.infer; // Fetch run context for a log entry export const loader = async ({ request, params }: LoaderFunctionArgs) => { @@ -99,38 +162,43 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { schedule = scheduleData; } + const runData = { + id: run.id, + friendlyId: run.friendlyId, + taskIdentifier: run.taskIdentifier, + status: run.status, + createdAt: run.createdAt.toISOString(), + startedAt: run.startedAt?.toISOString(), + completedAt: run.completedAt?.toISOString(), + isTest: run.isTest, + tags: run.runTags, + queue: run.queue, + concurrencyKey: run.concurrencyKey, + usageDurationMs: run.usageDurationMs, + costInCents: run.costInCents, + baseCostInCents: run.baseCostInCents, + machinePreset: run.machinePreset, + version: run.lockedToVersion?.version, + rootRun: run.rootTaskRun + ? { + friendlyId: run.rootTaskRun.friendlyId, + taskIdentifier: run.rootTaskRun.taskIdentifier, + } + : null, + parentRun: run.parentTaskRun + ? { + friendlyId: run.parentTaskRun.friendlyId, + taskIdentifier: run.parentTaskRun.taskIdentifier, + } + : null, + batch: run.batch ? { friendlyId: run.batch.friendlyId } : null, + schedule: schedule, + }; + + // Validate the run data + const validatedRun = RunContextSchema.parse(runData); + return json({ - run: { - id: run.id, - friendlyId: run.friendlyId, - taskIdentifier: run.taskIdentifier, - status: run.status, - createdAt: run.createdAt.toISOString(), - startedAt: run.startedAt?.toISOString(), - completedAt: run.completedAt?.toISOString(), - isTest: run.isTest, - tags: run.runTags, - queue: run.queue, - concurrencyKey: run.concurrencyKey, - usageDurationMs: run.usageDurationMs, - costInCents: run.costInCents, - baseCostInCents: run.baseCostInCents, - machinePreset: run.machinePreset, - version: run.lockedToVersion?.version, - rootRun: run.rootTaskRun - ? { - friendlyId: run.rootTaskRun.friendlyId, - taskIdentifier: run.rootTaskRun.taskIdentifier, - } - : null, - parentRun: run.parentTaskRun - ? { - friendlyId: run.parentTaskRun.friendlyId, - taskIdentifier: run.parentTaskRun.taskIdentifier, - } - : null, - batch: run.batch ? { friendlyId: run.batch.friendlyId } : null, - schedule: schedule, - }, + run: validatedRun, }); }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index edc30ee57f..b16cc97f7f 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -573,9 +573,11 @@ function RunBody({
-
- {run.idempotencyKey ? run.idempotencyKey : "–"} -
+ {run.idempotencyKey ? ( + + ) : ( +
+ )} {run.idempotencyKey && (
Expires:{" "} From d8bbc1012be3d83221d9b8353177bed08134320b Mon Sep 17 00:00:00 2001 From: mpcgird Date: Mon, 12 Jan 2026 13:04:42 +0200 Subject: [PATCH 25/28] fixed some react update issues extracted common logic to logUtils improved codeBlock highlight adjusted CLICKHOUSE LOGS values --- apps/webapp/app/components/code/CodeBlock.tsx | 82 +------------------ .../app/components/logs/LogDetailView.tsx | 47 ----------- apps/webapp/app/components/logs/LogsTable.tsx | 32 +------- apps/webapp/app/env.server.ts | 14 ++-- .../presenters/v3/LogsListPresenter.server.ts | 19 +++-- .../route.tsx | 60 +++++++------- ...ectParam.env.$envParam.logs.$logId.run.tsx | 4 +- .../runsRepository/runsRepository.server.ts | 4 +- apps/webapp/app/utils/logUtils.ts | 59 +++++++++++++ 9 files changed, 121 insertions(+), 200 deletions(-) diff --git a/apps/webapp/app/components/code/CodeBlock.tsx b/apps/webapp/app/components/code/CodeBlock.tsx index 4cfc0acdc7..431a02ba35 100644 --- a/apps/webapp/app/components/code/CodeBlock.tsx +++ b/apps/webapp/app/components/code/CodeBlock.tsx @@ -5,6 +5,7 @@ import { Highlight, Prism } from "prism-react-renderer"; import { forwardRef, ReactNode, useCallback, useEffect, useState } from "react"; import { TextWrapIcon } from "~/assets/icons/TextWrapIcon"; import { cn } from "~/utils/cn"; +import { highlightSearchText } from "~/utils/logUtils"; import { Button } from "../primitives/Buttons"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../primitives/Dialog"; import { Paragraph } from "../primitives/Paragraph"; @@ -72,12 +73,6 @@ type CodeBlockProps = { const dimAmount = 0.5; const extraLinesWhenClipping = 0.35; -const SEARCH_HIGHLIGHT_STYLES = { - backgroundColor: "#facc15", - color: "#000000", - fontWeight: "500", -} as const; - const defaultTheme: PrismTheme = { plain: { color: "#9C9AF2", @@ -371,7 +366,7 @@ export const CodeBlock = forwardRef( )} dir="ltr" > - {highlightSearchInText(code, searchTerm)} + {highlightSearchText(code, searchTerm)}
)} @@ -413,7 +408,7 @@ export const CodeBlock = forwardRef( className="overflow-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600" >
-                  {highlightSearchInText(code, searchTerm)}
+                  {highlightSearchText(code, searchTerm)}
                 
)} @@ -426,42 +421,6 @@ export const CodeBlock = forwardRef( CodeBlock.displayName = "CodeBlock"; -/** - * Highlights search term matches in plain text - */ -function highlightSearchInText(text: string, searchTerm: string | undefined): React.ReactNode { - if (!searchTerm || searchTerm.trim() === "") { - return text; - } - - const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const regex = new RegExp(escapedSearch, "gi"); - - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match; - let matchCount = 0; - - while ((match = regex.exec(text)) !== null) { - if (match.index > lastIndex) { - parts.push(text.substring(lastIndex, match.index)); - } - parts.push( - - {match[0]} - - ); - lastIndex = regex.lastIndex; - matchCount++; - } - - if (lastIndex < text.length) { - parts.push(text.substring(lastIndex)); - } - - return parts.length > 0 ? parts : text; -} - function Chrome({ title }: { title?: string }) { return (
@@ -607,40 +566,7 @@ function HighlightCode({ const tokenProps = getTokenProps({ token, key }); // Highlight search term matches in token - let content: React.ReactNode = token.content; - if (searchTerm && searchTerm.trim() !== "" && token.content) { - const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const regex = new RegExp(escapedSearch, "gi"); - - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match; - let matchCount = 0; - - while ((match = regex.exec(token.content)) !== null) { - if (match.index > lastIndex) { - parts.push(token.content.substring(lastIndex, match.index)); - } - parts.push( - - {match[0]} - - ); - lastIndex = regex.lastIndex; - matchCount++; - } - - if (lastIndex < token.content.length) { - parts.push(token.content.substring(lastIndex)); - } - - if (parts.length > 0) { - content = parts; - } - } + const content = highlightSearchText(token.content, searchTerm); return ( lastIndex) { - parts.push(json.substring(lastIndex, match.index)); - } - // Add highlighted match with inline styles - parts.push( - - {match[0]} - - ); - lastIndex = regex.lastIndex; - matchCount++; - } - - // Add remaining text - if (lastIndex < json.length) { - parts.push(json.substring(lastIndex)); - } - - return parts.length > 0 ? parts : json; -} - export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDetailViewProps) { const organization = useOrganization(); diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index e3216c18fe..c00ce5d2f4 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -7,7 +7,7 @@ import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import type { LogEntry, LogsListAppliedFilters } from "~/presenters/v3/LogsListPresenter.server"; -import { getLevelColor } from "~/utils/logUtils"; +import { getLevelColor, highlightSearchText } from "~/utils/logUtils"; import { v3RunSpanPath } from "~/utils/pathBuilder"; import { DateTime } from "../primitives/DateTime"; import { Paragraph } from "../primitives/Paragraph"; @@ -58,30 +58,6 @@ function getLevelBorderColor(level: LogEntry["level"]): string { } } -// Case-insensitive text highlighting -function highlightText(text: string, searchTerm: string | undefined): ReactNode { - if (!searchTerm || searchTerm.trim() === "") { - return text; - } - - const lowerText = text.toLowerCase(); - const lowerSearch = searchTerm.toLowerCase(); - const index = lowerText.indexOf(lowerSearch); - - if (index === -1) { - return text; - } - - return ( - <> - {text.slice(0, index)} - - {text.slice(index, index + searchTerm.length)} - - {text.slice(index + searchTerm.length)} - - ); -} export function LogsTable({ logs, @@ -206,7 +182,7 @@ export function LogsTable({ - {highlightText(log.message, searchTerm)} + {highlightSearchText(log.message, searchTerm)} ; + if (isLoading) return ; return ( - +
No logs match your filters. Try refreshing or modifying your filters. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 6b28281be9..31ef4deffe 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1176,16 +1176,16 @@ const EnvironmentSchema = z CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"), // Logs List Query Settings (for paginated log views) - CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE: z.coerce.number().int().default(2_000_000_000), - CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT: z.coerce.number().int().default(1_000_000_000), - CLICKHOUSE_LOGS_LIST_MAX_THREADS: z.coerce.number().int().default(4), - CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ: z.coerce.number().int().optional(), - CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME: z.coerce.number().int().optional(), + CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE: z.coerce.number().int().default(256_000_000), + CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT: z.coerce.number().int().default(256_000_000), + CLICKHOUSE_LOGS_LIST_MAX_THREADS: z.coerce.number().int().default(2), + CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ: z.coerce.number().int().default(10_000_000), + CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME: z.coerce.number().int().default(120), // Logs Detail Query Settings (for single log views) - CLICKHOUSE_LOGS_DETAIL_MAX_MEMORY_USAGE: z.coerce.number().int().default(500_000_000), + CLICKHOUSE_LOGS_DETAIL_MAX_MEMORY_USAGE: z.coerce.number().int().default(64_000_000), CLICKHOUSE_LOGS_DETAIL_MAX_THREADS: z.coerce.number().int().default(2), - CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME: z.coerce.number().int().optional(), + CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME: z.coerce.number().int().default(60), EVENTS_CLICKHOUSE_URL: z .string() diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index a1a21abb33..5e2f23bcbc 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -21,6 +21,13 @@ import { kindToLevel, type LogLevel } from "~/utils/logUtils"; export type { LogLevel }; +type ErrorAttributes = { + error?: { + message?: unknown; + }; + [key: string]: unknown; +}; + export type LogsListOptions = { userId?: string; projectId: string; @@ -237,7 +244,7 @@ export class LogsListPresenter { if (hasRunLevelFilters) { const runsRepository = new RunsRepository({ clickhouse: this.clickhouse, - prisma: this.replica as PrismaClient, + prisma: this.replica, }); function clampToNow(date: Date): Date { @@ -460,14 +467,14 @@ export class LogsListPresenter { // For error logs with status ERROR, try to extract error message from attributes if (log.status === "ERROR" && log.attributes) { try { - let attributes = log.attributes as Record; + let attributes = log.attributes as ErrorAttributes; if (attributes?.error?.message && typeof attributes.error.message === 'string') { - displayMessage = attributes.error.message; - } - } catch { - // If attributes parsing fails, use the regular message + displayMessage = attributes.error.message; } + } catch { + // If attributes parsing fails, use the regular message + } } return { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index 97b96e7a73..e6e7b813e5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -22,7 +22,7 @@ import { } from "~/services/preferences/uiPreferences.server"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; -import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; +import { Suspense, useCallback, useEffect, useMemo, useState, useTransition } from "react"; import { Spinner } from "~/components/primitives/Spinner"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Callout } from "~/components/primitives/Callout"; @@ -71,12 +71,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { - throw new Error("Project not found"); + throw new Response("Project not found", { status: 404 }); } const environment = await findEnvironmentBySlug(project.id, envParam, userId); if (!environment) { - throw new Error("Environment not found"); + throw new Response("Environment not found", { status: 404 }); } const filters = await getRunFiltersFromRequest(request); @@ -117,7 +117,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { data, rootOnlyDefault, filters, isAdmin, showDebug } = useTypedLoaderData(); + const { data, rootOnlyDefault, isAdmin, showDebug } = useTypedLoaderData(); return ( @@ -180,6 +180,7 @@ function LogsList({ const navigation = useNavigation(); const location = useLocation(); const fetcher = useFetcher<{ logs: LogEntry[]; pagination: { next?: string } }>(); + const [, startTransition] = useTransition(); const isLoading = navigation.state !== "idle"; // Accumulated logs state @@ -187,11 +188,7 @@ function LogsList({ const [nextCursor, setNextCursor] = useState(list.pagination.next); // Selected log state - managed locally to avoid triggering navigation - const [selectedLogId, setSelectedLogId] = useState(() => { - // Initialize from URL on mount - const params = new URLSearchParams(location.search); - return params.get("log") ?? undefined; - }); + const [selectedLogId, setSelectedLogId] = useState(); const handleDebugToggle = useCallback( (checked: boolean) => { @@ -206,23 +203,24 @@ function LogsList({ [] ); + // Reset accumulated logs when the initial list changes (e.g., filters change) useEffect(() => { setAccumulatedLogs(list.logs); setNextCursor(list.pagination.next); }, [list.logs, list.pagination.next]); - // Memoize existing IDs to avoid creating a new Set on every render - const existingIds = useMemo(() => new Set(accumulatedLogs.map((log) => log.id)), [accumulatedLogs]); - // Append new logs when fetcher completes (with deduplication) useEffect(() => { if (fetcher.data && fetcher.state === "idle") { + const existingIds = new Set(accumulatedLogs.map((log) => log.id)); const newLogs = fetcher.data.logs.filter((log) => !existingIds.has(log.id)); - setAccumulatedLogs((prev) => [...prev, ...newLogs]); - setNextCursor(fetcher.data.pagination.next); + if (newLogs.length > 0) { + setAccumulatedLogs((prev) => [...prev, ...newLogs]); + setNextCursor(fetcher.data.pagination.next); + } } - }, [fetcher.data, fetcher.state, existingIds]); + }, [fetcher.data, fetcher.state, accumulatedLogs]); // Build resource URL for loading more const loadMoreUrl = useMemo(() => { @@ -230,7 +228,7 @@ function LogsList({ const resourcePath = `/resources${location.pathname}`; const params = new URLSearchParams(location.search); params.set("cursor", nextCursor); - params.delete("log"); // Don't include selected log in fetch + params.delete("log"); return `${resourcePath}?${params.toString()}`; }, [location.pathname, location.search, nextCursor]); @@ -240,13 +238,11 @@ function LogsList({ } }, [loadMoreUrl, fetcher]); - // Find the selected log in the accumulated list for initial data const selectedLog = useMemo(() => { if (!selectedLogId) return undefined; return accumulatedLogs.find((log) => log.id === selectedLogId); }, [selectedLogId, accumulatedLogs]); - // Update URL without triggering navigation using History API const updateUrlWithLog = useCallback( (logId: string | undefined) => { const url = new URL(window.location.href); @@ -262,16 +258,20 @@ function LogsList({ const handleLogSelect = useCallback( (logId: string) => { - setSelectedLogId(logId); + startTransition(() => { + setSelectedLogId(logId); + }); updateUrlWithLog(logId); }, - [updateUrlWithLog] + [updateUrlWithLog, startTransition] ); const handleClosePanel = useCallback(() => { - setSelectedLogId(undefined); + startTransition(() => { + setSelectedLogId(undefined); + }); updateUrlWithLog(undefined); - }, [updateUrlWithLog]); + }, [updateUrlWithLog, startTransition]); return ( @@ -321,15 +321,17 @@ function LogsList({ <> - +
}> + + )} ); -} \ No newline at end of file +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx index deffb8ffbe..15986c305a 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run.tsx @@ -28,9 +28,7 @@ export const RunContextSchema = z.object({ id: z.string(), friendlyId: z.string(), taskIdentifier: z.string(), - status: z.enum(VALID_TASK_RUN_STATUSES).catch((ctx) => { - throw new Error(`Invalid TaskRunStatus: ${ctx.input}`); - }), + status: z.enum(VALID_TASK_RUN_STATUSES), createdAt: z.string().datetime(), startedAt: z.string().datetime().optional(), completedAt: z.string().datetime().optional(), diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts index 553938e77f..895c8b5fe5 100644 --- a/apps/webapp/app/services/runsRepository/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts @@ -7,7 +7,7 @@ import { type Prisma, TaskRunStatus } from "@trigger.dev/database"; import parseDuration from "parse-duration"; import { z } from "zod"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; -import { type PrismaClient } from "~/db.server"; +import { type PrismaClient, type PrismaClientOrTransaction } from "~/db.server"; import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server"; import { startActiveSpan } from "~/v3/tracer.server"; import { logger } from "../logger.server"; @@ -16,7 +16,7 @@ import { PostgresRunsRepository } from "./postgresRunsRepository.server"; export type RunsRepositoryOptions = { clickhouse: ClickHouse; - prisma: PrismaClient; + prisma: PrismaClientOrTransaction; logger?: Logger; logLevel?: LogLevel; tracer?: Tracer; diff --git a/apps/webapp/app/utils/logUtils.ts b/apps/webapp/app/utils/logUtils.ts index e35ac723d5..0e305748ef 100644 --- a/apps/webapp/app/utils/logUtils.ts +++ b/apps/webapp/app/utils/logUtils.ts @@ -1,5 +1,64 @@ +import { createElement, Fragment, type ReactNode } from "react"; + export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "CANCELLED"; +// Default styles for search highlighting +const DEFAULT_HIGHLIGHT_STYLES: React.CSSProperties = { + backgroundColor: "#facc15", // yellow-400 + color: "#000000", + fontWeight: "500", + borderRadius: "0.25rem", + padding: "0 0.125rem", +} as const; + +/** + * Highlights all occurrences of a search term in text with consistent styling. + * Case-insensitive search with regex special character escaping. + * + * @param text - The text to search within + * @param searchTerm - The term to highlight (optional) + * @param style - Optional custom inline styles for highlights + * @returns React nodes with highlighted matches, or the original text if no matches + */ +export function highlightSearchText( + text: string, + searchTerm?: string, + style: React.CSSProperties = DEFAULT_HIGHLIGHT_STYLES +): ReactNode { + if (!searchTerm || searchTerm.trim() === "") { + return text; + } + + // Escape special regex characters in search term + const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escapedSearch, "gi"); + + const parts: ReactNode[] = []; + let lastIndex = 0; + let match; + let matchCount = 0; + + while ((match = regex.exec(text)) !== null) { + // Add text before match + if (match.index > lastIndex) { + parts.push(text.substring(lastIndex, match.index)); + } + // Add highlighted match + parts.push( + createElement("span", { key: `match-${matchCount}`, style }, match[0]) + ); + lastIndex = regex.lastIndex; + matchCount++; + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push(text.substring(lastIndex)); + } + + return parts.length > 0 ? parts : text; +} + // Convert ClickHouse kind to display level export function kindToLevel(kind: string, status: string): LogLevel { if (status === "CANCELLED") { From c9610374beff83a6f9423538df9aac81c05f9fd9 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Mon, 12 Jan 2026 13:26:41 +0200 Subject: [PATCH 26/28] cleanup --- .../app/components/logs/LogDetailView.tsx | 18 +++++++++++----- .../app/components/logs/LogsSearchInput.tsx | 1 + apps/webapp/app/components/logs/LogsTable.tsx | 3 +-- .../presenters/v3/LogsListPresenter.server.ts | 21 +++++++++++++++---- .../route.tsx | 2 -- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index fae67fecd7..8f306c8c28 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -1,10 +1,10 @@ -import { XMarkIcon, ArrowTopRightOnSquareIcon, CheckIcon, ClockIcon } from "@heroicons/react/20/solid"; +import { XMarkIcon, ArrowTopRightOnSquareIcon, CheckIcon } from "@heroicons/react/20/solid"; import { Link } from "@remix-run/react"; import { type MachinePresetName, formatDurationMilliseconds, } from "@trigger.dev/core/v3"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState } from "react"; import { useTypedFetcher } from "remix-typedjson"; import { cn } from "~/utils/cn"; import { Button } from "~/components/primitives/Buttons"; @@ -22,7 +22,7 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; import { getLevelColor } from "~/utils/logUtils"; -import { v3RunSpanPath, v3RunsPath, v3BatchPath, v3RunPath, v3DeploymentVersionPath } from "~/utils/pathBuilder"; +import { v3RunSpanPath, v3RunsPath, v3DeploymentVersionPath } from "~/utils/pathBuilder"; import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId"; import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus"; import { MachineLabelCombo } from "~/components/MachineLabelCombo"; @@ -48,6 +48,12 @@ type LogDetailViewProps = { type TabType = "details" | "run"; +type LogAttributes = Record & { + error?: { + message?: string; + }; +}; + // Event kind badge color styles function getKindColor(kind: string): string { if (kind === "SPAN") { @@ -109,6 +115,7 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet fetcher.load( `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(logId)}` ); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [organization.slug, project.slug, environment.slug, logId]); const isLoading = fetcher.state === "loading"; @@ -226,7 +233,7 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: string; searchTerm?: string }) { const logWithExtras = log as LogEntry & { - attributes?: Record; + attributes?: LogAttributes; }; @@ -242,7 +249,7 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri // Determine message to show let message = log.message ?? ""; if (log.level === "ERROR") { - const maybeErrorMessage = (logWithExtras.attributes as any)?.error?.message; + const maybeErrorMessage = logWithExtras.attributes?.error?.message; if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) { message = maybeErrorMessage; } @@ -298,6 +305,7 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { fetcher.load( `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(log.id)}/run?runId=${encodeURIComponent(log.runId)}` ); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [organization.slug, project.slug, environment.slug, log.id, log.runId]); const isLoading = !requested || fetcher.state === "loading"; diff --git a/apps/webapp/app/components/logs/LogsSearchInput.tsx b/apps/webapp/app/components/logs/LogsSearchInput.tsx index 1843089660..41871722b9 100644 --- a/apps/webapp/app/components/logs/LogsSearchInput.tsx +++ b/apps/webapp/app/components/logs/LogsSearchInput.tsx @@ -25,6 +25,7 @@ export function LogsSearchInput() { if (urlSearch !== text && !isFocused) { setText(urlSearch); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.search]); const handleSubmit = useCallback(() => { diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index c00ce5d2f4..6bad90a9af 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -1,6 +1,5 @@ import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; -import { formatDurationNanoseconds } from "@trigger.dev/core/v3"; -import { type ReactNode, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { cn } from "~/utils/cn"; import { Button } from "~/components/primitives/Buttons"; import { useEnvironment } from "~/hooks/useEnvironment"; diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index 5e2f23bcbc..ababd21366 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -110,6 +110,19 @@ function convertDateToNanoseconds(date: Date): bigint { return BigInt(date.getTime()) * 1_000_000n; } +function formatNanosecondsForClickhouse(ns: bigint): string { + const nsString = ns.toString(); + // Handle negative numbers (dates before 1970-01-01) + if (nsString.startsWith("-")) { + const absString = nsString.slice(1); + const padded = absString.padStart(19, "0"); + return "-" + padded.slice(0, 10) + "." + padded.slice(10); + } + // Pad positive numbers to 19 digits to ensure correct slicing + const padded = nsString.padStart(19, "0"); + return padded.slice(0, 10) + "." + padded.slice(10); +} + export class LogsListPresenter { constructor( private readonly replica: PrismaClientOrTransaction, @@ -324,23 +337,23 @@ export class LogsListPresenter { // Time filters - inserted_at in PREWHERE for partition pruning, start_time in WHERE if (effectiveFrom) { - const fromNs = convertDateToNanoseconds(effectiveFrom).toString(); + const fromNs = convertDateToNanoseconds(effectiveFrom); queryBuilder.prewhere("inserted_at >= {insertedAtStart: DateTime64(3)}", { insertedAtStart: convertDateToClickhouseDateTime(effectiveFrom), }); queryBuilder.where("start_time >= {fromTime: String}", { - fromTime: fromNs.slice(0, 10) + "." + fromNs.slice(10), + fromTime: formatNanosecondsForClickhouse(fromNs), }); } if (effectiveTo) { const clampedTo = effectiveTo > new Date() ? new Date() : effectiveTo; - const toNs = convertDateToNanoseconds(clampedTo).toString(); + const toNs = convertDateToNanoseconds(clampedTo); queryBuilder.prewhere("inserted_at <= {insertedAtEnd: DateTime64(3)}", { insertedAtEnd: convertDateToClickhouseDateTime(clampedTo), }); queryBuilder.where("start_time <= {toTime: String}", { - toTime: toNs.slice(0, 10) + "." + toNs.slice(10), + toTime: formatNanosecondsForClickhouse(toNs), }); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index e6e7b813e5..6583ce37aa 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -26,7 +26,6 @@ import { Suspense, useCallback, useEffect, useMemo, useState, useTransition } fr import { Spinner } from "~/components/primitives/Spinner"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Callout } from "~/components/primitives/Callout"; -import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import { RunsFilters } from "~/components/runs/v3/RunFilters"; import { LogsTable } from "~/components/logs/LogsTable"; import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; @@ -39,7 +38,6 @@ import { ResizablePanelGroup, } from "~/components/primitives/Resizable"; import { Switch } from "~/components/primitives/Switch"; -import { getUserById } from "~/models/user.server"; // Valid log levels for filtering const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"]; From 30ce10696d8a33f1c55b55a6c83ece3a70f7825a Mon Sep 17 00:00:00 2001 From: mpcgird Date: Mon, 12 Jan 2026 14:34:40 +0200 Subject: [PATCH 27/28] types fixes --- .../app/components/logs/LogDetailView.tsx | 42 +------------------ apps/webapp/app/components/logs/LogsTable.tsx | 1 - .../app/components/primitives/Table.tsx | 3 ++ .../app/presenters/RunFilters.server.ts | 3 +- .../presenters/v3/LogsListPresenter.server.ts | 34 +++++++++++++++ ...ojects.$projectParam.env.$envParam.logs.ts | 11 +++-- apps/webapp/app/utils/logUtils.ts | 40 ++++++++++++++++++ 7 files changed, 87 insertions(+), 47 deletions(-) diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index 8f306c8c28..992f182f2c 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -21,7 +21,7 @@ import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; -import { getLevelColor } from "~/utils/logUtils"; +import { getLevelColor, getKindColor, getKindLabel } from "~/utils/logUtils"; import { v3RunSpanPath, v3RunsPath, v3DeploymentVersionPath } from "~/utils/pathBuilder"; import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId"; import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus"; @@ -54,46 +54,6 @@ type LogAttributes = Record & { }; }; -// Event kind badge color styles -function getKindColor(kind: string): string { - if (kind === "SPAN") { - return "text-purple-400 bg-purple-500/10 border-purple-500/20"; - } - if (kind === "SPAN_EVENT") { - return "text-amber-400 bg-amber-500/10 border-amber-500/20"; - } - if (kind.startsWith("LOG_")) { - return "text-blue-400 bg-blue-500/10 border-blue-500/20"; - } - return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; -} - -// Get human readable kind label -function getKindLabel(kind: string): string { - switch (kind) { - case "SPAN": - return "Span"; - case "SPAN_EVENT": - return "Event"; - case "LOG_DEBUG": - return "Log"; - case "LOG_INFO": - return "Log"; - case "LOG_WARN": - return "Log"; - case "LOG_ERROR": - return "Log"; - case "LOG_LOG": - return "Log"; - case "DEBUG_EVENT": - return "Debug"; - case "ANCESTOR_OVERRIDE": - return "Override"; - default: - return kind; - } -} - function formatStringJSON(str: string): string { return str .replace(/\\n/g, "\n") // Converts literal "\n" to newline diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index 6bad90a9af..6bc66be932 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -28,7 +28,6 @@ import { PopoverMenuItem } from "~/components/primitives/Popover"; type LogsTableProps = { logs: LogEntry[]; hasFilters: boolean; - filters: LogsListAppliedFilters; searchTerm?: string; isLoading?: boolean; isLoadingMore?: boolean; diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index a13549fa3e..3452f87fed 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -22,7 +22,10 @@ const variants = { }, "bright/no-hover": { header: "bg-transparent", + headerCell: "px-3 py-2.5 pb-3 text-sm", cell: "group-hover/table-row:bg-transparent", + cellSize: "px-3 py-3", + cellText: "text-xs", stickyCell: "bg-background-bright", menuButton: "bg-background-bright", menuButtonDivider: "", diff --git a/apps/webapp/app/presenters/RunFilters.server.ts b/apps/webapp/app/presenters/RunFilters.server.ts index 8d70b4d3bd..ef428f6ca2 100644 --- a/apps/webapp/app/presenters/RunFilters.server.ts +++ b/apps/webapp/app/presenters/RunFilters.server.ts @@ -1,3 +1,4 @@ +import { type TaskRunStatus } from "@trigger.dev/database"; import { getRunFiltersFromSearchParams, TaskRunListSearchFilters, @@ -39,7 +40,7 @@ export async function getRunFiltersFromRequest(request: Request): Promise val.every((v) => Object.values(TaskRunStatusEnum).includes(v)), + { message: "Invalid TaskRunStatus values" } +) as unknown as z.ZodSchema; import parseDuration from "parse-duration"; import { type Direction } from "~/components/ListPagination"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; @@ -57,6 +65,32 @@ export type LogsListOptions = { pageSize?: number; }; +export const LogsListOptionsSchema = z.object({ + userId: z.string().optional(), + projectId: z.string(), + tasks: z.array(z.string()).optional(), + versions: z.array(z.string()).optional(), + statuses: TaskRunStatusSchema.optional(), + tags: z.array(z.string()).optional(), + scheduleId: z.string().optional(), + period: z.string().optional(), + bulkId: z.string().optional(), + from: z.number().int().nonnegative().optional(), + to: z.number().int().nonnegative().optional(), + isTest: z.boolean().optional(), + rootOnly: z.boolean().optional(), + batchId: z.string().optional(), + runId: z.array(z.string()).optional(), + queues: z.array(z.string()).optional(), + machines: z.array(z.string()).optional(), + levels: z.array(z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"])).optional(), + search: z.string().max(1000).optional(), + includeDebugLogs: z.boolean().optional(), + direction: z.enum(["forward", "backward"]).optional(), + cursor: z.string().optional(), + pageSize: z.number().int().positive().max(1000).optional(), +}); + const DEFAULT_PAGE_SIZE = 50; const MAX_RUN_IDS = 5000; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts index 07f02888ed..164cdaea9a 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts @@ -5,7 +5,7 @@ import { EnvironmentParamSchema } from "~/utils/pathBuilder"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; -import { LogsListPresenter, type LogLevel } from "~/presenters/v3/LogsListPresenter.server"; +import { LogsListPresenter, type LogLevel, LogsListOptionsSchema } from "~/presenters/v3/LogsListPresenter.server"; import { $replica } from "~/db.server"; import { clickhouseClient } from "~/services/clickhouseInstance.server"; @@ -44,8 +44,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const levels = parseLevelsFromUrl(url); const showDebug = url.searchParams.get("showDebug") === "true"; - const presenter = new LogsListPresenter($replica, clickhouseClient); - const result = await presenter.call(project.organizationId, environment.id, { + + const options = LogsListOptionsSchema.parse({ userId, projectId: project.id, ...filters, @@ -53,7 +53,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { cursor, levels, includeDebugLogs: isAdmin && showDebug, - }); + }) as any; // Validated by LogsListOptionsSchema at runtime + + const presenter = new LogsListPresenter($replica, clickhouseClient); + const result = await presenter.call(project.organizationId, environment.id, options); return json({ logs: result.logs, diff --git a/apps/webapp/app/utils/logUtils.ts b/apps/webapp/app/utils/logUtils.ts index 0e305748ef..e88ade9233 100644 --- a/apps/webapp/app/utils/logUtils.ts +++ b/apps/webapp/app/utils/logUtils.ts @@ -107,3 +107,43 @@ export function getLevelColor(level: LogLevel): string { return "text-text-dimmed bg-charcoal-750 border-charcoal-700"; } } + +// Event kind badge color styles +export function getKindColor(kind: string): string { + if (kind === "SPAN") { + return "text-purple-400 bg-purple-500/10 border-purple-500/20"; + } + if (kind === "SPAN_EVENT") { + return "text-amber-400 bg-amber-500/10 border-amber-500/20"; + } + if (kind.startsWith("LOG_")) { + return "text-blue-400 bg-blue-500/10 border-blue-500/20"; + } + return "text-charcoal-400 bg-charcoal-700 border-charcoal-600"; +} + +// Get human readable kind label +export function getKindLabel(kind: string): string { + switch (kind) { + case "SPAN": + return "Span"; + case "SPAN_EVENT": + return "Event"; + case "LOG_DEBUG": + return "Log"; + case "LOG_INFO": + return "Log"; + case "LOG_WARN": + return "Log"; + case "LOG_ERROR": + return "Log"; + case "LOG_LOG": + return "Log"; + case "DEBUG_EVENT": + return "Debug"; + case "ANCESTOR_OVERRIDE": + return "Override"; + default: + return kind; + } +} From 64e75ea2c71111aaadb2d3a08f709884db566bd4 Mon Sep 17 00:00:00 2001 From: mpcgird Date: Tue, 13 Jan 2026 00:54:40 +0200 Subject: [PATCH 28/28] review fixes merged master --- .../app/components/logs/LogDetailView.tsx | 33 ++++++++++++------- apps/webapp/app/components/logs/LogsTable.tsx | 12 +++---- .../app/components/runs/v3/RunFilters.tsx | 4 ++- .../app/components/runs/v3/SharedFilters.tsx | 8 ++++- .../presenters/v3/LogsListPresenter.server.ts | 33 +++++++++++++------ .../route.tsx | 9 +++-- ...ojects.$projectParam.env.$envParam.logs.ts | 1 + apps/webapp/app/utils/logUtils.ts | 16 ++++++--- 8 files changed, 79 insertions(+), 37 deletions(-) diff --git a/apps/webapp/app/components/logs/LogDetailView.tsx b/apps/webapp/app/components/logs/LogDetailView.tsx index 992f182f2c..a367e75495 100644 --- a/apps/webapp/app/components/logs/LogDetailView.tsx +++ b/apps/webapp/app/components/logs/LogDetailView.tsx @@ -67,17 +67,30 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet const environment = useEnvironment(); const fetcher = useTypedFetcher(); const [activeTab, setActiveTab] = useState("details"); + const [error, setError] = useState(null); // Fetch full log details when logId changes useEffect(() => { if (!logId) return; + setError(null); fetcher.load( `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(logId)}` ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [organization.slug, project.slug, environment.slug, logId]); + // Handle fetch errors + useEffect(() => { + if (fetcher.data && typeof fetcher.data === "object" && "error" in fetcher.data) { + setError(fetcher.data.error as string); + } else if (fetcher.state === "idle" && fetcher.data === null && !initialLog) { + setError("Failed to load log details"); + } else { + setError(null); + } + }, [fetcher.data, initialLog, fetcher.state]); + const isLoading = fetcher.state === "loading"; const log = fetcher.data ?? initialLog; @@ -110,7 +123,7 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
- Log not found + {error ?? "Log not found"}
); @@ -255,20 +268,18 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) { const project = useProject(); const environment = useEnvironment(); const fetcher = useTypedFetcher(); - const [requested, setRequested] = useState(false); // Fetch run details when tab is active useEffect(() => { if (!log.runId) return; - setRequested(true); fetcher.load( `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(log.id)}/run?runId=${encodeURIComponent(log.runId)}` ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [organization.slug, project.slug, environment.slug, log.id, log.runId]); - const isLoading = !requested || fetcher.state === "loading"; + const isLoading = fetcher.state === "loading"; const runData = fetcher.data?.run; if (isLoading) { @@ -388,14 +399,12 @@ function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) {
- {environment && ( - - Environment - - - - - )} + + Environment + + + + Queue diff --git a/apps/webapp/app/components/logs/LogsTable.tsx b/apps/webapp/app/components/logs/LogsTable.tsx index 6bc66be932..169e65515b 100644 --- a/apps/webapp/app/components/logs/LogsTable.tsx +++ b/apps/webapp/app/components/logs/LogsTable.tsx @@ -5,7 +5,7 @@ import { Button } from "~/components/primitives/Buttons"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import type { LogEntry, LogsListAppliedFilters } from "~/presenters/v3/LogsListPresenter.server"; +import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server"; import { getLevelColor, highlightSearchText } from "~/utils/logUtils"; import { v3RunSpanPath } from "~/utils/pathBuilder"; import { DateTime } from "../primitives/DateTime"; @@ -131,7 +131,7 @@ export function LogsTable({ {!isLoading && } ) : logs.length === 0 ? ( - + window.location.reload()} /> ) : ( logs.map((log) => { const isSelected = selectedLogId === log.id; @@ -222,9 +222,11 @@ function NoLogs({ title }: { title: string }) { ); } -function BlankState({ isLoading }: { isLoading?: boolean }) { +function BlankState({ isLoading, onRefresh }: { isLoading?: boolean; onRefresh?: () => void }) { if (isLoading) return ; + const handleRefresh = onRefresh ?? (() => window.location.reload()); + return (
@@ -235,9 +237,7 @@ function BlankState({ isLoading }: { isLoading?: boolean }) { diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 01422f52d4..5695081816 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -326,6 +326,8 @@ type RunFiltersProps = { hasFilters: boolean; /** Hide the AI search input (useful when replacing with a custom search component) */ hideSearch?: boolean; + /** Custom default period for the time filter (e.g., "1h", "7d") */ + defaultPeriod?: string; }; export function RunsFilters(props: RunFiltersProps) { @@ -348,7 +350,7 @@ export function RunsFilters(props: RunFiltersProps) { {!props.hideSearch && } - + {hasFilters && (
diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx index 89b7ab5611..bbb8ee8dc0 100644 --- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -260,13 +260,18 @@ export function timeFilterRenderValues({ return { label, valueLabel, rangeType }; } -export function TimeFilter() { +export interface TimeFilterProps { + defaultPeriod?: string; +} + +export function TimeFilter({ defaultPeriod }: TimeFilterProps = {}) { const { value, del } = useSearchParams(); const { period, from, to, label, valueLabel } = timeFilters({ period: value("period"), from: value("from"), to: value("to"), + defaultPeriod, }); return ( @@ -287,6 +292,7 @@ export function TimeFilter() { period={period} from={from} to={to} + defaultPeriod={defaultPeriod} /> )} diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index 4b6473ac31..0b5f2c175a 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -10,10 +10,7 @@ import { } from "@trigger.dev/database"; // Create a schema that validates TaskRunStatus enum values -const TaskRunStatusSchema = z.array(z.string()).refine( - (val) => val.every((v) => Object.values(TaskRunStatusEnum).includes(v)), - { message: "Invalid TaskRunStatus values" } -) as unknown as z.ZodSchema; +const TaskRunStatusSchema = z.array(z.nativeEnum(TaskRunStatusEnum)); import parseDuration from "parse-duration"; import { type Direction } from "~/components/ListPagination"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; @@ -25,7 +22,7 @@ import { convertDateToClickhouseDateTime, convertClickhouseDateTime64ToJsDate, } from "~/v3/eventRepository/clickhouseEventRepository.server"; -import { kindToLevel, type LogLevel } from "~/utils/logUtils"; +import { kindToLevel, type LogLevel, LogLevelSchema } from "~/utils/logUtils"; export type { LogLevel }; @@ -56,6 +53,7 @@ export type LogsListOptions = { queues?: string[]; machines?: MachinePresetName[]; levels?: LogLevel[]; + defaultPeriod?: string; // search search?: string; includeDebugLogs?: boolean; @@ -82,8 +80,9 @@ export const LogsListOptionsSchema = z.object({ batchId: z.string().optional(), runId: z.array(z.string()).optional(), queues: z.array(z.string()).optional(), - machines: z.array(z.string()).optional(), - levels: z.array(z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"])).optional(), + machines: z.array(MachinePresetName).optional(), + levels: z.array(LogLevelSchema).optional(), + defaultPeriod: z.string().optional(), search: z.string().max(1000).optional(), includeDebugLogs: z.boolean().optional(), direction: z.enum(["forward", "backward"]).optional(), @@ -106,6 +105,13 @@ type LogCursor = { runId: string; }; +const LogCursorSchema = z.object({ + startTime: z.string(), + traceId: z.string(), + spanId: z.string(), + runId: z.string(), +}); + function encodeCursor(cursor: LogCursor): string { return Buffer.from(JSON.stringify(cursor)).toString("base64"); } @@ -113,7 +119,12 @@ function encodeCursor(cursor: LogCursor): string { function decodeCursor(cursor: string): LogCursor | null { try { const decoded = Buffer.from(cursor, "base64").toString("utf-8"); - return JSON.parse(decoded) as LogCursor; + const parsed = JSON.parse(decoded); + const validated = LogCursorSchema.safeParse(parsed); + if (!validated.success) { + return null; + } + return validated.data; } catch { return null; } @@ -189,12 +200,14 @@ export class LogsListPresenter { cursor, pageSize = DEFAULT_PAGE_SIZE, includeDebugLogs = true, + defaultPeriod, }: LogsListOptions ) { const time = timeFilters({ period, from, to, + defaultPeriod, }); let effectiveFrom = time.from; @@ -418,7 +431,7 @@ export class LogsListPresenter { if (levels && levels.length > 0) { const conditions: string[] = []; - const params: Record = {}; + const params: Record = {}; const hasErrorOrCancelledLevel = levels.includes("ERROR") || levels.includes("CANCELLED"); for (const level of levels) { @@ -451,7 +464,7 @@ export class LogsListPresenter { } if (conditions.length > 0) { - queryBuilder.where(`(${conditions.join(" OR ")})`, params as any); + queryBuilder.where(`(${conditions.join(" OR ")})`, params); } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index 6583ce37aa..aad6a2be53 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -93,6 +93,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { search, levels, includeDebugLogs: isAdmin && showDebug, + defaultPeriod: "1h", }); const session = await setRootOnlyFilterPreference(filters.rootOnly, request); @@ -105,6 +106,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { filters, isAdmin, showDebug, + defaultPeriod: "1h", }, { headers: { @@ -115,7 +117,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { data, rootOnlyDefault, isAdmin, showDebug } = useTypedLoaderData(); + const { data, rootOnlyDefault, isAdmin, showDebug, defaultPeriod } = useTypedLoaderData(); return ( @@ -154,6 +156,7 @@ export default function Page() { rootOnlyDefault={rootOnlyDefault} isAdmin={isAdmin} showDebug={showDebug} + defaultPeriod={defaultPeriod} /> ); }} @@ -169,11 +172,13 @@ function LogsList({ rootOnlyDefault, isAdmin, showDebug, + defaultPeriod, }: { list: Awaited["data"]>; rootOnlyDefault: boolean; isAdmin: boolean; showDebug: boolean; + defaultPeriod?: string; }) { const navigation = useNavigation(); const location = useLocation(); @@ -284,6 +289,7 @@ function LogsList({ hasFilters={list.hasFilters} rootOnlyDefault={rootOnlyDefault} hideSearch + defaultPeriod={defaultPeriod} /> @@ -302,7 +308,6 @@ function LogsList({ { cursor, levels, includeDebugLogs: isAdmin && showDebug, + defaultPeriod: "1h", }) as any; // Validated by LogsListOptionsSchema at runtime const presenter = new LogsListPresenter($replica, clickhouseClient); diff --git a/apps/webapp/app/utils/logUtils.ts b/apps/webapp/app/utils/logUtils.ts index e88ade9233..b4a130b8e5 100644 --- a/apps/webapp/app/utils/logUtils.ts +++ b/apps/webapp/app/utils/logUtils.ts @@ -1,6 +1,10 @@ import { createElement, Fragment, type ReactNode } from "react"; +import { z } from "zod"; -export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "CANCELLED"; +export const LogLevelSchema = z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"]); +export type LogLevel = z.infer; + +export const validLogLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"]; // Default styles for search highlighting const DEFAULT_HIGHLIGHT_STYLES: React.CSSProperties = { @@ -29,6 +33,12 @@ export function highlightSearchText( return text; } + // Defense in depth: limit search term length to prevent ReDoS and performance issues + const MAX_SEARCH_LENGTH = 500; + if (searchTerm.length > MAX_SEARCH_LENGTH) { + return text; + } + // Escape special regex characters in search term const escapedSearch = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(escapedSearch, "gi"); @@ -130,13 +140,9 @@ export function getKindLabel(kind: string): string { case "SPAN_EVENT": return "Event"; case "LOG_DEBUG": - return "Log"; case "LOG_INFO": - return "Log"; case "LOG_WARN": - return "Log"; case "LOG_ERROR": - return "Log"; case "LOG_LOG": return "Log"; case "DEBUG_EVENT":