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:
Python
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]:
...namesets the span name. Left out, aCHAINspan (the default kind) takes the function’s qualified name, and every other kind is named the way the OpenTelemetry GenAI conventions ask, such asexecute_tool search_ordersorinvoke_agent support-agent; see Span names and identity.kinddefaults toSpanKind.CHAIN; it sets the span taxonomy (AGENT,LLM,TOOL,RETRIEVER,RERANKER,EMBEDDING,CHAIN) and, for every kind exceptCHAIN, the matchinggen_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 isrerank.capture_input/capture_output(default true) record the arguments as JSON ininput.valueand the return value inoutput.value. A string return value is recorded as-is, and anything else as JSON, serialized with areprfallback. Values are truncated at 32 KB, the same cap the TypeScript SDK applies.- Exceptions are recorded on the span with ERROR status and
error.typeset 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:
Python
# 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.
| Kind | Operation | Name without an explicit one |
|---|---|---|
LLM (generations) | chat, or the operation you pass | chat gpt-4o, from the requested model |
TOOL | execute_tool | execute_tool get_weather, from the tool name |
AGENT | invoke_agent | invoke_agent planner, from the invoked agent’s name |
RETRIEVER | retrieval | retrieval product-kb, from the data source id |
EMBEDDING | embeddings | embeddings |
RERANKER | rerank | rerank |
CHAIN | none | the 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:
| Python | TypeScript | Kind | Attribute |
|---|---|---|---|
tool_name | toolName | TOOL | gen_ai.tool.name, the tool’s own name |
tool_call_id | toolCallId | TOOL | gen_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_type | toolType | TOOL | gen_ai.tool.type, such as function, extension, or datastore |
agent_name | agentName | AGENT | gen_ai.agent.name, the agent this span invokes. Defaults to the configured agent name, never to the function name |
agent_id | agentId | AGENT | gen_ai.agent.id, for a hosted agent resource such as a Bedrock agent ARN. Leave it unset for an in-process agent |
agent_version | agentVersion | AGENT | gen_ai.agent.version, the invoked agent definition’s version, recorded as given |
data_source_id | dataSourceId | RETRIEVER | gen_ai.data_source.id, the index, collection, or knowledge base searched |
top_k | topK | RETRIEVER | gen_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:
Python
# 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:
| Attribute | Source |
|---|---|
gen_ai.request.model | model= argument |
gen_ai.provider.name | provider= argument |
gen_ai.request.* / rius.request.* | model_parameters, see Request parameters |
gen_ai.request.reasoning.level | reasoning_level= argument |
gen_ai.output.type | output_type= argument, the requested output modality such as text or json |
gen_ai.tool.definitions | tools= argument or set_tool_definitions(), recorded verbatim in the provider’s shape |
gen_ai.input.messages / gen_ai.output.messages | set_input() / set_output() |
gen_ai.usage.input_tokens / gen_ai.usage.output_tokens | set_usage() |
gen_ai.usage.cache_read.input_tokens / gen_ai.usage.cache_write.input_tokens | set_usage() |
gen_ai.usage.reasoning.output_tokens | set_usage() |
gen_ai.response.model | set_response_model() |
gen_ai.response.id | set_response_id(), the provider’s completion id |
gen_ai.response.finish_reasons | set_finish_reasons() |
gen_ai.operation.name | operation= argument (default chat) |
rius.context.sizes | computed 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, andmaxOutputTokensall becomegen_ai.request.max_tokens;stopandstop_sequencesbecomegen_ai.request.stop_sequences;nbecomesgen_ai.request.choice.count;reasoning_effortbecomesgen_ai.request.reasoning.level. - Anything else keeps its key under
rius.request., for examplerius.request.tool_choiceorrius.request.response_format. The conventions reservegen_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
stopstring becomes a one-element list, and a value of the wrong type (a temperature passed as a string, say) is kept underrius.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/undefinedmeans “not set” and is skipped. Themodelandreasoning_levelarguments 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:
Python
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
openaiand LlamaIndex integrations, Pydantic AI, and the Vercel AI SDK. In Python, a LangChain or LiteLLM stream over theopenaiintegration 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
useron input,assistanton output) - OpenAI-style dicts (
{"role", "content"}) are converted, includingtool_callsarrays androle: "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:
Python
import rius
with rius.session(conversation_id):
handle_turn(message) # every span of the turn carries session.idCall 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:
Python
import rius
with rius.user(request.user_id):
handle(request) # every span of the request carries user.idFor 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
- Integrations: let the SDK create generations for you.
- Advanced features: sampling and privacy controls over what you just captured.