@cvtoolman/huawei-lts-cli 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2024 cvtoolman
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2024 cvtoolman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -153,10 +153,10 @@ lts-cli query --last 1h -f json | jq '.logs[].content'
153
153
  --app <取值> appName:"<取值>",与 -q 用 AND 合并
154
154
  --content <关键词> content:"<关键词>",与 -q 用 AND 合并
155
155
  --request-id <id> REQUEST_ID:"<id>",按时间正序输出
156
- -l, --limit <n> 每页条数(默认 100),--all 时即单页大小
156
+ -l, --limit <n> 每页条数(默认 100,上限 5000),--all 时同时是总数上限
157
157
  -r, --reverse 最新优先
158
158
  -f, --format <fmt> pretty(默认)/ table / json
159
- --all 沿游标读到末尾(上限 10000 条)
159
+ --all 沿游标读到末尾(默认上限 10000 条,显式 -l 则为 -l)
160
160
  ```
161
161
 
162
162
  `--app` / `--content` / `--request-id` 自动为值加双引号——含连字符的 UUID 不加引号会被分词导致匹配不到。
@@ -167,12 +167,21 @@ lts-cli query --last 1h -f json | jq '.logs[].content'
167
167
  lts-cli trace <id> [--field <name>] [--last 24h] [-l 200] [-f fmt]
168
168
  ```
169
169
 
170
- 按时间正序拉出一条 ID 的完整链路。默认走全文检索(不依赖任何特定字段命名);如果你的日志流有专用字段,配一次即可:
170
+ 按时间正序拉出一条 ID 的完整链路。字段选择顺序是 `--field` → `config.traceField` → 运行时读一次流的索引配置:把 `REQUEST_ID` / `trace_id` / `span_id` 这类 ID 字段和全文检索 `content` 一起 OR 上。结构化日志的 ID 住在 labels 里,全文索引只覆盖正文,所以不能只靠 `content`。零命中时 stderr 会列出试过哪些字段。
171
171
 
172
172
  ```bash
173
- lts-cli config --set traceField=REQUEST_ID
173
+ lts-cli trace <id> --field REQUEST_ID # 临时指定
174
+ lts-cli config --set traceField=REQUEST_ID # 这条流长期如此才值得配
174
175
  ```
175
176
 
177
+ ### `lts-cli context`
178
+
179
+ ```bash
180
+ lts-cli context --line-num <lineNum> [--time <毫秒>] [--before 20] [--after 20]
181
+ ```
182
+
183
+ `--time` 可省:`line_num` 本身就是纳秒时间戳,前 13 位即毫秒,CLI 自己推。
184
+
176
185
  ## 配置说明
177
186
 
178
187
  配置文件 `~/.lts-cli/config.json`(Windows 为 `%USERPROFILE%\.lts-cli\config.json`):
@@ -186,7 +195,7 @@ lts-cli config --set traceField=REQUEST_ID
186
195
  | `endpoint` | 否 | 需包含 `https://` |
187
196
  | `groupId` | 否 | 默认日志组 ID |
188
197
  | `streamId` | 否 | 默认日志流 ID |
189
- | `traceField` | 否 | `trace` 默认匹配的字段名,缺省为 `content` |
198
+ | `traceField` | 否 | `trace` 的匹配字段名;不配则自动从流索引里发现 ID 字段 |
190
199
 
191
200
  `lts-cli config --list` 会把 `ak` / `sk` 脱敏输出,避免凭证进入终端滚屏、shell 历史或 AI 上下文。
192
201
 
@@ -8,6 +8,7 @@ exports.resolveFormat = resolveFormat;
8
8
  exports.warnIfIncomplete = warnIfIncomplete;
9
9
  exports.handleCommandError = handleCommandError;
10
10
  const config_1 = require("../../config");
11
+ const client_1 = require("../../lts/client");
11
12
  /** 加载配置并校验凭证,缺失时打印引导并退出 */
12
13
  function loadCheckedConfig() {
13
14
  const config = (0, config_1.loadConfig)();
@@ -39,7 +40,14 @@ function resolveGroupStream(options, config) {
39
40
  }
40
41
  return { groupId, streamId };
41
42
  }
42
- /** 解析时间选项:--last 与 --st/--et 互斥,至少提供一组 */
43
+ /**
44
+ * 解析时间选项:`--last` 与 `--st/--et` 互斥,至少提供一组。
45
+ *
46
+ * 返回值一律是绝对区间(ISO 8601)。相对时长必须在这里就换算掉、只算一次:
47
+ * 若把 `last` 传下去,每翻一页都会重算一次 `Date.now()`,窗口随时间往前滑,
48
+ * 游标(line_num)一旦滑出 [start, end],服务端直接报
49
+ * “line_num must be between them”,`--all` 会中途失败。
50
+ */
43
51
  function resolveTimeParams(options, defaultLast) {
44
52
  const startTime = options.startTime || options.st;
45
53
  const endTime = options.endTime || options.et;
@@ -52,7 +60,18 @@ function resolveTimeParams(options, defaultLast) {
52
60
  console.error('Error: time range required. Use --last (e.g. --last 2h) or both --start-time and --end-time.');
53
61
  process.exit(1);
54
62
  }
55
- return last ? { last } : { startTime, endTime };
63
+ let range;
64
+ try {
65
+ range = (0, client_1.resolveTimeRange)(last ? { last } : { startTime, endTime });
66
+ }
67
+ catch (error) {
68
+ console.error(`Error: ${error.message}`);
69
+ process.exit(1);
70
+ }
71
+ return {
72
+ startTime: new Date(range.startMs).toISOString(),
73
+ endTime: new Date(range.endMs).toISOString(),
74
+ };
56
75
  }
57
76
  /** 将 -q 表达式与便捷筛选 flag 合并为一个查询表达式(AND 连接)
58
77
  * 字段值统一加双引号:含连字符等分词符的值(如 UUID)不加引号会匹配不到 */
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.maskSecret = maskSecret;
4
+ exports.displayValue = displayValue;
5
+ exports.parseAssignment = parseAssignment;
3
6
  exports.createConfigCommand = createConfigCommand;
4
7
  const commander_1 = require("commander");
5
8
  const config_1 = require("../../config");
@@ -10,6 +13,17 @@ function maskSecret(value) {
10
13
  return '****';
11
14
  return `${value.slice(0, 2)}${'*'.repeat(Math.max(4, value.length - 4))}${value.slice(-2)}`;
12
15
  }
16
+ /** 输出任何配置值时都经过这里:密钥只看得到首尾,其余原样。 */
17
+ function displayValue(key, value) {
18
+ return SECRET_KEYS.has(key) ? maskSecret(value) : value;
19
+ }
20
+ /** 拆 `key=value`。值里可能含 `=`(base64 密钥、URL 参数),只切第一个等号。 */
21
+ function parseAssignment(input) {
22
+ const eq = input.indexOf('=');
23
+ if (eq <= 0)
24
+ return undefined;
25
+ return { key: input.slice(0, eq), value: input.slice(eq + 1) };
26
+ }
13
27
  function createConfigCommand() {
14
28
  const cmd = new commander_1.Command('config');
15
29
  cmd.description('Manage LTS CLI configuration')
@@ -19,19 +33,19 @@ function createConfigCommand() {
19
33
  .option('--unset <key>', 'Remove a configuration value')
20
34
  .action((options) => {
21
35
  if (options.set) {
22
- const parts = options.set.split('=');
23
- if (parts.length !== 2) {
36
+ const assignment = parseAssignment(options.set);
37
+ if (!assignment) {
24
38
  console.error('Usage: config --set key=value');
25
39
  process.exit(1);
26
40
  }
27
- const [key, value] = parts;
41
+ const { key, value } = assignment;
28
42
  (0, config_1.setConfig)(key, value);
29
- console.log(`Set ${key}=${value}`);
43
+ console.log(`Set ${key}=${displayValue(key, value)}`);
30
44
  }
31
45
  else if (options.get) {
32
46
  const value = (0, config_1.getConfig)(options.get);
33
47
  if (value !== undefined) {
34
- console.log(SECRET_KEYS.has(options.get) ? maskSecret(value) : value);
48
+ console.log(displayValue(options.get, value));
35
49
  }
36
50
  else {
37
51
  console.error(`Config key '${options.get}' not found`);
@@ -46,7 +60,7 @@ function createConfigCommand() {
46
60
  }
47
61
  else {
48
62
  for (const [key, value] of entries) {
49
- console.log(`${key}=${SECRET_KEYS.has(key) ? maskSecret(value) : value}`);
63
+ console.log(`${key}=${displayValue(key, value)}`);
50
64
  }
51
65
  }
52
66
  }
@@ -11,7 +11,7 @@ function createContextCommand() {
11
11
  .option('-g, --group-id <groupId>', 'Log group ID (can also be set via config)')
12
12
  .option('-s, --stream-id <streamId>', 'Log stream ID (can also be set via config)')
13
13
  .requiredOption('--line-num <lineNum>', 'line_num of the anchor log entry')
14
- .requiredOption('--time <time>', 'Timestamp (ms) of the anchor log entry')
14
+ .option('--time <time>', 'Timestamp (ms) of the anchor log entry (derived from --line-num when omitted)')
15
15
  .option('--before <count>', 'Number of logs before the anchor', (v) => parseInt(v, 10), 20)
16
16
  .option('--after <count>', 'Number of logs after the anchor', (v) => parseInt(v, 10), 20)
17
17
  .option('-f, --format <format>', 'Output format: json, table, pretty', 'pretty')
@@ -19,12 +19,19 @@ function createContextCommand() {
19
19
  const config = (0, common_1.loadCheckedConfig)();
20
20
  const { groupId, streamId } = (0, common_1.resolveGroupStream)(options, config);
21
21
  const format = (0, common_1.resolveFormat)(options.format);
22
+ // line_num 本身就是纳秒时间戳,前 13 位即目标日志的毫秒时间。
23
+ // 让用户再抄一遍 --time 是纯粹的重复劳动,还会抄错。
24
+ const time = options.time ?? (0, client_1.anchorTimeFromLineNum)(options.lineNum);
25
+ if (!time) {
26
+ console.error(`Cannot derive the anchor time from line_num '${options.lineNum}'. Pass --time <ms>.`);
27
+ process.exit(1);
28
+ }
22
29
  const client = new client_1.LTSClient(config);
23
30
  const params = {
24
31
  groupId,
25
32
  streamId,
26
33
  lineNum: options.lineNum,
27
- time: options.time,
34
+ time,
28
35
  before: options.before,
29
36
  after: options.after,
30
37
  };
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseLimitOption = parseLimitOption;
3
4
  exports.createQueryCommand = createQueryCommand;
4
5
  const commander_1 = require("commander");
5
6
  const client_1 = require("../../lts/client");
@@ -10,6 +11,17 @@ const common_1 = require("./common");
10
11
  function parseIntOption(value) {
11
12
  return parseInt(value, 10);
12
13
  }
14
+ /** --all 读不到末尾时的止损上限 */
15
+ const ALL_CAP = 10000;
16
+ /** 超限的 limit 本地就拦下:服务端会回一个 LTS.1903,还要多一次往返 */
17
+ function parseLimitOption(value) {
18
+ const limit = parseIntOption(value);
19
+ if (!Number.isInteger(limit) || limit < 1 || limit > pagination_1.MAX_PAGE_SIZE) {
20
+ // 用 commander 的 InvalidArgumentError:它会打印一行提示,而不是整个堆栈
21
+ throw new commander_1.InvalidArgumentError(`--limit must be between 1 and ${pagination_1.MAX_PAGE_SIZE}`);
22
+ }
23
+ return limit;
24
+ }
13
25
  function createQueryCommand() {
14
26
  const cmd = new commander_1.Command('query');
15
27
  cmd.description('Query LTS logs')
@@ -28,10 +40,10 @@ function createQueryCommand() {
28
40
  .option('--app <appName>', 'Filter by appName field, merged with -q via AND')
29
41
  .option('--content <keyword>', 'Filter by log content (content:<keyword>), merged with -q via AND')
30
42
  .option('--request-id <id>', 'Filter by REQUEST_ID field; output in chronological order')
31
- .option('-l, --limit <limit>', 'Page size (also the cap for --all)', parseIntOption, 100)
43
+ .option('-l, --limit <limit>', `Page size; with --all it also caps the total (max ${pagination_1.MAX_PAGE_SIZE})`, parseLimitOption, 100)
32
44
  .option('-r, --reverse', 'Reverse order (newest first)')
33
45
  .option('-f, --format <format>', 'Output format: json, table, pretty', 'pretty')
34
- .option('--all', 'Follow the cursor to the end (up to 10000 logs; default is a single page)')
46
+ .option('--all', `Follow the cursor to the end (up to ${ALL_CAP} logs unless -l is given)`)
35
47
  .action(async (options) => {
36
48
  const config = (0, common_1.loadCheckedConfig)();
37
49
  const { groupId, streamId } = (0, common_1.resolveGroupStream)(options, config);
@@ -57,9 +69,11 @@ function createQueryCommand() {
57
69
  console.log((0, output_1.formatOutput)(result, format));
58
70
  }
59
71
  else {
60
- // --all:沿游标读到末尾,上限 10000 条防止失控
72
+ // --all:沿游标读到末尾。显式给了 -l 就以它为上限(帮助里就是这么写的),
73
+ // 否则用 ALL_CAP 防止失控。
74
+ const max = cmd.getOptionValueSource('limit') === 'cli' ? options.limit : ALL_CAP;
61
75
  const result = await (0, pagination_1.fetchAllPages)((p) => client.queryLogs(p), params, {
62
- max: 10000,
76
+ max,
63
77
  pageSize: options.limit,
64
78
  });
65
79
  (0, common_1.warnIfIncomplete)(result);
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.traceCandidateFields = traceCandidateFields;
7
+ exports.buildTraceExpression = buildTraceExpression;
6
8
  exports.createTraceCommand = createTraceCommand;
7
9
  const commander_1 = require("commander");
8
10
  const chalk_1 = __importDefault(require("chalk"));
@@ -12,20 +14,39 @@ const output_1 = require("../../output");
12
14
  const config_1 = require("../../config");
13
15
  const common_1 = require("./common");
14
16
  /**
15
- * 默认按全文索引检索,而不是 REQUEST_ID。
17
+ * 默认字段的选择顺序:`--field` → `config.traceField` → 运行时发现。
16
18
  *
17
- * 理由:`REQUEST_ID` 是某类应用的字段命名约定,不是 LTS 的通用概念。新用户的日志流
18
- * 里往往没有这个字段,写死它会让 `trace` 在正确的用法下返回空。全文检索总能命中,
19
- * 而用到专用字段的人可以在 config 里设一次 `traceField`。
19
+ * 以前第三步写死 `content`(全文检索)。实测不对:结构化日志的 ID 住在 labels 里
20
+ * (REQUEST_ID / trace_id …),而全文索引只覆盖正文,`content:"<uuid>"` 一条都
21
+ * 匹配不到——而且不报错,只是空结果。所以第三步改成读索引配置,把形如
22
+ * request/trace/span + id 的字段都 OR 上,再由 full-text 兜底。
20
23
  */
21
24
  const DEFAULT_TRACE_FIELD = 'content';
25
+ /** 字段名能凭空进查询语句(不能含连字符,否则会被当成运算符) */
26
+ const SAFE_FIELD_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
27
+ const ID_FIELD_PATTERN = /(request|trace|span)[_-]?id$/i;
28
+ /** 从索引配置里挑出可能装链路 ID 的字段名(运行时发现,不维护字段表) */
29
+ function traceCandidateFields(index) {
30
+ const idFields = index.fields
31
+ .map((f) => f.name)
32
+ .filter((name) => SAFE_FIELD_NAME.test(name) && ID_FIELD_PATTERN.test(name));
33
+ // 全文索引用在两种情况下:有 ID 字段时做兜底,没有 ID 字段时是唯一手段
34
+ if (idFields.length === 0 || index.fullText.enabled) {
35
+ return [...idFields, DEFAULT_TRACE_FIELD];
36
+ }
37
+ return idFields;
38
+ }
39
+ /** 候选字段拼成 OR 表达式,值统一加双引号(含连字符的 UUID 不分词就匹配不到) */
40
+ function buildTraceExpression(fields, requestId) {
41
+ return fields.map((field) => `${field}:"${requestId}"`).join(' OR ');
42
+ }
22
43
  function createTraceCommand() {
23
44
  const cmd = new commander_1.Command('trace');
24
45
  cmd.description('Trace a request by ID — fetch the full request chain in chronological order')
25
46
  .argument('<requestId>', 'Value to trace (request ID, trace ID, or any search text)')
26
47
  .option('-g, --group-id <groupId>', 'Log group ID (can also be set via config)')
27
48
  .option('-s, --stream-id <streamId>', 'Log stream ID (can also be set via config)')
28
- .option('--field <name>', `Field to match. Defaults to config.traceField, then "${DEFAULT_TRACE_FIELD}"`)
49
+ .option('--field <name>', 'Field to match. Defaults to config.traceField, then the ID fields found in the stream index')
29
50
  .option('--last <duration>', 'Relative time range: 30m, 2h, 1d, 7d (default: 24h)')
30
51
  .option('--start-time <startTime>', 'Start time (ISO 8601)')
31
52
  .option('--end-time <endTime>', 'End time (ISO 8601)')
@@ -38,20 +59,23 @@ function createTraceCommand() {
38
59
  const { groupId, streamId } = (0, common_1.resolveGroupStream)(options, config);
39
60
  const timeParams = (0, common_1.resolveTimeParams)(options, '24h');
40
61
  const format = (0, common_1.resolveFormat)(options.format);
41
- const field = options.field || (0, config_1.getConfig)('traceField') || DEFAULT_TRACE_FIELD;
42
- // 值加双引号:含连字符的 UUID 不加引号会被分词导致匹配不到
43
- const query = `${field}:"${requestId}"`;
62
+ const explicitField = options.field || (0, config_1.getConfig)('traceField');
44
63
  const client = new client_1.LTSClient(config);
64
+ // 链路按时间正序,且不加应用过滤:一条链路本来就会横跨多个应用
45
65
  const params = {
46
66
  groupId,
47
67
  streamId,
48
68
  ...timeParams,
49
- query,
50
- reverse: false, // 链路按时间正序
69
+ reverse: false,
51
70
  };
52
71
  try {
72
+ const fields = explicitField
73
+ ? [explicitField]
74
+ : await discoverTraceFields(client, groupId, streamId);
75
+ params.query = buildTraceExpression(fields, requestId);
53
76
  const maxTotal = options.limit || 200;
54
- // 每页不超过 500:再大 LTS 会开始截断,且首屏延迟明显变差
77
+ // 每页 500 条:服务端单页最多 5000,但 500 的首屏延迟明显更好,
78
+ // 而一条链路本来就只有几十条,多跑几页也不痛不痒
55
79
  const pageSize = Math.min(maxTotal, 500);
56
80
  const result = await (0, pagination_1.fetchAllPages)((p) => client.queryLogs(p), params, {
57
81
  max: maxTotal,
@@ -61,6 +85,10 @@ function createTraceCommand() {
61
85
  if (result.truncated) {
62
86
  console.error(`Warning: stopped at --limit ${maxTotal}; the chain may continue. Re-run with a larger --limit.`);
63
87
  }
88
+ if (result.logs.length === 0) {
89
+ console.error(`No logs matched "${requestId}" in ${fields.join(', ')}. ` +
90
+ 'Widen --last, or pick the exact field with --field <name>.');
91
+ }
64
92
  let output = (0, output_1.formatOutput)({ logs: result.logs, total: result.total }, format);
65
93
  // pretty 格式下高亮 requestId,便于肉眼定位
66
94
  if (format === 'pretty' && result.logs.length > 0) {
@@ -74,3 +102,15 @@ function createTraceCommand() {
74
102
  });
75
103
  return cmd;
76
104
  }
105
+ /**
106
+ * 读一次索引配置来决定搜哪些字段。读不到(权限/接口异常)时退回全文检索:
107
+ * 宁可退化成以前的行为,也不要让 trace 直接报错。
108
+ */
109
+ async function discoverTraceFields(client, groupId, streamId) {
110
+ try {
111
+ return traceCandidateFields(await client.getStreamIndex(groupId, streamId));
112
+ }
113
+ catch {
114
+ return [DEFAULT_TRACE_FIELD];
115
+ }
116
+ }
package/dist/cli/index.js CHANGED
@@ -65,25 +65,25 @@ program
65
65
  .name('lts-cli')
66
66
  .description('Huawei Cloud LTS (Log Tank Service) CLI — query logs from the terminal')
67
67
  .version(readVersion())
68
- .addHelpText('after', `
69
- Examples:
70
- $ lts-cli init Setup credentials interactively
71
- $ lts-cli init --from-url <url> Import region/groupId/streamId from a console URL
72
- $ lts-cli config --list Show current configuration
73
- $ lts-cli groups List log groups
74
- $ lts-cli streams List log streams in a group
75
- $ lts-cli fields Show the index config of a log stream
76
- $ lts-cli query --last 2h -q 'level:error' --all
77
- $ lts-cli trace <requestId> Fetch the full request chain
78
-
79
- Environment variables:
80
- LTS_AK Access Key
81
- LTS_SK Secret Key
82
- LTS_PROJECT_ID Project ID
83
- LTS_REGION Region
84
- LTS_ENDPOINT Service endpoint
85
- LTS_GROUP_ID Default log group ID
86
- LTS_STREAM_ID Default log stream ID
68
+ .addHelpText('after', `
69
+ Examples:
70
+ $ lts-cli init Setup credentials interactively
71
+ $ lts-cli init --from-url <url> Import region/groupId/streamId from a console URL
72
+ $ lts-cli config --list Show current configuration
73
+ $ lts-cli groups List log groups
74
+ $ lts-cli streams List log streams in a group
75
+ $ lts-cli fields Show the index config of a log stream
76
+ $ lts-cli query --last 2h -q 'level:error' --all
77
+ $ lts-cli trace <requestId> Fetch the full request chain
78
+
79
+ Environment variables:
80
+ LTS_AK Access Key
81
+ LTS_SK Secret Key
82
+ LTS_PROJECT_ID Project ID
83
+ LTS_REGION Region
84
+ LTS_ENDPOINT Service endpoint
85
+ LTS_GROUP_ID Default log group ID
86
+ LTS_STREAM_ID Default log stream ID
87
87
  `);
88
88
  program.addCommand((0, init_1.createInitCommand)());
89
89
  program.addCommand((0, query_1.createQueryCommand)());
@@ -99,6 +99,7 @@ program.addCommand((0, config_1.createConfigCommand)());
99
99
  */
100
100
  const QUERY_OPTS = '-g --group-id -s --stream-id --start-time --end-time --st --et --last -q --query --app --content --request-id -l --limit -r --reverse -f --format --all';
101
101
  const TRACE_OPTS = '-g --group-id -s --stream-id --field --last --start-time --end-time --st --et -l --limit -f --format';
102
+ const CONTEXT_OPTS = '-g --group-id -s --stream-id --line-num --time --before --after -f --format';
102
103
  const VALUE_OPTS = '-g --group-id -s --stream-id -f --format';
103
104
  // Shell completion support
104
105
  const completionCmd = new commander_1.Command('completion')
@@ -107,179 +108,200 @@ const completionCmd = new commander_1.Command('completion')
107
108
  .action((shell) => {
108
109
  switch (shell) {
109
110
  case 'bash':
110
- console.log(`# lts-cli bash completion — add to ~/.bashrc or ~/.bash_profile
111
- _lts_cli_completion() {
112
- local words cword
113
- _get_comp_words_by_ref -n : words cword
114
- local cur="\${words[cword]}"
115
-
116
- if (( cword == 1 )); then
117
- COMPREPLY=( $(compgen -W "query trace context groups streams fields config init completion" -- "\$cur") )
118
- return
119
- fi
120
-
121
- local cmd="\${words[1]}"
122
- case "\$cmd" in
123
- query)
124
- case "\$prev" in
125
- -g|--group-id|-s|--stream-id|-q|--query|--app|--content|--request-id|-l|--limit|-f|--format)
126
- COMPREPLY=()
127
- return
128
- ;;
129
- *)
130
- COMPREPLY=( $(compgen -W "${QUERY_OPTS}" -- "\$cur") )
131
- return
132
- ;;
133
- esac
134
- ;;
135
- trace)
136
- case "\$prev" in
137
- -g|--group-id|-s|--stream-id|--field|--last|-l|--limit|-f|--format)
138
- COMPREPLY=()
139
- return
140
- ;;
141
- *)
142
- COMPREPLY=( $(compgen -W "${TRACE_OPTS}" -- "\$cur") )
143
- return
144
- ;;
145
- esac
146
- ;;
147
- fields)
148
- COMPREPLY=( $(compgen -W "${VALUE_OPTS}" -- "\$cur") )
149
- return
150
- ;;
151
- config)
152
- COMPREPLY=( $(compgen -W "--set --get --list --unset" -- "\$cur") )
153
- return
154
- ;;
155
- completion)
156
- COMPREPLY=( $(compgen -W "bash zsh powershell" -- "\$cur") )
157
- return
158
- ;;
159
- *)
160
- COMPREPLY=()
161
- return
162
- ;;
163
- esac
164
- }
111
+ console.log(`# lts-cli bash completion — add to ~/.bashrc or ~/.bash_profile
112
+ _lts_cli_completion() {
113
+ local words cword
114
+ _get_comp_words_by_ref -n : words cword
115
+ local cur="\${words[cword]}"
116
+ local prev="\${words[cword-1]}"
117
+
118
+ if (( cword == 1 )); then
119
+ COMPREPLY=( $(compgen -W "query trace context groups streams fields config init completion" -- "\$cur") )
120
+ return
121
+ fi
122
+
123
+ local cmd="\${words[1]}"
124
+ case "\$cmd" in
125
+ query)
126
+ case "\$prev" in
127
+ -g|--group-id|-s|--stream-id|-q|--query|--app|--content|--request-id|--start-time|--end-time|--st|--et|--last|-l|--limit|-f|--format)
128
+ COMPREPLY=()
129
+ return
130
+ ;;
131
+ *)
132
+ COMPREPLY=( $(compgen -W "${QUERY_OPTS}" -- "\$cur") )
133
+ return
134
+ ;;
135
+ esac
136
+ ;;
137
+ trace)
138
+ case "\$prev" in
139
+ -g|--group-id|-s|--stream-id|--field|--start-time|--end-time|--st|--et|--last|-l|--limit|-f|--format)
140
+ COMPREPLY=()
141
+ return
142
+ ;;
143
+ *)
144
+ COMPREPLY=( $(compgen -W "${TRACE_OPTS}" -- "\$cur") )
145
+ return
146
+ ;;
147
+ esac
148
+ ;;
149
+ context)
150
+ case "\$prev" in
151
+ -g|--group-id|-s|--stream-id|--line-num|--time|--before|--after|-f|--format)
152
+ COMPREPLY=()
153
+ return
154
+ ;;
155
+ *)
156
+ COMPREPLY=( $(compgen -W "${CONTEXT_OPTS}" -- "\$cur") )
157
+ return
158
+ ;;
159
+ esac
160
+ ;;
161
+ groups)
162
+ COMPREPLY=( $(compgen -W "-f --format" -- "\$cur") )
163
+ return
164
+ ;;
165
+ streams)
166
+ COMPREPLY=( $(compgen -W "${VALUE_OPTS}" -- "\$cur") )
167
+ return
168
+ ;;
169
+ fields)
170
+ COMPREPLY=( $(compgen -W "${VALUE_OPTS}" -- "\$cur") )
171
+ return
172
+ ;;
173
+ config)
174
+ COMPREPLY=( $(compgen -W "--set --get --list --unset" -- "\$cur") )
175
+ return
176
+ ;;
177
+ completion)
178
+ COMPREPLY=( $(compgen -W "bash zsh powershell" -- "\$cur") )
179
+ return
180
+ ;;
181
+ *)
182
+ COMPREPLY=()
183
+ return
184
+ ;;
185
+ esac
186
+ }
165
187
  complete -F _lts_cli_completion lts-cli`);
166
188
  break;
167
189
  case 'zsh':
168
- console.log(`# lts-cli zsh completion — add to ~/.zshrc
169
- #compdef lts-cli
170
- _lts_cli() {
171
- local -a commands
172
- commands=(
173
- 'query:Query LTS logs'
174
- 'trace:Trace a request by ID'
175
- 'context:View logs before/after a log entry'
176
- 'groups:List log groups'
177
- 'streams:List log streams'
178
- 'fields:Show the index config of a log stream'
179
- 'config:Manage configuration'
180
- 'init:Interactive setup wizard'
181
- 'completion:Generate shell completion script'
182
- )
183
-
184
- local context state state_descr line
185
- typeset -A opt_args
186
-
187
- _arguments -C \\
188
- '1: :->command' \\
189
- '*:: :->args'
190
-
191
- case "\$state" in
192
- command)
193
- _describe 'command' commands
194
- ;;
195
- args)
196
- case "\$line[1]" in
197
- query)
198
- _arguments \\
199
- '-g[Log group ID]:group ID:' \\
200
- '-s[Log stream ID]:stream ID:' \\
201
- '--start-time[Start time (ISO 8601)]:time:' \\
202
- '--end-time[End time (ISO 8601)]:time:' \\
203
- '--st[Alias for --start-time]:time:' \\
204
- '--et[Alias for --end-time]:time:' \\
205
- '--last[Relative range: 30m, 2h, 1d, 7d]:duration:' \\
206
- '-q[Query expression]:query:' \\
207
- '--app[Filter by appName]:appName:' \\
208
- '--content[Filter by content]:keyword:' \\
209
- '--request-id[Filter by REQUEST_ID]:id:' \\
210
- '-l[Page size]:number:' \\
211
- '-r[Reverse order]' \\
212
- '-f[Output format]:format:(json table pretty)' \\
213
- '--all[Follow the cursor to the end]'
214
- ;;
215
- trace)
216
- _arguments \\
217
- '-g[Log group ID]:group ID:' \\
218
- '-s[Log stream ID]:stream ID:' \\
219
- '--field[Field to match]:field:' \\
220
- '--last[Relative range: 30m, 2h, 1d, 7d]:duration:' \\
221
- '--start-time[Start time (ISO 8601)]:time:' \\
222
- '--end-time[End time (ISO 8601)]:time:' \\
223
- '--st[Alias for --start-time]:time:' \\
224
- '--et[Alias for --end-time]:time:' \\
225
- '-l[Max logs]:number:' \\
226
- '-f[Output format]:format:(json table pretty)'
227
- ;;
228
- fields|groups|streams)
229
- _arguments \\
230
- '-g[Log group ID]:group ID:' \\
231
- '-s[Log stream ID]:stream ID:' \\
232
- '-f[Output format]:format:(json table pretty)'
233
- ;;
234
- config)
235
- _arguments \\
236
- '--set[Set config value]:key=value:' \\
237
- '--get[Get config value]:key:' \\
238
- '--list[List all config]' \\
239
- '--unset[Remove config value]:key:'
240
- ;;
241
- completion)
242
- _values 'shell' bash zsh powershell
243
- ;;
244
- esac
245
- ;;
246
- esac
247
- }
190
+ console.log(`# lts-cli zsh completion — add to ~/.zshrc
191
+ #compdef lts-cli
192
+ _lts_cli() {
193
+ local -a commands
194
+ commands=(
195
+ 'query:Query LTS logs'
196
+ 'trace:Trace a request by ID'
197
+ 'context:View logs before/after a log entry'
198
+ 'groups:List log groups'
199
+ 'streams:List log streams'
200
+ 'fields:Show the index config of a log stream'
201
+ 'config:Manage configuration'
202
+ 'init:Interactive setup wizard'
203
+ 'completion:Generate shell completion script'
204
+ )
205
+
206
+ local context state state_descr line
207
+ typeset -A opt_args
208
+
209
+ _arguments -C \\
210
+ '1: :->command' \\
211
+ '*:: :->args'
212
+
213
+ case "\$state" in
214
+ command)
215
+ _describe 'command' commands
216
+ ;;
217
+ args)
218
+ case "\$line[1]" in
219
+ query)
220
+ _arguments \\
221
+ '-g[Log group ID]:group ID:' \\
222
+ '-s[Log stream ID]:stream ID:' \\
223
+ '--start-time[Start time (ISO 8601)]:time:' \\
224
+ '--end-time[End time (ISO 8601)]:time:' \\
225
+ '--st[Alias for --start-time]:time:' \\
226
+ '--et[Alias for --end-time]:time:' \\
227
+ '--last[Relative range: 30m, 2h, 1d, 7d]:duration:' \\
228
+ '-q[Query expression]:query:' \\
229
+ '--app[Filter by appName]:appName:' \\
230
+ '--content[Filter by content]:keyword:' \\
231
+ '--request-id[Filter by REQUEST_ID]:id:' \\
232
+ '-l[Page size]:number:' \\
233
+ '-r[Reverse order]' \\
234
+ '-f[Output format]:format:(json table pretty)' \\
235
+ '--all[Follow the cursor to the end]'
236
+ ;;
237
+ trace)
238
+ _arguments \\
239
+ '-g[Log group ID]:group ID:' \\
240
+ '-s[Log stream ID]:stream ID:' \\
241
+ '--field[Field to match]:field:' \\
242
+ '--last[Relative range: 30m, 2h, 1d, 7d]:duration:' \\
243
+ '--start-time[Start time (ISO 8601)]:time:' \\
244
+ '--end-time[End time (ISO 8601)]:time:' \\
245
+ '--st[Alias for --start-time]:time:' \\
246
+ '--et[Alias for --end-time]:time:' \\
247
+ '-l[Max logs]:number:' \\
248
+ '-f[Output format]:format:(json table pretty)'
249
+ ;;
250
+ fields|groups|streams)
251
+ _arguments \\
252
+ '-g[Log group ID]:group ID:' \\
253
+ '-s[Log stream ID]:stream ID:' \\
254
+ '-f[Output format]:format:(json table pretty)'
255
+ ;;
256
+ config)
257
+ _arguments \\
258
+ '--set[Set config value]:key=value:' \\
259
+ '--get[Get config value]:key:' \\
260
+ '--list[List all config]' \\
261
+ '--unset[Remove config value]:key:'
262
+ ;;
263
+ completion)
264
+ _values 'shell' bash zsh powershell
265
+ ;;
266
+ esac
267
+ ;;
268
+ esac
269
+ }
248
270
  compdef _lts_cli lts-cli`);
249
271
  break;
250
272
  case 'powershell':
251
- console.log(`# lts-cli PowerShell completion — add to $PROFILE
252
- Register-ArgumentCompleter -CommandName lts-cli -ScriptBlock {
253
- param(\$wordToComplete, \$commandAst, \$cursorPosition)
254
-
255
- \$commands = @('query', 'trace', 'context', 'groups', 'streams', 'fields', 'config', 'init', 'completion')
256
-
257
- \$args = \$commandAst.ToString() -split '\\s+'
258
- if (\$args.Count -le 1) {
259
- return \$commands | Where-Object { \$_ -like "\$wordToComplete*" }
260
- }
261
-
262
- switch (\$args[1]) {
263
- 'query' {
264
- \$opts = @('-g', '-s', '--start-time', '--end-time', '--st', '--et', '--last', '-q', '--app', '--content', '--request-id', '-l', '-r', '-f', '--all')
265
- return \$opts | Where-Object { \$_ -like "\$wordToComplete*" }
266
- }
267
- 'trace' {
268
- \$opts = @('-g', '-s', '--field', '--last', '--start-time', '--end-time', '--st', '--et', '-l', '-f')
269
- return \$opts | Where-Object { \$_ -like "\$wordToComplete*" }
270
- }
271
- 'fields' {
272
- \$opts = @('-g', '-s', '-f')
273
- return \$opts | Where-Object { \$_ -like "\$wordToComplete*" }
274
- }
275
- 'config' {
276
- \$opts = @('--set', '--get', '--list', '--unset')
277
- return \$opts | Where-Object { \$_ -like "\$wordToComplete*" }
278
- }
279
- 'completion' {
280
- return @('bash', 'zsh', 'powershell') | Where-Object { \$_ -like "\$wordToComplete*" }
281
- }
282
- }
273
+ console.log(`# lts-cli PowerShell completion — add to $PROFILE
274
+ Register-ArgumentCompleter -CommandName lts-cli -ScriptBlock {
275
+ param(\$wordToComplete, \$commandAst, \$cursorPosition)
276
+
277
+ \$commands = @('query', 'trace', 'context', 'groups', 'streams', 'fields', 'config', 'init', 'completion')
278
+
279
+ \$args = \$commandAst.ToString() -split '\\s+'
280
+ if (\$args.Count -le 1) {
281
+ return \$commands | Where-Object { \$_ -like "\$wordToComplete*" }
282
+ }
283
+
284
+ switch (\$args[1]) {
285
+ 'query' {
286
+ \$opts = @('-g', '-s', '--start-time', '--end-time', '--st', '--et', '--last', '-q', '--app', '--content', '--request-id', '-l', '-r', '-f', '--all')
287
+ return \$opts | Where-Object { \$_ -like "\$wordToComplete*" }
288
+ }
289
+ 'trace' {
290
+ \$opts = @('-g', '-s', '--field', '--last', '--start-time', '--end-time', '--st', '--et', '-l', '-f')
291
+ return \$opts | Where-Object { \$_ -like "\$wordToComplete*" }
292
+ }
293
+ 'fields' {
294
+ \$opts = @('-g', '-s', '-f')
295
+ return \$opts | Where-Object { \$_ -like "\$wordToComplete*" }
296
+ }
297
+ 'config' {
298
+ \$opts = @('--set', '--get', '--list', '--unset')
299
+ return \$opts | Where-Object { \$_ -like "\$wordToComplete*" }
300
+ }
301
+ 'completion' {
302
+ return @('bash', 'zsh', 'powershell') | Where-Object { \$_ -like "\$wordToComplete*" }
303
+ }
304
+ }
283
305
  }`);
284
306
  break;
285
307
  default:
@@ -1,8 +1,43 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.LTSClient = void 0;
4
37
  exports.resolveTimeRange = resolveTimeRange;
5
38
  exports.parseQueryExpression = parseQueryExpression;
39
+ exports.anchorTimeFromLineNum = anchorTimeFromLineNum;
40
+ const path = __importStar(require("path"));
6
41
  const LtsClient_1 = require("@huaweicloud/huaweicloud-sdk-lts/v2/LtsClient");
7
42
  const ClientBuilder_1 = require("@huaweicloud/huaweicloud-sdk-core/ClientBuilder");
8
43
  const BasicCredentials_1 = require("@huaweicloud/huaweicloud-sdk-core/auth/BasicCredentials");
@@ -15,6 +50,32 @@ const ListLogGroupsRequest_1 = require("@huaweicloud/huaweicloud-sdk-lts/v2/mode
15
50
  const ListLogStreamsRequest_1 = require("@huaweicloud/huaweicloud-sdk-lts/v2/model/ListLogStreamsRequest");
16
51
  const ListLogStreamIndexRequest_1 = require("@huaweicloud/huaweicloud-sdk-lts/v2/model/ListLogStreamIndexRequest");
17
52
  const pagination_1 = require("./pagination");
53
+ /**
54
+ * 华为云 SDK 把 log4js 的 console appender 指向 stdout,任何 API 错误都会把整个
55
+ * axios 错误对象(含 Authorization 头)倒进 stdout——`-f json` 的输出因此变成
56
+ * 不可解析的混合体。这里把 SDK 日志改道 stderr 并默认关闭:CLI 自己会打印
57
+ * 一行可读的错误,6KB 的 axios dump 只会淹没它。要排查 SDK 层问题时设
58
+ * LTS_CLI_DEBUG=1。
59
+ *
60
+ * 解析到的必须是 SDK 自己那个 log4js 实例,否则 configure 改的是另一份模块状态。
61
+ * 拿不到就保持原状:日志难看,但不影响结果本身。
62
+ */
63
+ function configureSdkLogger() {
64
+ try {
65
+ const corePkg = require.resolve('@huaweicloud/huaweicloud-sdk-core/package.json');
66
+ const log4js = require(require.resolve('log4js', { paths: [path.dirname(corePkg)] }));
67
+ log4js.configure({
68
+ appenders: { sdkStderr: { type: 'stderr' } },
69
+ categories: {
70
+ default: { appenders: ['sdkStderr'], level: process.env.LTS_CLI_DEBUG ? 'all' : 'off' },
71
+ },
72
+ });
73
+ }
74
+ catch {
75
+ /* 无法改道时不做任何事 */
76
+ }
77
+ }
78
+ configureSdkLogger();
18
79
  function sleep(ms) {
19
80
  return new Promise((resolve) => setTimeout(resolve, ms));
20
81
  }
@@ -71,6 +132,14 @@ function parseQueryExpression(expr) {
71
132
  }
72
133
  return { keywords: trimmed, isAnalysisQuery: false };
73
134
  }
135
+ /**
136
+ * line_num 是 19 位纳秒时间戳,前 13 位就是毫秒(实测与日志时间严格对齐)。
137
+ * `context` 接口要求传毫秒时间戳,而 LTS 的日志记录本身不带时间字段,只能从
138
+ * line_num 推。格式不符时返回 undefined,让调用方要求显式 --time。
139
+ */
140
+ function anchorTimeFromLineNum(lineNum) {
141
+ return /^\d{19}$/.test(lineNum) ? lineNum.slice(0, 13) : undefined;
142
+ }
74
143
  /** 服务端默认在命中词上包裹 <HighLightTag>,输出前剥离 */
75
144
  function stripHighlightTags(value) {
76
145
  return value.replace(/<\/?HighLightTag>/g, '');
@@ -118,7 +187,7 @@ class LTSClient {
118
187
  // 游标翻页:首屏不带游标,后续页把上一页末条的 line_num 当作续读起点
119
188
  if (params.lineNum) {
120
189
  queryParams.withLineNum(params.lineNum);
121
- queryParams.withSearchType(params.searchType ?? (0, pagination_1.cursorDirection)(params.reverse));
190
+ queryParams.withSearchType(params.searchType ?? pagination_1.CONTINUE_SEARCH_TYPE);
122
191
  }
123
192
  const request = new ListLogsRequest_1.ListLogsRequest(params.groupId, params.streamId, 'application/json')
124
193
  .withBody(queryParams);
@@ -1,12 +1,31 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.cursorDirection = cursorDirection;
3
+ exports.MAX_PAGE_SIZE = exports.CONTINUE_SEARCH_TYPE = void 0;
4
4
  exports.nextCursor = nextCursor;
5
+ exports.shiftLineNum = shiftLineNum;
5
6
  exports.fetchAllPages = fetchAllPages;
6
- /** 倒序(新到旧)→ 往更早读;正序 → 往更晚读。 */
7
- function cursorDirection(reverse) {
8
- return reverse ? 'backwards' : 'forwards';
9
- }
7
+ /**
8
+ * LTS 的翻页语义:服务端没有 offset,只有「从某个 line_num 继续读」。
9
+ *
10
+ * 第一页不带游标;之后每页把上一页最后一条的 line_num 作为续读起点。
11
+ *
12
+ * 续读方向只有一种:`forwards`,它表示「沿当前排序方向继续」,与 `is_desc` 无关。
13
+ * 实测(2026-10,cn-south-1)降序查询配 `backwards` 时,第二页与第一页的 line_num
14
+ * 重叠 38%~100%:正序配 backwards 更直接,返回的就是第一页那一段。而且全程不报
15
+ * 错。所以这里不再按排序方向推方向——反了会静默产出重复数据,比报错难查得多。
16
+ * 对照:无论升降序,`forwards` 连翻 10 页、每页 100 条,边界重叠为 0。
17
+ *
18
+ * 游标是排他的:从 line_num=L 续读只会返回严格在 L 之后的记录。但 line_num 在
19
+ * 纳秒级并不唯一——同一纳秒的多条日志共用一个值,一组日志正好被页边界切开时,
20
+ * 后半组会被整组跳过(实测:单页查得到 18 条,--all 只拿到 17 条)。所以续读
21
+ * 游标取 L 的前一格(正序 L-1、降序 L+1),让下一页从这一组的组头开始读,再按
22
+ * 「line_num + 内容 + labels」跳过本页已经收走的那几条。line_num 不唯一,不能
23
+ * 只按它去重,否则会把同纳秒的真实日志一起丢掉。
24
+ */
25
+ /** 续读游标时唯一正确的 search_type。 */
26
+ exports.CONTINUE_SEARCH_TYPE = 'forwards';
27
+ /** 服务端单页上限:limit=5001 直接回 LTS.1903 "limit must be between 1 and 5000"。 */
28
+ exports.MAX_PAGE_SIZE = 5000;
10
29
  /** 游标取本页最后一条的 line_num;拿不到就返回 undefined(调用方据此停止)。 */
11
30
  function nextCursor(logs) {
12
31
  if (logs.length === 0)
@@ -14,39 +33,119 @@ function nextCursor(logs) {
14
33
  const last = logs[logs.length - 1].lineNum;
15
34
  return last ? last : undefined;
16
35
  }
36
+ /** line_num 是纳秒整数;游标前后挪一格,才能把被页边界切开的同值组重新纳入。 */
37
+ function shiftLineNum(lineNum, delta) {
38
+ if (!/^\d+$/.test(lineNum))
39
+ return undefined;
40
+ return (BigInt(lineNum) + delta).toString();
41
+ }
42
+ /** line_num 不唯一,同纳秒同内容也可能是两条真实日志;回放判定要带上内容与 labels。 */
43
+ function entrySignature(entry) {
44
+ const labels = Object.keys(entry.labels)
45
+ .sort()
46
+ .map((key) => `${key}=${entry.labels[key]}`)
47
+ .join('\u0001');
48
+ return `${entry.content}\u0000${labels}`;
49
+ }
50
+ /** 统计某一 line_num 组内各签名的条数,下一页据此跳过回放。 */
51
+ function countSignatures(entries, lineNum) {
52
+ const counts = new Map();
53
+ for (const entry of entries) {
54
+ if (entry.lineNum !== lineNum)
55
+ continue;
56
+ const signature = entrySignature(entry);
57
+ counts.set(signature, (counts.get(signature) ?? 0) + 1);
58
+ }
59
+ return counts;
60
+ }
61
+ function mergeCounts(into, extra) {
62
+ for (const [signature, count] of extra) {
63
+ into.set(signature, (into.get(signature) ?? 0) + count);
64
+ }
65
+ return into;
66
+ }
17
67
  async function fetchAllPages(fetchPage, params, options = {}) {
18
68
  const max = options.max ?? 10000;
19
- const pageSize = options.pageSize ?? params.limit ?? 100;
20
- const direction = cursorDirection(params.reverse);
69
+ // 每页条数不能超过服务端上限,否则整次 --all 直接报错
70
+ const basePageSize = Math.min(options.pageSize ?? params.limit ?? 100, exports.MAX_PAGE_SIZE);
71
+ // forwards 沿当前排序方向推进:正序越来越晚(-1),降序越来越早(+1)
72
+ const step = params.reverse === true ? 1n : -1n;
21
73
  const collected = [];
22
74
  let cursor;
75
+ // 上一页末条所在的 line_num 组;下一页从该组组头重读,组内已收走的条目按签名跳过
76
+ let boundaryLineNum;
77
+ let boundaryCounts = new Map();
23
78
  let isQueryComplete;
24
79
  let truncated = false;
25
80
  while (collected.length < max) {
26
- const page = await fetchPage({
27
- ...params,
28
- limit: pageSize,
29
- lineNum: cursor,
30
- searchType: cursor ? direction : undefined,
31
- });
32
- if (page.isQueryComplete === false) {
33
- isQueryComplete = false;
81
+ // 重读组头会带回已收走的同值组;多要这么多条,保证每页仍有 pageSize 条新数据
82
+ const boundarySize = [...boundaryCounts.values()].reduce((sum, count) => sum + count, 0);
83
+ let pageSize = Math.min(basePageSize + boundarySize, exports.MAX_PAGE_SIZE);
84
+ let pageLength = 0;
85
+ let fresh = [];
86
+ let reachedEnd = false;
87
+ // 同纳秒的日志多到一页装不下时,重读组头永远只能拿到前 pageSize 条;
88
+ // 放大一页才可能越过这一组。
89
+ while (true) {
90
+ const page = await fetchPage({
91
+ ...params,
92
+ limit: pageSize,
93
+ lineNum: cursor,
94
+ searchType: cursor ? exports.CONTINUE_SEARCH_TYPE : undefined,
95
+ });
96
+ if (page.isQueryComplete === false) {
97
+ isQueryComplete = false;
98
+ }
99
+ else if (isQueryComplete === undefined && page.isQueryComplete === true) {
100
+ isQueryComplete = true;
101
+ }
102
+ pageLength = page.logs.length;
103
+ if (pageLength === 0) {
104
+ reachedEnd = true;
105
+ break;
106
+ }
107
+ const pending = new Map(boundaryCounts);
108
+ fresh = [];
109
+ for (const entry of page.logs) {
110
+ if (boundaryLineNum !== undefined && entry.lineNum === boundaryLineNum) {
111
+ const signature = entrySignature(entry);
112
+ const remaining = pending.get(signature) ?? 0;
113
+ if (remaining > 0) {
114
+ pending.set(signature, remaining - 1);
115
+ continue;
116
+ }
117
+ }
118
+ fresh.push(entry);
119
+ }
120
+ if (fresh.length > 0)
121
+ break;
122
+ // 不满一页且全是回放 = 同值组之后再无日志,已经到底
123
+ if (pageLength < pageSize) {
124
+ reachedEnd = true;
125
+ break;
126
+ }
127
+ if (pageSize >= exports.MAX_PAGE_SIZE) {
128
+ truncated = true;
129
+ reachedEnd = true;
130
+ break;
131
+ }
132
+ pageSize = Math.min(pageSize * 2, exports.MAX_PAGE_SIZE);
34
133
  }
35
- else if (isQueryComplete === undefined && page.isQueryComplete === true) {
36
- isQueryComplete = true;
37
- }
38
- if (page.logs.length === 0)
39
- break;
40
- const next = nextCursor(page.logs);
41
- // 服务端把同一段原样又给了一遍:整页丢弃并停止。
42
- // 先判游标再入账,否则重放的一页会被当成新数据拼进结果。
43
- if (cursor !== undefined && next === cursor)
134
+ if (reachedEnd)
44
135
  break;
45
- collected.push(...page.logs);
46
- // 不满一页 = 没有更多了;拿不到游标则无法继续翻页
47
- if (page.logs.length < pageSize || !next)
136
+ collected.push(...fresh);
137
+ const lastLineNum = fresh[fresh.length - 1]?.lineNum;
138
+ // 不满一页 = 没有更多了;游标推不动则无法继续翻页
139
+ const next = lastLineNum ? shiftLineNum(lastLineNum, step) : undefined;
140
+ if (pageLength < pageSize || !next)
48
141
  break;
49
142
  cursor = next;
143
+ // 同值组可能横跨多页:计数必须累计,否则重读组头时会把前几页收走的条目再收一遍
144
+ boundaryCounts =
145
+ lastLineNum === boundaryLineNum
146
+ ? mergeCounts(boundaryCounts, countSignatures(fresh, lastLineNum))
147
+ : countSignatures(fresh, lastLineNum);
148
+ boundaryLineNum = lastLineNum;
50
149
  if (collected.length >= max) {
51
150
  truncated = true;
52
151
  }
package/package.json CHANGED
@@ -1,57 +1,57 @@
1
- {
2
- "name": "@cvtoolman/huawei-lts-cli",
3
- "version": "2.1.0",
4
- "description": "Command-line tool for querying Huawei Cloud Log Tank Service (LTS) logs",
5
- "main": "dist/cli/index.js",
6
- "bin": {
7
- "lts-cli": "dist/cli/index.js"
8
- },
9
- "files": [
10
- "dist/"
11
- ],
12
- "scripts": {
13
- "build": "tsc",
14
- "dev": "tsx src/cli/index.ts",
15
- "start": "node dist/cli/index.js",
16
- "test": "node --import tsx --test \"test/**/*.test.ts\"",
17
- "typecheck": "tsc --noEmit",
18
- "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
19
- "prepublishOnly": "npm run clean && npm run build"
20
- },
21
- "keywords": [
22
- "huawei-cloud",
23
- "huawei",
24
- "lts",
25
- "log-tank-service",
26
- "logs",
27
- "cli",
28
- "log-query",
29
- "cloud-logging"
30
- ],
31
- "author": "cvtoolman",
32
- "license": "MIT",
33
- "repository": {
34
- "type": "git",
35
- "url": "git+https://github.com/jcxy/huawei-lts-cli.git"
36
- },
37
- "bugs": {
38
- "url": "https://github.com/jcxy/huawei-lts-cli/issues"
39
- },
40
- "homepage": "https://github.com/jcxy/huawei-lts-cli#readme",
41
- "engines": {
42
- "node": ">=18.0.0"
43
- },
44
- "dependencies": {
45
- "@huaweicloud/huaweicloud-sdk-core": "^3.1.200",
46
- "@huaweicloud/huaweicloud-sdk-lts": "^3.1.200",
47
- "chalk": "^4.1.2",
48
- "cli-table3": "^0.6.5",
49
- "commander": "^15.0.0",
50
- "uuid": "^9.0.0"
51
- },
52
- "devDependencies": {
53
- "@types/node": "^26.0.0",
54
- "tsx": "^4.22.4",
55
- "typescript": "^6.0.3"
56
- }
57
- }
1
+ {
2
+ "name": "@cvtoolman/huawei-lts-cli",
3
+ "version": "2.1.1",
4
+ "description": "Command-line tool for querying Huawei Cloud Log Tank Service (LTS) logs",
5
+ "main": "dist/cli/index.js",
6
+ "bin": {
7
+ "lts-cli": "dist/cli/index.js"
8
+ },
9
+ "files": [
10
+ "dist/"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev": "tsx src/cli/index.ts",
15
+ "start": "node dist/cli/index.js",
16
+ "test": "node --import tsx --test \"test/**/*.test.ts\"",
17
+ "typecheck": "tsc --noEmit",
18
+ "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
19
+ "prepublishOnly": "npm run clean && npm run build"
20
+ },
21
+ "keywords": [
22
+ "huawei-cloud",
23
+ "huawei",
24
+ "lts",
25
+ "log-tank-service",
26
+ "logs",
27
+ "cli",
28
+ "log-query",
29
+ "cloud-logging"
30
+ ],
31
+ "author": "cvtoolman",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/jcxy/huawei-lts-cli.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/jcxy/huawei-lts-cli/issues"
39
+ },
40
+ "homepage": "https://github.com/jcxy/huawei-lts-cli#readme",
41
+ "engines": {
42
+ "node": ">=18.0.0"
43
+ },
44
+ "dependencies": {
45
+ "@huaweicloud/huaweicloud-sdk-core": "^3.1.200",
46
+ "@huaweicloud/huaweicloud-sdk-lts": "^3.1.200",
47
+ "chalk": "^4.1.2",
48
+ "cli-table3": "^0.6.5",
49
+ "commander": "^15.0.0",
50
+ "uuid": "^9.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^26.0.0",
54
+ "tsx": "^4.22.4",
55
+ "typescript": "^6.0.3"
56
+ }
57
+ }
@@ -1,15 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.queryOptions = void 0;
4
- exports.queryOptions = {
5
- groupId: { short: '-g', long: '--group-id <groupId>', description: 'Log group ID', required: true },
6
- streamId: { short: '-s', long: '--stream-id <streamId>', description: 'Log stream ID', required: true },
7
- startTime: { short: '--start-time <startTime>', long: '--start-time <startTime>', description: 'Start time (ISO 8601)', required: true },
8
- endTime: { short: '--end-time <endTime>', long: '--end-time <endTime>', description: 'End time (ISO 8601)', required: true },
9
- keyword: { short: '-k', long: '--keyword <keyword>', description: 'Search keyword', required: false },
10
- limit: { short: '-l', long: '--limit <limit>', description: 'Limit number of results', required: false },
11
- offset: { short: '-o', long: '--offset <offset>', description: 'Offset for pagination', required: false },
12
- reverse: { short: '-r', long: '--reverse', description: 'Reverse order', required: false },
13
- format: { short: '-f', long: '--format <format>', description: 'Output format: json, table, pretty', required: false },
14
- noPaginate: { short: '', long: '--no-paginate', description: 'Disable pagination', required: false },
15
- };