mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
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
This commit is contained in:
parent
14f1c22a60
commit
46dde274d8
21 changed files with 471 additions and 24 deletions
|
|
@ -7,6 +7,9 @@ import (
|
|||
"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"
|
||||
)
|
||||
|
|
@ -175,3 +178,61 @@ func TestEmitRunResult_NilQuietHandle(t *testing.T) {
|
|||
})
|
||||
_ = 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ type jsonToolCalls struct {
|
|||
|
||||
type jsonOutput struct {
|
||||
Status string `json:"status"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Summary *jsonSummary `json:"summary,omitempty"`
|
||||
ToolCalls *jsonToolCalls `json:"tool_calls"`
|
||||
|
|
@ -263,9 +264,10 @@ func outputJSON(comments []model.LlmComment) error {
|
|||
|
||||
func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning,
|
||||
filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64,
|
||||
duration time.Duration, projectSummary string, toolCalls map[string]int64) error {
|
||||
duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string) error {
|
||||
out := jsonOutput{
|
||||
Status: "success",
|
||||
TraceID: traceID,
|
||||
Comments: comments,
|
||||
Summary: &jsonSummary{
|
||||
FilesReviewed: filesReviewed,
|
||||
|
|
@ -311,9 +313,10 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW
|
|||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
func outputJSONNoFiles() error {
|
||||
func outputJSONNoFiles(traceID string) error {
|
||||
out := jsonOutput{
|
||||
Status: "skipped",
|
||||
TraceID: traceID,
|
||||
Message: "No supported files changed.",
|
||||
Comments: []model.LlmComment{},
|
||||
ToolCalls: &jsonToolCalls{
|
||||
|
|
|
|||
|
|
@ -168,8 +168,7 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) {
|
|||
os.Stdout = w
|
||||
|
||||
warnings := []agent.AgentWarning{{Type: "subtask_error", File: "x.go", Message: "fail"}}
|
||||
err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil)
|
||||
|
||||
err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace")
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
|
|
@ -188,6 +187,9 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) {
|
|||
if !strings.Contains(out.Message, "errors") {
|
||||
t.Errorf("message = %q, expected to mention errors", out.Message)
|
||||
}
|
||||
if out.TraceID != "abc123trace" {
|
||||
t.Errorf("trace_id = %q, want abc123trace", out.TraceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusBadge(t *testing.T) {
|
||||
|
|
@ -275,8 +277,7 @@ func TestOutputJSONWithWarnings(t *testing.T) {
|
|||
|
||||
comments := []model.LlmComment{{Path: "b.go", Content: "test"}}
|
||||
warnings := []agent.AgentWarning{{Type: "subtask_error", File: "c.go", Message: "failed"}}
|
||||
err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3})
|
||||
|
||||
err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789")
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
|
|
@ -301,6 +302,9 @@ func TestOutputJSONWithWarnings(t *testing.T) {
|
|||
if out.ToolCalls == nil || out.ToolCalls.Total != 3 {
|
||||
t.Errorf("ToolCalls.Total = %v", out.ToolCalls)
|
||||
}
|
||||
if out.TraceID != "trace-xyz-789" {
|
||||
t.Errorf("trace_id = %q, want trace-xyz-789", out.TraceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) {
|
||||
|
|
@ -309,8 +313,7 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) {
|
|||
os.Stdout = w
|
||||
|
||||
warnings := []agent.AgentWarning{{Type: "warning", Message: "something"}}
|
||||
err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil)
|
||||
|
||||
err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "")
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
|
|
@ -336,7 +339,7 @@ func TestOutputJSONNoFiles(t *testing.T) {
|
|||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
err := outputJSONNoFiles()
|
||||
err := outputJSONNoFiles("test-trace-id-456")
|
||||
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
|
|
@ -353,6 +356,9 @@ func TestOutputJSONNoFiles(t *testing.T) {
|
|||
if out.Status != "skipped" {
|
||||
t.Errorf("status = %q, want skipped", out.Status)
|
||||
}
|
||||
if out.TraceID != "test-trace-id-456" {
|
||||
t.Errorf("trace_id = %q, want test-trace-id-456", out.TraceID)
|
||||
}
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import (
|
|||
"github.com/open-code-review/open-code-review/internal/mcp"
|
||||
"github.com/open-code-review/open-code-review/internal/telemetry"
|
||||
"github.com/open-code-review/open-code-review/internal/tool"
|
||||
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
)
|
||||
|
||||
func runReview(args []string) error {
|
||||
|
|
@ -104,11 +106,23 @@ func runReview(args []string) error {
|
|||
|
||||
ctx, span := telemetry.StartSpan(context.Background(), "review.run")
|
||||
defer span.End()
|
||||
telemetry.SetAttr(span, "review.repo", cc.RepoDir)
|
||||
telemetry.SetAttr(span, "review.from", opts.from)
|
||||
telemetry.SetAttr(span, "review.to", opts.to)
|
||||
telemetry.SetAttr(span, "review.model", rt.Model)
|
||||
var traceID string
|
||||
if telemetry.IsEnabled() {
|
||||
traceID = telemetry.TraceIDFromContext(ctx)
|
||||
if opts.outputFormat != "json" {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID)
|
||||
}
|
||||
}
|
||||
startTime := time.Now()
|
||||
|
||||
comments, err := ag.Run(ctx)
|
||||
if err != nil {
|
||||
telemetry.SetAttr(span, "error", err.Error())
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
return fmt.Errorf("review failed: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package main
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -11,6 +12,8 @@ import (
|
|||
"github.com/open-code-review/open-code-review/internal/scan"
|
||||
"github.com/open-code-review/open-code-review/internal/telemetry"
|
||||
"github.com/open-code-review/open-code-review/internal/tool"
|
||||
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
)
|
||||
|
||||
// scanOptions mirrors reviewOptions for the full-scan subcommand. The two
|
||||
|
|
@ -207,11 +210,19 @@ func runScan(args []string) error {
|
|||
|
||||
ctx, span := telemetry.StartSpan(context.Background(), "scan.run")
|
||||
defer span.End()
|
||||
var traceID string
|
||||
if telemetry.IsEnabled() {
|
||||
traceID = telemetry.TraceIDFromContext(ctx)
|
||||
if opts.outputFormat != "json" {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID)
|
||||
}
|
||||
}
|
||||
startTime := time.Now()
|
||||
|
||||
comments, err := ag.Run(ctx)
|
||||
if err != nil {
|
||||
telemetry.SetAttr(span, "error", err.Error())
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
return fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -276,8 +276,10 @@ func emitRunResult(
|
|||
telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
|
||||
}
|
||||
|
||||
traceID := telemetry.TraceIDFromContext(ctx)
|
||||
|
||||
if outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 {
|
||||
return outputJSONNoFiles()
|
||||
return outputJSONNoFiles(traceID)
|
||||
}
|
||||
|
||||
// Agent-text audiences need stdout back before PrintTraceSummary so the
|
||||
|
|
@ -296,7 +298,7 @@ func emitRunResult(
|
|||
return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(),
|
||||
ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
|
||||
ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration,
|
||||
ag.ProjectSummary(), ag.ToolCalls())
|
||||
ag.ProjectSummary(), ag.ToolCalls(), traceID)
|
||||
}
|
||||
outputTextWithWarnings(comments, ag.Warnings())
|
||||
if summary := ag.ProjectSummary(); summary != "" {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue