@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.
@@ -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.0.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
- };
@@ -1,108 +0,0 @@
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
- })();
35
- Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.getProfilePath = getProfilePath;
37
- exports.profileExists = profileExists;
38
- exports.writeProfileSkeleton = writeProfileSkeleton;
39
- const fs = __importStar(require("fs"));
40
- const path = __importStar(require("path"));
41
- const store_1 = require("./store");
42
- /**
43
- * `profile.md` 是给人写、给 AI 读的业务词表。CLI 从不读它。
44
- *
45
- * 分界线:`config.json` 存 CLI 的行为(凭证、traceField、默认 group/stream);
46
- * `profile.md` 存 API 拿不到的东西(业务词 → 日志里的值、命名约定、黑话)。
47
- * 字段清单、日志组/流 ID 一律不进 profile —— 那是第二份会过期的真相,
48
- * 用 `lts-cli groups` / `streams` / `fields` 现查。
49
- */
50
- function getProfilePath() {
51
- return path.join((0, store_1.getConfigDir)(), 'profile.md');
52
- }
53
- function profileExists() {
54
- return fs.existsSync(getProfilePath());
55
- }
56
- const SKELETON = `# LTS 业务词表(本机私有)
57
-
58
- > 这份文件给人改、给 AI 读。\`lts-cli\` 本身**不读**它——CLI 的行为配置在 \`~/.lts-cli/config.json\`。
59
- >
60
- > 只写 API 拿不到的东西:业务词怎么落到日志里的值、团队自己的命名约定、黑话。
61
- > **不要**把日志组/日志流 ID、字段清单写进来——用 \`lts-cli groups\` / \`lts-cli streams\` /
62
- > \`lts-cli fields\` 现查。抄进文档的那一刻它就开始过期。
63
-
64
- ## 1. 业务称呼 → 日志里的取值
65
-
66
- 查日志前先在这里翻译,避免每次重新猜应用名。
67
-
68
- | 业务称呼 | 日志中的取值 | 说明 |
69
- | --- | --- | --- |
70
- | 订单服务 | (填字段值和值) | 例如 appName 或 service_name 的取值 |
71
-
72
- ## 2. 字段含义
73
-
74
- 只写「这个字段在我们这里表示什么」。类型、是否大小写敏感、分词符请用 \`lts-cli fields\` 查。
75
-
76
- | 字段 | 含义 |
77
- | --- | --- |
78
- | (字段名) | (说明) |
79
-
80
- ## 3. 命名约定与分词陷阱
81
-
82
- - 哪些值含连字符/下划线,必须加双引号才会被当成一个词
83
- - 值的大小写约定(全大写?小写?)
84
- - 哪些字段看起来该有索引、实际上没建(没建索引的字段只能全文检索命中)
85
-
86
- ## 4. 团队黑话
87
-
88
- | 说法 | 实际指什么 |
89
- | --- | --- |
90
- | (黑话) | (含义) |
91
-
92
- ## 5. 常用排查入口
93
-
94
- | 想查什么 | 建议命令 |
95
- | --- | --- |
96
- | 某个 ID 的完整链路 | \`lts-cli trace <id>\` |
97
- | 某业务最近的报错 | \`lts-cli query --last 1h -q '(字段):(值)'\` |
98
- | 先看清楚这条流有什么字段 | \`lts-cli fields\` |
99
- `;
100
- function writeProfileSkeleton() {
101
- const target = getProfilePath();
102
- if (fs.existsSync(target)) {
103
- return { path: target, created: false };
104
- }
105
- fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
106
- fs.writeFileSync(target, SKELETON, { encoding: 'utf-8', mode: 0o600 });
107
- return { path: target, created: true };
108
- }