mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
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
This commit is contained in:
parent
14f1c22a60
commit
46dde274d8
21 changed files with 471 additions and 24 deletions
|
|
@ -22,6 +22,8 @@ import (
|
|||
"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"
|
||||
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
)
|
||||
|
||||
// AgentWarning is re-exported from llmloop for backwards compatibility with
|
||||
|
|
@ -487,7 +489,17 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
err := a.runner.RunPerFile(ctx, messages, newPath)
|
||||
err := func() error {
|
||||
ctx, mainSpan := telemetry.StartSpan(ctx, "main.loop")
|
||||
defer mainSpan.End()
|
||||
telemetry.SetAttr(mainSpan, "file.path", newPath)
|
||||
if err := a.runner.RunPerFile(ctx, messages, newPath); err != nil {
|
||||
mainSpan.SetStatus(codes.Error, err.Error())
|
||||
mainSpan.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if err == nil {
|
||||
// REVIEW_FILTER_TASK runs after the main loop and decides which of the
|
||||
// just-collected comments to drop. It needs to see comments produced by
|
||||
|
|
@ -503,6 +515,10 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error {
|
|||
// executeReviewFilter runs the REVIEW_FILTER_TASK to remove comments that are
|
||||
// provably incorrect based solely on the diff. Errors are logged and silently ignored.
|
||||
func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath string) {
|
||||
ctx, span := telemetry.StartSpan(ctx, "review_filter.execute")
|
||||
defer span.End()
|
||||
telemetry.SetAttr(span, "file.path", newPath)
|
||||
|
||||
ft := a.args.Template.ReviewFilterTask
|
||||
if ft == nil || len(ft.Messages) == 0 {
|
||||
return
|
||||
|
|
@ -512,6 +528,7 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s
|
|||
if len(comments) == 0 {
|
||||
return
|
||||
}
|
||||
telemetry.SetAttr(span, "comments.before", len(comments))
|
||||
|
||||
commentsJSON := buildFilterCommentsJSON(comments)
|
||||
|
||||
|
|
@ -534,20 +551,33 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s
|
|||
rec := fs.AppendTaskRecord(session.ReviewFilterTask, messages)
|
||||
startTime := time.Now()
|
||||
|
||||
_, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model)
|
||||
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
|
||||
Model: a.args.Model,
|
||||
Messages: messages,
|
||||
MaxTokens: a.args.Template.MaxTokens,
|
||||
})
|
||||
duration := time.Since(startTime)
|
||||
if err != nil {
|
||||
rec.SetError(err, time.Since(startTime))
|
||||
telemetry.RecordLLMResult(llmSpan, duration, 0, err)
|
||||
llmSpan.End()
|
||||
rec.SetError(err, duration)
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Review filter failed for %s: %v\n", newPath, err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
return
|
||||
}
|
||||
rec.SetResponse(resp, time.Since(startTime))
|
||||
var totalTokens int64
|
||||
if resp.Usage != nil {
|
||||
totalTokens = resp.Usage.TotalTokens
|
||||
}
|
||||
telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
|
||||
llmSpan.End()
|
||||
rec.SetResponse(resp, duration)
|
||||
a.runner.RecordUsage(resp.Usage)
|
||||
|
||||
indices := parseFilterResponse(resp.Content(), len(comments))
|
||||
telemetry.SetAttr(span, "comments.filtered", len(indices))
|
||||
if len(indices) == 0 {
|
||||
return
|
||||
}
|
||||
|
|
@ -722,6 +752,10 @@ func (a *Agent) extFromPath(path string) string {
|
|||
// executePlanPhase runs the plan task for a single file, sending template messages
|
||||
// with resolved placeholders and collecting the LLM response as plan guidance.
|
||||
func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFiles, rule string) (string, error) {
|
||||
ctx, span := telemetry.StartSpan(ctx, "plan.execute")
|
||||
defer span.End()
|
||||
telemetry.SetAttr(span, "file.path", newPath)
|
||||
|
||||
pt := a.args.Template.PlanTask
|
||||
messages := make([]llm.Message, 0, len(pt.Messages))
|
||||
for _, m := range pt.Messages {
|
||||
|
|
@ -740,16 +774,28 @@ func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFi
|
|||
rec := fs.AppendTaskRecord(session.PlanTask, messages)
|
||||
startTime := time.Now()
|
||||
|
||||
_, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model)
|
||||
resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
|
||||
Model: a.args.Model,
|
||||
Messages: messages,
|
||||
MaxTokens: a.args.Template.MaxTokens,
|
||||
})
|
||||
duration := time.Since(startTime)
|
||||
if err != nil {
|
||||
rec.SetError(err, time.Since(startTime))
|
||||
telemetry.RecordLLMResult(llmSpan, duration, 0, err)
|
||||
llmSpan.End()
|
||||
rec.SetError(err, duration)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
return "", fmt.Errorf("plan request: %w", err)
|
||||
}
|
||||
rec.SetResponse(resp, time.Since(startTime))
|
||||
var totalTokens int64
|
||||
if resp.Usage != nil {
|
||||
totalTokens = resp.Usage.TotalTokens
|
||||
}
|
||||
telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
|
||||
llmSpan.End()
|
||||
rec.SetResponse(resp, duration)
|
||||
a.runner.RecordUsage(resp.Usage)
|
||||
fmt.Fprintf(stdout.Writer(), "[ocr] Plan completed for %s\n", newPath)
|
||||
return resp.Content(), nil
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"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
|
||||
|
|
@ -44,15 +45,26 @@ func ReLocateComment(
|
|||
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 == "" {
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
rec := fs.AppendTaskRecord(session.MainTask, append([]llm.Message(nil), messages...))
|
||||
startTime := time.Now()
|
||||
|
||||
_, llmSpan := telemetry.StartLLMSpan(ctx, r.deps.Model)
|
||||
resp, err := r.deps.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{
|
||||
Model: r.deps.Model,
|
||||
Messages: messages,
|
||||
|
|
@ -172,6 +173,8 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
duration := time.Since(startTime)
|
||||
if err != nil {
|
||||
rec.SetError(err, duration)
|
||||
telemetry.RecordLLMResult(llmSpan, duration, 0, err)
|
||||
llmSpan.End()
|
||||
telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, 0, "error")
|
||||
return fmt.Errorf("LLM completion error: %w", err)
|
||||
}
|
||||
|
|
@ -184,6 +187,8 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath
|
|||
atomic.AddInt64(&r.totalCacheReadTokens, resp.Usage.CacheReadTokens)
|
||||
atomic.AddInt64(&r.totalCacheWriteTokens, resp.Usage.CacheWriteTokens)
|
||||
}
|
||||
telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil)
|
||||
llmSpan.End()
|
||||
telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, totalTokens, "ok")
|
||||
|
||||
content := resp.Content()
|
||||
|
|
@ -272,14 +277,19 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
|
|||
return tool.Of(fmt.Sprintf("Error parsing tool arguments for %s: %v", call.Function.Name, err))
|
||||
}
|
||||
telemetry.PrintToolCallStarted(call.Function.Name, dynArgs)
|
||||
_, toolSpan := telemetry.StartToolSpan(ctx, call.Function.Name)
|
||||
startTime := time.Now()
|
||||
result, err := p.Execute(ctx, dynArgs)
|
||||
dur := time.Since(startTime)
|
||||
if err != nil {
|
||||
telemetry.RecordToolResult(toolSpan, call.Function.Name, dur.Milliseconds(), err)
|
||||
toolSpan.End()
|
||||
telemetry.RecordToolCall(ctx, call.Function.Name, dur, false)
|
||||
telemetry.PrintToolCallError(call.Function.Name, err)
|
||||
return tool.Of(fmt.Sprintf("Error executing tool %s: %v", call.Function.Name, err))
|
||||
}
|
||||
telemetry.RecordToolResult(toolSpan, call.Function.Name, dur.Milliseconds(), nil)
|
||||
toolSpan.End()
|
||||
telemetry.RecordToolCall(ctx, call.Function.Name, dur, true)
|
||||
telemetry.PrintToolCallFinished(call.Function.Name, dur)
|
||||
if rec != nil {
|
||||
|
|
@ -314,10 +324,14 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
|
|||
|
||||
if t == tool.CodeComment {
|
||||
telemetry.PrintToolCallStarted(t.Name(), args)
|
||||
_, toolSpan := telemetry.StartToolSpan(ctx, t.Name())
|
||||
|
||||
comments, errMsg := tool.ParseComments(args)
|
||||
if errMsg != "" {
|
||||
telemetry.RecordToolCall(ctx, t.Name(), time.Since(startTime), false)
|
||||
dur := time.Since(startTime)
|
||||
telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), fmt.Errorf("%s", errMsg))
|
||||
toolSpan.End()
|
||||
telemetry.RecordToolCall(ctx, t.Name(), dur, false)
|
||||
return tool.Of(errMsg)
|
||||
}
|
||||
|
||||
|
|
@ -361,8 +375,13 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
|
|||
asyncCtx := context.WithoutCancel(ctx)
|
||||
toolName := t.Name()
|
||||
pool.Submit(func() ([]model.LlmComment, error) {
|
||||
defer func() {
|
||||
dur := time.Since(startTime)
|
||||
telemetry.RecordToolResult(toolSpan, toolName, dur.Milliseconds(), nil)
|
||||
toolSpan.End()
|
||||
telemetry.PrintToolCallFinished(toolName, dur)
|
||||
}()
|
||||
resolveAndCollect(asyncCtx)
|
||||
telemetry.PrintToolCallFinished(toolName, time.Since(startTime))
|
||||
return []model.LlmComment{}, nil
|
||||
})
|
||||
telemetry.RecordToolCall(asyncCtx, toolName, time.Since(startTime), true)
|
||||
|
|
@ -371,6 +390,8 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
|
|||
|
||||
resolveAndCollect(ctx)
|
||||
dur := time.Since(startTime)
|
||||
telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), nil)
|
||||
toolSpan.End()
|
||||
telemetry.RecordToolCall(ctx, t.Name(), dur, true)
|
||||
telemetry.PrintToolCallFinished(t.Name(), dur)
|
||||
if rec != nil {
|
||||
|
|
@ -381,9 +402,12 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T
|
|||
|
||||
// Synchronous path for all other tools
|
||||
telemetry.PrintToolCallStarted(t.Name(), args)
|
||||
_, toolSpan := telemetry.StartToolSpan(ctx, t.Name())
|
||||
result, err := p.Execute(ctx, args)
|
||||
dur := time.Since(startTime)
|
||||
ok := err == nil
|
||||
telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), err)
|
||||
toolSpan.End()
|
||||
telemetry.RecordToolCall(ctx, t.Name(), dur, ok)
|
||||
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ func TestResolveEnv(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "otlp protocol override",
|
||||
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" {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import (
|
|||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
|
||||
otlpmetricgrpc "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
otlpmetrichttp "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
|
||||
otlptracegrpc "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
otlptracehttp "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
stdoutmetric "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
|
||||
stdouttrace "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
|
||||
)
|
||||
|
|
@ -27,11 +29,11 @@ func newStdoutMetricExporter() (sdkmetric.Exporter, error) {
|
|||
}
|
||||
|
||||
// parseOTLPEndpoint strips a http:// or https:// scheme from the endpoint and
|
||||
// reports whether the connection should be insecure (plaintext gRPC).
|
||||
// reports whether the connection should be plaintext (insecure).
|
||||
// Scheme matching is case-insensitive per RFC 3986. A bare host:port (no
|
||||
// scheme) is left unchanged and defaults to TLS. Any trailing slash left
|
||||
// over from a URL-style endpoint (e.g. "http://localhost:4317/") is
|
||||
// trimmed, since otlptracegrpc/otlpmetricgrpc's WithEndpoint expects a bare
|
||||
// trimmed, since both gRPC and HTTP exporters' WithEndpoint expects a bare
|
||||
// host:port with no path.
|
||||
func parseOTLPEndpoint(endpoint string) (addr string, insecure bool) {
|
||||
switch {
|
||||
|
|
@ -44,8 +46,21 @@ func parseOTLPEndpoint(endpoint string) (addr string, insecure bool) {
|
|||
}
|
||||
}
|
||||
|
||||
// initOTLPProviders sets up OTLP gRPC exporters for traces and metrics.
|
||||
// initOTLPProviders dispatches to the gRPC or HTTP exporter based on cfg.OTLPProtocol.
|
||||
func initOTLPProviders(ctx context.Context, res *resource.Resource, cfg Config) {
|
||||
switch cfg.OTLPProtocol {
|
||||
case "http/protobuf", "http/json":
|
||||
initOTLPHTTPProviders(ctx, res, cfg)
|
||||
case "", "grpc":
|
||||
initOTLPGRPCProviders(ctx, res, cfg)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: unsupported OTLP protocol %q, falling back to gRPC\n", cfg.OTLPProtocol)
|
||||
initOTLPGRPCProviders(ctx, res, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// initOTLPGRPCProviders sets up OTLP gRPC exporters for traces and metrics.
|
||||
func initOTLPGRPCProviders(ctx context.Context, res *resource.Resource, cfg Config) {
|
||||
addr, insecure := parseOTLPEndpoint(cfg.OTLPEndpoint)
|
||||
|
||||
traceOpts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(addr)}
|
||||
|
|
@ -83,6 +98,45 @@ func initOTLPProviders(ctx context.Context, res *resource.Resource, cfg Config)
|
|||
shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return mp.Shutdown(ctx) })
|
||||
}
|
||||
|
||||
// initOTLPHTTPProviders sets up OTLP HTTP exporters for traces and metrics.
|
||||
func initOTLPHTTPProviders(ctx context.Context, res *resource.Resource, cfg Config) {
|
||||
addr, insecure := parseOTLPEndpoint(cfg.OTLPEndpoint)
|
||||
|
||||
traceOpts := []otlptracehttp.Option{otlptracehttp.WithEndpoint(addr)}
|
||||
if insecure {
|
||||
traceOpts = append(traceOpts, otlptracehttp.WithInsecure())
|
||||
}
|
||||
traceExp, err := otlptracehttp.New(ctx, traceOpts...)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create OTLP HTTP 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) })
|
||||
|
||||
metricOpts := []otlpmetrichttp.Option{otlpmetrichttp.WithEndpoint(addr)}
|
||||
if insecure {
|
||||
metricOpts = append(metricOpts, otlpmetrichttp.WithInsecure())
|
||||
}
|
||||
metricExp, err := otlpmetrichttp.New(ctx, metricOpts...)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create OTLP HTTP 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()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
package telemetry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
|
|
@ -103,3 +107,94 @@ func TestInitOTLPProviders_InvalidEndpoint(t *testing.T) {
|
|||
t.Error("expected tracerProvider to be set (OTLP exporter creation is lazy)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitOTLPHTTPProviders_InvalidEndpoint(t *testing.T) {
|
||||
tracerProvider = nil
|
||||
meterProvider = nil
|
||||
shutdownFuncs = nil
|
||||
defer func() {
|
||||
for _, fn := range shutdownFuncs {
|
||||
_ = fn(context.Background())
|
||||
}
|
||||
tracerProvider = nil
|
||||
meterProvider = nil
|
||||
shutdownFuncs = nil
|
||||
}()
|
||||
|
||||
cfg := Config{
|
||||
Exporter: "otlp",
|
||||
OTLPEndpoint: "localhost:0",
|
||||
OTLPProtocol: "http/protobuf",
|
||||
}
|
||||
initOTLPHTTPProviders(context.Background(), resource.Default(), cfg)
|
||||
if tracerProvider == nil {
|
||||
t.Error("expected tracerProvider to be set (OTLP HTTP exporter creation is lazy)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitOTLPProviders_ProtocolRouting(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
protocol string
|
||||
wantWarning bool // default branch emits a gRPC fallback warning
|
||||
}{
|
||||
{"grpc default", "grpc", false},
|
||||
{"empty defaults to grpc", "", false},
|
||||
{"http/protobuf routes to http", "http/protobuf", false},
|
||||
{"http/json routes to http", "http/json", false},
|
||||
{"unknown falls back to grpc", "foo", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tracerProvider = nil
|
||||
meterProvider = nil
|
||||
shutdownFuncs = nil
|
||||
|
||||
// Capture stderr to assert on the fallback warning.
|
||||
oldStderr := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stderr = w
|
||||
|
||||
cfg := Config{
|
||||
Exporter: "otlp",
|
||||
OTLPEndpoint: "localhost:0",
|
||||
OTLPProtocol: tc.protocol,
|
||||
}
|
||||
initOTLPProviders(context.Background(), resource.Default(), cfg)
|
||||
w.Close()
|
||||
os.Stderr = oldStderr
|
||||
|
||||
defer func() {
|
||||
for _, fn := range shutdownFuncs {
|
||||
_ = fn(context.Background())
|
||||
}
|
||||
tracerProvider = nil
|
||||
meterProvider = nil
|
||||
shutdownFuncs = nil
|
||||
}()
|
||||
|
||||
if tracerProvider == nil {
|
||||
t.Error("expected tracerProvider to be set")
|
||||
}
|
||||
if meterProvider == nil {
|
||||
t.Error("expected meterProvider to be set")
|
||||
}
|
||||
if len(shutdownFuncs) != 2 {
|
||||
t.Errorf("expected 2 shutdown funcs, got %d", len(shutdownFuncs))
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
stderrOut := buf.String()
|
||||
if tc.wantWarning {
|
||||
if !strings.Contains(stderrOut, "falling back to gRPC") {
|
||||
t.Errorf("expected gRPC fallback warning in stderr, got %q", stderrOut)
|
||||
}
|
||||
} else {
|
||||
if strings.Contains(stderrOut, "falling back to gRPC") {
|
||||
t.Errorf("expected no fallback warning, got %q", stderrOut)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package telemetry
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
|
@ -23,6 +24,16 @@ func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption)
|
|||
return getTracer().Start(ctx, name, opts...)
|
||||
}
|
||||
|
||||
// TraceIDFromContext returns the hex-encoded trace ID of the span carried by
|
||||
// ctx, or "" if ctx carries no valid span (e.g. telemetry is disabled).
|
||||
func TraceIDFromContext(ctx context.Context) string {
|
||||
sc := trace.SpanContextFromContext(ctx)
|
||||
if !sc.TraceID().IsValid() {
|
||||
return ""
|
||||
}
|
||||
return sc.TraceID().String()
|
||||
}
|
||||
|
||||
// EndSpan ends the span and records error status if present.
|
||||
func EndSpan(span trace.Span, err error) {
|
||||
if err != nil {
|
||||
|
|
@ -69,11 +80,35 @@ func RecordToolResult(span trace.Span, toolName string, durationMs int64, err er
|
|||
SetAttr(span, "tool.status", "error")
|
||||
SetAttr(span, "tool.error", err.Error())
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
} else {
|
||||
SetAttr(span, "tool.status", "ok")
|
||||
}
|
||||
}
|
||||
|
||||
// StartLLMSpan creates a span for an LLM request with standard attributes.
|
||||
func StartLLMSpan(ctx context.Context, model string) (context.Context, trace.Span) {
|
||||
return StartSpan(ctx, "llm.request",
|
||||
trace.WithAttributes(attribute.String("llm.model", model)))
|
||||
}
|
||||
|
||||
// RecordLLMResult sets the outcome of an LLM request on the span.
|
||||
func RecordLLMResult(span trace.Span, duration time.Duration, totalTokens int64, err error) {
|
||||
if span == nil {
|
||||
return
|
||||
}
|
||||
SetAttr(span, "llm.duration_ms", duration.Milliseconds())
|
||||
SetAttr(span, "llm.total_tokens", totalTokens)
|
||||
if err != nil {
|
||||
SetAttr(span, "llm.status", "error")
|
||||
SetAttr(span, "llm.error", err.Error())
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
span.RecordError(err)
|
||||
} else {
|
||||
SetAttr(span, "llm.status", "ok")
|
||||
}
|
||||
}
|
||||
|
||||
// AnyToAttr converts an arbitrary value to an OTel attribute.KeyValue.
|
||||
func AnyToAttr(k string, v interface{}) attribute.KeyValue {
|
||||
switch val := v.(type) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
|
@ -138,3 +139,34 @@ func TestRecordToolResult_NilSpan(t *testing.T) {
|
|||
RecordToolResult(nil, "tool", 100, nil)
|
||||
RecordToolResult(nil, "tool", 100, fmt.Errorf("err"))
|
||||
}
|
||||
|
||||
func TestStartLLMSpan(t *testing.T) {
|
||||
setupEnabledTelemetry(t)
|
||||
ctx, span := StartLLMSpan(context.Background(), "qwen-max")
|
||||
if !span.SpanContext().IsValid() {
|
||||
t.Error("expected valid span context")
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Error("expected non-nil context")
|
||||
}
|
||||
span.End()
|
||||
}
|
||||
|
||||
func TestRecordLLMResult_Success(t *testing.T) {
|
||||
setupEnabledTelemetry(t)
|
||||
_, span := StartSpan(context.Background(), "test.llm")
|
||||
RecordLLMResult(span, 500*time.Millisecond, 1200, nil)
|
||||
span.End()
|
||||
}
|
||||
|
||||
func TestRecordLLMResult_Error(t *testing.T) {
|
||||
setupEnabledTelemetry(t)
|
||||
_, span := StartSpan(context.Background(), "test.llm")
|
||||
RecordLLMResult(span, 100*time.Millisecond, 0, errors.New("timeout"))
|
||||
span.End()
|
||||
}
|
||||
|
||||
func TestRecordLLMResult_NilSpan(t *testing.T) {
|
||||
RecordLLMResult(nil, 100*time.Millisecond, 0, nil)
|
||||
RecordLLMResult(nil, 100*time.Millisecond, 0, fmt.Errorf("err"))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue