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.RETRIEVER, capture_output=False)
def rerank(docs: list[str]) -> list[str]:
...namedefaults to the function’s qualified name.kinddefaults toSpanKind.CHAIN; it sets the span taxonomy (AGENT,LLM,TOOL,RETRIEVER,EMBEDDING,CHAIN).capture_input/capture_output(default true) record the arguments and return value as JSON ininput.value/output.value. Values are serialized with areprfallback and truncated at 8192 characters.- Exceptions are recorded on the span with ERROR status 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. name falls back to the wrapped function’s name property,
then to "anonymous". The wrapper awaits the return value, so a synchronous
function is traced as one completed call and there is no per-step generator
handling.
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.
with rius.start_as_current_span("retrieve", kind=SpanKind.RETRIEVER) as obs:
obs.set_input(query)
docs = search(query)
obs.set_output([d.id 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.
obs = rius.start_span("background-job")
try:
obs.set_attribute("job.id", job_id)
...
finally:
obs.end()Both return an Observation handle with set_input(), set_output(),
set_attribute(), update(input=..., output=...), and end(). The TypeScript
handle has setInput(), setOutput(), setAttribute(), recordException(),
and end(); its setters are chainable and end() is idempotent.
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.
Generations (LLM calls)
Generations are spans with LLM semantics. They emit OpenTelemetry GenAI attributes that Rius uses for model analytics and cost computation:
Python
with rius.start_as_current_generation(
"chat gpt-4o",
model="gpt-4o",
provider="openai",
model_parameters={"temperature": 0.2},
) 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_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().
The attributes emitted:
| Attribute | Source |
|---|---|
gen_ai.request.model | model= argument |
gen_ai.provider.name | provider= argument |
gen_ai.request.<param> | each key of model_parameters |
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.response.model | set_response_model() |
gen_ai.response.finish_reasons | set_finish_reasons() |
gen_ai.operation.name | operation= argument (default chat) |
In TypeScript the same attributes come from model, provider,
modelParameters, setInput() / setOutput(), setUsage(), setModel(),
and setFinishReasons(). There is no operation option: gen_ai.operation.name
follows the span kind, so a generation is always chat.
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. Rius detects the exclusive shape and
normalizes it server-side, so no client arithmetic is needed either way.
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("chat gpt-4o", 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() marks the moment the first chunk arrives (as a
gen_ai.first_token span event). Only the first call records anything, so
guarding it yourself is unnecessary. Rius derives time to first token
from the event, which unlocks per-model TTFT percentiles and inter-token
latency in your analytics.
The TypeScript recordFirstToken() is not idempotent: it adds a
gen_ai.first_token event on every call, so guard it with a flag as in the
snippet above.
Requires glassflow-rius 0.5.0 or newer. If you use the openai
auto-instrumentation instead of manual generations,
streamed OpenAI calls get a first-token marker automatically.
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.
The TypeScript SDK does no such normalization: setInput() and setOutput()
record strings, numbers, and booleans as-is and serialize anything else to
JSON, with circular references replaced by [Circular]. Pass messages already
in the shape you want to query on.
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.
Workspace scopes
Sessions 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.