Attributes are how you slice everything in the dashboard: filter by user, break down spend by model, segment by your own experiment ID. Some are built into the SDK (Documentation Index
Fetch the complete documentation index at: https://docs.bentolabs.ai/llms.txt
Use this file to discover all available pages before exploring further.
user_id, model, convo_id); the rest you define yourself with properties={...}.
If you skip a kwarg, the dashboard column behind it stays empty for that call.
The built-in fields
| Kwarg | OTel attribute | Column | Skip it and… |
|---|---|---|---|
event (required) | span.name | Span name | (required) |
user_id | gen_ai.user.id | User | No user filter |
convo_id | gen_ai.conversation.id | Session | Turns look standalone |
model | gen_ai.request.model | Model | No cost or model breakdown |
provider | gen_ai.system | Provider | No provider filter |
input | input.value | Input | Empty input column |
output | output.value | Output | Empty output column |
properties={...} | each key, as-is | Attributes JSON | No custom dimensions |
Why each one matters
provider has no auto-inference
provider has no auto-inference
A model name like
claude-3-5-sonnet is not auto-tagged as Anthropic. Bedrock model IDs (anthropic.claude-3-...) are ambiguous on purpose. Pass provider= explicitly: "openai", "anthropic", "google", "aws_bedrock", etc.convo_id is the only way to group turns
convo_id is the only way to group turns
Without
convo_id, multi-turn conversations look like N independent requests. See Sessions and users.user_id is required for user filtering
user_id is required for user filtering
Required to filter the trace list by user. We do not store profile data (no email, no name, no traits).
user_id is a pass-through string only.model is required for the cost view
model is required for the cost view
The cost view and per-model breakdowns key off
gen_ai.request.model. Without it, spend rolls up under “Unknown”.Custom attributes via properties
Use properties for anything you want to filter or aggregate on that isn’t covered above.
properties becomes a top-level OTel attribute with the same name. No namespace is added.
Type fidelity
Property values keep their type when possible, so downstream filters and aggregates work.| You pass | Span sees | Notes |
|---|---|---|
"foo" (str) | string | |
42 (int) | int | filterable: experiment_id > 100 |
3.14 (float) | float | |
True (bool) | bool | filterable: is_premium = true |
[1, 2, 3] (homogeneous) | array | |
{"nested": "x"} (dict) | JSON string | OTel attributes are flat |
[1, "two"] (mixed list) | JSON string | OTel can’t store heterogeneous arrays |
user.tier, user.region) if you need numeric filters.
Span kind
The kind of a span lives inopeninference.span.kind. The SDK sets it for you:
| API | Kind |
|---|---|
bento.track_ai(...) | (unset, treated as LLM call) |
bento.tool_span(...), @bento.tool | "tool" |
interaction.tool_span(...) | "tool" |
retriever, embedding, agent, chain). Set them through properties:
What an emitted span looks like
The OTel JSON for atrack_ai call with properties={"experiment_id": 17}: