Skip to content

Commit 614e2e3

Browse files
authored
Fix metrics bounds, warnings, tail reads, and rotation (#3622)
* Bound metrics summary input (#3512) * Warn on metrics sink startup failures (#3504) * Stream report log tail reads (#3397) * Share replacement-aware log rotation (#3412) * Make metrics bad-path test portable (#3504)
1 parent 1fd2a60 commit 614e2e3

14 files changed

Lines changed: 332 additions & 163 deletions

USER_GUIDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1395,7 +1395,7 @@ same source location.
13951395
| `--max-results <n>` | `search` | Alias for `--limit` |
13961396
| `--color <when>` | All commands | Control ANSI color output. Accepts `auto` (default), `always`, or `never`. Precedence: `--color` flag > `CLICOLOR_FORCE` > `NO_COLOR` > `CLICOLOR=0` > terminal capability auto-detect. Auto mode treats redirected stdout and StringWriter-style test capture as non-ANSI; on Windows it also accepts ConPTY/Windows Terminal virtual-terminal support and terminal hints such as `WT_SESSION`, `WT_PROFILE_ID`, `TERM_PROGRAM`, or non-`dumb` `TERM`. Use `--color=always` to keep colored kind labels through a pager such as `cdidx symbols Foo \| less -R`; use `--color=never` (or `NO_COLOR=1`) to suppress ANSI even on a TTY. |
13971397
| `--palette <name>` | All commands | Choose the ANSI palette used when color output is enabled. Accepts `basic` (8-color SGR 30–37, the default fallback for minimal SSH/CI terminals), `256` (256-color `\x1b[38;5;Nm`), or `truecolor` (24-bit RGB `\x1b[38;2;R;G;Bm`). Precedence: `--palette` flag > `CDIDX_COLOR_PALETTE` env var > `COLORTERM` / `TERM` auto-detect. The basic palette avoids `\x1b[90m` (bright-black / dim), which is unreadable on many minimal terminals. |
1398-
| `--metrics <path>` | All commands (and MCP tool calls) | Append one JSONL metrics record per CLI command / MCP tool call to `<path>`. The `CDIDX_METRICS=<path>` environment variable provides the same destination as a fallback when the flag is not passed. Best-effort: any IO failure (missing directory, read-only mount, etc.) is swallowed silently and never breaks the underlying command. |
1398+
| `--metrics <path>` | All commands (and MCP tool calls) | Append one JSONL metrics record per CLI command / MCP tool call to `<path>`. The `CDIDX_METRICS=<path>` environment variable provides the same destination as a fallback when the flag is not passed. If the destination cannot be opened at startup, cdidx emits a bounded warning, disables metrics, and continues the underlying command. Later write or rotation failures remain best-effort and never break the command. |
13991399

14001400
If a query itself begins with `-`, pass it as `--query <query>` or `-- <query>`. If an option value itself begins with `--`, pass it as `--opt=<value>` rather than a separated value, for example `--path=--json-dir` or `--db=--tmp.db`.
14011401

@@ -1512,7 +1512,7 @@ Currently only the `cdidx --sushi` / `--coffee` / `--ramen` / `--wine` / `--beer
15121512

15131513
### Metrics emission
15141514

1515-
Pass `--metrics <path>` (or set `CDIDX_METRICS=<path>` in the environment) to make `cdidx` append one JSON-lines record per CLI command and per MCP tool call. The flag wins over the environment variable when both are present. The destination file is opened in append mode, so multiple cdidx invocations writing to the same path interleave cleanly. Emission is best-effort: any IO failure (missing directory, unwritable mount, etc.) is swallowed silently and never breaks the underlying command.
1515+
Pass `--metrics <path>` (or set `CDIDX_METRICS=<path>` in the environment) to make `cdidx` append one JSON-lines record per CLI command and per MCP tool call. The flag wins over the environment variable when both are present. The destination file is opened in append mode, so multiple cdidx invocations writing to the same path interleave cleanly. If the destination cannot be opened at startup, cdidx emits a bounded warning to stderr, disables metrics, and continues the underlying command. Later record writes and rotation attempts remain best-effort and never break the command.
15161516

15171517
Each record is a single JSON object on its own line with these fields:
15181518

@@ -3856,7 +3856,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit
38563856
| `--max-results <n>` | `search` | `--limit` のエイリアス |
38573857
| `--color <when>` | 全コマンド | ANSI カラー出力の制御。`auto`(既定)、`always``never` を受け付ける。優先順位: `--color` フラグ > `CLICOLOR_FORCE` > `NO_COLOR` > `CLICOLOR=0` > 端末能力の自動判定。auto では redirected stdout と StringWriter 風のテスト capture を非 ANSI とみなし、Windows では ConPTY / Windows Terminal の virtual-terminal 対応と `WT_SESSION``WT_PROFILE_ID``TERM_PROGRAM`、非 `dumb``TERM` などの端末ヒントも見る。`cdidx symbols Foo \| less -R` のような pager pipe でも色を維持したい場合は `--color=always`、TTY 上でも ANSI を抑止したい場合は `--color=never`(または `NO_COLOR=1`)を指定する。 |
38583858
| `--palette <name>` | 全コマンド | カラー出力が有効なときに用いる ANSI パレットを選択する。`basic`(標準8色 SGR 30–37、最小 SSH/CI 端末向けの既定フォールバック)、`256`(256色 `\x1b[38;5;Nm`)、`truecolor`(24ビット RGB `\x1b[38;2;R;G;Bm`)を受け付ける。優先順位: `--palette` フラグ > `CDIDX_COLOR_PALETTE` 環境変数 > `COLORTERM` / `TERM` 自動判定。`basic` パレットは最小端末で読みにくい `\x1b[90m`(暗灰 / dim)を避ける。 |
3859-
| `--metrics <path>` | 全コマンド(および MCP ツール呼び出し) | CLI コマンド / MCP ツール呼び出し 1 回ごとに JSONL レコードを 1 行ずつ `<path>` に追記する。フラグ未指定時のフォールバックとして `CDIDX_METRICS=<path>` 環境変数でも同じ出力先を指定できる。Best-effort のため、ディレクトリが無い・read-only マウント等の IO 失敗は黙って握り潰し、本体コマンドを壊さない。 |
3859+
| `--metrics <path>` | 全コマンド(および MCP ツール呼び出し) | CLI コマンド / MCP ツール呼び出し 1 回ごとに JSONL レコードを 1 行ずつ `<path>` に追記する。フラグ未指定時のフォールバックとして `CDIDX_METRICS=<path>` 環境変数でも同じ出力先を指定できる。起動時に出力先を開けない場合、cdidx は長さを制限した警告を出してメトリクスを無効化し、本体コマンドを続行する。その後の書き込みやローテーションの失敗はベストエフォートのまま扱われ、本体コマンドを壊さない。 |
38603860

38613861
クエリ自体が `-` で始まる場合は `--query <query>` または `-- <query>` で渡してください。オプション値自体が `--` で始まる場合は、分離形式ではなく `--opt=<value>` で渡します。たとえば `--path=--json-dir``--db=--tmp.db` のように指定します。
38623862

@@ -3973,7 +3973,7 @@ MCP ツールで catch-all まで突き抜けた例外(想定外の SQLite 例
39733973

39743974
### メトリクス出力
39753975

3976-
`--metrics <path>` を渡す(または環境変数 `CDIDX_METRICS=<path>` を設定する)と、`cdidx` は CLI コマンド 1 回・MCP ツール呼び出し 1 回ごとに 1 行の JSON レコードを指定パスへ追記します。両方指定されている場合はフラグが優先されます。出力先は append モードで開かれるため、複数の cdidx 実行が同じファイルへ書いてもきれいにインターリーブされます。出力は best-effort で、ディレクトリ不在・書き込み不可マウントなどの IO 失敗は黙って握り潰し、本体コマンドを壊しません。
3976+
`--metrics <path>` を渡す(または環境変数 `CDIDX_METRICS=<path>` を設定する)と、`cdidx` は CLI コマンド 1 回・MCP ツール呼び出し 1 回ごとに 1 行の JSON レコードを指定パスへ追記します。両方指定されている場合はフラグが優先されます。出力先は append モードで開かれるため、複数の cdidx 実行が同じファイルへ書いてもきれいにインターリーブされます。起動時に出力先を開けない場合、cdidx は stderr に長さを制限した警告を出してメトリクスを無効化し、本体コマンドを続行します。その後のレコード書き込みやローテーションの失敗はベストエフォートのまま扱われ、本体コマンドを壊しません。
39773977

39783978
各レコードは独立した行に 1 つの JSON オブジェクトとして書き出され、フィールドは次の通りです。
39793979

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 3397
5+
affected:
6+
- src/CodeIndex/Cli/ReportCommandRunner.cs
7+
- tests/CodeIndex.Tests/ReportCommandRunnerTests.cs
8+
---
9+
10+
## English
11+
12+
- **Report log tailing now reads incrementally (#3397)**`cdidx report` no longer materializes the bounded log tail window with `ReadToEnd`; it streams lines from the tail offset and keeps only the requested final lines.
13+
14+
## 日本語
15+
16+
- **report のログ末尾読み取りを逐次処理にしました (#3397)**`cdidx report` は bounded log tail window を `ReadToEnd` でまとめて materialize せず、tail offset から行単位で読みながら要求された末尾行だけを保持するようになりました。
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 3412
5+
affected:
6+
- src/CodeIndex/Cli/PrivateLogFile.cs
7+
- src/CodeIndex/Cli/SuggestionStore.cs
8+
- src/CodeIndex/Mcp/AuditLogSink.cs
9+
- tests/CodeIndex.Tests/GlobalToolLogTests.cs
10+
---
11+
12+
## English
13+
14+
- **Archive and log rotation now share replacement-aware slot rotation (#3412)** — metrics logs, MCP audit logs, and suggestion archives now use the common `PrivateLogFile.TryRotateSlots` helper, which replaces existing destination slots with overwrite move semantics where the platform supports them.
15+
16+
## 日本語
17+
18+
- **archive と log rotation が replacement-aware な共通 slot rotation を使うようになりました (#3412)** — metrics log、MCP audit log、suggestion archive は共通の `PrivateLogFile.TryRotateSlots` helper を使い、platform が対応する範囲で overwrite move semantics により既存 destination slot を置き換えるようになりました。
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 3504
5+
affected:
6+
- src/CodeIndex/Cli/MetricsSink.cs
7+
- tests/CodeIndex.Tests/MetricsSinkTests.cs
8+
---
9+
10+
## English
11+
12+
- **Metrics sink failures now surface a warning for configured paths (#3504)**`--metrics` / `CDIDX_METRICS` still keep command execution best-effort, but an unusable configured metrics path now emits a bounded stderr warning instead of silently disabling metrics.
13+
14+
## 日本語
15+
16+
- **設定済み metrics path の sink 失敗時に warning を出すようになりました (#3504)**`--metrics` / `CDIDX_METRICS` は引き続きコマンド実行をベストエフォートで継続しますが、設定済み metrics path が使えない場合はメトリクスを黙って無効化せず、上限付きの stderr warning を出します。
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 3512
5+
affected:
6+
- scripts/metrics-summary.py
7+
---
8+
9+
## English
10+
11+
- **Bounded metrics summary input and percentile retention (#3512)**`scripts/metrics-summary.py` now caps JSONL lines, decoded records, per-line bytes, and retained elapsed values per bucket while keeping total counts and max latency across all processed records.
12+
13+
## 日本語
14+
15+
- **metrics summary の入力と percentile 用保持値を上限化しました (#3512)**`scripts/metrics-summary.py` は JSONL の行数、デコード済みレコード数、1 行あたりの byte 数、bucket ごとの elapsed 値保持数を制限しつつ、処理済み全レコードの count と最大 latency は維持するようになりました。

scripts/metrics-summary.py

Lines changed: 129 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,17 @@
1515
import argparse
1616
import json
1717
import math
18+
import random
1819
import sys
19-
from collections import defaultdict
20+
from dataclasses import dataclass, field
2021
from pathlib import Path
21-
from typing import Iterable
22+
from typing import BinaryIO, Iterable, Iterator
23+
24+
25+
DEFAULT_MAX_LINES = 1_000_000
26+
DEFAULT_MAX_RECORDS = 1_000_000
27+
DEFAULT_MAX_LINE_BYTES = 1_048_576
28+
DEFAULT_MAX_VALUES_PER_BUCKET = 10_000
2229

2330

2431
def _percentile(sorted_values: list[float], pct: float) -> float:
@@ -34,37 +41,117 @@ def _percentile(sorted_values: list[float], pct: float) -> float:
3441
return sorted_values[lower] + (sorted_values[upper] - sorted_values[lower]) * (k - lower)
3542

3643

37-
def _iter_records(stream: Iterable[str]) -> Iterable[dict]:
38-
for raw in stream:
39-
line = raw.strip()
44+
def _positive_int(value: str) -> int:
45+
try:
46+
parsed = int(value, 10)
47+
except ValueError as exc:
48+
raise argparse.ArgumentTypeError("must be an integer") from exc
49+
if parsed <= 0:
50+
raise argparse.ArgumentTypeError("must be greater than zero")
51+
return parsed
52+
53+
54+
def _drain_long_line(stream: BinaryIO, max_line_bytes: int) -> None:
55+
while True:
56+
chunk = stream.readline(max_line_bytes)
57+
if not chunk or chunk.endswith(b"\n"):
58+
return
59+
60+
61+
def _iter_records(
62+
stream: BinaryIO,
63+
*,
64+
max_lines: int,
65+
max_records: int,
66+
max_line_bytes: int,
67+
) -> Iterator[dict]:
68+
line_number = 0
69+
records = 0
70+
while line_number < max_lines:
71+
raw = stream.readline(max_line_bytes + 1)
72+
if not raw:
73+
return
74+
line_number += 1
75+
76+
if len(raw) > max_line_bytes:
77+
if not raw.endswith(b"\n"):
78+
_drain_long_line(stream, max_line_bytes)
79+
print(f"warn: skipping line {line_number}: exceeds --max-line-bytes", file=sys.stderr)
80+
continue
81+
82+
try:
83+
line = raw.decode("utf-8").strip()
84+
except UnicodeDecodeError:
85+
print(f"warn: skipping line {line_number}: invalid UTF-8", file=sys.stderr)
86+
continue
4087
if not line:
4188
continue
4289
try:
43-
yield json.loads(line)
44-
except json.JSONDecodeError as exc:
45-
print(f"warn: skipping malformed line: {exc}", file=sys.stderr)
46-
47-
48-
def summarize(records: Iterable[dict]) -> list[dict]:
49-
buckets: dict[tuple[str, str], list[float]] = defaultdict(list)
90+
record = json.loads(line)
91+
except json.JSONDecodeError:
92+
print(f"warn: skipping line {line_number}: invalid JSON", file=sys.stderr)
93+
continue
94+
records += 1
95+
if records > max_records:
96+
print(f"warn: stopped after --max-records={max_records}", file=sys.stderr)
97+
return
98+
yield record
99+
100+
print(f"warn: stopped after --max-lines={max_lines}", file=sys.stderr)
101+
102+
103+
@dataclass
104+
class Bucket:
105+
max_samples: int
106+
rng: random.Random
107+
count: int = 0
108+
max_value: float = -math.inf
109+
samples: list[float] = field(default_factory=list)
110+
111+
def add(self, value: float) -> None:
112+
self.count += 1
113+
self.max_value = max(self.max_value, value)
114+
if len(self.samples) < self.max_samples:
115+
self.samples.append(value)
116+
return
117+
118+
replacement = self.rng.randrange(self.count)
119+
if replacement < self.max_samples:
120+
self.samples[replacement] = value
121+
122+
123+
def summarize(
124+
records: Iterable[dict],
125+
*,
126+
max_values_per_bucket: int = DEFAULT_MAX_VALUES_PER_BUCKET,
127+
seed: int = 0,
128+
) -> list[dict]:
129+
buckets: dict[tuple[str, str], Bucket] = {}
50130
for rec in records:
51131
elapsed = rec.get("elapsed_ms")
52132
if not isinstance(elapsed, (int, float)):
53133
continue
54134
key = (rec.get("source") or "?", rec.get("tool") or "?")
55-
buckets[key].append(float(elapsed))
135+
bucket = buckets.get(key)
136+
if bucket is None:
137+
bucket_seed = f"{seed}:{key[0]}:{key[1]}"
138+
bucket = Bucket(max_values_per_bucket, random.Random(bucket_seed))
139+
buckets[key] = bucket
140+
bucket.add(float(elapsed))
56141

57142
rows: list[dict] = []
58-
for (source, tool), values in sorted(buckets.items()):
59-
values.sort()
143+
for (source, tool), bucket in sorted(buckets.items()):
144+
values = sorted(bucket.samples)
60145
rows.append({
61146
"source": source,
62147
"tool": tool,
63-
"count": len(values),
148+
"count": bucket.count,
149+
"sample_count": len(values),
150+
"sampled": bucket.count > len(values),
64151
"p50_ms": round(_percentile(values, 50), 3),
65152
"p95_ms": round(_percentile(values, 95), 3),
66153
"p99_ms": round(_percentile(values, 99), 3),
67-
"max_ms": round(values[-1], 3),
154+
"max_ms": round(bucket.max_value, 3),
68155
})
69156
return rows
70157

@@ -73,7 +160,7 @@ def _print_table(rows: list[dict]) -> None:
73160
if not rows:
74161
print("(no records)")
75162
return
76-
headers = ["source", "tool", "count", "p50_ms", "p95_ms", "p99_ms", "max_ms"]
163+
headers = ["source", "tool", "count", "sample_count", "sampled", "p50_ms", "p95_ms", "p99_ms", "max_ms"]
77164
widths = [max(len(h), *(len(str(r[h])) for r in rows)) for h in headers]
78165
fmt = " ".join(f"{{:<{w}}}" for w in widths)
79166
print(fmt.format(*headers))
@@ -86,13 +173,34 @@ def main() -> int:
86173
parser = argparse.ArgumentParser(description="Summarize cdidx --metrics JSONL output")
87174
parser.add_argument("path", nargs="?", help="Path to JSONL file (defaults to stdin)")
88175
parser.add_argument("--json", action="store_true", help="Emit summary rows as JSON")
176+
parser.add_argument("--max-lines", type=_positive_int, default=DEFAULT_MAX_LINES, help="Maximum physical JSONL lines to read")
177+
parser.add_argument("--max-records", type=_positive_int, default=DEFAULT_MAX_RECORDS, help="Maximum decoded JSON records to process")
178+
parser.add_argument("--max-line-bytes", type=_positive_int, default=DEFAULT_MAX_LINE_BYTES, help="Maximum bytes accepted for one JSONL line")
179+
parser.add_argument("--max-values-per-bucket", type=_positive_int, default=DEFAULT_MAX_VALUES_PER_BUCKET, help="Maximum elapsed_ms values retained per (source, tool) bucket")
180+
parser.add_argument("--sample-seed", type=int, default=0, help="Deterministic seed for bounded percentile sampling")
89181
args = parser.parse_args()
90182

183+
def records_from(stream: BinaryIO) -> Iterator[dict]:
184+
return _iter_records(
185+
stream,
186+
max_lines=args.max_lines,
187+
max_records=args.max_records,
188+
max_line_bytes=args.max_line_bytes,
189+
)
190+
91191
if args.path and args.path != "-":
92-
with Path(args.path).open("r", encoding="utf-8") as fh:
93-
rows = summarize(_iter_records(fh))
192+
with Path(args.path).open("rb") as fh:
193+
rows = summarize(
194+
records_from(fh),
195+
max_values_per_bucket=args.max_values_per_bucket,
196+
seed=args.sample_seed,
197+
)
94198
else:
95-
rows = summarize(_iter_records(sys.stdin))
199+
rows = summarize(
200+
records_from(sys.stdin.buffer),
201+
max_values_per_bucket=args.max_values_per_bucket,
202+
seed=args.sample_seed,
203+
)
96204

97205
if args.json:
98206
json.dump(rows, sys.stdout, indent=2)

0 commit comments

Comments
 (0)