Skip to Content

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

In JavaScript you set gen_ai.input.messages and gen_ai.output.messages on the span with setAttributes, and Rius renders them as the prompt and completion. Each value is a JSON string in the OpenTelemetry GenAI message shape. Spans that carry them show the conversation on the span’s Input / Output tab, next to the timings, tokens and cost.

How it works

Rius reads the prompt from gen_ai.input.messages and the completion from gen_ai.output.messages. Both are plain span attributes whose value is a JSON string, so in JavaScript you call JSON.stringify on an array before you set them.

The array holds messages, and each message has a role and a list of parts. A text part has a type of text and a content string. Tool interactions have their own part types:

  • tool_call carries an id, a name and arguments, for an assistant message that asks for a tool.
  • tool_call_response carries the id of that call and a response, for a message with the tool role.

When you emit spans directly, you produce this shape yourself. The message rendering in the console depends on it. If you send a different shape, the span still arrives and its timings, tokens and cost still work, but the Input / Output tab may not show your messages as a conversation.

The messages are only the content half of a generation. For Rius to compute cost and list the span in model analytics, also set gen_ai.request.model and the gen_ai.usage.input_tokens and gen_ai.usage.output_tokens counts. Rius computes cost on its side from those, so you never send a cost attribute. Set openinference.span.kind to LLM and gen_ai.operation.name to chat so the span is classified as a model call.

Spans from OpenLLMetry that use the older flattened gen_ai.prompt.* and gen_ai.completion.* keys are reassembled into this shape when they arrive. When you write the span yourself, set the two message keys directly. The full list of keys is in the span attribute reference.

Set it up

  1. Register a tracer provider that exports to Rius. This is the shared setup for any vanilla OpenTelemetry SDK, covered in full on Vanilla OpenTelemetry SDKs. The exporter reads the standard OTEL_EXPORTER_OTLP_* variables, which send nothing on their own until a provider like this one is running. Shut the SDK down before the process exits, or queued spans are not sent.
import { NodeSDK } from '@opentelemetry/sdk-node' import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' const sdk = new NodeSDK({ // No url or headers in code, so the exporter reads OTEL_EXPORTER_OTLP_* traceExporter: new OTLPTraceExporter(), // Rius ingests traces only metricReaders: [], logRecordProcessors: [], }) sdk.start() // ... your agent code ... // Flushes queued spans before the process exits (top-level await needs an ES module) await sdk.shutdown()
  1. Start a span for the model call and set the request attributes. Name it the way the GenAI conventions ask, the operation followed by the model, such as chat gpt-4o.

  2. Call your model, then set the usage, the response model and both message attributes. Build each message array in the shape above and pass it through JSON.stringify. Here callTheModel and prompt are your own code.

import { trace } from '@opentelemetry/api' const tracer = trace.getTracer('my-agent') const span = tracer.startSpan('chat gpt-4o') span.setAttributes({ 'openinference.span.kind': 'LLM', 'gen_ai.operation.name': 'chat', 'gen_ai.request.model': 'gpt-4o', 'gen_ai.provider.name': 'openai', 'gen_ai.input.messages': JSON.stringify([ { role: 'user', parts: [{ type: 'text', content: prompt }] }, ]), }) const response = await callTheModel() span.setAttributes({ 'gen_ai.usage.input_tokens': response.usage.prompt_tokens, 'gen_ai.usage.output_tokens': response.usage.completion_tokens, 'gen_ai.response.model': response.model, 'gen_ai.output.messages': JSON.stringify([ { role: 'assistant', parts: [{ type: 'text', content: response.text }] }, ]), }) span.end()
  1. Nest the span under one root span for the agent run, the normal OpenTelemetry way, and the spans arrive as one trace.

  2. Open the trace in the console, select the span and open its Input / Output tab.

What you get

  • The prompt and completion render on the span’s Input / Output tab as a conversation, including tool calls and their responses.
  • Because the span also carries the model and token counts, Rius computes its cost server-side and includes it in model analytics.
  • A span without the two message attributes shows “Content not captured” on that tab. Its timings, tokens and cost are unaffected, so you can add messages to some spans and not others.
  • Spans from your JavaScript service sit in the same trace views as spans from any other sender, which is the idea behind Trace LLM calls with OpenTelemetry.

When something else fits

  • If you would rather not build the message JSON by hand, the Rius SDKs normalize OpenAI-style messages into this shape for you, and also give you export-time privacy controls that raw OpenTelemetry emission does not have.
  • If your library already emits OpenInference or OpenLLMetry attributes, you do not need to set these two keys yourself, because Rius maps those dialects onto them when the spans arrive.

Still stuck? Get help

Last updated on