This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new c2ca79393b feat(rest): observability seams — 
juneau-rest-server-micrometer + juneau-rest-server-otel (TODO-67)
c2ca79393b is described below

commit c2ca79393be87a0f0a8dea9394fc2f8161de7189
Author: James Bognar <[email protected]>
AuthorDate: Tue May 26 18:30:35 2026 -0400

    feat(rest): observability seams — juneau-rest-server-micrometer + 
juneau-rest-server-otel (TODO-67)
---
 juneau-rest/juneau-rest-server-micrometer/pom.xml  | 120 ++++++++++
 .../micrometer/MicrometerMetricsRecorder.java      | 164 ++++++++++++++
 .../rest/metrics/micrometer/package-info.java      |  54 +++++
 juneau-rest/juneau-rest-server-otel/pom.xml        | 121 ++++++++++
 .../juneau/rest/tracing/otel/OtelTracerHook.java   | 246 +++++++++++++++++++++
 .../tracing/otel/RestRequestTextMapGetter.java     |  63 ++++++
 .../juneau/rest/tracing/otel/package-info.java     |  55 +++++
 .../java/org/apache/juneau/rest/RestOpInvoker.java |  75 ++++++-
 .../java/org/apache/juneau/rest/RestOpSession.java |   2 +-
 .../juneau/rest/metrics/MetricsRecorder.java       | 104 +++++++++
 .../juneau/rest/metrics/NoOpMetricsRecorder.java   |  47 ++++
 .../apache/juneau/rest/metrics/package-info.java   |  57 +++++
 .../apache/juneau/rest/tracing/NoOpTracerHook.java |  81 +++++++
 .../java/org/apache/juneau/rest/tracing/Scope.java |  78 +++++++
 .../org/apache/juneau/rest/tracing/TracerHook.java |  97 ++++++++
 .../apache/juneau/rest/tracing/package-info.java   |  60 +++++
 juneau-rest/pom.xml                                |   2 +
 juneau-utest/pom.xml                               |  51 +++++
 .../metrics/MetricsRecorder_Contract_Test.java     | 181 +++++++++++++++
 .../micrometer/MicrometerMetricsRecorder_Test.java | 180 +++++++++++++++
 .../rest/tracing/TracerHook_Contract_Test.java     | 171 ++++++++++++++
 .../rest/tracing/otel/OtelTracerHook_Test.java     | 188 ++++++++++++++++
 .../otel/RestRequestTextMapGetter_Test.java        |  99 +++++++++
 23 files changed, 2292 insertions(+), 4 deletions(-)

diff --git a/juneau-rest/juneau-rest-server-micrometer/pom.xml 
b/juneau-rest/juneau-rest-server-micrometer/pom.xml
new file mode 100644
index 0000000000..b440d67b36
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-micrometer/pom.xml
@@ -0,0 +1,120 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
https://maven.apache.org/xsd/maven-4.0.0.xsd";>
+
+       <modelVersion>4.0.0</modelVersion>
+
+       <parent>
+               <groupId>org.apache.juneau</groupId>
+               <artifactId>juneau-rest</artifactId>
+               <version>9.5.0-SNAPSHOT</version>
+       </parent>
+
+       <artifactId>juneau-rest-server-micrometer</artifactId>
+       <name>Apache Juneau REST Server Micrometer</name>
+       <description>Apache Juneau REST Server - Micrometer MetricsRecorder 
bridge (opt-in, micrometer-core)</description>
+       <packaging>bundle</packaging>
+
+       <properties>
+               
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+               <micrometer.version>1.13.6</micrometer.version>
+       </properties>
+
+       <dependencies>
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-server</artifactId>
+                       <version>${project.version}</version>
+               </dependency>
+
+               <!--
+                       micrometer-core is the ONLY hard runtime dep of this 
module.  It is intentionally
+                       declared `provided` so it does NOT leak as a transitive 
dependency of either
+                       juneau-rest-server-micrometer OR any module that 
depends on this one.  Consumers who
+                       want Micrometer-backed metrics must declare 
micrometer-core themselves (at the version
+                       they want) plus their preferred MeterRegistry (e.g. 
micrometer-registry-prometheus).
+
+                       Containment guarantee: a `dependency:tree` on 
juneau-rest-server (the upstream module)
+                       must never surface micrometer.
+               -->
+               <dependency>
+                       <groupId>io.micrometer</groupId>
+                       <artifactId>micrometer-core</artifactId>
+                       <version>${micrometer.version}</version>
+                       <scope>provided</scope>
+               </dependency>
+       </dependencies>
+
+       <build>
+               <plugins>
+                       <plugin>
+                               <groupId>org.apache.maven.plugins</groupId>
+                               <artifactId>maven-source-plugin</artifactId>
+                               <executions>
+                                       <execution>
+                                               <id>attach-sources</id>
+                                               <phase>verify</phase>
+                                               <goals>
+                                                       <goal>jar-no-fork</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.apache.felix</groupId>
+                               <artifactId>maven-bundle-plugin</artifactId>
+                               <extensions>true</extensions>
+                               <configuration>
+                                       
<supportIncrementalBuild>true</supportIncrementalBuild>
+                               </configuration>
+                               <executions>
+                                       <execution>
+                                               <id>bundle-manifest</id>
+                                               <phase>process-classes</phase>
+                                               <goals>
+                                                       <goal>manifest</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.apache.maven.plugins</groupId>
+                               <artifactId>maven-jar-plugin</artifactId>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.jacoco</groupId>
+                               <artifactId>jacoco-maven-plugin</artifactId>
+                               <executions>
+                                       <execution>
+                                               <id>default-prepare-agent</id>
+                                               <goals>
+                                                       
<goal>prepare-agent</goal>
+                                               </goals>
+                                       </execution>
+                                       <execution>
+                                               <id>default-report</id>
+                                               <phase>prepare-package</phase>
+                                               <goals>
+                                                       <goal>report</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+               </plugins>
+       </build>
+</project>
diff --git 
a/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder.java
 
b/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder.java
new file mode 100644
index 0000000000..4ab8d65922
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder.java
@@ -0,0 +1,164 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.metrics.micrometer;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.time.*;
+
+import org.apache.juneau.rest.metrics.*;
+
+import io.micrometer.core.instrument.*;
+
+/**
+ * {@link MetricsRecorder} bridge that records every {@code @RestOp} call as a 
Micrometer
+ * {@link Timer} sample on the configured {@link MeterRegistry}.
+ *
+ * <h5 class='topic'>Metric shape</h5>
+ *
+ * <p>
+ * Each call records to one Timer named (by default) {@value 
#DEFAULT_TIMER_NAME} with four tags:
+ * <ul>
+ *     <li><b>{@code method}</b> &mdash; uppercased HTTP method (e.g. {@code 
GET}, {@code POST}).
+ *     <li><b>{@code uri}</b> &mdash; the {@code @RestOp} path template (e.g. 
{@code /users/{id}}), not
+ *             the raw concrete URI. Using the template keeps tag cardinality 
bounded; raw URIs
+ *             ({@code /users/123}, {@code /users/124}, ...) would explode the 
registry's series count.
+ *             Empty string when the operation has no path pattern.
+ *     <li><b>{@code status}</b> &mdash; HTTP response status code as a string 
(e.g. {@code 200},
+ *             {@code 404}, {@code 500}).
+ *     <li><b>{@code exception}</b> &mdash; the thrown exception's simple-name 
(e.g. {@code BadRequest},
+ *             {@code NullPointerException}), or {@value #NO_EXCEPTION_TAG} 
when the call completed normally.
+ * </ul>
+ *
+ * <p>
+ * The shape mirrors Spring Boot's
+ * <a class="doclink" 
href="https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#actuator.metrics.supported.spring-mvc";>{@code
 WebMvcMetricsFilter}</a>
+ * convention so existing dashboards / alerts that scrape {@code 
http.server.requests} from Spring
+ * Boot services keep working when those services are reimplemented on Juneau.
+ *
+ * <h5 class='topic'>Wiring</h5>
+ *
+ * <p class='bjava'>
+ *     <ja>@Configuration</ja>
+ *     <jk>public class</jk> ObservabilityConfig {
+ *
+ *             <ja>@Bean</ja>
+ *             <jk>public</jk> MeterRegistry meterRegistry() {
+ *                     <jk>return new</jk> 
PrometheusMeterRegistry(PrometheusConfig.<jsf>DEFAULT</jsf>);
+ *             }
+ *
+ *             <ja>@Bean</ja>
+ *             <jk>public</jk> MetricsRecorder metricsRecorder(MeterRegistry 
<jv>r</jv>) {
+ *                     <jk>return new</jk> 
MicrometerMetricsRecorder(<jv>r</jv>);
+ *             }
+ *     }
+ * </p>
+ *
+ * <p>
+ * The {@code MetricsRecorder} bean is picked up by {@code juneau-rest-server} 
through the
+ * {@code RestContext}'s bean store and invoked once per {@code @RestOp} call.
+ *
+ * <h5 class='topic'>Customizing the metric name</h5>
+ *
+ * <p>
+ * Pass a non-default name to the two-arg constructor when the default
+ * {@value #DEFAULT_TIMER_NAME} doesn't match an existing dashboard / 
Prometheus rule set:
+ *
+ * <p class='bjava'>
+ *     <jk>new</jk> MicrometerMetricsRecorder(<jv>r</jv>, 
<js>"my.service.http.timer"</js>)
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link MetricsRecorder}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerObservability";>REST 
Server &mdash; Observability (Micrometer + OpenTelemetry)</a>
+ *     <li class='link'><a class="doclink" 
href="https://micrometer.io/docs/concepts";>Micrometer Concepts</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class MicrometerMetricsRecorder implements MetricsRecorder {
+
+       /** Default timer name {@code "http.server.requests"} &mdash; matches 
Spring Boot's convention. */
+       public static final String DEFAULT_TIMER_NAME = "http.server.requests";
+
+       /** Tag value used for the {@code exception} tag when the call 
completed normally. */
+       public static final String NO_EXCEPTION_TAG = "None";
+
+       private static final String TAG_METHOD = "method";
+       private static final String TAG_URI = "uri";
+       private static final String TAG_STATUS = "status";
+       private static final String TAG_EXCEPTION = "exception";
+
+       private static final String ARG_registry = "registry";
+       private static final String ARG_timerName = "timerName";
+
+       private final MeterRegistry registry;
+       private final String timerName;
+
+       /**
+        * Constructor using the default timer name {@value 
#DEFAULT_TIMER_NAME}.
+        *
+        * @param registry The Micrometer {@link MeterRegistry} to publish 
timers to. Must not be <jk>null</jk>.
+        */
+       public MicrometerMetricsRecorder(MeterRegistry registry) {
+               this(registry, DEFAULT_TIMER_NAME);
+       }
+
+       /**
+        * Constructor with a custom timer name.
+        *
+        * @param registry The Micrometer {@link MeterRegistry} to publish 
timers to. Must not be <jk>null</jk>.
+        * @param timerName The timer name. Must not be <jk>null</jk> or blank.
+        */
+       public MicrometerMetricsRecorder(MeterRegistry registry, String 
timerName) {
+               this.registry = assertArgNotNull(ARG_registry, registry);
+               this.timerName = assertArgNotNullOrBlank(ARG_timerName, 
timerName);
+       }
+
+       /**
+        * Returns the {@link MeterRegistry} this recorder publishes to.
+        *
+        * @return The {@link MeterRegistry}.
+        */
+       public MeterRegistry getRegistry() { return registry; }
+
+       /**
+        * Returns the configured timer name.
+        *
+        * @return The timer name.
+        */
+       public String getTimerName() { return timerName; }
+
+       @Override /* MetricsRecorder */
+       public void record(String opName, String httpMethod, String 
uriTemplate, int statusCode, Duration elapsed, Throwable error) {
+               Timer.builder(timerName)
+                       .tag(TAG_METHOD, defaultIfBlank(httpMethod, ""))
+                       .tag(TAG_URI, defaultIfBlank(uriTemplate, ""))
+                       .tag(TAG_STATUS, Integer.toString(statusCode))
+                       .tag(TAG_EXCEPTION, exceptionTag(error))
+                       .register(registry)
+                       .record(elapsed);
+       }
+
+       private static String exceptionTag(Throwable error) {
+               return error == null ? NO_EXCEPTION_TAG : 
error.getClass().getSimpleName();
+       }
+
+       private static String defaultIfBlank(String value, String fallback) {
+               return (value == null || value.isEmpty()) ? fallback : value;
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/package-info.java
 
b/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/package-info.java
new file mode 100644
index 0000000000..fbf81b9461
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/package-info.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Micrometer bridge for the {@code juneau-rest-server}
+ * {@link org.apache.juneau.rest.metrics.MetricsRecorder} SPI.
+ *
+ * <p>
+ * This module is opt-in and ships a single class &mdash;
+ * {@link org.apache.juneau.rest.metrics.micrometer.MicrometerMetricsRecorder} 
&mdash; that bridges
+ * per-request metric events from {@code juneau-rest-server} to a Micrometer
+ * {@link io.micrometer.core.instrument.MeterRegistry MeterRegistry}. 
Consumers wire one as a
+ * {@code @Bean MetricsRecorder} on their resource and pick the Micrometer 
registry that matches
+ * their downstream (Prometheus, StatsD, JMX, Datadog, NewRelic, etc.).
+ *
+ * <h5 class='topic'>Containment</h5>
+ *
+ * <p>
+ * The {@code io.micrometer:micrometer-core} dependency is declared in {@code 
provided} scope on
+ * this module's POM. Consumers that want Micrometer-backed metrics must add 
{@code micrometer-core}
+ * themselves at the version of their choice along with their preferred {@code 
MeterRegistry}. This
+ * way:
+ * <ul>
+ *     <li>The core {@code juneau-rest-server} jar stays Micrometer-free.
+ *     <li>Services that never opt in pay zero classpath cost.
+ *     <li>Services that DO opt in pick their own Micrometer version (security 
upgrades, CVE patches)
+ *             without waiting for a Juneau release.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.metrics.micrometer.MicrometerMetricsRecorder}
+ *     <li class='jc'>{@link org.apache.juneau.rest.metrics.MetricsRecorder}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerObservability";>REST 
Server &mdash; Observability (Micrometer + OpenTelemetry)</a>
+ *     <li class='link'><a class="doclink" 
href="https://micrometer.io/";>Micrometer</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+package org.apache.juneau.rest.metrics.micrometer;
diff --git a/juneau-rest/juneau-rest-server-otel/pom.xml 
b/juneau-rest/juneau-rest-server-otel/pom.xml
new file mode 100644
index 0000000000..a76b6fd37d
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-otel/pom.xml
@@ -0,0 +1,121 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one or more
+  contributor license agreements.  See the NOTICE file distributed with
+  this work for additional information regarding copyright ownership.
+  The ASF licenses this file to You under the Apache License, Version 2.0
+  (the "License"); you may not use this file except in compliance with
+  the License.  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"; 
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; 
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
https://maven.apache.org/xsd/maven-4.0.0.xsd";>
+
+       <modelVersion>4.0.0</modelVersion>
+
+       <parent>
+               <groupId>org.apache.juneau</groupId>
+               <artifactId>juneau-rest</artifactId>
+               <version>9.5.0-SNAPSHOT</version>
+       </parent>
+
+       <artifactId>juneau-rest-server-otel</artifactId>
+       <name>Apache Juneau REST Server OpenTelemetry</name>
+       <description>Apache Juneau REST Server - OpenTelemetry TracerHook 
bridge (opt-in, opentelemetry-api)</description>
+       <packaging>bundle</packaging>
+
+       <properties>
+               
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+               <opentelemetry.version>1.43.0</opentelemetry.version>
+       </properties>
+
+       <dependencies>
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-server</artifactId>
+                       <version>${project.version}</version>
+               </dependency>
+
+               <!--
+                       opentelemetry-api is the ONLY hard runtime dep of this 
module.  It is intentionally
+                       declared `provided` so it does NOT leak as a transitive 
dependency of either
+                       juneau-rest-server-otel OR any module that depends on 
this one.  Consumers who want
+                       OpenTelemetry-backed tracing must declare 
opentelemetry-api themselves (at the version
+                       they want) plus an SDK / exporter combination matching 
their downstream tracing
+                       backend (Jaeger, Zipkin, OTLP, etc.).
+
+                       Containment guarantee: a `dependency:tree` on 
juneau-rest-server (the upstream module)
+                       must never surface opentelemetry.
+               -->
+               <dependency>
+                       <groupId>io.opentelemetry</groupId>
+                       <artifactId>opentelemetry-api</artifactId>
+                       <version>${opentelemetry.version}</version>
+                       <scope>provided</scope>
+               </dependency>
+       </dependencies>
+
+       <build>
+               <plugins>
+                       <plugin>
+                               <groupId>org.apache.maven.plugins</groupId>
+                               <artifactId>maven-source-plugin</artifactId>
+                               <executions>
+                                       <execution>
+                                               <id>attach-sources</id>
+                                               <phase>verify</phase>
+                                               <goals>
+                                                       <goal>jar-no-fork</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.apache.felix</groupId>
+                               <artifactId>maven-bundle-plugin</artifactId>
+                               <extensions>true</extensions>
+                               <configuration>
+                                       
<supportIncrementalBuild>true</supportIncrementalBuild>
+                               </configuration>
+                               <executions>
+                                       <execution>
+                                               <id>bundle-manifest</id>
+                                               <phase>process-classes</phase>
+                                               <goals>
+                                                       <goal>manifest</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.apache.maven.plugins</groupId>
+                               <artifactId>maven-jar-plugin</artifactId>
+                       </plugin>
+                       <plugin>
+                               <groupId>org.jacoco</groupId>
+                               <artifactId>jacoco-maven-plugin</artifactId>
+                               <executions>
+                                       <execution>
+                                               <id>default-prepare-agent</id>
+                                               <goals>
+                                                       
<goal>prepare-agent</goal>
+                                               </goals>
+                                       </execution>
+                                       <execution>
+                                               <id>default-report</id>
+                                               <phase>prepare-package</phase>
+                                               <goals>
+                                                       <goal>report</goal>
+                                               </goals>
+                                       </execution>
+                               </executions>
+                       </plugin>
+               </plugins>
+       </build>
+</project>
diff --git 
a/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook.java
 
b/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook.java
new file mode 100644
index 0000000000..e4a0c8922c
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook.java
@@ -0,0 +1,246 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.tracing.otel;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Utils.*;
+
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.tracing.*;
+import org.apache.juneau.rest.tracing.Scope;
+
+import io.opentelemetry.api.*;
+import io.opentelemetry.api.common.*;
+import io.opentelemetry.api.trace.*;
+import io.opentelemetry.context.*;
+import io.opentelemetry.context.propagation.*;
+
+/**
+ * {@link TracerHook} bridge that opens an OpenTelemetry {@link Span} for 
every {@code @RestOp} call.
+ *
+ * <h5 class='topic'>Span shape</h5>
+ *
+ * <p>
+ * Each request becomes a single span with kind {@link SpanKind#SERVER}, named 
after the HTTP method
+ * (e.g. {@code "GET"}, {@code "POST"}) per the OpenTelemetry HTTP semantic 
conventions. Three OTel
+ * HTTP semantic-convention attributes are populated:
+ * <ul>
+ *     <li><b>{@code http.request.method}</b> &mdash; uppercased HTTP method.
+ *     <li><b>{@code http.response.status_code}</b> &mdash; HTTP response 
status (set right before the
+ *             span is closed, so error paths get the framework's resolved 
status).
+ *     <li><b>{@code http.route}</b> &mdash; the {@code @RestOp} path template 
(e.g.
+ *             {@code "/users/{id}"}), not the raw concrete URI. Keeps trace 
cardinality bounded for the same
+ *             reason the Micrometer bridge uses the template as its {@code 
uri} tag.
+ * </ul>
+ *
+ * <p>
+ * On the exception path, the throwable is recorded via {@link 
Span#recordException(Throwable)}, the
+ * span status is set to {@link StatusCode#ERROR}, and an {@code 
exception.type} attribute carries the
+ * exception's simple-name.
+ *
+ * <h5 class='topic'>W3C trace context propagation</h5>
+ *
+ * <p>
+ * Incoming {@code traceparent} / {@code tracestate} request headers are 
extracted via the configured
+ * {@link TextMapPropagator} (default: {@link W3CTraceContextPropagator}) so 
the server span continues
+ * a caller-supplied distributed trace. Downstream HTTP calls made from inside 
the handler will pick
+ * up the active span's context automatically when the consumer's outbound 
HTTP client honors the OTel
+ * {@link Context#current() current context} (the standard OTel client 
instrumentations do).
+ *
+ * <h5 class='topic'>Wiring</h5>
+ *
+ * <p class='bjava'>
+ *     <ja>@Configuration</ja>
+ *     <jk>public class</jk> ObservabilityConfig {
+ *
+ *             <jc>// Option A: rely on the JVM-wide GlobalOpenTelemetry 
(recommended).</jc>
+ *             <ja>@Bean</ja>
+ *             <jk>public</jk> TracerHook tracerHook() {
+ *                     <jk>return new</jk> OtelTracerHook();
+ *             }
+ *
+ *             <jc>// Option B: pass a specific OpenTelemetry instance (e.g. 
for tests or multi-tenant setups).</jc>
+ *             <ja>@Bean</ja>
+ *             <jk>public</jk> TracerHook tracerHook(OpenTelemetry 
<jv>otel</jv>) {
+ *                     <jk>return new</jk> OtelTracerHook(<jv>otel</jv>);
+ *             }
+ *     }
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link TracerHook}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerObservability";>REST 
Server &mdash; Observability (Micrometer + OpenTelemetry)</a>
+ *     <li class='link'><a class="doclink" 
href="https://opentelemetry.io/docs/specs/semconv/http/http-spans/";>OpenTelemetry
 HTTP semantic conventions</a>
+ *     <li class='link'><a class="doclink" 
href="https://www.w3.org/TR/trace-context/";>W3C Trace Context</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class OtelTracerHook implements TracerHook {
+
+       /** Default tracer instrumentation scope name. */
+       public static final String DEFAULT_INSTRUMENTATION_NAME = 
"org.apache.juneau.rest";
+
+       /** OTel HTTP semantic-convention attribute key {@code 
http.request.method}. */
+       public static final AttributeKey<String> ATTR_HTTP_REQUEST_METHOD = 
AttributeKey.stringKey("http.request.method");
+
+       /** OTel HTTP semantic-convention attribute key {@code 
http.response.status_code}. */
+       public static final AttributeKey<Long> ATTR_HTTP_RESPONSE_STATUS_CODE = 
AttributeKey.longKey("http.response.status_code");
+
+       /** OTel HTTP semantic-convention attribute key {@code http.route}. */
+       public static final AttributeKey<String> ATTR_HTTP_ROUTE = 
AttributeKey.stringKey("http.route");
+
+       /** OTel attribute key {@code exception.type} used when recording a 
thrown exception. */
+       public static final AttributeKey<String> ATTR_EXCEPTION_TYPE = 
AttributeKey.stringKey("exception.type");
+
+       private static final String ARG_openTelemetry = "openTelemetry";
+       private static final String ARG_tracer = "tracer";
+       private static final String ARG_propagator = "propagator";
+
+       private final Tracer tracer;
+       private final TextMapPropagator propagator;
+
+       /**
+        * Constructor using {@link GlobalOpenTelemetry#get()} as the {@link 
OpenTelemetry} source.
+        *
+        * <p>
+        * Convenient when the deployment installs a single global {@link 
OpenTelemetry} (the dominant
+        * pattern with the OTel auto-instrumentation agent). Equivalent to
+        * {@code new OtelTracerHook(GlobalOpenTelemetry.get())}.
+        */
+       public OtelTracerHook() {
+               this(GlobalOpenTelemetry.get());
+       }
+
+       /**
+        * Constructor with an explicit {@link OpenTelemetry} instance.
+        *
+        * <p>
+        * The instance is used to obtain both the {@link Tracer} (named
+        * {@value #DEFAULT_INSTRUMENTATION_NAME}) and the {@link 
TextMapPropagator} used for incoming /
+        * outgoing W3C trace-context header transfer.
+        *
+        * @param openTelemetry The {@link OpenTelemetry} instance. Must not be 
<jk>null</jk>.
+        */
+       public OtelTracerHook(OpenTelemetry openTelemetry) {
+               assertArgNotNull(ARG_openTelemetry, openTelemetry);
+               this.tracer = 
openTelemetry.getTracer(DEFAULT_INSTRUMENTATION_NAME);
+               this.propagator = 
openTelemetry.getPropagators().getTextMapPropagator();
+       }
+
+       /**
+        * Constructor with an explicit {@link Tracer} and {@link 
TextMapPropagator}.
+        *
+        * <p>
+        * Useful for tests where in-memory tracer / propagator instances are 
wired directly without going
+        * through a full {@link OpenTelemetry} SDK assembly.
+        *
+        * @param tracer The {@link Tracer} used to build per-request spans. 
Must not be <jk>null</jk>.
+        * @param propagator The {@link TextMapPropagator} used for incoming / 
outgoing W3C trace-context
+        *      header transfer. Must not be <jk>null</jk>.
+        */
+       public OtelTracerHook(Tracer tracer, TextMapPropagator propagator) {
+               this.tracer = assertArgNotNull(ARG_tracer, tracer);
+               this.propagator = assertArgNotNull(ARG_propagator, propagator);
+       }
+
+       @Override /* TracerHook */
+       public Scope startSpan(RestRequest request) {
+               Context extracted = propagator.extract(Context.current(), 
request, RestRequestTextMapGetter.INSTANCE);
+
+               String httpMethod = defaultIfBlank(request.getMethod(), 
"UNKNOWN");
+               String route = resolveRoute(request);
+
+               var spanBuilder = tracer.spanBuilder(httpMethod)
+                       .setSpanKind(SpanKind.SERVER)
+                       .setParent(extracted)
+                       .setAttribute(ATTR_HTTP_REQUEST_METHOD, httpMethod);
+               if (! route.isEmpty())
+                       spanBuilder.setAttribute(ATTR_HTTP_ROUTE, route);
+
+               Span span = spanBuilder.startSpan();
+               Context spanContext = extracted.with(span);
+               io.opentelemetry.context.Scope otelScope = 
spanContext.makeCurrent();
+
+               return new OtelScope(span, otelScope);
+       }
+
+       /**
+        * Returns the {@link Tracer} this hook publishes spans to.
+        *
+        * @return The {@link Tracer}.
+        */
+       public Tracer getTracer() { return tracer; }
+
+       /**
+        * Returns the {@link TextMapPropagator} used to extract W3C 
trace-context headers from
+        * incoming requests.
+        *
+        * @return The {@link TextMapPropagator}.
+        */
+       public TextMapPropagator getPropagator() { return propagator; }
+
+       private static String resolveRoute(RestRequest request) {
+               try {
+                       var opCtx = request.getOpContext();
+                       return opCtx == null ? "" : 
defaultIfBlank(opCtx.getPathPattern(), "");
+               } catch (RuntimeException e) {
+                       // Defensive: opCtx.getPathPattern() reaches into the 
path-matcher array; protect against NPE / array bounds in odd routing setups.
+                       return "";
+               }
+       }
+
+       private static String defaultIfBlank(String value, String fallback) {
+               return (value == null || value.isEmpty()) ? fallback : value;
+       }
+
+       private static final class OtelScope implements Scope {
+
+               private final Span span;
+               private final io.opentelemetry.context.Scope otelScope;
+
+               OtelScope(Span span, io.opentelemetry.context.Scope otelScope) {
+                       this.span = span;
+                       this.otelScope = otelScope;
+               }
+
+               @Override /* Scope */
+               public void setStatusCode(int statusCode) {
+                       span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, 
(long) statusCode);
+                       if (statusCode >= 500)
+                               span.setStatus(StatusCode.ERROR);
+               }
+
+               @Override /* Scope */
+               public void setError(Throwable error) {
+                       if (nn(error)) {
+                               span.recordException(error);
+                               span.setAttribute(ATTR_EXCEPTION_TYPE, 
error.getClass().getSimpleName());
+                               span.setStatus(StatusCode.ERROR);
+                       }
+               }
+
+               @Override /* Scope */
+               public void close() {
+                       try {
+                               otelScope.close();
+                       } finally {
+                               span.end();
+                       }
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/RestRequestTextMapGetter.java
 
b/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/RestRequestTextMapGetter.java
new file mode 100644
index 0000000000..65cd276a3c
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/RestRequestTextMapGetter.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.tracing.otel;
+
+import java.util.*;
+
+import org.apache.juneau.rest.*;
+
+import io.opentelemetry.context.propagation.*;
+
+/**
+ * {@link TextMapGetter} implementation that reads HTTP request headers from a 
{@link RestRequest}.
+ *
+ * <p>
+ * Used by {@link OtelTracerHook} to extract W3C {@code traceparent} / {@code 
tracestate} headers (and
+ * any other propagator-defined headers, e.g. baggage) from incoming requests 
so the server span can
+ * be created as a child of the caller's trace context.
+ *
+ * <p>
+ * Header lookups are case-insensitive at the underlying {@code 
RequestHeaderList}, matching the
+ * RFC 9110 contract.
+ *
+ * @since 9.5.0
+ */
+public final class RestRequestTextMapGetter implements 
TextMapGetter<RestRequest> {
+
+       /** Process-wide singleton instance. */
+       public static final RestRequestTextMapGetter INSTANCE = new 
RestRequestTextMapGetter();
+
+       private RestRequestTextMapGetter() {}
+
+       @Override /* TextMapGetter */
+       public Iterable<String> keys(RestRequest carrier) {
+               if (carrier == null)
+                       return List.of();
+               return carrier.getHeaders().getNames();
+       }
+
+       @Override /* TextMapGetter */
+       public String get(RestRequest carrier, String key) {
+               if (carrier == null || key == null)
+                       return null;
+               var header = carrier.getHeaderParam(key);
+               if (! header.isPresent())
+                       return null;
+               var value = header.orElse(null);
+               return (value == null || value.isEmpty()) ? null : value;
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/package-info.java
 
b/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/package-info.java
new file mode 100644
index 0000000000..f6b9a13e6e
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/package-info.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * OpenTelemetry bridge for the {@code juneau-rest-server}
+ * {@link org.apache.juneau.rest.tracing.TracerHook} SPI.
+ *
+ * <p>
+ * This module is opt-in and ships
+ * {@link org.apache.juneau.rest.tracing.otel.OtelTracerHook} &mdash; a bridge 
that creates one
+ * OpenTelemetry {@link io.opentelemetry.api.trace.Span Span} per {@code 
@RestOp} call, populates the
+ * canonical HTTP semantic-convention attributes ({@code http.request.method},
+ * {@code http.response.status_code}, {@code http.route}), and continues an 
inbound W3C trace context
+ * carried on the {@code traceparent} / {@code tracestate} request headers.
+ *
+ * <h5 class='topic'>Containment</h5>
+ *
+ * <p>
+ * The {@code io.opentelemetry:opentelemetry-api} dependency is declared in 
{@code provided} scope on
+ * this module's POM. Consumers that want OpenTelemetry-backed tracing must add
+ * {@code opentelemetry-api} themselves at the version of their choice along 
with an SDK / exporter
+ * combination matching their downstream tracing backend (Jaeger, Zipkin, 
OTLP, etc.). This way:
+ * <ul>
+ *     <li>The core {@code juneau-rest-server} jar stays OpenTelemetry-free.
+ *     <li>Services that never opt in pay zero classpath cost.
+ *     <li>Services that DO opt in pick their own OpenTelemetry version 
(semantic-convention upgrades,
+ *             security patches) without waiting for a Juneau release.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.tracing.otel.OtelTracerHook}
+ *     <li class='jc'>{@link org.apache.juneau.rest.tracing.TracerHook}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerObservability";>REST 
Server &mdash; Observability (Micrometer + OpenTelemetry)</a>
+ *     <li class='link'><a class="doclink" 
href="https://opentelemetry.io/docs/specs/semconv/http/http-spans/";>OpenTelemetry
 HTTP semantic conventions</a>
+ *     <li class='link'><a class="doclink" 
href="https://www.w3.org/TR/trace-context/";>W3C Trace Context</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+package org.apache.juneau.rest.tracing.otel;
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
index b861f213f2..b4374bf310 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
@@ -19,12 +19,15 @@ package org.apache.juneau.rest;
 import static org.apache.juneau.commons.utils.Utils.*;
 
 import java.lang.reflect.*;
+import java.time.*;
 import java.util.function.*;
 
 import org.apache.juneau.commons.reflect.*;
 import org.apache.juneau.http.response.*;
 import org.apache.juneau.rest.arg.*;
+import org.apache.juneau.rest.metrics.*;
 import org.apache.juneau.rest.stats.*;
+import org.apache.juneau.rest.tracing.*;
 
 /**
  * A specialized invoker for methods that are called during a servlet request.
@@ -70,6 +73,28 @@ public class RestOpInvoker extends MethodInvoker {
         * @throws Exception If an error occurred during either parameter 
resolution or method invocation.
         */
        public void invoke(RestOpSession opSession) throws Exception {
+               invoke(opSession, false);
+       }
+
+       /**
+        * Invokes this method as the main {@code @RestOp} handler from the 
specified {@link RestOpSession},
+        * firing per-request observability hooks ({@link MetricsRecorder} and 
{@link TracerHook}) around the
+        * underlying invocation.
+        *
+        * <p>
+        * Called by {@link RestOpSession#run()} for the {@code 
@RestOp}-annotated handler method. Pre / post
+        * call methods (those invoked via {@link 
RestContext#preCall(RestOpSession)} /
+        * {@link RestContext#postCall(RestOpSession)}) continue to use {@link 
#invoke(RestOpSession)} so the
+        * observability boundary stays anchored on the user-facing handler.
+        *
+        * @param opSession The REST call.
+        * @throws Exception If an error occurred during either parameter 
resolution or method invocation.
+        */
+       public void invokeOp(RestOpSession opSession) throws Exception {
+               invoke(opSession, true);
+       }
+
+       private void invoke(RestOpSession opSession, boolean observable) throws 
Exception {
                var args = new Object[opArgs.length];
                for (var i = 0; i < opArgs.length; i++) {
                        ParameterInfo pi = inner().getParameter(i);
@@ -81,10 +106,24 @@ public class RestOpInvoker extends MethodInvoker {
                                throw new BadRequest(e, "Could not resolve 
parameter {0} of type ''{1}'' on method ''{2}''.", i, pi.getParameterType(), 
getFullName());
                        }
                }
+
+               RestRequest req = opSession.getRequest();
+               RestResponse res = opSession.getResponse();
+
+               MetricsRecorder recorder = NoOpMetricsRecorder.INSTANCE;
+               Scope tracerScope = NoOpTracerHook.NoOpScope.INSTANCE;
+               long startNanos = 0L;
+               Throwable observed = null;
+               if (observable) {
+                       var bs = opSession.getRestContext().getBeanStore();
+                       recorder = 
bs.getBean(MetricsRecorder.class).orElse(NoOpMetricsRecorder.INSTANCE);
+                       var tracer = 
bs.getBean(TracerHook.class).orElse(NoOpTracerHook.INSTANCE);
+                       tracerScope = tracer.startSpan(req);
+                       startNanos = System.nanoTime();
+               }
+
                try {
                        RestSession session = opSession.getRestSession();
-                       RestRequest req = opSession.getRequest();
-                       RestResponse res = opSession.getResponse();
 
                        var target = resourceSupplier == null ? 
session.getResource() : resourceSupplier.get();
                        Object output = super.invoke(target, args);
@@ -101,12 +140,42 @@ public class RestOpInvoker extends MethodInvoker {
                                res.setContent(output);
 
                } catch (IllegalAccessException | IllegalArgumentException e) {
+                       observed = e;
                        throw new InternalServerError(e, "Error occurred 
invoking method ''{0}''.", inner().getNameFull());
                } catch (InvocationTargetException e) {
-                       RestResponse res = opSession.getResponse();
                        Throwable e2 = e.getTargetException();
+                       observed = e2;
                        res.setStatus(500);  // May be overridden later.
                        
res.setContent(opSession.getRestContext().convertThrowable(e2));
+               } finally {
+                       if (observable) {
+                               int status = res.getStatus();
+                               if (status == 0)
+                                       status = (observed == null) ? 200 : 500;
+                               try {
+                                       tracerScope.setStatusCode(status);
+                                       if (nn(observed))
+                                               tracerScope.setError(observed);
+                               } finally {
+                                       try {
+                                               tracerScope.close();
+                                       } finally {
+                                               var elapsed = 
Duration.ofNanos(System.nanoTime() - startNanos);
+                                               var pathTemplate = 
resolveUriTemplate(opSession);
+                                               recorder.record(getFullName(), 
req.getMethod(), pathTemplate, status, elapsed, observed);
+                                       }
+                               }
+                       }
+               }
+       }
+
+       private static String resolveUriTemplate(RestOpSession opSession) {
+               try {
+                       var pp = opSession.getContext().getPathPattern();
+                       return pp == null ? "" : pp;
+               } catch (RuntimeException e) {
+                       // Defensive: getPathPattern() dereferences 
pathMatchers[0]; protect against any unusual routing setup.
+                       return "";
                }
        }
 }
\ No newline at end of file
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpSession.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpSession.java
index 95530cddb5..318ae4c15a 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpSession.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpSession.java
@@ -215,7 +215,7 @@ public class RestOpSession extends ContextSession {
                                return;
                }
 
-               ctx.getMethodInvoker().invoke(this);
+               ctx.getMethodInvoker().invokeOp(this);
 
                if (res.hasContent())
                        for (var converter : ctx.getConverters())
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/MetricsRecorder.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/MetricsRecorder.java
new file mode 100644
index 0000000000..593a950f4d
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/MetricsRecorder.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.metrics;
+
+import java.time.*;
+
+/**
+ * SPI for receiving per-request metric events from {@code juneau-rest-server}.
+ *
+ * <p>
+ * One {@code record(...)} call fires after every {@code @RestOp} handler 
invocation &mdash; including
+ * exception paths. Bridge implementations translate the event into the format 
their downstream
+ * registry expects (Micrometer {@link io.micrometer.core.instrument.Timer 
Timer}, OpenTelemetry
+ * metric instruments, Dropwizard {@code MetricRegistry}, etc.).
+ *
+ * <h5 class='topic'>Off-by-default contract</h5>
+ *
+ * <p>
+ * {@code juneau-rest-server} resolves the {@code MetricsRecorder} via
+ * {@code RestContext.getBeanStore().getBean(MetricsRecorder.class)}. When no 
bean is supplied,
+ * {@link NoOpMetricsRecorder#INSTANCE} is used and the framework never 
allocates a downstream
+ * registry, never reaches Micrometer / OpenTelemetry on its own, and never 
adds any per-request
+ * cost beyond a single static-field read. To opt in, the consumer registers a 
{@code MetricsRecorder}
+ * bean &mdash; typically via {@code @Bean MetricsRecorder} on the resource or 
its parent.
+ *
+ * <h5 class='topic'>Usage</h5>
+ *
+ * <p class='bjava'>
+ *     <ja>@Rest</ja>
+ *     <jk>public class</jk> MyResource <jk>extends</jk> RestServlet {
+ *
+ *             <ja>@Bean</ja>
+ *             <jk>public</jk> MetricsRecorder recorder(MeterRegistry 
<jv>registry</jv>) {
+ *                     <jk>return new</jk> 
MicrometerMetricsRecorder(<jv>registry</jv>);
+ *             }
+ *     }
+ * </p>
+ *
+ * <h5 class='topic'>Threading</h5>
+ *
+ * <p>
+ * Implementations <b>must</b> be thread-safe &mdash; the same {@code 
MetricsRecorder} bean is invoked
+ * concurrently from every request thread for the lifetime of the {@code 
RestContext}. The framework
+ * calls {@code record(...)} synchronously inside the request thread 
immediately after the response
+ * status is known, so implementations should keep the call cheap 
(constant-time tag computation,
+ * non-blocking registry writes).
+ *
+ * <h5 class='topic'>Event ordering</h5>
+ *
+ * <p>
+ * For a given {@code @RestOp} invocation, {@code record(...)} fires exactly 
once after the operation
+ * completes &mdash; whether the handler returned normally, threw a {@code 
BasicHttpException}, or
+ * triggered an internal-server-error. The {@code elapsed} parameter measures 
wall-clock time spent
+ * inside the framework's per-op invocation boundary (from just before 
parameter resolution to just
+ * after the handler returns or throws). It does <b>not</b> include response 
serialization performed
+ * later in the request pipeline.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link NoOpMetricsRecorder}
+ *     <li class='jc'>{@link org.apache.juneau.rest.tracing.TracerHook}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerObservability";>REST 
Server &mdash; Observability (Micrometer + OpenTelemetry)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+@FunctionalInterface
+public interface MetricsRecorder {
+
+       /**
+        * Records one per-request metric event.
+        *
+        * @param opName The fully-qualified {@code @RestOp} method name (e.g.
+        *      {@code 
"com.example.OrderResource.create(java.lang.String,com.example.Order)"}).
+        *      Never <jk>null</jk>; never blank.
+        * @param httpMethod The uppercased HTTP method of the request (e.g. 
{@code "GET"}, {@code "POST"}).
+        *      Never <jk>null</jk>; never blank.
+        * @param uriTemplate The {@code @RestOp} path template (e.g. {@code 
"/users/{id}"}), <b>not</b> the
+        *      raw concrete URI. Bridges should use this as the {@code uri} 
tag to keep cardinality bounded.
+        *      May be empty for resource-level operations that match {@code 
/*}; never <jk>null</jk>.
+        * @param statusCode The HTTP response status code (e.g. {@code 200}, 
{@code 404}, {@code 500}).
+        *      If the handler threw before the status was set, this is the 
framework's inferred status (typically
+        *      {@code 500} for an unmapped exception, or the exception's 
status for a {@code BasicHttpException}).
+        * @param elapsed Wall-clock duration of the {@code @RestOp} 
invocation. Never <jk>null</jk>; never
+        *      negative.
+        * @param error The exception thrown by the handler, or <jk>null</jk> 
if the call completed normally.
+        *      Bridges typically derive an {@code exception} tag from the 
throwable's simple class name, or
+        *      {@code "None"} when null (mirrors Spring Boot's {@code 
WebMvcMetricsFilter} convention).
+        */
+       void record(String opName, String httpMethod, String uriTemplate, int 
statusCode, Duration elapsed, Throwable error);
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/NoOpMetricsRecorder.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/NoOpMetricsRecorder.java
new file mode 100644
index 0000000000..b1ea271350
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/NoOpMetricsRecorder.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.metrics;
+
+import java.time.*;
+
+/**
+ * Default {@link MetricsRecorder} implementation that drops every event on 
the floor.
+ *
+ * <p>
+ * Used whenever no consumer-provided {@code MetricsRecorder} bean is 
reachable from the
+ * {@code RestContext}'s bean store. Ensures the {@link MetricsRecorder} 
contract is always satisfied
+ * &mdash; the framework can call {@code record(...)} unconditionally without 
a null-check.
+ *
+ * <p>
+ * The implementation is a single static-field constant ({@link #INSTANCE}); 
the {@code record(...)}
+ * method does nothing, allocates nothing, and JIT-inlines to an empty method 
body, so the
+ * off-by-default path has no observable runtime cost.
+ *
+ * @since 9.5.0
+ */
+public final class NoOpMetricsRecorder implements MetricsRecorder {
+
+       /** Process-wide singleton instance. */
+       public static final NoOpMetricsRecorder INSTANCE = new 
NoOpMetricsRecorder();
+
+       private NoOpMetricsRecorder() {}
+
+       @Override /* MetricsRecorder */
+       public void record(String opName, String httpMethod, String 
uriTemplate, int statusCode, Duration elapsed, Throwable error) {
+               // Intentionally empty — the default off-by-default behaviour 
is to drop every event.
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/package-info.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/package-info.java
new file mode 100644
index 0000000000..7f742607f2
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/package-info.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Metrics SPI for {@code juneau-rest-server} &mdash; per-request 
observability hook backed by a
+ * caller-supplied {@link org.apache.juneau.rest.metrics.MetricsRecorder} bean.
+ *
+ * <h5 class='topic'>Opt-in</h5>
+ *
+ * <p>
+ * The SPI is off by default. A fresh {@code RestContext} built without any
+ * {@code @Bean MetricsRecorder} entry uses {@link 
org.apache.juneau.rest.metrics.NoOpMetricsRecorder}
+ * &mdash; no downstream registry is touched, no per-request allocation is 
made. Consumers opt in by
+ * supplying a bridge implementation:
+ *
+ * <ul>
+ *     <li>{@code juneau-rest-server-micrometer} ships {@code 
MicrometerMetricsRecorder} backed by an
+ *             {@link io.micrometer.core.instrument.MeterRegistry 
MeterRegistry}.
+ *     <li>Custom backends (Dropwizard {@code MetricRegistry}, 
application-specific stat counters, etc.)
+ *             can implement {@link 
org.apache.juneau.rest.metrics.MetricsRecorder} directly &mdash; the SPI
+ *             intentionally has no Micrometer / OpenTelemetry dependency on 
the core surface.
+ * </ul>
+ *
+ * <h5 class='topic'>Event shape</h5>
+ *
+ * <p>
+ * Every {@code @RestOp} invocation fires exactly one
+ * {@link org.apache.juneau.rest.metrics.MetricsRecorder#record record(...)} 
call &mdash; happy path
+ * and exception path alike. The event carries the op name, the HTTP method, 
the {@code @RestOp} path
+ * template (so consumers can use it as a bounded-cardinality {@code uri} 
tag), the response status
+ * code, the wall-clock elapsed time, and the thrown {@link Throwable} (or 
{@code null} on success).
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ *     <li class='jc'>{@link org.apache.juneau.rest.metrics.MetricsRecorder}
+ *     <li class='jc'>{@link 
org.apache.juneau.rest.metrics.NoOpMetricsRecorder}
+ *     <li class='jp'>{@link org.apache.juneau.rest.tracing}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerObservability";>REST 
Server &mdash; Observability (Micrometer + OpenTelemetry)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+package org.apache.juneau.rest.metrics;
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/NoOpTracerHook.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/NoOpTracerHook.java
new file mode 100644
index 0000000000..ea86a7ad8e
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/NoOpTracerHook.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.tracing;
+
+import org.apache.juneau.rest.*;
+
+/**
+ * Default {@link TracerHook} implementation that opens no spans and records 
nothing.
+ *
+ * <p>
+ * Used whenever no consumer-provided {@code TracerHook} bean is reachable 
from the
+ * {@code RestContext}'s bean store. Ensures the {@link TracerHook} contract 
is always satisfied
+ * &mdash; the framework can call {@link #startSpan(RestRequest)} 
unconditionally without a
+ * null-check, and the returned {@link NoOpScope} satisfies the 
close-in-finally contract with
+ * zero allocations.
+ *
+ * <p>
+ * Both {@link #INSTANCE} and {@link NoOpScope#INSTANCE} are process-wide 
singletons; the
+ * {@link #startSpan(RestRequest)} method returns the same scope reference for 
every call and JIT-inlines
+ * the no-op setters / close to empty method bodies.
+ *
+ * @since 9.5.0
+ */
+public final class NoOpTracerHook implements TracerHook {
+
+       /** Process-wide singleton instance. */
+       public static final NoOpTracerHook INSTANCE = new NoOpTracerHook();
+
+       private NoOpTracerHook() {}
+
+       @Override /* TracerHook */
+       public Scope startSpan(RestRequest request) {
+               return NoOpScope.INSTANCE;
+       }
+
+       /**
+        * No-op {@link Scope} returned by {@link NoOpTracerHook}.
+        *
+        * <p>
+        * Drops every status, error, and close transition on the floor. Public 
so user code that wants to
+        * fall back to the no-op behaviour from a custom {@link TracerHook} 
(for example, when the
+        * incoming trace context is unparseable) can return {@link #INSTANCE} 
instead of implementing a
+        * private equivalent.
+        */
+       public static final class NoOpScope implements Scope {
+
+               /** Process-wide singleton instance. */
+               public static final NoOpScope INSTANCE = new NoOpScope();
+
+               private NoOpScope() {}
+
+               @Override /* Scope */
+               public void setStatusCode(int statusCode) {
+                       // Intentionally empty.
+               }
+
+               @Override /* Scope */
+               public void setError(Throwable error) {
+                       // Intentionally empty.
+               }
+
+               @Override /* Scope */
+               public void close() {
+                       // Intentionally empty.
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/Scope.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/Scope.java
new file mode 100644
index 0000000000..d9920e2fcc
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/Scope.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.tracing;
+
+/**
+ * Handle to one in-flight {@link TracerHook}-managed span.
+ *
+ * <p>
+ * Returned by {@link 
TracerHook#startSpan(org.apache.juneau.rest.RestRequest)}. The framework drives
+ * the {@code setStatusCode} / {@code setError} / {@code close} transitions 
inside a {@code finally}
+ * block so the span always closes &mdash; including on exception paths.
+ *
+ * <p>
+ * Bridge implementations typically wrap the downstream tracer's native span / 
scope. The OpenTelemetry
+ * bridge in {@code juneau-rest-server-otel}, for example, wraps an
+ * {@code io.opentelemetry.api.trace.Span} plus its {@code 
io.opentelemetry.context.Scope}: setters
+ * translate to span attribute / status writes; {@code close()} ends the span 
and closes the OTel
+ * scope.
+ *
+ * @since 9.5.0
+ */
+public interface Scope extends AutoCloseable {
+
+       /**
+        * Records the HTTP response status code on the span.
+        *
+        * <p>
+        * Called by the framework after the {@code @RestOp} handler completes 
&mdash; before
+        * {@link #close()}, and before {@link #setError(Throwable)} if the 
handler also threw. Bridges
+        * typically translate the status into both a numeric attribute (e.g. 
OpenTelemetry's
+        * {@code http.response.status_code}) and an overall span status (HTTP 
{@code 5xx} maps to
+        * span-status {@code ERROR}; other codes map to {@code UNSET} per the 
OTel HTTP semantic
+        * conventions).
+        *
+        * @param statusCode The HTTP status code (e.g. {@code 200}, {@code 
404}, {@code 500}).
+        */
+       void setStatusCode(int statusCode);
+
+       /**
+        * Records the exception (if any) the {@code @RestOp} handler threw on 
the span.
+        *
+        * <p>
+        * Called by the framework only when the handler threw &mdash; 
immediately before {@link #close()}.
+        * Bridges typically record the throwable as a span event with the 
stack trace, set the span status
+        * to {@code ERROR}, and add an {@code exception.type} attribute 
carrying the exception's
+        * simple-name.
+        *
+        * @param error The exception thrown by the handler. Never 
<jk>null</jk> when this method is called.
+        */
+       void setError(Throwable error);
+
+       /**
+        * Closes the span.
+        *
+        * <p>
+        * Always called by the framework &mdash; via the standard {@code 
finally}-block contract on
+        * {@link AutoCloseable} &mdash; whether the handler returned normally 
or threw. Implementations
+        * <b>must not</b> throw checked exceptions; the close path runs in a 
{@code finally} where masking
+        * the original handler exception is unsafe. Implementations that need 
to surface bridge-level
+        * errors should log + swallow instead.
+        */
+       @Override
+       void close();
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/TracerHook.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/TracerHook.java
new file mode 100644
index 0000000000..88d7f390f4
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/TracerHook.java
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.tracing;
+
+import org.apache.juneau.rest.*;
+
+/**
+ * SPI for receiving per-request tracing events from {@code 
juneau-rest-server}.
+ *
+ * <p>
+ * {@link #startSpan(RestRequest)} fires immediately before every {@code 
@RestOp} handler runs &mdash;
+ * the returned {@link Scope} is closed by the framework after the handler 
completes (including the
+ * exception path). Bridge implementations create whatever the downstream 
tracer expects (an
+ * OpenTelemetry {@link io.opentelemetry.api.trace.Span Span}, a Brave {@code 
Span}, an application
+ * tracing object, etc.) and surface the close / status / error transitions 
through the {@code Scope}.
+ *
+ * <h5 class='topic'>Off-by-default contract</h5>
+ *
+ * <p>
+ * {@code juneau-rest-server} resolves the {@code TracerHook} via
+ * {@code RestContext.getBeanStore().getBean(TracerHook.class)}. When no bean 
is supplied,
+ * {@link NoOpTracerHook#INSTANCE} is used &mdash; the framework never reaches 
for a tracer, never
+ * inspects headers for W3C trace context, and adds no per-request cost beyond 
a single static-field
+ * read plus a single {@code AutoCloseable.close()} on a singleton noop scope. 
To opt in, the consumer
+ * registers a {@code TracerHook} bean &mdash; typically via {@code @Bean 
TracerHook} on the resource
+ * or its parent.
+ *
+ * <h5 class='topic'>Usage</h5>
+ *
+ * <p class='bjava'>
+ *     <ja>@Rest</ja>
+ *     <jk>public class</jk> MyResource <jk>extends</jk> RestServlet {
+ *
+ *             <ja>@Bean</ja>
+ *             <jk>public</jk> TracerHook tracer(OpenTelemetry <jv>otel</jv>) {
+ *                     <jk>return new</jk> OtelTracerHook(<jv>otel</jv>);
+ *             }
+ *     }
+ * </p>
+ *
+ * <h5 class='topic'>Lifecycle</h5>
+ *
+ * <p>
+ * For each {@code @RestOp} invocation, the framework:
+ * <ol>
+ *     <li>Calls {@link #startSpan(RestRequest)} just before parameter 
resolution, passing the in-flight
+ *             {@link RestRequest} so the bridge can read trace-context headers
+ *             ({@code traceparent} / {@code tracestate}) and set 
request-derived attributes.
+ *     <li>Runs the handler (which may throw).
+ *     <li>Calls {@link Scope#setStatusCode(int)} with the resolved response 
status, then
+ *             {@link Scope#setError(Throwable)} if the handler threw, then 
{@link Scope#close()} &mdash;
+ *             always in a {@code finally} block so the span closes even on 
exception.
+ * </ol>
+ *
+ * <p>
+ * Implementations <b>must</b> be thread-safe &mdash; the same {@code 
TracerHook} bean is invoked
+ * concurrently from every request thread for the lifetime of the {@code 
RestContext}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link NoOpTracerHook}
+ *     <li class='jc'>{@link Scope}
+ *     <li class='jc'>{@link org.apache.juneau.rest.metrics.MetricsRecorder}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerObservability";>REST 
Server &mdash; Observability (Micrometer + OpenTelemetry)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+@FunctionalInterface
+public interface TracerHook {
+
+       /**
+        * Opens a new span for the in-flight request.
+        *
+        * @param request The in-flight {@link RestRequest}. Never 
<jk>null</jk>. Bridges may read headers
+        *      for distributed-tracing context propagation (e.g. W3C {@code 
traceparent} / {@code tracestate}),
+        *      the HTTP method, the path, etc. Bridges <b>must not</b> mutate 
the request.
+        * @return The opened {@link Scope}. Never <jk>null</jk>. Returning 
{@code null} is a contract
+        *      violation &mdash; bridges that cannot open a span (e.g. because 
the trace context is invalid)
+        *      should return {@link NoOpTracerHook.NoOpScope#INSTANCE} so the 
framework's close-in-finally
+        *      contract still holds.
+        */
+       Scope startSpan(RestRequest request);
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/package-info.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/package-info.java
new file mode 100644
index 0000000000..cdbf6f4d07
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/package-info.java
@@ -0,0 +1,60 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Tracing SPI for {@code juneau-rest-server} &mdash; per-request span hook 
backed by a
+ * caller-supplied {@link org.apache.juneau.rest.tracing.TracerHook} bean.
+ *
+ * <h5 class='topic'>Opt-in</h5>
+ *
+ * <p>
+ * The SPI is off by default. A fresh {@code RestContext} built without any
+ * {@code @Bean TracerHook} entry uses {@link 
org.apache.juneau.rest.tracing.NoOpTracerHook}
+ * &mdash; no tracer is reached for, no trace-context headers are parsed, no 
spans are opened. Consumers
+ * opt in by supplying a bridge implementation:
+ *
+ * <ul>
+ *     <li>{@code juneau-rest-server-otel} ships {@code OtelTracerHook} backed 
by an
+ *             {@link io.opentelemetry.api.OpenTelemetry OpenTelemetry} 
instance &mdash; per-request span
+ *             creation, OTel HTTP semantic-convention attributes, and W3C 
{@code traceparent} /
+ *             {@code tracestate} context propagation.
+ *     <li>Custom tracers (Brave, application-specific span beans, etc.) can 
implement
+ *             {@link org.apache.juneau.rest.tracing.TracerHook} directly 
&mdash; the SPI intentionally has no
+ *             OpenTelemetry dependency on the core surface.
+ * </ul>
+ *
+ * <h5 class='topic'>Lifecycle</h5>
+ *
+ * <p>
+ * For every {@code @RestOp} invocation, the framework opens a {@link 
org.apache.juneau.rest.tracing.Scope}
+ * via {@link org.apache.juneau.rest.tracing.TracerHook#startSpan 
startSpan(...)} immediately before
+ * parameter resolution and closes it in a {@code finally} block after the 
handler returns or throws.
+ * Status code and exception are reported via {@link 
org.apache.juneau.rest.tracing.Scope#setStatusCode}
+ * and {@link org.apache.juneau.rest.tracing.Scope#setError} before {@link 
org.apache.juneau.rest.tracing.Scope#close}.
+ *
+ * <h5 class='section'>See Also:</h5>
+ * <ul>
+ *     <li class='jc'>{@link org.apache.juneau.rest.tracing.TracerHook}
+ *     <li class='jc'>{@link org.apache.juneau.rest.tracing.Scope}
+ *     <li class='jc'>{@link org.apache.juneau.rest.tracing.NoOpTracerHook}
+ *     <li class='jp'>{@link org.apache.juneau.rest.metrics}
+ *     <li class='link'><a class="doclink" 
href="https://juneau.apache.org/docs/topics/RestServerObservability";>REST 
Server &mdash; Observability (Micrometer + OpenTelemetry)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+package org.apache.juneau.rest.tracing;
diff --git a/juneau-rest/pom.xml b/juneau-rest/pom.xml
index 6dd2e13936..8620982e8d 100644
--- a/juneau-rest/pom.xml
+++ b/juneau-rest/pom.xml
@@ -37,6 +37,8 @@
                <module>juneau-rest-server-rdf</module>
                <module>juneau-rest-server-mcp</module>
                <module>juneau-rest-server-jwt</module>
+               <module>juneau-rest-server-micrometer</module>
+               <module>juneau-rest-server-otel</module>
                <module>juneau-rest-server-view-jsp</module>
                <module>juneau-rest-server-view-thymeleaf</module>
                <module>juneau-rest-server-view-mustache</module>
diff --git a/juneau-utest/pom.xml b/juneau-utest/pom.xml
index dcf546ac91..e06744284e 100644
--- a/juneau-utest/pom.xml
+++ b/juneau-utest/pom.xml
@@ -353,6 +353,57 @@
                        <version>5.0.0</version>
                        <scope>test</scope>
                </dependency>
+               <!--
+                       Micrometer + OpenTelemetry observability bridge 
modules. Both module poms declare
+                       their external dep (`micrometer-core` / 
`opentelemetry-api`) in `provided` scope so
+                       the consumer picks the version they want — juneau-utest 
exercises the bridges
+                       end-to-end against the real Micrometer / OTel runtimes, 
so we add the runtime libs
+                       here in `test` scope explicitly. The Prometheus 
registry covers the Spring-style
+                       scrape-output assertion in the Micrometer bridge tests; 
the OpenTelemetry SDK
+                       testing artifact provides the in-memory span exporter 
the OTel bridge tests use.
+               -->
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-server-micrometer</artifactId>
+                       <version>${project.version}</version>
+                       <scope>test</scope>
+               </dependency>
+               <dependency>
+                       <groupId>io.micrometer</groupId>
+                       <artifactId>micrometer-core</artifactId>
+                       <version>1.13.6</version>
+                       <scope>test</scope>
+               </dependency>
+               <dependency>
+                       <groupId>io.micrometer</groupId>
+                       <artifactId>micrometer-registry-prometheus</artifactId>
+                       <version>1.13.6</version>
+                       <scope>test</scope>
+               </dependency>
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-server-otel</artifactId>
+                       <version>${project.version}</version>
+                       <scope>test</scope>
+               </dependency>
+               <dependency>
+                       <groupId>io.opentelemetry</groupId>
+                       <artifactId>opentelemetry-api</artifactId>
+                       <version>1.43.0</version>
+                       <scope>test</scope>
+               </dependency>
+               <dependency>
+                       <groupId>io.opentelemetry</groupId>
+                       <artifactId>opentelemetry-sdk</artifactId>
+                       <version>1.43.0</version>
+                       <scope>test</scope>
+               </dependency>
+               <dependency>
+                       <groupId>io.opentelemetry</groupId>
+                       <artifactId>opentelemetry-sdk-testing</artifactId>
+                       <version>1.43.0</version>
+                       <scope>test</scope>
+               </dependency>
                <dependency>
                        <groupId>org.openjdk.jmh</groupId>
                        <artifactId>jmh-core</artifactId>
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/MetricsRecorder_Contract_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/MetricsRecorder_Contract_Test.java
new file mode 100644
index 0000000000..113853628f
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/MetricsRecorder_Contract_Test.java
@@ -0,0 +1,181 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.metrics;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates the {@link MetricsRecorder} SPI contract end-to-end &mdash; the 
{@code record(...)} call
+ * fires exactly once per {@code @RestOp} invocation, carries the 
framework-resolved op name, HTTP
+ * method, path template, status code, elapsed time, and the exception (or 
{@code null}) the handler
+ * threw, and the default off-by-default {@link NoOpMetricsRecorder} drops 
events on the floor.
+ */
+class MetricsRecorder_Contract_Test extends TestBase {
+
+       /** Recording {@link MetricsRecorder} that captures every event in 
declaration order. */
+       public static final class RecordingMetricsRecorder implements 
MetricsRecorder {
+               public final List<Event> events = new CopyOnWriteArrayList<>();
+
+               @Override
+               public void record(String opName, String httpMethod, String 
uriTemplate, int statusCode, Duration elapsed, Throwable error) {
+                       events.add(new Event(opName, httpMethod, uriTemplate, 
statusCode, elapsed, error));
+               }
+
+               public Event last() { return events.get(events.size() - 1); }
+       }
+
+       public static final class Event {
+               public final String opName;
+               public final String httpMethod;
+               public final String uriTemplate;
+               public final int statusCode;
+               public final Duration elapsed;
+               public final Throwable error;
+
+               Event(String opName, String httpMethod, String uriTemplate, int 
statusCode, Duration elapsed, Throwable error) {
+                       this.opName = opName;
+                       this.httpMethod = httpMethod;
+                       this.uriTemplate = uriTemplate;
+                       this.statusCode = statusCode;
+                       this.elapsed = elapsed;
+                       this.error = error;
+               }
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // A: Recorder is opt-in via a @Bean MetricsRecorder. Happy path fires 
record(...) exactly once with the resolved
+       //    op name, HTTP method, path template, 200, non-zero elapsed, and 
null error.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private static final RecordingMetricsRecorder A_REC = new 
RecordingMetricsRecorder();
+
+       @Rest
+       public static class A extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @org.apache.juneau.commons.inject.Bean
+               public MetricsRecorder recorder() { return A_REC; }
+
+               @RestGet("/users/{id}")
+               public String findUser(@org.apache.juneau.http.annotation.Path 
String id) {
+                       return "user:" + id;
+               }
+       }
+
+       private static final MockRestClient CA = 
MockRestClient.buildLax(A.class);
+
+       @Test void a01_happyPath_oneEventCaptured() throws Exception {
+               A_REC.events.clear();
+               
CA.get("/users/42").run().assertStatus(200).assertContent("user:42");
+               assertEquals(1, A_REC.events.size(), "exactly one event per 
@RestOp call");
+               var e = A_REC.last();
+               assertEquals("GET", e.httpMethod);
+               assertEquals(200, e.statusCode);
+               assertNull(e.error);
+               assertNotNull(e.elapsed);
+               assertFalse(e.elapsed.isNegative(), "elapsed must be 
non-negative");
+               assertTrue(e.opName.contains("findUser"), "opName carries the 
Java method name: " + e.opName);
+               assertEquals("/users/{id}", e.uriTemplate, "uriTemplate carries 
the @RestOp path template (bounded cardinality)");
+       }
+
+       @Test void a02_pathTemplateNotRawUri() throws Exception {
+               A_REC.events.clear();
+               CA.get("/users/abc").run().assertStatus(200);
+               CA.get("/users/xyz").run().assertStatus(200);
+               assertEquals(2, A_REC.events.size());
+               assertEquals(A_REC.events.get(0).uriTemplate, 
A_REC.events.get(1).uriTemplate, "different concrete URIs must share the same 
template tag");
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B: Exception path — record(...) still fires; status >= 500; error 
carries the thrown Throwable.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private static final RecordingMetricsRecorder B_REC = new 
RecordingMetricsRecorder();
+
+       @Rest
+       public static class B extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @org.apache.juneau.commons.inject.Bean
+               public MetricsRecorder recorder() { return B_REC; }
+
+               @RestGet("/boom")
+               public String boom() {
+                       throw new IllegalStateException("kaboom");
+               }
+       }
+
+       private static final MockRestClient CB = 
MockRestClient.buildLax(B.class);
+
+       @Test void b01_exceptionPath_eventFiredWithError() throws Exception {
+               B_REC.events.clear();
+               CB.get("/boom").run().assertStatus(500);
+               assertEquals(1, B_REC.events.size());
+               var e = B_REC.last();
+               assertEquals(500, e.statusCode);
+               assertNotNull(e.error);
+               assertEquals("IllegalStateException", 
e.error.getClass().getSimpleName());
+               assertEquals("/boom", e.uriTemplate);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // C: Off-by-default — without a @Bean MetricsRecorder, the framework 
wires NoOpMetricsRecorder and no events
+       //    reach any external recorder. We verify by asserting that this 
resource has no recording side effect on a
+       //    shared canary recorder.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private static final RecordingMetricsRecorder C_CANARY = new 
RecordingMetricsRecorder();
+
+       @Rest
+       public static class C extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @RestGet("/ping")
+               public String ping() { return "pong"; }
+       }
+
+       private static final MockRestClient CC = 
MockRestClient.buildLax(C.class);
+
+       @Test void c01_noRecorderBean_noEventsFanOut() throws Exception {
+               C_CANARY.events.clear();
+               CC.get("/ping").run().assertStatus(200);
+               assertEquals(0, C_CANARY.events.size(), "without a @Bean 
MetricsRecorder, no events reach external recorders");
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // D: NoOpMetricsRecorder direct contract — single static instance, 
record(...) is a no-op.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void d01_noOpRecorder_isSingleton() {
+               assertSame(NoOpMetricsRecorder.INSTANCE, 
NoOpMetricsRecorder.INSTANCE);
+       }
+
+       @Test void d02_noOpRecorder_recordIsNoOp() {
+               assertDoesNotThrow(() -> 
NoOpMetricsRecorder.INSTANCE.record("op", "GET", "/x", 200, 
Duration.ofMillis(1), null));
+               assertDoesNotThrow(() -> 
NoOpMetricsRecorder.INSTANCE.record(null, null, null, 0, Duration.ZERO, new 
RuntimeException()));
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder_Test.java
new file mode 100644
index 0000000000..c3479fb652
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder_Test.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.metrics.micrometer;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+import io.micrometer.core.instrument.*;
+import io.micrometer.core.instrument.simple.*;
+import io.micrometer.prometheusmetrics.*;
+import io.prometheus.metrics.model.registry.*;
+
+/**
+ * Validates {@link MicrometerMetricsRecorder} &mdash; direct {@code 
record(...)} produces a
+ * Micrometer {@link Timer} with the Spring-Boot-style tags, the bridge picks 
up via a
+ * {@code @Bean MetricsRecorder} on a real {@code @Rest}, and the {@link 
PrometheusMeterRegistry}
+ * scrape output carries the {@code method} / {@code uri} / {@code status} / 
{@code exception} tags.
+ */
+class MicrometerMetricsRecorder_Test extends TestBase {
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // A: Unit tests against a SimpleMeterRegistry.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_defaultTimerName_isHttpServerRequests() {
+               var registry = new SimpleMeterRegistry();
+               var r = new MicrometerMetricsRecorder(registry);
+               assertEquals("http.server.requests", r.getTimerName());
+               assertSame(registry, r.getRegistry());
+       }
+
+       @Test void a02_customTimerName_isHonored() {
+               var registry = new SimpleMeterRegistry();
+               var r = new MicrometerMetricsRecorder(registry, "my.timer");
+               assertEquals("my.timer", r.getTimerName());
+       }
+
+       @Test void a03_nullRegistry_throws() {
+               assertThrows(IllegalArgumentException.class, () -> new 
MicrometerMetricsRecorder(null));
+       }
+
+       @Test void a04_blankTimerName_throws() {
+               var registry = new SimpleMeterRegistry();
+               assertThrows(IllegalArgumentException.class, () -> new 
MicrometerMetricsRecorder(registry, ""));
+               assertThrows(IllegalArgumentException.class, () -> new 
MicrometerMetricsRecorder(registry, "   "));
+       }
+
+       @Test void a05_happyPath_registersTimerWithExpectedTags() {
+               var registry = new SimpleMeterRegistry();
+               var r = new MicrometerMetricsRecorder(registry);
+               r.record("MyResource.get(java.lang.String)", "GET", 
"/users/{id}", 200, Duration.ofMillis(7), null);
+
+               var timer = registry.find("http.server.requests")
+                       .tag("method", "GET")
+                       .tag("uri", "/users/{id}")
+                       .tag("status", "200")
+                       .tag("exception", "None")
+                       .timer();
+               assertNotNull(timer, "timer with full tag set should exist");
+               assertEquals(1, timer.count());
+               
assertTrue(timer.totalTime(java.util.concurrent.TimeUnit.NANOSECONDS) > 0);
+       }
+
+       @Test void a06_exceptionPath_recordsExceptionSimpleNameTag() {
+               var registry = new SimpleMeterRegistry();
+               var r = new MicrometerMetricsRecorder(registry);
+               r.record("op", "POST", "/orders", 500, Duration.ofMillis(3), 
new IllegalStateException());
+
+               var timer = registry.find("http.server.requests")
+                       .tag("exception", "IllegalStateException")
+                       .timer();
+               assertNotNull(timer);
+               assertEquals(1, timer.count());
+       }
+
+       @Test void a07_separateTagSets_produceSeparateTimers() {
+               var registry = new SimpleMeterRegistry();
+               var r = new MicrometerMetricsRecorder(registry);
+               r.record("op", "GET", "/a", 200, Duration.ofMillis(1), null);
+               r.record("op", "GET", "/b", 200, Duration.ofMillis(2), null);
+
+               assertEquals(1, 
registry.find("http.server.requests").tag("uri", "/a").timer().count());
+               assertEquals(1, 
registry.find("http.server.requests").tag("uri", "/b").timer().count());
+       }
+
+       @Test void a08_blankUri_emitsEmptyStringTag() {
+               var registry = new SimpleMeterRegistry();
+               var r = new MicrometerMetricsRecorder(registry);
+               r.record("op", "GET", "", 200, Duration.ofMillis(1), null);
+               assertNotNull(registry.find("http.server.requests").tag("uri", 
"").timer());
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B: End-to-end test wiring the bridge into a real @Rest as a @Bean 
MetricsRecorder.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private static final SimpleMeterRegistry B_REGISTRY = new 
SimpleMeterRegistry();
+
+       @Rest
+       public static class B extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @org.apache.juneau.commons.inject.Bean
+               public org.apache.juneau.rest.metrics.MetricsRecorder 
recorder() {
+                       return new MicrometerMetricsRecorder(B_REGISTRY);
+               }
+
+               @RestGet("/users/{id}")
+               public String get(@org.apache.juneau.http.annotation.Path 
String id) { return "u:" + id; }
+
+               @RestGet("/boom")
+               public String boom() { throw new 
IllegalStateException("kaboom"); }
+       }
+
+       private static final MockRestClient CB = 
MockRestClient.buildLax(B.class);
+
+       @Test void b01_endToEnd_happyPath_recordsTimerSample() throws Exception 
{
+               CB.get("/users/1").run().assertStatus(200);
+
+               var timer = B_REGISTRY.find("http.server.requests")
+                       .tag("method", "GET")
+                       .tag("uri", "/users/{id}")
+                       .tag("status", "200")
+                       .tag("exception", "None")
+                       .timer();
+               assertNotNull(timer, "end-to-end timer should be registered 
after one happy-path call");
+               assertTrue(timer.count() >= 1);
+       }
+
+       @Test void b02_endToEnd_exceptionPath_recordsExceptionTag() throws 
Exception {
+               CB.get("/boom").run().assertStatus(500);
+
+               var timer = B_REGISTRY.find("http.server.requests")
+                       .tag("method", "GET")
+                       .tag("uri", "/boom")
+                       .tag("status", "500")
+                       .tag("exception", "IllegalStateException")
+                       .timer();
+               assertNotNull(timer);
+               assertTrue(timer.count() >= 1);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // C: Prometheus scrape output verification — the bridge produces text 
Prometheus understands.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void c01_prometheusScrapeContainsExpectedSample() {
+               var registry = new 
PrometheusMeterRegistry(PrometheusConfig.DEFAULT, new PrometheusRegistry(), 
io.micrometer.core.instrument.Clock.SYSTEM);
+               var r = new MicrometerMetricsRecorder(registry);
+               r.record("op", "GET", "/users/{id}", 200, Duration.ofMillis(5), 
null);
+
+               String scrape = registry.scrape();
+               
assertTrue(scrape.contains("http_server_requests_seconds_count"), "scrape 
output: " + scrape);
+               assertTrue(scrape.contains("uri=\"/users/{id}\""), "scrape 
output should carry uri tag verbatim");
+               assertTrue(scrape.contains("status=\"200\""));
+               assertTrue(scrape.contains("exception=\"None\""));
+               assertTrue(scrape.contains("method=\"GET\""));
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/tracing/TracerHook_Contract_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/tracing/TracerHook_Contract_Test.java
new file mode 100644
index 0000000000..7dc55c99c0
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/tracing/TracerHook_Contract_Test.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.tracing;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Validates the {@link TracerHook} SPI contract end-to-end &mdash; {@link 
TracerHook#startSpan} fires
+ * once per {@code @RestOp} call (including exception paths), the returned 
{@link Scope} receives the
+ * resolved status code, the error path receives the thrown throwable, and the 
framework always
+ * closes the scope (in a {@code finally} block).
+ */
+class TracerHook_Contract_Test extends TestBase {
+
+       /** Recording {@link TracerHook} that captures every span open / close 
transition. */
+       public static final class RecordingTracerHook implements TracerHook {
+               public final List<RecordingScope> spans = new 
CopyOnWriteArrayList<>();
+
+               @Override
+               public Scope startSpan(RestRequest request) {
+                       var s = new RecordingScope(request.getMethod());
+                       spans.add(s);
+                       return s;
+               }
+
+               public RecordingScope last() { return spans.get(spans.size() - 
1); }
+       }
+
+       public static final class RecordingScope implements Scope {
+               public final String method;
+               public final AtomicInteger statusCode = new AtomicInteger(-1);
+               public final AtomicReference<Throwable> error = new 
AtomicReference<>();
+               public final AtomicInteger closeCount = new AtomicInteger(0);
+
+               RecordingScope(String method) { this.method = method; }
+
+               @Override public void setStatusCode(int statusCode) { 
this.statusCode.set(statusCode); }
+               @Override public void setError(Throwable t) { 
this.error.set(t); }
+               @Override public void close() { closeCount.incrementAndGet(); }
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // A: Happy path — one startSpan, one setStatusCode(200), zero 
setError, one close.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private static final RecordingTracerHook A_HOOK = new 
RecordingTracerHook();
+
+       @Rest
+       public static class A extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @org.apache.juneau.commons.inject.Bean
+               public TracerHook tracer() { return A_HOOK; }
+
+               @RestGet("/users/{id}")
+               public String getUser(@org.apache.juneau.http.annotation.Path 
String id) { return "user:" + id; }
+       }
+
+       private static final MockRestClient CA = 
MockRestClient.buildLax(A.class);
+
+       @Test void a01_happyPath_oneSpanOpenedSetStatusClosed() throws 
Exception {
+               A_HOOK.spans.clear();
+               CA.get("/users/42").run().assertStatus(200);
+               assertEquals(1, A_HOOK.spans.size());
+               var s = A_HOOK.last();
+               assertEquals("GET", s.method);
+               assertEquals(200, s.statusCode.get());
+               assertNull(s.error.get());
+               assertEquals(1, s.closeCount.get(), "close() called exactly 
once");
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B: Exception path — span still opens; setStatusCode receives the 
framework-resolved 500;
+       //    setError receives the original throwable; close() still fires 
exactly once.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private static final RecordingTracerHook B_HOOK = new 
RecordingTracerHook();
+
+       @Rest
+       public static class B extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @org.apache.juneau.commons.inject.Bean
+               public TracerHook tracer() { return B_HOOK; }
+
+               @RestGet("/boom")
+               public String boom() { throw new 
IllegalStateException("kaboom"); }
+       }
+
+       private static final MockRestClient CB = 
MockRestClient.buildLax(B.class);
+
+       @Test void b01_exceptionPath_setErrorThenClose() throws Exception {
+               B_HOOK.spans.clear();
+               CB.get("/boom").run().assertStatus(500);
+               assertEquals(1, B_HOOK.spans.size());
+               var s = B_HOOK.last();
+               assertEquals(500, s.statusCode.get());
+               assertNotNull(s.error.get());
+               assertEquals("IllegalStateException", 
s.error.get().getClass().getSimpleName());
+               assertEquals(1, s.closeCount.get(), "close() called exactly 
once even on exception path");
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // C: Off-by-default — no @Bean TracerHook means NoOpTracerHook with 
NoOpScope; recording hook on a
+       //    different resource sees nothing.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       private static final RecordingTracerHook C_CANARY = new 
RecordingTracerHook();
+
+       @Rest
+       public static class C extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @RestGet("/ping")
+               public String ping() { return "pong"; }
+       }
+
+       private static final MockRestClient CC = 
MockRestClient.buildLax(C.class);
+
+       @Test void c01_noTracerBean_noSpansFanOut() throws Exception {
+               C_CANARY.spans.clear();
+               CC.get("/ping").run().assertStatus(200);
+               assertEquals(0, C_CANARY.spans.size(), "without a @Bean 
TracerHook, no spans reach external tracers");
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // D: NoOpTracerHook direct contract.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void d01_noOpTracer_isSingleton() {
+               assertSame(NoOpTracerHook.INSTANCE, NoOpTracerHook.INSTANCE);
+       }
+
+       @Test void d02_noOpTracer_returnsNoOpScope() {
+               var scope = NoOpTracerHook.INSTANCE.startSpan(null);
+               assertSame(NoOpTracerHook.NoOpScope.INSTANCE, scope);
+       }
+
+       @Test void d03_noOpScope_allMethodsAreNoOp() {
+               assertDoesNotThrow(() -> {
+                       NoOpTracerHook.NoOpScope.INSTANCE.setStatusCode(200);
+                       NoOpTracerHook.NoOpScope.INSTANCE.setError(new 
RuntimeException());
+                       NoOpTracerHook.NoOpScope.INSTANCE.close();
+               });
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook_Test.java
new file mode 100644
index 0000000000..3d0b1e9382
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook_Test.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.tracing.otel;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.apache.juneau.rest.tracing.*;
+import org.junit.jupiter.api.*;
+
+import io.opentelemetry.api.*;
+import io.opentelemetry.api.trace.*;
+import io.opentelemetry.api.trace.propagation.*;
+import io.opentelemetry.context.propagation.*;
+import io.opentelemetry.sdk.*;
+import io.opentelemetry.sdk.testing.exporter.*;
+import io.opentelemetry.sdk.trace.*;
+import io.opentelemetry.sdk.trace.export.*;
+
+/**
+ * End-to-end tests for {@link OtelTracerHook} using the OpenTelemetry SDK's 
in-memory span exporter
+ * to capture and assert against real {@link 
io.opentelemetry.sdk.trace.data.SpanData SpanData}
+ * produced by the bridge during {@code @RestOp} invocations.
+ */
+class OtelTracerHook_Test extends TestBase {
+
+       // Static init so the SDK is wired before MockRestClient.buildLax() in 
the @Rest classes runs.
+       static final InMemorySpanExporter EXPORTER = 
InMemorySpanExporter.create();
+       static final OpenTelemetrySdk OTEL_SDK = OpenTelemetrySdk.builder()
+               .setTracerProvider(SdkTracerProvider.builder()
+                       .addSpanProcessor(SimpleSpanProcessor.create(EXPORTER))
+                       .build())
+               
.setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance()))
+               .build();
+
+       @BeforeEach
+       void resetExporter() {
+               EXPORTER.reset();
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // A: Construction surface.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_nullOpenTelemetry_throws() {
+               assertThrows(IllegalArgumentException.class, () -> new 
OtelTracerHook((OpenTelemetry) null));
+       }
+
+       @Test void a02_nullTracer_throws() {
+               assertThrows(IllegalArgumentException.class, () -> new 
OtelTracerHook(null, W3CTraceContextPropagator.getInstance()));
+       }
+
+       @Test void a03_nullPropagator_throws() {
+               var t = OTEL_SDK.getTracer("t");
+               assertThrows(IllegalArgumentException.class, () -> new 
OtelTracerHook(t, null));
+       }
+
+       @Test void a04_accessors_returnInjectedInstances() {
+               var t = OTEL_SDK.getTracer("t");
+               var p = W3CTraceContextPropagator.getInstance();
+               var hook = new OtelTracerHook(t, p);
+               assertSame(t, hook.getTracer());
+               assertSame(p, hook.getPropagator());
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B: End-to-end happy path — one SERVER span per request, with HTTP 
semantic attributes set.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class B extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @org.apache.juneau.commons.inject.Bean
+               public TracerHook tracer() { return new 
OtelTracerHook(OTEL_SDK); }
+
+               @RestGet("/users/{id}")
+               public String get(@org.apache.juneau.http.annotation.Path 
String id) { return "u:" + id; }
+       }
+
+       private static final MockRestClient CB = 
MockRestClient.buildLax(B.class);
+
+       @Test void b01_happyPath_serverSpanWithHttpSemconvAttributes() throws 
Exception {
+               CB.get("/users/42").run().assertStatus(200);
+               var spans = EXPORTER.getFinishedSpanItems();
+               assertEquals(1, spans.size());
+               var s = spans.get(0);
+               assertEquals(SpanKind.SERVER, s.getKind());
+               assertEquals("GET", s.getName());
+               assertEquals("GET", 
s.getAttributes().get(OtelTracerHook.ATTR_HTTP_REQUEST_METHOD));
+               assertEquals(Long.valueOf(200), 
s.getAttributes().get(OtelTracerHook.ATTR_HTTP_RESPONSE_STATUS_CODE));
+               assertEquals("/users/{id}", 
s.getAttributes().get(OtelTracerHook.ATTR_HTTP_ROUTE));
+               assertEquals(StatusCode.UNSET, s.getStatus().getStatusCode(), 
"2xx maps to UNSET per OTel HTTP semconv");
+       }
+
+       @Test void b02_pathTemplate_notRawUri() throws Exception {
+               CB.get("/users/aaa").run().assertStatus(200);
+               CB.get("/users/bbb").run().assertStatus(200);
+               var spans = EXPORTER.getFinishedSpanItems();
+               assertEquals(2, spans.size());
+               
assertEquals(spans.get(0).getAttributes().get(OtelTracerHook.ATTR_HTTP_ROUTE),
+                       
spans.get(1).getAttributes().get(OtelTracerHook.ATTR_HTTP_ROUTE),
+                       "different concrete URIs share the same http.route 
attribute");
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // C: Exception path — error recorded, span status set to ERROR, 
exception.type set.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class C extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @org.apache.juneau.commons.inject.Bean
+               public TracerHook tracer() { return new 
OtelTracerHook(OTEL_SDK); }
+
+               @RestGet("/boom")
+               public String boom() { throw new 
IllegalStateException("kaboom"); }
+       }
+
+       private static final MockRestClient CC = 
MockRestClient.buildLax(C.class);
+
+       @Test void c01_exceptionPath_spanCarriesErrorStatusAndExceptionType() 
throws Exception {
+               CC.get("/boom").run().assertStatus(500);
+               var spans = EXPORTER.getFinishedSpanItems();
+               assertEquals(1, spans.size());
+               var s = spans.get(0);
+               assertEquals(Long.valueOf(500), 
s.getAttributes().get(OtelTracerHook.ATTR_HTTP_RESPONSE_STATUS_CODE));
+               assertEquals(StatusCode.ERROR, s.getStatus().getStatusCode());
+               assertEquals("IllegalStateException", 
s.getAttributes().get(OtelTracerHook.ATTR_EXCEPTION_TYPE));
+               assertFalse(s.getEvents().isEmpty(), "recordException should 
add an event with the throwable's stack trace");
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // D: W3C traceparent propagation — incoming traceparent continues an 
existing trace.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void d01_incomingTraceparent_continuesTrace() throws Exception {
+               // Build a valid W3C traceparent: 00-<32 hex traceId>-<16 hex 
spanId>-01
+               String traceId = "0af7651916cd43dd8448eb211c80319c";
+               String parentSpanId = "b7ad6b7169203331";
+               String traceparent = "00-" + traceId + "-" + parentSpanId + 
"-01";
+
+               CB.get("/users/9").header("traceparent", 
traceparent).run().assertStatus(200);
+
+               var spans = EXPORTER.getFinishedSpanItems();
+               assertEquals(1, spans.size());
+               var s = spans.get(0);
+               assertEquals(traceId, s.getTraceId(), "server span should 
inherit the incoming trace id");
+               assertEquals(parentSpanId, s.getParentSpanId(), "server span 
should be a child of the incoming span");
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // E: Off-by-default — no @Bean TracerHook means no spans are emitted 
by the framework.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Rest
+       public static class E extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @RestGet("/ping")
+               public String ping() { return "pong"; }
+       }
+
+       private static final MockRestClient CE = 
MockRestClient.buildLax(E.class);
+
+       @Test void e01_noTracerBean_noSpansEmitted() throws Exception {
+               CE.get("/ping").run().assertStatus(200);
+               assertEquals(0, EXPORTER.getFinishedSpanItems().size(), 
"without a @Bean TracerHook, no spans reach the SDK exporter");
+       }
+}
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/rest/tracing/otel/RestRequestTextMapGetter_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/rest/tracing/otel/RestRequestTextMapGetter_Test.java
new file mode 100644
index 0000000000..68ec999ceb
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/rest/tracing/otel/RestRequestTextMapGetter_Test.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.tracing.otel;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Exercises {@link RestRequestTextMapGetter} against a live {@link 
RestRequest} captured inside a
+ * {@code @RestOp} handler so that header names, present/absent semantics, and 
the OpenTelemetry
+ * {@code TextMapGetter} contract are all validated against the real request 
shape.
+ */
+class RestRequestTextMapGetter_Test extends TestBase {
+
+       private static final AtomicReference<RestRequest> CAPTURED = new 
AtomicReference<>();
+
+       @Rest
+       public static class A extends RestServlet {
+               private static final long serialVersionUID = 1L;
+
+               @RestGet("/capture")
+               public String capture(RestRequest req) {
+                       CAPTURED.set(req);
+                       return "ok";
+               }
+       }
+
+       private static final MockRestClient C = 
MockRestClient.buildLax(A.class);
+
+       @BeforeEach
+       void resetCaptured() { CAPTURED.set(null); }
+
+       @Test void a01_get_returnsPresentHeaderValue() throws Exception {
+               C.get("/capture")
+                       .header("traceparent", 
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
+                       .run().assertStatus(200);
+
+               var req = CAPTURED.get();
+               assertNotNull(req);
+               var g = RestRequestTextMapGetter.INSTANCE;
+               
assertEquals("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", 
g.get(req, "traceparent"));
+       }
+
+       @Test void a02_get_returnsNullForAbsentHeader() throws Exception {
+               C.get("/capture").run().assertStatus(200);
+               var req = CAPTURED.get();
+               assertNull(RestRequestTextMapGetter.INSTANCE.get(req, 
"x-not-present"));
+       }
+
+       @Test void a03_get_isCaseInsensitiveForHttpHeaders() throws Exception {
+               C.get("/capture").header("X-Foo", 
"bar").run().assertStatus(200);
+               var req = CAPTURED.get();
+               // HTTP header names are case-insensitive; RequestHeader lookup 
must honor that.
+               assertEquals("bar", RestRequestTextMapGetter.INSTANCE.get(req, 
"x-foo"));
+               assertEquals("bar", RestRequestTextMapGetter.INSTANCE.get(req, 
"X-FOO"));
+       }
+
+       @Test void a04_get_nullCarrier_returnsNull() {
+               assertNull(RestRequestTextMapGetter.INSTANCE.get(null, 
"anything"));
+       }
+
+       @Test void a05_keys_includesSentHeader() throws Exception {
+               C.get("/capture").header("X-Trace-Id", 
"abc").run().assertStatus(200);
+               var req = CAPTURED.get();
+               var keys = RestRequestTextMapGetter.INSTANCE.keys(req);
+               assertNotNull(keys);
+               boolean found = false;
+               for (var k : keys) {
+                       if ("X-Trace-Id".equalsIgnoreCase(k)) { found = true; 
break; }
+               }
+               assertTrue(found, "keys() should include sent header name (any 
case)");
+       }
+
+       @Test void a06_singleton_isStable() {
+               assertSame(RestRequestTextMapGetter.INSTANCE, 
RestRequestTextMapGetter.INSTANCE);
+       }
+}

Reply via email to