Go SDK
Install, initialize, and instrument Go LLM and agent apps with Neatlogs.
The Go SDK installs as github.com/neatlogs/neatlogs-go. It is built on OpenTelemetry, but keeps to a private tracer provider — one Init call configures it and exports spans to Neatlogs over OTLP/HTTP, without ever touching process-global OpenTelemetry state. You instrument explicitly: wrap a google.golang.org/genai client, or open spans directly with a small set of helpers. This page covers everything from install to the full Config reference; use the contents on the right to jump around.
New to tracing? A trace is the record of one run of your app; a span is one step inside it (an LLM call, a tool call). You instrument once, then read traces in the dashboard. See Introduction.
Install
go get github.com/neatlogs/neatlogs-goRequires Go ≥ 1.25. The Gemini wrapper lives in a separate module so the heavy google.golang.org/genai dependency stays out of apps that only need the core helpers:
go get github.com/neatlogs/neatlogs-go/contrib/genaiYour first trace
Call neatlogs.Init once at startup and defer the returned shutdown so buffered spans flush before the process exits. Then wrap your Gemini client with genai.WrapGenAI from contrib/genai — the wrapper has the same method signatures as client.Models, so wrapping is a one-line change.
package main
import (
"context"
"fmt"
"log"
"os"
"google.golang.org/genai"
neatlogs "github.com/neatlogs/neatlogs-go"
nlgenai "github.com/neatlogs/neatlogs-go/contrib/genai"
)
func main() {
ctx := context.Background()
shutdown, err := neatlogs.Init(ctx, neatlogs.Config{
APIKey: os.Getenv("NEATLOGS_API_KEY"),
WorkflowName: "my-first-app",
})
if err != nil {
log.Fatal(err)
}
defer shutdown(ctx)
client, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: os.Getenv("GEMINI_API_KEY"),
Backend: genai.BackendGeminiAPI,
})
if err != nil {
log.Fatal(err)
}
gc := nlgenai.WrapGenAI(client) // the one added line
resp, err := gc.GenerateContent(ctx, "gemini-2.5-flash",
genai.Text("What is the capital of France?"), nil)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp.Text())
}Run it and a trace appears under my-first-app within a few seconds. WrapGenAI opens a workflow root automatically (named after your WorkflowName) with the llm span nested inside — no extra wrapper needed.
Always defer shutdown(ctx) (or call neatlogs.Flush(ctx)) before a short-lived program exits — export runs on a background batch processor, so a process that ends immediately can drop its last spans. Long-running servers export continuously; call shutdown once on server shutdown.
Isolation — a private provider
Init creates a private OpenTelemetry TracerProvider and never calls otel.SetTracerProvider or otel.SetTextMapPropagator. It doesn't touch process-global OpenTelemetry state at all. Parent/child linkage rides on a private slot in context.Context, not the standard OTel active-span slot.
The guarantee is bidirectional: a co-tenant tracer (Datadog, Braintrust, …) can never export, parent, or be parented by Neatlogs spans, and vice-versa. The trade-off is that frameworks which resolve their tracer only from the global OTel API are not auto-instrumented — you instrument them explicitly with the helpers below or an injected private provider.
Earlier releases registered the global provider, which let OTel-native frameworks like Google ADK flow through automatically. That passthrough is retired in the private-provider model; contrib/adk is deprecated. Instrument model calls and boundaries explicitly instead.
Capturing Gemini calls — WrapGenAI
genai.WrapGenAI (import nlgenai "github.com/neatlogs/neatlogs-go/contrib/genai") wraps a *genai.Client and traces each call with full request/response detail: input/output messages, tool definitions and calls, invocation parameters, token usage, and finish reason. It detects the Vertex AI backend from the client config and tags it distinctly from the Gemini API.
gc := nlgenai.WrapGenAI(client)
// Same signatures as genai's client.Models:
resp, err := gc.GenerateContent(ctx, "gemini-2.5-flash", contents, config)
for resp, err := range gc.GenerateContentStream(ctx, "gemini-2.5-flash", contents, config) {
// streaming chunks
}
emb, err := gc.EmbedContent(ctx, "text-embedding-004", contents, nil)
tok, err := gc.CountTokens(ctx, "gemini-2.5-flash", contents, nil)Any method the wrapper doesn't trace is reachable via gc.Raw(), which returns the underlying *genai.Models.
Explicit span helpers
For direct provider SDKs (OpenAI, Anthropic, …), retrieval, and service boundaries, open spans yourself. Every helper auto-roots under a workflow span when there's no active parent and uses the private provider only — no OpenTelemetry types in your call sites.
Custom code — Trace / StartSpan
Trace opens a workflow root; StartSpan opens an explicitly-typed span at a framework or service boundary. Both return (ctx, span, end) — call end() once (usually via defer) and thread the returned ctx into children so nesting is correct.
ctx, span, end := neatlogs.Trace(ctx, "handle_request") // workflow root
defer end()
_ = span
// A typed child span (e.g. a TOOL) at a boundary:
ctx, toolSpan, endTool := neatlogs.StartSpan(ctx, "lookup_account", "tool")
defer endTool()
_ = toolSpanRunning several independent features in one service? Open a distinctly-named
workflowroot at each feature's entry point so they appear as separate workflows in the dashboard — see Multiple Workflows in One Codebase.
Direct LLM calls — StartLLMSpan
Instrument any provider call WrapGenAI doesn't cover. Records provider/model/system, input messages, invocation params, output message, token usage, finish reason, and response id.
ctx, llm := neatlogs.StartLLMSpan(ctx, neatlogs.LLMCallOptions{
Provider: "openai",
Model: "gpt-5.5",
Messages: []neatlogs.LLMMessage{
{Role: "system", Content: "You are concise."},
{Role: "user", Content: "What is the capital of France?"},
},
})
defer llm.End()
// ... make the real provider call ...
llm.SetOutputMessage("assistant", "Paris.")
llm.SetUsage(12, 3, 15) // prompt, completion, total
llm.SetFinishReason("stop")SetModel / SetProvider allow a post-call override (e.g. alias resolution or a primary→fallback switch). The span name defaults to "{provider}.chat".
Retrieval — StartRetrieverSpan
For vector search, RAG lookups, and agent memory recall (all retriever kind). Every completed retrieval gets an explicit output — an empty result set is recorded as "[]", never omitted.
ctx, r := neatlogs.StartRetrieverSpan(ctx, "search_docs", query, 5) // name, query, topK
defer r.End()
docs := vectorStore.Search(query, 5)
r.SetDocuments(docs, len(docs))Service boundaries — StartToolSpanFromHeaders
Continue a Neatlogs trace carried on an inbound request's headers, bind identity, and open a tool span in one call:
ctx, tool := neatlogs.StartToolSpanFromHeaders(ctx, r.Header, "get_account", input, neatlogs.IdentifyOptions{})
defer tool.End()
result := doWork(input)
tool.SetOutput(result)Cross-process propagation
InjectTraceContext / ExtractTraceContext carry the active Neatlogs span across an HTTP/RPC boundary as W3C traceparent/tracestate, using a private propagator — they never read or replace the global OTel propagator.
// Caller — inject before sending:
neatlogs.InjectTraceContext(ctx, propagation.HeaderCarrier(req.Header))
// Remote — extract to continue the trace:
ctx = neatlogs.ExtractTraceContext(ctx, propagation.HeaderCarrier(r.Header))For the full cross-language flow — a TypeScript caller injecting and a Go service extracting — see Distributed Tracing.
Sessions & end-user identity
Identity is per-request, bound on the context.Context — never set on Init. Identify returns a derived context; pass it into Trace, StartLLMSpan, wrapped calls, etc. Only non-empty fields are set, so a later Identify can override one field without clearing the others. It stamps root spans only (neatlogs.session.id, neatlogs.end_user.id, neatlogs.end_user.metadata); the backend rolls it up to the trace and session.
// Same SessionID every turn → one session; EndUserID attributes it to your user.
ctx = neatlogs.Identify(ctx, neatlogs.IdentifyOptions{
SessionID: fmt.Sprintf("conv_%s", conversationID),
EndUserID: userID,
EndUserMetadata: map[string]any{"plan": "pro"},
})
gc.GenerateContent(ctx, "gemini-2.5-flash", genai.Text(message), nil)A standalone workflow (a one-off job, not a conversation) must omit SessionID — Neatlogs then sets session_id = trace_id, so the run is its own single-turn session, still attributed to the end-user. See Sessions and End-User Identity.
Span kinds
The SDK normalizes every span into the shared neatlogs.* namespace with one of these kinds. The genai wrapper produces llm and embedding spans; the explicit helpers add llm, retriever, and tool; Trace/StartSpan produce workflow and any kind you name. The full taxonomy (llm, tool, agent, chain, workflow, retriever, embedding, reranker, guardrail, mcp_tool, and more) is shared across all Neatlogs SDKs — see Span Kinds.
Transport
The SDK exports with standard OTLP/HTTP. Attribute normalization to the neatlogs.* namespace happens at the exporter boundary, so spans from any source — your WrapGenAI calls or explicit helpers — are translated before they leave the process. Export is batched on a background goroutine and never blocks your agent code.
Config reference
shutdown, err := neatlogs.Init(ctx, neatlogs.Config{ /* … */ })| Field | Type | Default | Description |
|---|---|---|---|
APIKey | string | NEATLOGS_API_KEY env | Project API key. Export disabled (spans dropped) if unset. |
WorkflowName | string | executable name | Label all traces appear under (e.g. main.go). |
Tags | []string | — | Tags attached to every span. |
Debug | bool | false | Verbose diagnostics on stderr. |
DisableExport | bool | false | Build spans but skip export (useful in tests). |
Config has no session or end-user field — identity is per-request via Identify (above), not process-global.
Lifecycle: Init returns a ShutdownFunc — defer shutdown(ctx) to flush and release resources. neatlogs.Flush(ctx) exports buffered spans immediately without shutting down (a no-op if not initialized).
Version: neatlogs.Version is an exported const carrying the SDK version. It is stamped on every trace as the service.version resource attribute and as the instrumentation-scope version, so you can tell which SDK build produced a span from the dashboard.
Building in another language? See the Python SDK and TypeScript SDK.
