Skip to content

feat(stats): wire token-savings telemetry into search and find_related#82

Open
amondnet wants to merge 1 commit into
amondnet/max-snippet-linesfrom
amondnet/savings-wiring
Open

feat(stats): wire token-savings telemetry into search and find_related#82
amondnet wants to merge 1 commit into
amondnet/max-snippet-linesfrom
amondnet/savings-wiring

Conversation

@amondnet

@amondnet amondnet commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

save_search_stats was defined and unit-tested but never called in any code path — nothing recorded to ~/.csp/savings.jsonl, so csp savings always reported zero. This wires it in at the CLI/MCP app boundary and applies the semble#206 snippet accounting deferred from #75.

Closes #81.

What changed

  • CspIndex.file_sizes (repo-relative path → UTF-16 char count) captured at build time in from_path, carried through from_git's re-root, and recomputed in load_from_disk when the source root is a still-present local directory (mirrors semble reading sizes off root). Empty when source files aren't available (e.g. a cached git index) → file_chars is simply 0.
  • save_search_stats takes max_snippet_lines so recorded snippet_chars reflects what the caller actually received (semble#206): None → full chunk, Some(0) → 0, Some(n) → first n lines.
  • Recording wired in at search_output / find_related_output (CLI) and search_tool / find_related_tool (MCP), each behind an injected Option<&Path> stats file. CspIndex::search stays a pure, side-effect-free library call — telemetry lives at the app boundary (issue savings telemetry is not wired into the search flow (blocks semble#206) #81 permits either).

Testability / no pollution

Every recording site takes an injected Option<&Path>. The MCP server holds an injectable stats_file field (None in tests); CLI dispatch is split into dispatch_with_stats(command, stats_file). Tests redirect telemetry to a temp file or disable it, so the developer's real ~/.csp/savings.jsonl is never touched.

Testing

cargo fmt --all && cargo clippy --all-targets --all-features -- -D warnings && cargo test --workspace — 273 lib + 22 CLI tests pass (4 network-gated ignored).

Stacking

Stacked on #75 (amondnet/max-snippet-lines) — base will retarget to main once #75 merges.

🤖 Generated with Claude Code


Summary by cubic

Record token-savings telemetry for search and find-related in the CLI and MCP so csp savings shows real data. Snippet accounting now respects max_snippet_lines.

  • New Features
    • Wired save_search_stats into CLI (search_output, find_related_output) and MCP (search_tool, find_related_tool) behind an optional stats file; defaults to ~/.csp/savings.jsonl.
    • Added CspIndex.file_sizes (repo-relative → UTF-16 char count), computed in from_path, preserved through from_git, and recomputed in load_from_disk when the local root exists; empty when sources aren’t available.
    • Updated save_search_stats to accept max_snippet_lines and count snippet_chars for only the delivered lines (None = full, 0 = none, n = first n lines).
    • Kept CspIndex::search side-effect-free; tests inject None or a temp file to avoid touching the real stats file. Fixes savings telemetry is not wired into the search flow (blocks semble#206) #81.

Written for commit 306b1a2. Summary will update on new commits.

`save_search_stats` was defined and tested but never called — no code path
recorded to `~/.csp/savings.jsonl`, so `csp savings` always reported zero.
Wire it in at the CLI/MCP app boundary (keeping `CspIndex::search` a pure,
side-effect-free library call) and apply the semble#206 snippet accounting.

- CspIndex gains `file_sizes` (repo-relative path → UTF-16 char count),
  captured at build time in `from_path`, carried through `from_git`'s
  re-root, and recomputed in `load_from_disk` when the source root is a
  still-present local directory (mirrors semble reading sizes off `root`).
- `save_search_stats` takes `max_snippet_lines` so recorded `snippet_chars`
  reflects what the caller actually received (semble#206): `None` → full
  chunk, `Some(0)` → 0, `Some(n)` → first n lines.
- Recording happens in `search_output` / `find_related_output` (CLI) and
  `search_tool` / `find_related_tool` (MCP), each behind an injected
  `Option<&Path>` stats file so tests redirect telemetry off the real
  `~/.csp/savings.jsonl`. The MCP server and CLI dispatch default to the
  real file; tests pass a temp file or `None`.

Closes #81.
@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 15 complexity · 8 duplication

Metric Results
Complexity 15
Duplication 8

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 5 files

Architecture diagram
sequenceDiagram
    participant CLI as CLI Dispatch
    participant MCP as MCP Server
    participant Stats as save_search_stats()
    participant Index as CspIndex
    participant Disk as Source Tree on Disk
    participant File as ~/.csp/savings.jsonl

    Note over CLI,MCP: Search / find-related entry points

    CLI->>Index: search(query, options)
    Index->>Disk: read_from_path(root)
    Disk-->>Index: file_sizes (UTF-16 char counts)
    Index-->>CLI: results

    CLI->>Stats: save_search_stats(stats_file, results, CallType::Search, index.file_sizes, max_snippet_lines)
    alt max_snippet_lines is None
        Stats->>Stats: snippet_chars = utf16_len(content) (full chunk)
    else max_snippet_lines is Some(0)
        Stats->>Stats: snippet_chars = 0
    else max_snippet_lines is Some(n)
        Stats->>Stats: snippet_chars = utf16_len(first n lines)
    end
    Stats->>File: Append JSONL record
    File-->>Stats: (swallow I/O errors)

    Note over MCP,Index: MCP tools with injected stats_file

    MCP->>Index: search_tool(cache, ..., stats_file)
    Index-->>MCP: results
    alt stats_file is Some
        MCP->>Stats: save_search_stats(stats_file, results, CallType::Search, index.file_sizes, max_snippet_lines)
        Stats->>File: Append JSONL record
    end

    Note over Index,Disk: File sizes lifecycle

    alt from_path() (local directory)
        Index->>Disk: compute_file_sizes(root, chunks)
        Disk-->>Index: HashMap<path, utf16_char_count>
    else from_git() (remote URL)
        Index->>Index: clone from_path computes sizes
        Index->>Index: clone file_sizes before re-rooting
    else load_from_disk() (cached manifest)
        alt root is a present local directory
            Index->>Disk: compute_file_sizes(root, chunks)
            Disk-->>Index: HashMap<path, utf16_char_count>
        else root is git URL or missing
            Index->>Index: file_sizes = empty (file_chars = 0)
        end
    end
Loading

Re-trigger cubic

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.42857% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/csp/src/mcp.rs 86.27% 7 Missing ⚠️
crates/csp/src/indexing/index.rs 96.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

이번 풀 리퀘스트는 검색 및 관련 청크 찾기 기능에 토큰 절약 텔레메트리(통계 기록) 기능을 추가하는 변경사항을 담고 있습니다. 인덱스 빌드 시 파일 크기를 캡처하고, 검색 결과 출력 시 실제 전달된 문자 수를 계산하여 통계 파일에 기록하도록 구현되었습니다. 코드 리뷰 결과, compute_file_sizes 함수에서 상위 디렉터리 참조(..)나 절대 경로를 검증하지 않아 발생할 수 있는 경로 탐색(Path Traversal) 취약점(HIGH)과, delivered_chars 함수에서 불필요한 메모리 할당으로 인한 성능 저하 우려(MEDIUM)가 지적되었습니다. 두 피드백 모두 타당하며 제시된 코드로 수정할 것을 권장합니다.

Comment on lines +391 to 402
fn compute_file_sizes(root: &Path, chunks: &[Chunk]) -> HashMap<String, u64> {
let mut sizes: HashMap<String, u64> = HashMap::new();
for chunk in chunks {
if sizes.contains_key(&chunk.file_path) {
continue;
}
if let Ok(text) = std::fs::read_to_string(root.join(&chunk.file_path)) {
sizes.insert(chunk.file_path.clone(), text.encode_utf16().count() as u64);
}
}
sizes
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

[HIGH] compute_file_sizes 함수의 경로 탐색(Path Traversal) 취약점 방지

Symptom: compute_file_sizes 함수에서 root.join(&chunk.file_path)를 사용하여 파일을 읽을 때, chunk.file_path가 상위 디렉토리 참조(..)나 절대 경로를 포함하고 있는지 검증하지 않고 그대로 파일 시스템에서 읽고 있습니다.
Source: OWASP — Path Traversal (Input Validation)
Consequence: 악의적으로 조작된 인덱스 파일(chunks.json)을 로드할 경우, 공격자가 지정한 임의의 시스템 파일(예: /etc/passwd)을 읽으려고 시도하는 경로 탐색(Path Traversal) 취약점이 발생할 수 있습니다.
Remedy: chunk.file_path가 절대 경로이거나 상위 디렉토리 참조(..)를 포함하는지 검증하여, 안전하지 않은 경로인 경우 파일 읽기를 건너뛰도록 제한합니다.

fn compute_file_sizes(root: &Path, chunks: &[Chunk]) -> HashMap<String, u64> {
    let mut sizes: HashMap<String, u64> = HashMap::new();
    for chunk in chunks {
        if sizes.contains_key(&chunk.file_path) {
            continue;
        }
        let path = Path::new(&chunk.file_path);
        if path.is_absolute() || path.components().any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir)) {
            continue;
        }
        if let Ok(text) = std::fs::read_to_string(root.join(path)) {
            sizes.insert(chunk.file_path.clone(), text.encode_utf16().count() as u64);
        }
    }
    sizes
}

Comment thread crates/csp/src/stats.rs
Comment on lines +88 to +97
fn delivered_chars(content: &str, max_snippet_lines: Option<usize>) -> u64 {
match max_snippet_lines {
None => utf16_len(content),
Some(0) => 0,
Some(n) => {
let snippet = content.lines().take(n).collect::<Vec<_>>().join("\n");
utf16_len(&snippet)
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

[MEDIUM] delivered_chars 함수의 불필요한 메모리 할당 개선

Symptom: delivered_chars 함수에서 max_snippet_linesSome(n)일 때, 첫 n줄의 UTF-16 길이를 계산하기 위해 content.lines().take(n).collect::<Vec<_>>().join("\n")을 사용하여 새로운 VecString을 매번 할당하고 있습니다.
Source: McConnell — Code Complete (Ch. 26: Code-Tuning Techniques)
Consequence: 검색 결과가 많거나 검색 빈도가 높을 때 불필요한 메모리 할당과 복사가 발생하여 성능 저하 및 가비지 컬렉션(또는 메모리 해제) 부하를 유발할 수 있습니다.
Remedy: VecString을 할당하지 않고, 반복자를 통해 각 라인의 UTF-16 길이와 줄바꿈 문자 개수만 합산하도록 변경하여 메모리 할당을 제거합니다.

fn delivered_chars(content: &str, max_snippet_lines: Option<usize>) -> u64 {
    match max_snippet_lines {
        None => utf16_len(content),
        Some(0) => 0,
        Some(n) => {
            let mut total_len = 0;
            let mut count = 0;
            for line in content.lines().take(n) {
                total_len += utf16_len(line);
                count += 1;
            }
            if count > 0 {
                total_len += (count - 1) as u64;
            }
            total_len
        }
    }
}

@sonarqubecloud

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR wires the previously inert save_search_stats telemetry into the four user-facing call sites (search_output, find_related_output, search_tool, find_related_tool) and adds the file_sizes map to CspIndex so character counts are available at recording time. The injection pattern (Option<&Path> stats file, dispatch_with_stats) keeps the library layer side-effect-free and the tests clean.

  • stats.rs: max_snippet_lines is threaded through as a new parameter; delivered_chars() correctly caps the counted characters to match what callers actually receive, with new unit tests confirming the Some(0) and Some(n) branches.
  • indexing/index.rs: compute_file_sizes reads files from disk once per unique path and stores the UTF-16 lengths in CspIndex::file_sizes; the map is captured in from_path, carried through from_git's re-root, and recomputed in load_from_disk when the source directory is still reachable.
  • main.rs / mcp.rs / mcp_server.rs: Recording is wired at the app boundary after each successful search; the Command::Savings arm in dispatch_with_stats still hard-codes default_stats_file() rather than using the injected path, breaking the injection contract for that subcommand.

Confidence Score: 4/5

Safe to merge; the only concrete issue is that Command::Savings inside dispatch_with_stats reads from the hardcoded default path instead of the injected one, which has no production impact but would silently mislead a future test that pipes telemetry to a temp file and then inspects it via Savings.

The core telemetry wiring, the file_sizes capture across all three build paths, the delivered_chars accounting, and the test isolation strategy are all correct and well-tested. The one inconsistency — Savings ignoring the injected stats_file — is a latent gap in the injection contract, not a runtime defect in any currently exercised path.

crates/csp/src/bin/csp/main.rs (the Command::Savings arm) and crates/csp/src/bin/csp/mcp_server.rs (the find_related test that skips nullifying stats_file).

Important Files Changed

Filename Overview
crates/csp/src/stats.rs Adds max_snippet_lines parameter to save_search_stats and introduces delivered_chars() to cap counted characters per the semble#206 spec. Logic and new tests are correct.
crates/csp/src/indexing/index.rs Adds file_sizes: HashMap<String, u64> to CspIndex, computed by compute_file_sizes() at build time, carried through from_git's re-root, and recomputed in load_from_disk when the source directory is still present. Correct and well-scoped.
crates/csp/src/mcp.rs Wires stats_file: Option<&Path> into search_tool and find_related_tool, calling save_search_stats after results are obtained. Tests pass None to avoid touching the real stats file.
crates/csp/src/bin/csp/mcp_server.rs Adds stats_file: Option<PathBuf> to CspMcpServer, defaulting to Some(default_stats_file()) in new(). The search_tool_call_returns_json_payload test correctly nullifies the field, but find_related_tool_call_reports_missing_chunk omits the nullification (harmless here because that path returns before recording stats, but a pattern to be aware of).
crates/csp/src/bin/csp/main.rs Splits dispatch into dispatch + dispatch_with_stats(stats_file) for testability; wires telemetry into search_output and find_related_output. The Command::Savings arm inside dispatch_with_stats hard-codes default_stats_file() rather than using the injected stats_file, breaking the injection contract for that subcommand.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant CLI as csp CLI / MCP Tool
    participant dispatch as dispatch_with_stats(stats_file)
    participant search as search_output / find_related_output
    participant index as CspIndex
    participant stats as save_search_stats
    participant file as savings.jsonl

    User->>CLI: csp search "query"
    CLI->>dispatch: "dispatch_with_stats(Search{...}, stats_file)"
    dispatch->>index: load_index() → CspIndex (with file_sizes)
    index-->>dispatch: "CspIndex {chunks, file_sizes}"
    dispatch->>search: "search_output(&idx, query, top_k, max_lines, Some(stats_file))"
    search->>index: index.search(query, options)
    index-->>search: "Vec<SearchResult>"
    search->>stats: save_search_stats(stats_file, results, Search, file_sizes, max_lines)
    stats->>stats: compute snippet_chars via delivered_chars()
    stats->>stats: dedup file paths → sum file_chars
    stats->>file: append JSONL record
    search-->>dispatch: JSON string
    dispatch-->>User: stdout

    Note over index: file_sizes populated at from_path() / from_git() / load_from_disk()
    Note over stats: Best-effort: I/O errors are swallowed
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant CLI as csp CLI / MCP Tool
    participant dispatch as dispatch_with_stats(stats_file)
    participant search as search_output / find_related_output
    participant index as CspIndex
    participant stats as save_search_stats
    participant file as savings.jsonl

    User->>CLI: csp search "query"
    CLI->>dispatch: "dispatch_with_stats(Search{...}, stats_file)"
    dispatch->>index: load_index() → CspIndex (with file_sizes)
    index-->>dispatch: "CspIndex {chunks, file_sizes}"
    dispatch->>search: "search_output(&idx, query, top_k, max_lines, Some(stats_file))"
    search->>index: index.search(query, options)
    index-->>search: "Vec<SearchResult>"
    search->>stats: save_search_stats(stats_file, results, Search, file_sizes, max_lines)
    stats->>stats: compute snippet_chars via delivered_chars()
    stats->>stats: dedup file paths → sum file_chars
    stats->>file: append JSONL record
    search-->>dispatch: JSON string
    dispatch-->>User: stdout

    Note over index: file_sizes populated at from_path() / from_git() / load_from_disk()
    Note over stats: Best-effort: I/O errors are swallowed
Loading

Comments Outside Diff (2)

  1. crates/csp/src/bin/csp/main.rs, line 404-408 (link)

    P2 The Savings arm hard-codes default_stats_file() instead of using the injected stats_file. Every other subcommand in dispatch_with_stats uses stats_file for telemetry I/O, so this one diverges from the established contract. A test that redirects writes to a temp file and then calls Savings to inspect them would silently read from ~/.csp/savings.jsonl instead of the temp file.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/csp/src/bin/csp/main.rs
    Line: 404-408
    
    Comment:
    The `Savings` arm hard-codes `default_stats_file()` instead of using the injected `stats_file`. Every other subcommand in `dispatch_with_stats` uses `stats_file` for telemetry I/O, so this one diverges from the established contract. A test that redirects writes to a temp file and then calls `Savings` to inspect them would silently read from `~/.csp/savings.jsonl` instead of the temp file.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code

  2. crates/csp/src/bin/csp/mcp_server.rs, line 272-297 (link)

    P2 find_related test omits stats_file = None

    This test creates CspMcpServer::new(...), which now initialises stats_file to Some(default_stats_file()). It escapes touching ~/.csp only because the lookup for nope.ts returns before the save_search_stats call is ever reached. Any future test that exercises a successful find_related call on a bare CspMcpServer::new(...) would silently append to the developer's real savings file. Adding server.stats_file = None; here makes the guard explicit and immune to future refactors of the early-return path.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/csp/src/bin/csp/mcp_server.rs
    Line: 272-297
    
    Comment:
    **`find_related` test omits `stats_file = None`**
    
    This test creates `CspMcpServer::new(...)`, which now initialises `stats_file` to `Some(default_stats_file())`. It escapes touching `~/.csp` only because the lookup for `nope.ts` returns before the `save_search_stats` call is ever reached. Any future test that exercises a *successful* `find_related` call on a bare `CspMcpServer::new(...)` would silently append to the developer's real savings file. Adding `server.stats_file = None;` here makes the guard explicit and immune to future refactors of the early-return path.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
crates/csp/src/bin/csp/main.rs:404-408
The `Savings` arm hard-codes `default_stats_file()` instead of using the injected `stats_file`. Every other subcommand in `dispatch_with_stats` uses `stats_file` for telemetry I/O, so this one diverges from the established contract. A test that redirects writes to a temp file and then calls `Savings` to inspect them would silently read from `~/.csp/savings.jsonl` instead of the temp file.

```suggestion
        Command::Savings { verbose } => {
            print!(
                "{}",
                format_savings_report(stats_file, verbose, now_secs())
            );
```

### Issue 2 of 2
crates/csp/src/bin/csp/mcp_server.rs:272-297
**`find_related` test omits `stats_file = None`**

This test creates `CspMcpServer::new(...)`, which now initialises `stats_file` to `Some(default_stats_file())`. It escapes touching `~/.csp` only because the lookup for `nope.ts` returns before the `save_search_stats` call is ever reached. Any future test that exercises a *successful* `find_related` call on a bare `CspMcpServer::new(...)` would silently append to the developer's real savings file. Adding `server.stats_file = None;` here makes the guard explicit and immune to future refactors of the early-return path.

Reviews (1): Last reviewed commit: "feat(stats): wire token-savings telemetr..." | Re-trigger Greptile

@codspeed-hq

codspeed-hq Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 5 untouched benchmarks


Comparing amondnet/savings-wiring (306b1a2) with amondnet/max-snippet-lines (3fb210a)

Open in CodSpeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant