Skip to content

Commit e9e459b

Browse files
authored
Merge pull request #289 from techulus/feat/show-cron-logs
Show cron runs in service logs
2 parents a1df38a + 1e39774 commit e9e459b

4 files changed

Lines changed: 146 additions & 13 deletions

File tree

‎web/app/api/services/[id]/logs/route.ts‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@ import {
55
isLoggingEnabled,
66
type LogType,
77
queryLogsByService,
8+
type StoredLog,
89
} from "@/lib/victoria-logs";
910

11+
function streamOf(log: StoredLog): string {
12+
if (log.stream) return log.stream;
13+
if (log.log_type === "http") return "http";
14+
if (log.log_type === "cron") return "cron";
15+
return "stdout";
16+
}
17+
1018
export async function GET(
1119
request: Request,
1220
{ params }: { params: Promise<{ id: string }> },
@@ -36,7 +44,8 @@ export async function GET(
3644
const logType =
3745
logTypeParam === "container" ||
3846
logTypeParam === "http" ||
39-
logTypeParam === "cron"
47+
logTypeParam === "cron" ||
48+
logTypeParam === "container-cron"
4049
? (logTypeParam as LogType)
4150
: undefined;
4251

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

5160
const logs = result.logs.map((log) => ({
52-
id: `${log.deployment_id || log.service_id}-${log._time}`,
61+
id: [log.deployment_id || log.service_id, log.cron_id, log._time]
62+
.filter(Boolean)
63+
.join("-"),
5364
deploymentId: log.deployment_id,
54-
stream: log.stream || (log.log_type === "http" ? "http" : "stdout"),
65+
stream: streamOf(log),
5566
message: log._msg,
5667
timestamp: log._time,
5768
logType: log.log_type || "container",
@@ -60,6 +71,7 @@ export async function GET(
6071
path: log.path,
6172
duration: log.duration_ms,
6273
clientIp: log.client_ip,
74+
error: log.error,
6375
}));
6476

6577
return Response.json({

‎web/components/logs/log-viewer.tsx‎

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,12 @@ interface BaseEntry {
5353
interface ServiceLogEntry extends BaseEntry {
5454
id: string;
5555
deploymentId?: string;
56-
stream: "stdout" | "stderr";
56+
stream: "stdout" | "stderr" | "cron";
5757
message: string;
58+
path?: string;
59+
status?: number | null;
60+
duration?: number | null;
61+
error?: string | null;
5862
}
5963

6064
interface RequestEntry extends BaseEntry {
@@ -194,7 +198,7 @@ function buildLogEndpoint(
194198
case "service-logs":
195199
path = `/api/services/${props.serviceId}/logs`;
196200
params.set("limit", "500");
197-
params.set("type", "container");
201+
params.set("type", "container-cron");
198202
if (filterServerId) params.set("serverId", filterServerId);
199203
break;
200204
case "requests":
@@ -271,13 +275,17 @@ function ServiceLogsFilters({
271275
onShowStdoutChange,
272276
showStderr,
273277
onShowStderrChange,
278+
showCron,
279+
onShowCronChange,
274280
}: {
275281
levels: Set<LogLevel>;
276282
onLevelsChange: (levels: Set<LogLevel>) => void;
277283
showStdout: boolean;
278284
onShowStdoutChange: (show: boolean) => void;
279285
showStderr: boolean;
280286
onShowStderrChange: (show: boolean) => void;
287+
showCron: boolean;
288+
onShowCronChange: (show: boolean) => void;
281289
}) {
282290
const toggleLevel = (level: LogLevel) => {
283291
const newLevels = new Set(levels);
@@ -360,6 +368,13 @@ function ServiceLogsFilters({
360368
>
361369
stderr
362370
</Button>
371+
<Button
372+
variant={showCron ? "secondary" : "outline"}
373+
size="sm"
374+
onClick={() => onShowCronChange(!showCron)}
375+
>
376+
cron
377+
</Button>
363378
</div>
364379
</>
365380
);
@@ -544,13 +559,26 @@ function ServerFilter({
544559
);
545560
}
546561

562+
function formatCronMessage(entry: ServiceLogEntry): string {
563+
const parts = [entry.message];
564+
if (entry.path) parts.push(entry.path);
565+
if (entry.status != null) parts.push(String(entry.status));
566+
if (entry.duration != null)
567+
parts.push(`${Math.round(Number(entry.duration) || 0)}ms`);
568+
if (entry.error) parts.push(entry.error);
569+
return parts.join(" · ");
570+
}
571+
547572
function ServiceLogRow({
548573
entry,
549574
search,
550575
}: {
551576
entry: ServiceLogEntry;
552577
search: string;
553578
}) {
579+
if (entry.stream === "cron")
580+
return <CronLogRow entry={entry} search={search} />;
581+
554582
const level = detectLevel(entry.message);
555583

556584
return (
@@ -592,6 +620,41 @@ function ServiceLogRow({
592620
);
593621
}
594622

623+
function CronLogRow({
624+
entry,
625+
search,
626+
}: {
627+
entry: ServiceLogEntry;
628+
search: string;
629+
}) {
630+
const failed = !!entry.error;
631+
632+
return (
633+
<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">
634+
<div className="flex items-baseline sm:contents">
635+
<span
636+
className="shrink-0 w-[70px] text-slate-400 dark:text-slate-600 select-none pr-2 tabular-nums"
637+
title={formatPreciseDateTime(entry.timestamp)}
638+
>
639+
{formatTime(entry.timestamp)}
640+
</span>
641+
<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">
642+
cron
643+
</span>
644+
</div>
645+
<span
646+
className={`break-all whitespace-pre-wrap ${
647+
failed
648+
? "text-red-600 dark:text-red-400"
649+
: "text-slate-800 dark:text-slate-200"
650+
}`}
651+
>
652+
{highlightMatches(formatCronMessage(entry), search)}
653+
</span>
654+
</div>
655+
);
656+
}
657+
595658
function RequestRow({
596659
entry,
597660
search,
@@ -697,7 +760,9 @@ function serializeLogs(
697760
.map((log) => {
698761
if (variant === "service-logs") {
699762
const entry = log as ServiceLogEntry;
700-
return `[${entry.timestamp}] [${entry.stream}] ${entry.message}`;
763+
const message =
764+
entry.stream === "cron" ? formatCronMessage(entry) : entry.message;
765+
return `[${entry.timestamp}] [${entry.stream}] ${message}`;
701766
}
702767
if (variant === "requests") {
703768
const entry = log as RequestEntry;
@@ -754,6 +819,10 @@ export function LogViewer(props: LogViewerProps) {
754819
"stderr",
755820
parseAsBoolean.withDefault(true),
756821
);
822+
const [showCron, setShowCron] = useQueryState(
823+
"cron",
824+
parseAsBoolean.withDefault(true),
825+
);
757826

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

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

9461016
return true;
9471017
});
948-
}, [logs, props.variant, levels, showStdout, showStderr, statusFilter]);
1018+
}, [
1019+
logs,
1020+
props.variant,
1021+
levels,
1022+
showStdout,
1023+
showStderr,
1024+
showCron,
1025+
statusFilter,
1026+
]);
9491027
const logCount = logs.length;
9501028
const filteredLogCount = filteredLogs.length;
9511029
const newestFilteredLogTimestamp = (
@@ -1083,6 +1161,8 @@ export function LogViewer(props: LogViewerProps) {
10831161
onShowStdoutChange={setShowStdout}
10841162
showStderr={showStderr}
10851163
onShowStderrChange={setShowStderr}
1164+
showCron={showCron}
1165+
onShowCronChange={setShowCron}
10861166
/>
10871167
)}
10881168

‎web/lib/victoria-logs.ts‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,14 @@ function getQueryEndpoint(): EndpointConfig | undefined {
2020
return parseEndpoint(endpoint);
2121
}
2222

23-
export type LogType = "container" | "http" | "cron";
24-
type LogSearchField = "_msg" | "path" | "method" | "status" | "client_ip";
23+
export type LogType = "container" | "http" | "cron" | "container-cron";
24+
type LogSearchField =
25+
| "_msg"
26+
| "path"
27+
| "method"
28+
| "status"
29+
| "client_ip"
30+
| "error";
2531

2632
export type StoredLog = {
2733
_msg: string;
@@ -35,10 +41,12 @@ export type StoredLog = {
3541
host?: string;
3642
method?: string;
3743
path?: string;
38-
status?: number;
44+
status?: number | null;
3945
duration_ms?: number;
4046
size?: number;
4147
client_ip?: string;
48+
cron_id?: string;
49+
error?: string | null;
4250
};
4351

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

120+
function serviceLogSearchFields(
121+
logType: LogType | undefined,
122+
): readonly LogSearchField[] {
123+
if (logType === "http")
124+
return ["_msg", "path", "method", "status", "client_ip"];
125+
if (logType === "cron" || logType === "container-cron")
126+
return ["_msg", "path", "status", "error"];
127+
return ["_msg"];
128+
}
129+
112130
function buildServiceLogFilter(options: QueryLogsByServiceOptions): string {
113131
const { serviceId, logType, serverId, search, range } = options;
114132
let query = formatLogSqlExactFilter("service_id", serviceId);
@@ -118,6 +136,8 @@ function buildServiceLogFilter(options: QueryLogsByServiceOptions): string {
118136
query += ` log_type:cron`;
119137
} else if (logType === "container") {
120138
query += ` -log_type:http -log_type:build -log_type:rollout -log_type:cron`;
139+
} else if (logType === "container-cron") {
140+
query += ` -log_type:http -log_type:build -log_type:rollout`;
121141
} else {
122142
query += ` -log_type:build -log_type:rollout`;
123143
}
@@ -129,9 +149,7 @@ function buildServiceLogFilter(options: QueryLogsByServiceOptions): string {
129149
}
130150
const searchFilter = formatLogSqlSearchFilter(
131151
search,
132-
logType === "http"
133-
? ["_msg", "path", "method", "status", "client_ip"]
134-
: ["_msg"],
152+
serviceLogSearchFields(logType),
135153
);
136154
if (searchFilter) {
137155
query += ` ${searchFilter}`;

‎web/tests/victoria-logs.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,29 @@ describe("VictoriaLogs queries", () => {
387387
expect(queries[2]).toContain("-log_type:cron");
388388
});
389389

390+
it("keeps cron logs and drops HTTP logs for the combined service log filter", async () => {
391+
const { queryLogsByService } = await loadVictoriaLogs();
392+
const queries: string[] = [];
393+
vi.stubGlobal(
394+
"fetch",
395+
vi.fn(async (input: string | URL | Request) => {
396+
queries.push(new URL(String(input)).searchParams.get("query") || "");
397+
return jsonLinesResponse([]);
398+
}),
399+
);
400+
await queryLogsByService({
401+
serviceId: "service-1",
402+
limit: 1,
403+
logType: "container-cron",
404+
search: "500",
405+
});
406+
expect(queries[0]).toContain("-log_type:http");
407+
expect(queries[0]).not.toContain("-log_type:cron");
408+
for (const field of ["_msg", "path", "status", "error"]) {
409+
expect(queries[0]).toContain(`${field}:~`);
410+
}
411+
});
412+
390413
it("ingests only supplied cron metadata with a five-second deadline", async () => {
391414
const { ingestCronLog } = await loadVictoriaLogs();
392415
const fetchMock = vi.fn(

0 commit comments

Comments
 (0)