1import * as Console from "effect/Console";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Stream from "effect/Stream";
5import { Command, Flag } from "effect/cli";
6
7import * as Logs from "../../Alchemist/routes/logs.ts";
8import { paint } from "../CliKit/index.ts";
9import { formatLocalTimestamp, TAIL_COLORS } from "../Format.ts";
10import {
11 config,
12 envFile,
13 parseSince,
14 profile,
15 resolveStage,
16 stage,
17} from "./flags.ts";
18import { instrumentCommand } from "./instrument.ts";
19
20const logsLimit = Flag.Int("limit").pipe(
21 Flag.withDescription("Number of log entries to fetch (default: 100)"),
22 Flag.withDefault(100),
23);
24
25const tail = Flag.Boolean("tail").pipe(
26 Flag.withAlias("t"),
27 Flag.withDescription("Continue streaming new log entries"),
28 Flag.withDefault(false),
29);
30
31const resources = Flag.String("resource").pipe(
32 Flag.withAlias("r"),
33 Flag.withDescription(
34 "Comma-separated logical resource IDs to include (for example Worker,Api)",
35 ),
36 Flag.optional,
37 Flag.map(Option.getOrUndefined),
38);
39
40const logsSince = Flag.String("since").pipe(
41 Flag.withDescription(
42 "Fetch logs since this time (e.g. '1h', '30m', '2024-01-01T00:00:00Z')",
43 ),
44 Flag.optional,
45 Flag.map(Option.getOrUndefined),
46);
47
48interface LogsArgs {
49 readonly main: string;
50 readonly stage: string;
51 readonly envFile: Option.Option<string>;
52 readonly profile: string | undefined;
53 readonly resources: string | undefined;
54 readonly limit: number;
55 readonly since: string | undefined;
56 readonly tail: boolean;
57}
58
59const runLogs = Effect.fn(function* ({
60 main,
61 stage,
62 envFile,
63 profile,
64 resources,
65 limit,
66 since,
67 tail,
68}: LogsArgs) {
69 const sinceDate = since ? yield* parseSince(since) : undefined;
70 const selected = (resources ?? "")
71 .split(",")
72 .map((resource) => resource.trim())
73 .filter((resource) => resource.length > 0);
74 const target = {
75 entrypoint: main,
76 stage,
77 profile,
78 envFile: Option.getOrUndefined(envFile),
79 };
80 const available = yield* Logs.resources(target);
81 const selectedSet = new Set(selected);
82 const matching = available.filter(
83 (resource) =>
84 selectedSet.size === 0 ||
85 selectedSet.has(resource.fqn) ||
86 selectedSet.has(resource.logicalId),
87 );
88 const colors = new Map(
89 matching.map((resource, index) => [
90 resource.fqn,
91 TAIL_COLORS[index % TAIL_COLORS.length]!,
92 ]),
93 );
94 const format = (entry: {
95 resource: { fqn: string };
96 timestamp: Date;
97 message: string;
98 }) =>
99 `${paint(colors.get(entry.resource.fqn) ?? "gray", `${formatLocalTimestamp(entry.timestamp)} [${entry.resource.fqn}]`)} ${entry.message}`;
100
101 if (tail) {
102 const tailing = matching.filter((resource) => resource.supportsTail);
103 if (tailing.length === 0) {
104 yield* Console.log("No matching resources support live logs.");
105 return;
106 }
107 yield* Console.log(
108 `Tailing: ${tailing.map(({ logicalId }) => logicalId).join(", ")}`,
109 );
110 yield* Logs.tail({ target, resources: selected }).pipe(
111 Stream.runForEach((entry) => Console.log(format(entry))),
112 );
113 return;
114 }
115
116 const entries = yield* Logs.entries({
117 target,
118 resources: selected,
119 limit,
120 since: sinceDate,
121 });
122 if (entries.length === 0) {
123 yield* Console.log(
124 selected.length > 0
125 ? "No resources with logs match --resource (deploy first, or selected resources may not expose logs)."
126 : "No resources with logs found. Deploy first, then run logs.",
127 );
128 return;
129 }
130 for (const entry of entries) yield* Console.log(format(entry));
131});
132
133export const logsCommand = Command.make(
134 "logs",
135 {
136 main: config,
137 envFile,
138 stage,
139 profile,
140 resources,
141 limit: logsLimit,
142 since: logsSince,
143 tail,
144 },
145 (args) =>
146 resolveStage("live", args.stage, args.envFile).pipe(
147 Effect.flatMap((stage) =>
148 instrumentCommand("logs", (a: LogsArgs) => ({
149 "alchemy.stage": a.stage,
150 "alchemy.profile": a.profile ?? "",
151 "alchemy.main": a.main,
152 "alchemy.limit": a.limit,
153 "alchemy.tail": a.tail,
154 }))(runLogs)({ ...args, stage }),
155 ),
156 ),
157).pipe(Command.withDescription("Fetch or tail logs from stack resources"));