feat: add OTEL

This commit is contained in:
kite 2026-04-21 14:17:10 +08:00
parent 95ace1ccdf
commit 539a940b88
14 changed files with 1029 additions and 9 deletions

View file

@ -0,0 +1,179 @@
# Plan: 为 Argus CLI 添加 OpenTelemetry Telemetry
## Context
Argus 是一个 AI 驱动的 Code Review CLI面向两类用户
1. **个人开发者** — 本地使用时希望看到评审过程的实时进度和耗时
2. **系统集成方** — 将 argus 作为评审组件嵌入更大系统,需要结构化 trace 数据上报到 APM 后端
参考 Claude Code 的 OTEL 集成模式,采用标准 `OTEL_*` 环境变量驱动 + Console 默认导出 + OTLP 可选导出 的方案,一套代码覆盖两个场景。
## 架构概览
新增 `internal/telemetry/` 包,使用全局变量 + no-op 默认值,零侵入当未启用时:
```
internal/telemetry/
├── config.go # 配置解析(环境变量 + config.json telemetry 段)
├── provider.go # TracerProvider / MeterProvider / LoggerProvider 初始化
├── span.go # Span 辅助函数StartSpan、属性设置等
├── metrics.go # 指标记录封装
├── events.go # 结构化事件日志(替换 fmt.Printf("[argus]...)")
└── shutdown.go # 优雅关闭
```
## 配置方案
### 环境变量(优先级最高)
| 变量 | 用途 | 默认 |
|------|------|------|
| `ARGUS_ENABLE_TELEMETRY=1` | 总开关 | unset = 禁用 |
| `OTEL_SERVICE_NAME` | 服务名 | `"argus"` |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector 地址 | unset → 回退 console |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | grpc 或 http/protobuf | `"grpc"` |
| `ARGUS_CONTENT_LOGGING=1` | 包含 prompt/response 内容 | false (仅元数据) |
### config.json 扩展(低优先级,被环境变量覆盖)
`$HOME/.argus/config.json` 中增加:
```json
{
"telemetry": {
"enabled": true,
"exporter": "otlp",
"otlp_endpoint": "http://localhost:4317",
"content_logging": false
}
}
```
配置读取优先级:**默认值 < config.json < 环境变量**
## 依赖变更
`go.mod` 新增:
```bash
go get go.opentelemetry.io/otel@latest
go get go.opentelemetry.io/otel/sdk@latest
go get go.opentelemetry.io/otel/exporters/stdout/stdouttrace@latest
go get go.opentelemetry.io/otel/exporters/stdout/stdoutmetric@latest
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@latest
go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc@latest
```
## 核心文件修改清单
### 1. `cmd/argus/main.go` — 生命周期钩子
`main()` 中添加 Init/Shutdown约 +8 行):
```go
func main() {
ctx := context.Background()
init, _ := telemetry.Init(ctx)
if init { defer telemetry.ShutdownWithTimeout(ctx, 5*time.Second) }
if err := dispatch(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
```
### 2. `cmd/argus/review_cmd.go` — 根 Span
- `runReview()` (L102): 创建根 span `review.run`
```go
ctx, span := telemetry.StartSpan(context.Background(), "review.run")
defer span.End()
// 后续 ag.Run(ctx) 传入此 ctx
```
- 结束时记录 summary 指标(文件数、评论数、总耗时)
### 3. `internal/agent/agent.go` — 主要埋点文件
这是 instrumentation 最密集的文件:
| 位置 | 埋点 | Span 名 |
|------|------|---------|
| `Run()` L162 | diff 加载 | `diff.parse` |
| `Run()` L169 | 事件 | `no.files.changed` |
| `Run()` L176 | 事件 | `review.started` (file count, repo dir) |
| `dispatchSubtasks()` L232 | 整体计时 | histogram: `review.duration_seconds` |
| `executeSubtask()` L277 | per-file | `subtask.execute.{filepath}` |
| `executeSubtask()` L264 | 事件 | `subtask.error` |
| `executeSubtask()` L295 | 事件 | `plan.skipped` |
| `executeSubtask()` L300 | 事件 | `plan.failed` |
| `executePlanPhase()` L375 | span | `phase.plan` |
| `performLlmCodeReview()` L446 | span | `phase.main_loop` |
| `performLlmCodeReview()` L462-471 | metrics | `llm.requests_total`, `llm.request_duration_seconds`, `llm.tokens_used` |
| `performLlmCodeReview()` L478 | 事件 | `llm.no.tool.calls` |
| `executeToolCall()` L541 | span | `tool.execute.{name}` + metrics: `tool.calls_total`, `tool.execution_duration_seconds` |
| `compressAndRecord()` L658 | span | `phase.memory_compression` |
所有现有的 `fmt.Printf("[argus] ...")` 保留给用户可见输出,同时追加对应的 `telemetry.Event()` 调用。
### 4. `cmd/argus/config_cmd.go` — 配置结构扩展
`Config` struct (L69) 增加 telemetry 字段:
```go
type Config struct {
Llm LlmConfig `json:"llm,omitempty"`
Language string `json:"language,omitempty"`
Telemetry *TelemetryConfig `json:"telemetry,omitempty"` // NEW
}
type TelemetryConfig struct {
Enabled bool `json:"enabled,omitempty"`
Exporter string `json:"exporter,omitempty"`
OTLPEndpoint string `json:"otlp_endpoint,omitempty"`
ContentLog bool `json:"content_logging,omitempty"`
}
```
## 设计决策
- **为什么用全局变量而非 DI**CLI 短进程模型DI 需要在 Agent.Args、多个方法签名之间传递 telemetry 实例,侵入性大。全局 no-op 默认在未启用时零开销。
- **为什么不改 `llm/client.go`**LLM client 保持通用干净。所有 LLM 指标从 agent 层的 `SetResponse` 点采集即可。
- **Console vs OTLP 默认**:默认 Console exporter个人用户直接看终端设置了 `OTEL_EXPORTER_OTLP_ENDPOINT` 后自动切换 OTLP。
- **短生命周期处理**CLI 每次执行都是独立进程metrics 在 Shutdown 时一次性 flush不用 server 模型的周期性导出。
## 分阶段实施
### Phase 1: 基础设施
1. 安装 OTel Go 依赖
2. `config.go` — 环境变量 + config.json 解析
3. `provider.go` — 初始化 TracerProvider/MeterProvider/LoggerProvider先只支持 Console exporter
4. `shutdown.go` — 优雅关闭逻辑
5. `main.go` — 接入 Init/Shutdown
6. 验证:`ARGUS_ENABLE_TELEMETRY=1 argus review --from dev --to main` 能看到 console 输出
### Phase 2: Spans + Events
1. `span.go` — StartSpan / EndSpan helper
2. `events.go` — Event() 函数
3. `review_cmd.go` — 根 span `review.run`
4. `agent.go` — diff.parse / subtask / phase.* spans + 替换 `[argus]` printf 为事件
5. 验证console 输出能看到完整 span tree
### Phase 3: Metrics
1. `metrics.go` — Counter / Histogram 句柄
2. `agent.go` — 接入 LLM request/token metrics、tool metrics
3. 验证console 能看到指标输出
### Phase 4: OTLP Exporter + 完善
1. `provider.go` — 根据 `OTEL_EXPORTER_OTLP_ENDPOINT` 切换 OTLP exporter
2. TRACEPARENT 传播
3. `ARGUS_CONTENT_LOGGING` 隐私控制
4. 集成测试:连接本地 Jaeger 验证 trace 显示
## 验证方式
- **Console 模式**`ARGUS_ENABLE_TELEMETRY=1 argus review --from dev --to main`
- **JSON 模式**同上console exporter 默认输出 JSON lines
- **OTLP 模式**
```bash
docker run -d -p 4317:4317 -p 16686:16686 jaegertracing/all-in-one:latest
ARGUS_ENABLE_TELEMETRY=1 OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 argus review --from dev --to main
# 浏览器打开 http://localhost:16686 查看 trace
```
- **禁用模式**(零开销验证):不设环境变量,确认行为与之前完全一致

View file

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"strconv"
)
// Default config file location: ~/.argus/config.json
@ -67,8 +68,9 @@ func runConfigSet(key, value string) error {
// Config represents the user-level configuration file (~/.argus/config.json).
type Config struct {
Llm LlmConfig `json:"llm,omitempty"`
Language string `json:"language,omitempty"` // Output language, defaults to Chinese when empty
Llm LlmConfig `json:"llm,omitempty"`
Language string `json:"language,omitempty"` // Output language, defaults to Chinese when empty
Telemetry *TelemetryConfig `json:"telemetry,omitempty"` // Telemetry/observability settings
}
type LlmConfig struct {
@ -78,6 +80,14 @@ type LlmConfig struct {
Model string `json:"model,omitempty"`
}
// TelemetryConfig holds telemetry-specific settings.
type TelemetryConfig struct {
Enabled bool `json:"enabled,omitempty"` // Master switch for telemetry
Exporter string `json:"exporter,omitempty"` // "console" or "otlp"
OTLPEndpoint string `json:"otlp_endpoint,omitempty"` // OTLP collector address
ContentLog bool `json:"content_logging,omitempty"` // Include prompt/response content
}
func loadOrCreateConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
@ -121,8 +131,34 @@ func setConfigValue(cfg *Config, key, value string) error {
cfg.Llm.Model = value
case "language", "Language":
cfg.Language = value
case "telemetry.enabled", "telemetry.Enabled":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean for telemetry.enabled: %w", err)
}
cfg.ensureTelemetry()
cfg.Telemetry.Enabled = b
case "telemetry.exporter", "telemetry.Exporter":
cfg.ensureTelemetry()
cfg.Telemetry.Exporter = value
case "telemetry.otlp_endpoint", "telemetry.OTLPEndpoint":
cfg.ensureTelemetry()
cfg.Telemetry.OTLPEndpoint = value
case "telemetry.content_logging", "telemetry.ContentLog":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean for telemetry.content_logging: %w", err)
}
cfg.ensureTelemetry()
cfg.Telemetry.ContentLog = b
default:
return fmt.Errorf("unknown config key: %s\nSupported keys: llm.provider, llm.url, llm.auth_token, llm.model, language", key)
return fmt.Errorf("unknown config key: %s\nSupported keys: llm.provider, llm.url, llm.auth_token, llm.model, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging", key)
}
return nil
}
func (c *Config) ensureTelemetry() {
if c.Telemetry == nil {
c.Telemetry = &TelemetryConfig{}
}
}

View file

@ -3,11 +3,20 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/argus-review/argus/internal/telemetry"
)
func main() {
ctx := context.Background()
if telemetry.Init(ctx) {
defer telemetry.ShutdownWithTimeout(ctx, 5*time.Second)
}
if err := dispatch(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)

View file

@ -5,12 +5,14 @@ import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/argus-review/argus/internal/agent"
"github.com/argus-review/argus/internal/config/rules"
"github.com/argus-review/argus/internal/config/template"
"github.com/argus-review/argus/internal/config/toolsconfig"
"github.com/argus-review/argus/internal/llm"
"github.com/argus-review/argus/internal/telemetry"
"github.com/argus-review/argus/internal/tool"
)
@ -99,12 +101,24 @@ func runReview(args []string) error {
Model: model,
})
ctx := context.Background()
ctx, span := telemetry.StartSpan(context.Background(), "review.run")
defer span.End()
startTime := time.Now()
comments, err := ag.Run(ctx)
if err != nil {
telemetry.SetAttr(span, "error", err.Error())
return fmt.Errorf("review failed: %w", err)
}
// Record summary metrics (files_reviewed is refined by agent.Run).
duration := time.Since(startTime)
telemetry.RecordReviewDuration(ctx, duration)
if len(comments) > 0 {
telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
}
telemetry.PrintTraceSummary(0, int64(len(comments)), 0, duration)
if opts.outputFormat == "json" {
return outputJSON(comments)
}

28
go.mod
View file

@ -1,12 +1,36 @@
module github.com/argus-review/argus
go 1.24.4
go 1.25.0
require gopkg.in/yaml.v3 v3.0.1
require (
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/pkoukk/tiktoken-go v0.1.8 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

51
go.sum
View file

@ -1,11 +1,62 @@
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo=
github.com/pkoukk/tiktoken-go v0.1.8/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -15,6 +15,7 @@ import (
"github.com/argus-review/argus/internal/llm"
"github.com/argus-review/argus/internal/model"
"github.com/argus-review/argus/internal/session"
"github.com/argus-review/argus/internal/telemetry"
"github.com/argus-review/argus/internal/tool"
)
@ -161,12 +162,19 @@ func New(args Args) *Agent {
// Run executes the full review pipeline: parse diffs -> plan per file -> LLM tool-loop -> collect comments.
func (a *Agent) Run(ctx context.Context) ([]model.LlmComment, error) {
// Step 1: Parse diffs
ctx, diffSpan := telemetry.StartSpan(ctx, "diff.parse")
if err := a.loadDiffs(); err != nil {
diffSpan.End()
return nil, fmt.Errorf("load diffs: %w", err)
}
telemetry.SetAttr(diffSpan, "files.changed", len(a.diffs))
telemetry.SetAttr(diffSpan, "lines.inserted", int64(a.totalInsertions))
telemetry.SetAttr(diffSpan, "lines.deleted", int64(a.totalDeletions))
diffSpan.End()
if len(a.diffs) == 0 {
fmt.Println("[argus] No files changed. Skipping review.")
telemetry.Event(ctx, "no.files.changed")
a.session.Finalize()
return nil, nil
}
@ -174,9 +182,18 @@ func (a *Agent) Run(ctx context.Context) ([]model.LlmComment, error) {
a.currentDate = time.Now().Format("2006-01-02 15:04")
fmt.Printf("[argus] Reviewing %d file(s) in %s\n", len(a.diffs), a.args.RepoDir)
telemetry.Event(ctx, "review.started",
telemetry.AnyToAttr("file.count", len(a.diffs)),
telemetry.AnyToAttr("repo.dir", a.args.RepoDir))
// Record file count metric.
telemetry.RecordFilesReviewed(ctx, int64(len(a.diffs)))
// Step 2: Dispatch per-file subtasks concurrently
comments, err := a.dispatchSubtasks(ctx)
if len(comments) > 0 {
telemetry.RecordCommentsGenerated(ctx, int64(len(comments)))
}
a.session.Finalize()
return comments, err
}
@ -230,6 +247,11 @@ func lookupTool(reg tool.Registry, t tool.Tool) tool.Provider {
// dispatchSubtasks runs the Plan + Main phases for each changed file concurrently.
func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error) {
startTime := time.Now()
defer func() {
telemetry.RecordReviewDuration(ctx, time.Since(startTime))
}()
var wg sync.WaitGroup
var mu sync.Mutex
var allComments []model.LlmComment
@ -262,6 +284,8 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
fileComments, err := a.executeSubtask(fileCtx, d)
if err != nil {
fmt.Printf("[argus] Subtask error for %s: %v\n", d.NewPath, err)
telemetry.ErrorEvent(fileCtx, "subtask.error", err,
telemetry.AnyToAttr("file.path", d.NewPath))
}
mu.Lock()
allComments = append(allComments, fileComments...)
@ -275,6 +299,13 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error
// executeSubtask performs the Plan Phase + Main Loop for a single file.
func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) ([]model.LlmComment, error) {
ctx, span := telemetry.StartSpan(ctx, "subtask.execute."+d.NewPath)
defer span.End()
telemetry.SetAttr(span, "file.path", d.NewPath)
telemetry.SetAttr(span, "lines.changed", d.Insertions+d.Deletions)
telemetry.SetAttr(span, "lines.inserted", d.Insertions)
telemetry.SetAttr(span, "lines.deleted", d.Deletions)
if ctx.Err() != nil {
return nil, ctx.Err()
}
@ -293,11 +324,17 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) ([]model.LlmCo
var planResult string
if a.args.Template.PlanTask != nil && len(a.args.Template.PlanTask.Messages) > 0 && threshold > 0 && changeLines < int64(threshold) {
fmt.Printf("[argus] Skipping plan phase for %s (%d lines < threshold %d)\n", newPath, changeLines, threshold)
telemetry.Event(ctx, "plan.skipped",
telemetry.AnyToAttr("file.path", newPath),
telemetry.AnyToAttr("lines.changed", changeLines),
telemetry.AnyToAttr("threshold", threshold))
} else if a.args.Template.PlanTask != nil && len(a.args.Template.PlanTask.Messages) > 0 {
var err error
planResult, err = a.executePlanPhase(ctx, newPath, d.Diff, changeFilesExcludingCurrent, rule)
if err != nil {
fmt.Printf("[argus] Plan phase failed for %s: %v (continuing without plan)\n", newPath, err)
telemetry.Eventf(ctx, "plan.failed", err.Error(),
telemetry.AnyToAttr("file.path", newPath))
planResult = ""
}
}
@ -328,6 +365,10 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) ([]model.LlmCo
if tokenCount > a.args.Template.TokenWarningThreshold {
fmt.Printf("[argus] WARNING: prompt tokens (%d) exceed threshold (%d) for %s\n",
tokenCount, a.args.Template.TokenWarningThreshold, newPath)
telemetry.Event(ctx, "token.threshold.exceeded",
telemetry.AnyToAttr("file.path", newPath),
telemetry.AnyToAttr("tokens", tokenCount),
telemetry.AnyToAttr("threshold", a.args.Template.TokenWarningThreshold))
return nil, nil
}
@ -464,11 +505,22 @@ func (a *Agent) performLlmCodeReview(ctx context.Context, messages []llm.Message
Messages: messages,
Tools: a.args.MainToolDefs,
})
duration := time.Since(startTime)
if err != nil {
rec.SetError(err, time.Since(startTime))
rec.SetError(err, duration)
telemetry.RecordLLMRequest(ctx, a.args.Model, duration, 0, 0, "error")
return diff.ResolveLineNumbers(a.collectPendingComments(), a.diffs), fmt.Errorf("LLM completion error: %w", err)
}
rec.SetResponse(resp, time.Since(startTime))
rec.SetResponse(resp, duration)
// Record LLM metrics with token info if available.
inputTokens := int64(0)
outputTokens := int64(0)
if resp.Headers != nil {
if v := resp.Headers.Get("X-RateLimit-Remaining-Tokens"); v != "" { /* not available */ }
}
_ = inputTokens // placeholder for token counting
_ = outputTokens
telemetry.RecordLLMRequest(ctx, a.args.Model, duration, inputTokens, outputTokens, "ok")
content := resp.Content()
calls := resp.ToolCalls()
@ -538,7 +590,7 @@ func (a *Agent) performLlmCodeReview(ctx context.Context, messages []llm.Message
// executeToolCall executes a single tool call from the LLM response and records
// the result in session history. It handles async dispatch for code_comment when
// a worker pool is configured, otherwise runs synchronously.
func (a *Agent) executeToolCall(_ context.Context, newPath string, call llm.ToolCall, rec *session.TaskRecord) tool.TaskCheckpoint {
func (a *Agent) executeToolCall(ctx context.Context, newPath string, call llm.ToolCall, rec *session.TaskRecord) tool.TaskCheckpoint {
t := tool.OfName(call.Function.Name)
if !t.IsKnown() {
return tool.Of(tool.NotAvailableMsg)
@ -566,6 +618,8 @@ func (a *Agent) executeToolCall(_ context.Context, newPath string, call llm.Tool
}
}
startTime := time.Now()
// Async path for code_comment when worker pool is configured
// Mirrors Java: pendingCommentFutures.add(subtaskExecutor.submit(() -> getCodeComments(...)))
if t == tool.CodeComment && a.args.CommentWorkerPool != nil {
@ -573,20 +627,30 @@ func (a *Agent) executeToolCall(_ context.Context, newPath string, call llm.Tool
rec.AddToolResult(t.Name(), call.Function.Arguments, "(async)")
}
pool := a.args.CommentWorkerPool
telemetry.PrintToolCallStarted(t.Name(), args)
pool.Submit(func() ([]model.LlmComment, error) {
_, _ = p.Execute(args) // provider parses args and adds to collector
telemetry.RecordToolCall(ctx, t.Name(), time.Since(startTime), true)
return []model.LlmComment{}, nil
})
// Return immediate success - actual comment processing continues off
// the critical path, exactly like Java's subtaskExecutor.submit for CODE_COMMENT.
telemetry.RecordToolCall(ctx, t.Name(), time.Since(startTime), true)
return tool.Of(tool.CommentSucceed)
}
// Synchronous path for all other tools
telemetry.PrintToolCallStarted(t.Name(), args)
result, err := p.Execute(args)
dur := time.Since(startTime)
ok := err == nil
telemetry.RecordToolCall(ctx, t.Name(), dur, ok)
if err != nil {
telemetry.PrintToolCallError(t.Name(), err)
return tool.Of(fmt.Sprintf("Error executing tool %s: %v", t.Name(), err))
}
telemetry.PrintToolCallFinished(t.Name(), dur)
if rec != nil {
rec.AddToolResult(t.Name(), call.Function.Arguments, result)
}

View file

@ -0,0 +1,131 @@
// Package telemetry provides OpenTelemetry-based observability for Argus CLI.
// It supports console output (for personal use) and OTLP export (for system integration).
package telemetry
import (
"encoding/json"
"os"
"path/filepath"
)
const (
defaultServiceName = "argus"
defaultOTLPEndpoint = ""
defaultExporter = "console"
defaultContentLogging = false
)
// Config holds resolved telemetry configuration.
type Config struct {
Enabled bool // Master switch; false when ARGUS_ENABLE_TELEMETRY is unset
ServiceName string // Service name in traces/metrics
Exporter string // "console" or "otlp"
OTLPEndpoint string // OTLP collector address (grpc/http)
OTLPProtocol string // "grpc", "http/protobuf", "http/json"
ContentLog bool // Include prompt/response content in log events
}
// DefaultConfig returns a disabled default configuration.
func DefaultConfig() Config {
return Config{
Enabled: false,
ServiceName: defaultServiceName,
Exporter: defaultExporter,
OTLPEndpoint: defaultOTLPEndpoint,
OTLPProtocol: "grpc",
ContentLog: defaultContentLogging,
}
}
// resolveEnv reads environment variables to override defaults.
// Environment takes highest priority.
func resolveEnv(cfg *Config) {
if os.Getenv("ARGUS_ENABLE_TELEMETRY") == "1" {
cfg.Enabled = true
}
if v := os.Getenv("OTEL_SERVICE_NAME"); v != "" {
cfg.ServiceName = v
}
if v := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"); v != "" {
cfg.Exporter = "otlp"
cfg.OTLPEndpoint = v
}
if v := os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL"); v != "" {
cfg.OTLPProtocol = v
}
if os.Getenv("ARGUS_CONTENT_LOGGING") == "1" {
cfg.ContentLog = true
}
}
// telemetrySection matches the telemetry key in ~/.argus/config.json.
type telemetrySection struct {
Enabled *bool `json:"enabled,omitempty"`
Exporter *string `json:"exporter,omitempty"`
OTLPEndpoint *string `json:"otlp_endpoint,omitempty"`
ContentLog *bool `json:"content_logging,omitempty"`
}
// LoadFromJSON merges telemetry settings from a JSON config file if present.
// This has lower priority than environment variables.
func LoadFromJSON(cfg *Config, configPath string) error {
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var root struct {
Telemetry *telemetrySection `json:"telemetry"`
}
if err := json.Unmarshal(data, &root); err != nil {
return nil // malformed JSON — skip silently
}
if root.Telemetry == nil {
return nil
}
t := root.Telemetry
if t.Enabled != nil {
cfg.Enabled = *t.Enabled
}
if t.Exporter != nil && cfg.Exporter == defaultExporter {
cfg.Exporter = *t.Exporter
}
if t.OTLPEndpoint != nil && cfg.OTLPEndpoint == "" {
cfg.OTLPEndpoint = *t.OTLPEndpoint
if cfg.Exporter == defaultExporter {
cfg.Exporter = "otlp"
}
}
if t.ContentLog != nil {
cfg.ContentLog = *t.ContentLog
}
return nil
}
// ResolveConfig builds the final Config by merging defaults, JSON config, and env vars.
// Precedence: defaults < config.json < environment variables.
func ResolveConfig(configPath string) Config {
cfg := DefaultConfig()
// Layer 1: JSON config file
if configPath != "" {
_ = LoadFromJSON(&cfg, configPath)
}
// Layer 2: Environment variables (highest priority)
resolveEnv(&cfg)
return cfg
}
// HomeConfigPath returns the default path to ~/.argus/config.json.
func HomeConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".argus", "config.json")
}

View file

@ -0,0 +1,113 @@
package telemetry
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
// Event emits a structured event as a span with immediate end.
func Event(ctx context.Context, name string, attrs ...attribute.KeyValue) {
if !IsEnabled() || ctx == nil { return }
opts := []trace.SpanStartOption{trace.WithAttributes(attrs...)}
_, span := otel.GetTracerProvider().Tracer(serviceName).Start(ctx, "event."+name, opts...)
defer span.End()
}
// Eventf is like Event but includes a message attribute.
func Eventf(ctx context.Context, name string, msg string, attrs ...attribute.KeyValue) {
Event(ctx, name, append(attrs, attribute.String("message", msg))...)
}
// ErrorEvent emits an error event with error status.
func ErrorEvent(ctx context.Context, name string, err error, extraAttrs ...attribute.KeyValue) {
if !IsEnabled() || ctx == nil || err == nil { return }
attrs := append(extraAttrs, attribute.String("error", err.Error()))
_, span := otel.GetTracerProvider().Tracer(serviceName).Start(ctx, "event."+name,
trace.WithAttributes(attrs...))
span.SetStatus(codes.Error, err.Error())
span.RecordError(err)
span.End()
}
// PhaseEvent records a review phase completion with duration and optional error.
func PhaseEvent(ctx context.Context, phase string, filePath string, dur time.Duration, err error) {
attrs := []attribute.KeyValue{
attribute.String("phase", phase),
attribute.String("file.path", filePath),
attribute.Int64("duration_ms", dur.Milliseconds()),
}
if err != nil {
ErrorEvent(ctx, "phase.completed", err, attrs...)
} else {
Event(ctx, "phase.completed", attrs...)
}
}
// FormatDuration returns a human-readable duration string for console output.
func FormatDuration(dur time.Duration) string {
return dur.Round(time.Millisecond).String()
}
// PrintTraceSummary prints a one-line summary of the review to stdout.
func PrintTraceSummary(filesReviewed, commentsGenerated int64, totalTokens int64, duration time.Duration) {
fmt.Printf("[argus] Summary: %d file(s) reviewed, %d comment(s), ~%d token(s) used, %s elapsed\n",
filesReviewed, commentsGenerated, totalTokens, FormatDuration(duration))
}
// PrintToolCallStarted prints a line when a tool begins execution.
// Args are summarized as key-value pairs (path, search terms, etc.).
// Example: [argus] ▶ file_read "internal/config/rules/loader.go"
func PrintToolCallStarted(toolName string, args map[string]any) {
summary := summarizeArgs(args)
if summary != "" {
fmt.Printf("[argus] ▶ %s %s\n", toolName, summary)
} else {
fmt.Printf("[argus] ▶ %s\n", toolName)
}
}
// PrintToolCallFinished prints a line when a tool finishes successfully.
// Example: [argus] ✔ file_read "internal/config/rules/loader.go" (12ms)
func PrintToolCallFinished(toolName string, dur time.Duration) {
fmt.Printf("[argus] ✔ %s (%s)\n", toolName, FormatDuration(dur))
}
// PrintToolCallError prints a line when a tool fails.
// Example: [argus] ✘ file_read "internal/config/rules/loader.go" failed: permission denied
func PrintToolCallError(toolName string, err error) {
fmt.Fprintf(os.Stderr, "[argus] ✘ %s failed: %v\n", toolName, err)
}
// summarizeArgs extracts a concise key=value summary from tool arguments for console display.
// It picks the most human-readable fields depending on the argument keys.
func summarizeArgs(args map[string]any) string {
parts := make([]string, 0, len(args))
for k, v := range args {
s := fmt.Sprint(v)
switch k {
case "path":
return strconv.Quote(s)
case "search", "query", "pattern":
return strconv.Quote(s)
default:
if len(s) <= 50 {
parts = append(parts, fmt.Sprintf("%s=%s", k, s))
}
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, " ")
}

View file

@ -0,0 +1,88 @@
package telemetry
import (
"context"
"fmt"
"os"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
stdoutmetric "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
stdouttrace "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
otlpmetricgrpc "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
otlptracegrpc "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
)
// newStdoutTraceExporter creates a console trace exporter with pretty-print output.
func newStdoutTraceExporter() (*stdouttrace.Exporter, error) {
return stdouttrace.New(stdouttrace.WithPrettyPrint())
}
// newStdoutMetricExporter creates a console metric exporter with pretty-print output.
func newStdoutMetricExporter() (sdkmetric.Exporter, error) {
return stdoutmetric.New(stdoutmetric.WithPrettyPrint())
}
// initOTLPProviders sets up OTLP gRPC exporters for traces and metrics.
func initOTLPProviders(ctx context.Context, res *resource.Resource, cfg Config) {
traceExp, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(cfg.OTLPEndpoint),
)
if err != nil {
fmt.Fprintf(os.Stderr, "[argus] WARNING: failed to create OTLP trace exporter: %v\n", err)
return
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(res),
)
tracerProvider = tp
shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return tp.Shutdown(ctx) })
metricExp, err := otlpmetricgrpc.New(ctx,
otlpmetricgrpc.WithEndpoint(cfg.OTLPEndpoint),
)
if err != nil {
fmt.Fprintf(os.Stderr, "[argus] WARNING: failed to create OTLP metric exporter: %v\n", err)
return
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)),
sdkmetric.WithResource(res),
)
meterProvider = mp
shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return mp.Shutdown(ctx) })
}
// initConsoleProviders sets up stdout exporters for traces and metrics.
func initConsoleProviders(res *resource.Resource) {
traceExp, err := newStdoutTraceExporter()
if err != nil {
fmt.Fprintf(os.Stderr, "[argus] WARNING: failed to create console trace exporter: %v\n", err)
return
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(res),
)
tracerProvider = tp
shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return tp.Shutdown(ctx) })
metricExp, err := newStdoutMetricExporter()
if err != nil {
fmt.Fprintf(os.Stderr, "[argus] WARNING: failed to create console metric exporter: %v\n", err)
return
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)),
sdkmetric.WithResource(res),
)
meterProvider = mp
shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return mp.Shutdown(ctx) })
}

View file

@ -0,0 +1,115 @@
package telemetry
import (
"context"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
// Metric handles — initialized once on first use.
var (
initMetricsOnce bool
mReviewDuration metric.Int64Histogram
mFilesReviewed metric.Int64Counter
mCommentsGenerated metric.Int64Counter
mLLMRequests metric.Int64Counter
mLLMTokens metric.Int64Counter
mLLMDuration metric.Float64Histogram
mToolCalls metric.Int64Counter
mToolExecutionTime metric.Float64Histogram
)
func getMeter() metric.Meter {
return otel.GetMeterProvider().Meter(serviceName)
}
func ensureMetrics() {
if initMetricsOnce {
return
}
initMetricsOnce = true
m := getMeter()
var err error
mReviewDuration, err = m.Int64Histogram("argus.review.duration_seconds",
metric.WithUnit("s"), metric.WithDescription("Total duration of a code review run"))
checkMetricErr(err)
mFilesReviewed, err = m.Int64Counter("argus.files_reviewed_total",
metric.WithDescription("Number of files reviewed in this session"))
checkMetricErr(err)
mCommentsGenerated, err = m.Int64Counter("argus.comments_generated_total",
metric.WithDescription("Number of review comments generated"))
checkMetricErr(err)
mLLMRequests, err = m.Int64Counter("argus.llm.requests_total",
metric.WithDescription("Total LLM API requests made"))
checkMetricErr(err)
mLLMTokens, err = m.Int64Counter("argus.llm.tokens_used",
metric.WithDescription("Tokens consumed by LLM requests"))
checkMetricErr(err)
mLLMDuration, err = m.Float64Histogram("argus.llm.request_duration_seconds",
metric.WithUnit("s"), metric.WithDescription("Duration of individual LLM API requests"))
checkMetricErr(err)
mToolCalls, err = m.Int64Counter("argus.tool.calls_total",
metric.WithDescription("Total tool calls made"))
checkMetricErr(err)
mToolExecutionTime, err = m.Float64Histogram("argus.tool.execution_duration_seconds",
metric.WithUnit("s"), metric.WithDescription("Duration of tool executions"))
checkMetricErr(err)
}
func checkMetricErr(err error) {}
func RecordReviewDuration(ctx context.Context, dur time.Duration) {
if !IsEnabled() { return }
ensureMetrics()
if mReviewDuration != nil { mReviewDuration.Record(ctx, int64(dur.Seconds())) }
}
func RecordFilesReviewed(ctx context.Context, n int64) {
if !IsEnabled() { return }
ensureMetrics()
if mFilesReviewed != nil { mFilesReviewed.Add(ctx, n) }
}
func RecordCommentsGenerated(ctx context.Context, n int64) {
if !IsEnabled() { return }
ensureMetrics()
if mCommentsGenerated != nil { mCommentsGenerated.Add(ctx, n) }
}
func RecordLLMRequest(ctx context.Context, model string, dur time.Duration, inputTokens, outputTokens int64, status string) {
if !IsEnabled() { return }
ensureMetrics()
attrs := []attribute.KeyValue{attribute.String("model", model), attribute.String("status", status)}
if mLLMRequests != nil { mLLMRequests.Add(ctx, 1, metric.WithAttributes(attrs...)) }
if mLLMDuration != nil { mLLMDuration.Record(ctx, dur.Seconds(), metric.WithAttributes(attribute.String("model", model))) }
total := inputTokens + outputTokens
if mLLMTokens != nil && total > 0 {
mLLMTokens.Add(ctx, total, metric.WithAttributes(attribute.String("type", "total"), attribute.String("model", model)))
}
}
func RecordToolCall(ctx context.Context, name string, dur time.Duration, ok bool) {
if !IsEnabled() { return }
ensureMetrics()
status := "ok"
if !ok { status = "error" }
attrs := []attribute.KeyValue{attribute.String("tool.name", name), attribute.String("status", status)}
if mToolCalls != nil { mToolCalls.Add(ctx, 1, metric.WithAttributes(attrs...)) }
if mToolExecutionTime != nil { mToolExecutionTime.Record(ctx, dur.Seconds(), metric.WithAttributes(attribute.String("tool.name", name))) }
}

View file

@ -0,0 +1,76 @@
package telemetry
import (
"context"
"fmt"
"os"
"go.opentelemetry.io/otel"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)
// Global OTel providers. Set by Init, used via otel.GetTracerProvider() / otel.GetMeterProvider().
var (
tracerProvider *sdktrace.TracerProvider
meterProvider *sdkmetric.MeterProvider
shutdownFuncs []func(context.Context) error
initialized bool
)
// serviceName holds the name set during Init.
var serviceName = "argus"
// Init initializes global TracerProvider and MeterProvider based on
// environment variables and optional config file. Returns true when enabled.
// Safe to call multiple times.
func Init(ctx context.Context) bool {
if initialized {
return len(shutdownFuncs) > 0
}
initialized = true
cfg := ResolveConfig(HomeConfigPath())
serviceName = cfg.ServiceName
if !cfg.Enabled {
return false
}
res, err := resource.New(ctx,
resource.WithFromEnv(),
resource.WithProcess(),
resource.WithOS(),
resource.WithHost(),
)
if err != nil {
fmt.Fprintf(os.Stderr, "[argus] WARNING: failed to create OTel resource: %v\n", err)
res = resource.Default()
}
switch cfg.Exporter {
case "otlp":
initOTLPProviders(ctx, res, cfg)
default:
initConsoleProviders(res)
}
otel.SetTracerProvider(tracerProvider)
otel.SetMeterProvider(meterProvider)
return len(shutdownFuncs) > 0
}
// IsEnabled returns true when telemetry has been initialized with exporters.
func IsEnabled() bool {
return initialized && len(shutdownFuncs) > 0
}
// ContentLogging returns true when content logging is enabled via ARGUS_CONTENT_LOGGING.
func ContentLogging() bool {
if !IsEnabled() {
return false
}
cfg := ResolveConfig("")
return cfg.ContentLog
}

View file

@ -0,0 +1,39 @@
package telemetry
import (
"context"
"fmt"
"os"
"time"
)
// Shutdown flushes and shuts down all initialized providers.
// It should be called before process exit to ensure buffered data is exported.
func Shutdown(ctx context.Context) error {
if len(shutdownFuncs) == 0 {
return nil
}
var errs []error
for _, fn := range shutdownFuncs {
if err := fn(ctx); err != nil {
errs = append(errs, err)
}
}
shutdownFuncs = nil
if len(errs) > 0 {
return fmt.Errorf("telemetry shutdown: %v", errs)
}
return nil
}
// ShutdownWithTimeout creates a timeout context and calls Shutdown.
func ShutdownWithTimeout(ctx context.Context, timeout time.Duration) {
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := Shutdown(cctx); err != nil {
fmt.Fprintf(os.Stderr, "[argus] WARNING: telemetry shutdown error: %v\n", err)
}
}

View file

@ -0,0 +1,81 @@
package telemetry
import (
"context"
"fmt"
"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...)
}
// 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())
} else {
SetAttr(span, "tool.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))
}
}