OpenTelemetry tracing
Ekbatan instruments its action execution pipeline with the **OpenTelemetry API** (`opentelemetry-api`). The library depends only on the API — no SDK. When no OTel SDK is registered at runtime, all tracing calls are no-ops with zero overhead. Consumers bring their own `opentelemetry-sdk` and exporters.
The instrumentation scope is io.ekbatan.core version 1.0.0, obtained from GlobalOpenTelemetry.get().getTracer(...).
Span hierarchy
Each executor.execute(...) call produces a span tree of the following shape:
[ekbatan.action.execute] ActionExecutor.execute()
├── [ekbatan.action.perform] Action.perform()
└── [ekbatan.action.persist] ActionExecutor.persistChanges()
└── [ekbatan.transaction] TransactionManager.inTransactionChecked() (per shard)
├── [ekbatan.repository] AbstractRepository.addAllNoResult / updateAllNoResult
└── [ekbatan.event.persist] EventPersister.persistActionEvents()
A single action that touches one shard produces one of each span. A cross-shard action produces one transaction + nested repo + event-persist spans per involved shard.
Attributes
| Attribute | Type | Span | Description |
|---|---|---|---|
ekbatan.action.name | string | action.execute | Simple class name of the action |
ekbatan.action.principal | string | action.execute | Principal name |
ekbatan.action.outcome | string | action.execute | "success" or "error" |
ekbatan.action.retry.count | long | action.execute | Total retries (0 if none) |
ekbatan.shard.cross_shard | boolean | action.persist | Present (and true) when changes span multiple shards |
ekbatan.shard.group | long | transaction | Shard group identifier |
ekbatan.shard.member | long | transaction | Shard member identifier |
db.operation.name | string | repository | "INSERT" or "UPDATE". Follows OTel semantic conventions. |
ekbatan.entity.type | string | repository | Simple class name of the domain object |
ekbatan.batch.size | long | repository | Number of records in the batch |
ekbatan.event.count | long | event.persist | Number of model events persisted |
db.operation.name follows OTel semantic conventions. All others use the ekbatan.* namespace.
Retry events
Each retry attempt adds a span event named "retry" to the action span with attributes retry.attempt (int) and retry.exception (exception class name). Retries are not their own spans — each retry re-executes perform + persist, so the child spans of each attempt already appear under the action span; a separate retry span would just add a redundant level.
Errors
On failure, spans are marked with StatusCode.ERROR and the exception is recorded via span.recordException().
Context propagation
Since actions execute single-threaded (ScopedValue-based transactions), context flows naturally via Span.makeCurrent() / Scope. No async context passing is needed. Each TransactionManager instance knows its own ShardIdentifier (set at construction time, defaults to ShardIdentifier.DEFAULT), so inTransactionChecked() automatically sets shard attributes on the transaction span without requiring the shard to be passed per-call.
Bringing your own SDK
The framework only declares the opentelemetry-api dependency. To actually export traces, your application brings the SDK and exporters:
implementation("io.opentelemetry:opentelemetry-sdk")
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
implementation("io.opentelemetry:opentelemetry-sdk-extension-autoconfigure")
Then configure via standard OTel system properties or environment variables — otel.exporter.otlp.endpoint, otel.service.name, etc. The framework picks up whatever GlobalOpenTelemetry returns.
In tests, register an in-memory exporter via opentelemetry-sdk-testing to assert on emitted spans:
testImplementation("io.opentelemetry:opentelemetry-sdk-testing")
The :ekbatan-core:tracingTest Gradle task forks a separate JVM specifically because the OTel SDK must be registered before any instrumented class loads its static Tracer field via GlobalOpenTelemetry.
Read-path tracing is not instrumented
AbstractRepository’s read methods (findById, findAllWhere, count, etc.) don’t produce their own spans. They run inside Action.perform() which already has a span, and instrumenting every read would be noisy for actions that do many lookups. If you need fine-grained read tracing, instrument those calls in your repository subclass.
See also
- Actions, ActionPlan, ActionExecutor — the source of
action.execute/action.perform/action.persist - Sharding —
ekbatan.shard.*attributes come from the per-shardTransactionManager - Repositories on JOOQ — what the
ekbatan.repositoryspan wraps