Logs: new materialized view and some UI improvements (#3069)

This will prevent internal logs to be added to the
task_events_search_table

Closes #<issue>

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

Ran the migration, deleted the old invalid rows and ran new tasks.
The undesired logs are not added to the table.

---

## Changelog

Updated the MATERIALIZED VIEW to also filter for `trace_id != ''`

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
This commit is contained in:
Mihai Popescu
2026-02-18 11:48:52 +02:00
committed by GitHub
parent 6e0b54b081
commit b793f33e59
25 changed files with 516 additions and 782 deletions
@@ -1,7 +1,6 @@
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { LinkButton } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import { LogLevel } from "./logs/LogLevel";
export function LogLevelTooltipInfo() {
return (
@@ -13,51 +12,45 @@ export function LogLevelTooltipInfo() {
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-blue-400">Info</Header3>
<div className="mb-1">
<LogLevel level="TRACE" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Traces and spans representing the execution flow of your tasks.
</Paragraph>
</div>
<div>
<div className="mb-1">
<LogLevel level="INFO" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
General informational messages about task execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-warning">Warn</Header3>
<div className="mb-1">
<LogLevel level="WARN" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Warning messages indicating potential issues that don't prevent execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-error">Error</Header3>
<div className="mb-1">
<LogLevel level="ERROR" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Error messages for failures and exceptions during task execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-charcoal-400">Debug</Header3>
<div className="mb-1">
<LogLevel level="DEBUG" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Detailed diagnostic information for development and debugging.
</Paragraph>
</div>
<div className="border-t border-charcoal-700 pt-4">
<Header3>Tracing & Spans</Header3>
<Paragraph variant="small" className="text-text-dimmed">
Automatically track the flow of your code through task triggers, attempts, and HTTP
requests. Create custom traces to monitor specific operations.
</Paragraph>
</div>
<LinkButton
to="https://trigger.dev/docs/logging#tracing-and-spans"
variant="docs/small"
LeadingIcon={BookOpenIcon}
>
Read docs
</LinkButton>
</div>
);
}
@@ -68,6 +68,9 @@ type CodeBlockProps = {
/** Search term to highlight in the code */
searchTerm?: string;
/** Whether to wrap the code */
wrap?: boolean;
};
const dimAmount = 0.5;
@@ -207,6 +210,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
fileName,
rowTitle,
searchTerm,
wrap = false,
...props
}: CodeBlockProps,
ref
@@ -215,7 +219,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
const [copied, setCopied] = useState(false);
const [modalCopied, setModalCopied] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isWrapped, setIsWrapped] = useState(false);
const [isWrapped, setIsWrapped] = useState(wrap);
const onCopied = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
+127 -352
View File
@@ -1,43 +1,30 @@
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 { XMarkIcon } from "@heroicons/react/20/solid";
import type { TaskRunStatus } from "@trigger.dev/database";
import { useEffect, useState } from "react";
import { useTypedFetcher } from "remix-typedjson";
import { cn } from "~/utils/cn";
import { Button } from "~/components/primitives/Buttons";
import { DateTimeAccurate } 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 { Button, LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { SimpleTooltip, InfoIconTooltip } from "~/components/primitives/Tooltip";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { Spinner } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import {
TaskRunStatusCombo,
descriptionForTaskRunStatus,
} from "~/components/runs/v3/TaskRunStatus";
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, 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;
};
import { cn } from "~/utils/cn";
import { getLevelColor } from "~/utils/logUtils";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { LogLevel } from "./LogLevel";
import { ExitIcon } from "~/assets/icons/ExitIcon";
type LogDetailViewProps = {
logId: string;
// If we have the log entry from the list, we can display it immediately
@@ -46,27 +33,38 @@ type LogDetailViewProps = {
searchTerm?: string;
};
type TabType = "details" | "run";
type LogAttributes = Record<string, unknown> & {
error?: {
message?: string;
};
};
function getDisplayMessage(log: {
message: string;
level: string;
attributes?: LogAttributes;
}): string {
let message = log.message ?? "";
if (log.level === "ERROR") {
const maybeErrorMessage = log.attributes?.error?.message;
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
message = maybeErrorMessage;
}
}
return message;
}
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
}
export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDetailViewProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<typeof logDetailLoader>();
const [activeTab, setActiveTab] = useState<TabType>("details");
const [error, setError] = useState<string | null>(null);
// Fetch full log details when logId changes
@@ -75,7 +73,9 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
setError(null);
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(logId)}`
`/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]);
@@ -93,6 +93,7 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
const isLoading = fetcher.state === "loading";
const log = fetcher.data ?? initialLog;
const runStatus = fetcher.data?.runStatus;
const runPath = v3RunSpanPath(
organization,
@@ -102,27 +103,6 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
{ spanId: log?.spanId ?? "" }
);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target && (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.contentEditable === "true"
)) {
return;
}
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose, log, runPath, isLoading]);
if (isLoading && !log) {
return (
<div className="flex h-full items-center justify-center">
@@ -134,11 +114,16 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
if (!log) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-grid-dimmed p-4">
<div className="flex items-center justify-between border-b border-grid-dimmed py-2 pl-3 pr-2">
<Header2>Log Details</Header2>
<Button variant="minimal/small" onClick={onClose}>
<XMarkIcon className="size-5" />
</Button>
<Button
onClick={onClose}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="flex flex-1 items-center justify-center">
<Paragraph className="text-text-dimmed">{error ?? "Log not found"}</Paragraph>
@@ -148,103 +133,112 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-grid-dimmed px-2 py-2">
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase tracking-wider",
getLevelColor(log.level)
)}
>
{log.level}
</span>
<Button variant="minimal/small" onClick={onClose} shortcut={{ key: "esc" }}>
<XMarkIcon className="size-5" />
</Button>
<div className="flex items-center justify-between overflow-hidden border-b border-grid-dimmed py-2 pl-3 pr-2">
<Header2 className="truncate">{getDisplayMessage(log)}</Header2>
<Button
onClick={onClose}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
{/* Tabs */}
<div className="flex items-center justify-between border-b border-grid-dimmed px-4">
<TabContainer>
<TabButton
isActive={activeTab === "details"}
layoutId="log-detail-tabs"
onClick={() => setActiveTab("details")}
shortcut={{ key: "d" }}
>
Details
</TabButton>
<TabButton
isActive={activeTab === "run"}
layoutId="log-detail-tabs"
onClick={() => setActiveTab("run")}
shortcut={{ key: "r" }}
>
Run
</TabButton>
</TabContainer>
<Link to={runPath} target="_blank" rel="noopener noreferrer">
<Button variant="minimal/small" LeadingIcon={ArrowTopRightOnSquareIcon} shortcut={{ key: "v" }}>
View full run
</Button>
</Link>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "details" && (
<DetailsTab log={log} runPath={runPath} searchTerm={searchTerm} />
)}
{activeTab === "run" && (
<RunTab log={log} runPath={runPath} />
)}
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<DetailsTab log={log} runPath={runPath} runStatus={runStatus} searchTerm={searchTerm} />
</div>
</div>
);
}
function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: string; searchTerm?: string }) {
const logWithExtras = log as LogEntry & {
function DetailsTab({
log,
runPath,
runStatus,
searchTerm,
}: {
log: LogEntry & {
attributes?: LogAttributes;
};
runPath: string;
runStatus?: TaskRunStatus;
searchTerm?: string;
}) {
let beautifiedAttributes: string | null = null;
if (logWithExtras.attributes) {
beautifiedAttributes = JSON.stringify(logWithExtras.attributes, null, 2);
if (log.attributes) {
beautifiedAttributes = JSON.stringify(log.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?.error?.message;
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
message = maybeErrorMessage;
}
}
const message = getDisplayMessage(log);
return (
<>
{/* Time */}
<div className="mb-6">
<Header3 className="mb-2">Timestamp</Header3>
<div className="text-sm text-text-dimmed">
<DateTimeAccurate date={log.triggeredTimestamp} />
</div>
</div>
<Property.Table>
<Property.Item>
<Property.Label>Run ID</Property.Label>
<Property.Value>
<CopyableText value={log.runId} copyValue={log.runId} asChild />
<LinkButton
to={runPath}
variant="tertiary/small"
shortcut={{ key: "v" }}
className="mt-2"
>
View full run
</LinkButton>
</Property.Value>
</Property.Item>
{runStatus && (
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<SimpleTooltip
button={<TaskRunStatusCombo status={runStatus} />}
content={descriptionForTaskRunStatus(runStatus)}
disableHoverableContent
className="mt-1"
/>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<CopyableText value={log.taskIdentifier} copyValue={log.taskIdentifier} asChild />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Level</Property.Label>
<Property.Value>
<LogLevel level={log.level} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Timestamp</Property.Label>
<Property.Value>
<DateTimeAccurate date={log.triggeredTimestamp} />
</Property.Value>
</Property.Item>
</Property.Table>
{/* Message */}
<div className="mb-6">
<div className="mb-6 mt-3">
<PacketDisplay
data={message}
dataType="application/json"
title="Message"
searchTerm={searchTerm}
wrap={true}
/>
</div>
@@ -262,222 +256,3 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri
</>
);
}
function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<RunContextData>();
// 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)}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organization.slug, project.slug, environment.slug, log.id, log.runId]);
const isLoading = fetcher.state === "loading";
const runData = fetcher.data?.run;
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
);
}
if (!runData) {
return (
<div className="flex flex-col items-center justify-center py-8">
<Paragraph className="text-text-dimmed">Run not found in database.</Paragraph>
</div>
);
}
return (
<div className="flex flex-col gap-4 py-3">
<Property.Table>
<Property.Item>
<Property.Label>Run ID</Property.Label>
<Property.Value>
<CopyableText value={runData.friendlyId} copyValue={runData.friendlyId} asChild />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<SimpleTooltip
button={<TaskRunStatusCombo status={runData.status as TaskRunStatus} />}
content={descriptionForTaskRunStatus(runData.status as TaskRunStatus)}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<CopyableText
value={runData.taskIdentifier}
copyValue={runData.taskIdentifier}
asChild
/>
</Property.Value>
</Property.Item>
{runData.rootRun && (
<Property.Item>
<Property.Label>Root and parent run</Property.Label>
<Property.Value>
<CopyableText
value={runData.rootRun.taskIdentifier}
copyValue={runData.rootRun.taskIdentifier}
asChild
/>
</Property.Value>
</Property.Item>
)}
{runData.batch && (
<Property.Item>
<Property.Label>Batch</Property.Label>
<Property.Value>
<CopyableText
value={runData.batch.friendlyId}
copyValue={runData.batch.friendlyId}
asChild
/>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
{runData.version ? (
environment.type === "DEVELOPMENT" ? (
<CopyableText value={runData.version} copyValue={runData.version} asChild />
) : (
<SimpleTooltip
button={
<TextLink
to={v3DeploymentVersionPath(
organization,
project,
environment,
runData.version
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
<CopyableText value={runData.version} copyValue={runData.version} asChild />
</TextLink>
}
content={"Jump to deployment"}
/>
)
) : (
<span className="flex items-center gap-1">
<span>Never started</span>
<InfoIconTooltip
content={"Runs get locked to the latest version when they start."}
contentClassName="normal-case tracking-normal"
/>
</span>
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Test run</Property.Label>
<Property.Value>
{runData.isTest ? <CheckIcon className="size-4 text-text-dimmed" /> : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Environment</Property.Label>
<Property.Value>
<EnvironmentCombo environment={environment} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue</Property.Label>
<Property.Value>
<div>Name: {runData.queue}</div>
<div>Concurrency key: {runData.concurrencyKey ? runData.concurrencyKey : ""}</div>
</Property.Value>
</Property.Item>
{runData.tags && runData.tags.length > 0 && (
<Property.Item>
<Property.Label>Tags</Property.Label>
<Property.Value>
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
{runData.tags.map((tag: string) => (
<RunTag
key={tag}
tag={tag}
to={v3RunsPath(organization, project, environment, { tags: [tag] })}
tooltip={`Filter runs by ${tag}`}
/>
))}
</div>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Machine</Property.Label>
<Property.Value className="-ml-0.5">
{runData.machinePreset ? (
<MachineLabelCombo preset={runData.machinePreset as MachinePresetName} />
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Run invocation cost</Property.Label>
<Property.Value>
{runData.baseCostInCents > 0
? formatCurrencyAccurate(runData.baseCostInCents / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Compute cost</Property.Label>
<Property.Value>
{runData.costInCents > 0 ? formatCurrencyAccurate(runData.costInCents / 100) : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Total cost</Property.Label>
<Property.Value>
{runData.costInCents > 0 || runData.baseCostInCents > 0
? formatCurrencyAccurate((runData.baseCostInCents + runData.costInCents) / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Usage duration</Property.Label>
<Property.Value>
{runData.usageDurationMs > 0
? formatDurationMilliseconds(runData.usageDurationMs, { style: "short" })
: ""}
</Property.Value>
</Property.Item>
</Property.Table>
</div>
);
}
@@ -0,0 +1,16 @@
import { cn } from "~/utils/cn";
import { getLevelColor } from "~/utils/logUtils";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
export function LogLevel({ level }: { level: LogEntry["level"] }) {
return (
<span
className={cn(
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
getLevelColor(level)
)}
>
{level}
</span>
);
}
@@ -16,6 +16,7 @@ import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
import { cn } from "~/utils/cn";
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
{ level: "TRACE", label: "Trace", color: "text-purple-400" },
{ level: "INFO", label: "Info", color: "text-blue-400" },
{ level: "WARN", label: "Warning", color: "text-warning" },
{ level: "ERROR", label: "Error", color: "text-error" },
@@ -33,6 +34,8 @@ function getLevelBadgeColor(level: LogLevel): string {
return "text-error bg-error/10 border-error/20";
case "WARN":
return "text-warning bg-warning/10 border-warning/20";
case "TRACE":
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
case "DEBUG":
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
case "INFO":
@@ -1,55 +1,49 @@
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { motion } from "framer-motion";
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";
import { useSearchParams } from "~/hooks/useSearchParam";
export function LogsSearchInput() {
const location = useOptimisticLocation();
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const { value, replace, del } = useSearchParams();
// Get initial search value from URL
const searchParams = new URLSearchParams(location.search);
const initialSearch = searchParams.get("search") ?? "";
const initialSearch = value("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") ?? "";
const urlSearch = value("search") ?? "";
if (urlSearch !== text && !isFocused) {
setText(urlSearch);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
}, [value, text, isFocused]);
const handleSubmit = useCallback(() => {
const params = new URLSearchParams(location.search);
if (text.trim()) {
params.set("search", text.trim());
replace({ search: text.trim() });
} else {
params.delete("search");
del("search");
}
// Reset cursor when searching
params.delete("cursor");
params.delete("direction");
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [text, location.pathname, location.search, navigate]);
}, [text, replace, del]);
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 handleClear = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setText("");
del(["search", "cursor", "direction"]);
},
[del]
);
return (
<div className="flex items-center gap-1">
@@ -71,7 +65,7 @@ export function LogsSearchInput() {
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
className={cn(isFocused && "placeholder:text-text-dimmed/70")}
className={cn("", isFocused && "placeholder:text-text-dimmed/70")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -86,22 +80,21 @@ export function LogsSearchInput() {
icon={<MagnifyingGlassIcon className="size-4" />}
accessory={
text.length > 0 ? (
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
<div className="-mr-1 flex items-center gap-1">
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
<button
type="button"
onClick={handleClear}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-3" />
</button>
</div>
) : undefined
}
/>
</motion.div>
{text.length > 0 && (
<button
type="button"
onClick={handleClear}
className="flex size-6 items-center justify-center rounded text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-4" />
</button>
)}
</div>
);
}
+15 -23
View File
@@ -2,16 +2,17 @@ import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/so
import { Link } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Button } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
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, highlightSearchText } from "~/utils/logUtils";
import { highlightSearchText } from "~/utils/logUtils";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { DateTimeAccurate } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
import { LogLevel } from "./LogLevel";
import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo";
import {
@@ -48,14 +49,14 @@ function getLevelBoxShadow(level: LogEntry["level"]): string {
return "inset 2px 0 0 0 rgb(234, 179, 8)";
case "INFO":
return "inset 2px 0 0 0 rgb(59, 130, 246)";
case "TRACE":
return "inset 2px 0 0 0 rgb(168, 85, 247)";
case "DEBUG":
default:
return "none";
}
}
export function LogsTable({
logs,
searchTerm,
@@ -162,7 +163,7 @@ export function LogsTable({
boxShadow: getLevelBoxShadow(log.level),
}}
>
<DateTimeAccurate date={log.triggeredTimestamp} />
<DateTimeAccurate date={log.triggeredTimestamp} hour12={false} />
</TableCell>
<TableCell className="min-w-24">
<TruncatedCopyableValue value={log.runId} />
@@ -171,14 +172,7 @@ export function LogsTable({
<span className="font-mono text-xs">{log.taskIdentifier}</span>
</TableCell>
<TableCell onClick={handleRowClick} hasAction>
<span
className={cn(
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
getLevelColor(log.level)
)}
>
{log.level}
</span>
<LogLevel level={log.level} />
</TableCell>
<TableCell className="max-w-0 truncate" onClick={handleRowClick} hasAction>
<span className="block truncate font-mono text-xs" title={log.message}>
@@ -188,11 +182,13 @@ export function LogsTable({
<TableCellMenu
className="pl-32"
hiddenButtons={
<Link to={runPath} target="_blank" rel="noopener noreferrer">
<Button variant="minimal/small" TrailingIcon={ArrowTopRightOnSquareIcon}>
View run
</Button>
</Link>
<LinkButton
to={runPath}
variant="minimal/small"
TrailingIcon={ArrowTopRightOnSquareIcon}
>
View run
</LinkButton>
}
/>
</TableRow>
@@ -233,11 +229,7 @@ function BlankState({ isLoading, onRefresh }: { isLoading?: boolean; onRefresh?:
No logs match your filters. Try refreshing or modifying your filters.
</Paragraph>
<div className="flex items-center gap-2">
<Button
LeadingIcon={ArrowPathIcon}
variant="tertiary/medium"
onClick={handleRefresh}
>
<Button LeadingIcon={ArrowPathIcon} variant="tertiary/medium" onClick={handleRefresh}>
Refresh
</Button>
</div>
@@ -433,18 +433,7 @@ export function SideMenu({
data-action="deployments"
isCollapsed={isCollapsed}
/>
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
<SideMenuItem
name="Logs"
icon={LogsIcon}
activeIconColor="text-logs"
inactiveIconColor="text-logs"
to={v3LogsPath(organization, project, environment)}
data-action="logs"
badge={<AlphaBadge />}
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Test"
icon={BeakerIcon}
@@ -467,6 +456,18 @@ export function SideMenu({
)}
onCollapseToggle={handleSectionToggle("metrics")}
>
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
<SideMenuItem
name="Logs"
icon={LogsIcon}
activeIconColor="text-logs"
inactiveIconColor="text-logs"
to={v3LogsPath(organization, project, environment)}
data-action="logs"
badge={<AlphaBadge />}
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Query"
icon={TableCellsIcon}
@@ -196,7 +196,11 @@ export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: Date
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
: formatTimeOnly(realDate, userTimeZone, locales, hour12);
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
// Helper function to check if two dates are on the same day
@@ -270,14 +274,18 @@ const DateTimeAccurateInner = ({
return hideDate
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: realPrevDate
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
}, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]);
if (!showTooltip)
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
const tooltipContent = (
<TooltipContent
@@ -290,7 +298,11 @@ const DateTimeAccurateInner = ({
return (
<SimpleTooltip
button={<span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>}
button={
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
}
content={tooltipContent}
side="right"
asChild={true}
@@ -326,9 +338,13 @@ function formatDateTimeAccurate(
locales: string[],
hour12: boolean = true
): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
const datePart = new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
timeZone,
}).format(date);
const timePart = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
second: "numeric",
@@ -338,7 +354,7 @@ function formatDateTimeAccurate(
hour12,
}).format(date);
return formattedDateTime;
return `${datePart} ${timePart}`;
}
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
@@ -347,7 +363,11 @@ export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
const realDate = typeof date === "string" ? new Date(date) : date;
const formattedDateTime = formatDateTimeShort(realDate, userTimeZone, locales, hour12);
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
function formatDateTimeShort(
@@ -12,11 +12,13 @@ export function PacketDisplay({
dataType,
title,
searchTerm,
wrap,
}: {
data: string;
dataType: string;
title: string;
searchTerm?: string;
wrap?: boolean;
}) {
switch (dataType) {
case "application/store": {
@@ -54,6 +56,7 @@ export function PacketDisplay({
showLineNumbers={false}
showTextWrapping
searchTerm={searchTerm}
wrap={wrap}
/>
);
}
@@ -67,6 +70,7 @@ export function PacketDisplay({
showLineNumbers={false}
showTextWrapping
searchTerm={searchTerm}
wrap={wrap}
/>
);
}
@@ -219,7 +219,7 @@ export function timeFilterFromTo(props: {
from?: string | number;
to?: string | number;
defaultPeriod: string;
}): { from: Date; to: Date } {
}): { from: Date; to: Date; isDefault: boolean } {
const time = timeFilters(props);
const periodMs = time.period ? parse(time.period) : undefined;
@@ -228,6 +228,7 @@ export function timeFilterFromTo(props: {
return {
from: new Date(Date.now() - periodMs),
to: new Date(),
isDefault: time.isDefault,
};
}
@@ -235,6 +236,7 @@ export function timeFilterFromTo(props: {
return {
from: time.from,
to: time.to,
isDefault: time.isDefault,
};
}
@@ -242,6 +244,7 @@ export function timeFilterFromTo(props: {
return {
from: time.from,
to: new Date(),
isDefault: time.isDefault,
};
}
@@ -249,6 +252,7 @@ export function timeFilterFromTo(props: {
return {
from: new Date(Date.now() - defaultPeriodMs),
to: time.to ?? new Date(),
isDefault: time.isDefault,
};
}
+7 -6
View File
@@ -1180,7 +1180,7 @@ 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)
// Logs Query Settings
CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_000_000_000),
CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT: z.coerce
.number()
@@ -1190,14 +1190,15 @@ const EnvironmentSchema = z
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(64_000_000),
CLICKHOUSE_LOGS_DETAIL_MAX_THREADS: z.coerce.number().int().default(2),
CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME: z.coerce.number().int().default(60),
// Query feature flag
QUERY_FEATURE_ENABLED: z.string().default("1"),
// Logs page ClickHouse URL (for logs queries)
LOGS_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
// Query page ClickHouse limits (for TSQL queries)
QUERY_CLICKHOUSE_URL: z
.string()
@@ -1,13 +1,11 @@
import { z } from "zod";
import { type ClickHouse } from "@internal/clickhouse";
import {
type PrismaClientOrTransaction,
} from "@trigger.dev/database";
import { type ClickHouse, type WhereCondition } from "@internal/clickhouse";
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
import { EVENT_STORE_TYPES, getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
import parseDuration from "parse-duration";
import { type Direction } from "~/components/ListPagination";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { timeFilterFromTo, timeFilters } from "~/components/runs/v3/SharedFilters";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
@@ -28,14 +26,9 @@ type ErrorAttributes = {
};
function escapeClickHouseString(val: string): string {
return val
.replace(/\\/g, "\\\\")
.replace(/\//g, "\\/")
.replace(/%/g, "\\%")
.replace(/_/g, "\\_");
return val.replace(/\\/g, "\\\\").replace(/\//g, "\\/").replace(/%/g, "\\%").replace(/_/g, "\\_");
}
export type LogsListOptions = {
userId?: string;
projectId: string;
@@ -115,10 +108,12 @@ function decodeCursor(cursor: string): LogCursor | null {
// Convert display level to ClickHouse kinds and statuses
function levelToKindsAndStatuses(level: LogLevel): { kinds?: string[]; statuses?: string[] } {
switch (level) {
case "TRACE":
return { kinds: ["SPAN"] };
case "DEBUG":
return { kinds: ["LOG_DEBUG"] };
case "INFO":
return { kinds: ["LOG_INFO", "LOG_LOG", "SPAN"] };
return { kinds: ["LOG_INFO", "LOG_LOG"] };
case "WARN":
return { kinds: ["LOG_WARN"] };
case "ERROR":
@@ -153,24 +148,16 @@ export class LogsListPresenter extends BasePresenter {
retentionLimitDays,
}: LogsListOptions
) {
const time = timeFilters({
const time = timeFilterFromTo({
period,
from,
to,
defaultPeriod,
defaultPeriod: defaultPeriod ?? "1h",
});
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();
}
}
// Apply retention limit if provided
let wasClampedByRetention = false;
if (retentionLimitDays !== undefined && effectiveFrom) {
@@ -236,6 +223,11 @@ export class LogsListPresenter extends BasePresenter {
const queryBuilder = this.clickhouse.taskEventsSearch.logsListQueryBuilder();
// This should be removed once we clear the old inserts, 30 DAYS, the materialized view excludes events without trace_id)
queryBuilder.where("trace_id != ''", {
environmentId,
});
queryBuilder.where("environment_id = {environmentId: String}", {
environmentId,
});
@@ -245,11 +237,10 @@ export class LogsListPresenter extends BasePresenter {
});
queryBuilder.where("project_id = {projectId: String}", { projectId });
if (effectiveFrom) {
queryBuilder.where("triggered_timestamp >= {triggeredAtStart: DateTime64(3)}", {
triggeredAtStart: convertDateToClickhouseDateTime(effectiveFrom),
});
queryBuilder.where("triggered_timestamp >= {triggeredAtStart: DateTime64(3)}", {
triggeredAtStart: convertDateToClickhouseDateTime(effectiveFrom),
});
}
if (effectiveTo) {
@@ -278,50 +269,43 @@ export class LogsListPresenter extends BasePresenter {
queryBuilder.where(
"(lower(message) like {searchPattern: String} OR lower(attributes_text) like {searchPattern: String})",
{
searchPattern: `%${searchTerm}%`
searchPattern: `%${searchTerm}%`,
}
);
}
if (levels && levels.length > 0) {
const conditions: string[] = [];
const params: Record<string, string[]> = {};
const conditions: WhereCondition[] = [];
for (const level of levels) {
const filter = levelToKindsAndStatuses(level);
const levelConditions: string[] = [];
for (let i = 0; i < levels.length; i++) {
const filter = levelToKindsAndStatuses(levels[i]);
if (filter.kinds && filter.kinds.length > 0) {
const kindsKey = `kinds_${level}`;
let kindCondition = `kind IN {${kindsKey}: Array(String)}`;
kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`;
params["excluded_statuses"] = ["ERROR", "CANCELLED"];
levelConditions.push(kindCondition);
params[kindsKey] = filter.kinds;
conditions.push({
clause: `kind IN {kinds_${i}: Array(String)} AND status NOT IN {excluded_statuses: Array(String)}`,
params: {
[`kinds_${i}`]: filter.kinds,
excluded_statuses: ["ERROR", "CANCELLED"],
},
});
}
if (filter.statuses && filter.statuses.length > 0) {
const statusesKey = `statuses_${level}`;
levelConditions.push(`status IN {${statusesKey}: Array(String)}`);
params[statusesKey] = filter.statuses;
}
if (levelConditions.length > 0) {
conditions.push(`(${levelConditions.join(" OR ")})`);
conditions.push({
clause: `status IN {statuses_${i}: Array(String)}`,
params: { [`statuses_${i}`]: filter.statuses },
});
}
}
if (conditions.length > 0) {
queryBuilder.where(`(${conditions.join(" OR ")})`, params);
}
queryBuilder.whereOr(conditions);
}
// Cursor pagination using explicit lexicographic comparison
// Must mirror the ORDER BY columns: (organization_id, environment_id, triggered_timestamp, trace_id)
// Cursor-based pagination using lexicographic comparison on (triggered_timestamp, trace_id).
// Since ORDER BY is DESC, "next page" means rows that sort *after* the cursor, i.e. less-than.
// The OR handles the tiebreaker: rows with an earlier timestamp always qualify, and rows
// with the *same* timestamp only qualify if their trace_id is also smaller.
// Equivalent to: WHERE (triggered_timestamp, trace_id) < (cursor.triggered_timestamp, cursor.trace_id)
const decodedCursor = cursor ? decodeCursor(cursor) : null;
if (decodedCursor) {
queryBuilder.where(
@@ -423,10 +407,13 @@ export class LogsListPresenter extends BasePresenter {
hasFilters,
hasAnyLogs: transformedLogs.length > 0,
searchTerm: search,
retention: retentionLimitDays !== undefined ? {
limitDays: retentionLimitDays,
wasClamped: wasClampedByRetention,
} : undefined,
retention:
retentionLimitDays !== undefined
? {
limitDays: retentionLimitDays,
wasClamped: wasClampedByRetention,
}
: undefined,
};
}
}
@@ -16,7 +16,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { LogsListPresenter, LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import type { LogLevel } from "~/utils/logUtils";
import { $replica, prisma } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { logsClickhouseClient } from "~/services/clickhouseInstance.server";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
@@ -40,7 +40,7 @@ import { Button } from "~/components/primitives/Buttons";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
// Valid log levels for filtering
const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
@@ -134,7 +134,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const plan = await getCurrentPlan(project.organizationId);
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
const presenter = new LogsListPresenter($replica, clickhouseClient);
const presenter = new LogsListPresenter($replica, logsClickhouseClient);
const listPromise = presenter
.call(project.organizationId, environment.id, {
@@ -322,7 +322,10 @@ function LogsList({
const [nextCursor, setNextCursor] = useState<string | undefined>(list.pagination.next);
// Selected log state - managed locally to avoid triggering navigation
const [selectedLogId, setSelectedLogId] = useState<string | undefined>();
const [selectedLogId, setSelectedLogId] = useState<string | undefined>(() => {
const params = new URLSearchParams(location.search);
return params.get("log") ?? undefined;
});
// Track which filter state (search params) the current fetcher request corresponds to
const fetcherFilterStateRef = useRef<string>(location.search);
@@ -333,8 +336,9 @@ function LogsList({
useEffect(() => {
setAccumulatedLogs([]);
setNextCursor(undefined);
// Close side panel when filters change to avoid showing a log that's no longer visible
setSelectedLogId(undefined);
// Preserve log selection from URL param, clear if not present
const params = new URLSearchParams(location.search);
setSelectedLogId(params.get("log") ?? undefined);
}, [location.search]);
// Populate accumulated logs when new data arrives
@@ -1,202 +0,0 @@
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),
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<typeof RunContextSchema>;
// 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;
}
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: validatedRun,
});
};
@@ -1,13 +1,14 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { logsClickhouseClient } 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";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import type { TaskRunStatus } from "@trigger.dev/database";
const LogIdParamsSchema = z.object({
organizationSlug: z.string(),
@@ -42,7 +43,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const [traceId, spanId, , startTime] = parts;
const presenter = new LogDetailPresenter($replica, clickhouseClient);
const presenter = new LogDetailPresenter($replica, logsClickhouseClient);
let result;
try {
@@ -65,5 +66,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Log not found", { status: 404 });
}
return typedjson(result);
// Look up the run status from Postgres
let runStatus: TaskRunStatus | undefined;
if (result.runId) {
const run = await $replica.taskRun.findFirst({
select: { status: true },
where: {
friendlyId: result.runId,
runtimeEnvironmentId: environment.id,
},
});
runStatus = run?.status;
}
return typedjson({ ...result, runStatus });
};
@@ -6,11 +6,11 @@ import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { LogsListPresenter, type LogLevel, LogsListOptionsSchema } from "~/presenters/v3/LogsListPresenter.server";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { logsClickhouseClient } from "~/services/clickhouseInstance.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
// Valid log levels for filtering
const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
@@ -69,7 +69,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
retentionLimitDays,
}) as any; // Validated by LogsListOptionsSchema at runtime
const presenter = new LogsListPresenter($replica, clickhouseClient);
const presenter = new LogsListPresenter($replica, logsClickhouseClient);
const result = await presenter.call(project.organizationId, environment.id, options);
return json({
@@ -12,29 +12,6 @@ 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",
@@ -47,12 +24,53 @@ function initializeClickhouseClient() {
request: true,
},
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
logsQuerySettings,
});
return clickhouse;
}
export const logsClickhouseClient = singleton(
"logsClickhouseClient",
initializeLogsClickhouseClient
);
function initializeLogsClickhouseClient() {
if (!env.LOGS_CLICKHOUSE_URL) {
throw new Error("LOGS_CLICKHOUSE_URL is not set");
}
const url = new URL(env.LOGS_CLICKHOUSE_URL);
// Remove secure param
url.searchParams.delete("secure");
return new ClickHouse({
url: url.toString(),
name: "logs-clickhouse",
keepAlive: {
enabled: env.CLICKHOUSE_KEEP_ALIVE_ENABLED === "1",
idleSocketTtl: env.CLICKHOUSE_KEEP_ALIVE_IDLE_SOCKET_TTL_MS,
},
logLevel: env.CLICKHOUSE_LOG_LEVEL,
compression: {
request: true,
},
maxOpenConnections: env.CLICKHOUSE_MAX_OPEN_CONNECTIONS,
clickhouseSettings: {
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,
}),
},
});
}
export const queryClickhouseClient = singleton(
"queryClickhouseClient",
initializeQueryClickhouseClient
+5 -2
View File
@@ -1,10 +1,10 @@
import { createElement, Fragment, type ReactNode } from "react";
import { z } from "zod";
export const LogLevelSchema = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]);
export const LogLevelSchema = z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]);
export type LogLevel = z.infer<typeof LogLevelSchema>;
export const validLogLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR",];
export const validLogLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
// Default styles for search highlighting
const DEFAULT_HIGHLIGHT_STYLES: React.CSSProperties = {
@@ -87,6 +87,7 @@ export function kindToLevel(kind: string, status: string): LogLevel {
case "LOG_LOG":
return "INFO"; // Changed from "LOG"
case "SPAN":
return "TRACE";
case "ANCESTOR_OVERRIDE":
case "SPAN_EVENT":
default:
@@ -101,6 +102,8 @@ export function getLevelColor(level: LogLevel): string {
return "text-error bg-error/10 border-error/20";
case "WARN":
return "text-warning bg-warning/10 border-warning/20";
case "TRACE":
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
case "DEBUG":
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
case "INFO":
+1 -1
View File
@@ -166,7 +166,7 @@ const deployments = colors.green[500];
const concurrency = colors.amber[500];
const limits = colors.purple[500];
const regions = colors.green[500];
const logs = colors.blue[500];
const logs = colors.pink[500];
const tests = colors.lime[500];
const apiKeys = colors.amber[500];
const environmentVariables = colors.pink[500];
@@ -0,0 +1,62 @@
-- +goose Up
-- We drop the existing MV and recreate it with the new filter condition
DROP VIEW IF EXISTS trigger_dev.task_events_search_mv_v1;
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.task_events_search_mv_v1
TO trigger_dev.task_events_search_v1 AS
SELECT
environment_id,
organization_id,
project_id,
trace_id,
span_id,
run_id,
task_identifier,
start_time,
inserted_at,
message,
kind,
status,
duration,
parent_span_id,
attributes_text,
fromUnixTimestamp64Nano(toUnixTimestamp64Nano(start_time) + toInt64(duration)) AS triggered_timestamp
FROM trigger_dev.task_events_v2
WHERE
trace_id != '' -- New condition added here
AND kind != 'DEBUG_EVENT'
AND status != 'PARTIAL'
AND NOT (kind = 'SPAN_EVENT' AND attributes_text = '{}')
AND kind != 'ANCESTOR_OVERRIDE'
AND message != 'trigger.dev/start';
-- +goose Down
-- In the down migration, we revert to the previous filter set
DROP VIEW IF EXISTS trigger_dev.task_events_search_mv_v1;
CREATE MATERIALIZED VIEW IF NOT EXISTS trigger_dev.task_events_search_mv_v1
TO trigger_dev.task_events_search_v1 AS
SELECT
environment_id,
organization_id,
project_id,
trace_id,
span_id,
run_id,
task_identifier,
start_time,
inserted_at,
message,
kind,
status,
duration,
parent_span_id,
attributes_text,
fromUnixTimestamp64Nano(toUnixTimestamp64Nano(start_time) + toInt64(duration)) AS triggered_timestamp
FROM trigger_dev.task_events_v2
WHERE
kind != 'DEBUG_EVENT'
AND status != 'PARTIAL'
AND NOT (kind = 'SPAN_EVENT' AND attributes_text = '{}')
AND kind != 'ANCESTOR_OVERRIDE'
AND message != 'trigger.dev/start';
@@ -0,0 +1,22 @@
-- +goose Up
-- These indexes are not used in any WHERE clause.
-- idx_duration: duration is only written/read as a column, never filtered on.
-- idx_attributes_text: search queries use the task_events_search_v1 table instead.
ALTER TABLE trigger_dev.task_events_v2
DROP INDEX IF EXISTS idx_duration;
ALTER TABLE trigger_dev.task_events_v2
DROP INDEX IF EXISTS idx_attributes_text;
-- +goose Down
ALTER TABLE trigger_dev.task_events_v2
ADD INDEX IF NOT EXISTS idx_duration duration
TYPE minmax
GRANULARITY 1;
ALTER TABLE trigger_dev.task_events_v2
ADD INDEX IF NOT EXISTS idx_attributes_text attributes_text
TYPE tokenbf_v1(32768, 3, 0)
GRANULARITY 8;
@@ -4,6 +4,11 @@ import { ClickHouseSettings } from "@clickhouse/client";
export type QueryParamValue = string | number | boolean | Array<string | number | boolean> | null;
export type QueryParams = Record<string, QueryParamValue>;
export type WhereCondition = {
clause: string;
params?: QueryParams;
};
export class ClickhouseQueryBuilder<TOutput> {
private name: string;
private baseQuery: string;
@@ -45,6 +50,20 @@ export class ClickhouseQueryBuilder<TOutput> {
return this;
}
whereOr(conditions: WhereCondition[]): this {
if (conditions.length === 0) {
return this;
}
const combinedClause = conditions.map((c) => `(${c.clause})`).join(" OR ");
this.whereClauses.push(`(${combinedClause})`);
for (const condition of conditions) {
if (condition.params) {
Object.assign(this.params, condition.params);
}
}
return this;
}
groupBy(clause: string): this {
this.groupByClause = clause;
return this;
@@ -153,6 +172,20 @@ export class ClickhouseQueryFastBuilder<TOutput extends Record<string, any>> {
return this;
}
whereOr(conditions: WhereCondition[]): this {
if (conditions.length === 0) {
return this;
}
const combinedClause = conditions.map((c) => `(${c.clause})`).join(" OR ");
this.whereClauses.push(`(${combinedClause})`);
for (const condition of conditions) {
if (condition.params) {
Object.assign(this.params, condition.params);
}
}
return this;
}
groupBy(clause: string): this {
this.groupByClause = clause;
return this;
+2 -10
View File
@@ -61,11 +61,6 @@ export type { OutputColumnMetadata } from "@internal/tsql";
// Errors
export { QueryError } from "./client/errors.js";
export type LogsQuerySettings = {
list?: ClickHouseSettings;
detail?: ClickHouseSettings;
};
export type ClickhouseCommonConfig = {
keepAlive?: {
enabled?: boolean;
@@ -80,7 +75,6 @@ export type ClickhouseCommonConfig = {
response?: boolean;
};
maxOpenConnections?: number;
logsQuerySettings?: LogsQuerySettings;
};
export type ClickHouseConfig =
@@ -104,11 +98,9 @@ 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);
@@ -220,13 +212,13 @@ export class ClickHouse {
traceSummaryQueryBuilder: getTraceSummaryQueryBuilderV2(this.reader),
traceDetailedSummaryQueryBuilder: getTraceDetailedSummaryQueryBuilderV2(this.reader),
spanDetailsQueryBuilder: getSpanDetailsQueryBuilderV2(this.reader),
logDetailQueryBuilder: getLogDetailQueryBuilderV2(this.reader, this.logsQuerySettings?.detail),
logDetailQueryBuilder: getLogDetailQueryBuilderV2(this.reader),
};
}
get taskEventsSearch() {
return {
logsListQueryBuilder: getLogsSearchListQueryBuilder(this.reader, this.logsQuerySettings?.list),
logsListQueryBuilder: getLogsSearchListQueryBuilder(this.reader),
};
}
}
@@ -256,10 +256,7 @@ export const LogsSearchListResult = z.object({
export type LogsSearchListResult = z.output<typeof LogsSearchListResult>;
export function getLogsSearchListQueryBuilder(
ch: ClickhouseReader,
settings?: ClickHouseSettings
) {
export function getLogsSearchListQueryBuilder(ch: ClickhouseReader) {
return ch.queryBuilderFast<LogsSearchListResult>({
name: "getLogsSearchList",
table: "trigger_dev.task_events_search_v1",
@@ -280,7 +277,6 @@ export function getLogsSearchListQueryBuilder(
"attributes_text",
"triggered_timestamp",
],
settings,
});
}
@@ -304,7 +300,7 @@ export const LogDetailV2Result = z.object({
export type LogDetailV2Result = z.output<typeof LogDetailV2Result>;
export function getLogDetailQueryBuilderV2(ch: ClickhouseReader, settings?: ClickHouseSettings) {
export function getLogDetailQueryBuilderV2(ch: ClickhouseReader) {
return ch.queryBuilderFast<LogDetailV2Result>({
name: "getLogDetail",
table: "trigger_dev.task_events_v2",
@@ -324,6 +320,5 @@ export function getLogDetailQueryBuilderV2(ch: ClickhouseReader, settings?: Clic
"duration",
"attributes_text",
],
settings,
});
}