Go SDK
Install, initialize, and instrument Go LLM and agent apps with Neatlogs.
The Go SDK installs as github.com/neatlogs/neatlogs-go. Use the latest published stable release. 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/genaiThe current wrapper compatibility baseline is google.golang.org/genai 1.61.x.
Check the installed SDK with Doctor
Resolve the project module version from the application root:
go list -m -f '{{.Version}}' github.com/neatlogs/neatlogs-goAdding the module to go.mod does not install the CLI. If the neatlogs binary
is missing, replace <resolved-module-version> below with the exact result from
the previous command. Review and approve the installation before running it:
go install github.com/neatlogs/neatlogs-go/cmd/neatlogs@<resolved-module-version>Do not use go run, @latest, or an unversioned download. Run the network-free
local check:
neatlogs doctor --local --jsonAfter instrumenting and running your normal checks, set the project key through your environment or secret manager and run the authenticated probe separately:
neatlogs doctor --probe --jsonRequire Doctor v2 format, Go runtime, and schema 2. The reported SDK version
must match the project module. If the module lacks Doctor v2, check the canonical
module tags for the latest published stable release and ask before upgrading.
Accept newer compatible releases and never downgrade one. A local pass validates
controlled in-process capture only. A probe pass additionally proves exact
finalized readback of its own four-span trace, but neither replaces testing your
real workflow. See SDK Doctor for the full pass criteria, reason
codes, and safe troubleshooting steps.
Your 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. See Flush and Shutdown for workers, streams, tests, and secondary clients.
For long-running services, workers, and stream consumers, initialize once and
call shutdown once after graceful shutdown stops new work. Do not flush for
every request, job, or stream item. A serverless handler should end its spans
and call neatlogs.Flush(ctx) before returning; reserve shutdown for actual
runtime teardown. Tests can flush before export assertions and call shutdown in
test cleanup. For streams, end spans on completion, error, or cancellation
before flushing.
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 let Google ADK flow through the global provider. That
passthrough is retired. The supported contrib/adk integration now uses
InstrumentConfig and Run to create Neatlogs-owned spans on the private
provider. See Google ADK.
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()
_ = neatlogs.SetTraceInput(span, map[string]any{"question": "Where is order 123?"})
result := map[string]any{"status": "shipped"}
_ = neatlogs.SetTraceOutput(span, result)
// 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. A later Identify can override individual fixed fields and merge arbitrary SessionCustomFields. It stamps root spans only (neatlogs.session.id, neatlogs.session.parent_id, neatlogs.session.custom_fields, and end-user identity); 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),
ParentSessionID: parentConversationID,
SessionCustomFields: map[string]any{
"feature_name": "assistant",
"entry_point": "api",
},
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. |
Endpoint | string | https://ingest.neatlogs.com | OTLP/HTTP ingest base URL. |
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. |
SampleRate | *float64 | 1.0 | Parent-based whole-trace sampling probability in the inclusive range 0..1. |
DisableExport | bool | false | Build spans but skip export (useful in tests). |
Mask | MaskFunc | — | Context-aware client-side telemetry transform after canonicalization. Error, panic, timeout, cancellation, or nil fails closed and drops the span. |
EnableUploads | bool | false | Enable authenticated typed-media and oversized-OTLP uploads; can also be enabled with NEATLOGS_UPLOADS_ENABLED. |
EnableSignalHandlers | bool | false | Opt into bounded SIGINT/SIGTERM shutdown. The host owns signals by default. |
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). A secondary Client owns its own Flush and Shutdown; there is no process-wide FlushAll in Go.
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.
