open-code-review/cmd/opencodereview/emit_run_result_test.go
xujiejie 46dde274d8
Feat/telemetry http exporter (#314)
* feat(telemetry): add OTLP HTTP exporter and print TraceID

- Add HTTP/protobuf exporter support alongside existing gRPC exporter
- Route based on OTEL_EXPORTER_OTLP_PROTOCOL config (http/protobuf vs grpc)
- Print TraceID to stderr when telemetry is enabled for easier correlation
- Add corresponding unit tests

* feat(telemetry): add span coverage for LLM calls, tool execution, plan/filter phases

- Add StartLLMSpan / RecordLLMResult helpers (span.go), symmetric with
  existing StartToolSpan / RecordToolResult
- Wrap LLM completion calls in llmloop.RunPerFile with llm.request spans
- Wrap all three tool execution paths in executeToolCall with
  tool.execute.* spans (dynamic tools, code_comment sync/async, other tools)
- Add plan.execute span around executePlanPhase
- Add main.loop span around RunPerFile call in executeSubtask
- Add review_filter.execute span around executeReviewFilter, with
  comments.before / comments.filtered attributes
- Record llm.error attribute on LLM failures for diagnosability
- Record review.repo / review.from / review.to / review.model on the
  top-level review.run span
- Metrics (RecordLLMRequest / RecordToolCall) are preserved alongside
  the new spans — they serve different purposes (aggregate dashboards
  vs per-run diagnosis)

Verified end-to-end against Sunfire (OTLP HTTP gateway): full span tree
observed for review.run -> subtask.execute -> plan.execute/main.loop/
review_filter.execute -> llm.request/tool.execute.*

* fix(telemetry): address CR findings — span error handling, async span lifecycle, protocol robustness

- Add span.RecordError(err) to RecordLLMResult and RecordToolResult for
  consistency with EndSpan
- Use OTel standard pattern (span.SetStatus + span.RecordError) in error
  paths of review.run, plan.execute, main.loop, review_filter.execute
- Move async code_comment span end into pool.Submit callback so span
  duration reflects actual execution time
- Unify time.Since(startTime) in code_comment error path to a single dur
- Remove http/json from supported OTLP protocols (not actually implemented)
- Add stderr warning when unknown OTLP protocol falls back to gRPC

* feat(telemetry): include trace_id in JSON output, restrict stderr to text format

- Add trace_id as top-level field in jsonOutput struct (omitempty)
- JSON format: trace_id in structured response for programmatic extraction
- Text format: TraceID printed to stderr for human debugging
- Telemetry disabled: trace_id field omitted entirely

* fix: address PR review findings

- loop.go: wrap async span lifecycle in defer to prevent leak on panic
- exporter.go: update parseOTLPEndpoint comment to reflect gRPC+HTTP usage
- scan_cmd.go: align traceID extraction and OTel error handling with review_cmd
- output.go/shared.go: propagate traceID to outputJSONNoFiles for consistency
- agent.go: move comments.filtered attribute before early return so 0 is
  distinguishable from not-executed

* feat(telemetry): address PR review — http/json routing, LLM span coverage, trace_id tests

- Route http/json to HTTP exporter (Go OTel SDK HTTP transport only
  supports protobuf serialization; users need HTTP transport, not JSON encoding)
- Add llm.request spans to executePlanPhase, executeReviewFilter, and
  ReLocateComment with Usage nil-safety consistent with loop.go
- Add trace_id assertions to output helper tests and emitRunResult
  end-to-end tests using real TracerProvider

* docs: add OTLP protocol selection and endpoint format to telemetry section

Sync across all 5 README language versions (en, zh-CN, ja-JP, ko-KR, ru-RU).

* fix: unify time.Since in async code_comment defer to single dur variable
2026-07-08 13:12:21 +08:00

238 lines
7.1 KiB
Go

package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"github.com/open-code-review/open-code-review/internal/agent"
"github.com/open-code-review/open-code-review/internal/model"
)
type mockResultProvider struct {
diffs []model.Diff
filesReviewed int64
inputTokens int64
outputTokens int64
totalTokens int64
cacheReadTokens int64
cacheWriteTokens int64
warnings []agent.AgentWarning
projectSummary string
toolCalls map[string]int64
}
func (m *mockResultProvider) Diffs() []model.Diff { return m.diffs }
func (m *mockResultProvider) FilesReviewed() int64 { return m.filesReviewed }
func (m *mockResultProvider) TotalInputTokens() int64 { return m.inputTokens }
func (m *mockResultProvider) TotalOutputTokens() int64 { return m.outputTokens }
func (m *mockResultProvider) TotalTokensUsed() int64 { return m.totalTokens }
func (m *mockResultProvider) TotalCacheReadTokens() int64 { return m.cacheReadTokens }
func (m *mockResultProvider) TotalCacheWriteTokens() int64 { return m.cacheWriteTokens }
func (m *mockResultProvider) Warnings() []agent.AgentWarning { return m.warnings }
func (m *mockResultProvider) ProjectSummary() string { return m.projectSummary }
func (m *mockResultProvider) ToolCalls() map[string]int64 { return m.toolCalls }
func TestEmitRunResult_JSONNoFiles(t *testing.T) {
ag := &mockResultProvider{filesReviewed: 0}
got := captureStdout(t, func() {
err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
var out jsonOutput
if err := json.Unmarshal([]byte(got), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Status != "skipped" {
t.Errorf("status = %q, want skipped", out.Status)
}
}
func TestEmitRunResult_JSONWithComments(t *testing.T) {
ag := &mockResultProvider{
filesReviewed: 3,
inputTokens: 100,
outputTokens: 50,
totalTokens: 150,
warnings: []agent.AgentWarning{{Type: "info", Message: "note"}},
toolCalls: map[string]int64{"file_read": 2},
}
comments := []model.LlmComment{{Path: "main.go", Content: "fix", StartLine: 1, EndLine: 2}}
got := captureStdout(t, func() {
err := emitRunResult(context.Background(), ag, comments, time.Now(), "json", "developer", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
var out jsonOutput
if err := json.Unmarshal([]byte(got), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(out.Comments) != 1 {
t.Errorf("expected 1 comment, got %d", len(out.Comments))
}
if out.Summary == nil || out.Summary.FilesReviewed != 3 {
t.Errorf("summary.FilesReviewed = %v", out.Summary)
}
}
func TestEmitRunResult_TextNoComments(t *testing.T) {
ag := &mockResultProvider{filesReviewed: 2}
got := captureStdout(t, func() {
err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "developer", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
if !strings.Contains(got, "Looks good to me") {
t.Errorf("expected 'Looks good to me', got %q", got)
}
}
func TestEmitRunResult_TextWithComments(t *testing.T) {
ag := &mockResultProvider{filesReviewed: 1}
comments := []model.LlmComment{{Path: "a.go", Content: "rename", StartLine: 5, EndLine: 10}}
got := captureStdout(t, func() {
err := emitRunResult(context.Background(), ag, comments, time.Now(), "text", "developer", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
if !strings.Contains(got, "a.go") {
t.Errorf("expected path, got %q", got)
}
if !strings.Contains(got, "rename") {
t.Errorf("expected comment content, got %q", got)
}
}
func TestEmitRunResult_TextWithProjectSummary(t *testing.T) {
ag := &mockResultProvider{
filesReviewed: 5,
projectSummary: "All tests pass, code quality is good.",
}
got := captureStdout(t, func() {
err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "developer", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
if !strings.Contains(got, "Project Summary") {
t.Errorf("expected 'Project Summary', got %q", got)
}
if !strings.Contains(got, "All tests pass") {
t.Errorf("expected summary content, got %q", got)
}
}
func TestEmitRunResult_AgentTextRestoresQuiet(t *testing.T) {
ag := &mockResultProvider{filesReviewed: 1}
q := newQuietHandle("text", "agent")
got := captureStdout(t, func() {
err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "agent", q)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
if q.fn != nil {
t.Error("expected quiet handle to be restored")
}
_ = got
}
func TestEmitRunResult_AgentJSONDoesNotRestore(t *testing.T) {
ag := &mockResultProvider{
filesReviewed: 1,
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
}
q := newQuietHandle("json", "agent")
got := captureStdout(t, func() {
err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "agent", q)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
var out jsonOutput
if err := json.Unmarshal([]byte(got), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
q.Restore()
}
func TestEmitRunResult_NilQuietHandle(t *testing.T) {
ag := &mockResultProvider{filesReviewed: 1}
got := captureStdout(t, func() {
err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "agent", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
_ = got
}
func TestEmitRunResult_JSONTraceIDFromContext(t *testing.T) {
tp := sdktrace.NewTracerProvider()
defer tp.Shutdown(context.Background())
otel.SetTracerProvider(tp)
ctx, span := tp.Tracer("test").Start(context.Background(), "test-root")
wantTraceID := span.SpanContext().TraceID().String()
defer span.End()
ag := &mockResultProvider{
filesReviewed: 2,
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
}
got := captureStdout(t, func() {
err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
var out jsonOutput
if err := json.Unmarshal([]byte(got), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.TraceID != wantTraceID {
t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID)
}
}
func TestEmitRunResult_JSONNoFilesTraceID(t *testing.T) {
tp := sdktrace.NewTracerProvider()
defer tp.Shutdown(context.Background())
otel.SetTracerProvider(tp)
ctx, span := tp.Tracer("test").Start(context.Background(), "test-root")
wantTraceID := span.SpanContext().TraceID().String()
defer span.End()
ag := &mockResultProvider{filesReviewed: 0}
got := captureStdout(t, func() {
err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
var out jsonOutput
if err := json.Unmarshal([]byte(got), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.Status != "skipped" {
t.Errorf("status = %q, want skipped", out.Status)
}
if out.TraceID != wantTraceID {
t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID)
}
}