Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 1 addition & 15 deletions apps/webapp/app/components/logs/LogsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import { PopoverMenuItem } from "~/components/primitives/Popover";

type LogsTableProps = {
logs: LogEntry[];
hasFilters: boolean;
searchTerm?: string;
isLoading?: boolean;
isLoadingMore?: boolean;
Expand Down Expand Up @@ -59,7 +58,6 @@ function getLevelBorderColor(level: LogEntry["level"]): string {

export function LogsTable({
logs,
hasFilters,
searchTerm,
isLoading = false,
isLoadingMore = false,
Expand Down Expand Up @@ -126,11 +124,7 @@ export function LogsTable({
</TableRow>
</TableHeader>
<TableBody>
{logs.length === 0 && !hasFilters ? (
<TableBlankRow colSpan={6}>
{!isLoading && <NoLogs title="No logs found" />}
</TableBlankRow>
) : logs.length === 0 ? (
{logs.length === 0 ? (
<BlankState isLoading={isLoading} onRefresh={() => window.location.reload()} />
) : (
logs.map((log) => {
Expand Down Expand Up @@ -214,14 +208,6 @@ export function LogsTable({
);
}

function NoLogs({ title }: { title: string }) {
return (
<div className="flex items-center justify-center">
<Paragraph className="w-auto">{title}</Paragraph>
</div>
);
}

function BlankState({ isLoading, onRefresh }: { isLoading?: boolean; onRefresh?: () => void }) {
if (isLoading) return <TableBlankRow colSpan={6}></TableBlankRow>;

Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/app/components/navigation/SideMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export function SideMenu({
to={v3DeploymentsPath(organization, project, environment)}
data-action="deployments"
/>
{(isAdmin || user.isImpersonating) && (
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
<SideMenuItem
name="Logs"
icon={LogsIcon}
Expand Down
1 change: 0 additions & 1 deletion apps/webapp/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1220,7 +1220,6 @@ const EnvironmentSchema = z
.number()
.int()
.default(60_000 * 5), // 5 minutes
EVENT_REPOSITORY_CLICKHOUSE_ROLLOUT_PERCENT: z.coerce.number().optional(),
EVENT_REPOSITORY_DEFAULT_STORE: z
.enum(["postgres", "clickhouse", "clickhouse_v2"])
.default("postgres"),
Expand Down
32 changes: 21 additions & 11 deletions apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { type ClickHouse } from "@internal/clickhouse";
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
import { convertClickhouseDateTime64ToJsDate } from "~/v3/eventRepository/clickhouseEventRepository.server";
import { kindToLevel } from "~/utils/logUtils";
import { getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";

export type LogDetailOptions = {
environmentId: string;
Expand All @@ -24,8 +26,21 @@ export class LogDetailPresenter {
public async call(options: LogDetailOptions) {
const { environmentId, organizationId, projectId, spanId, traceId, startTime } = options;

// Build ClickHouse query
const queryBuilder = this.clickhouse.taskEventsV2.logDetailQueryBuilder();
// Determine which store to use based on organization configuration
const { store } = await getConfiguredEventRepository(organizationId);

// Throw error if postgres is detected
if (store === "postgres") {
throw new ServiceValidationError(
"Log details are not available for PostgreSQL event store. Please contact support."
);
}

// Build ClickHouse query - only v2 is supported for log details
const isClickhouseV2 = store === "clickhouse_v2";
const queryBuilder = isClickhouseV2
? this.clickhouse.taskEventsV2.logDetailQueryBuilder()
: this.clickhouse.taskEvents.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
Expand Down Expand Up @@ -69,15 +84,10 @@ export class LogDetailPresenter {
}

try {
// Handle attributes which could be a JSON object or string
if (log.attributes) {
if (typeof log.attributes === "string") {
parsedAttributes = JSON.parse(log.attributes) as Record<string, unknown>;
rawAttributesString = log.attributes;
} else if (typeof log.attributes === "object") {
parsedAttributes = log.attributes as Record<string, unknown>;
rawAttributesString = JSON.stringify(log.attributes);
}
// Handle attributes_text which is a string
if (log.attributes_text) {
parsedAttributes = JSON.parse(log.attributes_text) as Record<string, unknown>;
rawAttributesString = log.attributes_text;
}
} catch {
// Ignore parse errors
Expand Down
115 changes: 66 additions & 49 deletions apps/webapp/app/presenters/v3/LogsListPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
TaskRunStatus as TaskRunStatusEnum,
TaskTriggerSource,
} from "@trigger.dev/database";
import { getConfiguredEventRepository } from "~/v3/eventRepository/index.server";

// Create a schema that validates TaskRunStatus enum values
const TaskRunStatusSchema = z.array(z.nativeEnum(TaskRunStatusEnum));
Expand All @@ -18,11 +19,13 @@ 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";
import {
convertDateToClickhouseDateTime,
convertClickhouseDateTime64ToJsDate,
} from "~/v3/eventRepository/clickhouseEventRepository.server";
import { kindToLevel, type LogLevel, LogLevelSchema } from "~/utils/logUtils";


export type { LogLevel };

Expand Down Expand Up @@ -131,9 +134,7 @@ function decodeCursor(cursor: string): LogCursor | null {
}

// Convert display level to ClickHouse kinds and statuses
function levelToKindsAndStatuses(
level: LogLevel
): { kinds?: string[]; statuses?: string[] } {
function levelToKindsAndStatuses(level: LogLevel): { kinds?: string[]; statuses?: string[] } {
switch (level) {
case "DEBUG":
return { kinds: ["DEBUG_EVENT", "LOG_DEBUG"] };
Expand All @@ -150,7 +151,6 @@ function levelToKindsAndStatuses(
}
}


function convertDateToNanoseconds(date: Date): bigint {
return BigInt(date.getTime()) * 1_000_000n;
}
Expand All @@ -168,11 +168,13 @@ function formatNanosecondsForClickhouse(ns: bigint): string {
return padded.slice(0, 10) + "." + padded.slice(10);
}

export class LogsListPresenter {
export class LogsListPresenter extends BasePresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
private readonly clickhouse: ClickHouse
) {}
) {
super();
}

public async call(
organizationId: string,
Expand Down Expand Up @@ -242,10 +244,7 @@ export class LogsListPresenter {
(search !== undefined && search !== "") ||
!time.isDefault;

const possibleTasksAsync = getAllTaskIdentifiers(
this.replica,
environmentId
);
const possibleTasksAsync = getAllTaskIdentifiers(this.replica, environmentId);

const bulkActionsAsync = this.replica.bulkActionGroup.findMany({
select: {
Expand All @@ -264,31 +263,26 @@ export class LogsListPresenter {
take: 20,
});

const [possibleTasks, bulkActions, displayableEnvironment] =
await Promise.all([
possibleTasksAsync,
bulkActionsAsync,
findDisplayableEnvironment(environmentId, userId),
]);

if (
bulkId &&
!bulkActions.some((bulkAction) => bulkAction.friendlyId === bulkId)
) {
const selectedBulkAction =
await this.replica.bulkActionGroup.findFirst({
select: {
friendlyId: true,
type: true,
createdAt: true,
name: true,
},
where: {
friendlyId: bulkId,
projectId,
environmentId,
},
});
const [possibleTasks, bulkActions, displayableEnvironment] = await Promise.all([
possibleTasksAsync,
bulkActionsAsync,
findDisplayableEnvironment(environmentId, userId),
]);

if (bulkId && !bulkActions.some((bulkAction) => bulkAction.friendlyId === bulkId)) {
const selectedBulkAction = await this.replica.bulkActionGroup.findFirst({
select: {
friendlyId: true,
type: true,
createdAt: true,
name: true,
},
where: {
friendlyId: bulkId,
projectId,
environmentId,
},
});

if (selectedBulkAction) {
bulkActions.push(selectedBulkAction);
Expand Down Expand Up @@ -371,7 +365,22 @@ export class LogsListPresenter {
}
}

const queryBuilder = this.clickhouse.taskEventsV2.logsListQueryBuilder();
// Determine which store to use based on organization configuration
const { store } = await getConfiguredEventRepository(organizationId);

// Throw error if postgres is detected
if (store === "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";

const queryBuilder = isClickhouseV2
? this.clickhouse.taskEventsV2.logsListQueryBuilder()
: this.clickhouse.taskEvents.logsListQueryBuilder();

queryBuilder.prewhere("environment_id = {environmentId: String}", {
environmentId,
Expand All @@ -382,12 +391,17 @@ export class LogsListPresenter {
});
queryBuilder.where("project_id = {projectId: String}", { projectId });

// Time filters - inserted_at in PREWHERE for partition pruning, start_time in WHERE
// Time filters - inserted_at in PREWHERE only for v2, start_time in WHERE for both
if (effectiveFrom) {
const fromNs = convertDateToNanoseconds(effectiveFrom);
queryBuilder.prewhere("inserted_at >= {insertedAtStart: DateTime64(3)}", {
insertedAtStart: convertDateToClickhouseDateTime(effectiveFrom),
});

// Only use inserted_at for partition pruning if v2
if (isClickhouseV2) {
queryBuilder.prewhere("inserted_at >= {insertedAtStart: DateTime64(3)}", {
insertedAtStart: convertDateToClickhouseDateTime(effectiveFrom),
});
}

queryBuilder.where("start_time >= {fromTime: String}", {
fromTime: formatNanosecondsForClickhouse(fromNs),
});
Expand All @@ -396,9 +410,14 @@ export class LogsListPresenter {
if (effectiveTo) {
const clampedTo = effectiveTo > new Date() ? new Date() : effectiveTo;
const toNs = convertDateToNanoseconds(clampedTo);
queryBuilder.prewhere("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
});

// Only use inserted_at for partition pruning if v2
if (isClickhouseV2) {
queryBuilder.prewhere("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
});
}

queryBuilder.where("start_time <= {toTime: String}", {
toTime: formatNanosecondsForClickhouse(toNs),
});
Expand Down Expand Up @@ -428,7 +447,6 @@ export class LogsListPresenter {
);
}


if (levels && levels.length > 0) {
const conditions: string[] = [];
const params: Record<string, string[]> = {};
Expand Down Expand Up @@ -477,7 +495,6 @@ export class LogsListPresenter {

queryBuilder.where("NOT (kind = 'SPAN' AND status = 'PARTIAL')");


// Cursor pagination
const decodedCursor = cursor ? decodeCursor(cursor) : null;
if (decodedCursor) {
Expand Down Expand Up @@ -525,11 +542,11 @@ export class LogsListPresenter {
let displayMessage = log.message;

// For error logs with status ERROR, try to extract error message from attributes
if (log.status === "ERROR" && log.attributes) {
if (log.status === "ERROR" && log.attributes_text) {
try {
let attributes = log.attributes as ErrorAttributes;
let attributes = JSON.parse(log.attributes_text) as ErrorAttributes;

if (attributes?.error?.message && typeof attributes.error.message === 'string') {
if (attributes?.error?.message && typeof attributes.error.message === "string") {
displayMessage = attributes.error.message;
}
} catch {
Expand Down
Loading
Loading