Secondary Clients

Send some traces to a different Neatlogs project entirely, from the same process — without a second init().

Warning

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 WORKFLOW root with a workflow_name override — see Multiple Workflows in One Codebase. Don't use Client for this; it's heavier than you need.
  • Genuinely different project (different API key)? That's what Client is 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 manual neatlogs.trace(...) / @neatlogs.span(...) spans
  • neatlogs.log(...) structured logs (only if the Client was constructed with capture_logs=True)
  • the auto-created WORKFLOW root's name, which comes from the active client's own workflow_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 running

A 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

On this page

Ask Neatlogs AI

Answers from the docs

How can I help?

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