mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
* 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
238 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|