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.

Note

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-go

Requires 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/genai

The 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-go

Adding 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 --json

After 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 --json

Require 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.

Warning

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.

Note

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()
_ = toolSpan

Running several independent features in one service? Open a distinctly-named workflow root 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{ /* … */ })
FieldTypeDefaultDescription
APIKeystringNEATLOGS_API_KEY envProject API key. Export disabled (spans dropped) if unset.
Endpointstringhttps://ingest.neatlogs.comOTLP/HTTP ingest base URL.
WorkflowNamestringexecutable nameLabel all traces appear under (e.g. main.go).
Tags[]stringTags attached to every span.
DebugboolfalseVerbose diagnostics on stderr.
SampleRate*float641.0Parent-based whole-trace sampling probability in the inclusive range 0..1.
DisableExportboolfalseBuild spans but skip export (useful in tests).
MaskMaskFuncContext-aware client-side telemetry transform after canonicalization. Error, panic, timeout, cancellation, or nil fails closed and drops the span.
EnableUploadsboolfalseEnable authenticated typed-media and oversized-OTLP uploads; can also be enabled with NEATLOGS_UPLOADS_ENABLED.
EnableSignalHandlersboolfalseOpt into bounded SIGINT/SIGTERM shutdown. The host owns signals by default.
Note

Config has no session or end-user field — identity is per-request via Identify (above), not process-global.

Lifecycle: Init returns a ShutdownFuncdefer 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.

On this page

Ask Neatlogs AI

Answers from the docs

How can I help?

Ask anything about instrumenting, tracing, or the Neatlogs dashboard.