Skip to Content

How do I set the prompt and completion message attribute on an OpenTelemetry span in Python?

Rius reads the prompt and completion from the gen_ai.input.messages and gen_ai.output.messages attributes, which you set in Python with span.set_attribute and a JSON string. Each value is a JSON array of messages in the OpenTelemetry GenAI message shape. Rius then shows the prompt and completion on the span’s Input / Output tab.

How it works

Rius expects two keys on the span for a model call:

  • gen_ai.input.messages holds the prompt messages.
  • gen_ai.output.messages holds the completion.

OpenTelemetry attribute values in Python cannot be lists of dicts, so you pass each one as a string from json.dumps. The JSON is an array of messages. Each message has a role and a list of parts. A text part has type set to text and the text under content. Tool interactions use their own part types, shown below.

[ {"role": "user", "parts": [{"type": "text", "content": "What is 2+2?"}]}, { "role": "assistant", "parts": [ {"type": "tool_call", "id": "call_1", "name": "calculator", "arguments": "{\"expr\": \"2+2\"}"} ] }, { "role": "tool", "parts": [{"type": "tool_call_response", "id": "call_1", "response": "4"}] } ]

When you emit spans directly, you build this shape yourself, because message rendering in the console depends on it. A system prompt that you send apart from the chat history goes in gen_ai.system_instructions, as a JSON array of parts with type set to text and the text under content. Rius counts it as the system prompt when it breaks down a call’s context.

The messages are content only. For Rius to treat the span as a model call and price it, also set the model and token counts. Rius computes cost on its side from gen_ai.request.model and the gen_ai.usage.* counts, so you never send a cost attribute. See the span attribute reference for every key.

Set it up

  1. Register a tracer provider that exports to Rius. Install opentelemetry-sdk and opentelemetry-exporter-otlp-proto-http, then register the provider. Importing the exporter from opentelemetry.exporter.otlp.proto.http selects http/protobuf. With no arguments the exporter reads the standard OTEL_EXPORTER_OTLP_* variables, which point it at https://ingest.eu.console.rius-glassflow.com and carry your API key as a bearer authorization header. The variables send nothing on their own. The vanilla OpenTelemetry SDKs page lists them all. For a wider view of this path, see Trace LLM calls with OpenTelemetry.
from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter # With no arguments, the exporter reads the OTEL_EXPORTER_OTLP_* variables provider = TracerProvider() provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) trace.set_tracer_provider(provider)

Set the variables before you start the process:

export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.eu.console.rius-glassflow.com" export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer <your API key>"
  1. Open a span for the model call and set the messages. Name the span the way the GenAI conventions ask, the operation followed by the model. Set the input messages before the call and the output messages after it. Here call_the_model stands for your own function, and the response fields are your own.
import json from opentelemetry import trace tracer = trace.get_tracer("my-agent") with tracer.start_as_current_span("chat gpt-4o") as span: span.set_attribute("openinference.span.kind", "LLM") span.set_attribute("gen_ai.operation.name", "chat") span.set_attribute("gen_ai.request.model", "gpt-4o") span.set_attribute("gen_ai.provider.name", "openai") span.set_attribute("gen_ai.input.messages", json.dumps([ {"role": "user", "parts": [{"type": "text", "content": "What is 2+2?"}]}, ])) response = call_the_model() # your own function span.set_attribute("gen_ai.usage.input_tokens", response.usage.prompt_tokens) span.set_attribute("gen_ai.usage.output_tokens", response.usage.completion_tokens) span.set_attribute("gen_ai.response.model", response.model) span.set_attribute("gen_ai.output.messages", json.dumps([ {"role": "assistant", "parts": [{"type": "text", "content": response.text}]}, ]))
  1. Nest this span under a root span for the agent run, the normal OpenTelemetry way with parent context, so the calls arrive as one trace.

  2. Run the code, open the trace in Rius and select the model-call span. The prompt and completion appear on its Input / Output tab.

What you get

  • The prompt and completion render on the span’s Input / Output tab, with tool calls and tool results shown as their own parts.
  • The span counts as a model call in model analytics, because it carries the model and token counts.
  • Rius computes cost for the span from the model and the token counts, as a list-price estimate in US dollars. A model that Rius’s price catalog does not know gets an empty cost.
  • A span without the message attributes shows “Content not captured” on that tab. Its timings, tokens and cost are unaffected, so you can add the messages later without changing anything else.
  • If an instrumentation library emits OpenInference or OpenLLMetry attributes, Rius reassembles their flattened messages into the same GenAI shape at ingest. You then search and filter on the canonical keys.

When something else fits

  • If you want a library to record calls for you, use the Rius Python SDK. Its set_input() and set_output() normalize OpenAI-style message dicts into this shape, and its auto-instrumentation records supported clients without manual attributes.
  • If your instrumentation library already emits OpenInference or OpenLLMetry attributes, keep it. Rius maps them onto the canonical keys, so you do not need to set the messages by hand.

Still stuck? Get help

Last updated on