Skip to content

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

export OPENAI_API_KEY="sk-..."
export CX_OTEL_ENDPOINT="<COLLECTOR_HOST>:4317"

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

go mod tidy && go run main.go

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.