Skip to content

Commit 3bf908b

Browse files
author
razvan
committed
feat(telemetry): JSONL search metrics in .ragcode/ (#6 from proposals)
- pkg/telemetry: AppendSearchMetric() — thread-safe JSONL append per tool invocation to {workspaceRoot}/.ragcode/search_metrics.jsonl - pkg/telemetry: ReadAggregatedMetrics() — reads JSONL, returns cumulative stats (total searches, fallback vs vector, bytes saved, avg scores) - smart_search: records metric async (goroutine) after each search - 4 unit tests for write/read round-trip and edge cases
1 parent 3f60866 commit 3bf908b

5 files changed

Lines changed: 231 additions & 0 deletions

File tree

internal/service/tools/smart_search.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ func (t *SmartSearchTool) Register(server *mcp.Server) {
9999
}
100100

101101
func (t *SmartSearchTool) Execute(ctx context.Context, input SmartSearchInput) (string, error) {
102+
t0 := time.Now()
102103
query, limit, input, err := normalizeInput(input, t.searchLimit)
103104
if err != nil {
104105
return "", err
@@ -130,6 +131,9 @@ func (t *SmartSearchTool) Execute(ctx context.Context, input SmartSearchInput) (
130131
response := t.buildResponseMeta(sr.meta, useCompact)
131132
serializeResults(&response, merged, useCompact, isFallback, query, input.IncludeReasons)
132133

134+
// Record metric asynchronously to avoid blocking response
135+
go recordSearchMetric(sr.meta, query, merged, isFallback, response.Context.Telemetry, t0)
136+
133137
return response.JSON()
134138
}
135139

internal/service/tools/smart_search_pipeline.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,33 @@ func noResultsResponse(query string, meta searchMetadata) (string, error) {
381381
}
382382
return response.JSON()
383383
}
384+
385+
// recordSearchMetric maps pipeline data to a telemetry.SearchMetric and appends to JSONL.
386+
func recordSearchMetric(meta searchMetadata, query string, merged []mergedResult, isFallback bool, savings *telemetry.Savings, start time.Time) {
387+
source := "vector"
388+
if isFallback {
389+
source = "fallback"
390+
}
391+
392+
var topScore float32
393+
if len(merged) > 0 {
394+
topScore = merged[0].score
395+
}
396+
397+
var bytesSaved, tokensSaved int64
398+
if savings != nil {
399+
bytesSaved = savings.BytesAvoided
400+
tokensSaved = savings.TokensSaved
401+
}
402+
403+
telemetry.AppendSearchMetric(meta.workspaceRoot, telemetry.SearchMetric{
404+
Tool: "rag_search",
405+
Query: query,
406+
ResultCount: len(merged),
407+
TopScore: topScore,
408+
Source: source,
409+
BytesSaved: bytesSaved,
410+
TokensSaved: tokensSaved,
411+
ResponseMs: time.Since(start).Milliseconds(),
412+
})
413+
}

pkg/telemetry/metrics.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package telemetry
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"sync"
8+
"time"
9+
)
10+
11+
// SearchMetric records a single tool invocation for cumulative analytics.
12+
type SearchMetric struct {
13+
Timestamp time.Time `json:"ts"`
14+
Tool string `json:"tool"` // "rag_search", "rag_find_usages", etc.
15+
Query string `json:"query,omitempty"` // search query
16+
ResultCount int `json:"result_count"` // number of results returned
17+
TopScore float32 `json:"top_score,omitempty"` // score of best result
18+
Source string `json:"source,omitempty"` // "vector", "fallback", "hybrid"
19+
BytesSaved int64 `json:"bytes_saved,omitempty"` // bytes avoided via RAG
20+
TokensSaved int64 `json:"tokens_saved,omitempty"` // estimated tokens saved
21+
ResponseMs int64 `json:"response_ms,omitempty"` // response time in milliseconds
22+
}
23+
24+
const metricsFile = "search_metrics.jsonl"
25+
26+
// mu protects concurrent writes to the same metrics file.
27+
var mu sync.Mutex
28+
29+
// AppendSearchMetric appends a single metric line to {workspaceRoot}/.ragcode/search_metrics.jsonl.
30+
// Thread-safe via mutex. Fails silently (logs nothing) to avoid impacting tool response times.
31+
func AppendSearchMetric(workspaceRoot string, m SearchMetric) {
32+
if workspaceRoot == "" {
33+
return
34+
}
35+
36+
m.Timestamp = time.Now()
37+
38+
line, err := json.Marshal(m)
39+
if err != nil {
40+
return
41+
}
42+
line = append(line, '\n')
43+
44+
dir := filepath.Join(workspaceRoot, ".ragcode")
45+
path := filepath.Join(dir, metricsFile)
46+
47+
mu.Lock()
48+
defer mu.Unlock()
49+
50+
_ = os.MkdirAll(dir, 0o755)
51+
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
52+
if err != nil {
53+
return
54+
}
55+
defer f.Close()
56+
_, _ = f.Write(line)
57+
}

pkg/telemetry/metrics_reader.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package telemetry
2+
3+
import (
4+
"bufio"
5+
"encoding/json"
6+
"os"
7+
"path/filepath"
8+
)
9+
10+
// AggregatedMetrics holds cumulative statistics from search_metrics.jsonl.
11+
type AggregatedMetrics struct {
12+
TotalSearches int `json:"total_searches"`
13+
SearchesWithResults int `json:"searches_with_results"`
14+
FallbackSearches int `json:"fallback_searches"`
15+
VectorSearches int `json:"vector_searches"`
16+
AvgTopScore float32 `json:"avg_top_score"`
17+
TotalBytesSaved int64 `json:"total_bytes_saved"`
18+
TotalTokensSaved int64 `json:"total_tokens_saved"`
19+
AvgResponseMs int64 `json:"avg_response_ms"`
20+
}
21+
22+
// ReadAggregatedMetrics reads and aggregates all metrics from the JSONL file.
23+
// Returns nil if no metrics file exists.
24+
func ReadAggregatedMetrics(workspaceRoot string) *AggregatedMetrics {
25+
if workspaceRoot == "" {
26+
return nil
27+
}
28+
path := filepath.Join(workspaceRoot, ".ragcode", metricsFile)
29+
f, err := os.Open(path)
30+
if err != nil {
31+
return nil
32+
}
33+
defer f.Close()
34+
35+
var agg AggregatedMetrics
36+
var scoreSum float32
37+
var msSum int64
38+
39+
scanner := bufio.NewScanner(f)
40+
for scanner.Scan() {
41+
var m SearchMetric
42+
if json.Unmarshal(scanner.Bytes(), &m) != nil {
43+
continue
44+
}
45+
agg.TotalSearches++
46+
if m.ResultCount > 0 {
47+
agg.SearchesWithResults++
48+
}
49+
if m.Source == "fallback" {
50+
agg.FallbackSearches++
51+
} else {
52+
agg.VectorSearches++
53+
}
54+
scoreSum += m.TopScore
55+
agg.TotalBytesSaved += m.BytesSaved
56+
agg.TotalTokensSaved += m.TokensSaved
57+
msSum += m.ResponseMs
58+
}
59+
60+
if agg.TotalSearches == 0 {
61+
return nil
62+
}
63+
agg.AvgTopScore = scoreSum / float32(agg.TotalSearches)
64+
agg.AvgResponseMs = msSum / int64(agg.TotalSearches)
65+
return &agg
66+
}

pkg/telemetry/metrics_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package telemetry
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestAppendAndRead(t *testing.T) {
10+
tmp := t.TempDir()
11+
12+
AppendSearchMetric(tmp, SearchMetric{
13+
Tool: "rag_search", Query: "auth", ResultCount: 5,
14+
TopScore: 0.85, Source: "vector", BytesSaved: 14000, TokensSaved: 3500, ResponseMs: 120,
15+
})
16+
AppendSearchMetric(tmp, SearchMetric{
17+
Tool: "rag_search", Query: "config", ResultCount: 0,
18+
TopScore: 0, Source: "fallback", BytesSaved: 0, TokensSaved: 0, ResponseMs: 80,
19+
})
20+
AppendSearchMetric(tmp, SearchMetric{
21+
Tool: "rag_find_usages", Query: "MyFunc", ResultCount: 3,
22+
TopScore: 0.92, Source: "vector", BytesSaved: 5000, TokensSaved: 1250, ResponseMs: 60,
23+
})
24+
25+
// Verify file exists
26+
path := filepath.Join(tmp, ".ragcode", metricsFile)
27+
if _, err := os.Stat(path); err != nil {
28+
t.Fatalf("metrics file not created: %v", err)
29+
}
30+
31+
agg := ReadAggregatedMetrics(tmp)
32+
if agg == nil {
33+
t.Fatal("expected aggregated metrics, got nil")
34+
}
35+
36+
if agg.TotalSearches != 3 {
37+
t.Errorf("TotalSearches=%d, want 3", agg.TotalSearches)
38+
}
39+
if agg.SearchesWithResults != 2 {
40+
t.Errorf("SearchesWithResults=%d, want 2", agg.SearchesWithResults)
41+
}
42+
if agg.FallbackSearches != 1 {
43+
t.Errorf("FallbackSearches=%d, want 1", agg.FallbackSearches)
44+
}
45+
if agg.VectorSearches != 2 {
46+
t.Errorf("VectorSearches=%d, want 2", agg.VectorSearches)
47+
}
48+
if agg.TotalBytesSaved != 19000 {
49+
t.Errorf("TotalBytesSaved=%d, want 19000", agg.TotalBytesSaved)
50+
}
51+
if agg.TotalTokensSaved != 4750 {
52+
t.Errorf("TotalTokensSaved=%d, want 4750", agg.TotalTokensSaved)
53+
}
54+
}
55+
56+
func TestReadEmptyWorkspace(t *testing.T) {
57+
tmp := t.TempDir()
58+
agg := ReadAggregatedMetrics(tmp)
59+
if agg != nil {
60+
t.Error("expected nil for workspace without metrics")
61+
}
62+
}
63+
64+
func TestReadEmptyString(t *testing.T) {
65+
agg := ReadAggregatedMetrics("")
66+
if agg != nil {
67+
t.Error("expected nil for empty workspace")
68+
}
69+
}
70+
71+
func TestAppendEmptyWorkspace(t *testing.T) {
72+
// Should not panic
73+
AppendSearchMetric("", SearchMetric{Tool: "test"})
74+
}

0 commit comments

Comments
 (0)