This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch docs
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/docs by this push:
new dd079dda1d feat: custom observation API, OTLP bundle, and
OpenTelemetry log correlation
dd079dda1d is described below
commit dd079dda1d34f328d81267e04c883dbaa9cfe714
Author: James Bognar <[email protected]>
AuthorDate: Thu Jun 18 17:36:09 2026 -0400
feat: custom observation API, OTLP bundle, and OpenTelemetry log correlation
---
pages/release-notes/10.0.0.md | 10 ++++
pages/topics/10.40.RestServerObservability.md | 82 +++++++++++++++++++++++++++
2 files changed, 92 insertions(+)
diff --git a/pages/release-notes/10.0.0.md b/pages/release-notes/10.0.0.md
index 5920a9aea6..5d387223ba 100644
--- a/pages/release-notes/10.0.0.md
+++ b/pages/release-notes/10.0.0.md
@@ -279,6 +279,16 @@ public class ApiResource extends BasicRestServlet { ... }
Internally this also normalized mixin discovery (one `ResolvedMixin` carrier
for both bare and rich forms) and replaced the `RestContext.Args` boolean
`mixinContext` flag with a typed `ContextKind` discriminator
(`Root`/`Child`/`Mixin`). See [Host-side overrides with
`@Mixin`](/docs/topics/RestServerMixinSubContexts#host-side-overrides-with-mixin-1000).
+#### Custom observations + one-dependency OTLP export + log correlation
+
+Juneau 10.0 extends its request-boundary observability to cover custom
(non-request) observations, ships a single OTLP export bundle, and adds
OpenTelemetry trace-id log correlation — all keeping the explicit-over-magic,
off-by-default contract (no classpath auto-configuration).
+
+- **Custom-observation API** (`org.apache.juneau.rest.server.observation`): a
new explicit programmatic API — `Observations.observe(name, tags)`
(try-with-resources `Observation` handle), an `Observer` that composes the
existing SPIs, and non-request default methods on the two SPIs
(`MetricsRecorder.record(name, tags, elapsed, error)` +
`TracerHook.startSpan(name)`). Instruments an arbitrary block of work with a
timer + span; reuses the exact request-path `metricName`/`metricTags` model.
[...]
+- **`juneau-observability-otlp-bundle`**: a curated dependency bundle (under
`juneau-bundles`) that pulls both Juneau observability bridges plus a
version-aligned OTLP exporter stack — `opentelemetry-exporter-otlp` (+ SDK +
autoconfigure) for traces and `micrometer-registry-otlp` for metrics — in one
dependency. Metrics + traces for v1 (OTLP logs export deferred). A dependency
aggregator only; you still wire the `@Bean MetricsRecorder`/`@Bean TracerHook`
explicitly.
+- **Log correlation**: the JUL `LogEntryFormatter` gains optional
`{traceId}`/`{spanId}` placeholders, sourced reflectively from the active OTel
`Span.current()` (no hard OTel dependency — they render empty when OTel is
absent). `MdcAsyncListener` additionally folds the active trace/span id into
the propagated SLF4J MDC snapshot (keys `trace_id`/`span_id`) so correlation
survives the async-completion hop, gated by `isMdcAsyncPropagation()` and kept
reflective. SLF4J→Logback/Log4j2 apps w [...]
+
+See the extended [Observability — Micrometer +
OpenTelemetry](/docs/topics/RestServerObservability) topic page.
+
### juneau-marshall
#### Token-Streaming and Record-Streaming API
diff --git a/pages/topics/10.40.RestServerObservability.md
b/pages/topics/10.40.RestServerObservability.md
index 394ea0fdf1..71fb0151c2 100644
--- a/pages/topics/10.40.RestServerObservability.md
+++ b/pages/topics/10.40.RestServerObservability.md
@@ -409,6 +409,88 @@ For programmatic resource construction via
`AbstractRestBuilder`, use the `obser
`RestBuilder<SELF>` interface) to set the resource-level value, and
`RestAnnotation.Builder.observability(String)` for
op-level configuration.
+## Custom (non-request) observations
+
+Everything above instruments the **request boundary** — the framework opens
and closes the observation around each `@RestOp` handler. To instrument an
**arbitrary block of work** that has no associated HTTP request (a scheduled
job, a downstream service call, a cache refresh), use the explicit programmatic
observation API in `org.apache.juneau.rest.server.observation`.
+
+### Why explicit, not `@Observed`
+
+Juneau has **no method-interception / AOP substrate** — no dynamic proxying of
arbitrary beans, no bytecode weaving, no annotation-processor agent. Rather
than introduce one to support a Spring-style `@Observed` annotation, custom
observations are **explicit**: you wrap the block in a try-with-resources. This
is the same explicit-over-magic stance as the [curated dependency
bundles](/docs/topics/DependencyManagement) (which register nothing
automatically) — the active observation is the [...]
+
+### Usage
+
+```java
+import org.apache.juneau.rest.server.observation.*;
+
+try (Observation o = Observations.observe("order.load", "team=payments")) {
+ return loadOrder(id);
+} catch (RuntimeException e) {
+ o.setError(e);
+ throw e;
+}
+```
+
+`Observations.observe(name, tags)` records a metric timer named `name`
(carrying the comma-separated `key=value` `tags` plus an `exception` tag)
**and** opens a tracing span named `name`. On `close()` the elapsed time is
recorded and the span ends; `setError(...)` marks both the timer's `exception`
tag and the span's error status.
+
+### Reuses the request-path SPIs
+
+Custom observations go through the **same two SPIs** the request path uses —
via non-request default methods added to each:
+
+- `MetricsRecorder.record(String metricName, String metricTags, Duration
elapsed, Throwable error)`
+- `TracerHook.startSpan(String spanName)` (opens an `INTERNAL`-kind span,
nested under any active request span)
+
+The shipped bridges (`MicrometerMetricsRecorder`, `OtelTracerHook`) override
both. A bridge that doesn't override them simply skips custom observations.
There is no second tag model — the `metricName` / `metricTags` strings are
exactly the request-path model.
+
+### Wiring the backend
+
+`Observations` is a static facade over an explicitly-installed default
`Observer`. Install one once at startup, after your observability beans are
known:
+
+```java
+Observations.install(new Observer(metricsRecorder, tracerHook));
+```
+
+Until a backend is installed (and in any process that never installs one),
`Observations.observe(...)` returns `Observation.NOOP` — no timestamp taken, no
span opened, **zero allocation** beyond the try-with-resources scaffolding.
Code that prefers explicit DI over a global can hold an `Observer` directly
(resolved from the `RestContext` bean store) and call `observer.start(name,
tags)`.
+
+## One-dependency OTLP export — `juneau-observability-otlp-bundle`
+
+The two bridge modules (`-metrics-micrometer`, `-tracing-otel`) leave the
consumer to assemble a coherent exporter stack. The curated **OTLP bundle**
does that in one dependency — pull it to export both metrics and traces over
OTLP:
+
+```xml
+<dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-observability-otlp-bundle</artifactId>
+ <version>10.0.0</version>
+ <type>pom</type>
+</dependency>
+```
+
+It transitively brings both Juneau bridges plus a version-aligned OTLP
exporter stack: `opentelemetry-exporter-otlp` (+ SDK + autoconfigure) for
traces and `micrometer-registry-otlp` for metrics (which also satisfy the
bridges' `provided` API dependencies, so you get a runnable stack from the one
bundle). v1 covers **metrics + traces**; OTLP logs export is not bundled.
+
+Like every Juneau bundle it is a **dependency aggregator only** — it registers
no exporters and auto-discovers nothing. You still wire your `MeterRegistry` /
`OpenTelemetry` beans and the `@Bean MetricsRecorder` / `@Bean TracerHook`
explicitly, exactly as shown above. See [Dependency Management (BOM &
Bundles)](/docs/topics/DependencyManagement).
+
+## Log correlation — trace id in your logs
+
+To correlate log lines with traces, Juneau's JUL `LogEntryFormatter` supports
two optional placeholders:
+
+| Placeholder | Value |
+|---|---|
+| `{traceId}` | The active OpenTelemetry trace id, or empty when OTel is
absent / no span is active. |
+| `{spanId}` | The active OpenTelemetry span id, or empty when OTel is absent
/ no span is active. |
+
+```text
+[{date} {level}] trace={traceId} span={spanId} {msg}%n
+```
+
+The ids are sourced from the active OTel `Span.current().getSpanContext()` via
an **optional / reflective** coupling — `juneau-microservice` does **not**
hard-depend on OpenTelemetry. When OTel is absent from the runtime classpath
the fields simply render empty (the placeholders never leak literally). This
mirrors how `MdcAsyncListener` reflectively handles SLF4J.
+
+### Async correlation
+
+When a `@RestOp` returns a `CompletableFuture`, `MdcAsyncListener` (gated by
`RestContext.isMdcAsyncPropagation()`) additionally folds the active trace id /
span id into the propagated SLF4J MDC snapshot under the conventional OTel
appender keys (`trace_id` / `span_id`), so correlation survives the
async-completion hop even when the completion thread has lost the OTel context.
This too is reflective and optional.
+
+### Limitation — JUL only
+
+`LogEntryFormatter` is `java.util.logging`-based. An application on **SLF4J →
Logback / Log4j2** is not served by these placeholders — wire your own MDC
pattern instead (the OTel logback/log4j appenders populate `trace_id` /
`span_id` in MDC, which the async-correlation enrichment above feeds). Driving
every logging backend is explicitly out of scope.
+
## What's out of scope (v1)
- **Structured-logging bridges** (SLF4J / Log4j2 structured appender) —
TODO-20 owns the call-logger rework; the OTel bridge can publish a `Logs` event
later if there's demand.