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 c425cffed0 docs: Observability topic page + 9.5.0 release-notes entry
(TODO-67)
c425cffed0 is described below
commit c425cffed0e1cad3734285e0ada764b7ee9bb24c
Author: James Bognar <[email protected]>
AuthorDate: Tue May 26 18:30:49 2026 -0400
docs: Observability topic page + 9.5.0 release-notes entry (TODO-67)
Co-authored-by: Cursor <[email protected]>
---
pages/release-notes/9.5.0.md | 129 ++++++++++
pages/topics/10.20g.RestServerObservability.md | 316 +++++++++++++++++++++++++
sidebars.ts | 5 +
3 files changed, 450 insertions(+)
diff --git a/pages/release-notes/9.5.0.md b/pages/release-notes/9.5.0.md
index d72d68df95..5e32179106 100644
--- a/pages/release-notes/9.5.0.md
+++ b/pages/release-notes/9.5.0.md
@@ -2291,6 +2291,44 @@ A new JUnit 5 extension and `@TestBean` annotation
enable Spring-style test-time
- **New constant.** `RestServerConstants.REQUEST_ID` (`"requestId"`) — the
canonical request-attribute key used by `RequestIdFilter`.
+#### Observability — Micrometer + OpenTelemetry hooks (TODO-67)
+
+**Metrics and tracing are opt-in and disabled by default.** Same
non-negotiable contract as Bean Validation (TODO-68): a fresh `RestContext`
built with no `MetricsRecorder` / `TracerHook` bean registered never records a
sample, never opens a span, and never adds a per-request cost — even when
`io.micrometer:micrometer-core` and `io.opentelemetry:opentelemetry-api` are on
the classpath. Opt in by registering a `@Bean MetricsRecorder` / `@Bean
TracerHook` on the resource.
+
+##### New SPIs in `juneau-rest-server`
+
+- **`org.apache.juneau.rest.metrics.MetricsRecorder`** —
`@FunctionalInterface` with a single method `record(String opName, String
httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable
error)`. Called once per `@RestOp` invocation (happy path and exception path).
The `uriTemplate` is the `@RestOp` path template (`/users/{id}`) — not the raw
concrete URI — so tag cardinality stays bounded.
+- **`org.apache.juneau.rest.metrics.NoOpMetricsRecorder`** — process-wide
singleton `INSTANCE`; resolved when no bean is registered. Drops events on the
floor with zero allocations.
+- **`org.apache.juneau.rest.tracing.TracerHook`** — `@FunctionalInterface`
with `Scope startSpan(RestRequest request)`. The framework opens the scope
before parameter binding and always closes it in a `finally` block; the
resolved HTTP status and any thrown throwable are delivered via
`Scope.setStatusCode(int)` / `Scope.setError(Throwable)` before `Scope.close()`.
+- **`org.apache.juneau.rest.tracing.Scope`** — `AutoCloseable` with
`setStatusCode(int)`, `setError(Throwable)`, and `close()`. The framework
guarantees `close()` runs exactly once per call (including parameter-resolution
failures and 5xx paths).
+- **`org.apache.juneau.rest.tracing.NoOpTracerHook`** — process-wide singleton
`INSTANCE` (and `NoOpTracerHook.NoOpScope.INSTANCE`); resolved when no bean is
registered.
+
+##### Where the hooks fire
+
+The hooks wrap the `@RestOp` handler invocation in
`RestOpInvoker.invokeOp(...)` — they cover parameter binding, the user method
body, and the framework's standard exception-mapping path. Pre/post-call
lifecycle methods (`@RestPreCall` / `@RestPostCall`) are intentionally outside
the observability boundary so the metric / span describes the user-facing
operation only. Resolution goes through the `RestContext` bean store: the first
`MetricsRecorder` / `TracerHook` bean wins; absent any re [...]
+
+##### Wiring
+
+```java
+@Rest
+public class MyResource extends BasicRestServlet {
+
+ @Bean
+ public MetricsRecorder metrics(MeterRegistry r) {
+ return new MicrometerMetricsRecorder(r); // see
juneau-rest-server-micrometer below
+ }
+
+ @Bean
+ public TracerHook tracer(OpenTelemetry otel) {
+ return new OtelTracerHook(otel); // see
juneau-rest-server-otel below
+ }
+
+ @RestGet("/users/{id}") public User get(@Path String id) { ... }
+}
+```
+
+See [REST Server — Observability (Micrometer +
OpenTelemetry)](/docs/topics/RestServerObservability) for the full topic.
+
#### Health Probe SPI + Resource (TODO-65)
`juneau-rest-server` now includes a built-in probe SPI and aggregation
resource under
@@ -3841,6 +3879,97 @@ Requests without an `id` are treated as JSON-RPC
notifications: handlers run, ex
See [juneau-rest-server-mcp](/docs/topics/JuneauRestServerMcpBasics) for the
full topic.
+### juneau-rest-server-micrometer (new module)
+
+A new opt-in REST module, `juneau-rest-server-micrometer`, bridges the new
`MetricsRecorder` SPI (see [juneau-rest-server](#juneau-rest-server)) into a
Micrometer `MeterRegistry` so a Juneau REST service can drop into existing
Prometheus / StatsD / JMX scrape pipelines with no hand-rolled instrumentation.
Engine-agnostic POM stance (TODO-67 resolved decision #1, mirroring TODO-68 /
TODO-78 / TODO-82 / TODO-83 / TODO-84): `io.micrometer:micrometer-core` is
declared in `provided` scope on [...]
+
+`mvn -pl juneau-rest/juneau-rest-server dependency:tree | grep -i micrometer`
returns nothing — the containment requirement is verified at build time.
+
+#### New Classes
+
+- **`org.apache.juneau.rest.metrics.micrometer.MicrometerMetricsRecorder`** —
`MetricsRecorder` implementation. Builds a Micrometer `Timer` named
`http.server.requests` (overridable via the two-arg constructor) with four tags
per Spring Boot's `WebMvcMetricsFilter` convention: `method` (uppercased HTTP
method), `uri` (the `@RestOp` path template — bounded cardinality), `status`
(HTTP response status), `exception` (the thrown exception's simple-name, or
`None` on the happy path). Records [...]
+
+#### Naming convention
+
+Spring Boot's `http.server.requests` + `{method, uri, status, exception}` was
chosen (TODO-67 resolved decision #2) over the OTel-native
`http.server.duration` shape because the wider Prometheus / Grafana
scrape-config and dashboard ecosystem already standardizes on it. The
OTel-native attribute names live in the sibling `juneau-rest-server-otel`
module (see below).
+
+#### Wiring
+
+```java
+@Configuration
+public class ObservabilityConfig {
+ @Bean MeterRegistry registry() { return new
PrometheusMeterRegistry(PrometheusConfig.DEFAULT); }
+ @Bean MetricsRecorder recorder(MeterRegistry r) { return new
MicrometerMetricsRecorder(r); }
+}
+```
+
+#### Dependency
+
+```xml
+<dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-micrometer</artifactId>
+ <version>9.5.0</version>
+</dependency>
+<dependency>
+ <groupId>io.micrometer</groupId>
+ <artifactId>micrometer-core</artifactId>
+ <version>1.13.6</version> <!-- consumer-supplied; provided scope
on juneau-rest-server-micrometer -->
+</dependency>
+```
+
+See [REST Server — Observability (Micrometer +
OpenTelemetry)](/docs/topics/RestServerObservability) for the full topic.
+
+### juneau-rest-server-otel (new module)
+
+A new opt-in REST module, `juneau-rest-server-otel`, bridges the new
`TracerHook` SPI (see [juneau-rest-server](#juneau-rest-server)) into an
OpenTelemetry `Tracer` so each `@RestOp` invocation becomes a server span with
HTTP semantic-convention attributes and W3C trace-context propagation.
Engine-agnostic POM stance: `io.opentelemetry:opentelemetry-api` is declared in
`provided` scope on the module's POM, so consumers explicitly pick the
OpenTelemetry version they want.
+
+`mvn -pl juneau-rest/juneau-rest-server dependency:tree | grep -i
opentelemetry` returns nothing — the containment requirement is verified at
build time.
+
+#### New Classes
+
+- **`org.apache.juneau.rest.tracing.otel.OtelTracerHook`** — `TracerHook`
implementation. Each request becomes a single `SpanKind.SERVER` span named
after the HTTP method (`GET`, `POST`, …) per the OpenTelemetry HTTP semantic
conventions, with attributes `http.request.method`,
`http.response.status_code`, and `http.route` (the `@RestOp` path template,
bounded cardinality). On the exception path, the throwable is recorded via
`Span.recordException(...)`, the span status is set to `StatusC [...]
+- **`org.apache.juneau.rest.tracing.otel.RestRequestTextMapGetter`** —
`TextMapGetter<RestRequest>` for OpenTelemetry context extraction. Reads the
standard W3C `traceparent` / `tracestate` headers (and any other
propagator-defined headers, e.g. baggage) from the incoming request so the
server span is created as a child of the caller's trace.
+
+#### Attribute naming convention
+
+OpenTelemetry HTTP semantic conventions (`http.request.method`,
`http.response.status_code`, `http.route`) were chosen (TODO-67 resolved
decision #2) for the tracer hook to keep the spans interoperable with any OTel
collector / backend. The Spring-Boot-style `http.server.requests` metric shape
lives in the sibling `juneau-rest-server-micrometer` module (see above).
+
+#### Wiring
+
+```java
+@Configuration
+public class ObservabilityConfig {
+
+ // Option A: rely on the JVM-wide GlobalOpenTelemetry (recommended).
+ @Bean TracerHook tracer() { return new OtelTracerHook(); }
+
+ // Option B: pass a specific OpenTelemetry instance (e.g. for tests).
+ @Bean TracerHook tracer(OpenTelemetry otel) { return new
OtelTracerHook(otel); }
+}
+```
+
+#### W3C trace-context propagation
+
+Incoming `traceparent` / `tracestate` request headers are extracted via the
configured `TextMapPropagator` (default: `W3CTraceContextPropagator`) so the
server span continues a caller-supplied distributed trace. Downstream HTTP
calls made from inside the handler pick up the active span's context
automatically when the outbound HTTP client honors the OTel `Context.current()`
(the standard OTel client instrumentations do).
+
+#### Dependency
+
+```xml
+<dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-otel</artifactId>
+ <version>9.5.0</version>
+</dependency>
+<dependency>
+ <groupId>io.opentelemetry</groupId>
+ <artifactId>opentelemetry-api</artifactId>
+ <version>1.43.0</version> <!-- consumer-supplied; provided scope
on juneau-rest-server-otel -->
+</dependency>
+```
+
+See [REST Server — Observability (Micrometer +
OpenTelemetry)](/docs/topics/RestServerObservability) for the full topic.
+
### juneau-rest-server-view-jsp (new module)
A new opt-in REST module, `juneau-rest-server-view-jsp`, adds JSP
view-rendering to `juneau-rest-server` without bleeding the JSP-engine
dependency (Apache Jasper) into the core. The new `View` interface (see
[juneau-rest-server](#juneau-rest-server)) lives in core; this module ships the
JSP-specific implementation. Engine-agnostic POM stance: the bridge module
declares the JSP API + JSTL impl in `provided` scope only — consumers add the
engine matching their container (Jetty 12 EE11's ` [...]
diff --git a/pages/topics/10.20g.RestServerObservability.md
b/pages/topics/10.20g.RestServerObservability.md
new file mode 100644
index 0000000000..40671d97d4
--- /dev/null
+++ b/pages/topics/10.20g.RestServerObservability.md
@@ -0,0 +1,316 @@
+---
+title: "Observability — Micrometer + OpenTelemetry"
+slug: RestServerObservability
+---
+
+**Observability is opt-in and disabled by default.** A fresh `RestContext`
built with no `MetricsRecorder` or `TracerHook` bean registered never records a
metric sample, never opens a span, and never adds a per-request cost — even
when `io.micrometer:micrometer-core` and `io.opentelemetry:opentelemetry-api`
are sitting on the classpath. Opt in by registering a `@Bean MetricsRecorder`
and / or `@Bean TracerHook` on the resource. Same non-negotiable contract as
[Jakarta Bean Validation](/d [...]
+
+This is a deliberate departure from Spring Boot's auto-configuration, where
`WebMvcMetricsFilter` is wired up at startup and a `MeterRegistry` bean is
enough to turn on per-request metrics. Juneau's contract is the opposite: zero
classpath cost, zero per-request cost, and zero startup cost until the user
explicitly opts in.
+
+## Motivation
+
+Juneau REST already tracks per-method execution statistics via
`MethodExecStats` / `RestContextStats`. The data is there — only the wiring to
the dominant observability stacks (Prometheus / Grafana via Micrometer;
distributed tracing via OpenTelemetry) was missing. The new SPIs
(`MetricsRecorder`, `TracerHook`) plus two opt-in bridge modules
(`juneau-rest-server-micrometer`, `juneau-rest-server-otel`) close that gap
without dragging either runtime into the core.
+
+End-state developer experience:
+
+```java
+// pom: add juneau-rest-server-micrometer + your preferred MeterRegistry (e.g.
micrometer-registry-prometheus).
+@Configuration
+public class ObservabilityConfig {
+ @Bean MeterRegistry registry() { return new
PrometheusMeterRegistry(PrometheusConfig.DEFAULT); }
+ @Bean MetricsRecorder recorder(MeterRegistry r) { return new
MicrometerMetricsRecorder(r); }
+}
+// → Each @RestOp call records a Timer sample on the registry; scrape via
/actuator/prometheus.
+
+// pom: add juneau-rest-server-otel + OTel SDK.
+@Bean OpenTelemetry otel() { return GlobalOpenTelemetry.get(); }
+@Bean TracerHook hook(OpenTelemetry otel) { return new OtelTracerHook(otel); }
+// → Each @RestOp call becomes a SERVER span; incoming traceparent continues
the upstream trace.
+```
+
+## The off-by-default contract
+
+The off-by-default contract has six tangible guarantees:
+
+1. **Cold-start default.** A fresh `RestContext` built with no
`MetricsRecorder` / `TracerHook` bean registered never resolves a non-`NoOp`
recorder or tracer. The framework looks up the SPI through the `RestContext`'s
`BeanStore` and falls back to `NoOpMetricsRecorder.INSTANCE` /
`NoOpTracerHook.INSTANCE` — both stateless singletons whose `record(...)` /
`startSpan(...)` methods short-circuit immediately.
+2. **Per-resource granularity.** A `@Bean MetricsRecorder` on one `@Rest`
class never opts in a sibling resource, an unrelated resource, or the global
JVM. Bean resolution is scoped to the resource's `BeanStore`.
+3. **No global "on" switch.** There is no
`RestContext.Builder.observability(true)`, no
`juneau.observability.enabled=true` system property, and no resource-level
boolean attribute that enables observability across every operation in a class.
Bean registration is the only opt-in surface.
+4. **No silent enablement via classpath presence.** Even if `micrometer-core`
and `opentelemetry-api` are on the classpath, no metric is recorded and no span
is opened unless the user has registered a `@Bean MetricsRecorder` / `@Bean
TracerHook`.
+5. **Lifecycle hooks are outside the observability boundary.** `@RestPreCall`
and `@RestPostCall` lifecycle methods are not wrapped — only the `@RestOp`
handler invocation (the user-facing operation) is. This keeps the metric / span
scoped to the call as the API consumer perceives it.
+6. **Hot-path overhead is short-circuited.** The
`NoOpMetricsRecorder.record(...)` and `NoOpTracerHook.NoOpScope.close()`
methods compile to empty bodies, so the `RestOpInvoker` observability block
costs at most one `BeanStore.getBean(...)` lookup plus two no-op virtual calls
per request when nothing is wired up.
+
+## SPIs in `juneau-rest-server`
+
+Two functional interfaces live in core `juneau-rest-server`. They're stable
enough for users to implement directly — the Micrometer / OpenTelemetry bridges
shipped in the sibling sub-modules are not the only valid implementations.
+
+### `MetricsRecorder`
+
+```java
+@FunctionalInterface
+public interface MetricsRecorder {
+ void record(String opName, String httpMethod, String uriTemplate,
+ int statusCode, Duration elapsed, Throwable error);
+}
+```
+
+Called exactly once per `@RestOp` invocation (happy path and exception path).
Arguments:
+
+| Field | Meaning |
+|---|---|
+| `opName` | Fully-qualified Java method name of the handler (e.g.
`com.acme.MyResource.findUser(java.lang.String)`). |
+| `httpMethod` | Uppercased HTTP method (e.g. `GET`, `POST`). |
+| `uriTemplate` | The `@RestOp` path template (e.g. `/users/{id}`) — **not**
the raw concrete URI. Using the template keeps metric / tag cardinality
bounded; raw URIs (`/users/123`, `/users/124`, …) would explode the registry's
series count. Empty string when the operation has no path pattern. |
+| `statusCode` | HTTP response status as resolved by the framework after the
handler runs (200 on success; 4xx / 5xx on the exception path). |
+| `elapsed` | Wall-clock duration from just-before parameter binding to
just-after framework exception mapping. |
+| `error` | The thrown throwable on the exception path, or `null` on the happy
path. |
+
+### `TracerHook`
+
+```java
+@FunctionalInterface
+public interface TracerHook {
+ Scope startSpan(RestRequest request);
+}
+
+public interface Scope extends AutoCloseable {
+ void setStatusCode(int statusCode);
+ void setError(Throwable error);
+ @Override void close();
+}
+```
+
+`startSpan` opens a per-request scope before parameter binding. The framework
always closes the scope in a `finally` block, delivering the resolved HTTP
status and any thrown throwable via `setStatusCode(int)` /
`setError(Throwable)` first. `close()` runs exactly once per call (including
parameter-resolution failures and 5xx paths) so consumers can safely end an
OTel span or finalize an in-memory record inside `close()`.
+
+### `NoOp` defaults
+
+- `NoOpMetricsRecorder.INSTANCE` — drops events on the floor with zero
allocations.
+- `NoOpTracerHook.INSTANCE` — returns `NoOpTracerHook.NoOpScope.INSTANCE`. All
three `Scope` methods are empty.
+
+Both are process-wide singletons. The framework picks them up automatically
when no `@Bean` of the corresponding type is registered.
+
+## Where the hooks fire
+
+The hooks wrap the handler invocation in `RestOpInvoker.invokeOp(...)`. The
sequence per call:
+
+1. Resolve `MetricsRecorder` / `TracerHook` from the `RestContext`'s
`BeanStore` (one lookup; falls back to the `NoOp` singletons).
+2. `tracer.startSpan(request)` → `Scope`.
+3. `System.nanoTime()` → `startNanos`.
+4. Bind parameters, invoke the user method, run the framework's standard
exception-mapping path.
+5. In a `finally` block:
+ - `elapsed` = `Duration.ofNanos(System.nanoTime() - startNanos)`.
+ - `status` = `res.getStatus()` (falling back to 200 / 500 if the response
status is still 0).
+ - `scope.setStatusCode(status)`; `scope.setError(observed)` if a throwable
was caught.
+ - `scope.close()`.
+ - `recorder.record(opName, httpMethod, uriTemplate, status, elapsed,
observed)`.
+
+Pre / post-call lifecycle methods (`@RestPreCall` / `@RestPostCall`) are
intentionally outside the observability boundary — the metric / span describes
the user-facing operation only.
+
+## Micrometer bridge — `juneau-rest-server-micrometer`
+
+The Micrometer bridge ships a single `MetricsRecorder` implementation that
records to any `MeterRegistry`:
+
+```java
+public class MicrometerMetricsRecorder implements MetricsRecorder {
+ public MicrometerMetricsRecorder(MeterRegistry registry) { ... }
+ public MicrometerMetricsRecorder(MeterRegistry registry, String timerName)
{ ... }
+
+ @Override
+ public void record(String opName, String httpMethod, String uriTemplate,
+ int statusCode, Duration elapsed, Throwable error) {
+ Timer.builder(timerName) // default: "http.server.requests"
+ .tag("method", httpMethod)
+ .tag("uri", uriTemplate) // bounded cardinality
+ .tag("status", Integer.toString(statusCode))
+ .tag("exception", error == null ? "None" :
error.getClass().getSimpleName())
+ .register(registry)
+ .record(elapsed);
+ }
+}
+```
+
+### Metric shape
+
+The default tag set mirrors Spring Boot's `WebMvcMetricsFilter` convention
(`http.server.requests` with `{method, uri, status, exception}`) so existing
Prometheus / Grafana dashboards built for Spring Boot services keep working
when those services are reimplemented on Juneau:
+
+```text
+http_server_requests_seconds_count{method="GET",uri="/users/{id}",status="200",exception="None"}
1
+http_server_requests_seconds_sum
{method="GET",uri="/users/{id}",status="200",exception="None"} 0.007
+```
+
+### Why Spring's naming convention (not OTel-native)
+
+OpenTelemetry's metric semantic conventions specify `http.server.duration`
with `http.request.method` / `http.response.status_code` / `http.route`
attribute names. We chose Spring Boot's older `http.server.requests` shape for
the **Micrometer** bridge because the wider Prometheus / Grafana scrape-config
and dashboard ecosystem already standardizes on it. The OTel-native attribute
names live on the **tracing** side — see [OpenTelemetry bridge —
`juneau-rest-server-otel`](#opentelemetry-br [...]
+
+To override the timer name, use the two-arg constructor:
+
+```java
+@Bean public MetricsRecorder recorder(MeterRegistry r) {
+ return new MicrometerMetricsRecorder(r, "myservice.http.timer");
+}
+```
+
+### Dependency
+
+```xml
+<dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-micrometer</artifactId>
+ <version>9.5.0</version>
+</dependency>
+<dependency>
+ <groupId>io.micrometer</groupId>
+ <artifactId>micrometer-core</artifactId>
+ <version>1.13.6</version> <!-- consumer-supplied; provided scope
on the bridge module -->
+</dependency>
+<!-- pick the registry you want -->
+<dependency>
+ <groupId>io.micrometer</groupId>
+ <artifactId>micrometer-registry-prometheus</artifactId>
+ <version>1.13.6</version>
+</dependency>
+```
+
+The bridge declares `micrometer-core` in `provided` scope so the consumer
picks the version. `mvn -pl juneau-rest/juneau-rest-server dependency:tree |
grep -i micrometer` returns nothing — the containment requirement is verified
at build time.
+
+## OpenTelemetry bridge — `juneau-rest-server-otel`
+
+The OpenTelemetry bridge ships a single `TracerHook` implementation that opens
a span per request and propagates W3C trace context:
+
+```java
+public class OtelTracerHook implements TracerHook {
+
+ // Option A — use the JVM-wide GlobalOpenTelemetry (recommended).
+ public OtelTracerHook() { this(GlobalOpenTelemetry.get()); }
+
+ // Option B — explicit OpenTelemetry instance (e.g. multi-tenant or test).
+ public OtelTracerHook(OpenTelemetry otel) { ... }
+
+ // Option C — explicit Tracer + TextMapPropagator (typically for tests).
+ public OtelTracerHook(Tracer tracer, TextMapPropagator propagator) { ... }
+
+ @Override
+ public Scope startSpan(RestRequest request) {
+ // Extract incoming W3C traceparent / tracestate.
+ Context extracted = propagator.extract(Context.current(), request,
RestRequestTextMapGetter.INSTANCE);
+
+ Span span = tracer.spanBuilder(request.getMethod()) // "GET",
"POST", ...
+ .setSpanKind(SpanKind.SERVER)
+ .setParent(extracted)
+ .setAttribute("http.request.method", request.getMethod())
+ .setAttribute("http.route", /* @RestOp path template */)
+ .startSpan();
+
+ return new OtelScope(span, span.makeCurrent());
+ }
+}
+```
+
+### Span shape
+
+Each `@RestOp` invocation becomes one span with:
+
+| Attribute | Meaning |
+|---|---|
+| Span kind | `SpanKind.SERVER` |
+| Span name | The uppercased HTTP method (`GET`, `POST`, …) per the OTel HTTP
semantic conventions. |
+| `http.request.method` | Uppercased HTTP method. |
+| `http.response.status_code` | HTTP response status (set in `Scope.close()`,
so error paths get the framework's resolved status). |
+| `http.route` | The `@RestOp` path template (e.g. `/users/{id}`) — bounded
cardinality, same reasoning as the Micrometer `uri` tag. |
+
+On the exception path:
+
+- `Span.recordException(throwable)` adds an event carrying the stack trace.
+- The span status is set to `StatusCode.ERROR`.
+- An `exception.type` attribute carries the throwable's simple-name (e.g.
`IllegalStateException`).
+
+A 2xx status leaves the span status at `StatusCode.UNSET` per the OTel HTTP
semconv (servers should not pre-color successful spans as `OK`).
+
+### W3C trace-context propagation
+
+Incoming `traceparent` / `tracestate` headers are extracted via the configured
`TextMapPropagator` (default: `W3CTraceContextPropagator`) so the server span
continues a caller-supplied distributed trace:
+
+```text
+Request: traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
+
+Server span: traceId=0af7651916cd43dd8448eb211c80319c
+ parentSpanId=b7ad6b7169203331
+ spanId=<freshly-minted>
+```
+
+Downstream HTTP calls made from inside the handler pick up the active span's
context automatically when the outbound HTTP client honors the OTel
`Context.current()` (the standard OTel client instrumentations do).
+
+### Wiring
+
+```java
+@Configuration
+public class ObservabilityConfig {
+
+ // Option A: rely on GlobalOpenTelemetry (typically wired by the
otel-java-agent).
+ @Bean TracerHook tracer() { return new OtelTracerHook(); }
+
+ // Option B: explicit instance (e.g. for tests).
+ @Bean TracerHook tracer(OpenTelemetry otel) { return new
OtelTracerHook(otel); }
+}
+```
+
+### Dependency
+
+```xml
+<dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-otel</artifactId>
+ <version>9.5.0</version>
+</dependency>
+<dependency>
+ <groupId>io.opentelemetry</groupId>
+ <artifactId>opentelemetry-api</artifactId>
+ <version>1.43.0</version> <!-- consumer-supplied; provided scope
on the bridge module -->
+</dependency>
+<!-- typically also the SDK + exporter of your choice -->
+<dependency>
+ <groupId>io.opentelemetry</groupId>
+ <artifactId>opentelemetry-sdk</artifactId>
+ <version>1.43.0</version>
+</dependency>
+```
+
+The bridge declares `opentelemetry-api` in `provided` scope so the consumer
picks the version. `mvn -pl juneau-rest/juneau-rest-server dependency:tree |
grep -i opentelemetry` returns nothing — the containment requirement is
verified at build time.
+
+## Custom `MetricsRecorder` / `TracerHook` implementations
+
+The SPIs are deliberately tiny — implement them directly when you need a
backend the shipped bridges don't cover (Dropwizard Metrics, an internal
time-series store, a structured-log appender, …):
+
+```java
+@Bean public MetricsRecorder recorder() {
+ return (opName, method, uri, status, elapsed, error) -> {
+ myInternalMetricsBus.recordHttpCall(method, uri, status,
elapsed.toMillis(), error);
+ };
+}
+```
+
+```java
+@Bean public TracerHook tracer() {
+ return request -> {
+ var startedAt = System.nanoTime();
+ return new Scope() {
+ @Override public void setStatusCode(int s) { /* stash */ }
+ @Override public void setError(Throwable t) { /* stash */ }
+ @Override public void close() {
+ myInternalTraceBus.recordSpan(request.getMethod(),
System.nanoTime() - startedAt);
+ }
+ };
+ };
+}
+```
+
+## 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.
+- **Custom tag schemes per resource.** v1 uses fixed Spring-style tags on the
Micrometer side and fixed OTel HTTP semantic-convention attribute names on the
OTel side. Open a follow-on TODO if you need per-resource customization.
+- **Histogram percentile config from annotations.** Let the user configure the
`MeterRegistry` directly — that's already a Micrometer-native surface
(`@Timed`, `MeterFilter`).
+- **StatsD / Datadog / NewRelic native bridges.** Use Micrometer's registries
— that's the whole point of `MetricsRecorder` going through `MeterRegistry`.
+
+## See also
+
+- [REST Server — Jakarta Bean Validation](/docs/topics/RestServerValidation) —
same off-by-default contract precedent (TODO-68).
+- [REST Server — Rate-Limiting and Request-Id
Propagation](/docs/topics/RestServerRateLimitAndRequestId) — sibling
operational primitives (TODO-66). The `requestId` from TODO-66 is the natural
log-correlation key alongside the OTel span id.
diff --git a/sidebars.ts b/sidebars.ts
index 0eb3832853..54a80ba6de 100644
--- a/sidebars.ts
+++ b/sidebars.ts
@@ -1556,6 +1556,11 @@ const sidebars: SidebarsConfig = {
id:
'topics/10.20f.RestServerValidation',
label: '10.20f. Jakarta
Bean Validation',
},
+ {
+ type: 'doc',
+ id:
'topics/10.20g.RestServerObservability',
+ label: '10.20g.
Observability — Micrometer + OpenTelemetry',
+ },
{
type: 'doc',
id:
'topics/10.21.BuiltInParameters',