@optima-chat/dev-skills 0.16.2 → 0.16.3
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/.claude/commands/logs.md +60 -3
- package/.claude/commands/trace-user.md +18 -6
- package/.claude/skills/cn-deploy/SKILL.md +5 -3
- package/.claude/skills/logs/SKILL.md +10 -2
- package/.claude/skills/reset-onboarding/SKILL.md +116 -0
- package/.codex/skills/cn-deploy/SKILL.md +5 -3
- package/.codex/skills/reset-onboarding/SKILL.md +116 -0
- package/AGENTS.md +11 -6
- package/README.md +73 -61
- package/bin/helpers/cn-deploy.ts +44 -37
- package/bin/helpers/logs.ts +464 -31
- package/dist/bin/helpers/cn-deploy.js +43 -37
- package/dist/bin/helpers/logs.js +410 -31
- package/package.json +1 -1
package/bin/helpers/logs.ts
CHANGED
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
* optima-logs —— 一条命令直取服务日志,四环境统一,用 --env 区分。
|
|
4
4
|
*
|
|
5
5
|
* stage / prod : AWS CloudWatch(`aws logs tail /ecs/<svc>-<env>`)
|
|
6
|
-
* cn-prod / cn-stage: 阿里云 SLS 直连(`aliyun sls
|
|
6
|
+
* cn-prod / cn-stage: 阿里云 SLS 直连(`aliyun sls GetLogsV2`)
|
|
7
7
|
*
|
|
8
8
|
* cn 的关键改进:旧流程要 SSH 进 buildbox 再调 SAE `DescribeInstanceLog`,
|
|
9
9
|
* 只能看实例**当前缓冲**(重启即丢、不能检索)。现在 cn-prod/cn-stage 全部
|
|
10
|
-
* 服务已接 SLS,
|
|
10
|
+
* 服务已接 SLS,GetLogsV2 是公网控制面 API,本机 `aliyun-optima` profile 直连即可:
|
|
11
11
|
* - 免 buildbox 跳板
|
|
12
12
|
* - 支持时间窗(--since)+ 关键词检索(--grep)+ 历史(重启不丢)
|
|
13
13
|
*
|
|
14
|
+
* cn 侧两个已知坑(#75 / #57,都曾把排障带偏),本工具的应对:
|
|
15
|
+
* 1. 单次最多回 100 条(服务端硬上限,V1/V2 皆然)→ 用 --offset 自动翻页到 -n 要的条数;
|
|
16
|
+
* 仍取满、或 SLS 自报未扫完(meta.progress)时,打 ⚠ 明说「总数未知、别拿来计数」。
|
|
17
|
+
* 2. --grep 走 SLS 索引,logstore 正文未入索引时命中数与真实出现次数无关
|
|
18
|
+
* (零命中 ≠ 没有报错,非零命中也 ≠ 真实次数)→ 用 GetIndex 直查索引配置,
|
|
19
|
+
* 搜不到正文就在结果**之前**告警,并给出「拉原始行 + 本地过滤」的替代读法。
|
|
20
|
+
*
|
|
14
21
|
* 前置:
|
|
15
22
|
* AWS → 已配 aws CLI 凭证(ap-southeast-1)
|
|
16
23
|
* cn → 已配 aliyun CLI profile `aliyun-optima`(cn-beijing)
|
|
@@ -42,11 +49,19 @@ interface Args {
|
|
|
42
49
|
service: string;
|
|
43
50
|
env: Env;
|
|
44
51
|
lines: number;
|
|
52
|
+
linesExplicit: boolean; // 用户是否显式传了 --lines/-n(AWS 侧用不上,要提示)
|
|
45
53
|
since: string; // 原样字符串,如 1h / 30m / 2d
|
|
46
54
|
grep?: string;
|
|
47
55
|
json: boolean;
|
|
48
56
|
}
|
|
49
57
|
|
|
58
|
+
/**
|
|
59
|
+
* SLS 单次请求的服务端硬上限(GetLogs / GetLogsV2 皆然):`--line` 传 300/3000 也只回 100 条
|
|
60
|
+
* (实测 aliyun CLI 直连同样如此,不是本工具的限制)。要拿更多必须 --offset 翻页。
|
|
61
|
+
* 见 optima-dev-skills#75。
|
|
62
|
+
*/
|
|
63
|
+
export const SLS_PAGE_MAX = 100;
|
|
64
|
+
|
|
50
65
|
const HELP = `Usage: optima-logs <service> [options]
|
|
51
66
|
|
|
52
67
|
四环境统一查日志,cn 直连阿里云 SLS(免 buildbox),AWS 走 CloudWatch。
|
|
@@ -56,7 +71,8 @@ Required:
|
|
|
56
71
|
|
|
57
72
|
Optional:
|
|
58
73
|
--env <e> stage | prod | cn-prod | cn-stage (default: cn-prod)
|
|
59
|
-
--lines, -n <N>
|
|
74
|
+
--lines, -n <N> 返回条数,cn 侧自动翻页突破单次 100 上限 (default: 100)
|
|
75
|
+
AWS 侧无此概念(aws logs tail 吐整个窗),传了会提示
|
|
60
76
|
--since <dur> 时间窗,如 30m / 2h / 1d / 3600(秒) (default: 1h)
|
|
61
77
|
--grep <kw> 关键词检索(cn=SLS query / aws=filter-pattern)
|
|
62
78
|
--json 原始 JSON 输出(接管道)
|
|
@@ -65,7 +81,22 @@ Optional:
|
|
|
65
81
|
Examples:
|
|
66
82
|
optima-logs gateway-core
|
|
67
83
|
optima-logs user-auth --env prod --since 2h
|
|
68
|
-
optima-logs commerce-backend --env cn-prod --grep error -n 200
|
|
84
|
+
optima-logs commerce-backend --env cn-prod --grep error -n 200
|
|
85
|
+
|
|
86
|
+
读数注意(#75/#57 两次误导排障的直接病根):
|
|
87
|
+
· cn 侧只要取到了行,stderr 就会打「实取 N 条 + 真实覆盖窗」——请求 --since 24h
|
|
88
|
+
不代表真拿到了 24h,取满 -n 时窗口会被截在最新那一段。看到 ⚠ 就别拿这批数做
|
|
89
|
+
计数/比值。(AWS 侧无此概念:aws logs tail 吐整个窗;cn 侧零结果时只打「无日志」。)
|
|
90
|
+
· --json 是 pretty-print 数组,数记录**不能** wc -l(一条记录十几到二十行),
|
|
91
|
+
用 grep -c '__time__'。
|
|
92
|
+
· cn 侧 --grep 走 SLS 索引,不是本地正则:logstore 没把正文纳入索引时,搜正文里的
|
|
93
|
+
词会静默少回(已知 cn-stage/agent-runtime)。用了 --grep 会先查 GetIndex,搜不到
|
|
94
|
+
正文就在结果前告警;按索引键查(--grep 'content.level: error')不受影响、不告警。
|
|
95
|
+
· --grep 不认 | 作「或」。query 里的 | 是 SLS「检索|分析(SQL)」的分隔符,
|
|
96
|
+
要或就写 'a or b';要 SQL 就写 '… | select …'(此时 -n 失效,用 SQL LIMIT)。
|
|
97
|
+
· 即便索引正常,零命中也**不等于**这个词不存在:SLS 按完整 token 匹配
|
|
98
|
+
(分词表不含 - _ .),"reconciler" 命不中 "session-reconciler"。要断定「真没有」
|
|
99
|
+
请换完整 token,或去掉 --grep 拉原始行本地过滤复核。`;
|
|
69
100
|
|
|
70
101
|
/** 把 30m / 2h / 1d / 纯秒 解析成秒数;同时回填给 aws 用的带单位字符串。 */
|
|
71
102
|
function parseSince(raw: string): { sec: number; awsStr: string } {
|
|
@@ -77,12 +108,281 @@ function parseSince(raw: string): { sec: number; awsStr: string } {
|
|
|
77
108
|
return { sec: n * mult[unit], awsStr: `${n}${unit}` };
|
|
78
109
|
}
|
|
79
110
|
|
|
111
|
+
/** 把想取的总条数切成一串每次 ≤SLS_PAGE_MAX 的请求(返回每次请求的 --line 值)。 */
|
|
112
|
+
export function pageSizes(want: number, max: number = SLS_PAGE_MAX): number[] {
|
|
113
|
+
const out: number[] = [];
|
|
114
|
+
for (let left = want; left > 0; left -= max) out.push(Math.min(left, max));
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 去掉双引号包住的片段:里面的 | : 之类是**值**,不是查询语法。 */
|
|
119
|
+
function stripQuoted(q: string): string {
|
|
120
|
+
return q.replace(/"(?:[^"\\]|\\.)*"/g, ' ');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 带分析语句(SQL)的 query 用 `|` 分隔检索与分析两段。SLS 明确规定此时
|
|
125
|
+
* line/offset 失效、要用 SQL 自己的 LIMIT 翻页 —— 这种 query 不能按页累加。
|
|
126
|
+
*
|
|
127
|
+
* 只看有没有 `|` 会把 `--grep 'timeout|error'` 这种最自然的写法误判成 SQL,
|
|
128
|
+
* 后果是**单页封顶 + 一条编造的「请在 SQL 里加 LIMIT」+ 覆盖窗被抑制** ——
|
|
129
|
+
* 正是本工具要消灭的形状。故两道收窄(都由 SLS 实测行为定):
|
|
130
|
+
* · 引号内的 `|` 不算分隔符 —— `"timeout|error"` 实测返回 meta.hasSQL=false,是普通检索;
|
|
131
|
+
* · SLS 规定分析段是标准 SQL —— 不带 SQL 起头词的 `a|b` SLS 直接报
|
|
132
|
+
* `parse fail, please check your query,if it has (SELECT)`,让它照常翻页、
|
|
133
|
+
* 把 SLS 自己的报错原样透出去,比我们编一个解释准。
|
|
134
|
+
* 起头词 select **和** with 都要认:实测 `* | with t as (select …) select * from t`
|
|
135
|
+
* 返回 meta.hasSQL=true,漏判它就会照聚合行反算出一个**假的**「实际覆盖窗」。
|
|
136
|
+
*/
|
|
137
|
+
export function isAnalyticQuery(q?: string): boolean {
|
|
138
|
+
return !!q && /\|\s*(select|with)\b/i.test(stripQuoted(q));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 从 query 里认出「按索引键过滤」的子句(SLS 语法 `content.<key>: <值>`)。
|
|
143
|
+
* fullyScoped = 整条 query 只由这类子句 + 布尔算子组成 ⇒ 命中数只取决于这些键
|
|
144
|
+
* 有没有进索引,与「正文进没进索引」无关。
|
|
145
|
+
*/
|
|
146
|
+
export function keyScopedQuery(q?: string, field = 'content'): { keys: string[]; fullyScoped: boolean } {
|
|
147
|
+
if (!q) return { keys: [], fullyScoped: false };
|
|
148
|
+
const keys: string[] = [];
|
|
149
|
+
// 🔴 引号只能作为**键的值**被吃掉,绝不能全局剥。先 stripQuoted 再匹配的写法会把
|
|
150
|
+
// 独立的引号短语检索项(`content.level: info and "warm"`里的 "warm")一并抹掉,
|
|
151
|
+
// 剩下的 rest 变空 ⇒ 误判成「纯按键查」⇒ 吞掉告警、还反过来发正面背书。
|
|
152
|
+
// 实测(cn-stage/agent-runtime 钉死窗 1786066795..1786073995):这条 query
|
|
153
|
+
// SLS 实回 2 条,而窗内 level=info 且含 warm 的真实是 12 条,工具却说「不受影响」。
|
|
154
|
+
// 故把值并进键子句的正则:引号在值的位置才被消费,在别处原样留在 rest 里。
|
|
155
|
+
const VAL = '("(?:[^"\\\\]|\\\\.)*"|\\S*)';
|
|
156
|
+
let rest = q.replace(
|
|
157
|
+
new RegExp(`\\b${field}\\.([A-Za-z0-9_.-]+)\\s*:\\s*${VAL}`, 'g'),
|
|
158
|
+
(_m, k: string) => { keys.push(k); return ' '; },
|
|
159
|
+
);
|
|
160
|
+
// 布尔算子/括号/通配符不是「正文词」,剔掉后还剩东西就说明混了全文检索项。
|
|
161
|
+
rest = rest.replace(/\b(and|or|not)\b/gi, ' ').replace(/[()*\s]+/g, ' ').trim();
|
|
162
|
+
return { keys, fullyScoped: keys.length > 0 && rest === '' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 从返回行的 __time__ 反算**真实**覆盖窗(可能远小于请求的 --since)。 */
|
|
166
|
+
export function coverage(rows: Array<Record<string, string>>): { count: number; from?: number; to?: number } {
|
|
167
|
+
// 用 reduce 而不是 Math.min(...ts):后者在大结果集上会栈溢出。
|
|
168
|
+
let lo = Infinity;
|
|
169
|
+
let hi = -Infinity;
|
|
170
|
+
for (const r of rows) {
|
|
171
|
+
// `t <= 0` 这一半是必需的:Number('') / Number(' ') / Number(null) 全是 0,
|
|
172
|
+
// 只判 isFinite 会把缺字段的行算成 epoch 0,覆盖窗打成「01-01 08:00:00 ~ …」。
|
|
173
|
+
// 而文档正让人「以实际覆盖窗为准、别以 --since 为准」——这个数是被要求信任的,
|
|
174
|
+
// 不能由一个空串编出来。SLS 的 __time__ 恒为正 epoch 秒,0 与负数都不是真时间。
|
|
175
|
+
const t = Number(r.__time__);
|
|
176
|
+
if (!Number.isFinite(t) || t <= 0) continue;
|
|
177
|
+
if (t < lo) lo = t;
|
|
178
|
+
if (t > hi) hi = t;
|
|
179
|
+
}
|
|
180
|
+
if (lo === Infinity) return { count: rows.length };
|
|
181
|
+
return { count: rows.length, from: lo, to: hi };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 一次 GetLogsV2 的结果:行 + SLS 自报的本次查询完整性。 */
|
|
185
|
+
export interface SlsPage {
|
|
186
|
+
rows: Array<Record<string, string>>;
|
|
187
|
+
/** SLS 的 meta.progress;'Complete' 以外都代表这次结果**不全**。空串 = 响应里根本没有这个字段。 */
|
|
188
|
+
progress: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface CollectResult {
|
|
192
|
+
rows: Array<Record<string, string>>;
|
|
193
|
+
/** 取满了 want 且最后一页仍是满的 ⇒ 窗内可能还有,真实总数未知。 */
|
|
194
|
+
truncated: boolean;
|
|
195
|
+
/** SLS 自报至少有一页没扫完 ⇒ 结果偏少,且不是「窗内就这么多」。 */
|
|
196
|
+
incomplete: boolean;
|
|
197
|
+
/** 至少有一页的响应里没有 meta.progress ⇒ 扫完没有**读不到**,不是「扫完了」。 */
|
|
198
|
+
progressUnknown: boolean;
|
|
199
|
+
requests: number;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 按 offset 翻页收集到 want 条为止。翻页是本工具存在的理由:SLS 单次硬顶
|
|
204
|
+
* SLS_PAGE_MAX 条,只发一次请求就会把「一页」当成「全部」(#75)。
|
|
205
|
+
* single=true 用于分析语句(SQL):此时 line/offset 对 SLS 无效,只能发一次。
|
|
206
|
+
*/
|
|
207
|
+
export function collectPages(
|
|
208
|
+
fetch: (line: number, offset: number) => SlsPage,
|
|
209
|
+
want: number,
|
|
210
|
+
opts: { single?: boolean } = {},
|
|
211
|
+
): CollectResult {
|
|
212
|
+
const rows: Array<Record<string, string>> = [];
|
|
213
|
+
let truncated = false;
|
|
214
|
+
let incomplete = false;
|
|
215
|
+
let progressUnknown = false;
|
|
216
|
+
let requests = 0;
|
|
217
|
+
for (const line of pageSizes(want)) {
|
|
218
|
+
const page = fetch(line, rows.length);
|
|
219
|
+
requests++;
|
|
220
|
+
rows.push(...page.rows);
|
|
221
|
+
// 换 V2 的**全部理由**就是能读到这个信号。读不到时若默认当 Complete,就等于
|
|
222
|
+
// 悄悄退回 V1 盲区 —— 与本工具「unknown 必须与 searchable 分开、不拿没读到的
|
|
223
|
+
// 东西背书」是同一条原则,故单独记一个 unknown 而不是并进 incomplete。
|
|
224
|
+
if (!page.progress) progressUnknown = true;
|
|
225
|
+
else if (page.progress !== 'Complete') incomplete = true;
|
|
226
|
+
if (page.rows.length < line) break; // 短页:窗内已取尽(但 Incomplete 时也会短返回,见下)
|
|
227
|
+
if (opts.single) { truncated = true; break; } // 不能翻页,后面拿不到
|
|
228
|
+
truncated = rows.length >= want; // 取满且最后一页是满的
|
|
229
|
+
}
|
|
230
|
+
return { rows, truncated, incomplete, progressUnknown, requests };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 依 GetIndex 的**实际索引配置**判断:这个 logstore 的日志正文能不能被 --grep 搜到。
|
|
235
|
+
* 这是权威直源 —— 不用「拿几个词试试看」这种间接推断(试到的词恰好落在白名单里
|
|
236
|
+
* 就会把坏库判成好库)。四种形态:
|
|
237
|
+
* · 没有 line(全文)索引 → 正文不可搜
|
|
238
|
+
* · 正文字段没有字段级索引 → 由全文索引覆盖,可搜
|
|
239
|
+
* · 正文字段是 text 型 → 可搜
|
|
240
|
+
* · 正文字段是 json 型且 index_all=false → **只有白名单键可搜,正文不可搜**
|
|
241
|
+
* (实测 cn-stage/agent-runtime 即此形态:26 个白名单键里没有 message)
|
|
242
|
+
*/
|
|
243
|
+
export type BodyIndex = 'searchable' | 'body-not-indexed' | 'unknown';
|
|
244
|
+
|
|
245
|
+
export interface BodyVerdict {
|
|
246
|
+
state: BodyIndex;
|
|
247
|
+
reason?: string;
|
|
248
|
+
indexedKeys?: string[];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const isObj = (v: any): boolean => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
252
|
+
|
|
253
|
+
export function bodySearchable(index: any, field = 'content'): BodyVerdict {
|
|
254
|
+
// 'unknown' 必须与 'searchable' 分开:GetIndex 调不通(如 AK 没有 log:GetIndex
|
|
255
|
+
// 权限)时若退化成「可搜」,就会拿一个从没读到过的索引去给零命中背书。
|
|
256
|
+
//
|
|
257
|
+
// 认不出的形状(数组、包了一层 wrapper key、能解析成 JSON 的错误对象)必须**两个
|
|
258
|
+
// 方向都**收敛到 unknown:往 body-not-indexed 掉会诬告一个健康 logstore、每次
|
|
259
|
+
// --grep 喷三行 ⚠;往 searchable 掉会把没验证过的形状变成正面背书。
|
|
260
|
+
if (!isObj(index)) return { state: 'unknown' };
|
|
261
|
+
// 真实 GetIndex 响应必有 line 或 keys 之一(cn-prod 只有 line,cn-stage 两者都有)。
|
|
262
|
+
// 两个都没有 = 这不是一份索引配置,别拿它下任何结论。
|
|
263
|
+
if (!('line' in index) && !('keys' in index)) return { state: 'unknown' };
|
|
264
|
+
if (!index.line) return { state: 'body-not-indexed', reason: '该 logstore 没有全文索引' };
|
|
265
|
+
if (index.keys !== undefined && !isObj(index.keys)) return { state: 'unknown' };
|
|
266
|
+
const f = (index.keys ?? {})[field];
|
|
267
|
+
if (f === undefined) return { state: 'searchable' }; // 无字段级索引:全文索引覆盖整个字段
|
|
268
|
+
if (!isObj(f) || typeof f.type !== 'string') return { state: 'unknown' };
|
|
269
|
+
if (f.type !== 'json') return { state: 'searchable' }; // text 型:整段进全文索引
|
|
270
|
+
if (f.index_all) return { state: 'searchable' }; // json 型但全键索引
|
|
271
|
+
return {
|
|
272
|
+
state: 'body-not-indexed',
|
|
273
|
+
reason: `${field} 被建成 JSON 型索引且 index_all=false —— 只有白名单键进了索引`,
|
|
274
|
+
indexedKeys: Object.keys(isObj(f.json_keys) ? f.json_keys : {}),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 一行给人看的提示;level 决定颜色,也决定它算不算「告警」。 */
|
|
279
|
+
export interface Msg { level: 'info' | 'warn'; text: string }
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* --grep 搜不到正文时的告警。零命中和非零命中一视同仁 —— 后者同样可能失真。
|
|
283
|
+
*
|
|
284
|
+
* 这个工具的全部价值是**告警的可信度**,所以它必须先对自己诚实,两处收窄:
|
|
285
|
+
* 1. 整条 query 只按索引键过滤时(`content.level: error`)**不告警** —— 这种查询
|
|
286
|
+
* 的命中数与「正文进没进索引」无关。实测 cn-stage/agent-runtime:
|
|
287
|
+
* `content.level: info` 回 150 条,同窗 166 行本地统计也是 150,分毫不差;
|
|
288
|
+
* 而旧版会一边说「这个数不可信」、一边在下一行推荐这个写法。
|
|
289
|
+
* 2. 其余情况仍告警,但**不再断言**「非零命中一定不是真实次数」。索引键的值是
|
|
290
|
+
* 进了索引的:实测 `--grep <userId>` 回 24 条 == 同窗本地统计 24 条(userId 在
|
|
291
|
+
* 白名单里)。真实的盲区是「词出现在正文(如 message)里」,而工具无法替用户
|
|
292
|
+
* 判断自己搜的词属于哪一种 —— 就把这句如实说出来。
|
|
293
|
+
* (两组数同一钉死窗 1786029531..1786036731;复跑命令只维护在
|
|
294
|
+
* .claude/commands/logs.md 第 3 节,那里是唯一权威。)
|
|
295
|
+
*/
|
|
296
|
+
export function indexWarning(service: string, v: BodyVerdict, grep?: string): Msg[] {
|
|
297
|
+
if (v.state !== 'body-not-indexed') return []; // 'unknown' 不告警也不背书
|
|
298
|
+
const indexed = v.indexedKeys ?? [];
|
|
299
|
+
const scoped = keyScopedQuery(grep);
|
|
300
|
+
if (scoped.fullyScoped && scoped.keys.every((k) => indexed.includes(k))) {
|
|
301
|
+
return [{
|
|
302
|
+
level: 'info',
|
|
303
|
+
text: `# ${service} 的正文未进 SLS 索引,但本次 --grep 只按索引键(${scoped.keys.join(', ')})过滤 ⇒ 命中数不受影响。`,
|
|
304
|
+
}];
|
|
305
|
+
}
|
|
306
|
+
const out: Msg[] = [
|
|
307
|
+
{ level: 'warn', text: `⚠ ${service} 的日志正文**没进 SLS 索引**(${v.reason}) —— --grep 搜不到正文里的词。` },
|
|
308
|
+
{ level: 'warn', text: ' 只有索引键的**值**进了索引:搜的词若出现在正文(如 message),零命中不代表「没有报错」、非零命中也**不是**真实出现次数;若它正好是某个索引键的值(如 userId/sessionId),命中数才是准的 —— 本工具替你判断不了是哪种。要按正文过滤请去掉 --grep 拉原始行 + 本地管道过滤。见 optima-dev-skills#75。' },
|
|
309
|
+
];
|
|
310
|
+
if (indexed.length) {
|
|
311
|
+
out.push({ level: 'warn', text: ` 索引键:${indexed.join(', ')} —— 按键查不受这个盲区影响,如 --grep 'content.level: error'。` });
|
|
312
|
+
}
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface ReportInput {
|
|
317
|
+
service: string;
|
|
318
|
+
lines: number;
|
|
319
|
+
grep?: string;
|
|
320
|
+
analytic: boolean;
|
|
321
|
+
from: number;
|
|
322
|
+
to: number;
|
|
323
|
+
cov: { count: number; from?: number; to?: number };
|
|
324
|
+
truncated: boolean;
|
|
325
|
+
incomplete: boolean;
|
|
326
|
+
/** 响应里读不到 meta.progress ⇒ 「扫完没有」未知,不能当成扫完了。 */
|
|
327
|
+
progressUnknown?: boolean;
|
|
328
|
+
body: BodyIndex;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 把「到底取到了什么、能信到什么程度」翻译成给人看的几行。做成纯函数是因为
|
|
333
|
+
* **决定告警响不响的这一层才是本工具的全部价值**,它必须能被测试钉住。
|
|
334
|
+
*/
|
|
335
|
+
export function reportLines(inp: ReportInput): Msg[] {
|
|
336
|
+
const out: Msg[] = [];
|
|
337
|
+
// 「未扫完」要在零结果时也说 —— SLS 扫不完最典型的表现就是结果偏少乃至为空,
|
|
338
|
+
// 那正是最容易被读成「窗内没有」的时候。
|
|
339
|
+
if (inp.incomplete) {
|
|
340
|
+
out.push({ level: 'warn', text: '⚠ SLS 自报本次查询未扫完(progress != Complete),结果偏少且**不是**「窗内就这么多」 —— 请缩窄 --since 重跑' });
|
|
341
|
+
} else if (inp.progressUnknown) {
|
|
342
|
+
// 「没读到」和「读到了 Complete」是两回事。默认当成扫完了 = 悄悄退回 V1 盲区。
|
|
343
|
+
out.push({ level: 'warn', text: '⚠ 响应里没有 meta.progress —— 「本次查询扫完没有」**读不到**(不等于扫完了)。结果可能偏少,别当「窗内就这么多」' });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (inp.cov.count === 0) {
|
|
347
|
+
out.push({ level: 'warn', text: `(无日志:该时间窗内 ${inp.service} 无输出${inp.grep ? ',或 --grep 没命中' : ''})` });
|
|
348
|
+
// 只在证据真的支持时才多说一句,且**只说到证据支持的那一步**:
|
|
349
|
+
// 「正文进了索引」不等于「这个词不存在」——SLS 按完整 token 匹配,搜子串必然零命中
|
|
350
|
+
// (实测 gateway-core:`reconciler` 0 条,而 `session-reconciler` 100+ 条)。
|
|
351
|
+
if (inp.grep && inp.body === 'searchable' && !inp.incomplete && !inp.progressUnknown && !inp.analytic) {
|
|
352
|
+
out.push({ level: 'info', text: '# 该 logstore 正文已进全文索引 ⇒ 这不是「索引搜不到」那一类零命中。' });
|
|
353
|
+
out.push({ level: 'info', text: `# 但 SLS 按**完整 token** 匹配(分词表不含 - _ .),搜子串必然零命中 —— 如 "reconciler" 命不中 "session-reconciler"。` });
|
|
354
|
+
out.push({ level: 'info', text: '# 要断定「真没有」,请换成完整 token,或去掉 --grep 拉原始行本地过滤复核。' });
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 分析语句返回的是聚合行,它的 __time__ 恒等于 from,反算出来的「覆盖窗」是假的。
|
|
360
|
+
const win = !inp.analytic && inp.cov.from !== undefined && inp.cov.to !== undefined
|
|
361
|
+
? `,实际覆盖 ${fmtCn(inp.cov.from)} ~ ${fmtCn(inp.cov.to)}(北京时间)`
|
|
362
|
+
: '';
|
|
363
|
+
out.push({ level: 'info', text: `# 实取 ${inp.cov.count} 条${win};请求窗 ${fmtCn(inp.from)} ~ ${fmtCn(inp.to)}(北京时间)` });
|
|
364
|
+
|
|
365
|
+
if (inp.truncated) {
|
|
366
|
+
out.push(inp.analytic
|
|
367
|
+
? { level: 'warn', text: '⚠ 分析语句结果已触及 SLS 对 SQL 的行上限;-n 对它无效,要更多请在 SQL 里加 LIMIT 翻页' }
|
|
368
|
+
: { level: 'warn', text: `⚠ 已取满 -n ${inp.lines},窗内可能还有更多、真实总数未知 —— 别拿这个数做计数/比值,请缩窄 --since 或加大 -n` });
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** epoch 秒 → 北京时间(UTC+8)`MM-DD HH:MM:SS`,不依赖本机时区。 */
|
|
374
|
+
export function fmtCn(sec: number): string {
|
|
375
|
+
const d = new Date((sec + 8 * 3600) * 1000);
|
|
376
|
+
const p = (n: number) => String(n).padStart(2, '0');
|
|
377
|
+
return `${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
80
380
|
function parseArgs(argv: string[]): Args {
|
|
81
381
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
82
382
|
console.log(HELP);
|
|
83
383
|
process.exit(0);
|
|
84
384
|
}
|
|
85
|
-
const out: Partial<Args> = { env: 'cn-prod', lines: 100, since: '1h', json: false };
|
|
385
|
+
const out: Partial<Args> = { env: 'cn-prod', lines: 100, linesExplicit: false, since: '1h', json: false };
|
|
86
386
|
const positional: string[] = [];
|
|
87
387
|
for (let i = 0; i < argv.length; i++) {
|
|
88
388
|
const a = argv[i];
|
|
@@ -92,7 +392,7 @@ function parseArgs(argv: string[]): Args {
|
|
|
92
392
|
case '--lines': case '-n': {
|
|
93
393
|
const v = parseInt(next, 10);
|
|
94
394
|
if (isNaN(v) || v <= 0) throw new Error('--lines 需要正整数');
|
|
95
|
-
out.lines = v; i++; break;
|
|
395
|
+
out.lines = v; out.linesExplicit = true; i++; break;
|
|
96
396
|
}
|
|
97
397
|
case '--since': out.since = next; i++; break;
|
|
98
398
|
case '--grep': out.grep = next; i++; break;
|
|
@@ -122,6 +422,11 @@ function fetchAws(args: Args): void {
|
|
|
122
422
|
const cmd = ['logs', 'tail', group, '--since', awsStr, '--region', AWS_REGION, '--format', args.json ? 'json' : 'short'];
|
|
123
423
|
if (args.grep) cmd.push('--filter-pattern', args.grep);
|
|
124
424
|
process.stderr.write(`${C.d}# AWS CloudWatch ${group} (since ${awsStr}${args.grep ? `, grep "${args.grep}"` : ''})${C.n}\n`);
|
|
425
|
+
// `aws logs tail` 会吐完整时间窗、没有条数参数 —— --lines 在 AWS 侧无处可传。
|
|
426
|
+
// 静默忽略会让人以为「只有这么多」,故显式说明(#75 的病根就是这类沉默截断)。
|
|
427
|
+
if (args.linesExplicit) {
|
|
428
|
+
process.stderr.write(`${C.y}(--lines 在 ${args.env} 上不生效:aws logs tail 返回整个时间窗;要限条数请管道接 head/tail)${C.n}\n`);
|
|
429
|
+
}
|
|
125
430
|
try {
|
|
126
431
|
process.stdout.write(run('aws', cmd));
|
|
127
432
|
} catch (e: any) {
|
|
@@ -132,48 +437,173 @@ function fetchAws(args: Args): void {
|
|
|
132
437
|
}
|
|
133
438
|
}
|
|
134
439
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* `--grep 'a|b'` 撞上 SLS 的 parse fail 时给一句人话。返回 null = 这条错误不归它管。
|
|
443
|
+
*
|
|
444
|
+
* 参数刻意要 **childStderr(子进程真正写出来的那份)**,不是「stderr 退化到 message」
|
|
445
|
+
* 的合并串 —— 因为这条提示的措辞里写着「原始报错见上」,而那句只有在子进程确实
|
|
446
|
+
* 打过东西时才成立(execFileSync 默认把子进程 stderr 透传给父进程)。
|
|
447
|
+
* 用合并串判会两头错:e.stderr 为空时 e.message 是 `Command failed: <完整 argv>`,
|
|
448
|
+
* argv 里**回显着用户的 query**,于是任何分析型查询(必然含 `| select`)都会让
|
|
449
|
+
* /SELECT/ 命中被回显的 query 自己 ⇒ 吞掉唯一的诊断、再附一句假的「见上」。
|
|
450
|
+
* 把它写进签名,这个坑就不可能再犯。
|
|
451
|
+
*/
|
|
452
|
+
export function pipeQueryHint(grep: string | undefined, childStderr: string): string | null {
|
|
453
|
+
// `|` 必须出现在引号**外**才谈得上「被当成分隔符」。引号里的 `|` 是短语内容,
|
|
454
|
+
// SLS 根本不当它是语法 —— 这种 query 出错必然是**别的**原因(参数非法、别处语法
|
|
455
|
+
// 手滑),此时指着 | 骂就是编造诊断。实测两例:
|
|
456
|
+
// --since 99999d --grep '"a|b"' → from 变负数,SLS 报
|
|
457
|
+
// `The parameter from must be a positive integer`,而 "a|b" 本身合法(直连 200);
|
|
458
|
+
// --grep '"timeout|error" and content.level:' → SLS 明确指出错在 `and` 附近。
|
|
459
|
+
// 更坏的是给出的处方会**改变检索语义**('"a or b"' 查的是短语 a or b)。
|
|
460
|
+
if (!grep || !stripQuoted(grep).includes('|')) return null;
|
|
461
|
+
// 真的分析语句(`| select …` / `| with …`)出错是 SQL 本身的问题,不是「把 | 当或用」。
|
|
462
|
+
// 代价:`a|with` 这类「with 恰好是 | 后最后一个 token」的输入也会被挡掉,拿不到提示
|
|
463
|
+
// (SLS 对它回 parse fail)。留着不修 —— 换来的是不对 `* | select conut(*)` 这类
|
|
464
|
+
// 真实的 SQL 手滑乱插嘴,后者常见得多。
|
|
465
|
+
if (isAnalyticQuery(grep)) return null;
|
|
466
|
+
// 实测:带裸 `|` 的非分析 query,SLS 一律以 Code=ParameterInvalid 拒绝,但 Message
|
|
467
|
+
// 有四种形状 —— `parse fail…(SELECT)` / `syntax error…` / `invalid pipe line operator`
|
|
468
|
+
// / `unclosed string quote`,取决于最后一个 | 后面是什么。只认 `parse fail` 会漏掉
|
|
469
|
+
// `a|b|c`、`timeout|connection refused` 这些同样常见的写法,故锚在 Code 上。
|
|
470
|
+
if (!/ParameterInvalid/.test(childStderr)) return null;
|
|
471
|
+
// 🔴 只给**静态**陈述,不生成「照抄就能跑」的处方。处方是个代码生成器,要同时在
|
|
472
|
+
// **两套语法**下正确 —— bash 引号规则 + SLS query 语法 —— 而它已经以同一种签名
|
|
473
|
+
// 失败过两次:生成的命令跑得通、跑的却是另一条 query(比报错更坏,因为无声)。
|
|
474
|
+
// · `--grep "a'b'c|timeout"` → 建议 `--grep 'a'b'c or timeout'`,bash 拼成
|
|
475
|
+
// `abc or timeout`(bash 那一半曾用 shell 转义补上,但那只是两套语法里的一套);
|
|
476
|
+
// · `--grep '"timeout|error" or conn|refused'` → 建议把**引号内**的 | 也拆了,
|
|
477
|
+
// 实测 SLS 返回 200 不报错,但检索词从 3 个(timeout|error / conn / refused)
|
|
478
|
+
// 变成 5 个(timeout / or / error / conn / refused)。
|
|
479
|
+
// 要把 SLS 那一半也做对,等于重写 SLS 的 query tokenizer —— 为一句便利提示背一个
|
|
480
|
+
// 无界的正确性义务,不划算。陈述句零生成、永远为真,信息量也够(SLS 自己的四种
|
|
481
|
+
// 报错没有一条说得出「| 不是或」)。
|
|
482
|
+
return '--grep 里的 | 是 SLS「检索|分析(SQL)」的分隔符,不是「或」(SLS 原始报错见上)。\n 要「或」请用 or;要把整串当一个短语,请用双引号把它包起来。';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** GetLogsV2 请求体。提成纯函数:query / reverse 之类漏传是静默失真,必须能被钉住。 */
|
|
486
|
+
export function slsRequestBody(
|
|
487
|
+
from: number, to: number, line: number, offset: number, grep?: string,
|
|
488
|
+
): Record<string, unknown> {
|
|
489
|
+
const body: Record<string, unknown> = { from, to, line, offset, reverse: true };
|
|
490
|
+
if (grep) body.query = grep;
|
|
491
|
+
return body;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* 解 GetLogsV2 响应。字段名写错会让工具恒返 0 条,同样要能被钉住。
|
|
496
|
+
* `raw || '{}'` 只挡得住空串:`JSON.parse('null')` 是 null、`JSON.parse('5')` 是数字,
|
|
497
|
+
* 直接取 `.data` 会抛 TypeError。slsIndex 那边已经用 null 检查防住了同一个坑,
|
|
498
|
+
* 这边漏了属非故意的不对称。
|
|
499
|
+
*/
|
|
500
|
+
export function parseSlsResponse(raw: string): SlsPage {
|
|
501
|
+
const parsed = JSON.parse(raw || '{}');
|
|
502
|
+
const res = isObj(parsed) ? parsed : {};
|
|
503
|
+
return {
|
|
504
|
+
rows: Array.isArray(res.data) ? res.data : [],
|
|
505
|
+
progress: typeof res.meta?.progress === 'string' ? res.meta.progress : '',
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** 发一次 GetLogsV2。用 V2 而不是 V1:只有 V2 透出 meta.progress —— SLS 对重查询
|
|
510
|
+
* 会返回**部分结果**,V1 下这与「窗内就这么多」完全无法区分。 */
|
|
511
|
+
function slsPage(
|
|
512
|
+
project: string, args: Args, from: number, to: number, line: number, offset: number,
|
|
513
|
+
): SlsPage {
|
|
514
|
+
return parseSlsResponse(run('aliyun', [
|
|
515
|
+
'sls', 'GetLogsV2',
|
|
143
516
|
'--project', project,
|
|
144
517
|
'--logstore', args.service,
|
|
145
|
-
'--
|
|
146
|
-
'--to', String(now),
|
|
147
|
-
'--line', String(args.lines),
|
|
148
|
-
'--reverse', 'true', // 先拿最新 N 条
|
|
518
|
+
'--body', JSON.stringify(slsRequestBody(from, to, line, offset, args.grep)),
|
|
149
519
|
'--region', ALIYUN_REGION,
|
|
150
520
|
'--profile', ALIYUN_PROFILE,
|
|
151
|
-
];
|
|
152
|
-
|
|
521
|
+
]));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** 读 logstore 的索引配置(判 --grep 能不能搜到正文);读不到就返回 null → 判 'unknown'。 */
|
|
525
|
+
function slsIndex(project: string, logstore: string): any {
|
|
526
|
+
try {
|
|
527
|
+
// stderr 丢弃:失败是预期内的(如 AK 无 log:GetIndex 权限),不该在正常输出里
|
|
528
|
+
// 多打一份 aliyun 的 ERROR 块。失败本身由 'unknown' 表达。
|
|
529
|
+
return JSON.parse(execFileSync('aliyun', [
|
|
530
|
+
'sls', 'GetIndex', '--project', project, '--logstore', logstore,
|
|
531
|
+
'--region', ALIYUN_REGION, '--profile', ALIYUN_PROFILE,
|
|
532
|
+
], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }) || 'null');
|
|
533
|
+
} catch {
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function emit(msgs: Msg[]): void {
|
|
539
|
+
for (const m of msgs) {
|
|
540
|
+
process.stderr.write(`${m.level === 'warn' ? C.y : C.d}${m.text}${C.n}\n`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 阿里云 SLS:aliyun sls GetLogsV2,project=optima-<env>-<account>,logstore=service。 */
|
|
545
|
+
function fetchCn(args: Args): void {
|
|
546
|
+
const project = `optima-${args.env}-${ALIYUN_ACCOUNT}`;
|
|
547
|
+
const { sec } = parseSince(args.since);
|
|
548
|
+
// to 只在开工时取一次:翻页期间新写入的日志会顶掉 offset,窗口必须钉死。
|
|
549
|
+
const to = Math.floor(Date.now() / 1000);
|
|
550
|
+
const from = to - sec;
|
|
153
551
|
process.stderr.write(`${C.d}# 阿里云 SLS ${project}/${args.service} (since ${args.since}${args.grep ? `, query "${args.grep}"` : ''})${C.n}\n`);
|
|
154
552
|
|
|
155
|
-
|
|
553
|
+
// --grep 之前先查索引:正文没进索引的 logstore 上,搜正文里的词会静默少回
|
|
554
|
+
// (已知 cn-stage/agent-runtime)。实测数字与复跑命令**只维护在**
|
|
555
|
+
// `.claude/commands/logs.md` 第 3 节「读数纪律」——同一组数曾在三处手抄、已经漂了。
|
|
556
|
+
const verdict = args.grep ? bodySearchable(slsIndex(project, args.service)) : { state: 'unknown' as const };
|
|
557
|
+
emit(indexWarning(args.service, verdict, args.grep));
|
|
558
|
+
|
|
559
|
+
const analytic = isAnalyticQuery(args.grep);
|
|
560
|
+
if (analytic) {
|
|
561
|
+
process.stderr.write(`${C.y}(query 含分析语句(|):SLS 规定此时 line/offset 失效 —— 只发一次请求,-n 不起作用,要更多请在 SQL 里用 LIMIT)${C.n}\n`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
let got;
|
|
156
565
|
try {
|
|
157
|
-
|
|
566
|
+
got = collectPages(
|
|
567
|
+
(line, offset) => {
|
|
568
|
+
if (offset > 0) process.stderr.write(`${C.d}# 已取 ${offset} 条,继续翻页…${C.n}\n`);
|
|
569
|
+
return slsPage(project, args, from, to, line, offset);
|
|
570
|
+
},
|
|
571
|
+
args.lines,
|
|
572
|
+
{ single: analytic },
|
|
573
|
+
);
|
|
158
574
|
} catch (e: any) {
|
|
159
|
-
|
|
160
|
-
|
|
575
|
+
// raw = 子进程真正写出来的 stderr(可能为空:非零退出无输出 / 被信号杀死);
|
|
576
|
+
// msg 是给人看的兜底,raw 为空时退化成 e.message(`Command failed: <完整 argv>`)。
|
|
577
|
+
// 两者必须分开,理由见 pipeQueryHint 的注释。
|
|
578
|
+
const raw = typeof e.stderr === 'string' ? e.stderr : '';
|
|
579
|
+
const msg = raw || e.message || '';
|
|
580
|
+
// 判 raw 不判 msg:真的 LogStoreNotExist 只可能来自 aliyun 的 stderr(已实测)。
|
|
581
|
+
// 用合并串会在「子进程空 stderr」时命中 e.message 里**被回显的 argv** ——
|
|
582
|
+
// `optima-logs agent-runtime --grep 'LogStoreNotExist'` 就会被诬告成「库不存在」,
|
|
583
|
+
// 与 pipeQueryHint 那条是同型缺陷。
|
|
584
|
+
if (/LogStoreNotExist|ProjectNotExist/.test(raw)) {
|
|
161
585
|
throw new Error(`SLS logstore 不存在: ${project}/${args.service}\n 确认服务名对,或该服务未接 SLS。列全部:\n aliyun sls ListLogStores --project ${project} --region ${ALIYUN_REGION} --profile ${ALIYUN_PROFILE}`);
|
|
162
586
|
}
|
|
587
|
+
// 只在 aliyun 确实打过报错块时才用提示顶替(那种情况下 msg 拼进来就是同一段
|
|
588
|
+
// 打两遍,把提示埋在第二份底下);没打过就照常抛 msg,绝不吞掉唯一的诊断。
|
|
589
|
+
const hint = pipeQueryHint(args.grep, raw);
|
|
590
|
+
if (hint) throw new Error(hint);
|
|
163
591
|
throw new Error(msg);
|
|
164
592
|
}
|
|
165
593
|
|
|
166
|
-
const rows: Array<Record<string, string>> = JSON.parse(raw || '[]');
|
|
167
594
|
// reverse=true 取到的是新→旧,翻回旧→新便于阅读
|
|
168
|
-
rows.reverse();
|
|
595
|
+
const rows = got.rows.slice().reverse();
|
|
596
|
+
emit(reportLines({
|
|
597
|
+
service: args.service, lines: args.lines, grep: args.grep, analytic,
|
|
598
|
+
from, to, cov: coverage(rows),
|
|
599
|
+
truncated: got.truncated, incomplete: got.incomplete,
|
|
600
|
+
progressUnknown: got.progressUnknown, body: verdict.state,
|
|
601
|
+
}));
|
|
602
|
+
|
|
169
603
|
if (args.json) {
|
|
170
604
|
console.log(JSON.stringify(rows, null, 2));
|
|
171
605
|
return;
|
|
172
606
|
}
|
|
173
|
-
if (rows.length === 0) {
|
|
174
|
-
process.stderr.write(`${C.y}(无日志:该时间窗内 ${args.service} 无输出,或 --grep 没命中)${C.n}\n`);
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
607
|
for (const r of rows) {
|
|
178
608
|
// SLS 存的 content 已含容器时间戳 + stream(stdout/stderr)前缀,直接打印即可
|
|
179
609
|
process.stdout.write((r.content ?? JSON.stringify(r)) + '\n');
|
|
@@ -197,4 +627,7 @@ function main(): void {
|
|
|
197
627
|
}
|
|
198
628
|
}
|
|
199
629
|
|
|
200
|
-
main()
|
|
630
|
+
// 只在被直接调用时跑 CLI —— 被 require() 进来(如翻页/覆盖窗的单测)不能触发 main()。
|
|
631
|
+
if (require.main === module) {
|
|
632
|
+
main();
|
|
633
|
+
}
|