Secondary Clients

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

Note

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 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",  # 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 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.

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

On this page

Ask Neatlogs AI

Answers from the docs

How can I help?

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