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:
@@ -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>) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -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
|
||||
|
||||
-202
@@ -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,
|
||||
});
|
||||
};
|
||||
+17
-3
@@ -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 });
|
||||
};
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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];
|
||||
|
||||
+62
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user