open-code-review/internal/telemetry/span.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

128 lines
3.6 KiB
Go

package telemetry
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
func getTracer() trace.Tracer {
return otel.GetTracerProvider().Tracer(serviceName)
}
// StartSpan creates a new span from the given context. When telemetry is not enabled,
// it returns a no-op span so callers can safely defer .End().
func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
if !IsEnabled() {
return ctx, trace.SpanFromContext(ctx)
}
return getTracer().Start(ctx, name, opts...)
}
// TraceIDFromContext returns the hex-encoded trace ID of the span carried by
// ctx, or "" if ctx carries no valid span (e.g. telemetry is disabled).
func TraceIDFromContext(ctx context.Context) string {
sc := trace.SpanContextFromContext(ctx)
if !sc.TraceID().IsValid() {
return ""
}
return sc.TraceID().String()
}
// EndSpan ends the span and records error status if present.
func EndSpan(span trace.Span, err error) {
if err != nil {
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
}
span.End()
}
// SetAttr sets a single attribute on a span.
func SetAttr(span trace.Span, key string, value interface{}) {
if span == nil {
return
}
switch v := value.(type) {
case string:
span.SetAttributes(attribute.String(key, v))
case int:
span.SetAttributes(attribute.Int64(key, int64(v)))
case int64:
span.SetAttributes(attribute.Int64(key, v))
case bool:
span.SetAttributes(attribute.Bool(key, v))
case float64:
span.SetAttributes(attribute.Float64(key, v))
default:
span.SetAttributes(attribute.String(key, ""))
}
}
// StartToolSpan creates a span for a tool execution with standard attributes.
func StartToolSpan(ctx context.Context, toolName string) (context.Context, trace.Span) {
return StartSpan(ctx, "tool.execute."+toolName,
trace.WithAttributes(attribute.String("tool.name", toolName)))
}
// RecordToolResult sets the outcome of a tool execution on the span.
func RecordToolResult(span trace.Span, toolName string, durationMs int64, err error) {
if span == nil {
return
}
SetAttr(span, "tool.duration_ms", durationMs)
if err != nil {
SetAttr(span, "tool.status", "error")
SetAttr(span, "tool.error", err.Error())
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
} else {
SetAttr(span, "tool.status", "ok")
}
}
// StartLLMSpan creates a span for an LLM request with standard attributes.
func StartLLMSpan(ctx context.Context, model string) (context.Context, trace.Span) {
return StartSpan(ctx, "llm.request",
trace.WithAttributes(attribute.String("llm.model", model)))
}
// RecordLLMResult sets the outcome of an LLM request on the span.
func RecordLLMResult(span trace.Span, duration time.Duration, totalTokens int64, err error) {
if span == nil {
return
}
SetAttr(span, "llm.duration_ms", duration.Milliseconds())
SetAttr(span, "llm.total_tokens", totalTokens)
if err != nil {
SetAttr(span, "llm.status", "error")
SetAttr(span, "llm.error", err.Error())
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
} else {
SetAttr(span, "llm.status", "ok")
}
}
// AnyToAttr converts an arbitrary value to an OTel attribute.KeyValue.
func AnyToAttr(k string, v interface{}) attribute.KeyValue {
switch val := v.(type) {
case string:
return attribute.String(k, val)
case int:
return attribute.Int64(k, int64(val))
case int64:
return attribute.Int64(k, val)
case bool:
return attribute.Bool(k, val)
case float64:
return attribute.Float64(k, val)
default:
return attribute.String(k, fmt.Sprintf("%v", v))
}
}