End-User Identity

Attach your app's end-user to every trace to analyze usage, cost, and errors by customer and segment.

The end-user is the user of your application — the person interacting with the AI product you built. Attaching it to every trace turns raw telemetry into customer analytics: you can slice usage, cost, latency, and errors by user and by segment (plan tier, region, cohort), see who your power users are versus who's churning, spot the one customer a bug actually affects, and pull up a specific person's history when they reach out to support. Anonymous traces answer "is the agent working?"; end-user identity answers "for whom, at what cost, and are they coming back?"

Note

End-user is not the operator user_id. user_id (set on init()) identifies whoever runs the SDK — a developer, a service account. end_user_id identifies your application's user.

The model: one end-user per session

End-user is session-level — a session belongs to one end-user. A multi-turn chat is one session with many traces, all the same person; a single workflow run is a session of one trace (where session_id = trace_id). Either way, every trace in the session carries the same end-user.

You declare it on the trace root of each turn — the WORKFLOW decorator or the top-level trace() — in the same place, and the same way, you set the session_id. Pass the same end_user_id on every turn of a conversation. Setting it on a nested child span has no effect; the root owns the identity, and Neatlogs rolls it up to the trace and its session.

On the trace root (the common case)

On a server that handles many users, read the id from your per-request context and set it on the root you open in the handler — never hardcode it. Set the session here too:

@app.post("/chat")
def chat(req):
    with neatlogs.trace(
        "chat",
        session_id=f"conv_{req.conversation_id}",
        end_user_id=str(req.user.id),
        end_user_metadata={"plan": req.user.plan},   # optional, arbitrary fields
    ):
        return run_agent(req.message)   # child spans inherit the trace's identity

Decorated workflow root

When your entry point is a decorated function, set it on the decorator:

@neatlogs.span(kind="WORKFLOW", session_id=f"conv_{cid}", end_user_id=str(user_id))
def handle_request(request):
    ...

Wrapper-only code: identify()

If you only use a wrapper (neatlogs.wrap(...) / WrapGenAI) and don't open a root yourself, bind the end-user (and session) with identify() — the wrapper's auto-root picks it up:

client = neatlogs.wrap(OpenAI())

with neatlogs.identify(
    session_id=f"conv_{cid}",
    end_user_id=str(user_id),
    end_user_metadata={"plan": "pro"},
):
    client.chat.completions.create(...)   # auto-root carries the end-user
Warning

End-user is per-request, so it is not set on init(). On a shared server, a process-global default would tag every user's traces with the same id. Always set it at the request boundary — on the trace root, or via identify() for wrapper-only code.

Browser SDK

In neatlogs/browser, set the end-user once on the client, or per call:

const nl = new Neatlogs({
  apiKey: 'nlw_...',
  project: 'my-app',
  endUserId: 'u_812',                       // default for every trace
  endUserMetadata: { plan: 'pro' },
  sessionId: conversationId,                // pair the end-user with their session
});

// or per call (overrides the client default):
await nl.trackAI({ name: 'chat', model: 'gpt-4o', input, output, endUserId: 'u_999' });

The browser SDK uses the same field names as the Node/Python SDKsendUserId, endUserMetadata, sessionId. It also takes a first-class sessionId (client default or per call) — see Sessions.

Filtering by end-user

Once traces carry an end-user, open the Filters on the traces page and add the End-user filter — is, contains, starts with, etc. — exactly like the Workflow filter. Custom fields in end_user_metadata are filterable via the metadata: filter (e.g. metadata:plan is pro).

Reference

PythonTypeScriptGo
On the roottrace(end_user_id=, end_user_metadata=) · @span(...)trace({endUserId, endUserMetadata}) · span({...})Identify(ctx, IdentifyOptions{EndUserID, EndUserMetadata}) then Trace(ctx, name)
Wrapper-onlywith identify(end_user_id=, end_user_metadata=)identify({endUserId, endUserMetadata}, fn)Identify(ctx, IdentifyOptions{...}) (same)
Canonical attributesneatlogs.end_user.id · neatlogs.end_user.metadata (root span)samesame

Related: Sessions · Tags · PII Redaction

On this page

Ask Neatlogs AI

Answers from the docs

How can I help?

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