Go — manual instrumentation
No auto-instrumentation for GenAI in Go. Manual spans with new semconv + raw net/http to OpenAI.
Before you start
These examples export to a local OpenTelemetry Collector over OTLP/gRPC. Deploy the collector first — see Code examples → Deploy an OpenTelemetry Collector.
go.mod
module go-genai-manual-demo
go 1.22
require (
go.opentelemetry.io/otel v1.34.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0
go.opentelemetry.io/otel/sdk v1.34.0
go.opentelemetry.io/otel/trace v1.34.0
)
Environment variables
main.go
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.opentelemetry.io/otel/trace"
)
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
User string `json:"user,omitempty"`
}
type chatChoice struct {
Message chatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type chatUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
}
type chatResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []chatChoice `json:"choices"`
Usage chatUsage `json:"usage"`
}
type msgPart struct {
Type string `json:"type"`
Content string `json:"content"`
}
type semconvMsg struct {
Role string `json:"role"`
Parts []msgPart `json:"parts"`
}
// --- OTel setup: configure OTLP exporter and tracer provider ---
func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
endpoint := os.Getenv("CX_OTEL_ENDPOINT")
if endpoint == "" {
endpoint = "<COLLECTOR_HOST>:4317"
}
exporter, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(endpoint),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, err
}
res, _ := resource.Merge(resource.Default(),
resource.NewWithAttributes(semconv.SchemaURL,
semconv.ServiceName("go-genai-manual-demo")))
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter), sdktrace.WithResource(res))
otel.SetTracerProvider(tp)
return tp, nil
}
// --- Your app logic with manual OTel GenAI spans ---
func chatCompletion(ctx context.Context, tracer trace.Tracer) (*chatResponse, error) {
model := "gpt-4o-mini"
user := "user-42"
msgs := []chatMessage{
{Role: "system", Content: "You are a concise assistant."},
{Role: "user", Content: "What is OpenTelemetry?"},
}
inputMsgs := make([]semconvMsg, len(msgs))
for i, m := range msgs {
inputMsgs[i] = semconvMsg{Role: m.Role,
Parts: []msgPart{{Type: "text", Content: m.Content}}}
}
inputJSON, _ := json.Marshal(inputMsgs)
ctx, span := tracer.Start(ctx, "chat "+model,
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
attribute.String("gen_ai.operation.name", "chat"),
attribute.String("gen_ai.provider.name", "openai"),
attribute.String("gen_ai.request.model", model),
attribute.String("gen_ai.request.user", user),
attribute.String("gen_ai.input.messages", string(inputJSON)),
attribute.String("server.address", "api.openai.com"),
attribute.Int("server.port", 443),
))
defer span.End()
body, _ := json.Marshal(chatRequest{Model: model, Messages: msgs, User: user})
req, _ := http.NewRequestWithContext(ctx, "POST",
"https://api.openai.com/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENAI_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
span.RecordError(err)
return nil, err
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)
var chatResp chatResponse
json.Unmarshal(respBytes, &chatResp)
finishReasons := make([]string, len(chatResp.Choices))
outMsgs := make([]semconvMsg, len(chatResp.Choices))
for i, c := range chatResp.Choices {
finishReasons[i] = c.FinishReason
outMsgs[i] = semconvMsg{Role: c.Message.Role,
Parts: []msgPart{{Type: "text", Content: c.Message.Content}}}
}
outJSON, _ := json.Marshal(outMsgs)
span.SetAttributes(
attribute.String("gen_ai.response.id", chatResp.ID),
attribute.String("gen_ai.response.model", chatResp.Model),
attribute.StringSlice("gen_ai.response.finish_reasons", finishReasons),
attribute.Int("gen_ai.usage.input_tokens", chatResp.Usage.PromptTokens),
attribute.Int("gen_ai.usage.output_tokens", chatResp.Usage.CompletionTokens),
attribute.String("gen_ai.output.messages", string(outJSON)))
return &chatResp, nil
}
func main() {
ctx := context.Background()
tp, err := initTracer(ctx)
if err != nil {
log.Fatal(err)
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
tp.Shutdown(ctx)
}()
resp, err := chatCompletion(ctx, otel.Tracer("genai-manual-demo"))
if err != nil {
log.Fatal(err)
}
for _, c := range resp.Choices {
fmt.Printf("%s: %s\n", c.FinishReason, c.Message.Content)
}
fmt.Printf("Tokens: %d in, %d out\n", resp.Usage.PromptTokens, resp.Usage.CompletionTokens)
}
Build and run
Tip
The Go OTel SDK does not export gen_ai.* constants yet — all keys are raw strings. Use gen_ai.provider.name (new semconv); the deprecated gen_ai.system is not needed.
Next steps
Look up which open-source library to use for your provider in Compatibility matrix.
Theme
Light