Distributed Tracing
Continue one Neatlogs trace across a service boundary — inject the active span on the way out, extract it on the way in.
When a request crosses from one process to another — a TypeScript agent runtime calling a Go tool service, a gateway calling a worker — you usually want one logical trace, not two disconnected ones. Neatlogs carries the active span across that boundary as standard W3C traceparent/tracestate headers: the caller injects, the callee extracts, and the remote service's spans join the same trace_id with the caller's span as their parent.
Because the wire format is W3C trace context, the headers are language-agnostic — any of the Neatlogs SDKs injects a traceparent that any W3C-compatible tracer can read. Python and Go are full participants: each can both inject the active span on the way out and extract a remote traceparent back into a Neatlogs trace on the way in. TypeScript is inject-only (it can start a trace and hand it off, but can't continue an inbound one). So a boundary that joins into one Neatlogs trace is inject (Python / TypeScript / Go) → extract (Python / Go) — or inject into any external W3C/OTLP receiver.
Why it's explicit
Every Neatlogs SDK runs on a private OpenTelemetry propagator and never reads or replaces the process-global one (see OpenTelemetry isolation). That isolation is what lets Neatlogs run beside Datadog, Sentry, or another OTel setup without either side capturing the other's spans — but it also means cross-process continuity can't ride the global propagator. So you opt in at the exact boundary: call the inject helper right before the outbound request, and the extract helper right after the inbound one.
The two sides
| Direction | Python | TypeScript | Go |
|---|---|---|---|
| Inject (caller, before sending) | inject_trace_context(carrier) | injectTraceContext(carrier) | InjectTraceContext(ctx, carrier) |
| Extract (callee, on receipt) | extract_trace_context(carrier, …) | — (continue on the far side with a Python/Go extract, or an OTLP receiver) | ExtractTraceContext(ctx, carrier) · StartToolSpanFromHeaders(...) |
Roles differ by SDK. Python, TypeScript and Go can all inject (be the caller). Python and Go can also extract (be the callee that continues the trace); TypeScript exposes no extract/continue API, so a TypeScript service can start a trace and hand it off but can't adopt an inbound one.
carrier is anything the SDK can write headers onto: a plain Record<string,string> / map[string]string, a Headers object, or a propagation.HeaderCarrier(req.Header) in Go.
Inject is a no-op when no Neatlogs span is active — TypeScript's injectTraceContext returns false, and Go's InjectTraceContext writes nothing. Guard on that return (or just send the request; an un-parented remote trace is still valid, it's simply not linked).
End-to-end: a TypeScript agent calling a Go service
This is the common shape: a TypeScript agent runtime owns the turn's trace and proxies a tool call to a Go microservice. Inject on the fetch, extract on the handler, and the Go work lands inside the TypeScript trace.
Caller — TypeScript
import { injectTraceContext } from 'neatlogs';
const headers: Record<string, string> = { 'content-type': 'application/json' };
// Write the active Neatlogs span onto the outbound headers as W3C traceparent.
injectTraceContext(headers);
await fetch(`${goServiceUrl}/runtime/tools/${toolName}`, {
method: 'POST',
headers,
body: JSON.stringify({ input }),
});Callee — Go
You have two options on the receiving side.
Continue the trace and open your own spans — use this when the remote already owns the canonical span for the operation and you just want your Go-side work (retrievers, DB calls, logs) filed under the same trace:
import "go.opentelemetry.io/otel/propagation"
func handleTool(c *gin.Context) {
// Continue the caller's Neatlogs trace. Does NOT touch the global OTel /
// Datadog context.
ctx := neatlogs.ExtractTraceContext(
c.Request.Context(),
propagation.HeaderCarrier(c.Request.Header),
)
// Trace context carries the parent linkage only — session & end-user do NOT
// ride the traceparent, so re-bind them here from the request.
ctx = neatlogs.Identify(ctx, neatlogs.IdentifyOptions{
SessionID: sessionID,
EndUserID: userID,
})
// Any span opened under ctx now shares the caller's trace_id.
ctx, r := neatlogs.StartRetrieverSpan(ctx, "lookup", query, 5)
defer r.End()
// ...
}Extract, identify, and open a tool span in one call — use StartToolSpanFromHeaders when the Go service is the one that should own the tool span:
ctx, tool := neatlogs.StartToolSpanFromHeaders(
c.Request.Context(),
c.Request.Header,
"get_account", // tool name
input, // serialized input
neatlogs.IdentifyOptions{SessionID: sessionID, EndUserID: userID},
)
defer tool.End()
result := doWork(input)
tool.SetOutput(result)Don't create the same span twice. If the calling service already owns the canonical TOOL span (e.g. a Mastra/agent runtime that traces every proxied tool call), continue with ExtractTraceContext and open only your own child spans — using StartToolSpanFromHeaders there would produce a second, duplicate tool span with the same name. Pick StartToolSpanFromHeaders only when the remote is the span's true owner.
Identity does not ride the wire
The traceparent carries trace and span linkage only — not the session or end-user. Re-bind identity on the remote from the request payload (that's why both examples call Identify / pass IdentifyOptions). Send the ids you need — session_id, end_user_id — in your request body or your own headers, then bind them on the far side.
Python
Python is a full participant — it both injects and extracts, mirroring Go. Import the two helpers straight off the package.
Use the explicit helpers below, not a bare opentelemetry.propagate.inject(headers). The bare call reads the global OpenTelemetry propagator, which under Neatlogs' private-provider isolation does not see the active Neatlogs span — so it writes an empty carrier. neatlogs.inject_trace_context(...) resolves the active span from Neatlogs' private context and injects that.
Caller — inject before sending
import neatlogs
with neatlogs.trace("py_caller"):
headers = {"content-type": "application/json"}
# Write the active Neatlogs span onto the outbound headers as W3C traceparent.
if neatlogs.inject_trace_context(headers):
requests.post(url, headers=headers, json=payload)inject_trace_context(carrier) takes any mutable header mapping (a plain dict, a requests CaseInsensitiveDict, …). It returns True when a Neatlogs span was active and headers were written, False when there was nothing to propagate — the carrier is left untouched. An upstream traceparent already on the carrier is preserved, never overwritten.
Callee — extract on receipt
import neatlogs
@app.post("/tool")
def handle(req):
# Continue the caller's Neatlogs trace for the enclosed block.
with neatlogs.extract_trace_context(
req.headers,
session_id=req.session_id, # identity does NOT ride the wire — re-bind it
end_user_id=req.user_id,
):
with neatlogs.trace("do_tool"): # joins the caller's trace as a child
...extract_trace_context(carrier, *, session_id=None, end_user_id=None, end_user_metadata=None) is a context manager. Inside it, the next trace() / @span / wrap() root nests under the remote span and shares its trace_id instead of starting a fresh root. When the carrier has no valid traceparent it's a no-op passthrough (any identity you pass still binds).
Isolation-safe. The remote parent is threaded on Neatlogs' private context key, never the OTel global current-span — so a co-tenant instrumentor (Datadog / Langfuse / OpenLLMetry) neither sees it nor parents from it, and Neatlogs never adopts their active span when injecting. This is the same private-provider guarantee described in OpenTelemetry isolation.
See also
- OpenTelemetry isolation — why propagation is opt-in and how Neatlogs stays separate from a co-tenant tracer.
- Python SDK —
inject_trace_context/extract_trace_contextreference. - TypeScript SDK —
injectTraceContextreference. - Go SDK —
InjectTraceContext/ExtractTraceContext/StartToolSpanFromHeadersreference. - Sessions · End-User Identity — the identity you re-bind on the remote side.
