Best practices for custom instrumentation of Go LLM applications

更新时间:
复制 MD 格式

After you integrate Application Monitoring of Application Real-Time Monitoring Service (ARMS), probes automatically instrument common AI frameworks and collect trace data without code changes. To capture business method execution in your traces, you can use loongsuite-go/util-genai and the OpenTelemetry Go SDK to add custom instrumentation. This topic describes how to implement custom instrumentation and custom Attributes with util-genai and the OpenTelemetry Go SDK.

Supported AI components and frameworks

The ARMS probe supports the following AI components and frameworks: Go components and frameworks supported by ARMS Application Monitoring

Prerequisites

  • ARMS Application Monitoring is integrated. If the Go application already has the Golang Agent installed, the probe automatically covers common LLM SDK calls. The manual instrumentation described in this topic can coexist with automatic instrumentation without conflicts.

  • If you use the Go probe for integration, you do not need to report data yourself. If you only use the SDK, obtain the reporting endpoint information: log on to the Cloud Monitor 2.0 console, select the target workspace, and in the left-side navigation pane click Integration Center. In the Server-side Applications area, click the OpenTelemetry card. Click the copy icon next to LicenseKey to obtain OTEL_EXPORTER_OTLP_HEADERS and OTEL_EXPORTER_OTLP_ENDPOINT for data reporting.

  • Local Go version >=1.24.0 (as declared in the util-genai go.mod).

Dependencies

util-genai is published as part of the main repository github.com/alibaba/loongsuite-go, with the module path github.com/alibaba/loongsuite-go/util-genai. Run the following commands in your project:

go get github.com/alibaba/loongsuite-go/util-genai@latest
go get go.opentelemetry.io/otel@v1.40.0
go get go.opentelemetry.io/otel/sdk@v1.40.0

util-genai depends only on the OpenTelemetry API (otel / otel/trace / otel/metric / otel/log) and does not import the SDK. TracerProvider, MeterProvider, LoggerProvider, Exporter, and Resource are set up by the caller. Version 0.1.0 implements the core attributes, metrics, events, and span naming rules of the OpenTelemetry GenAI Semantic Conventions and can be used alongside ARMS probe auto-instrumentation. For more information, see the repository root README.

Instrumentation capabilities

With util-genai and the OpenTelemetry Go SDK you can perform the following operations:

  • Create GenAI semantic Spans (LLM, Agent, Tool, Embedding, Retrieve, Rerank).

  • Generate custom business Spans through the OpenTelemetry SDK.

  • Add custom Attributes to Spans.

  • Retrieve the current Trace context and read TraceID / SpanID.

  • Automatically record GenAI Metrics (duration, token usage, time to first token, etc.).

  • Send GenAI events via the OTel Logs API (gen_ai.client.inference.operation.details, etc.).

  • Offload large payloads (prompt/response) asynchronously to external storage via CompletionHook (content-addressed SHA-256 deduplication).

Terminology

  • Span: A specific operation within a request, such as an LLM call or a tool execution.

  • SpanContext: The context of a request trace, containing TraceID, SpanID, and other identifiers.

  • Attribute: An additional field on a Span used to record key information such as model name and token usage.

  • Handler: The TelemetryHandler provided by util-genai for creating Spans and Metrics conforming to GenAI semantic conventions.

  • Invocation: The data model for each operation type (such as LLMInvocation, ExecuteToolInvocation), carrying Request / Response and Token fields.

The following table lists the Span types supported by util-genai. Compared to the Python version, the Go version does not directly provide Invocation structs for Entry / ReAct Step / Memory. However, by using the LoongSuite extension attribute gen_ai.span.kind (with values ENTRY / STEP / MEMORY, etc.) together with the OpenTelemetry SDK, you can achieve equivalent trace data.

Span type

Operation name

Creation method

Description

Entry

enter

OTel SDK + gen_ai.span.kind=ENTRY

Application entry point, carrying session_id / user_id / full interaction info

Agent

invoke_agent {name}

handler.StartInvokeAgent

Agent invocation with cumulative token usage

Tool

execute_tool {name}

handler.StartExecuteTool

Tool / function execution

Step

react

OTel SDK + gen_ai.span.kind=STEP

Single ReAct iteration marker

LLM

chat {model}

handler.StartLLM (or probe auto)

Large language model conversation

Embedding

embeddings {model}

handler.StartEmbedding

Vector embedding

Retriever

retrieval {data_source}

handler.StartRetrieve

Retrieval (RAG)

Reranker

rerank_documents

handler.StartRerank

Reranking (LoongSuite extension)

Memory

memory {operation}

OTel SDK + gen_ai.span.kind=MEMORY

Memory read/write

The following sections describe how to instrument each Span type, with independent code snippets for each step. For a complete runnable example, see the appendix at the end of this topic.

1. Obtain the Handler and Tracer

Use utilgenai.GetTelemetryHandler() to obtain the util-genai singleton Handler, and otel.Tracer("...") to obtain the OpenTelemetry SDK Tracer. The Handler creates GenAI semantic Spans, while the Tracer creates custom business Spans. To bind custom TracerProvider / MeterProvider instances, pass them as Options. If omitted, the Handler falls back to otel.GetTracerProvider() / otel.GetMeterProvider().

import (
    "go.opentelemetry.io/otel"
    utilgenai "github.com/alibaba/loongsuite-go/util-genai"
)

// Singleton — reuses the same tracer/meter instance within the process
handler := utilgenai.GetTelemetryHandler()

// To specify custom TracerProvider / MeterProvider / LoggerProvider:
// handler := utilgenai.NewTelemetryHandler(
//     utilgenai.WithTracerProvider(tp),
//     utilgenai.WithMeterProvider(mp),
//     utilgenai.WithLoggerProvider(lp),       // For GenAI event emission (OTel Logs API)
//     utilgenai.WithCompletionHook(hook),     // For large payload async offloading
// )

tracer := otel.Tracer("techcontent-agent")

The Handler lifecycle methods follow an explicit three-phase pattern: Start* / Stop* / Fail*:

ctx = handler.StartLLM(ctx, invocation)
var callErr error
defer func() {
    if callErr != nil {
        handler.FailLLM(invocation, &utilgenai.Error{
            Message: callErr.Error(),
            Type:    "APIError",
        })
    } else {
        handler.StopLLM(invocation)
    }
}()

2. Create an Entry Span

Create an Entry Span at the request entry point, carrying session_id and user_id, and writing user input and final output through attributes. The Go version does not have a dedicated EntryInvocation type. Use the OTel SDK to create a regular Span and write the LoongSuite extension attribute gen_ai.span.kind=ENTRY so the ARMS console recognizes it as an entry point.

ctx, entrySpan := tracer.Start(ctx, "enter",
    trace.WithSpanKind(trace.SpanKindServer),
    trace.WithAttributes(
        attribute.String("gen_ai.span.kind", "ENTRY"),
        attribute.String("gen_ai.session.id", sessionID),
        attribute.String("gen_ai.user.id", userID),
        attribute.String("gen_ai.operation.name", "enter"),
    ),
)
defer entrySpan.End()

// Record user input
inputMsgs := []utilgenai.InputMessage{
    {Role: "user", Parts: []utilgenai.MessagePart{utilgenai.Text{Content: req.Topic}}},
}
entrySpan.SetAttributes(
    attribute.String("gen_ai.input.messages", utilgenai.InputMessagesToJSON(inputMsgs)),
)

// After streaming response completes, write aggregated content as output.messages
outputMsgs := []utilgenai.OutputMessage{
    {
        Role:         "assistant",
        Parts:        []utilgenai.MessagePart{utilgenai.Text{Content: aggregated}},
        FinishReason: utilgenai.FinishReasonStop,
    },
}
entrySpan.SetAttributes(
    attribute.String("gen_ai.output.messages", utilgenai.OutputMessagesToJSON(outputMsgs)),
)

In the console, you can view the full input and final output for each request. gen_ai.session.id and gen_ai.user.id propagate down the Context. As long as downstream child Spans are created within the same Context, you can analyze them by session and user dimensions.

3. Create an Agent Span

Use StartInvokeAgent to create an Agent Span that records the Agent name, model, and description. The Agent Span is the root GenAI Span of the entire call chain — all subsequent ReAct Steps, LLM calls, and Tool calls are its child Spans.

Go version 0.1.0 does not include the automatic gen_ai.agent.name propagation via OpenTelemetry Baggage available in Python 0.6.1. If you need to read the Agent name in downstream child Spans, propagate it yourself through the Context or LLMInvocation.Attributes.

agentInv := utilgenai.NewInvokeAgentInvocation()
agentInv.Provider = "dashscope"
agentInv.AgentName = "TechContentAgent"
agentInv.AgentDescription = "Technical content generation assistant"
agentInv.RequestModel = "qwen-plus"
agentInv.ConversationID = sessionID

var (
    totalInputTokens  int
    totalOutputTokens int
)

ctx = handler.StartInvokeAgent(ctx, agentInv)
defer func() {
    if r := recover(); r != nil {
        handler.FailInvokeAgent(agentInv, &utilgenai.Error{
            Message: fmt.Sprintf("agent panic: %v", r),
            Type:    "RuntimeError",
        })
        panic(r)
    }
}()

// ... Agent core logic (ReAct loop, see Step 4) ...

agentInv.InputTokens = &totalInputTokens
agentInv.OutputTokens = &totalOutputTokens
handler.StopInvokeAgent(agentInv)

After the Agent finishes execution, write the accumulated totalInputTokens and totalOutputTokens into the InputTokens / OutputTokens fields. StopInvokeAgent persists them as gen_ai.usage.input_tokens / gen_ai.usage.output_tokens attributes and drives the gen_ai.client.token.usage histogram record, enabling Agent-level token usage aggregation.

4. Create a ReAct Step Span

Create a Step Span at each ReAct reasoning iteration, passing the current round number. Mark the iteration end through finish_reason: continue for further iterations, stop for the final answer. The Go version does not have a ReactStepInvocation — use the OTel SDK to create a Span and write gen_ai.span.kind=STEP:

ctx, stepSpan := tracer.Start(ctx, "react",
    trace.WithSpanKind(trace.SpanKindInternal),
    trace.WithAttributes(
        attribute.String("gen_ai.span.kind", "STEP"),
        attribute.String("gen_ai.operation.name", "react"),
        attribute.Int("gen_ai.step.round", iteration+1),
    ),
)

finishReason := "continue"
func() {
    defer stepSpan.End()

    resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
        Model:    "qwen-plus",
        Messages: messages,
        Tools:    toolDefs,
    })
    if err != nil {
        stepSpan.RecordError(err)
        stepSpan.SetStatus(codes.Error, err.Error())
        return
    }
    // ... process response ...
    if noMoreToolCalls {
        finishReason = "stop"
    }
    stepSpan.SetAttributes(attribute.String("gen_ai.response.finish_reason", finishReason))
}()

If LLM calls within the Step Span lifecycle are automatically instrumented by the ARMS probe, manual creation is not needed. If you use the OpenAI SDK directly without instrumentation, use the LLMInvocation approach in Step 6 for manual instrumentation.

5. Create a Tool Span

When the model returns tool calls, create a Tool Span for each tool_call to record the tool name, call ID, input arguments, and return result.

toolInv := utilgenai.NewExecuteToolInvocation(toolCall.Function.Name)
toolInv.ToolCallID = toolCall.ID
toolInv.ToolType = "function"

// Input: util-genai automatically JSON.stringifies during serialization, writing to gen_ai.tool.call.arguments
_ = json.Unmarshal([]byte(toolCall.Function.Arguments), &toolInv.Input)

ctx = handler.StartExecuteTool(ctx, toolInv)

result, err := dispatchTool(toolCall.Function.Name, toolCall.Function.Arguments)
if err != nil {
    handler.FailExecuteTool(toolInv, &utilgenai.Error{
        Message: err.Error(),
        Type:    "ToolError",
    })
    return err
}

toolInv.Output = result // Persisted to gen_ai.tool.call.result
handler.StopExecuteTool(toolInv)

The tool input gen_ai.tool.call.arguments and result gen_ai.tool.call.result are experimental content attributes. They are written to the Span only when OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental is set and OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY (or another value containing SPAN) is enabled.

6. Create ASR/TTS LLM Spans

For audio model calls such as ASR and TTS, if you need to record additional business attributes like audio duration, recognition latency, time to first token, or character count, create a specialized LLM Span: use OperationName="transcribe" for ASR and OperationName="synthesize_speech" for TTS. Write standard token fields only when the model API actually returns token counts. Do not estimate gen_ai.usage.* from audio duration, character count, or byte size.

The Go version mounts custom key-value pairs through LLMInvocation.Attributes (supports string / int / int64 / float64 / bool / []string; other types are silently dropped).

// ASR: speech recognition outputs text
asrInv := utilgenai.NewLLMInvocation("paraformer-v2")
asrInv.Provider = "dashscope"
asrInv.OperationName = "transcribe"
asrInv.OutputType = utilgenai.OutputTypeText

asrInv.InputMessages = []utilgenai.InputMessage{
    {Role: "user", Parts: []utilgenai.MessagePart{
        utilgenai.Uri{MimeType: "audio/wav", Modality: "audio", URI: audioURL},
    }},
}
asrInv.Attributes = map[string]any{
    "gen_ai.asr.channel":      "dashscope_transcription_async",
    "gen_ai.asr.input.source": "url",
}

ctx = handler.StartLLM(ctx, asrInv)
// Call the actual ASR SDK
asrResp, asrErr := dashscopeASR(ctx, audioURL)
if asrErr != nil {
    handler.FailLLM(asrInv, &utilgenai.Error{Message: asrErr.Error(), Type: "ASRError"})
    return asrErr
}

asrInv.ResponseID = asrResp.TaskID
asrInv.OutputMessages = []utilgenai.OutputMessage{
    {Role: "assistant",
     Parts:        []utilgenai.MessagePart{utilgenai.Text{Content: asrResp.Transcript}},
     FinishReason: utilgenai.FinishReasonStop},
}
if v := asrResp.Usage.InputTokens; v > 0 {
    asrInv.InputTokens = &v
}
if v := asrResp.Usage.OutputTokens; v > 0 {
    asrInv.OutputTokens = &v
}
asrInv.Attributes["gen_ai.asr.success"] = true
asrInv.Attributes["gen_ai.asr.status"] = "SUCCEEDED"
asrInv.Attributes["gen_ai.asr.audio.duration"] = asrResp.Usage.Duration
asrInv.Attributes["gen_ai.asr.call.wall_time"] = asrCallWallTime
asrInv.Attributes["gen_ai.asr.provider.processing.duration"] = asrProviderProcessingTime
handler.StopLLM(asrInv)
// TTS: text-to-speech synthesis
ttsInv := utilgenai.NewLLMInvocation("qwen-tts")
ttsInv.Provider = "dashscope"
ttsInv.OperationName = "synthesize_speech"
ttsInv.OutputType = utilgenai.OutputTypeSpeech

ttsInv.InputMessages = []utilgenai.InputMessage{
    {Role: "user", Parts: []utilgenai.MessagePart{utilgenai.Text{Content: ttsText}}},
}
ttsInv.Attributes = map[string]any{
    "gen_ai.tts.channel":           "qwen_tts_http",
    "gen_ai.tts.voice":             "Cherry",
    "gen_ai.tts.input.text_length": len(ttsText),
}

ctx = handler.StartLLM(ctx, ttsInv)
ttsResp, ttsErr := qwenTTS(ctx, ttsText)
if ttsErr != nil {
    handler.FailLLM(ttsInv, &utilgenai.Error{Message: ttsErr.Error(), Type: "TTSError"})
    return ttsErr
}

ttsInv.ResponseID = ttsResp.RequestID
if v := ttsResp.Usage.InputTokens; v > 0 {
    ttsInv.InputTokens = &v
}
if v := ttsResp.Usage.OutputTokens; v > 0 {
    ttsInv.OutputTokens = &v
}
ttsInv.OutputMessages = []utilgenai.OutputMessage{
    {Role: "assistant",
     Parts:        []utilgenai.MessagePart{utilgenai.Text{Content: fmt.Sprintf("audio generated: %s", ttsResp.AudioID)}},
     FinishReason: utilgenai.FinishReasonStop},
}
ttsInv.Attributes["gen_ai.tts.success"] = true
ttsInv.Attributes["gen_ai.tts.status"] = "SUCCEEDED"
ttsInv.Attributes["gen_ai.tts.audio.duration"] = ttsResp.AudioDuration
ttsInv.Attributes["gen_ai.tts.call.wall_time"] = ttsCallWallTime
ttsInv.Attributes["gen_ai.tts.first_audio.duration"] = firstAudioDelay
handler.StopLLM(ttsInv)

Recommended field usage by purpose: gen_ai.asr.audio.duration for input audio duration, gen_ai.asr.provider.processing.duration for server-side recognition latency, gen_ai.tts.audio.duration for synthesized audio duration, and gen_ai.tts.first_audio.duration for streaming synthesis time to first token. For end-to-end call duration, use the Span Duration or additionally write gen_ai.asr.call.wall_time / gen_ai.tts.call.wall_time.

7. GenAI event emission and content offloading

Event emission (Events)

util-genai supports sending GenAI events via the OTel Logs API. When StopLLM / StopInvokeAgent is called, the Handler automatically emits an event to the LoggerProvider (event name gen_ai.client.inference.operation.details or gen_ai.client.agent.invoke.operation.details) if all of the following conditions are met:

  • Environment variable OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental is set.

  • Environment variable OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT=true is set.

  • A LoggerProvider was injected via WithLoggerProvider(lp) when creating the Handler.

Message content is included in the event body only when OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT includes EVENT (i.e., EVENT_ONLY or SPAN_AND_EVENT).

export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
export OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT=true
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=EVENT_ONLY
import (
    logapi "go.opentelemetry.io/otel/log"
    sdklog "go.opentelemetry.io/otel/sdk/log"
    "go.opentelemetry.io/otel/exporters/stdout/stdoutlog"
)

logExporter, _ := stdoutlog.New()
lp := sdklog.NewLoggerProvider(sdklog.WithProcessor(
    sdklog.NewBatchProcessor(logExporter),
))
defer lp.Shutdown(ctx)

handler := utilgenai.NewTelemetryHandler(
    utilgenai.WithTracerProvider(tp),
    utilgenai.WithLoggerProvider(lp),
)

After setup, use StartLLM / StopLLM and StartInvokeAgent / StopInvokeAgent as usual. Events are emitted automatically without additional code.

Content offloading (CompletionHook)

When LLM prompt/response payloads are large (e.g., tens of KBs of multi-turn conversation context), writing them directly to Span Attributes can make Spans excessively large. util-genai provides a CompletionHook interface that, during StopLLM, automatically offloads large payloads asynchronously to external storage (file system or custom backend). Only a content-addressed reference URI is written to the Span (gen_ai.input.messages_ref / gen_ai.output.messages_ref, etc.).

The built-in UploadCompletionHook uses SHA-256 for file name computation to achieve deduplication (identical payloads are not written twice). It supports JSON and JSONL serialization formats, and internally uses a worker pool with a bounded FIFO set for asynchronous concurrent uploads.

# Configure external storage path and format
export OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH=/data/genai-traces
export OTEL_INSTRUMENTATION_GENAI_UPLOAD_FORMAT=json
export OTEL_INSTRUMENTATION_GENAI_UPLOAD_MAX_QUEUE_SIZE=128
// Use built-in FS Uploader (you can also implement a custom Uploader interface to write to OSS, etc.)
hook := utilgenai.NewUploadCompletionHook(
    utilgenai.WithUploader(utilgenai.NewFSUploader("/data/genai-traces")),
    utilgenai.WithUploadFormat(utilgenai.UploadFormatJSON),
    utilgenai.WithUploadQueueSize(128),
)

handler := utilgenai.NewTelemetryHandler(
    utilgenai.WithTracerProvider(tp),
    utilgenai.WithCompletionHook(hook),
)
defer handler.Shutdown(ctx) // Gracefully drain pending uploads in the queue

After StopLLM completes, the Handler internally calls offloadLLMContent to serialize InputMessages, OutputMessages, SystemInstruction, and ToolDefinitions and submit them to the CompletionHook. The Hook computes SHA-256, writes to the file system (e.g., /data/genai-traces/<sha256>.json), and writes the reference attribute gen_ai.input.messages_ref=sha256://<hash> on the Span.

Custom Uploader interface:

type Uploader interface {
    Upload(ctx context.Context, ref string, data []byte) error
}

Implement this interface to write payloads to OSS, S3, or other object storage services.

Reasoning message type

util-genai includes a Reasoning message part type for carrying the model's reasoning/thinking process (e.g., Chain-of-Thought). Use it within OutputMessage.Parts:

invocation.OutputMessages = []utilgenai.OutputMessage{{
    Role: "assistant",
    Parts: []utilgenai.MessagePart{
        utilgenai.Reasoning{Content: "Let me analyze this problem... First..."},
        utilgenai.Text{Content: "The final answer is..."},
    },
    FinishReason: utilgenai.FinishReasonStop,
}}
// Reasoning tokens can be tracked separately via ReasoningOutputTokens
reasoningTokens := 150
invocation.ReasoningOutputTokens = &reasoningTokens

The corresponding Span attribute is gen_ai.usage.reasoning.output_tokens, allowing you to distinguish between reasoning tokens and regular output tokens in the console.

View monitoring details

  1. Log on to the Cloud Monitor 2.0 console, select the target workspace, and in the left-side navigation pane choose All Features > AI Application Observability.

  2. On the AI Applications list page, click an application name to view detailed monitoring data.

Instrumentation result examples

Entry Span details

In the Entry Span details panel, key attributes include gen_ai.session.id (unique session identifier) and gen_ai.user.id (user identifier). Once set at the function entry point, these propagate automatically to downstream child Spans (provided they are created from the same Context). You can also view gen_ai.input.messages (complete user input message content) and gen_ai.output.messages (complete model output message content).

Agent Span details

The Agent Span panel displays the Agent name and description, along with Agent-level token usage aggregation. Example: gen_ai.agent.name=TechContentAgent, gen_ai.provider.name=dashscope, gen_ai.request.model=qwen-plus, gen_ai.usage.input_tokens=3982, gen_ai.usage.output_tokens=884.

Tool Span details

The Tool Span displays detailed tool call information. For example, for execute_tool generate_seo_keywords, the details panel shows the tool name (gen_ai.tool.name=generate_seo_keywords), tool type (gen_ai.tool.type=function), tool call arguments (gen_ai.tool.call.arguments), and tool return result (gen_ai.tool.call.result), making it easy to verify whether tool input/output matches expectations.

LLM Span details

LLM Spans can be automatically collected by ARMS probes for supported Go components (such as sashabaranov/go-openai, dashscope, etc.), or manually created using LLMInvocation as shown in this topic. Auto-collection is suitable for common text conversation model calls. For ASR, TTS, or other model calls not yet automatically covered by probes or that require additional business attributes, manually create LLM Spans and write gen_ai.asr.* / gen_ai.tts.* custom attributes.

The LLM Span details panel displays basic information such as application name, interface name, IP, start and end times, SpanID, ParentSpanID, and status code. The Additional Info tab shows the response output content, custom Attributes, and token usage for that call.

Custom Span details

Custom Spans (such as duplicate_tool_detection, response_loop_detection) must be viewed in the full view of the console trace view. The panel shows the gen_ai.loop_detection.* attributes, useful for analyzing business-side loops, retries, and other abnormal behaviors.

Metrics overview

TelemetryHandler automatically records the following histograms during Stop* / Fail* (units align with the spec). These can be used for aggregated analysis in Cloud Monitor 2.0 / Prometheus.

Metric

Unit

Description

gen_ai.client.operation.duration

s

End-to-end duration of a single LLM / Embedding call

gen_ai.client.token.usage

{token}

Input/output token usage (differentiated by gen_ai.token.type=input/output)

gen_ai.client.operation.time_to_first_chunk

s

Streaming time to first chunk (requires LLMInvocation.TimeToFirstChunk)

gen_ai.invoke_agent.duration

s

Total Agent invocation duration

gen_ai.execute_tool.duration

s

Total tool execution duration

gen_ai.workflow.duration

s

Total workflow duration (reserved, no corresponding Handler method yet)

To enable message content writing to Spans (otherwise gen_ai.input.messages / gen_ai.output.messages / gen_ai.tool.call.arguments / gen_ai.tool.call.result are not written):

export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY

Complete environment variable reference:

Environment variable

Values

Effect

OTEL_SEMCONV_STABILITY_OPT_IN

gen_ai_latest_experimental

Enables experimental semantics (without this, message content is never written to attributes)

OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT

NO_CONTENT / SPAN_ONLY / EVENT_ONLY / SPAN_AND_EVENT

Controls whether input/output message JSON is written to Spans or Events

OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT

true / false

Whether to send GenAI events via the OTel Logs API

OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH

fsspec URI or local path

Content offloading target path

OTEL_INSTRUMENTATION_GENAI_UPLOAD_FORMAT

json / jsonl

Offloaded file serialization format

OTEL_INSTRUMENTATION_GENAI_UPLOAD_MAX_QUEUE_SIZE

int

Async upload worker queue size

Appendix: Complete example code

The following example demonstrates: initializing TracerProvider → creating an Entry Span → starting an Agent Span → running a ReAct loop (with Step / LLM / Tool Spans) → recording token usage and ending the Agent Span. The example uses sashabaranov/go-openai and can be seamlessly switched to DashScope-compatible access. For more code, see genai-demo.

main.go

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "os"
    "strings"
    "time"

    utilgenai "github.com/alibaba/loongsuite-go/util-genai"
    openai "github.com/sashabaranov/go-openai"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
    "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"
)

func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
    exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
    if err != nil {
        return nil, err
    }
    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceNameKey.String("techcontent-agent"),
            semconv.ServiceVersionKey.String("0.1.0"),
            // Critical Resource attributes: do not write via Span Attribute
            attribute.String("acs.arms.service.feature", "genai_app"),
            attribute.String("gen_ai.instrumentation.sdk.name", "loongsuite-genai-utils"),
        ),
    )
    if err != nil {
        return nil, err
    }
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(res),
    )
    otel.SetTracerProvider(tp)
    return tp, nil
}

func main() {
    ctx := context.Background()
    tp, err := initTracer(ctx)
    if err != nil {
        log.Fatalf("init tracer: %v", err)
    }
    defer tp.Shutdown(ctx)

    apiKey := os.Getenv("DASHSCOPE_API_KEY")
    if apiKey == "" {
        log.Fatal("DASHSCOPE_API_KEY is required")
    }

    cfg := openai.DefaultConfig(apiKey)
    cfg.BaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
    client := openai.NewClientWithConfig(cfg)

    handler := utilgenai.NewTelemetryHandler(utilgenai.WithTracerProvider(tp))
    tracer := otel.Tracer("techcontent-agent")

    if err := runAgent(ctx, client, handler, tracer, "CMS 2.0 AI alert noise reduction"); err != nil {
        log.Fatalf("run agent: %v", err)
    }
}

func runAgent(
    ctx context.Context,
    client *openai.Client,
    handler *utilgenai.TelemetryHandler,
    tracer trace.Tracer,
    topic string,
) error {
    // ---------- Entry Span ----------
    sessionID := fmt.Sprintf("sess-%d", time.Now().UnixNano())
    ctx, entrySpan := tracer.Start(ctx, "enter",
        trace.WithSpanKind(trace.SpanKindServer),
        trace.WithAttributes(
            attribute.String("gen_ai.span.kind", "ENTRY"),
            attribute.String("gen_ai.operation.name", "enter"),
            attribute.String("gen_ai.session.id", sessionID),
            attribute.String("gen_ai.user.id", "anonymous"),
        ),
    )
    defer entrySpan.End()

    inputMsgs := []utilgenai.InputMessage{
        {Role: "user", Parts: []utilgenai.MessagePart{utilgenai.Text{Content: topic}}},
    }
    entrySpan.SetAttributes(
        attribute.String("gen_ai.input.messages", utilgenai.InputMessagesToJSON(inputMsgs)),
    )

    // ---------- Agent Span ----------
    agentInv := utilgenai.NewInvokeAgentInvocation()
    agentInv.Provider = "dashscope"
    agentInv.AgentName = "TechContentAgent"
    agentInv.AgentDescription = "Technical content generation assistant"
    agentInv.RequestModel = "qwen-plus"
    agentInv.ConversationID = sessionID

    ctx = handler.StartInvokeAgent(ctx, agentInv)

    messages := []openai.ChatCompletionMessage{
        {Role: openai.ChatMessageRoleSystem, Content: "You are an experienced cloud-native technical editor who uses tools to gather material before writing."},
        {Role: openai.ChatMessageRoleUser, Content: topic},
    }

    var (
        totalIn, totalOut int
        finalContent     string
        toolCounter      = map[string]int{}
        prevContent      string
    )

    // ---------- ReAct Loop ----------
    for round := 0; round < 5; round++ {
        checkDuplicateTools(ctx, tracer, toolCounter, &messages)

        stepCtx, stepSpan := tracer.Start(ctx, "react",
            trace.WithSpanKind(trace.SpanKindInternal),
            trace.WithAttributes(
                attribute.String("gen_ai.span.kind", "STEP"),
                attribute.String("gen_ai.operation.name", "react"),
                attribute.Int("gen_ai.step.round", round+1),
            ),
        )

        // Manual LLM Span (can be omitted if ARMS Go auto-instrumentation is enabled)
        llmInv := utilgenai.NewLLMInvocation("qwen-plus")
        llmInv.Provider = "dashscope"
        llmInv.OperationName = utilgenai.OperationChat
        llmInv.ConversationID = sessionID
        llmInv.InputMessages = openAIToUtilMessages(messages)

        stepCtx = handler.StartLLM(stepCtx, llmInv)
        resp, err := client.CreateChatCompletion(stepCtx, openai.ChatCompletionRequest{
            Model:    "qwen-plus",
            Messages: messages,
            Tools:    toolDefinitions(),
        })
        if err != nil {
            handler.FailLLM(llmInv, &utilgenai.Error{Message: err.Error(), Type: "APIError"})
            stepSpan.RecordError(err)
            stepSpan.End()
            handler.FailInvokeAgent(agentInv, &utilgenai.Error{Message: err.Error(), Type: "APIError"})
            return err
        }

        choice := resp.Choices[0]
        llmInv.ResponseID = resp.ID
        llmInv.ResponseModelName = resp.Model
        pt := resp.Usage.PromptTokens
        ct := resp.Usage.CompletionTokens
        llmInv.InputTokens = &pt
        llmInv.OutputTokens = &ct
        totalIn += pt
        totalOut += ct

        llmInv.OutputMessages = []utilgenai.OutputMessage{{
            Role:         "assistant",
            Parts:        []utilgenai.MessagePart{utilgenai.Text{Content: choice.Message.Content}},
            FinishReason: utilgenai.FinishReason(choice.FinishReason),
        }}
        handler.StopLLM(llmInv)

        messages = append(messages, choice.Message)

        // ---------- Tool Loop ----------
        for _, tc := range choice.Message.ToolCalls {
            toolCounter[tc.Function.Name]++
            toolInv := utilgenai.NewExecuteToolInvocation(tc.Function.Name)
            toolInv.ToolCallID = tc.ID
            toolInv.ToolType = "function"

            _ = json.Unmarshal([]byte(tc.Function.Arguments), &toolInv.Input)

            stepCtx = handler.StartExecuteTool(stepCtx, toolInv)
            result, err := dispatchTool(tc.Function.Name, tc.Function.Arguments)
            if err != nil {
                handler.FailExecuteTool(toolInv, &utilgenai.Error{Message: err.Error(), Type: "ToolError"})
                stepSpan.End()
                handler.FailInvokeAgent(agentInv, &utilgenai.Error{Message: err.Error(), Type: "ToolError"})
                return err
            }
            toolInv.Output = result
            handler.StopExecuteTool(toolInv)

            messages = append(messages, openai.ChatCompletionMessage{
                Role:       openai.ChatMessageRoleTool,
                Content:    result,
                ToolCallID: tc.ID,
                Name:       tc.Function.Name,
            })
        }

        // ReAct termination check
        finishReason := "continue"
        if len(choice.Message.ToolCalls) == 0 {
            finishReason = "stop"
            finalContent = choice.Message.Content
            stepSpan.SetAttributes(attribute.String("gen_ai.response.finish_reason", finishReason))
            stepSpan.End()

            if checkResponseLoop(ctx, tracer, finalContent, prevContent) {
                agentInv.Attributes = map[string]any{
                    "gen_ai.response.finish_reason": "loop_detected",
                }
                break
            }
            break
        }
        stepSpan.SetAttributes(attribute.String("gen_ai.response.finish_reason", finishReason))
        stepSpan.End()
        prevContent = choice.Message.Content
    }

    // Aggregate token usage and end Agent Span
    agentInv.InputTokens = &totalIn
    agentInv.OutputTokens = &totalOut
    handler.StopInvokeAgent(agentInv)

    // Entry output
    outputMsgs := []utilgenai.OutputMessage{{
        Role:         "assistant",
        Parts:        []utilgenai.MessagePart{utilgenai.Text{Content: finalContent}},
        FinishReason: utilgenai.FinishReasonStop,
    }}
    entrySpan.SetAttributes(
        attribute.String("gen_ai.output.messages", utilgenai.OutputMessagesToJSON(outputMsgs)),
    )

    fmt.Println(strings.Repeat("-", 60))
    fmt.Println(finalContent)
    return nil
}

tools.go

package main

import (
    "encoding/json"
    "fmt"

    openai "github.com/sashabaranov/go-openai"
)

func toolDefinitions() []openai.Tool {
    return []openai.Tool{
        {
            Type: openai.ToolTypeFunction,
            Function: &openai.FunctionDefinition{
                Name:        "generate_seo_keywords",
                Description: "Generate SEO keywords based on topic",
                Parameters: map[string]any{
                    "type": "object",
                    "properties": map[string]any{
                        "topic": map[string]any{"type": "string"},
                    },
                    "required": []string{"topic"},
                },
            },
        },
        {
            Type: openai.ToolTypeFunction,
            Function: &openai.FunctionDefinition{
                Name:        "outline_article",
                Description: "Generate article outline",
                Parameters: map[string]any{
                    "type": "object",
                    "properties": map[string]any{
                        "topic":    map[string]any{"type": "string"},
                        "keywords": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
                    },
                    "required": []string{"topic"},
                },
            },
        },
    }
}

func dispatchTool(name, argJSON string) (string, error) {
    var args map[string]any
    _ = json.Unmarshal([]byte(argJSON), &args)
    switch name {
    case "generate_seo_keywords":
        topic, _ := args["topic"].(string)
        return fmt.Sprintf(`["%s best practices", "%s case study", "%s implementation"]`, topic, topic, topic), nil
    case "outline_article":
        topic, _ := args["topic"].(string)
        return fmt.Sprintf(`## %s Outline\n1. Background\n2. Technical Solution\n3. Results`, topic), nil
    default:
        return "", fmt.Errorf("unknown tool: %s", name)
    }
}

messages.go

package main

import (
    utilgenai "github.com/alibaba/loongsuite-go/util-genai"
    openai "github.com/sashabaranov/go-openai"
)

func openAIToUtilMessages(msgs []openai.ChatCompletionMessage) []utilgenai.InputMessage {
    out := make([]utilgenai.InputMessage, 0, len(msgs))
    for _, m := range msgs {
        out = append(out, utilgenai.InputMessage{
            Role:  m.Role,
            Parts: []utilgenai.MessagePart{utilgenai.Text{Content: m.Content}},
        })
    }
    return out
}

go.mod key dependencies

module github.com/example/techcontent-agent

go 1.24.0

require (
    github.com/alibaba/loongsuite-go/util-genai v0.1.0
    github.com/sashabaranov/go-openai v1.36.1
    go.opentelemetry.io/otel v1.40.0
    go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0
    go.opentelemetry.io/otel/log v0.16.0
    go.opentelemetry.io/otel/sdk v1.40.0
    go.opentelemetry.io/otel/trace v1.40.0
)
Note

util-genai does not yet have an independent version tag. If go get fails, fork the repository and use replace github.com/alibaba/loongsuite-go/util-genai => ./util-genai for local reference, consistent with the approach in the repository's example/genai/go.mod. For production environments, pin to a stable commit in the main repository.