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

# Attributes

> The dimensions every chart can filter, group, and break down by.

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 (`user_id`, `model`, `convo_id`); the rest you define yourself with `properties={...}`. Skip a kwarg and the dashboard column behind it stays empty for that call.

## The built-in fields

Each built-in kwarg maps to one OTel attribute and one dashboard column.

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

The mapping is the same for `track_ai`, `begin`, `interaction.update`, `interaction.finish`, and `tool_span`. Unpassed kwargs are omitted from the span, not set to null.

A few of these fields earn extra attention:

`provider` has no auto-inference. A model name like `claude-3-5-sonnet` isn't auto-tagged as Anthropic, and Bedrock model IDs (`anthropic.claude-3-...`) are ambiguous on purpose. Pass `provider=` explicitly: `"openai"`, `"anthropic"`, `"google"`, `"aws_bedrock"`, etc. Bedrock routes through AWS, so a Bedrock call wants `provider="aws_bedrock"`, not `"anthropic"`.

`convo_id` is the only way to group turns. Without it, multi-turn conversations look like N independent requests. See [Sessions and users](/concepts/sessions).

`user_id` drives user filtering. It's required to filter the trace list by user, and it's a pass-through string only. Bento doesn't store profile data: no email, no name, no traits.

`model` feeds 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`

`properties` covers anything you want to filter or aggregate on that the built-in fields don't.

```python theme={null}
bento.track_ai(
    event="answer",
    user_id="user_42",
    model="gpt-4o",
    provider="openai",
    input=question,
    output=answer,
    properties={
        "experiment_id": 17,
        "is_premium": True,
        "feature_flags": ["new_planner", "fast_path"],
        "latency_budget_ms": 1500,
    },
)
```

Each key in `properties` becomes a top-level OTel attribute with the same name. No namespace is added.

<Warning>
  **Don't put `gen_ai.*`, `input.value`, `output.value`, or `openinference.span.kind` in `properties`.** SDK kwargs are written *after* properties and will silently overwrite them. Use the dedicated kwargs.
</Warning>

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

The JSON-string fallback is lossless but won't be filterable as a number or bool. Flatten dicts to multiple keys (`user.tier`, `user.region`) if you need numeric filters.

## Span kind

The kind of a span lives in `openinference.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"`                       |

OpenInference defines other kinds (`retriever`, `embedding`, `agent`, `chain`). Set them through `properties`:

```python theme={null}
bento.track_ai(
    event="vector_search",
    input=query,
    output=results,
    properties={"openinference.span.kind": "retriever"},
)
```

## What an emitted span looks like

The OTel JSON for a `track_ai` call with `properties={"experiment_id": 17}`:

```json theme={null}
{
  "name": "answer",
  "attributes": {
    "gen_ai.user.id": "user_42",
    "gen_ai.conversation.id": "chat_99",
    "gen_ai.request.model": "gpt-4o",
    "gen_ai.system": "openai",
    "input.value": "...",
    "output.value": "...",
    "experiment_id": 17
  }
}
```

The same attributes show up in any OTel backend you wire this into. Nothing about your code is Bento-specific.

The exporter batches spans, so a short-lived process can exit before they ship. Call `bento.flush()` (or `bento.shutdown()`) before the process ends.
