Secondary Clients
Send some traces to a different Neatlogs project entirely, from the same process — without a second init().
Available in all three SDKs: Python neatlogs.Client (from neatlogs>=1.4.19), TypeScript Client, and Go neatlogs.NewClient. The examples below use Python; see TypeScript and Go for their equivalents.
neatlogs.init() configures one process-wide export pipeline - one API key, one project. Calling it again with a different project key raises a configuration error until you shut the default pipeline down, so it cannot serve two projects at once (see Flush and Shutdown, and Multiple Workflows for the different problem of multiple workflow labels inside the same project).
Client is for a genuinely different case: you need some traces from this process to go to a different project — different API key, different exporter, fully isolated pipeline — not just a different workflow label under the same project. The common trigger is multi-tenant: each tenant has their own Neatlogs project, and one shared process serves all of them.
init() stays a singleton - one active default pipeline per process. Client isn't: it's a plain class with no such guard, so a multi-tenant process typically builds and caches one Client per tenant (keyed by tenant id), not a single extra one. init()'s own project is unaffected no matter how many Clients exist alongside it - they're fully independent of it and of each other.
When to reach for this — and when not to
- Same project, want traces grouped separately? Use a named
WORKFLOWroot with aworkflow_nameoverride — see Multiple Workflows in One Codebase. Don't useClientfor this; it's heavier than you need. - Genuinely different project (different API key)? That's what
Clientis for.
How it works
neatlogs.Client(...) builds a fully independent pipeline — its own TracerProvider, its own OTLP exporter, its own LoggerProvider if you want logs. It does not replace or touch whatever init() already configured; the two coexist.
import neatlogs
# The process-wide default — unchanged, still just called once.
neatlogs.init(api_key=os.environ["NEATLOGS_API_KEY"], workflow_name="my-service")
# A second, fully isolated project — NOT another init().
tenant_client = neatlogs.Client(
api_key=tenant_api_key, # a DIFFERENT project's key
workflow_name="tenant-123",
)Activate it with client.activate() — a context manager that scopes to the current call, not the whole process:
with tenant_client.activate():
# Everything in here — neatlogs.wrap(), neatlogs.trace(), @neatlogs.span(),
# neatlogs.log() — routes to tenant_client's pipeline, not the default one.
client = neatlogs.wrap(OpenAI())
with neatlogs.trace("handle_request", kind="WORKFLOW"):
response = client.chat.completions.create(...)
# Outside the block, everything is back to the default init() pipeline.client.wrap(target) is a shortcut for the common case — activate, then wrap, in one call:
wrapped = tenant_client.wrap(OpenAI()) # same as: with tenant_client.activate(): neatlogs.wrap(OpenAI())Isolation is real, not just labeling
activate() sets a contextvars.ContextVar, so it's scoped correctly per async task and per thread — concurrent requests for different tenants, running at the same time in the same process, do not leak into each other's traces. This is tested directly (tests/unit/test_secondary_client.py): two clients activated inside two concurrently-running asyncio coroutines each only ever see their own spans in their own exporter, never the other's.
This isolation covers everything, not just wrap():
neatlogs.wrap(...)and manualneatlogs.trace(...)/@neatlogs.span(...)spansneatlogs.log(...)structured logs (only if theClientwas constructed withcapture_logs=True)- the auto-created
WORKFLOWroot's name, which comes from the active client's ownworkflow_name, not the process default
Nested/recursive entry points: if the same logical run can be entered more than once in-process (e.g. a handler invoked directly for the same request it already handles), track the active WORKFLOW span — a ContextVar works — and reuse it instead of activating the client again, so you get one root instead of a WORKFLOW-inside-WORKFLOW staircase. Only the outermost call should flush.
Constructor options
neatlogs.Client(
api_key=..., # required unless disable_export=True
workflow_name=..., # required
endpoint="https://ingest.neatlogs.com", # use https://eu.ingest.neatlogs.com for an EU project
tags=["prod"],
capture_logs=False, # set True to route neatlogs.log() calls too
batch_size=100,
flush_interval=5.0, # seconds
sample_rate=1.0, # fraction of traces to export
mask=None, # client-side transform, same contract as init(mask=...)
disable_export=False, # for tests - build the pipeline without sending anywhere
tracer_provider=None, # pass your OWN TracerProvider to own its lifecycle instead
)Python and TypeScript Client instances do not read NEATLOGS_ENDPOINT; pass endpoint explicitly when the project is not in the US region. A Go client falls back to NEATLOGS_ENDPOINT like Init does.
Lifecycle
Each Client manages its own flush/shutdown, independent of the default pipeline and of every other Client:
tenant_client.flush() # flush only this client's buffered spans/logs
tenant_client.shutdown() # flush + close this client only — others keep runningA Client also registers its own atexit shutdown hook, so it flushes on normal process exit even if you never call shutdown() explicitly - same safety net init() already gives you for the default pipeline. In a long-running process, still call flush() explicitly at the end of each run - atexit only fires at process exit, so relying on it alone delays visibility until the whole process shuts down.
TypeScript and Go
TypeScript's Client takes the same core options in camelCase (apiKey, workflowName, endpoint, tags, captureLogs, batchSize, flushInterval, sampleRate, mask, disableExport). Activate it around each execution with a callback, and await shutdown() when the destination is no longer needed:
import OpenAI from 'openai';
import { Client, trace, wrapOpenAI } from 'neatlogs';
const tenant = new Client({
apiKey: process.env.TENANT_NEATLOGS_API_KEY!, // a DIFFERENT project's key
workflowName: 'tenant-123',
});
const openai = wrapOpenAI(new OpenAI()); // routing happens at call time
await tenant.activate(async () => {
await trace({ name: 'handle_request', kind: 'WORKFLOW' }, async () => {
await openai.chat.completions.create(/* ... */);
});
});
await tenant.shutdown();Go's neatlogs.NewClient(ctx, cfg) builds an independent pipeline from a Config. Bind it to a context with neatlogs.WithClient(ctx, client) (or client.Context(ctx)); every wrapper and span helper that receives that context routes to the client. Clients never install signal handlers.
tenant, err := neatlogs.NewClient(ctx, neatlogs.Config{
APIKey: tenantAPIKey,
WorkflowName: "tenant-123",
})
if err != nil {
return err
}
defer tenant.Shutdown(context.Background())
ctx = neatlogs.WithClient(ctx, tenant)
ctx, span, end := neatlogs.Trace(ctx, "handle_request")
defer end()Each TypeScript and Go client has its own flush()/Flush(ctx) and shutdown()/Shutdown(ctx). TypeScript's flushAll() drains every registered client; Go has no process-wide flush. See Flush and Shutdown.
See also
- Multiple Workflows in One Codebase — the lighter-weight tool for "different workflow, same project."
- Workflow Name - the process-wide default label that
init()sets.
