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

265 lines
6.6 KiB
Go

package telemetry
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultConfig(t *testing.T) {
cfg := DefaultConfig()
if cfg.Enabled {
t.Error("expected Enabled=false by default")
}
if cfg.ServiceName != "open-code-review" {
t.Errorf("expected ServiceName=open-code-review, got %s", cfg.ServiceName)
}
if cfg.Exporter != "console" {
t.Errorf("expected Exporter=console, got %s", cfg.Exporter)
}
if cfg.OTLPEndpoint != "" {
t.Errorf("expected empty OTLPEndpoint, got %s", cfg.OTLPEndpoint)
}
if cfg.OTLPProtocol != "grpc" {
t.Errorf("expected OTLPProtocol=grpc, got %s", cfg.OTLPProtocol)
}
if cfg.ContentLog {
t.Error("expected ContentLog=false by default")
}
}
func TestResolveEnv(t *testing.T) {
tests := []struct {
name string
envs map[string]string
check func(t *testing.T, cfg Config)
}{
{
name: "enable telemetry",
envs: map[string]string{"OCR_ENABLE_TELEMETRY": "1"},
check: func(t *testing.T, cfg Config) {
if !cfg.Enabled {
t.Error("expected Enabled=true")
}
},
},
{
name: "custom service name",
envs: map[string]string{"OTEL_SERVICE_NAME": "my-service"},
check: func(t *testing.T, cfg Config) {
if cfg.ServiceName != "my-service" {
t.Errorf("expected ServiceName=my-service, got %s", cfg.ServiceName)
}
},
},
{
name: "otlp endpoint sets exporter to otlp",
envs: map[string]string{"OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317"},
check: func(t *testing.T, cfg Config) {
if cfg.Exporter != "otlp" {
t.Errorf("expected Exporter=otlp, got %s", cfg.Exporter)
}
if cfg.OTLPEndpoint != "localhost:4317" {
t.Errorf("expected OTLPEndpoint=localhost:4317, got %s", cfg.OTLPEndpoint)
}
},
},
{
name: "otlp protocol passthrough",
envs: map[string]string{"OTEL_EXPORTER_OTLP_PROTOCOL": "http/json"},
check: func(t *testing.T, cfg Config) {
if cfg.OTLPProtocol != "http/json" {
t.Errorf("expected OTLPProtocol=http/json, got %s", cfg.OTLPProtocol)
}
},
},
{
name: "content logging enabled",
envs: map[string]string{"OCR_CONTENT_LOGGING": "1"},
check: func(t *testing.T, cfg Config) {
if !cfg.ContentLog {
t.Error("expected ContentLog=true")
}
},
},
{
name: "no env vars leaves defaults",
envs: map[string]string{},
check: func(t *testing.T, cfg Config) {
if cfg.Enabled {
t.Error("expected Enabled=false")
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Clear relevant env vars
envKeys := []string{
"OCR_ENABLE_TELEMETRY", "OTEL_SERVICE_NAME",
"OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_PROTOCOL",
"OCR_CONTENT_LOGGING",
}
for _, k := range envKeys {
t.Setenv(k, "")
os.Unsetenv(k)
}
for k, v := range tc.envs {
t.Setenv(k, v)
}
cfg := DefaultConfig()
resolveEnv(&cfg)
tc.check(t, cfg)
})
}
}
func TestLoadFromJSON(t *testing.T) {
t.Run("nonexistent file returns nil error", func(t *testing.T) {
cfg := DefaultConfig()
err := LoadFromJSON(&cfg, "/nonexistent/path/config.json")
if err != nil {
t.Errorf("expected nil error for missing file, got %v", err)
}
})
t.Run("malformed json returns nil error", func(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.json")
os.WriteFile(path, []byte("{invalid json"), 0644)
cfg := DefaultConfig()
err := LoadFromJSON(&cfg, path)
if err != nil {
t.Errorf("expected nil error for malformed JSON, got %v", err)
}
})
t.Run("no telemetry section", func(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.json")
os.WriteFile(path, []byte(`{"other": "value"}`), 0644)
cfg := DefaultConfig()
err := LoadFromJSON(&cfg, path)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if cfg.Enabled {
t.Error("expected Enabled to remain false")
}
})
t.Run("all fields set", func(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.json")
data := `{
"telemetry": {
"enabled": true,
"exporter": "otlp",
"otlp_endpoint": "collector:4317",
"content_logging": true
}
}`
os.WriteFile(path, []byte(data), 0644)
cfg := DefaultConfig()
err := LoadFromJSON(&cfg, path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !cfg.Enabled {
t.Error("expected Enabled=true")
}
if cfg.Exporter != "otlp" {
t.Errorf("expected Exporter=otlp, got %s", cfg.Exporter)
}
if cfg.OTLPEndpoint != "collector:4317" {
t.Errorf("expected OTLPEndpoint=collector:4317, got %s", cfg.OTLPEndpoint)
}
if !cfg.ContentLog {
t.Error("expected ContentLog=true")
}
})
t.Run("otlp_endpoint auto-sets exporter to otlp", func(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.json")
data := `{"telemetry": {"otlp_endpoint": "localhost:4317"}}`
os.WriteFile(path, []byte(data), 0644)
cfg := DefaultConfig()
err := LoadFromJSON(&cfg, path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Exporter != "otlp" {
t.Errorf("expected Exporter=otlp, got %s", cfg.Exporter)
}
})
t.Run("exporter not overridden if already non-default", func(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.json")
data := `{"telemetry": {"exporter": "otlp"}}`
os.WriteFile(path, []byte(data), 0644)
cfg := DefaultConfig()
cfg.Exporter = "custom"
err := LoadFromJSON(&cfg, path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Exporter != "custom" {
t.Errorf("expected Exporter to remain custom, got %s", cfg.Exporter)
}
})
}
func TestResolveConfig(t *testing.T) {
t.Run("empty path uses only env and defaults", func(t *testing.T) {
envKeys := []string{
"OCR_ENABLE_TELEMETRY", "OTEL_SERVICE_NAME",
"OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_PROTOCOL",
"OCR_CONTENT_LOGGING",
}
for _, k := range envKeys {
t.Setenv(k, "")
os.Unsetenv(k)
}
cfg := ResolveConfig("")
if cfg.Enabled {
t.Error("expected disabled with no env")
}
})
t.Run("env overrides json", func(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.json")
data := `{"telemetry": {"enabled": false}}`
os.WriteFile(path, []byte(data), 0644)
t.Setenv("OCR_ENABLE_TELEMETRY", "1")
cfg := ResolveConfig(path)
if !cfg.Enabled {
t.Error("expected env to override json: Enabled should be true")
}
})
}
func TestHomeConfigPath(t *testing.T) {
path := HomeConfigPath()
if path == "" {
t.Skip("could not determine home dir")
}
if !filepath.IsAbs(path) {
t.Errorf("expected absolute path, got %s", path)
}
if filepath.Base(path) != "config.json" {
t.Errorf("expected config.json at end, got %s", filepath.Base(path))
}
}