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

308 lines
11 KiB
Go

package main
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/open-code-review/open-code-review/internal/agent"
"github.com/open-code-review/open-code-review/internal/config/rules"
"github.com/open-code-review/open-code-review/internal/config/template"
"github.com/open-code-review/open-code-review/internal/config/toolsconfig"
"github.com/open-code-review/open-code-review/internal/diff"
"github.com/open-code-review/open-code-review/internal/gitcmd"
"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"
"github.com/open-code-review/open-code-review/internal/tool"
)
// commonContext bundles the state that both `ocr review` and `ocr scan`
// need to load *before* deciding whether to dispatch a preview or a real
// LLM session: a validated template, the resolved repo path, review rules,
// and a shared git subprocess limiter.
type commonContext struct {
Template *template.Template
RepoDir string
Resolver rules.Resolver
FileFilter *rules.FileFilter
GitRunner *gitcmd.Runner
// IsGitRepo reports whether RepoDir is inside a git repository. Always
// true when requireGit was set; may be false when scan accepts non-git
// directories.
IsGitRepo bool
}
// loadCommonContext validates the working directory, loads the embedded
// template, raises MaxToolRequestTimes when maxTools exceeds the default,
// resolves the absolute repo path, loads system review rules, and creates
// the global git subprocess limiter. Both review and scan callers go
// through this so the startup sequence stays consistent.
//
// requireGit=true fails fast when the directory is not a git repo (review
// path: diff concept requires git). requireGit=false allows non-git
// directories (scan path: provider falls back to filepath.Walk).
func loadCommonContext(repoDirInput, rulePath string, maxTools, maxGitProcs int, requireGit bool) (*commonContext, error) {
tpl, err := template.LoadDefault()
if err != nil {
return nil, fmt.Errorf("load default template: %w", err)
}
if maxTools > tpl.MaxToolRequestTimes {
tpl.MaxToolRequestTimes = maxTools
}
if err := tpl.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
repoDir, isGit, err := resolveWorkingDir(repoDirInput, requireGit)
if err != nil {
return nil, err
}
resolver, fileFilter, err := rules.NewResolver(repoDir, rulePath)
if err != nil {
return nil, fmt.Errorf("load rules: %w", err)
}
return &commonContext{
Template: tpl,
RepoDir: repoDir,
Resolver: resolver,
FileFilter: fileFilter,
GitRunner: gitcmd.New(maxGitProcs),
IsGitRepo: isGit,
}, nil
}
// resolveWorkingDir returns (absPath, isGitRepo, err). When requireGit is
// true, returns an error if the directory is not a git repo. When false,
// returns IsGitRepo=false instead of erroring (scan path uses this).
func resolveWorkingDir(input string, requireGit bool) (string, bool, error) {
if input == "" {
wd, err := os.Getwd()
if err != nil {
return "", false, fmt.Errorf("get working directory: %w", err)
}
input = wd
}
absPath, err := filepath.Abs(input)
if err != nil {
return "", false, fmt.Errorf("resolve absolute path: %w", err)
}
if _, statErr := os.Stat(absPath); statErr != nil {
return "", false, fmt.Errorf("stat %s: %w", absPath, statErr)
}
out, err := runGitCmd(absPath, "rev-parse", "--git-dir")
isGit := err == nil && len(out) > 0
if !isGit && requireGit {
return "", false, fmt.Errorf("%s is not a git repository", absPath)
}
// #287: git reports diff and `git show HEAD:<path>` paths relative to the
// repository root, not the current directory. When `ocr review` runs from a
// subdirectory of a monorepo, anchor RepoDir at the git top-level so those
// root-relative paths resolve for both disk reads and git-show reads.
// requireGit is true only for the review path; scan (requireGit=false) keeps
// the CWD so its `git ls-files` walk stays scoped to the subdirectory.
if isGit && requireGit {
// runGitCmdStdout captures stdout only so git stderr notices can't
// pollute the resolved path. --show-toplevel fails (or is empty) when
// there is no work tree — e.g. a bare repo, where --git-dir succeeds so
// isGit is true. Fail loudly there instead of silently reusing the
// subdir, which would reproduce the #287 root-relative-path bug.
top, topErr := runGitCmdStdout(absPath, "rev-parse", "--show-toplevel")
t := strings.TrimSpace(string(top))
if topErr != nil || t == "" {
return "", false, fmt.Errorf("%s is a git repository without a work tree (bare repo?); cannot resolve its top level for review", absPath)
}
absPath = t
}
return absPath, isGit, nil
}
// llmRuntime bundles the LLM-side state both subcommands need once they've
// decided to actually run a session: tool definitions, an app-language
// adjusted template (mutated in place via ApplyLanguage), the LLM client,
// the resolved model name, and a fresh comment collector.
type llmRuntime struct {
Client llm.LLMClient
Model string
PlanToolDefs []llm.ToolDef
MainToolDefs []llm.ToolDef
Collector *tool.CommentCollector
AppCfg *Config
}
// loadLLMRuntime loads tool defs from toolConfigPath, reads the app config
// from the user's default config path (applying the configured language to
// tpl — defaulting when the config file is absent), resolves the LLM
// endpoint (honoring modelOverride from --model when non-empty), and
// returns the runtime bundle. tpl is mutated in place.
func loadLLMRuntime(tpl *template.Template, toolConfigPath, modelOverride string) (*llmRuntime, error) {
toolEntries, err := toolsconfig.Load(toolConfigPath)
if err != nil {
return nil, fmt.Errorf("load tools: %w", err)
}
planToolDefs := agent.BuildToolDefs(toolEntries, true)
mainToolDefs := agent.BuildToolDefs(toolEntries, false)
cfgPath, err := defaultConfigPath()
if err != nil {
return nil, err
}
appCfg, err := LoadAppConfig(cfgPath)
if err != nil {
return nil, fmt.Errorf("load app config: %w", err)
}
// Apply the language directive even when the config file is missing
// (upstream #fix: ApplyLanguage with empty lang falls back to default).
var lang string
if appCfg != nil {
lang = appCfg.Language
}
tpl.ApplyLanguage(lang)
ep, err := llm.ResolveEndpointWithModelOverride(cfgPath, modelOverride)
if err != nil {
return nil, fmt.Errorf("resolve LLM endpoint: %w", err)
}
return &llmRuntime{
Client: llm.NewLLMClient(ep),
Model: ep.Model,
PlanToolDefs: planToolDefs,
MainToolDefs: mainToolDefs,
Collector: tool.NewCommentCollector(),
AppCfg: appCfg,
}, nil
}
// applyCLIExcludes appends user-supplied --exclude patterns (already split
// into a []string) onto cc.FileFilter.Exclude. Creates the FileFilter if
// none was returned by rule.json layers. Idempotent on empty input.
func applyCLIExcludes(cc *commonContext, patterns []string) {
if len(patterns) == 0 {
return
}
if cc.FileFilter == nil {
cc.FileFilter = &rules.FileFilter{}
}
cc.FileFilter.Exclude = append(cc.FileFilter.Exclude, patterns...)
}
// excludeToolDef returns a copy of defs with any entries whose function name
// matches name removed. Used by `ocr scan` to hide tools that don't make
// sense in full-scan mode (e.g. file_read_diff).
func excludeToolDef(defs []llm.ToolDef, name string) []llm.ToolDef {
out := make([]llm.ToolDef, 0, len(defs))
for _, d := range defs {
if d.Function.Name == name {
continue
}
out = append(out, d)
}
return out
}
// quietHandle wraps a stdout.Quiet() restorer so callers can `defer
// q.Restore()` for safety while emitRunResult restores it early when the
// agent-text audience needs the trace summary on the user's terminal.
// Restore is idempotent.
type quietHandle struct {
fn func()
}
// newQuietHandle silences stdout when outputFormat=="json" or
// audience=="agent"; otherwise the returned handle is a no-op restorer.
func newQuietHandle(outputFormat, audience string) *quietHandle {
h := &quietHandle{}
if outputFormat == "json" || audience == "agent" {
h.fn = stdout.Quiet()
}
return h
}
// Restore re-enables stdout. Safe to call multiple times.
func (h *quietHandle) Restore() {
if h == nil || h.fn == nil {
return
}
h.fn()
h.fn = nil
}
// ResultProvider abstracts the metadata both internal/agent.Agent and
// internal/scan.Agent expose post-run, so emitRunResult can finalize
// either without knowing which kind it has.
type ResultProvider interface {
Diffs() []model.Diff
FilesReviewed() int64
TotalInputTokens() int64
TotalOutputTokens() int64
TotalTokensUsed() int64
TotalCacheReadTokens() int64
TotalCacheWriteTokens() int64
Warnings() []agent.AgentWarning
// ProjectSummary is the markdown project-level summary produced by
// scan's PROJECT_SUMMARY_TASK. Empty for review mode and for scans
// that skipped / failed the summary phase.
ProjectSummary() string
ToolCalls() map[string]int64
}
// emitRunResult is the post-LLM-run finalization shared by `ocr review` and
// `ocr scan`: resolves comment line numbers, records telemetry, restores
// stdout early for agent-text audiences so the summary is visible, prints
// the trace summary, and writes the result in the requested format.
//
// q is the silencing handle returned by newQuietHandle; pass nil if no
// silencing was set up (in which case the early restore is a no-op).
func emitRunResult(
ctx context.Context,
ag ResultProvider,
comments []model.LlmComment,
startTime time.Time,
outputFormat, audience string,
q *quietHandle,
) error {
comments = diff.ResolveLineNumbers(comments, ag.Diffs())
duration := time.Since(startTime)
telemetry.RecordReviewDuration(ctx, duration)
if len(comments) > 0 {
telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
}
traceID := telemetry.TraceIDFromContext(ctx)
if outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 {
return outputJSONNoFiles(traceID)
}
// Agent-text audiences need stdout back before PrintTraceSummary so the
// summary line lands on their terminal.
if audience == "agent" && outputFormat != "json" {
q.Restore()
}
if outputFormat != "json" {
telemetry.PrintTraceSummary(ag.FilesReviewed(), int64(len(comments)),
ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration)
}
if outputFormat == "json" {
return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(),
ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(),
ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration,
ag.ProjectSummary(), ag.ToolCalls(), traceID)
}
outputTextWithWarnings(comments, ag.Warnings())
if summary := ag.ProjectSummary(); summary != "" {
fmt.Printf("\n\n──────── Project Summary ────────\n\n%s\n", summary)
}
return nil
}