File size: 6,561 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
import { writeFileSync, appendFileSync, existsSync, unlinkSync } from "node:fs";
import { t } from "../i18n.mjs";
import { getBaseUrl, buildHeaders } from "../api.mjs";

export function registerLogs(program) {
  program
    .command("logs")
    .description(t("logs.description"))
    .option("--follow", t("logs.follow"))
    .option("--filter <level>", t("logs.filter"))
    .option("--lines <n>", t("logs.lines"), "100")
    .option("--timeout <ms>", t("logs.timeout"), "30000")
    .option("--base-url <url>", t("logs.baseUrl"))
    .option("--request-id <id>", t("logs.requestId"))
    .option("--api-key <key>", t("logs.apiKey"))
    .option("--combo <name>", t("logs.combo"))
    .option("--status <code>", t("logs.status"))
    .option("--duration-min <ms>", t("logs.durationMin"), parseInt)
    .option("--duration-max <ms>", t("logs.durationMax"), parseInt)
    .option("--export <path>", t("logs.export"))
    .action(async (opts, cmd) => {
      const globalOpts = cmd.optsWithGlobals();
      // `--context` and `--output` are global options, so forward them explicitly:
      // runLogsCommand resolves the base URL via getBaseUrl({ context }), and without
      // this a user's `--context` would be silently dropped.
      const exitCode = await runLogsCommand({
        ...opts,
        context: globalOpts.context,
        output: globalOpts.output,
      });
      if (exitCode !== 0) process.exit(exitCode);
    });
}

function buildLogFilter(opts) {
  const levelFilters = opts.filter ? opts.filter.split(",").map((f) => f.trim()) : [];
  const requestId = opts.requestId;
  const apiKey = opts.apiKey;
  const combo = opts.combo;
  const statusFilter = opts.status != null ? String(opts.status) : null;
  const durationMin = opts.durationMin != null ? Number(opts.durationMin) : null;
  const durationMax = opts.durationMax != null ? Number(opts.durationMax) : null;

  return function matchesLog(parsed) {
    if (levelFilters.length > 0) {
      const level = String(parsed.level || "info").toLowerCase();
      if (!levelFilters.includes(level)) return false;
    }
    if (requestId) {
      const rid = String(parsed.requestId || parsed.request_id || "");
      if (!rid.includes(requestId)) return false;
    }
    if (apiKey) {
      const key = String(parsed.apiKey || parsed.api_key || parsed.key || "");
      if (!key.includes(apiKey)) return false;
    }
    if (combo) {
      const c = String(parsed.combo || parsed.comboName || parsed.combo_name || "");
      if (!c.includes(combo)) return false;
    }
    if (statusFilter) {
      const s = String(parsed.status || parsed.statusCode || parsed.status_code || "");
      if (!s.startsWith(statusFilter)) return false;
    }
    if (durationMin != null) {
      const d = Number(parsed.duration || parsed.durationMs || parsed.latency || 0);
      if (d < durationMin) return false;
    }
    if (durationMax != null) {
      const d = Number(parsed.duration || parsed.durationMs || parsed.latency || 0);
      if (d > durationMax) return false;
    }
    return true;
  };
}

export async function runLogsCommand(opts = {}) {
  // Resolve the base URL the same way every other CLI command does: an explicit
  // --base-url wins, otherwise fall back to the active context / env / localhost.
  // Without this, `logs` always hit localhost and ignored a connected remote.
  const baseUrl = opts.baseUrl || opts["base-url"] || getBaseUrl({ context: opts.context });
  const follow = opts.follow ?? false;
  const timeout = parseInt(String(opts.timeout || "30000"), 10);
  const isJson = opts.output === "json";
  const exportPath = opts.export;

  // Prepare export file
  if (exportPath && existsSync(exportPath)) {
    unlinkSync(exportPath);
  }

  const matchesLog = buildLogFilter(opts);
  // Pass only level filters to the stream (server-side); other filters are client-side
  const levelFilters = opts.filter ? opts.filter.split(",").map((f) => f.trim()) : [];

  // Authenticate the log stream. The /api/cli-tools/logs endpoint requires the
  // management token; build the same headers (scoped context token + CLI token)
  // that apiFetch uses, so `logs` works against authenticated/remote servers.
  // NOTE: --api-key here is a client-side log *filter* (see buildLogFilter), not
  // an auth credential, so it is deliberately not forwarded to buildHeaders.
  const headers = await buildHeaders({ baseUrl, context: opts.context });

  const { createLogStream } = await import("../../../src/lib/cli-helper/log-streamer.js");
  const { stream, stop } = createLogStream({
    baseUrl,
    filters: levelFilters,
    follow,
    timeout,
    headers,
  });

  const reader = stream.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  const processLine = (line) => {
    if (!line.trim()) return;
    let parsed = null;
    try {
      parsed = JSON.parse(line);
    } catch {
      // Non-JSON line: only include if no structured filters active
      if (
        opts.requestId ||
        opts.apiKey ||
        opts.combo ||
        opts.status ||
        opts.durationMin != null ||
        opts.durationMax != null
      )
        return;
      if (exportPath) appendFileSync(exportPath, line + "\n", "utf8");
      else console.log(line);
      return;
    }

    if (!matchesLog(parsed)) return;

    if (exportPath) {
      appendFileSync(exportPath, JSON.stringify(parsed) + "\n", "utf8");
      return;
    }

    if (isJson) {
      console.log(JSON.stringify(parsed));
      return;
    }

    const level = parsed.level || "info";
    const ts = parsed.timestamp || new Date().toISOString();
    const msg = parsed.message || JSON.stringify(parsed);
    const prefix =
      { error: "\x1b[31m[ERR]", warn: "\x1b[33m[WRN]", info: "\x1b[36m[INF]" }[level] || "[INF]";
    console.log(`${prefix}\x1b[0m ${ts} ${msg}`);
  };

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      const parts = buffer.split("\n");
      buffer = parts.pop() || "";
      for (const line of parts) processLine(line);
    }
    if (buffer) processLine(buffer);
    if (exportPath) console.log(t("logs.exported", { path: exportPath }));
  } catch (err) {
    if (err.name === "AbortError") {
      console.log(t("logs.stopped"));
    } else {
      console.error(
        t("logs.streamError", {
          message: (err instanceof Error ? err.message : String(err)).slice(0, 100),
        })
      );
    }
  } finally {
    stop();
  }

  return 0;
}