feat: Add `runAttributes` config to OTel metrics for cardinality control (#12144)
## Summary
- Adds a `runAttributes` config section to
`experimentalObservability.otel.metrics` that controls which run-level
attributes are attached to exported metrics, following the same pattern
as the existing `taskAttributes` config
- Gates high-cardinality metric attributes behind opt-in flags (all
default to `false`) to prevent excessive metric series in observability
backends
- Removes `turbo.task.cache_time_saved_ms` as a metric attribute since
numeric values used as metric tags are a cardinality anti-pattern
### New config options
```jsonc
{
"experimentalObservability": {
"otel": {
"metrics": {
"runAttributes": {
"id": false, // turbo.run.id — unique per invocation
"scmRevision": false // turbo.scm.revision — unique per commit
}
}
}
}
}
```
Corresponding environment variables:
- `TURBO_EXPERIMENTAL_OTEL_METRICS_RUN_ATTRIBUTES_ID`
- `TURBO_EXPERIMENTAL_OTEL_METRICS_RUN_ATTRIBUTES_SCM_REVISION`
### Why
Some metric attributes have unbounded cardinality — their unique values
grow without limit. When these are attached as tags/dimensions on
metrics, each unique combination creates a new metric series.
Observability backends often charge per unique series, so unbounded
attributes can lead to unexpectedly high costs.
This change gates the following attributes behind opt-in config flags:
| Attribute | Risk | Config flag |
|---|---|---|
| `turbo.run.id` | Unique KSUID per `turbo` invocation |
`runAttributes.id` |
| `turbo.scm.revision` | Full Git SHA, unique per commit |
`runAttributes.scmRevision` |
| `turbo.task.id` | `package#task` combo per task | `taskAttributes.id`
(existing) |
| `turbo.task.hash` | Content hash, changes with inputs |
`taskAttributes.hashes` (existing) |
| `turbo.task.external_inputs_hash` | Content hash |
`taskAttributes.hashes` (existing) |
Additionally, `turbo.task.cache_time_saved_ms` was removed as a metric
attribute entirely — it's a numeric value being used as a tag dimension,
creating a new series for every distinct millisecond value. The cache
time saved data remains available in the run summary JSON output.
### Changes across layers
1. **Config schema** (`raw.rs`): Added
`RawObservabilityOtelRunAttributes` struct
2. **Config options** (`experimental_otel.rs`): Added
`ExperimentalOtelRunAttributesOptions` with `id` and `scm_revision`
fields
3. **Config parsing** (`turbo_json.rs`): Conversion from raw to config
options
4. **Runtime config** (`turborepo-otel/lib.rs`): Added
`RunAttributesConfig` and gated `turbo.run.id` / `turbo.scm.revision`
behind it
5. **Env vars** (`env.rs`): Registered
`TURBO_EXPERIMENTAL_OTEL_METRICS_RUN_ATTRIBUTES_ID` and `_SCM_REVISION`
6. **Docs** (`configuration.mdx`, `ARCHITECTURE.md`): Documented new
options with cardinality guidance
## Test plan
- [x] All existing tests pass (245 tests across 4 crates)
- [x] New test cases for `run_attributes.id` enabled, `run_attributes`
fully enabled, env var parsing for both `id` and `scm_revision`
- [x] `is_empty` check updated to account for `run_attributes`
- [x] Serialization consistency verified — `skip_serializing_if =
"Option::is_none"` on all fields across all layers
- [x] `cargo check` and `cargo clippy` pass cleanly