Add otel_attribute_format config for OTel-semconv span attributes (#665) (#666)
Closes [#665](https://github.com/dbos-inc/dbos-transact-py/issues/665).
Parallel TypeScript PR:
[dbos-transact-ts#1242](https://github.com/dbos-inc/dbos-transact-ts/pull/1242).
## Description
DBOS currently emits span attributes using flat camelCase names without
a vendor namespace (`operationUUID`, `applicationID`, `responseCode`,
…). These don't follow [OTel's attribute naming
spec](https://opentelemetry.io/docs/specs/semconv/general/attribute-naming/),
which calls for:
1. A vendor prefix (e.g. `dbos.*`) — without one, generic names like
`responseCode` and `requestID` collide with attributes set by other
instrumentation in the same process.
2. Dot-separated namespaces (`dbos.operation.uuid`).
3. Lowercase snake_case on the final segment.
This makes it hard for downstream consumers (SQL commenters, sampling
rules, log/trace filters, dashboard glob filters) to say "give me
everything DBOS-related" — they have to enumerate every legacy attribute
name and risk missing future ones.
## Implementation
Adds an opt-in `otel_attribute_format` config flag to `DBOSConfig`:
```python
DBOS(config={..., "otel_attribute_format": "semconv"})
```
| Value | Behavior |
|---|---|
| `"legacy"` | (default) Emit original DBOS names. No change for
existing users. |
| `"semconv"` | Emit OTel-style names under the `dbos.*` namespace. |
A new `DBOSTracer._resolve_attribute_name(key)` translates legacy names
through a static mapping table when `semconv` is selected, and is
invoked at the three places DBOS sets attributes on spans:
- `_tracer.py:start_span` — primary path, covers the `TracedAttributes`
TypedDict
- `_fastapi.py` — `responseCode` set on the request span
- `_context.py` — `authenticatedUser` / `authenticatedUserRoles` set
when auth is established
User-supplied `otlp_attributes` are passed through verbatim and **not**
affected by the format flag — the mapping only applies to DBOS's own
internal attribute names.
### Mapping
```
operationUUID -> dbos.operation.workflow_id
operationType -> dbos.operation.type
applicationID -> dbos.application.id
applicationVersion -> dbos.application.version
executorID -> dbos.executor.id
queueName -> dbos.queue.name
authenticatedUser -> dbos.user.name
authenticatedUserRoles -> dbos.user.roles
authenticatedUserAssumedRole -> dbos.user.assumed_role
requestID -> dbos.request.id
requestIP -> dbos.request.ip
requestURL -> dbos.request.url
requestMethod -> dbos.request.method
responseCode -> dbos.response.status_code
```
## Backward compatibility
- `"legacy"` is the default, so this is fully non-breaking. Existing
dashboards, alerts, and log queries continue to work unchanged.
- TypeScript Transact SDK compatibility (per the comment on
`TracedAttributes`) is preserved by default.
- The flag is process-wide, so a single deploy can flip an entire
service to the new names atomically.
## Cross-SDK consistency
The `# Keys must be the same as in TypeScript Transact` comment on
`TracedAttributes`
([_context.py:41](https://github.com/dbos-inc/dbos-transact-py/blob/main/dbos/_context.py#L41))
flags that these names are shared with
[`dbos-transact-ts`](https://github.com/dbos-inc/dbos-transact-ts).
The mapping in this PR was chosen to converge with the parallel TS PR
([dbos-transact-ts#1242](https://github.com/dbos-inc/dbos-transact-ts/pull/1242))
so users running both SDKs see the same `dbos.*` attribute schema once
both flip to `semconv`.
The current legacy names already drift in two places: Python's
`authenticatedUserRoles` / `authenticatedUserAssumedRole` vs TS's
`authenticatedRoles` / `assumedRole`. Both sides map their respective
name to the same `dbos.user.roles` / `dbos.user.assumed_role`, so opting
into `semconv` converges them. The aspirational `# Keys must be the
same` comment could probably be updated separately.
## Migration path (suggested)
1. Now: ship `legacy` (default) + `semconv` (opt-in).
2. Future minor: encourage users to test `semconv`. Optionally add a
deprecation log for the `legacy` mode.
3. Future major (or at maintainer discretion): flip the default to
`semconv`.
I deliberately did **not** add a `"both"` mode (emit both names
simultaneously). Happy to add it if maintainers think it's needed for
migration; left out for now to keep the surface area small.
## Testing
New file `tests/test_otel_attribute_format.py`:
- `test_resolve_attribute_name_legacy_passthrough` — every legacy key
resolves to itself in legacy mode.
- `test_resolve_attribute_name_semconv_remap` — every legacy key maps to
its `dbos.*` equivalent in semconv mode.
- `test_resolve_attribute_name_unknown_passes_through` — unknown
attribute names are unaffected by the format flag in either mode.
- `test_legacy_attributes_default_emitted_on_span` — end-to-end: with
the default config, finished spans carry `applicationVersion` /
`executorID` and not the `dbos.*` equivalents.
- `test_semconv_attributes_emitted_on_span` — end-to-end: with
`otel_attribute_format="semconv"`, finished spans carry
`dbos.application.version` / `dbos.executor.id` and not the legacy
names.
The integration tests use the existing `setup_in_memory_otlp_collector`
fixture from `tests/conftest.py`.
## Notes for reviewers
- I matched the existing `# type: ignore` comments on chained `.get()`
calls in `DBOSTracer.config()` rather than refactoring; happy to clean
those up in a separate PR if desired.
- Open to feedback on naming — `otel_attribute_format` could equally be
`attribute_format` or `attribute_naming`. I picked
`otel_attribute_format` because it makes clear that the values are about
OTel-semconv compliance, not DBOS-internal serialization.
---------
Co-authored-by: Peter Kraft <petereliaskraft@gmail.com> A
Adam Chaarawi committed
d230506c4379ade9adbaae978c97bd31ae8db5db
Parent: 882f762
Committed by GitHub <noreply@github.com>
on 5/13/2026, 6:08:53 PM