open-code-review/cmd/opencodereview/output.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

393 lines
10 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"unicode"
"github.com/open-code-review/open-code-review/internal/agent"
"github.com/open-code-review/open-code-review/internal/model"
"github.com/open-code-review/open-code-review/internal/suggestdiff"
)
func outputText(comments []model.LlmComment) {
if len(comments) == 0 {
fmt.Println("No comments generated. Looks good to me.")
return
}
for _, c := range comments {
renderComment(c)
}
}
func hasSubtaskErrors(warnings []agent.AgentWarning) bool {
for _, w := range warnings {
if w.Type == "subtask_error" {
return true
}
}
return false
}
func outputTextWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning) {
if len(comments) == 0 {
if hasSubtaskErrors(warnings) {
fmt.Println("Some files could not be reviewed due to errors (see warnings below).")
} else {
fmt.Println("No comments generated. Looks good to me.")
}
} else {
for _, c := range comments {
renderComment(c)
}
}
for _, w := range warnings {
if w.Type == "subtask_error" {
continue
}
fmt.Fprintf(os.Stderr, "[ocr] WARNING [%s] %s: %s\n", w.Type, sanitizeTerminal(w.File), sanitizeTerminal(w.Message))
}
}
func renderComment(comment model.LlmComment) {
lines := buildDiffLines(comment)
if len(lines) == 0 && comment.Content == "" {
return
}
fmt.Printf("\n\033[2m─── %s:%d-%d ───\033[0m\n", sanitizeTerminal(comment.Path), comment.StartLine, comment.EndLine)
if comment.Content != "" {
badge := buildBadge(comment)
content := sanitizeTerminal(comment.Content)
if badge != "" {
// Prepend the plain badge text to the content so it wraps inline with
// the first line, then colorize just the badge prefix after wrapping.
content = badge + " " + content
}
lines := wrapByRunes(content, 100)
for i, ln := range lines {
if i == 0 && badge != "" && strings.HasPrefix(ln, badge) {
color := severityColor(comment.Severity)
ln = color + badge + "\033[0m" + ln[len(badge):]
}
fmt.Printf("%s\n", ln)
}
fmt.Println()
}
if len(lines) > 0 {
for _, dl := range lines {
switch dl.Type {
case suggestdiff.DiffAdded:
printDiffLine("+", sanitizeTerminal(dl.Content), "\033[92m", "\033[48;2;0;60;0m")
case suggestdiff.DiffDeleted:
printDiffLine("-", sanitizeTerminal(dl.Content), "\033[91m", "\033[48;2;70;0;0m")
case suggestdiff.DiffContext:
printDiffLine(" ", sanitizeTerminal(dl.Content), "\033[2m", "\033[48;2;38;38;38m")
}
}
}
fmt.Println()
}
// buildBadge renders a compact "[category · severity]" tag for a finding. It returns
// an empty string when neither structured field is present, so text output for findings
// without metadata is unchanged.
func buildBadge(comment model.LlmComment) string {
category := sanitizeTerminal(comment.Category)
severity := sanitizeTerminal(comment.Severity)
switch {
case category != "" && severity != "":
return fmt.Sprintf("[%s · %s]", category, severity)
case category != "":
return fmt.Sprintf("[%s]", category)
case severity != "":
return fmt.Sprintf("[%s]", severity)
default:
return ""
}
}
// severityColor maps a finding severity to an ANSI color used for its badge.
// Unknown or empty severities fall back to dim.
func severityColor(severity string) string {
switch severity {
case "critical":
return "\033[1;91m" // bold bright red
case "high":
return "\033[91m" // bright red
case "medium":
return "\033[93m" // bright yellow
case "low":
return "\033[94m" // bright blue
default:
return "\033[2m" // dim
}
}
// printDiffLine renders a single diff line with colored prefix and background on content.
func printDiffLine(prefix, content, fgColor, bgColor string) {
fmt.Printf("%s%s%s %s%s\033[0m\n", fgColor+bgColor, prefix, "\033[0m"+bgColor, content, "\033[0m")
}
// wrapByRunes splits text into lines that fit within maxWidth **rune** columns.
// Respects existing newlines and wraps at word boundaries.
func wrapByRunes(text string, maxW int) []string {
if text == "" {
return nil
}
var result []string
for _, para := range strings.Split(text, "\n") {
result = append(result, wrapSingleRuneLine(para, maxW)...)
}
return result
}
// wrapSingleRuneLine breaks one paragraph (no newlines) into rune-width-constrained lines.
func wrapSingleRuneLine(line string, maxW int) []string {
runes := []rune(line)
if visibleRunesLen(runes) <= maxW {
return []string{line}
}
var result []string
for len(runes) > 0 {
cut := runeWrapCut(runes, maxW)
result = append(result, string(runes[:cut]))
runes = runes[cut:]
// trim leading spaces of next segment
for len(runes) > 0 && runes[0] == ' ' {
runes = runes[1:]
}
}
return result
}
// runeWrapCut returns a rune index suitable for breaking the line at ~maxW display width.
func runeWrapCut(runes []rune, maxW int) int {
if visibleRunesLen(runes) <= maxW {
return len(runes)
}
best := maxW
if best >= len(runes) {
return len(runes)
}
for i := best; i > 0; i-- {
if runes[i] == ' ' || runes[i] == '\t' {
return i
}
}
return best
}
func visibleRunesLen(runes []rune) int {
n := 0
for _, r := range runes {
if r >= 32 && r != 127 {
n++
}
}
return n
}
func sanitizeTerminal(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r == '\t' || r == '\n' || !unicode.IsControl(r) {
b.WriteRune(r)
}
}
return b.String()
}
func splitToLines(s string) []string {
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
func buildDiffLines(comment model.LlmComment) []suggestdiff.DiffLine {
if comment.SuggestionCode == "" || comment.ExistingCode == "" {
return nil
}
oldLines := splitToLines(comment.ExistingCode)
newLines := splitToLines(comment.SuggestionCode)
return suggestdiff.ComputeLineDiff(oldLines, newLines)
}
type jsonSummary struct {
FilesReviewed int64 `json:"files_reviewed"`
Comments int64 `json:"comments"`
TotalTokens int64 `json:"total_tokens"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
CacheReadTokens int64 `json:"cache_read_tokens,omitempty"`
CacheWriteTokens int64 `json:"cache_write_tokens,omitempty"`
Elapsed string `json:"elapsed"`
}
type jsonToolCalls struct {
Total int64 `json:"total"`
ByTool map[string]int64 `json:"by_tool"`
}
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"`
Comments []model.LlmComment `json:"comments"`
Warnings []agent.AgentWarning `json:"warnings,omitempty"`
ProjectSummary string `json:"project_summary,omitempty"`
}
func outputJSON(comments []model.LlmComment) error {
out := jsonOutput{
Status: "success",
Comments: comments,
}
if len(comments) == 0 {
out.Message = "No comments generated. Looks good to me."
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning,
filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64,
duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string) error {
out := jsonOutput{
Status: "success",
TraceID: traceID,
Comments: comments,
Summary: &jsonSummary{
FilesReviewed: filesReviewed,
Comments: int64(len(comments)),
TotalTokens: totalTokens,
InputTokens: inputTokens,
OutputTokens: outputTokens,
CacheReadTokens: cacheReadTokens,
CacheWriteTokens: cacheWriteTokens,
Elapsed: duration.Round(time.Second).String(),
},
ProjectSummary: projectSummary,
}
var total int64
for _, v := range toolCalls {
total += v
}
byTool := toolCalls
if byTool == nil {
byTool = make(map[string]int64)
}
out.ToolCalls = &jsonToolCalls{
Total: total,
ByTool: byTool,
}
if len(comments) == 0 {
if hasSubtaskErrors(warnings) {
out.Message = "Some files could not be reviewed due to errors."
} else {
out.Message = "No comments generated. Looks good to me."
}
}
if len(warnings) > 0 {
out.Warnings = warnings
if hasSubtaskErrors(warnings) {
out.Status = "completed_with_errors"
} else {
out.Status = "completed_with_warnings"
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
func outputJSONNoFiles(traceID string) error {
out := jsonOutput{
Status: "skipped",
TraceID: traceID,
Message: "No supported files changed.",
Comments: []model.LlmComment{},
ToolCalls: &jsonToolCalls{
ByTool: map[string]int64{},
},
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
func outputPreviewText(p *agent.DiffPreview) {
if p.TotalFiles == 0 {
fmt.Println("No files changed.")
return
}
maxPathLen := 0
for _, e := range p.Entries {
if n := len(sanitizeTerminal(e.Path)); n > maxPathLen {
maxPathLen = n
}
}
if maxPathLen < 20 {
maxPathLen = 20
}
pathFmt := fmt.Sprintf("%%-%ds", maxPathLen)
fmt.Printf("\nPreview: %d file(s) changed | \033[32m+%d\033[0m \033[31m-%d\033[0m\n",
p.TotalFiles, p.TotalInsertions, p.TotalDeletions)
if p.ReviewableCount > 0 {
fmt.Printf("\n\033[1mWill review (%d):\033[0m\n", p.ReviewableCount)
for _, e := range p.Entries {
if !e.WillReview {
continue
}
fmt.Printf(" %s "+pathFmt+" \033[32m+%-4d\033[0m \033[31m-%-4d\033[0m\n",
statusBadge(e.Status), sanitizeTerminal(e.Path), e.Insertions, e.Deletions)
}
}
if p.ExcludedCount > 0 {
fmt.Printf("\n\033[1mExcluded from review (%d):\033[0m\n", p.ExcludedCount)
for _, e := range p.Entries {
if e.WillReview {
continue
}
fmt.Printf(" %s "+pathFmt+" \033[2m(%s)\033[0m\n",
statusBadge(e.Status), sanitizeTerminal(e.Path), sanitizeTerminal(string(e.ExcludeReason)))
}
}
fmt.Println()
}
func statusBadge(status string) string {
switch status {
case "added":
return "\033[32m[A]\033[0m"
case "modified":
return "\033[33m[M]\033[0m"
case "deleted":
return "\033[31m[D]\033[0m"
case "renamed":
return "\033[36m[R]\033[0m"
case "binary":
return "\033[35m[B]\033[0m"
case "scan":
return "\033[34m[S]\033[0m"
default:
return "[?]"
}
}