Skip to content
Merged
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
18 changes: 15 additions & 3 deletions web/app/api/services/[id]/logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@ import {
isLoggingEnabled,
type LogType,
queryLogsByService,
type StoredLog,
} from "@/lib/victoria-logs";

function streamOf(log: StoredLog): string {
if (log.stream) return log.stream;
if (log.log_type === "http") return "http";
if (log.log_type === "cron") return "cron";
return "stdout";
}

export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
Expand Down Expand Up @@ -36,7 +44,8 @@ export async function GET(
const logType =
logTypeParam === "container" ||
logTypeParam === "http" ||
logTypeParam === "cron"
logTypeParam === "cron" ||
logTypeParam === "container-cron"
? (logTypeParam as LogType)
: undefined;

Expand All @@ -49,9 +58,11 @@ export async function GET(
});

const logs = result.logs.map((log) => ({
id: `${log.deployment_id || log.service_id}-${log._time}`,
id: [log.deployment_id || log.service_id, log.cron_id, log._time]
.filter(Boolean)
.join("-"),
deploymentId: log.deployment_id,
stream: log.stream || (log.log_type === "http" ? "http" : "stdout"),
stream: streamOf(log),
message: log._msg,
timestamp: log._time,
logType: log.log_type || "container",
Expand All @@ -60,6 +71,7 @@ export async function GET(
path: log.path,
duration: log.duration_ms,
clientIp: log.client_ip,
error: log.error,
}));

return Response.json({
Expand Down
88 changes: 84 additions & 4 deletions web/components/logs/log-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,12 @@ interface BaseEntry {
interface ServiceLogEntry extends BaseEntry {
id: string;
deploymentId?: string;
stream: "stdout" | "stderr";
stream: "stdout" | "stderr" | "cron";
message: string;
path?: string;
status?: number | null;
duration?: number | null;
error?: string | null;
}

interface RequestEntry extends BaseEntry {
Expand Down Expand Up @@ -194,7 +198,7 @@ function buildLogEndpoint(
case "service-logs":
path = `/api/services/${props.serviceId}/logs`;
params.set("limit", "500");
params.set("type", "container");
params.set("type", "container-cron");
if (filterServerId) params.set("serverId", filterServerId);
break;
case "requests":
Expand Down Expand Up @@ -271,13 +275,17 @@ function ServiceLogsFilters({
onShowStdoutChange,
showStderr,
onShowStderrChange,
showCron,
onShowCronChange,
}: {
levels: Set<LogLevel>;
onLevelsChange: (levels: Set<LogLevel>) => void;
showStdout: boolean;
onShowStdoutChange: (show: boolean) => void;
showStderr: boolean;
onShowStderrChange: (show: boolean) => void;
showCron: boolean;
onShowCronChange: (show: boolean) => void;
}) {
const toggleLevel = (level: LogLevel) => {
const newLevels = new Set(levels);
Expand Down Expand Up @@ -360,6 +368,13 @@ function ServiceLogsFilters({
>
stderr
</Button>
<Button
variant={showCron ? "secondary" : "outline"}
size="sm"
onClick={() => onShowCronChange(!showCron)}
>
cron
</Button>
</div>
</>
);
Expand Down Expand Up @@ -544,13 +559,26 @@ function ServerFilter({
);
}

function formatCronMessage(entry: ServiceLogEntry): string {
const parts = [entry.message];
if (entry.path) parts.push(entry.path);
if (entry.status != null) parts.push(String(entry.status));
if (entry.duration != null)
parts.push(`${Math.round(Number(entry.duration) || 0)}ms`);
if (entry.error) parts.push(entry.error);
return parts.join(" · ");
}

function ServiceLogRow({
entry,
search,
}: {
entry: ServiceLogEntry;
search: string;
}) {
if (entry.stream === "cron")
return <CronLogRow entry={entry} search={search} />;

const level = detectLevel(entry.message);

return (
Expand Down Expand Up @@ -592,6 +620,41 @@ function ServiceLogRow({
);
}

function CronLogRow({
entry,
search,
}: {
entry: ServiceLogEntry;
search: string;
}) {
const failed = !!entry.error;

return (
<div className="flex flex-col sm:flex-row hover:bg-black/5 dark:hover:bg-white/5 -mx-2 px-2 py-1 sm:py-0.5 group">
<div className="flex items-baseline sm:contents">
<span
className="shrink-0 w-[70px] text-slate-400 dark:text-slate-600 select-none pr-2 tabular-nums"
title={formatPreciseDateTime(entry.timestamp)}
>
{formatTime(entry.timestamp)}
</span>
<span className="shrink-0 w-[50px] text-center px-1 rounded text-[10px] mr-2 text-amber-600 dark:text-amber-400 bg-amber-500/10">
cron
</span>
</div>
<span
className={`break-all whitespace-pre-wrap ${
failed
? "text-red-600 dark:text-red-400"
: "text-slate-800 dark:text-slate-200"
}`}
>
{highlightMatches(formatCronMessage(entry), search)}
</span>
</div>
);
}

function RequestRow({
entry,
search,
Expand Down Expand Up @@ -697,7 +760,9 @@ function serializeLogs(
.map((log) => {
if (variant === "service-logs") {
const entry = log as ServiceLogEntry;
return `[${entry.timestamp}] [${entry.stream}] ${entry.message}`;
const message =
entry.stream === "cron" ? formatCronMessage(entry) : entry.message;
return `[${entry.timestamp}] [${entry.stream}] ${message}`;
}
if (variant === "requests") {
const entry = log as RequestEntry;
Expand Down Expand Up @@ -754,6 +819,10 @@ export function LogViewer(props: LogViewerProps) {
"stderr",
parseAsBoolean.withDefault(true),
);
const [showCron, setShowCron] = useQueryState(
"cron",
parseAsBoolean.withDefault(true),
);

const [statusParam, setStatusParam] = useQueryState(
"status",
Expand Down Expand Up @@ -931,6 +1000,7 @@ export function LogViewer(props: LogViewerProps) {
const entry = log as ServiceLogEntry;
if (entry.stream === "stdout" && !showStdout) return false;
if (entry.stream === "stderr" && !showStderr) return false;
if (entry.stream === "cron") return showCron;

const level = detectLevel(entry.message);
if (level && !levels.has(level)) return false;
Expand All @@ -945,7 +1015,15 @@ export function LogViewer(props: LogViewerProps) {

return true;
});
}, [logs, props.variant, levels, showStdout, showStderr, statusFilter]);
}, [
logs,
props.variant,
levels,
showStdout,
showStderr,
showCron,
statusFilter,
]);
const logCount = logs.length;
const filteredLogCount = filteredLogs.length;
const newestFilteredLogTimestamp = (
Expand Down Expand Up @@ -1083,6 +1161,8 @@ export function LogViewer(props: LogViewerProps) {
onShowStdoutChange={setShowStdout}
showStderr={showStderr}
onShowStderrChange={setShowStderr}
showCron={showCron}
onShowCronChange={setShowCron}
/>
)}

Expand Down
30 changes: 24 additions & 6 deletions web/lib/victoria-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ function getQueryEndpoint(): EndpointConfig | undefined {
return parseEndpoint(endpoint);
}

export type LogType = "container" | "http" | "cron";
type LogSearchField = "_msg" | "path" | "method" | "status" | "client_ip";
export type LogType = "container" | "http" | "cron" | "container-cron";
type LogSearchField =
| "_msg"
| "path"
| "method"
| "status"
| "client_ip"
| "error";

export type StoredLog = {
_msg: string;
Expand All @@ -35,10 +41,12 @@ export type StoredLog = {
host?: string;
method?: string;
path?: string;
status?: number;
status?: number | null;
duration_ms?: number;
size?: number;
client_ip?: string;
cron_id?: string;
error?: string | null;
};

const publicServiceLogEventIdPattern = /^e[0-9]{19}[a-z]{26}$/;
Expand Down Expand Up @@ -109,6 +117,16 @@ type PublicServiceLogsOptions = Omit<
cursor?: PublicServiceLogCursor;
};

function serviceLogSearchFields(
logType: LogType | undefined,
): readonly LogSearchField[] {
if (logType === "http")
return ["_msg", "path", "method", "status", "client_ip"];
if (logType === "cron" || logType === "container-cron")
return ["_msg", "path", "status", "error"];
return ["_msg"];
}

function buildServiceLogFilter(options: QueryLogsByServiceOptions): string {
const { serviceId, logType, serverId, search, range } = options;
let query = formatLogSqlExactFilter("service_id", serviceId);
Expand All @@ -118,6 +136,8 @@ function buildServiceLogFilter(options: QueryLogsByServiceOptions): string {
query += ` log_type:cron`;
} else if (logType === "container") {
query += ` -log_type:http -log_type:build -log_type:rollout -log_type:cron`;
} else if (logType === "container-cron") {
query += ` -log_type:http -log_type:build -log_type:rollout`;
} else {
query += ` -log_type:build -log_type:rollout`;
}
Expand All @@ -129,9 +149,7 @@ function buildServiceLogFilter(options: QueryLogsByServiceOptions): string {
}
const searchFilter = formatLogSqlSearchFilter(
search,
logType === "http"
? ["_msg", "path", "method", "status", "client_ip"]
: ["_msg"],
serviceLogSearchFields(logType),
);
if (searchFilter) {
query += ` ${searchFilter}`;
Expand Down
23 changes: 23 additions & 0 deletions web/tests/victoria-logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,29 @@ describe("VictoriaLogs queries", () => {
expect(queries[2]).toContain("-log_type:cron");
});

it("keeps cron logs and drops HTTP logs for the combined service log filter", async () => {
const { queryLogsByService } = await loadVictoriaLogs();
const queries: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: string | URL | Request) => {
queries.push(new URL(String(input)).searchParams.get("query") || "");
return jsonLinesResponse([]);
}),
);
await queryLogsByService({
serviceId: "service-1",
limit: 1,
logType: "container-cron",
search: "500",
});
expect(queries[0]).toContain("-log_type:http");
expect(queries[0]).not.toContain("-log_type:cron");
for (const field of ["_msg", "path", "status", "error"]) {
expect(queries[0]).toContain(`${field}:~`);
}
});

it("ingests only supplied cron metadata with a five-second deadline", async () => {
const { ingestCronLog } = await loadVictoriaLogs();
const fetchMock = vi.fn(
Expand Down
Loading