@cvtoolman/huawei-lts-cli 2.0.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
@@ -3,6 +3,7 @@
3
3
  **简体中文** | [English](README_en.md)
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@cvtoolman/huawei-lts-cli.svg)](https://www.npmjs.com/package/@cvtoolman/huawei-lts-cli)
6
+ [![CI](https://github.com/jcxy/huawei-lts-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/jcxy/huawei-lts-cli/actions/workflows/ci.yml)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
8
 
8
9
  > 让你的 AI 编程助手(Claude Code、Cursor、Copilot、Qoder 等)通过自然语言查询华为云日志服务(LTS)。
@@ -60,14 +61,15 @@ npm install -g @cvtoolman/huawei-lts-cli
60
61
  lts-cli init
61
62
  ```
62
63
 
63
- 向导会依次询问 AK、SK、Project ID、Region,一次配好。同时会生成两个文件:
64
+ 向导会依次询问 AK、SK、Project ID、Region,一次配好,只写一个文件:
64
65
 
65
66
  | 文件 | 内容 | 谁读 |
66
67
  |---|---|---|
67
68
  | `~/.lts-cli/config.json` | 凭证、默认日志组/流、`traceField`(权限 0600) | 仅 CLI |
68
- | `~/.lts-cli/profile.md` | 业务词表:把「订单服务」映射到日志里的实际取值 | 仅 AI |
69
69
 
70
- **CLI 不读 `profile.md`,AI 不读 `config.json`。** 后者含明文 AK/SK,任何让 AI 读它的设计都是错的。
70
+ **这个目录不要整个交给 AI**,里面是明文 AK/SK。要看配置用 `lts-cli config --list`(已脱敏),不需要打开文件。
71
+
72
+ 日志组有哪些、字段叫什么、值长什么样——这些事实全部靠 `lts-cli groups` / `streams` / `fields` 现查,仓库和配置里都不存第二份。
71
73
 
72
74
  拿到控制台日志搜索页的 URL 时,可以直接导入 region / groupId / streamId:
73
75
 
@@ -77,27 +79,34 @@ lts-cli init --from-url "<控制台URL>"
77
79
 
78
80
  ### 3. 安装 Skill(给 AI 工具用)
79
81
 
80
- skill 目录含 `SKILL.md` 和 2 个参考文档,整目录复制:
82
+ 用 [skills CLI](https://skills.sh) 一条命令装到对应工具的目录(支持 75+ 种 agent):
81
83
 
82
84
  ```bash
83
- # Claude Code
84
- git clone --depth 1 https://github.com/jcxy/huawei-lts-cli.git /tmp/huawei-lts-cli
85
- mkdir -p .claude/skills/huawei-lts-log-query
86
- cp -r /tmp/huawei-lts-cli/skills/huawei-lts-log-query/. .claude/skills/huawei-lts-log-query/
87
-
88
- # Qoder:把 .claude/skills/ 换成 .qoder/skills/
89
- # 其他工具:换成对应目录(.cursor/skills/ 等)
85
+ npx skills add jcxy/huawei-lts-cli -a qoder # Qoder
86
+ npx skills add jcxy/huawei-lts-cli -a claude-code # Claude Code
87
+ npx skills add jcxy/huawei-lts-cli -a cursor # Cursor
88
+ npx skills add jcxy/huawei-lts-cli # 交互式选 agent
90
89
  ```
91
90
 
92
- Windows PowerShell:
91
+ 装完重启 AI 工具:skill 在会话启动时读进上下文,已经开着的会话不会热加载。日常用 `npx skills update` 更新、`npx skills remove` 卸载。
93
92
 
94
- ```powershell
95
- git clone --depth 1 https://github.com/jcxy/huawei-lts-cli.git $env:TEMP\huawei-lts-cli
96
- New-Item -ItemType Directory -Force .claude\skills\huawei-lts-log-query | Out-Null
97
- Copy-Item -Recurse -Force $env:TEMP\huawei-lts-cli\skills\huawei-lts-log-query\* .claude\skills\huawei-lts-log-query\
93
+ 不想用 npx 也可以手动复制(任何工具都适用):
94
+
95
+ ```bash
96
+ git clone --depth 1 https://github.com/jcxy/huawei-lts-cli.git /tmp/huawei-lts-cli
97
+ cp -r /tmp/huawei-lts-cli/skills/huawei-lts-log-query <目标 skills 目录>/
98
98
  ```
99
99
 
100
- ### 4. 命令行直接使用(不依赖 AI)
100
+ > `npx skills` 默认用软链指向一份 git clone,更新时一起同步。用 `--copy`(Windows 无软链权限时的退路)或插件方式安装时,升级会整目录替换——如果你在里面放了业务词表,先备份。
101
+
102
+ ### 4. 业务词表(可选)
103
+
104
+ AI 不知道「订单服务」在你的日志里叫什么。两条路,都不需要配置文件:
105
+
106
+ - **默认行为**:AI 用管道 SQL 实时枚举候选值(`* | SELECT appName, count(*) GROUP BY appName`),再问你挑哪一个。不落盘,也不会过期。
107
+ - **想省这一步**:在 skill 的 `references/vocab.md` 里写映射。这个文件默认不存在,自己建;已被 `.gitignore` 排除,不会被误提交。
108
+
109
+ ### 5. 命令行直接使用(不依赖 AI)
101
110
 
102
111
  ```bash
103
112
  # 最近 2 小时最新 50 条
@@ -144,10 +153,10 @@ lts-cli query --last 1h -f json | jq '.logs[].content'
144
153
  --app <取值> appName:"<取值>",与 -q 用 AND 合并
145
154
  --content <关键词> content:"<关键词>",与 -q 用 AND 合并
146
155
  --request-id <id> REQUEST_ID:"<id>",按时间正序输出
147
- -l, --limit <n> 每页条数(默认 100),--all 时即单页大小
156
+ -l, --limit <n> 每页条数(默认 100,上限 5000),--all 时同时是总数上限
148
157
  -r, --reverse 最新优先
149
158
  -f, --format <fmt> pretty(默认)/ table / json
150
- --all 沿游标读到末尾(上限 10000 条)
159
+ --all 沿游标读到末尾(默认上限 10000 条,显式 -l 则为 -l)
151
160
  ```
152
161
 
153
162
  `--app` / `--content` / `--request-id` 自动为值加双引号——含连字符的 UUID 不加引号会被分词导致匹配不到。
@@ -158,12 +167,21 @@ lts-cli query --last 1h -f json | jq '.logs[].content'
158
167
  lts-cli trace <id> [--field <name>] [--last 24h] [-l 200] [-f fmt]
159
168
  ```
160
169
 
161
- 按时间正序拉出一条 ID 的完整链路。默认走全文检索(不依赖任何特定字段命名);如果你的日志流有专用字段,配一次即可:
170
+ 按时间正序拉出一条 ID 的完整链路。字段选择顺序是 `--field` → `config.traceField` → 运行时读一次流的索引配置:把 `REQUEST_ID` / `trace_id` / `span_id` 这类 ID 字段和全文检索 `content` 一起 OR 上。结构化日志的 ID 住在 labels 里,全文索引只覆盖正文,所以不能只靠 `content`。零命中时 stderr 会列出试过哪些字段。
171
+
172
+ ```bash
173
+ lts-cli trace <id> --field REQUEST_ID # 临时指定
174
+ lts-cli config --set traceField=REQUEST_ID # 这条流长期如此才值得配
175
+ ```
176
+
177
+ ### `lts-cli context`
162
178
 
163
179
  ```bash
164
- lts-cli config --set traceField=REQUEST_ID
180
+ lts-cli context --line-num <lineNum> [--time <毫秒>] [--before 20] [--after 20]
165
181
  ```
166
182
 
183
+ `--time` 可省:`line_num` 本身就是纳秒时间戳,前 13 位即毫秒,CLI 自己推。
184
+
167
185
  ## 配置说明
168
186
 
169
187
  配置文件 `~/.lts-cli/config.json`(Windows 为 `%USERPROFILE%\.lts-cli\config.json`):
@@ -177,7 +195,7 @@ lts-cli config --set traceField=REQUEST_ID
177
195
  | `endpoint` | 否 | 需包含 `https://` |
178
196
  | `groupId` | 否 | 默认日志组 ID |
179
197
  | `streamId` | 否 | 默认日志流 ID |
180
- | `traceField` | 否 | `trace` 默认匹配的字段名,缺省为 `content` |
198
+ | `traceField` | 否 | `trace` 的匹配字段名;不配则自动从流索引里发现 ID 字段 |
181
199
 
182
200
  `lts-cli config --list` 会把 `ak` / `sk` 脱敏输出,避免凭证进入终端滚屏、shell 历史或 AI 上下文。
183
201
 
@@ -216,7 +234,19 @@ lts-cli config --set traceField=REQUEST_ID
216
234
 
217
235
  不在表里时用 `--set endpoint=https://...` 手动指定,或参考 [华为云 LTS 终端节点](https://support.huaweicloud.com/api-lts/lts_api_0011.html)。
218
236
 
219
- ## 从 1.x 升级到 2.0
237
+ ## 升级说明
238
+
239
+ ### 从 2.0 升级到 2.1
240
+
241
+ | 变更 | 说明 |
242
+ |---|---|
243
+ | `init` 不再生成 `~/.lts-cli/profile.md` | 业务词表改为 skill 目录下的 `references/vocab.md`。旧文件可以删,留着也没影响(CLI 从来没读过它) |
244
+ | SKILL.md 增加运行时枚举候选值 | 没有词表也能定位应用名 |
245
+ | 新增 `npx skills` 安装方式 | 见上文「安装 Skill」 |
246
+
247
+ 命令行参数没有变化,脚本不需要改。
248
+
249
+ ### 从 1.x 升级到 2.0
220
250
 
221
251
  2.0 修了三个会静默出错的问题,因此有破坏性变更:
222
252
 
@@ -243,9 +273,15 @@ npm test # node:test,覆盖纯函数与游标推进
243
273
  npm run typecheck # tsc --noEmit
244
274
  ```
245
275
 
246
- 测试使用 Node 内置的 `node:test` + `tsx`,不引入额外测试依赖。跑测试需要 Node >= 20.6(`--import` 的要求)。
276
+ 测试使用 Node 内置的 `node:test` + `tsx`,不引入额外测试依赖。跑测试需要 Node >= 22——`node --test` 的通配符支持直到 22 才落地,20 上会报 `Could not find`。CLI 本身在 Node 18 上可以正常运行,CI 的 typecheck/build 覆盖 18/20/22。
277
+
278
+ 架构约定见 [CONTEXT.md](CONTEXT.md) 与 [docs/adr/](docs/adr/)。提交前请读 [CONTRIBUTING.md](CONTRIBUTING.md)——里面写了这个仓库唯一不能被破坏的原则:**同一个事实只允许有一个来源**。
279
+
280
+ ## 参与
247
281
 
248
- 架构约定见 [CONTEXT.md](CONTEXT.md) 与 [docs/adr/](docs/adr/)。
282
+ - 报告问题:[issue 模板](https://github.com/jcxy/huawei-lts-cli/issues/new/choose)(请勿贴真实凭证、日志组 ID 或内部业务名)
283
+ - 安全问题:[SECURITY.md](SECURITY.md),走私密通道,不要开公开 issue
284
+ - 版本变更:[CHANGELOG.md](CHANGELOG.md)
249
285
 
250
286
  ## License
251
287
 
@@ -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
  };
@@ -35,24 +35,28 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.createInitCommand = createInitCommand;
37
37
  const commander_1 = require("commander");
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
38
40
  const readline = __importStar(require("readline"));
39
41
  const config_1 = require("../../config");
40
- const profile_1 = require("../../config/profile");
42
+ const store_1 = require("../../config/store");
41
43
  function ask(rl, question) {
42
44
  return new Promise((resolve) => {
43
45
  rl.question(question, (answer) => resolve(answer.trim()));
44
46
  });
45
47
  }
46
- /** 写 profile.md 骨架并告知路径。已存在则绝不覆盖——那是用户手写的内容。 */
47
- function reportProfileSkeleton() {
48
- const { path, created } = (0, profile_1.writeProfileSkeleton)();
49
- if (created) {
50
- console.log(` profile.md = ${path} (skeleton created)`);
51
- console.log(' 业务词表:把「订单服务」这类称呼映射到日志里的实际取值');
52
- }
53
- else {
54
- console.log(` profile.md = ${path} (existing, left untouched)`);
48
+ /**
49
+ * 2.0 及更早版本会在凭证目录里生成一份业务词表骨架,那是把 AI 引进
50
+ * 明文 AK/SK 所在目录的入口。2.1 起不再生成,业务词表改由 skill 的
51
+ * `references/` 承载。这里只对老用户的残留文件做一次性提示,绝不代删。
52
+ */
53
+ function reportLegacyProfile() {
54
+ const legacy = path.join((0, store_1.getConfigDir)(), 'profile.md');
55
+ if (!fs.existsSync(legacy)) {
56
+ return;
55
57
  }
58
+ console.log(` note: ${legacy} is no longer used since 2.1 — safe to delete.`);
59
+ console.log(' Business vocabulary now lives in the skill: references/vocab.md');
56
60
  }
57
61
  const ENDPOINT_MAP = {
58
62
  'cn-north-1': 'lts.cn-north-1.myhuaweicloud.com',
@@ -156,7 +160,7 @@ function createInitCommand() {
156
160
  }
157
161
  console.log('');
158
162
  console.log('✅ Configuration saved!');
159
- reportProfileSkeleton();
163
+ reportLegacyProfile();
160
164
  console.log('');
161
165
  console.log('Try it out:');
162
166
  if (groupId && streamId) {
@@ -219,7 +223,7 @@ function importFromConsoleUrl(raw) {
219
223
  }
220
224
  console.log('');
221
225
  console.log('✅ Configuration saved!');
222
- reportProfileSkeleton();
226
+ reportLegacyProfile();
223
227
  console.log('');
224
228
  console.log('Next steps:');
225
229
  console.log(' 1. Configure credentials if not done yet: lts-cli init');
@@ -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
+ }