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.RETRIEVER, capture_output=False) def rerank(docs: list[str]) -> list[str]: ...
  • name defaults to the function’s qualified name.
  • kind defaults to SpanKind.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 in input.value / output.value. Values are serialized with a repr fallback 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:

# 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:

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:

AttributeSource
gen_ai.request.modelmodel= argument
gen_ai.provider.nameprovider= argument
gen_ai.request.<param>each key of model_parameters
gen_ai.input.messages / gen_ai.output.messagesset_input() / set_output()
gen_ai.usage.input_tokens / gen_ai.usage.output_tokensset_usage()
gen_ai.response.modelset_response_model()
gen_ai.response.finish_reasonsset_finish_reasons()
gen_ai.operation.nameoperation= 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.

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("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 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.

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:

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.

Next steps

Last updated on