-
-
- {getKindLabel(log.kind)}
-
-
- {log.level}
-
-
+
+
+ {log.level}
+
@@ -185,8 +185,8 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
-
- View Full Run
+
+ View full run
diff --git a/apps/webapp/app/components/logs/LogsLevelFilter.tsx b/apps/webapp/app/components/logs/LogsLevelFilter.tsx
index 4ec7d95730..8c2abf64f2 100644
--- a/apps/webapp/app/components/logs/LogsLevelFilter.tsx
+++ b/apps/webapp/app/components/logs/LogsLevelFilter.tsx
@@ -1,9 +1,8 @@
import * as Ariakit from "@ariakit/react";
-import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
-import { type ReactNode, useMemo } from "react";
+import { IconListTree } from "@tabler/icons-react";
+import { type ReactNode } from "react";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
- ComboBox,
SelectItem,
SelectList,
SelectPopover,
@@ -12,24 +11,20 @@ import {
shortcutFromIndex,
} from "~/components/primitives/Select";
import { useSearchParams } from "~/hooks/useSearchParam";
-import { FilterMenuProvider, appliedSummary } from "~/components/runs/v3/SharedFilters";
+import { appliedSummary } from "~/components/runs/v3/SharedFilters";
import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
import { cn } from "~/utils/cn";
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
- { level: "ERROR", label: "Error", color: "text-error" },
- { level: "WARN", label: "Warning", color: "text-warning" },
{ level: "INFO", label: "Info", color: "text-blue-400" },
- { level: "CANCELLED", label: "Cancelled", color: "text-charcoal-400" },
+ { level: "WARN", label: "Warning", color: "text-warning" },
+ { level: "ERROR", label: "Error", color: "text-error" },
{ level: "DEBUG", label: "Debug", color: "text-charcoal-400" },
- { level: "TRACE", label: "Trace", color: "text-charcoal-500" },
];
-function getAvailableLevels(showDebug: boolean): typeof allLogLevels {
- if (showDebug) {
- return allLogLevels;
- }
- return allLogLevels.filter((level) => level.level !== "DEBUG");
+// In the future we might add other levels or change which are available
+function getAvailableLevels(): typeof allLogLevels {
+ return allLogLevels;
}
function getLevelBadgeColor(level: LogLevel): string {
@@ -42,10 +37,6 @@ function getLevelBadgeColor(level: LogLevel): string {
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
case "INFO":
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
- case "TRACE":
- return "text-charcoal-500 bg-charcoal-800 border-charcoal-700";
- case "CANCELLED":
- return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
default:
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
}
@@ -53,81 +44,50 @@ function getLevelBadgeColor(level: LogLevel): string {
const shortcut = { key: "l" };
-export function LogsLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
+export function LogsLevelFilter() {
const { values } = useSearchParams();
const selectedLevels = values("levels");
const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== "");
if (hasLevels) {
- return
;
+ return
;
}
return (
-
- {(search, setSearch) => (
- }
- variant="secondary/small"
- shortcut={shortcut}
- tooltipTitle="Filter by level"
- >
- Level
-
- }
- searchValue={search}
- clearSearchValue={() => setSearch("")}
- showDebug={showDebug}
- />
- )}
-
+
}
+ variant="secondary/small"
+ shortcut={shortcut}
+ tooltipTitle="Filter by level"
+ >
+ Level
+
+ }
+ />
);
}
function LevelDropdown({
trigger,
- clearSearchValue,
- searchValue,
- onClose,
- showDebug = false,
}: {
trigger: ReactNode;
- clearSearchValue: () => void;
- searchValue: string;
- onClose?: () => void;
- showDebug?: boolean;
}) {
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
- clearSearchValue();
replace({ levels: values, cursor: undefined, direction: undefined });
};
- const availableLevels = getAvailableLevels(showDebug);
- const filtered = useMemo(() => {
- return availableLevels.filter((item) =>
- item.label.toLowerCase().includes(searchValue.toLowerCase())
- );
- }, [searchValue, availableLevels]);
+ const availableLevels = getAvailableLevels();
return (
{trigger}
- {
- if (onClose) {
- onClose();
- return false;
- }
- return true;
- }}
- >
-
+
- {filtered.map((item, index) => (
+ {availableLevels.map((item, index) => (
- {(search, setSearch) => (
- }>
- }
- value={appliedSummary(levels)}
- onRemove={() => del(["levels", "cursor", "direction"])}
- variant="secondary/small"
- />
-
- }
- searchValue={search}
- clearSearchValue={() => setSearch("")}
- showDebug={showDebug}
- />
- )}
-
+ }>
+ }
+ value={appliedSummary(levels)}
+ onRemove={() => del(["levels", "cursor", "direction"])}
+ variant="secondary/small"
+ />
+
+ }
+ />
);
}
diff --git a/apps/webapp/app/components/logs/LogsRunIdFilter.tsx b/apps/webapp/app/components/logs/LogsRunIdFilter.tsx
index 5c23d1a192..857e623d7c 100644
--- a/apps/webapp/app/components/logs/LogsRunIdFilter.tsx
+++ b/apps/webapp/app/components/logs/LogsRunIdFilter.tsx
@@ -14,7 +14,7 @@ import {
import { useSearchParams } from "~/hooks/useSearchParam";
import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
-const shortcut = { key: "r" };
+const shortcut = { key: "i" };
export function LogsRunIdFilter() {
const { value } = useSearchParams();
diff --git a/apps/webapp/app/components/logs/LogsSearchInput.tsx b/apps/webapp/app/components/logs/LogsSearchInput.tsx
index 41871722b9..fd539f66ae 100644
--- a/apps/webapp/app/components/logs/LogsSearchInput.tsx
+++ b/apps/webapp/app/components/logs/LogsSearchInput.tsx
@@ -1,5 +1,6 @@
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";
@@ -52,7 +53,16 @@ export function LogsSearchInput() {
return (
-
+ 0 ? "24rem" : "auto" }}
+ transition={{
+ type: "spring",
+ stiffness: 300,
+ damping: 30,
+ }}
+ className="relative h-6 min-w-52"
+ >
-
+
{text.length > 0 && (
void;
};
-// Left border color for error highlighting
-function getLevelBorderColor(level: LogEntry["level"]): string {
+// Inner shadow for level highlighting (better scroll performance than border-l)
+function getLevelBoxShadow(level: LogEntry["level"]): string {
switch (level) {
case "ERROR":
- return "border-l-error";
+ return "inset 2px 0 0 0 rgb(239, 68, 68)";
case "WARN":
- return "border-l-warning";
+ return "inset 2px 0 0 0 rgb(234, 179, 8)";
case "INFO":
- return "border-l-blue-500";
- case "CANCELLED":
- return "border-l-charcoal-600";
+ return "inset 2px 0 0 0 rgb(59, 130, 246)";
case "DEBUG":
- case "TRACE":
default:
- return "border-l-transparent hover:border-l-charcoal-800";
+ return "none";
}
}
+
export function LogsTable({
logs,
searchTerm,
@@ -112,14 +112,19 @@ export function LogsTable({
}, [hasMore, isLoadingMore, onLoadMore]);
return (
-
-
+
+
Time
Run
Task
- Level
+ }
+ >
+ Level
+
Message
@@ -143,8 +148,7 @@ export function LogsTable({
@@ -180,12 +187,11 @@ export function LogsTable({
+
+
+ View run
+
+
}
/>
@@ -196,12 +202,23 @@ export function LogsTable({
{/* Infinite scroll trigger */}
{hasMore && logs.length > 0 && (
-
- {showLoadMoreSpinner && (
-
- Loading more…
-
- )}
+
+ )}
+ {/* Show all logs message */}
+ {!hasMore && logs.length > 0 && (
+
+
+ Showing all {logs.length} logs
+
)}
diff --git a/apps/webapp/app/components/logs/LogsTaskFilter.tsx b/apps/webapp/app/components/logs/LogsTaskFilter.tsx
new file mode 100644
index 0000000000..3f95e07412
--- /dev/null
+++ b/apps/webapp/app/components/logs/LogsTaskFilter.tsx
@@ -0,0 +1,144 @@
+import type { TaskTriggerSource } from "@trigger.dev/database";
+import type { ReactNode } from "react";
+import { useMemo } from "react";
+import * as Ariakit from "@ariakit/react";
+import {
+ ComboBox,
+ SelectItem,
+ SelectList,
+ SelectPopover,
+ SelectProvider,
+ SelectTrigger,
+} from "~/components/primitives/Select";
+import { useSearchParams } from "~/hooks/useSearchParam";
+import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
+import { TaskIcon } from "~/assets/icons/TaskIcon";
+import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
+import { AppliedFilter } from "~/components/primitives/AppliedFilter";
+
+const shortcut = { key: "t" };
+
+type TaskOption = {
+ slug: string;
+ triggerSource: TaskTriggerSource;
+};
+
+interface LogsTaskFilterProps {
+ possibleTasks: TaskOption[];
+}
+
+export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) {
+ const { values, replace, del } = useSearchParams();
+ const selectedTasks = values("tasks");
+
+ if (selectedTasks.length === 0 || selectedTasks.every((v) => v === "")) {
+ return (
+
+ {(search, setSearch) => (
+ }
+ variant="secondary/small"
+ shortcut={shortcut}
+ tooltipTitle="Filter by task"
+ >
+ Tasks
+
+ }
+ searchValue={search}
+ clearSearchValue={() => setSearch("")}
+ possibleTasks={possibleTasks}
+ />
+ )}
+
+ );
+ }
+
+ return (
+
+ {(search, setSearch) => (
+ }>
+ }
+ value={appliedSummary(
+ selectedTasks.map((v) => {
+ const task = possibleTasks.find((task) => task.slug === v);
+ return task ? task.slug : v;
+ })
+ )}
+ onRemove={() => del(["tasks", "cursor", "direction"])}
+ variant="secondary/small"
+ />
+
+ }
+ searchValue={search}
+ clearSearchValue={() => setSearch("")}
+ possibleTasks={possibleTasks}
+ />
+ )}
+
+ );
+}
+
+function TasksDropdown({
+ trigger,
+ clearSearchValue,
+ searchValue,
+ onClose,
+ possibleTasks,
+}: {
+ trigger: ReactNode;
+ clearSearchValue: () => void;
+ searchValue: string;
+ onClose?: () => void;
+ possibleTasks: TaskOption[];
+}) {
+ const { values, replace } = useSearchParams();
+
+ const handleChange = (values: string[]) => {
+ clearSearchValue();
+ replace({ tasks: values, cursor: undefined, direction: undefined });
+ };
+
+ const filtered = useMemo(() => {
+ return possibleTasks.filter((item) => {
+ return item.slug.toLowerCase().includes(searchValue.toLowerCase());
+ });
+ }, [searchValue, possibleTasks]);
+
+ return (
+
+ {trigger}
+ {
+ if (onClose) {
+ onClose();
+ return false;
+ }
+
+ return true;
+ }}
+ >
+
+
+ {filtered.map((item, index) => (
+
+ }
+ >
+ {item.slug}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx
index 5b28ddbc5e..4db8179c5a 100644
--- a/apps/webapp/app/components/primitives/Table.tsx
+++ b/apps/webapp/app/components/primitives/Table.tsx
@@ -64,18 +64,30 @@ type TableProps = {
className?: string;
children: ReactNode;
fullWidth?: boolean;
+ showTopBorder?: boolean;
};
// Add TableContext
const TableContext = createContext<{ variant: TableVariant }>({ variant: "dimmed" });
export const Table = forwardRef
(
- ({ className, containerClassName, children, fullWidth, variant = "dimmed" }, ref) => {
+ (
+ {
+ className,
+ containerClassName,
+ children,
+ fullWidth,
+ variant = "dimmed",
+ showTopBorder = true,
+ },
+ ref
+ ) => {
return (
(
@@ -246,6 +259,7 @@ export const TableCell = forwardRef
(
isSticky = false,
isSelected,
isTabbableCell = false,
+ style,
},
ref
) => {
@@ -291,6 +305,7 @@ export const TableCell = forwardRef(
className
)}
colSpan={colSpan}
+ style={style}
>
{to ? (
();
+
+ useEffect(() => {
+ const url = `/resources/orgs/${organization.slug}/can-view-logs-page`;
+ fetcher.load(url);
+ }, [organization.slug]);
+
+ return fetcher.data?.canViewLogsPage;
+}
diff --git a/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts
index 4272892e65..e23e2f552c 100644
--- a/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts
@@ -36,10 +36,7 @@ export class LogDetailPresenter {
);
}
- const isClickhouseV2 = store === "clickhouse_v2";
- const queryBuilder = isClickhouseV2
- ? this.clickhouse.taskEventsV2.logDetailQueryBuilder()
- : this.clickhouse.taskEvents.logDetailQueryBuilder();
+ const queryBuilder = this.clickhouse.taskEventsV2.logDetailQueryBuilder();
// Required filters - spanId, traceId, and startTime uniquely identify the log
// Multiple events can share the same spanId (span, span events, logs), so startTime is needed
diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts
index 35ba43117f..69a84932a3 100644
--- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts
+++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts
@@ -1,21 +1,15 @@
import { z } from "zod";
-import { type ClickHouse, type LogsListResult } from "@internal/clickhouse";
-import { MachinePresetName } from "@trigger.dev/core/v3";
+import { type ClickHouse } from "@internal/clickhouse";
import {
type PrismaClientOrTransaction,
- type TaskRunStatus,
- TaskRunStatus as TaskRunStatusEnum,
} from "@trigger.dev/database";
-import { getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
+import { EVENT_STORE_TYPES, getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
-// Create a schema that validates TaskRunStatus enum values
-const TaskRunStatusSchema = z.array(z.nativeEnum(TaskRunStatusEnum));
import parseDuration from "parse-duration";
import { type Direction } from "~/components/ListPagination";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
-import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { kindToLevel, type LogLevel, LogLevelSchema } from "~/utils/logUtils";
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
@@ -33,27 +27,27 @@ type ErrorAttributes = {
[key: string]: unknown;
};
+function escapeClickHouseString(val: string): string {
+ return val
+ .replace(/\\/g, "\\\\")
+ .replace(/\//g, "\\/")
+ .replace(/%/g, "\\%")
+ .replace(/_/g, "\\_");
+}
+
+
export type LogsListOptions = {
userId?: string;
projectId: string;
// filters
tasks?: string[];
- versions?: string[];
- statuses?: TaskRunStatus[];
- tags?: string[];
- scheduleId?: string;
+ runId?: string;
period?: string;
- bulkId?: string;
from?: number;
to?: number;
- isTest?: boolean;
- rootOnly?: boolean;
- batchId?: string;
- runId?: string[];
- queues?: string[];
- machines?: MachinePresetName[];
levels?: LogLevel[];
defaultPeriod?: string;
+ retentionLimitDays?: number;
// search
search?: string;
includeDebugLogs?: boolean;
@@ -67,22 +61,13 @@ export const LogsListOptionsSchema = z.object({
userId: z.string().optional(),
projectId: z.string(),
tasks: z.array(z.string()).optional(),
- versions: z.array(z.string()).optional(),
- statuses: TaskRunStatusSchema.optional(),
- tags: z.array(z.string()).optional(),
- scheduleId: z.string().optional(),
+ runId: z.string().optional(),
period: z.string().optional(),
- bulkId: z.string().optional(),
from: z.number().int().nonnegative().optional(),
to: z.number().int().nonnegative().optional(),
- isTest: z.boolean().optional(),
- rootOnly: z.boolean().optional(),
- batchId: z.string().optional(),
- runId: z.array(z.string()).optional(),
- queues: z.array(z.string()).optional(),
- machines: z.array(MachinePresetName).optional(),
levels: z.array(LogLevelSchema).optional(),
defaultPeriod: z.string().optional(),
+ retentionLimitDays: z.number().int().positive().optional(),
search: z.string().max(1000).optional(),
includeDebugLogs: z.boolean().optional(),
direction: z.enum(["forward", "backward"]).optional(),
@@ -91,7 +76,6 @@ export const LogsListOptionsSchema = z.object({
});
const DEFAULT_PAGE_SIZE = 50;
-const MAX_RUN_IDS = 5000;
export type LogsList = Awaited>;
export type LogEntry = LogsList["logs"][0];
@@ -99,17 +83,15 @@ export type LogsListAppliedFilters = LogsList["filters"];
// Cursor is a base64 encoded JSON of the pagination keys
type LogCursor = {
- startTime: string;
+ environmentId: string;
+ unixTimestamp: number;
traceId: string;
- spanId: string;
- runId: string;
};
const LogCursorSchema = z.object({
- startTime: z.string(),
+ environmentId: z.string(),
+ unixTimestamp: z.number(),
traceId: z.string(),
- spanId: z.string(),
- runId: z.string(),
});
function encodeCursor(cursor: LogCursor): string {
@@ -141,10 +123,6 @@ function levelToKindsAndStatuses(level: LogLevel): { kinds?: string[]; statuses?
return { kinds: ["LOG_WARN"] };
case "ERROR":
return { kinds: ["LOG_ERROR"], statuses: ["ERROR"] };
- case "CANCELLED":
- return { statuses: ["CANCELLED"] };
- case "TRACE":
- return { kinds: ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"] };
}
}
@@ -180,18 +158,8 @@ export class LogsListPresenter extends BasePresenter {
userId,
projectId,
tasks,
- versions,
- statuses,
- tags,
- scheduleId,
- period,
- bulkId,
- isTest,
- rootOnly,
- batchId,
runId,
- queues,
- machines,
+ period,
levels,
search,
from,
@@ -200,6 +168,7 @@ export class LogsListPresenter extends BasePresenter {
pageSize = DEFAULT_PAGE_SIZE,
includeDebugLogs = true,
defaultPeriod,
+ retentionLimitDays,
}: LogsListOptions
) {
const time = timeFilters({
@@ -220,23 +189,20 @@ export class LogsListPresenter extends BasePresenter {
}
}
- const hasStatusFilters = statuses && statuses.length > 0;
- const hasRunLevelFilters =
- (versions !== undefined && versions.length > 0) ||
- hasStatusFilters ||
- (bulkId !== undefined && bulkId !== "") ||
- (scheduleId !== undefined && scheduleId !== "") ||
- (tags !== undefined && tags.length > 0) ||
- batchId !== undefined ||
- (runId !== undefined && runId.length > 0) ||
- (queues !== undefined && queues.length > 0) ||
- (machines !== undefined && machines.length > 0) ||
- typeof isTest === "boolean" ||
- rootOnly === true;
+ // Apply retention limit if provided
+ let wasClampedByRetention = false;
+ if (retentionLimitDays !== undefined && effectiveFrom) {
+ const retentionCutoffDate = new Date(Date.now() - retentionLimitDays * 24 * 60 * 60 * 1000);
+
+ if (effectiveFrom < retentionCutoffDate) {
+ effectiveFrom = retentionCutoffDate;
+ wasClampedByRetention = true;
+ }
+ }
const hasFilters =
(tasks !== undefined && tasks.length > 0) ||
- hasRunLevelFilters ||
+ (runId !== undefined && runId !== "") ||
(levels !== undefined && levels.length > 0) ||
(search !== undefined && search !== "") ||
!time.isDefault;
@@ -266,120 +232,29 @@ export class LogsListPresenter extends BasePresenter {
findDisplayableEnvironment(environmentId, userId),
]);
- if (bulkId && !bulkActions.some((bulkAction) => bulkAction.friendlyId === bulkId)) {
- const selectedBulkAction = await this.replica.bulkActionGroup.findFirst({
- select: {
- friendlyId: true,
- type: true,
- createdAt: true,
- name: true,
- },
- where: {
- friendlyId: bulkId,
- projectId,
- environmentId,
- },
- });
-
- if (selectedBulkAction) {
- bulkActions.push(selectedBulkAction);
- }
- }
-
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
- // If we have run-level filters, we need to first get matching run IDs from Postgres
- let runIds: string[] | undefined;
- if (hasRunLevelFilters) {
- const runsRepository = new RunsRepository({
- clickhouse: this.clickhouse,
- prisma: this.replica,
- });
-
- function clampToNow(date: Date): Date {
- const now = new Date();
- return date > now ? now : date;
- }
-
- runIds = await runsRepository.listFriendlyRunIds({
- organizationId,
- environmentId,
- projectId,
- tasks,
- versions,
- statuses,
- tags,
- scheduleId,
- period,
- from: effectiveFrom ? effectiveFrom.getTime() : undefined,
- to: effectiveTo ? clampToNow(effectiveTo).getTime() : undefined,
- isTest,
- rootOnly,
- batchId,
- runId,
- bulkId,
- queues,
- machines,
- page: {
- size: MAX_RUN_IDS,
- direction: "forward",
- },
- });
-
- if (runIds.length === 0) {
- return {
- logs: [],
- pagination: {
- next: undefined,
- previous: undefined,
- },
- possibleTasks: possibleTasks
- .map((task) => ({
- slug: task.slug,
- triggerSource: task.triggerSource,
- }))
- .sort((a, b) => a.slug.localeCompare(b.slug)),
- bulkActions: bulkActions.map((bulkAction) => ({
- id: bulkAction.friendlyId,
- type: bulkAction.type,
- createdAt: bulkAction.createdAt,
- name: bulkAction.name || bulkAction.friendlyId,
- })),
- filters: {
- tasks: tasks || [],
- versions: versions || [],
- statuses: statuses || [],
- levels: levels || [],
- from: effectiveFrom,
- to: effectiveTo,
- },
- hasFilters,
- hasAnyLogs: false,
- searchTerm: search,
- };
- }
- }
-
// Determine which store to use based on organization configuration
const { store } = await getConfiguredEventRepository(organizationId);
// Throw error if postgres is detected
- if (store === "postgres") {
+ if (store === EVENT_STORE_TYPES.POSTGRES) {
throw new ServiceValidationError(
"Logs are not available for PostgreSQL event store. Please contact support."
);
}
- // Get the appropriate query builder based on store type
- const isClickhouseV2 = store === "clickhouse_v2";
+ if (store === EVENT_STORE_TYPES.CLICKHOUSE) {
+ throw new ServiceValidationError(
+ "Logs are not available for ClickHouse event store. Please contact support."
+ );
+ }
- const queryBuilder = isClickhouseV2
- ? this.clickhouse.taskEventsV2.logsListQueryBuilder()
- : this.clickhouse.taskEvents.logsListQueryBuilder();
+ const queryBuilder = this.clickhouse.taskEventsV2.logsListQueryBuilder();
- queryBuilder.prewhere("environment_id = {environmentId: String}", {
+ queryBuilder.where("environment_id = {environmentId: String}", {
environmentId,
});
@@ -388,16 +263,13 @@ export class LogsListPresenter extends BasePresenter {
});
queryBuilder.where("project_id = {projectId: String}", { projectId });
- // Time filters - inserted_at in PREWHERE only for v2, start_time in WHERE for both
+
if (effectiveFrom) {
const fromNs = convertDateToNanoseconds(effectiveFrom);
- // Only use inserted_at for partition pruning if v2
- if (isClickhouseV2) {
- queryBuilder.prewhere("inserted_at >= {insertedAtStart: DateTime64(3)}", {
+ queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
insertedAtStart: convertDateToClickhouseDateTime(effectiveFrom),
});
- }
queryBuilder.where("start_time >= {fromTime: String}", {
fromTime: formatNanosecondsForClickhouse(fromNs),
@@ -408,12 +280,9 @@ export class LogsListPresenter extends BasePresenter {
const clampedTo = effectiveTo > new Date() ? new Date() : effectiveTo;
const toNs = convertDateToNanoseconds(clampedTo);
- // Only use inserted_at for partition pruning if v2
- if (isClickhouseV2) {
- queryBuilder.prewhere("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
- insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
- });
- }
+ queryBuilder.where("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
+ insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
+ });
queryBuilder.where("start_time <= {toTime: String}", {
toTime: formatNanosecondsForClickhouse(toNs),
@@ -427,19 +296,18 @@ export class LogsListPresenter extends BasePresenter {
});
}
- // Run IDs filter (from Postgres lookup)
- if (runIds && runIds.length > 0) {
- queryBuilder.where("run_id IN {runIds: Array(String)}", { runIds });
+ // Run ID filter
+ if (runId && runId !== "") {
+ queryBuilder.where("run_id = {runId: String}", { runId });
}
// Case-insensitive search in message, attributes, and status fields
if (search && search.trim() !== "") {
- const searchTerm = search.trim();
+ const searchTerm = escapeClickHouseString(search.trim()).toLowerCase();
queryBuilder.where(
- "(message ilike {searchPattern: String} OR attributes_text ilike {searchPattern: String} OR status = {statusTerm: String})",
+ "(lower(message) like {searchPattern: String} OR lower(attributes_text) like {searchPattern: String})",
{
- searchPattern: `%${searchTerm}%`,
- statusTerm: searchTerm.toUpperCase(),
+ searchPattern: `%${searchTerm}%`
}
);
}
@@ -447,7 +315,6 @@ export class LogsListPresenter extends BasePresenter {
if (levels && levels.length > 0) {
const conditions: string[] = [];
const params: Record = {};
- const hasErrorOrCancelledLevel = levels.includes("ERROR") || levels.includes("CANCELLED");
for (const level of levels) {
const filter = levelToKindsAndStatuses(level);
@@ -457,11 +324,10 @@ export class LogsListPresenter extends BasePresenter {
const kindsKey = `kinds_${level}`;
let kindCondition = `kind IN {${kindsKey}: Array(String)}`;
- // For TRACE: exclude error/cancelled traces if ERROR/CANCELLED not explicitly selected
- if (level === "TRACE" && !hasErrorOrCancelledLevel) {
- kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`;
- params["excluded_statuses"] = ["ERROR", "CANCELLED"];
- }
+
+ kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`;
+ params["excluded_statuses"] = ["ERROR", "CANCELLED"];
+
levelConditions.push(kindCondition);
params[kindsKey] = filter.kinds;
@@ -486,28 +352,34 @@ export class LogsListPresenter extends BasePresenter {
// Debug logs are available only to admins
if (includeDebugLogs === false) {
queryBuilder.where("kind NOT IN {debugKinds: Array(String)}", {
- debugKinds: ["DEBUG_EVENT", "LOG_DEBUG"],
+ debugKinds: ["DEBUG_EVENT"],
});
}
+ queryBuilder.where("kind NOT IN {debugSpans: Array(String)}", {
+ debugSpans: ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"],
+ });
+
+ // kindCondition += ` `;
+ // params["excluded_statuses"] = ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"];
+
+
queryBuilder.where("NOT (kind = 'SPAN' AND status = 'PARTIAL')");
// Cursor pagination
const decodedCursor = cursor ? decodeCursor(cursor) : null;
if (decodedCursor) {
queryBuilder.where(
- "(start_time, trace_id, span_id, run_id) < ({cursorStartTime: String}, {cursorTraceId: String}, {cursorSpanId: String}, {cursorRunId: String})",
+ "(environment_id, toUnixTimestamp(start_time), trace_id) < ({cursorEnvId: String}, {cursorUnixTimestamp: Int64}, {cursorTraceId: String})",
{
- cursorStartTime: decodedCursor.startTime,
+ cursorEnvId: decodedCursor.environmentId,
+ cursorUnixTimestamp: decodedCursor.unixTimestamp,
cursorTraceId: decodedCursor.traceId,
- cursorSpanId: decodedCursor.spanId,
- cursorRunId: decodedCursor.runId,
}
);
}
- queryBuilder.orderBy("start_time DESC, trace_id DESC, span_id DESC, run_id DESC");
-
+ queryBuilder.orderBy("environment_id DESC, toUnixTimestamp(start_time) DESC, trace_id DESC");
// Limit + 1 to check if there are more results
queryBuilder.limit(pageSize + 1);
@@ -525,11 +397,11 @@ export class LogsListPresenter extends BasePresenter {
let nextCursor: string | undefined;
if (hasMore && logs.length > 0) {
const lastLog = logs[logs.length - 1];
+ const unixTimestamp = Math.floor(new Date(lastLog.start_time).getTime() / 1000);
nextCursor = encodeCursor({
- startTime: lastLog.start_time,
+ environmentId,
+ unixTimestamp,
traceId: lastLog.trace_id,
- spanId: lastLog.span_id,
- runId: lastLog.run_id,
});
}
@@ -587,8 +459,6 @@ export class LogsListPresenter extends BasePresenter {
})),
filters: {
tasks: tasks || [],
- versions: versions || [],
- statuses: statuses || [],
levels: levels || [],
from: effectiveFrom,
to: effectiveTo,
@@ -596,6 +466,10 @@ export class LogsListPresenter extends BasePresenter {
hasFilters,
hasAnyLogs: transformedLogs.length > 0,
searchTerm: search,
+ retention: retentionLimitDays !== undefined ? {
+ limitDays: retentionLimitDays,
+ wasClamped: wasClampedByRetention,
+ } : undefined,
};
}
}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx
index 44fbd437f5..6237d699b3 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx
@@ -1,5 +1,6 @@
-import { type LoaderFunctionArgs , redirect} from "@remix-run/server-runtime";
-import { type MetaFunction, useFetcher, useNavigation, useLocation } from "@remix-run/react";
+import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
+import { type MetaFunction, useFetcher, useNavigation, useLocation, Form } from "@remix-run/react";
+import { XMarkIcon } from "@heroicons/react/20/solid";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import {
TypedAwait,
@@ -8,41 +9,41 @@ import {
useTypedLoaderData,
} from "remix-typedjson";
import { requireUser } from "~/services/session.server";
+import { getCurrentPlan } from "~/services/platform.v3.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
-import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server";
import type { LogLevel } from "~/utils/logUtils";
import { $replica, prisma } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
-import {
- setRootOnlyFilterPreference,
- uiPreferencesStorage,
-} from "~/services/preferences/uiPreferences.server";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
-import { Suspense, useCallback, useEffect, useMemo, useState, useTransition } from "react";
+import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
+import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { Spinner } from "~/components/primitives/Spinner";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Callout } from "~/components/primitives/Callout";
-import { RunsFilters } from "~/components/runs/v3/RunFilters";
import { LogsTable } from "~/components/logs/LogsTable";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import { LogDetailView } from "~/components/logs/LogDetailView";
import { LogsSearchInput } from "~/components/logs/LogsSearchInput";
import { LogsLevelFilter } from "~/components/logs/LogsLevelFilter";
+import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
+import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter";
+import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Switch } from "~/components/primitives/Switch";
+import { Button } from "~/components/primitives/Buttons";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
// Valid log levels for filtering
-const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
+const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
@@ -58,6 +59,7 @@ export const meta: MetaFunction = () => {
];
};
+// TODO: Move this to a more appropriate shared location
async function hasLogsPageAccess(
userId: string,
isAdmin: boolean,
@@ -97,7 +99,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = user.id;
const isAdmin = user.admin || user.isImpersonating;
-
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const canAccess = await hasLogsPageAccess(
@@ -121,53 +122,58 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Environment not found", { status: 404 });
}
- const filters = await getRunFiltersFromRequest(request);
-
- // Get search term, levels, and showDebug from query params
+ // Get filters from query params
const url = new URL(request.url);
+ const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
+ const runId = url.searchParams.get("runId") ?? undefined;
const search = url.searchParams.get("search") ?? undefined;
const levels = parseLevelsFromUrl(url);
const showDebug = url.searchParams.get("showDebug") === "true";
+ const period = url.searchParams.get("period") ?? undefined;
+ const fromStr = url.searchParams.get("from");
+ const toStr = url.searchParams.get("to");
+ const from = fromStr ? parseInt(fromStr, 10) : undefined;
+ const to = toStr ? parseInt(toStr, 10) : undefined;
+
+ // Get the user's plan to determine log retention limit
+ const plan = await getCurrentPlan(project.organizationId);
+ const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
const presenter = new LogsListPresenter($replica, clickhouseClient);
- const listPromise = presenter.call(project.organizationId, environment.id, {
- userId,
- projectId: project.id,
- ...filters,
- search,
- levels,
- includeDebugLogs: isAdmin && showDebug,
+ const listPromise = presenter
+ .call(project.organizationId, environment.id, {
+ userId,
+ projectId: project.id,
+ tasks: tasks.length > 0 ? tasks : undefined,
+ runId,
+ search,
+ levels,
+ period,
+ from,
+ to,
+ includeDebugLogs: isAdmin && showDebug,
+ defaultPeriod: "1h",
+ retentionLimitDays,
+ })
+ .catch((error) => {
+ if (error instanceof ServiceValidationError) {
+ return { error: error.message };
+ }
+ throw error;
+ });
+
+ return typeddefer({
+ data: listPromise,
+ isAdmin,
+ showDebug,
defaultPeriod: "1h",
- }).catch((error) => {
- if (error instanceof ServiceValidationError) {
- return { error: "Failed to load logs. Please refresh and try again." };
- }
- throw error;
});
-
- const session = await setRootOnlyFilterPreference(filters.rootOnly, request);
- const cookieValue = await uiPreferencesStorage.commitSession(session);
-
- return typeddefer(
- {
- data: listPromise,
- rootOnlyDefault: filters.rootOnly,
- filters,
- isAdmin,
- showDebug,
- defaultPeriod: "1h",
- },
- {
- headers: {
- "Set-Cookie": cookieValue,
- },
- }
- );
};
export default function Page() {
- const { data, rootOnlyDefault, isAdmin, showDebug, defaultPeriod } = useTypedLoaderData();
+ const { data, isAdmin, showDebug, defaultPeriod } =
+ useTypedLoaderData();
return (
@@ -183,7 +189,7 @@ export default function Page() {
-
Loading logs
+
Loading logs…
@@ -192,10 +198,17 @@ export default function Page() {
-
- Unable to load your logs. Please refresh the page or try again in a moment.
-
+
+
+
+
+ Unable to load your logs. Please refresh the page or try again in a moment.
+
+
}
>
@@ -203,21 +216,35 @@ export default function Page() {
// Check if result contains an error
if ("error" in result) {
return (
-
-
- {result.error}
-
+
+
+
+
+ {result.error}
+
+
);
}
return (
-
+
+
+
+
);
}}
@@ -227,15 +254,117 @@ export default function Page() {
);
}
-function LogsList({
+function RetentionNotice({
+ logCount,
+ retentionDays,
+}: {
+ logCount: number;
+ retentionDays: number;
+}) {
+ return (
+
+
+ Showing last {retentionDays} {retentionDays === 1 ? 'day' : 'days'}
+
+
+ Upgrade
+
+
+ );
+}
+
+function FiltersBar({
list,
- rootOnlyDefault,
isAdmin,
showDebug,
defaultPeriod,
+}: {
+ list?: Exclude
["data"]>, { error: string }>;
+ isAdmin: boolean;
+ showDebug: boolean;
+ defaultPeriod?: string;
+}) {
+ const location = useOptimisticLocation();
+ const searchParams = new URLSearchParams(location.search);
+ const hasFilters =
+ searchParams.has("tasks") ||
+ searchParams.has("runId") ||
+ searchParams.has("search") ||
+ searchParams.has("levels") ||
+ searchParams.has("period") ||
+ searchParams.has("from") ||
+ searchParams.has("to");
+
+ const handleDebugToggle = useCallback((checked: boolean) => {
+ const url = new URL(window.location.href);
+ if (checked) {
+ url.searchParams.set("showDebug", "true");
+ } else {
+ url.searchParams.delete("showDebug");
+ }
+ window.location.href = url.toString();
+ }, []);
+
+ return (
+
+
+ {list ? (
+ <>
+
+
+
+
+
+ {hasFilters && (
+
+ )}
+ >
+ ) : (
+ <>
+
+
+
+
+
+ {hasFilters && (
+
+ )}
+ >
+ )}
+
+
+ {list?.retention?.wasClamped && (
+
+ )}
+ {isAdmin && (
+
+ )}
+
+
+ );
+}
+
+function LogsList({
+ list,
}: {
list: Exclude["data"]>, { error: string }>; //exclude error, it is handled
- rootOnlyDefault: boolean;
isAdmin: boolean;
showDebug: boolean;
defaultPeriod?: string;
@@ -253,37 +382,50 @@ function LogsList({
// Selected log state - managed locally to avoid triggering navigation
const [selectedLogId, setSelectedLogId] = useState();
- const handleDebugToggle = useCallback(
- (checked: boolean) => {
- const url = new URL(window.location.href);
- if (checked) {
- url.searchParams.set("showDebug", "true");
- } else {
- url.searchParams.delete("showDebug");
- }
- window.location.href = url.toString();
- },
- []
- );
+ // Track which filter state (search params) the current fetcher request corresponds to
+ const fetcherFilterStateRef = useRef(location.search);
+ // Clear accumulated logs immediately when filters change (for instant visual feedback)
+ useEffect(() => {
+ setAccumulatedLogs([]);
+ setNextCursor(undefined);
+ // Close side panel when filters change to avoid showing a log that's no longer visible
+ setSelectedLogId(undefined);
+ }, [location.search]);
- // Reset accumulated logs when the initial list changes (e.g., filters change)
+ // Populate accumulated logs when new data arrives
useEffect(() => {
setAccumulatedLogs(list.logs);
setNextCursor(list.pagination.next);
}, [list.logs, list.pagination.next]);
+ // Clear log parameter from URL when selectedLogId is cleared
+ useEffect(() => {
+ if (!selectedLogId) {
+ const url = new URL(window.location.href);
+ if (url.searchParams.has("log")) {
+ url.searchParams.delete("log");
+ window.history.replaceState(null, "", url.toString());
+ }
+ }
+ }, [selectedLogId]);
+
// Append new logs when fetcher completes (with deduplication)
useEffect(() => {
if (fetcher.data && fetcher.state === "idle") {
+ // Ignore fetcher data if it was loaded for a different filter state
+ if (fetcherFilterStateRef.current !== location.search) {
+ return;
+ }
+
const existingIds = new Set(accumulatedLogs.map((log) => log.id));
const newLogs = fetcher.data.logs.filter((log) => !existingIds.has(log.id));
if (newLogs.length > 0) {
setAccumulatedLogs((prev) => [...prev, ...newLogs]);
- setNextCursor(fetcher.data.pagination.next);
}
+ setNextCursor(fetcher.data.pagination.next);
}
- }, [fetcher.data, fetcher.state, accumulatedLogs]);
+ }, [fetcher.data, fetcher.state, accumulatedLogs, location.search]);
// Build resource URL for loading more
const loadMoreUrl = useMemo(() => {
@@ -297,27 +439,26 @@ function LogsList({
const handleLoadMore = useCallback(() => {
if (loadMoreUrl && fetcher.state === "idle") {
+ // Store the current filter state before loading
+ fetcherFilterStateRef.current = location.search;
fetcher.load(loadMoreUrl);
}
- }, [loadMoreUrl, fetcher]);
+ }, [loadMoreUrl, fetcher, location.search]);
const selectedLog = useMemo(() => {
if (!selectedLogId) return undefined;
return accumulatedLogs.find((log) => log.id === selectedLogId);
}, [selectedLogId, accumulatedLogs]);
- const updateUrlWithLog = useCallback(
- (logId: string | undefined) => {
- const url = new URL(window.location.href);
- if (logId) {
- url.searchParams.set("log", logId);
- } else {
- url.searchParams.delete("log");
- }
- window.history.replaceState(null, "", url.toString());
- },
- []
- );
+ const updateUrlWithLog = useCallback((logId: string | undefined) => {
+ const url = new URL(window.location.href);
+ if (logId) {
+ url.searchParams.set("log", logId);
+ } else {
+ url.searchParams.delete("log");
+ }
+ window.history.replaceState(null, "", url.toString());
+ }, []);
const handleLogSelect = useCallback(
(logId: string) => {
@@ -339,51 +480,30 @@ function LogsList({
return (
-
- {/* Filters */}
-
-
-
-
-
-
- {isAdmin && (
-
- )}
-
-
- {/* Table */}
-
-
+
-
{/* Side panel for log details */}
{selectedLogId && (
<>
- }>
+
+
+
+ }
+ >
{
+ if (isAdmin || isImpersonating) {
+ return true;
+ }
+
+ const organization = await prisma.organization.findFirst({
+ where: {
+ slug: organizationSlug,
+ members: { some: { userId } },
+ },
+ select: {
+ featureFlags: true,
+ },
+ });
+
+ if (!organization?.featureFlags) {
+ return false;
+ }
+
+ const flags = organization.featureFlags as Record;
+ const hasLogsPageAccessResult = validateFeatureFlagValue(
+ FEATURE_FLAG.hasLogsPageAccess,
+ flags.hasLogsPageAccess
+ );
+
+ return hasLogsPageAccessResult.success && hasLogsPageAccessResult.data === true;
+}
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUser(request);
+ const { organizationSlug } = OrganizationParamsSchema.parse(params);
+
+ const canViewLogsPage = user.admin || user.isImpersonating || await hasLogsPageAccess(
+ user.id,
+ user.admin,
+ user.isImpersonating,
+ organizationSlug
+ );
+
+ return typedjson({ canViewLogsPage });
+};
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts
index fd6f1c1a6a..656e20472e 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.ts
@@ -4,13 +4,13 @@ import { requireUser, requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
-import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
import { LogsListPresenter, type LogLevel, LogsListOptionsSchema } from "~/presenters/v3/LogsListPresenter.server";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
+import { getCurrentPlan } from "~/services/platform.v3.server";
// Valid log levels for filtering
-const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
+const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
@@ -19,7 +19,10 @@ function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const userId = await requireUserId(request);
+ const user = await requireUser(request);
+ const userId = user.id;
+ const isAdmin = user?.admin || user?.isImpersonating;
+
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
@@ -32,28 +35,41 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Environment not found", { status: 404 });
}
- const user = await requireUser(request);
- const isAdmin = user?.admin || user?.isImpersonating;
-
- const filters = await getRunFiltersFromRequest(request);
+ // Get the user's plan to determine log retention limit
+ const plan = await getCurrentPlan(project.organizationId);
+ const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
- // Get search term, cursor, levels, and showDebug from query params
+ // Get filters from query params
const url = new URL(request.url);
+ const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
+ const runId = url.searchParams.get("runId") ?? undefined;
const search = url.searchParams.get("search") ?? undefined;
const cursor = url.searchParams.get("cursor") ?? undefined;
const levels = parseLevelsFromUrl(url);
const showDebug = url.searchParams.get("showDebug") === "true";
+ const period = url.searchParams.get("period") ?? undefined;
+ const fromStr = url.searchParams.get("from");
+ const toStr = url.searchParams.get("to");
+ let from = fromStr ? parseInt(fromStr, 10) : undefined;
+ let to = toStr ? parseInt(toStr, 10) : undefined;
+ if (Number.isNaN(from)) from = undefined;
+ if (Number.isNaN(to)) to = undefined;
const options = LogsListOptionsSchema.parse({
userId,
projectId: project.id,
- ...filters,
+ tasks: tasks.length > 0 ? tasks : undefined,
+ runId,
search,
cursor,
+ period,
+ from,
+ to,
levels,
includeDebugLogs: isAdmin && showDebug,
defaultPeriod: "1h",
+ retentionLimitDays,
}) as any; // Validated by LogsListOptionsSchema at runtime
const presenter = new LogsListPresenter($replica, clickhouseClient);
diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx
index 6c6222e7c3..bd186dcea4 100644
--- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx
+++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx
@@ -73,6 +73,7 @@ import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useHasAdminAccess } from "~/hooks/useUser";
+import { useCanViewLogsPage } from "~/hooks/useCanViewLogsPage";
import { redirectWithErrorMessage } from "~/models/message.server";
import { type Span, SpanPresenter, type SpanRun } from "~/presenters/v3/SpanPresenter.server";
import { logger } from "~/services/logger.server";
@@ -319,6 +320,7 @@ function RunBody({
const { value, replace } = useSearchParams();
const tab = value("tab");
const resetFetcher = useTypedFetcher();
+ const canViewLogsPage = useCanViewLogsPage();
return (
@@ -1012,44 +1014,55 @@ function RunBody({
{run.logsDeletedAt === null ? (
-
+ canViewLogsPage ? (
+
+
+ View logs
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
- View logs
+ Download logs
-
-
-
-
-
-
-
-
-
-
-
-
+ )
) : null}
diff --git a/apps/webapp/app/utils/logUtils.ts b/apps/webapp/app/utils/logUtils.ts
index b4a130b8e5..cad9bbc907 100644
--- a/apps/webapp/app/utils/logUtils.ts
+++ b/apps/webapp/app/utils/logUtils.ts
@@ -1,10 +1,10 @@
import { createElement, Fragment, type ReactNode } from "react";
import { z } from "zod";
-export const LogLevelSchema = z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"]);
+export const LogLevelSchema = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]);
export type LogLevel = z.infer;
-export const validLogLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
+export const validLogLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR",];
// Default styles for search highlighting
const DEFAULT_HIGHLIGHT_STYLES: React.CSSProperties = {
@@ -71,10 +71,6 @@ export function highlightSearchText(
// Convert ClickHouse kind to display level
export function kindToLevel(kind: string, status: string): LogLevel {
- if (status === "CANCELLED") {
- return "CANCELLED";
- }
-
// ERROR can come from either kind or status
if (kind === "LOG_ERROR" || status === "ERROR") {
return "ERROR";
@@ -94,7 +90,7 @@ export function kindToLevel(kind: string, status: string): LogLevel {
case "ANCESTOR_OVERRIDE":
case "SPAN_EVENT":
default:
- return "TRACE";
+ return "INFO";
}
}
@@ -109,47 +105,7 @@ export function getLevelColor(level: LogLevel): string {
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
case "INFO":
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
- case "TRACE":
- return "text-charcoal-500 bg-charcoal-800 border-charcoal-700";
- case "CANCELLED":
- return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
default:
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
}
}
-
-// Event kind badge color styles
-export function getKindColor(kind: string): string {
- if (kind === "SPAN") {
- return "text-purple-400 bg-purple-500/10 border-purple-500/20";
- }
- if (kind === "SPAN_EVENT") {
- return "text-amber-400 bg-amber-500/10 border-amber-500/20";
- }
- if (kind.startsWith("LOG_")) {
- return "text-blue-400 bg-blue-500/10 border-blue-500/20";
- }
- return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
-}
-
-// Get human readable kind label
-export function getKindLabel(kind: string): string {
- switch (kind) {
- case "SPAN":
- return "Span";
- case "SPAN_EVENT":
- return "Event";
- case "LOG_DEBUG":
- case "LOG_INFO":
- case "LOG_WARN":
- case "LOG_ERROR":
- case "LOG_LOG":
- return "Log";
- case "DEBUG_EVENT":
- return "Debug";
- case "ANCESTOR_OVERRIDE":
- return "Override";
- default:
- return kind;
- }
-}
diff --git a/internal-packages/clickhouse/schema/014_add_task_runs_v2_serch_indexes.sql b/internal-packages/clickhouse/schema/014_add_task_runs_v2_serch_indexes.sql
new file mode 100644
index 0000000000..1d6af32945
--- /dev/null
+++ b/internal-packages/clickhouse/schema/014_add_task_runs_v2_serch_indexes.sql
@@ -0,0 +1,20 @@
+-- +goose Up
+
+-- Add indexes for text search on task task_events_v2 tables for message and attributes fields
+ALTER TABLE trigger_dev.task_events_v2
+ ADD INDEX IF NOT EXISTS idx_attributes_text_search lower(attributes_text)
+ TYPE ngrambf_v1(3, 32768, 2, 0)
+ GRANULARITY 1;
+
+ALTER TABLE trigger_dev.task_events_v2
+ ADD INDEX IF NOT EXISTS idx_message_text_search lower(message)
+ TYPE ngrambf_v1(3, 32768, 2, 0)
+ GRANULARITY 1;
+
+-- +goose Down
+
+ALTER TABLE trigger_dev.task_events_v2
+DROP INDEX idx_attributes_text_search;
+
+ALTER TABLE trigger_dev.task_events_v2
+DROP INDEX idx_message_text_search;
diff --git a/internal-packages/clickhouse/src/index.ts b/internal-packages/clickhouse/src/index.ts
index acb3c56a8a..4f4cb5e3b1 100644
--- a/internal-packages/clickhouse/src/index.ts
+++ b/internal-packages/clickhouse/src/index.ts
@@ -25,8 +25,6 @@ import {
insertTaskEventsV2,
getLogsListQueryBuilderV2,
getLogDetailQueryBuilderV2,
- getLogsListQueryBuilderV1,
- getLogDetailQueryBuilderV1,
} from "./taskEvents.js";
import { Logger, type LogLevel } from "@trigger.dev/core/logger";
import type { Agent as HttpAgent } from "http";
@@ -213,8 +211,6 @@ export class ClickHouse {
traceSummaryQueryBuilder: getTraceSummaryQueryBuilder(this.reader),
traceDetailedSummaryQueryBuilder: getTraceDetailedSummaryQueryBuilder(this.reader),
spanDetailsQueryBuilder: getSpanDetailsQueryBuilder(this.reader),
- logsListQueryBuilder: getLogsListQueryBuilderV1(this.reader, this.logsQuerySettings?.list),
- logDetailQueryBuilder: getLogDetailQueryBuilderV1(this.reader, this.logsQuerySettings?.detail),
};
}
diff --git a/internal-packages/clickhouse/src/taskEvents.ts b/internal-packages/clickhouse/src/taskEvents.ts
index fa64a908dd..890eab9cc7 100644
--- a/internal-packages/clickhouse/src/taskEvents.ts
+++ b/internal-packages/clickhouse/src/taskEvents.ts
@@ -320,56 +320,4 @@ export function getLogDetailQueryBuilderV2(ch: ClickhouseReader, settings?: Clic
],
settings,
});
-}
-
-// ============================================================================
-// Logs List Query Builders for V1 (task_events_v1)
-// ============================================================================
-
-export function getLogsListQueryBuilderV1(ch: ClickhouseReader, settings?: ClickHouseSettings) {
- return ch.queryBuilderFast({
- name: "getLogsListV1",
- table: "trigger_dev.task_events_v1",
- columns: [
- "environment_id",
- "organization_id",
- "project_id",
- "task_identifier",
- "run_id",
- "start_time",
- "trace_id",
- "span_id",
- "parent_span_id",
- { name: "message", expression: "LEFT(message, 512)" },
- "kind",
- "status",
- "duration",
- "attributes_text"
- ],
- settings,
- });
-}
-
-export function getLogDetailQueryBuilderV1(ch: ClickhouseReader, settings?: ClickHouseSettings) {
- return ch.queryBuilderFast({
- name: "getLogDetailV1",
- table: "trigger_dev.task_events_v1",
- columns: [
- "environment_id",
- "organization_id",
- "project_id",
- "task_identifier",
- "run_id",
- "start_time",
- "trace_id",
- "span_id",
- "parent_span_id",
- "message",
- "kind",
- "status",
- "duration",
- "attributes_text",
- ],
- settings,
- });
-}
+}
\ No newline at end of file