Secondary Clients
Send some traces to a different Neatlogs project entirely, from the same process — without a second init().
Python only, available from neatlogs>=1.4.19. TypeScript and Go don't have this yet.
neatlogs.init() configures one process-wide export pipeline — one API key, one project. Calling it a second time to point at a different project is a no-op (see Workflow Name and Multiple Workflows for why, and for the different problem those pages solve: 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 true singleton — call it once, ever. 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", # override for self-hosted / EU / etc.
tags=["prod"],
capture_logs=False, # set True to route neatlogs.log() calls too
batch_size=100,
flush_interval=5.0, # seconds
disable_export=False, # for tests — build the pipeline without sending anywhere
tracer_provider=None, # pass your OWN TracerProvider to own its lifecycle instead
)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.
See also
- Multiple Workflows in One Codebase — the lighter-weight tool for "different workflow, same project."
- Workflow Name — why a second
init()call is a no-op.
