Skip to Content
RiusSDKTracing your code

Tracing your code

The Rius SDK gives you three levels of control: observe for whole functions, generic spans for arbitrary steps, and generations for LLM calls.

Tracing functions with observe

Apply observe to any function, as a decorator in Python or a wrapper in TypeScript, to trace each call as a span:

import rius from rius import SpanKind @rius.observe def plan(query: str) -> str: ... @rius.observe(name="rerank", kind=SpanKind.RERANKER, capture_output=False) def rerank(docs: list[str]) -> list[str]: ...
  • name sets the span name. Left out, a CHAIN span (the default kind) takes the function’s qualified name, and every other kind is named the way the OpenTelemetry GenAI conventions ask, such as execute_tool search_orders or invoke_agent support-agent; see Span names and identity.
  • kind defaults to SpanKind.CHAIN; it sets the span taxonomy (AGENT, LLM, TOOL, RETRIEVER, RERANKER, EMBEDDING, CHAIN) and, for every kind except CHAIN, the matching gen_ai.operation.name. RERANKER (since Python 1.1.0 and TypeScript 1.2.0) is for a step that reorders retrieved documents by relevance; its operation is rerank.
  • capture_input / capture_output (default true) record the arguments as JSON in input.value and the return value in output.value. A string return value is recorded as-is, and anything else as JSON, serialized with a repr fallback. Values are truncated at 32 KB, the same cap the TypeScript SDK applies.
  • Exceptions are recorded on the span with ERROR status and error.type set to the exception class, and always re-raised.

Sync functions, async def functions, generators, and async generators are all supported. For generators the span covers the whole iteration, from first call to exhaustion, and the tracing context is attached only around each step, so it never leaks into the code consuming the generator between yields.

In TypeScript the options are name, kind, captureInput, and captureOutput, plus the identity options in Span names and identity. Without a name, a CHAIN span falls back to the wrapped function’s name property, then to "anonymous", and other kinds get the composed name. Around an ordinary function the wrapper returns a promise of its result, so a synchronous function is traced as one completed call. Since TypeScript 1.7.0, a generator or async generator function stays one: the wrapper returns a generator, and the span covers the whole iteration, as in Python. A generator records its input but no output.

Generic spans

For steps that are not a single function, open spans directly. There are two lifecycles:

# Context manager: becomes the current span, children nest under it, # exceptions are recorded, and it ends automatically. Unnamed, this span is # called "retrieval product-kb". with rius.start_as_current_span( kind=SpanKind.RETRIEVER, data_source_id="product-kb", top_k=5 ) as obs: obs.set_input(query) docs = search(query) obs.set_retrieved_documents([{"id": d.id, "score": d.score} for d in docs]) # Manual: parented to the current span at creation, but NOT made current. # You own the lifetime and must call end(); exceptions are not auto-recorded, # so record them yourself to match what the context manager does. obs = rius.start_span("background-job") try: obs.set_attribute("job.id", job_id) ... except Exception as exc: obs.record_exception(exc) raise finally: obs.end()

Both return an Observation handle with set_input(), set_output(), set_retrieved_documents(), set_attribute(), update(input=..., output=...), record_exception(), and end(). The TypeScript handle has setInput(), setOutput(), setRetrievedDocuments(), setAttribute(), recordException(), and end(); its setters are chainable and end() is idempotent. In both SDKs a string passed to the input or output setters is recorded as-is and any other value as JSON. Before Python 1.1.0 a string was JSON-quoted, so Paris was stored as "Paris"; a text search that matched the quoted form needs updating. record_exception() / recordException() records the exception event, the ERROR status, and error.type together, so a manually recorded failure groups with every other error span.

set_retrieved_documents() records gen_ai.retrieval.documents, a list of {"id", "score"} objects. It carries identifiers and scores, not document text, so it is metadata and survives content stripping; put retrieved text in set_output() if you want it captured as content.

Use the manual form when the span outlives a lexical scope, for example a span that ends in a callback. Use the context-manager form for everything else.

Span names and identity

The span name is optional on observe, the span helpers, and the generation helpers. Left out, the span is named the way the OpenTelemetry GenAI conventions ask: the operation, then the one identifier saying what it acted on, or the bare operation when that identifier is unknown. A name you pass always wins.

KindOperationName without an explicit one
LLM (generations)chat, or the operation you passchat gpt-4o, from the requested model
TOOLexecute_toolexecute_tool get_weather, from the tool name
AGENTinvoke_agentinvoke_agent planner, from the invoked agent’s name
RETRIEVERretrievalretrieval product-kb, from the data source id
EMBEDDINGembeddingsembeddings
RERANKERrerankrerank
CHAINnonethe function’s name with observe, chain for an unnamed manual span

Span names changed in 1.0.0. Before it, an unnamed observe span of any kind took the function’s name; now only CHAIN does. Saved searches and alert filters that match on the old span name of a TOOL, AGENT, RETRIEVER, LLM, or EMBEDDING span need updating, or pass name explicitly to keep the old one.

The identifiers come from arguments you pass at span creation, so a partial span of a still-running step already carries them. Each applies to one kind and is ignored on the others:

PythonTypeScriptKindAttribute
tool_nametoolNameTOOLgen_ai.tool.name, the tool’s own name
tool_call_idtoolCallIdTOOLgen_ai.tool.call.id, the id the model put on the tool call this span answers, which joins the tool span back to the generation that asked for it
tool_typetoolTypeTOOLgen_ai.tool.type, such as function, extension, or datastore
agent_nameagentNameAGENTgen_ai.agent.name, the agent this span invokes. Defaults to the configured agent name, never to the function name
agent_idagentIdAGENTgen_ai.agent.id, for a hosted agent resource such as a Bedrock agent ARN. Leave it unset for an in-process agent
agent_versionagentVersionAGENTgen_ai.agent.version, the invoked agent definition’s version, recorded as given
data_source_iddataSourceIdRETRIEVERgen_ai.data_source.id, the index, collection, or knowledge base searched
top_ktopKRETRIEVERgen_ai.retrieval.top_k, how many documents were asked for

The AGENT identifiers name the agent a span invokes. The agent the whole process runs is described once, on the resource, by gen_ai.main_agent.id, gen_ai.main_agent.name, and gen_ai.main_agent.description, which come from main_agent_id / mainAgentId, agent_name / agentName, and main_agent_description / mainAgentDescription. See What every span carries.

A TOOL span also carries gen_ai.agent.name, and there it means the agent executing the tool: the innermost enclosing AGENT span opened with observe or the scoped span helper, else the configured agent name. MCP tool calls get it the same way. A manual start_span / startSpan never becomes current, so an AGENT span opened that way does not name the tools that run while it is open.

Without tool_name, observe uses the function’s name as the tool name. When you pass a span name on a TOOL span but no tool name, the span name doubles as the tool name and the SDK warns about it (a DeprecationWarning from Python’s observe, a one-time console warning from TypeScript’s span helpers); a future major release stops deriving one from the other, so pass tool_name / toolName when the two should differ.

Generations (LLM calls)

Generations are spans with LLM semantics. They emit OpenTelemetry GenAI attributes that Rius uses for model analytics and cost computation:

# Unnamed, the span is called "chat gpt-4o". with rius.start_as_current_generation( model="gpt-4o", provider="openai", model_parameters={"temperature": 0.2, "max_completion_tokens": 512}, ) as gen: gen.set_input(messages) response = client.chat.completions.create(model="gpt-4o", messages=messages) gen.set_output(response.choices[0].message.content) gen.set_response_model(response.model) gen.set_response_id(response.id) gen.set_usage( input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens, ) gen.set_finish_reasons(response.choices[0].finish_reason)

start_generation() is the manual-lifecycle twin, with the same semantics as start_span(), including record_exception() for failures you catch.

The attributes emitted:

AttributeSource
gen_ai.request.modelmodel= argument
gen_ai.provider.nameprovider= argument
gen_ai.request.* / rius.request.*model_parameters, see Request parameters
gen_ai.request.reasoning.levelreasoning_level= argument
gen_ai.output.typeoutput_type= argument, the requested output modality such as text or json
gen_ai.tool.definitionstools= argument or set_tool_definitions(), recorded verbatim in the provider’s shape
gen_ai.input.messages / gen_ai.output.messagesset_input() / set_output()
gen_ai.usage.input_tokens / gen_ai.usage.output_tokensset_usage()
gen_ai.usage.cache_read.input_tokens / gen_ai.usage.cache_write.input_tokensset_usage()
gen_ai.usage.reasoning.output_tokensset_usage()
gen_ai.response.modelset_response_model()
gen_ai.response.idset_response_id(), the provider’s completion id
gen_ai.response.finish_reasonsset_finish_reasons()
gen_ai.operation.nameoperation= argument (default chat)
rius.context.sizescomputed by the SDK when the span ends, see below

In TypeScript the same attributes come from the model, provider, modelParameters, reasoningLevel, outputType, tools, and operation options, and from setToolDefinitions(), setInput() / setOutput(), setUsage(), setModel(), setResponseId(), and setFinishReasons().

Every generation span also carries rius.context.sizes: a compact JSON breakdown of the byte size of each tool definition and each input and output message part, measured before content is truncated. Rius uses it to divide the call’s input tokens across the parts of the context. It holds sizes, not content, so it survives capture_content=False and is never masked.

Request parameters

model_parameters (modelParameters) takes the parameters you sent the model, and each lands under the attribute its meaning calls for:

  • A parameter the GenAI conventions define is recorded under its canonical gen_ai.request.* key, whichever spelling you pass: max_tokens, max_completion_tokens, max_output_tokens, and maxOutputTokens all become gen_ai.request.max_tokens; stop and stop_sequences become gen_ai.request.stop_sequences; n becomes gen_ai.request.choice.count; reasoning_effort becomes gen_ai.request.reasoning.level.
  • Anything else keeps its key under rius.request., for example rius.request.tool_choice or rius.request.response_format. The conventions reserve gen_ai.request.* for the keys they define, so a parameter of your own does not go there.
  • Each canonical key only takes a value of its own type: a lone stop string becomes a one-element list, and a value of the wrong type (a temperature passed as a string, say) is kept under rius.request.<key> instead of being dropped.
  • When two spellings of one parameter arrive together, a fixed precedence decides which one gets the canonical key, whatever order you wrote them in, and the other is kept under rius.request.<key>.
  • Values that are not scalars or lists of one scalar type are JSON-encoded. None / null / undefined means “not set” and is skipped. The model and reasoning_level arguments win over a parameter that maps to the same key.

This changed in 1.0.0. Earlier releases wrote every key as gen_ai.request.<key> verbatim, so a query on gen_ai.request.max_completion_tokens or on a custom parameter under gen_ai.request. stops matching new spans. Query the canonical key, or rius.request.<key> for your own parameters.

A tools or functions array passed through model_parameters is treated as content, like gen_ai.tool.definitions, and stripped when content capture is off. Pass tool definitions with tools= instead.

Send token usage, never cost: Rius computes cost server-side from model pricing, so your instrumentation stays free of pricing tables.

When the provider uses prompt caching, pass the cache counts too. Since glassflow-rius 0.13.0 and @glassflow-ai/rius 0.4.0, set_usage() also takes cache_read_input_tokens and cache_write_input_tokens (cacheReadInputTokens / cacheWriteInputTokens in TypeScript; Anthropic calls the write count “cache creation” in its payloads). Pass provider-reported values as-is, whatever their shape. Per the GenAI conventions input_tokens is the total including cached tokens, and OpenAI’s cached_tokens is already a subset of prompt_tokens; Anthropic instead reports an input_tokens that excludes cache reads and writes. When the generation’s provider is "anthropic", set_usage() adds the two cache counts to input_tokens for you, so no client arithmetic is needed either way. Pass provider="anthropic" (provider: 'anthropic' in TypeScript) for every Claude call, including Claude served through Bedrock or Vertex AI: with another provider name the SDK can’t tell the shape apart, and Rius falls back to a best-effort server-side guess. Skipping the cache fields makes cost badly undercount cache-heavy workloads such as agent loops.

Streaming responses

When you stream, total duration stops being a useful latency signal: a long generation usually just means a long answer. The number streaming users feel is time to first token, and the SDK records it with one call from your streaming loop:

gen = rius.start_generation(model="gpt-4o", provider="openai") gen.set_input(messages) chunks = [] stream = client.chat.completions.create(model="gpt-4o", messages=messages, stream=True) for chunk in stream: gen.record_first_token() # idempotent; call it unconditionally per chunk if chunk.choices and chunk.choices[0].delta.content: chunks.append(chunk.choices[0].delta.content) gen.set_output("".join(chunks)) gen.end()

record_first_token() / recordFirstToken() records the moment the first chunk arrives and marks the call as streamed. Only the first call records anything in either SDK, so guarding it yourself is unnecessary. Rius derives time to first token (TTFT) from it, which unlocks per-model TTFT percentiles and inter-token latency in your analytics. The span attribute reference lists the keys it writes.

If you use auto-instrumentation instead of manual generations:

  • TTFT is recorded automatically for streams through the Python openai and LlamaIndex integrations, Pydantic AI, and the Vercel AI SDK. In Python, a LangChain or LiteLLM stream over the openai integration gets it on its nested OpenAI span.
  • TTFT is not recorded for Anthropic streams, TypeScript OpenAI streams, and other LangChain and LiteLLM streams. Trace those calls as a manual generation as above; see No time to first token.

Message formats

set_input() and set_output() normalize whatever you pass into the GenAI message shape ({"role", "parts": [...]}):

  • a bare string becomes a single text part (role user on input, assistant on output)
  • OpenAI-style dicts ({"role", "content"}) are converted, including tool_calls arrays and role: "tool" responses
  • multimodal content lists are converted part by part
  • anything else is serialized to a JSON text part

You do not need to pre-shape messages; pass what your client library gives you. Both SDKs normalize the same way and write the same JSON, so spans from Python and TypeScript agents read alike in the console and in saved queries.

Sessions

A trace covers one causal unit of work and stays short. A session is a correlation unit your application assigns: one conversation, one agent run, anything that spans several turns and therefore several traces. You mint the id, and the SDK stamps it as the session.id attribute on every span started in scope.

Scope a session around each unit of work, passing your own id (a conversation id, a job id) to correlate with it:

import rius with rius.session(conversation_id): handle_turn(message) # every span of the turn carries session.id

Call rius.session() with no argument to mint a fresh UUID for the scope. Either way the block yields the id, so you can log it or hand it to the next turn:

with rius.session() as session_id: run_agent(task)

Scopes ride OpenTelemetry context, not a global: they nest (the innermost wins), unwind with the block even on an error, and follow async work the same way the active span does.

A process-wide default

For an agent that handles exactly one session per process, a CLI run or a batch job, set the id once at startup instead of scoping every call, with init(session_id=...) in Python, init({ sessionId }) in TypeScript, or the RIUS_SESSION_ID environment variable in either language. An active session scope overrides the default.

Do not set a process-wide default in a server that handles many users: every user’s traces would merge into one session. Scope each request instead.

With neither a scope nor a default, spans carry no session.id and the platform groups each trace as its own session, which is the right behavior when you have not claimed anything spans turns. The SDK deliberately never auto-generates a process-wide id for the same reason.

Users

A user is the person a request served. Where a session groups the turns of one conversation, the user ties together everything that person did across conversations, agents, and days. You supply the identifier your application already has for them, and the SDK stamps it as the user.id attribute on every span started in scope, including spans created by auto-instrumentation.

Scope the user around each request:

import rius with rius.user(request.user_id): handle(request) # every span of the request carries user.id

For a single span with no children of its own, user_id= on start_span, start_as_current_span, start_generation, and start_as_current_generation is sugar for the same thing. The block-scoped variants also enter the scope, so their children inherit the user.

User scopes ride OpenTelemetry context exactly like sessions: they nest (the innermost wins), unwind with the block, follow async work, and combine freely with a session scope. observe deliberately takes no user argument, because a decorator is bound once at import time and a user is a property of each request.

Two things differ from sessions on purpose. There is no process-wide default and no environment variable: a global user would attribute every request a process handles to one person. And nothing is minted when you pass nothing: a span outside any user scope is simply anonymous, and the platform treats an empty user as exactly that.

Prefer an opaque identifier over an email or a name. The value is stored on every span and shown in the console, so send what you would be comfortable seeing in a trace. When only sensitive identifiers exist, hash them first; OpenTelemetry’s user.hash and enduser.pseudo.id describe that practice.

Workspace scopes

Sessions and users have a sibling scope for multi-tenant runtimes: rius.workspace(alias) / withWorkspace(alias, fn) routes every span started inside it to another workspace’s destination, registered via init(workspaces={...}). It rides OpenTelemetry context exactly like sessions, so the two nest together, but a trace must stay inside one workspace. The full pattern lives in the multi-tenant observability guide.

Next steps

Last updated on