@@ -451,6 +498,7 @@ type HighlightCodeProps = {
className?: string;
preClassName?: string;
isWrapped: boolean;
+ searchTerm?: string;
};
function HighlightCode({
@@ -463,6 +511,7 @@ function HighlightCode({
className,
preClassName,
isWrapped,
+ searchTerm,
}: HighlightCodeProps) {
const [isLoaded, setIsLoaded] = useState(false);
@@ -556,6 +605,43 @@ function HighlightCode({
{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
new file mode 100644
index 0000000000..422b684af6
--- /dev/null
+++ b/apps/webapp/app/components/logs/LogDetailView.tsx
@@ -0,0 +1,553 @@
+import { XMarkIcon, ArrowTopRightOnSquareIcon, CheckIcon, ClockIcon } 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 { 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 { 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 { 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";
+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";
+import type { RunContext } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run";
+
+type RunContextData = {
+ run: RunContext | null;
+};
+
+
+type LogDetailViewProps = {
+ logId: string;
+ // If we have the log entry from the list, we can display it immediately
+ initialLog?: LogEntry;
+ onClose: () => void;
+ searchTerm?: string;
+};
+
+type TabType = "details" | "run";
+
+// 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
+ .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;
+ }
+
+ // 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));
+ }
+ // 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();
+ const project = useProject();
+ const environment = useEnvironment();
+ const fetcher = useTypedFetcher
();
+ const [activeTab, setActiveTab] = useState("details");
+
+ // 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
+
+
+
+
+ );
+ }
+
+ const runPath = v3RunSpanPath(
+ organization,
+ project,
+ environment,
+ { friendlyId: log.runId },
+ { spanId: log.spanId }
+ );
+
+ return (
+
+ {/* Header */}
+
+
+
+ {getKindLabel(log.kind)}
+
+
+ {log.level}
+
+
+
+
+
+ {/* Tabs */}
+
+
+ setActiveTab("details")}
+ shortcut={{ key: "d" }}
+ >
+ Details
+
+ setActiveTab("run")}
+ shortcut={{ key: "r" }}
+ >
+ Run
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {activeTab === "details" && (
+
+ )}
+ {activeTab === "run" && (
+
+ )}
+
+
+ );
+}
+
+function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: string; searchTerm?: string }) {
+ const logWithExtras = log as LogEntry & {
+ attributes?: Record;
+ };
+
+
+ let beautifiedAttributes: string | null = null;
+
+ if (logWithExtras.attributes) {
+ beautifiedAttributes = JSON.stringify(logWithExtras.attributes, null, 2);
+ beautifiedAttributes = formatStringJSON(beautifiedAttributes);
+ }
+
+ const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}";
+
+ // Determine message to show
+ 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 (
+ <>
+ {/* Time */}
+
+
+ {/* Message */}
+
+
+ {/* Attributes - only available in full log detail */}
+ {showAttributes && beautifiedAttributes && (
+
+ )}
+ >
+ );
+}
+
+function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) {
+ const organization = useOrganization();
+ 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 = !requested || fetcher.state === "loading";
+ const runData = fetcher.data?.run;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!runData) {
+ return (
+
+
Run not found in database.
+
+ );
+ }
+
+ return (
+
+
+
+ Run ID
+
+
+
+
+
+
+ Status
+
+ }
+ content={descriptionForTaskRunStatus(runData.status as TaskRunStatus)}
+ disableHoverableContent
+ />
+
+
+
+
+ Task
+
+
+
+
+
+ {runData.rootRun && (
+
+ Root and parent run
+
+
+
+
+ )}
+
+ {runData.batch && (
+
+ Batch
+
+
+
+
+ )}
+
+
+ Version
+
+ {runData.version ? (
+ environment.type === "DEVELOPMENT" ? (
+
+ ) : (
+
+
+
+ }
+ content={"Jump to deployment"}
+ />
+ )
+ ) : (
+
+ Never started
+
+
+ )}
+
+
+
+
+ Test run
+
+ {runData.isTest ? : "–"}
+
+
+
+ {environment && (
+
+ Environment
+
+
+
+
+ )}
+
+
+ Queue
+
+ Name: {runData.queue}
+ Concurrency key: {runData.concurrencyKey ? runData.concurrencyKey : "–"}
+
+
+
+ {runData.tags && runData.tags.length > 0 && (
+
+ Tags
+
+
+ {runData.tags.map((tag: string) => (
+
+ ))}
+
+
+
+ )}
+
+
+ Machine
+
+ {runData.machinePreset ? (
+
+ ) : (
+ "–"
+ )}
+
+
+
+
+ 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" })
+ : "–"}
+
+
+
+
+ );
+}
+
diff --git a/apps/webapp/app/components/logs/LogsLevelFilter.tsx b/apps/webapp/app/components/logs/LogsLevelFilter.tsx
new file mode 100644
index 0000000000..4ec7d95730
--- /dev/null
+++ b/apps/webapp/app/components/logs/LogsLevelFilter.tsx
@@ -0,0 +1,182 @@
+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 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" },
+ { level: "CANCELLED", label: "Cancelled", color: "text-charcoal-400" },
+ { level: "DEBUG", label: "Debug", color: "text-charcoal-400" },
+ { 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":
+ 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";
+ }
+}
+
+const shortcut = { key: "l" };
+
+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 (
+
+ {(search, setSearch) => (
+ }
+ variant="secondary/small"
+ shortcut={shortcut}
+ tooltipTitle="Filter by level"
+ >
+ Level
+
+ }
+ searchValue={search}
+ clearSearchValue={() => setSearch("")}
+ showDebug={showDebug}
+ />
+ )}
+
+ );
+}
+
+function LevelDropdown({
+ trigger,
+ clearSearchValue,
+ searchValue,
+ onClose,
+ showDebug = false,
+}: {
+ trigger: ReactNode;
+ clearSearchValue: () => void;
+ searchValue: string;
+ onClose?: () => void;
+ showDebug?: boolean;
+}) {
+ const { values, replace } = useSearchParams();
+
+ const handleChange = (values: string[]) => {
+ clearSearchValue();
+ replace({ levels: values, cursor: undefined, direction: undefined });
+ };
+
+ const availableLevels = getAvailableLevels(showDebug);
+ const filtered = useMemo(() => {
+ return availableLevels.filter((item) =>
+ item.label.toLowerCase().includes(searchValue.toLowerCase())
+ );
+ }, [searchValue, availableLevels]);
+
+ return (
+
+ {trigger}
+ {
+ if (onClose) {
+ onClose();
+ return false;
+ }
+ return true;
+ }}
+ >
+
+
+ {filtered.map((item, index) => (
+
+
+ {item.level}
+
+
+ ))}
+
+
+
+ );
+}
+
+function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
+ 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("")}
+ showDebug={showDebug}
+ />
+ )}
+
+ );
+}
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/LogsSearchInput.tsx b/apps/webapp/app/components/logs/LogsSearchInput.tsx
new file mode 100644
index 0000000000..1843089660
--- /dev/null
+++ b/apps/webapp/app/components/logs/LogsSearchInput.tsx
@@ -0,0 +1,96 @@
+import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
+import { useNavigate } from "@remix-run/react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Input } from "~/components/primitives/Input";
+import { ShortcutKey } from "~/components/primitives/ShortcutKey";
+import { cn } from "~/utils/cn";
+import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
+
+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 [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]);
+
+ return (
+
+
+ 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..e3216c18fe
--- /dev/null
+++ b/apps/webapp/app/components/logs/LogsTable.tsx
@@ -0,0 +1,274 @@
+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";
+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 { getLevelColor } from "~/utils/logUtils";
+import { v3RunSpanPath } from "~/utils/pathBuilder";
+import { DateTime } from "../primitives/DateTime";
+import { Paragraph } from "../primitives/Paragraph";
+import { Spinner } from "../primitives/Spinner";
+import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
+import {
+ Table,
+ TableBlankRow,
+ TableBody,
+ TableCell,
+ TableCellMenu,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+ type TableVariant,
+} from "../primitives/Table";
+import { PopoverMenuItem } from "~/components/primitives/Popover";
+
+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;
+};
+
+// 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 "CANCELLED":
+ return "border-l-charcoal-600";
+ case "DEBUG":
+ case "TRACE":
+ default:
+ return "border-l-transparent hover:border-l-charcoal-800";
+ }
+}
+
+// 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,
+ searchTerm,
+ isLoading = false,
+ isLoadingMore = false,
+ hasMore = false,
+ onLoadMore,
+ selectedLogId,
+ onLogSelect,
+}: LogsTableProps) {
+ const organization = useOrganization();
+ const project = useProject();
+ const environment = useEnvironment();
+ const loadMoreRef = useRef(null);
+ const [showLoadMoreSpinner, setShowLoadMoreSpinner] = useState(false);
+
+ // Show load more spinner only after 0.2 seconds of loading time
+ useEffect(() => {
+ if (!isLoadingMore) {
+ setShowLoadMoreSpinner(false);
+ return;
+ }
+
+ const timer = setTimeout(() => {
+ setShowLoadMoreSpinner(true);
+ }, 200);
+
+ return () => clearTimeout(timer);
+ }, [isLoadingMore]);
+
+ // 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
+ 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.taskIdentifier}
+
+
+
+ {log.level}
+
+
+
+
+ {highlightText(log.message, searchTerm)}
+
+
+
+ }
+ />
+
+ );
+ })
+ )}
+
+
+ {/* Infinite scroll trigger */}
+ {hasMore && logs.length > 0 && (
+
+ {showLoadMoreSpinner && (
+
+ Loading more…
+
+ )}
+
+ )}
+
+ );
+}
+
+function NoLogs({ title }: { title: string }) {
+ return (
+
+ );
+}
+
+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 48cbe2fda4..bcd6b8ca5c 100644
--- a/apps/webapp/app/components/navigation/SideMenu.tsx
+++ b/apps/webapp/app/components/navigation/SideMenu.tsx
@@ -26,9 +26,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";
@@ -64,6 +65,7 @@ import {
v3DeploymentsPath,
v3EnvironmentPath,
v3EnvironmentVariablesPath,
+ v3LogsPath,
v3ProjectAlertsPath,
v3ProjectPath,
v3ProjectSettingsPath,
@@ -267,6 +269,16 @@ export function SideMenu({
to={v3DeploymentsPath(organization, project, environment)}
data-action="deployments"
/>
+ {(isAdmin || user.isImpersonating) && (
+ }
+ />
+ )}
}
side="right"
+ asChild={true}
/>
);
};
@@ -273,6 +274,7 @@ const DateTimeAccurateInner = ({
button={{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}}
content={tooltipContent}
side="right"
+ asChild={true}
/>
);
};
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}
diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx
index 978c7a162f..a13549fa3e 100644
--- a/apps/webapp/app/components/primitives/Table.tsx
+++ b/apps/webapp/app/components/primitives/Table.tsx
@@ -10,7 +10,10 @@ import { InfoIconTooltip, SimpleTooltip } 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 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",
@@ -27,7 +30,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 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",
+ 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 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",
@@ -147,6 +165,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":
@@ -164,7 +183,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 (
|
);
}
@@ -63,6 +66,7 @@ export function PacketDisplay({
maxLines={20}
showLineNumbers={false}
showTextWrapping
+ searchTerm={searchTerm}
/>
);
}
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/env.server.ts b/apps/webapp/app/env.server.ts
index 6c0405bb79..6b28281be9 100644
--- a/apps/webapp/app/env.server.ts
+++ b/apps/webapp/app/env.server.ts
@@ -1175,6 +1175,18 @@ const EnvironmentSchema = z
CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
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(),
+
+ // 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_THREADS: z.coerce.number().int().default(2),
+ CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME: z.coerce.number().int().optional(),
+
EVENTS_CLICKHOUSE_URL: z
.string()
.optional()
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..5921090d70
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts
@@ -0,0 +1,107 @@
+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;
+ organizationId: string;
+ projectId: string;
+ spanId: string;
+ traceId: string;
+ // The exact start_time from the log id - used to uniquely identify the event
+ startTime: string;
+};
+
+export type LogDetail = Awaited
>;
+
+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 - 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,
+ });
+ 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 });
+ queryBuilder.where("start_time = {startTime: String}", { startTime });
+
+ 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 = {};
+ let rawAttributesString = "";
+
+ try {
+ if (log.metadata) {
+ parsedMetadata = JSON.parse(log.metadata) as Record;
+ }
+ } catch {
+ // Ignore parse errors
+ }
+
+ try {
+ // 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
+ }
+
+ 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, log.status),
+ metadata: parsedMetadata,
+ attributes: parsedAttributes,
+ // Raw strings for display
+ rawMetadata: log.metadata,
+ rawAttributes: rawAttributesString,
+ };
+ }
+}
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..a1a21abb33
--- /dev/null
+++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts
@@ -0,0 +1,520 @@
+import { type ClickHouse, type LogsListResult } from "@internal/clickhouse";
+import { MachinePresetName } from "@trigger.dev/core/v3";
+import {
+ type PrismaClient,
+ type PrismaClientOrTransaction,
+ 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";
+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";
+import { kindToLevel, type LogLevel } from "~/utils/logUtils";
+
+export type { LogLevel };
+
+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[];
+ levels?: LogLevel[];
+ // search
+ search?: string;
+ includeDebugLogs?: boolean;
+ // 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 display level to ClickHouse kinds and statuses
+function levelToKindsAndStatuses(
+ level: LogLevel
+): { kinds?: string[]; statuses?: string[] } {
+ switch (level) {
+ case "DEBUG":
+ return { kinds: ["DEBUG_EVENT", "LOG_DEBUG"] };
+ case "INFO":
+ return { kinds: ["LOG_INFO", "LOG_LOG"] };
+ case "WARN":
+ return { kinds: ["LOG_WARN"] };
+ case "ERROR":
+ return { kinds: ["LOG_ERROR"], statuses: ["ERROR"] };
+ case "CANCELLED":
+ return { statuses: ["CANCELLED"] };
+ case "TRACE":
+ return { kinds: ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"] };
+ }
+}
+
+
+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,
+ levels,
+ search,
+ from,
+ to,
+ cursor,
+ pageSize = DEFAULT_PAGE_SIZE,
+ includeDebugLogs = true,
+ }: LogsListOptions
+ ) {
+ const time = timeFilters({
+ period,
+ from,
+ to,
+ });
+
+ 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) ||
+ 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 ||
+ (levels !== undefined && levels.length > 0) ||
+ (search !== undefined && search !== "") ||
+ !time.isDefault;
+
+ const possibleTasksAsync = getAllTaskIdentifiers(
+ this.replica,
+ environmentId
+ );
+
+ const bulkActionsAsync = this.replica.bulkActionGroup.findMany({
+ select: {
+ friendlyId: true,
+ type: true,
+ createdAt: true,
+ name: true,
+ },
+ where: {
+ projectId: projectId,
+ environmentId,
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ take: 20,
+ });
+
+ const [possibleTasks, bulkActions, displayableEnvironment] =
+ await Promise.all([
+ possibleTasksAsync,
+ bulkActionsAsync,
+ findDisplayableEnvironment(environmentId, userId),
+ ]);
+
+ if (
+ 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: effectiveFrom ? effectiveFrom.getTime() : undefined,
+ to: effectiveTo ? clampToNow(effectiveTo).getTime() : undefined,
+ isTest,
+ rootOnly,
+ batchId,
+ runId,
+ bulkId,
+ queues,
+ machines,
+ page: {
+ size: MAX_RUN_IDS,
+ direction: "forward",
+ },
+ });
+
+ 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 || [],
+ levels: levels || [],
+ from: effectiveFrom,
+ to: effectiveTo,
+ },
+ hasFilters,
+ hasAnyLogs: false,
+ searchTerm: search,
+ };
+ }
+ }
+
+ const queryBuilder = this.clickhouse.taskEventsV2.logsListQueryBuilder();
+
+ queryBuilder.prewhere("environment_id = {environmentId: String}", {
+ environmentId,
+ });
+
+ queryBuilder.where("organization_id = {organizationId: String}", {
+ organizationId,
+ });
+ queryBuilder.where("project_id = {projectId: String}", { projectId });
+
+ // Time filters - inserted_at in PREWHERE for partition pruning, start_time in WHERE
+ if (effectiveFrom) {
+ const fromNs = convertDateToNanoseconds(effectiveFrom).toString();
+ 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 (effectiveTo) {
+ const clampedTo = effectiveTo > new Date() ? new Date() : effectiveTo;
+ const toNs = convertDateToNanoseconds(clampedTo).toString();
+ queryBuilder.prewhere("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 search in message, attributes, and status fields
+ if (search && search.trim() !== "") {
+ const searchTerm = search.trim();
+ queryBuilder.where(
+ "(message ilike {searchPattern: String} OR attributes_text ilike {searchPattern: String} OR status = {statusTerm: String})",
+ {
+ searchPattern: `%${searchTerm}%`,
+ statusTerm: searchTerm.toUpperCase(),
+ }
+ );
+ }
+
+
+ if (levels && levels.length > 0) {
+ const conditions: string[] = [];
+ const params: Record = {};
+ 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 ")})`, params as any);
+ }
+ }
+
+ // Debug logs are available only to admins
+ if (includeDebugLogs === false) {
+ queryBuilder.where("kind NOT IN {debugKinds: Array(String)}", {
+ debugKinds: ["DEBUG_EVENT", "LOG_DEBUG"],
+ });
+ }
+
+ queryBuilder.where("NOT (kind = 'SPAN' AND status = 'PARTIAL')");
+
+
+ // 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,
+ }
+ );
+ }
+
+ 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);
+
+ 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) => {
+ 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,
+ 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 || [],
+ levels: levels || [],
+ from: effectiveFrom,
+ to: effectiveTo,
+ },
+ 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..97b96e7a73
--- /dev/null
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx
@@ -0,0 +1,335 @@
+import { type LoaderFunctionArgs , redirect} 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 { requireUser } 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 type { LogLevel } from "~/utils/logUtils";
+import { $replica } from "~/db.server";
+import { clickhouseClient } from "~/services/clickhouseInstance.server";
+import {
+ setRootOnlyFilterPreference,
+ uiPreferencesStorage,
+} 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 { 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 { LogsLevelFilter } from "~/components/logs/LogsLevelFilter";
+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", "CANCELLED"];
+
+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 [
+ {
+ title: `Logs | Trigger.dev`,
+ },
+ ];
+};
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUser(request);
+ 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);
+ 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, 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, {
+ userId,
+ projectId: project.id,
+ ...filters,
+ search,
+ levels,
+ includeDebugLogs: isAdmin && showDebug,
+ });
+
+ const session = await setRootOnlyFilterPreference(filters.rootOnly, request);
+ const cookieValue = await uiPreferencesStorage.commitSession(session);
+
+ return typeddefer(
+ {
+ data: list,
+ rootOnlyDefault: filters.rootOnly,
+ filters,
+ isAdmin,
+ showDebug,
+ },
+ {
+ headers: {
+ "Set-Cookie": cookieValue,
+ },
+ }
+ );
+};
+
+export default function Page() {
+ const { data, rootOnlyDefault, filters, isAdmin, showDebug } = useTypedLoaderData();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ }
+ >
+
+
+ Unable to load your logs. Please refresh the page or try again in a moment.
+
+
+ }
+ >
+ {(list) => {
+ return (
+