open-code-review/internal/diff/relocation.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

103 lines
3 KiB
Go

package diff
import (
"context"
"fmt"
"strings"
"time"
"github.com/open-code-review/open-code-review/internal/config/template"
"github.com/open-code-review/open-code-review/internal/llm"
"github.com/open-code-review/open-code-review/internal/model"
"github.com/open-code-review/open-code-review/internal/stdout"
"github.com/open-code-review/open-code-review/internal/telemetry"
)
// ReLocateComment calls the LLM to regenerate a precise existing_code snippet
// when text-based matching fails, then retries ResolveComment with the new snippet.
// Returns (success, response, requestMessages) so the caller can record session
// history and track token usage. Response and messages are nil on early exits.
func ReLocateComment(
ctx context.Context,
cm *model.LlmComment,
d *model.Diff,
client llm.LLMClient,
task *template.LlmConversation,
modelName string,
maxTokens int,
) (bool, *llm.ChatResponse, []llm.Message) {
if task == nil || len(task.Messages) == 0 {
return false, nil, nil
}
if task.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(task.Timeout)*time.Second)
defer cancel()
}
messages := make([]llm.Message, 0, len(task.Messages))
for _, m := range task.Messages {
content := m.Content
content = strings.ReplaceAll(content, "{diff}", d.Diff)
content = strings.ReplaceAll(content, "{existing_code}", cm.ExistingCode)
content = strings.ReplaceAll(content, "{suggestion_content}", cm.Content)
messages = append(messages, llm.NewTextMessage(m.Role, content))
}
startTime := time.Now()
_, llmSpan := telemetry.StartLLMSpan(ctx, modelName)
resp, err := client.CompletionsWithCtx(ctx, llm.ChatRequest{
Model: modelName,
Messages: messages,
MaxTokens: maxTokens,
})
duration := time.Since(startTime)
if err != nil {
telemetry.RecordLLMResult(llmSpan, duration, 0, err)
llmSpan.End()
fmt.Fprintf(stdout.Writer(), "[ocr] Re-location LLM call failed for %s: %v\n", cm.Path, err)
return false, nil, messages
}
var totalTokens int64
if resp.Usage != nil {
totalTokens = resp.Usage.TotalTokens
}
telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
llmSpan.End()
code := extractCodeBlock(resp.Content())
if code == "" {
return false, resp, messages
}
original := cm.ExistingCode
cm.ExistingCode = code
if ResolveComment(cm, d) {
return true, resp, messages
}
cm.ExistingCode = original
return false, resp, messages
}
// extractCodeBlock extracts the content of the first fenced code block from text.
// Returns empty string if no code block is found.
func extractCodeBlock(text string) string {
text = strings.TrimSpace(text)
start := strings.Index(text, "```")
if start < 0 {
return ""
}
afterOpen := start + 3
// Skip optional language tag on the opening fence line.
if nl := strings.IndexByte(text[afterOpen:], '\n'); nl >= 0 {
afterOpen += nl + 1
} else {
return ""
}
end := strings.Index(text[afterOpen:], "```")
if end < 0 {
return ""
}
return strings.TrimSpace(text[afterOpen : afterOpen+end])
}