logs.ts208 lines · main
1import pc from 'picocolors';
2
3import { ApiCallError } from '../api-client.js';
4import { readCredentials } from '../config.js';
5import { readProjectConfig } from '../project-config.js';
6import { banner, blankLine, error as printError, step, success } from '../output.js';
7
8interface StreamOptions {
9 tail: boolean;
10 n: number;
11 sinceMs: number | null;
12}
13
14export async function runLogs(argv: readonly string[]): Promise<number> {
15 if (argv[0] === '--help' || argv[0] === '-h') {
16 printHelp();
17 return 0;
18 }
19 const opts = parseFlags(argv);
20 if (!opts.tail) {
21 // No other modes yet — tail is the default behaviour.
22 opts.tail = true;
23 }
24
25 const local = await readProjectConfig();
26 if (!local) {
27 printError('no briven.json in this directory.');
28 step('run: briven init');
29 return 1;
30 }
31 if (!local.projectId) {
32 printError('briven.json has no projectId — link this directory first.');
33 step('run: briven link');
34 return 1;
35 }
36 const creds = await readCredentials();
37 const cred = creds.projects[local.projectId];
38 if (!cred) {
39 printError(`no stored credentials for ${local.projectId}.`);
40 return 1;
41 }
42
43 banner(`logs ${local.projectId}`);
44 blankLine();
45
46 const qs = new URLSearchParams();
47 if (opts.sinceMs !== null) {
48 // Redis stream ids are `<ms>-<seq>`; clamping seq to 0 makes XRANGE
49 // inclusive from that wall-clock.
50 const sinceId = `${Date.now() - opts.sinceMs}-0`;
51 qs.set('since', sinceId);
52 } else if (opts.n > 0) {
53 qs.set('n', String(opts.n));
54 }
55
56 const url = `${cred.apiOrigin}/v1/projects/${local.projectId}/logs/stream${
57 qs.toString() ? `?${qs.toString()}` : ''
58 }`;
59
60 let backoff = 1_000;
61 while (true) {
62 try {
63 await streamOnce(url, cred.apiKey);
64 // Server closed cleanly — rare; reconnect with short delay.
65 await sleep(500);
66 backoff = 1_000;
67 } catch (err) {
68 if (err instanceof ApiCallError && err.status === 429) {
69 printError('rate limited — too many concurrent log streams on this project');
70 return 1;
71 }
72 const message = err instanceof Error ? err.message : String(err);
73 step(pc.dim(`log stream lost: ${message}; reconnecting in ${backoff / 1000}s`));
74 await sleep(backoff);
75 backoff = Math.min(backoff * 2, 10_000);
76 }
77 }
78}
79
80function printHelp(): void {
81 banner('logs');
82 blankLine();
83 step('briven logs stream new function invocation logs');
84 step('briven logs --tail same as above');
85 step('briven logs --tail -n 50 replay last 50, then follow');
86 step('briven logs --since 10m replay last 10 minutes, then follow');
87}
88
89function parseFlags(argv: readonly string[]): StreamOptions {
90 const out: StreamOptions = { tail: false, n: 0, sinceMs: null };
91 for (let i = 0; i < argv.length; i += 1) {
92 const a = argv[i]!;
93 if (a === '--tail') out.tail = true;
94 else if (a === '-n') out.n = Number(argv[++i] ?? '0');
95 else if (a.startsWith('-n=')) out.n = Number(a.slice(3));
96 else if (a === '--since') out.sinceMs = parseDuration(argv[++i] ?? '');
97 else if (a.startsWith('--since=')) out.sinceMs = parseDuration(a.slice('--since='.length));
98 }
99 return out;
100}
101
102function parseDuration(raw: string): number | null {
103 if (!raw) return null;
104 const m = raw.match(/^(\d+)(s|m|h)$/);
105 if (!m) return null;
106 const n = Number(m[1]);
107 switch (m[2]) {
108 case 's':
109 return n * 1_000;
110 case 'm':
111 return n * 60_000;
112 case 'h':
113 return n * 3_600_000;
114 default:
115 return null;
116 }
117}
118
119async function streamOnce(url: string, apiKey: string): Promise<void> {
120 const res = await fetch(url, {
121 headers: {
122 accept: 'text/event-stream',
123 authorization: `Bearer ${apiKey}`,
124 },
125 });
126 if (!res.ok) {
127 throw new ApiCallError(res.status, 'http_error', await res.text().catch(() => res.statusText));
128 }
129 if (!res.body) {
130 throw new Error('empty response body');
131 }
132
133 const reader = res.body.getReader();
134 const decoder = new TextDecoder();
135 let buf = '';
136 while (true) {
137 const { value, done } = await reader.read();
138 if (done) return;
139 buf += decoder.decode(value, { stream: true });
140 let idx: number;
141 while ((idx = buf.indexOf('\n\n')) !== -1) {
142 const raw = buf.slice(0, idx);
143 buf = buf.slice(idx + 2);
144 const msg = parseSseBlock(raw);
145 if (!msg || msg.event === 'ping') continue;
146 if (!msg.data) continue;
147 try {
148 const parsed = JSON.parse(msg.data) as Record<string, string>;
149 renderInvocation(parsed);
150 } catch {
151 // malformed line — skip
152 }
153 }
154 }
155}
156
157function parseSseBlock(block: string): { event?: string; data?: string } | null {
158 const out: { event?: string; data?: string } = {};
159 for (const line of block.split('\n')) {
160 if (line.startsWith(':')) continue;
161 const colon = line.indexOf(':');
162 if (colon === -1) continue;
163 const field = line.slice(0, colon);
164 let value = line.slice(colon + 1);
165 if (value.startsWith(' ')) value = value.slice(1);
166 if (field === 'event') out.event = value;
167 else if (field === 'data') out.data = (out.data ?? '') + value;
168 }
169 return out;
170}
171
172interface UserLog {
173 level: string;
174 message: string;
175 fieldsJson?: string;
176 ts: string;
177}
178
179function renderInvocation(raw: Record<string, string>): void {
180 const ts = raw.ts ? new Date(raw.ts) : new Date();
181 const clock = ts.toISOString().slice(11, 19);
182 const status = raw.status === 'ok' ? pc.green('ok ') : pc.red('err');
183 const duration = `${raw.durationMs ?? '0'}ms`;
184 const name = raw.functionName ?? '<unknown>';
185 step(`${clock} ${name.padEnd(22)} ${status} ${duration}`);
186
187 let userLogs: UserLog[] = [];
188 try {
189 userLogs = JSON.parse(raw.logs ?? '[]') as UserLog[];
190 } catch {
191 // ignore
192 }
193 for (const entry of userLogs) {
194 const label = entry.level === 'error' ? pc.red('err') : pc.dim('log');
195 step(` ${pc.dim('↳')} ${label} ${entry.message}`);
196 }
197 if (raw.status !== 'ok' && raw.errMessage) {
198 step(` ${pc.dim('↳')} ${pc.red('err')} ${raw.errMessage}`);
199 }
200}
201
202function sleep(ms: number): Promise<void> {
203 return new Promise((resolve) => setTimeout(resolve, ms));
204}
205
206// Reference `success` to satisfy linters in future extensions — kept out
207// of the main path deliberately (we never "finish" tailing).
208void success;