Sessions and context
A trace is one request. A conversation spans many requests. The platform groups traces into a conversation by session id, identifies the person by user id, and lets you filter by tags and metadata.
The SDK cannot infer these. You declare them once, at the point in your code that knows them, and every span opened inside carries them.
Declaring a session and a user
import hajer
def handle_turn(conversation_id: str, customer_id: str, question: str) -> str:
with hajer.session(conversation_id), hajer.user(customer_id):
return agent.run(question)
hajer.session(session_id) and hajer.user(user_id) are shorthands for hajer.context(...):
hajer.context(*, session=None, user=None, tags=(), metadata=None)
@hajer.context(session="conv_8f2a", user="u_1042", tags=["beta"], metadata={"plan": "pro", "seats": 3})
async def handle(request):
...
Like declared spans, each of session, user and context works as a decorator on a sync or async function, as a with block, and as an async with block.
user.id is exported to Hajer. Use an opaque id from your own system, never a name, an email address or a phone number.
What gets the attributes
Every span opened inside the block carries the context:
hajer.workflow,hajer.componentandhajer.toolspans,- model spans from
wrap,instrument()or attach mode, and - spans from any other OpenTelemetry instrumentation on the same tracer provider (an HTTP server, a database client, another GenAI instrumentation). On those, each attribute is added only if the span does not already set it.
The context is stored in a contextvars.ContextVar, so it follows await, reaches tasks created inside the block, and reaches threads that run with a copy of the context. asyncio.to_thread copies the context; a plain ThreadPoolExecutor.submit does not, so wrap the function with contextvars.copy_context().run if you need it there. A streamed model call keeps the context that was active when the call was opened, wherever you consume the stream.
Nesting
Declarations merge. An inner declaration adds to or overrides the outer one, key by key:
sessionanduser: the inner value wins when given; otherwise the outer value is kept.tags: accumulate in order. Duplicates are dropped.metadata: merged by key; the inner value wins for the same key.
with hajer.context(session="conv_1", tags=["web"], metadata={"plan": "pro"}):
with hajer.context(tags=["beta"], metadata={"plan": "enterprise", "region": "eu"}):
...
# session.id = "conv_1"
# hajer.tags = ("web", "beta")
# hajer.metadata.plan = "enterprise"
# hajer.metadata.region = "eu"
When the block exits, the outer context is restored.
Limits
Values over a limit are clipped, never refused, so a label never costs you the request it is attached to.
| Limit | Value | What happens past it |
|---|---|---|
| Tags | 16 | Tags after the 16th are dropped. |
| Metadata keys | 32 | Keys after the 32nd are dropped. |
| Length of one value | 1024 characters | The session id, user id, each tag, each metadata key and each string metadata value is truncated. |
Metadata values
A metadata value must be a str, int, float or bool. Any other value, such as a dict or list, raises TypeError where you create the context:
hajer.context(metadata={"order": {"id": 1}})
# TypeError: hajer.context metadata 'order' is dict; a value is a string, a number or a boolean ...
This is one of the few places the SDK raises. It happens where you write the declaration, not during a request in production. Put structured data in your span content, not in labels.
Span attributes
The context is exported as these attributes:
| Attribute | From |
|---|---|
session.id | session |
user.id | user |
hajer.tags | tags, as a string array |
hajer.metadata.<key> | one attribute per metadata key |
Every span also carries:
| Attribute | From |
|---|---|
hajer.workflow.id, hajer.component.id | the enclosing declared spans |
deployment.environment.name and deployment.environment | HAJER_ENVIRONMENT (see Configuration) |
Under hajer eval, spans also carry hajer.eval.run.id, hajer.eval.test_case.id, hajer.eval.workflow.id and hajer.eval.obligation.ids. See Evals.
Example: a web handler
Enter the context around the workflow, so the workflow span and everything inside it carry the session:
import hajer
from openai import AsyncOpenAI
client = hajer.wrap(AsyncOpenAI())
@hajer.workflow("wf_support")
async def support_turn(question: str) -> str:
response = await client.chat.completions.create(
model="gpt-5",
messages=[{"role": "user", "content": question}],
)
return response.choices[0].message.content
async def on_message(conversation_id: str, account_id: str, plan: str, question: str) -> str:
async with hajer.context(session=conversation_id, user=account_id, metadata={"plan": plan}):
return await support_turn(question)
Both the workflow wf_support span and the chat gpt-5 generation carry session.id, user.id and hajer.metadata.plan. If you enter the context inside the workflow instead, only the spans opened after it carry the attributes; the workflow span itself does not.