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 767b3d76f6 feat: custom observation API, OTLP bundle, and 
OpenTelemetry log correlation
767b3d76f6 is described below

commit 767b3d76f64d51c7e051f778d3734a48f60fa2a2
Author: James Bognar <[email protected]>
AuthorDate: Thu Jun 18 17:35:14 2026 -0400

    feat: custom observation API, OTLP bundle, and OpenTelemetry log correlation
---
 juneau-bom/pom.xml                                 |   5 +
 .../juneau-observability-otlp-bundle/pom.xml       |  85 +++++++
 juneau-bundles/pom.xml                             |   1 +
 .../juneau/config/store/ProfileConfigStore.java    |   3 +
 .../apache/juneau/config/ConfigProfiles_Test.java  |   9 +-
 .../observability/ObservabilityNoopBenchmark.java  |   3 +-
 .../server/processor/MdcAsyncListener_Test.java    |  46 ++++
 .../server/tracing/TracerHook_Contract_Test.java   |   2 +-
 .../microservice/resources/LogEntryFormatter.java  |  23 +-
 .../microservice/resources/TraceContext.java       | 140 ++++++++++++
 .../resources/LogEntryFormatter_Test.java          |  33 +++
 .../micrometer/MicrometerMetricsRecorder.java      |  20 +-
 .../MicrometerCustomObservation_Test.java          | 107 +++++++++
 .../rest/server/tracing/otel/OtelTracerHook.java   |  12 +
 .../server/tracing/TracerHook_Contract_Test.java   |   2 +-
 .../tracing/otel/OtelCustomObservation_Test.java   | 114 ++++++++++
 .../rest/server/metrics/MetricsRecorder.java       |  29 +++
 .../rest/server/observation/Observation.java       |  98 ++++++++
 .../rest/server/observation/Observations.java      | 126 +++++++++++
 .../juneau/rest/server/observation/Observer.java   | 170 ++++++++++++++
 .../rest/server/observation/package-info.java      |  51 +++++
 .../rest/server/processor/MdcAsyncListener.java    |  66 ++++++
 .../juneau/rest/server/tracing/TracerHook.java     |  23 ++
 .../rest/server/observation/Observations_Test.java | 248 +++++++++++++++++++++
 24 files changed, 1403 insertions(+), 13 deletions(-)

diff --git a/juneau-bom/pom.xml b/juneau-bom/pom.xml
index 9d374f785a..b76bb3b0e2 100644
--- a/juneau-bom/pom.xml
+++ b/juneau-bom/pom.xml
@@ -108,13 +108,18 @@
                        
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-microservice-jetty-bundle</artifactId><version>${project.version}</version><type>pom</type></dependency>
                        
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-microservice-tomcat-bundle</artifactId><version>${project.version}</version><type>pom</type></dependency>
                        
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-springboot-bundle</artifactId><version>${project.version}</version><type>pom</type></dependency>
+                       
<dependency><groupId>org.apache.juneau</groupId><artifactId>juneau-observability-otlp-bundle</artifactId><version>${project.version}</version><type>pom</type></dependency>
 
                        <!-- 
=====================================================================================
 -->
                        <!-- Starter-relevant third-party versions (the small 
set the curated bundles need to be    -->
                        <!-- coherent).  NOT a whole-world third-party BOM (see 
TODO-174c Q2).                       -->
                        <!-- 
=====================================================================================
 -->
                        
<dependency><groupId>io.micrometer</groupId><artifactId>micrometer-core</artifactId><version>${micrometer.version}</version></dependency>
+                       
<dependency><groupId>io.micrometer</groupId><artifactId>micrometer-registry-otlp</artifactId><version>${micrometer.version}</version></dependency>
                        
<dependency><groupId>io.opentelemetry</groupId><artifactId>opentelemetry-api</artifactId><version>${opentelemetry.version}</version></dependency>
+                       
<dependency><groupId>io.opentelemetry</groupId><artifactId>opentelemetry-sdk</artifactId><version>${opentelemetry.version}</version></dependency>
+                       
<dependency><groupId>io.opentelemetry</groupId><artifactId>opentelemetry-exporter-otlp</artifactId><version>${opentelemetry.version}</version></dependency>
+                       
<dependency><groupId>io.opentelemetry</groupId><artifactId>opentelemetry-sdk-extension-autoconfigure</artifactId><version>${opentelemetry.version}</version></dependency>
                        
<dependency><groupId>jakarta.servlet</groupId><artifactId>jakarta.servlet-api</artifactId><version>${jakarta.servlet-api.version}</version></dependency>
                        
<dependency><groupId>jakarta.validation</groupId><artifactId>jakarta.validation-api</artifactId><version>${jakarta.validation-api.version}</version></dependency>
 
diff --git a/juneau-bundles/juneau-observability-otlp-bundle/pom.xml 
b/juneau-bundles/juneau-observability-otlp-bundle/pom.xml
new file mode 100644
index 0000000000..632bc23197
--- /dev/null
+++ b/juneau-bundles/juneau-observability-otlp-bundle/pom.xml
@@ -0,0 +1,85 @@
+<?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-bundles</artifactId>
+               <version>10.0.0-SNAPSHOT</version>
+       </parent>
+
+       <artifactId>juneau-observability-otlp-bundle</artifactId>
+       <packaging>pom</packaging>
+
+       <name>Apache Juneau OTLP Observability Bundle</name>
+       <description>Curated dependency bundle for exporting Juneau REST 
metrics + traces over OTLP in one dependency.  Pulls both Juneau observability 
bridges (juneau-rest-server-metrics-micrometer + 
juneau-rest-server-tracing-otel) plus a coherent, version-aligned OTLP exporter 
stack: opentelemetry-exporter-otlp (+ SDK + autoconfigure) for traces and 
micrometer-registry-otlp for metrics.  This is a dependency aggregator only — 
it registers no exporters and performs no classpath auto-discovery [...]
+
+       <dependencyManagement>
+               <dependencies>
+                       <dependency>
+                               <groupId>org.apache.juneau</groupId>
+                               <artifactId>juneau-bom</artifactId>
+                               <version>${project.version}</version>
+                               <type>pom</type>
+                               <scope>import</scope>
+                       </dependency>
+               </dependencies>
+       </dependencyManagement>
+
+       <dependencies>
+               <!-- Juneau observability bridges (the MetricsRecorder / 
TracerHook implementations). -->
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       
<artifactId>juneau-rest-server-metrics-micrometer</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-server-tracing-otel</artifactId>
+               </dependency>
+
+               <!--
+                       Traces: the OTLP span exporter + SDK + autoconfigure.  
These transitively bring
+                       opentelemetry-api, satisfying the `provided` API 
dependency of the -tracing-otel bridge so
+                       the consumer gets a runnable OTLP trace path from this 
single bundle.
+               -->
+               <dependency>
+                       <groupId>io.opentelemetry</groupId>
+                       <artifactId>opentelemetry-exporter-otlp</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>io.opentelemetry</groupId>
+                       <artifactId>opentelemetry-sdk</artifactId>
+               </dependency>
+               <dependency>
+                       <groupId>io.opentelemetry</groupId>
+                       
<artifactId>opentelemetry-sdk-extension-autoconfigure</artifactId>
+               </dependency>
+
+               <!--
+                       Metrics: the Micrometer OTLP registry.  Transitively 
brings micrometer-core, satisfying the
+                       `provided` dependency of the -metrics-micrometer bridge.
+               -->
+               <dependency>
+                       <groupId>io.micrometer</groupId>
+                       <artifactId>micrometer-registry-otlp</artifactId>
+               </dependency>
+       </dependencies>
+
+</project>
diff --git a/juneau-bundles/pom.xml b/juneau-bundles/pom.xml
index f2c4b1eae6..0eec492da9 100644
--- a/juneau-bundles/pom.xml
+++ b/juneau-bundles/pom.xml
@@ -35,6 +35,7 @@
                <module>juneau-microservice-jetty-bundle</module>
                <module>juneau-microservice-tomcat-bundle</module>
                <module>juneau-springboot-bundle</module>
+               <module>juneau-observability-otlp-bundle</module>
        </modules>
 
 </project>
diff --git 
a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/store/ProfileConfigStore.java
 
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/store/ProfileConfigStore.java
index 2502439e2e..cfdb02901f 100644
--- 
a/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/store/ProfileConfigStore.java
+++ 
b/juneau-core/juneau-config/src/main/java/org/apache/juneau/config/store/ProfileConfigStore.java
@@ -48,6 +48,9 @@ import org.apache.juneau.marshall.*;
  *
  * @since 10.0.0
  */
+@SuppressWarnings({
+       "resource" // Decorator owns the wrapped delegate store and closes it 
in close(); the delegate's lifecycle is managed here, not leaked.
+})
 public class ProfileConfigStore extends ConfigStore {
 
        /**
diff --git 
a/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/ConfigProfiles_Test.java
 
b/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/ConfigProfiles_Test.java
index aea0a2a70a..09485c674f 100644
--- 
a/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/ConfigProfiles_Test.java
+++ 
b/juneau-core/juneau-config/src/test/java/org/apache/juneau/config/ConfigProfiles_Test.java
@@ -30,11 +30,11 @@ import org.junit.jupiter.api.*;
  * Tests for config <b>profiles</b> &mdash; {@code <name>-<profile>} overlays 
activated via
  * {@code Config.Builder.profiles(...)} (the standalone path; the Spring 
piggyback is covered in the springboot module).
  */
+@SuppressWarnings({
+       "resource" // MemoryStore/Config are test fixtures; lifecycle managed 
by the test, not a real leak.
+})
 class ConfigProfiles_Test extends TestBase {
 
-       @SuppressWarnings({
-               "resource" // MemoryStore/Config are test fixtures; lifecycle 
managed by the test, not a real leak.
-       })
        private static MemoryStore store(String... namesAndContents) {
                var s = MemoryStore.create().build();
                for (var i = 0; i < namesAndContents.length; i += 2)
@@ -42,9 +42,6 @@ class ConfigProfiles_Test extends TestBase {
                return s;
        }
 
-       @SuppressWarnings({
-               "resource" // see above.
-       })
        private static Config config(MemoryStore s, String name, String... 
profiles) {
                try {
                        return 
Config.create().store(s).name(name).profiles(profiles).build();
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
index 8174b80ee7..1900de2e17 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
@@ -20,6 +20,7 @@ import java.nio.file.*;
 import java.time.*;
 import java.util.concurrent.*;
 
+import org.apache.juneau.rest.server.*;
 import org.apache.juneau.rest.server.metrics.*;
 import org.apache.juneau.rest.server.tracing.*;
 import org.openjdk.jmh.annotations.*;
@@ -119,7 +120,7 @@ public class ObservabilityNoopBenchmark {
                @SuppressWarnings({
                        "java:S2637"  // Suppression required for test context; 
see annotation for details.
                })
-               var scope = tracer.startSpan(null);
+               var scope = tracer.startSpan((RestRequest) null);
                try {
                        scope.setStatusCode(STATUS_CODE);
                } finally {
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/processor/MdcAsyncListener_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/processor/MdcAsyncListener_Test.java
index 9b3c260cb2..caaa27abc5 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/processor/MdcAsyncListener_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/processor/MdcAsyncListener_Test.java
@@ -214,4 +214,50 @@ class MdcAsyncListener_Test extends TestBase {
                // MDC must be cleared even though the callback threw.
                assertNull(MDC.get("requestId"), "MDC must be cleared in the 
finally block even when callback throws.");
        }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // traceEnrichment: snapshot() folds the active OTel trace/span id into 
the MDC snapshot so log
+       // correlation survives the async-completion hop (resolved Q5).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test
+       void traceEnrichment_01_activeSpanFoldsTraceAndSpanIdIntoSnapshot() {
+               var tracer = io.opentelemetry.sdk.OpenTelemetrySdk.builder()
+                       
.setTracerProvider(io.opentelemetry.sdk.trace.SdkTracerProvider.builder().build())
+                       .build()
+                       .getTracer("test");
+               var span = tracer.spanBuilder("op").startSpan();
+               try (var scope = span.makeCurrent()) {
+                       var snap = MdcAsyncListener.snapshot();
+                       assertNotNull(snap, "An active valid span must produce 
a non-null snapshot even with empty MDC.");
+                       assertEquals(span.getSpanContext().getTraceId(), 
snap.get("trace_id"));
+                       assertEquals(span.getSpanContext().getSpanId(), 
snap.get("span_id"));
+               } finally {
+                       span.end();
+               }
+       }
+
+       @Test
+       void traceEnrichment_02_noActiveSpanLeavesSnapshotUnenriched() {
+               // No active span — empty MDC must stay null (lazy-skip 
contract preserved).
+               assertNull(MdcAsyncListener.snapshot());
+       }
+
+       @Test
+       void traceEnrichment_03_existingMdcPreservedAlongsideTraceIds() {
+               var tracer = io.opentelemetry.sdk.OpenTelemetrySdk.builder()
+                       
.setTracerProvider(io.opentelemetry.sdk.trace.SdkTracerProvider.builder().build())
+                       .build()
+                       .getTracer("test");
+               var span = tracer.spanBuilder("op").startSpan();
+               MDC.put("requestId", "abc-123");
+               try (var scope = span.makeCurrent()) {
+                       var snap = MdcAsyncListener.snapshot();
+                       assertNotNull(snap);
+                       assertEquals("abc-123", snap.get("requestId"), 
"Pre-existing MDC keys must be preserved.");
+                       assertEquals(span.getSpanContext().getTraceId(), 
snap.get("trace_id"));
+               } finally {
+                       span.end();
+               }
+       }
 }
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/tracing/TracerHook_Contract_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/tracing/TracerHook_Contract_Test.java
index 39409f3b0d..91ededd548 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/tracing/TracerHook_Contract_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/tracing/TracerHook_Contract_Test.java
@@ -159,7 +159,7 @@ class TracerHook_Contract_Test extends TestBase {
        }
 
        @Test void d02_noOpTracer_returnsNoOpScope() {
-               var scope = NoOpTracerHook.INSTANCE.startSpan(null);
+               var scope = NoOpTracerHook.INSTANCE.startSpan((RestRequest) 
null);
                assertSame(NoOpTracerHook.NoOpScope.INSTANCE, scope);
        }
 
diff --git 
a/juneau-microservice/juneau-microservice/src/main/java/org/apache/juneau/microservice/resources/LogEntryFormatter.java
 
b/juneau-microservice/juneau-microservice/src/main/java/org/apache/juneau/microservice/resources/LogEntryFormatter.java
index 0fa4aafacf..cf8979cd18 100644
--- 
a/juneau-microservice/juneau-microservice/src/main/java/org/apache/juneau/microservice/resources/LogEntryFormatter.java
+++ 
b/juneau-microservice/juneau-microservice/src/main/java/org/apache/juneau/microservice/resources/LogEntryFormatter.java
@@ -84,6 +84,8 @@ public class LogEntryFormatter extends Formatter {
         *              <li><js>"{msg}"</js> - The log message.
         *              <li><js>"{threadid}"</js> - The thread ID.
         *              <li><js>"{exception}"</js> - The localized exception 
message.
+        *              <li><js>"{traceId}"</js> - The active OpenTelemetry 
trace ID (empty when OTel is absent or no span is active).
+        *              <li><js>"{spanId}"</js> - The active OpenTelemetry span 
ID (empty when OTel is absent or no span is active).
         *      </ol>
         * @param dateFormat
         *      The {@link SimpleDateFormat} format to use for dates.  e.g. 
<js>"yyyy.MM.dd hh:mm:ss"</js>.
@@ -110,7 +112,9 @@ public class LogEntryFormatter extends Formatter {
                        .replace("{level}", "%5$s")
                        .replace("{msg}", "%6$s")
                        .replace("{threadid}", "%7$s")
-                       .replace("{exception}", "%8$s");
+                       .replace("{exception}", "%8$s")
+                       .replace("{traceId}", "%9$s")
+                       .replace("{spanId}", "%10$s");
                // @formatter:on
 
                this.format = format;
@@ -147,6 +151,9 @@ public class LogEntryFormatter extends Formatter {
                        } else if (state == S3) {  // NOSONAR - State check 
necessary for state machine
                                if (c == '$') {
                                        state = S4;
+                               } else if (Character.isDigit(c)) {
+                                       // Stay in S3: group numbers may be 
multi-digit (e.g. %10$s for {spanId}).
+                                       state = S3;  // NOSONAR - explicit 
self-transition documents the multi-digit case
                                } else {
                                        
re.append("\\%").append(format.substring(i1, i)); // HTT - requires %digit 
followed by non-$ which can't come from standard format placeholders
                                        state = S1;
@@ -187,7 +194,15 @@ public class LogEntryFormatter extends Formatter {
                                                        
fieldIndexes.put("exception", index++);
                                                        re.append("(.*)");
                                                        break;
-                                               default: // HTT - group numbers 
> 8 would require a format placeholder beyond {exception} // NOSONAR
+                                               case 9:
+                                                       
fieldIndexes.put("traceId", index++);
+                                                       
re.append("([0-9a-f]*)");
+                                                       break;
+                                               case 10:
+                                                       
fieldIndexes.put("spanId", index++);
+                                                       
re.append("([0-9a-f]*)");
+                                                       break;
+                                               default: // HTT - group numbers 
> 10 would require a format placeholder beyond {spanId} // NOSONAR
                                        }
                                } else {
                                        
re.append("\\%").append(format.substring(i1, i)); // HTT - requires %digit$ 
followed by non-s which can't come from standard format placeholders
@@ -229,7 +244,7 @@ public class LogEntryFormatter extends Formatter {
                        }
                }
                var s = String.format(format, df.format(new 
Date(r.getMillis())), r.getSourceClassName(), r.getSourceMethodName(), 
r.getLoggerName(), r.getLevel(), msg, r.getThreadID(),
-                       r.getThrown() == null ? "" : 
r.getThrown().getMessage());
+                       r.getThrown() == null ? "" : 
r.getThrown().getMessage(), TraceContext.currentTraceId(), 
TraceContext.currentSpanId());
                if (nn(t))
                        s += String.format("%n%s", 
getStackTrace(r.getThrown()));
                return s;
@@ -258,6 +273,8 @@ public class LogEntryFormatter extends Formatter {
         *              <li><js>"msg"</js>
         *              <li><js>"threadid"</js>
         *              <li><js>"exception"</js>
+        *              <li><js>"traceId"</js>
+        *              <li><js>"spanId"</js>
         *      </ul>
         * @param m The matcher.
         * @return The field value, or <jk>null</jk> if the specified field 
does not exist.
diff --git 
a/juneau-microservice/juneau-microservice/src/main/java/org/apache/juneau/microservice/resources/TraceContext.java
 
b/juneau-microservice/juneau-microservice/src/main/java/org/apache/juneau/microservice/resources/TraceContext.java
new file mode 100644
index 0000000000..dceb97b040
--- /dev/null
+++ 
b/juneau-microservice/juneau-microservice/src/main/java/org/apache/juneau/microservice/resources/TraceContext.java
@@ -0,0 +1,140 @@
+/*
+ * 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.microservice.resources;
+
+import java.lang.reflect.*;
+import java.util.logging.*;
+
+/**
+ * Reflective accessor for the currently-active OpenTelemetry trace / span id, 
used by
+ * {@link LogEntryFormatter} to populate the optional {@code {traceId}} / 
{@code {spanId}} log fields.
+ *
+ * <h5 class='topic'>Optional / reflective coupling</h5>
+ *
+ * <p>
+ * OpenTelemetry is <b>not</b> a compile-time dependency of {@code 
juneau-microservice}. This class
+ * probes for {@code io.opentelemetry.api.trace.Span} via {@link 
Class#forName(String)} at class-load
+ * time &mdash; mirroring how {@link 
org.apache.juneau.rest.server.processor.MdcAsyncListener} reflectively
+ * handles SLF4J. When OTel is absent from the runtime classpath the class 
gracefully degrades:
+ * {@link #isAvailable()} returns {@code false} and {@link #currentTraceId()} 
/ {@link #currentSpanId()}
+ * return the empty string, so the {@code {traceId}} / {@code {spanId}} 
placeholders render empty.
+ *
+ * <h5 class='topic'>Where the id comes from</h5>
+ *
+ * <p>
+ * The id is read from {@code Span.current().getSpanContext()} &mdash; the 
OTel context active on the
+ * calling thread. Inside a {@code @RestOp} handler (or a custom
+ * {@link org.apache.juneau.rest.server.observation.Observations observation}) 
wrapped by the OTel
+ * {@code TracerHook}, that is the request / observation span. An invalid (no 
active trace) context
+ * yields the empty string.
+ *
+ * @since 10.0.0
+ */
+public final class TraceContext {
+
+       private static final Logger LOG = 
Logger.getLogger(TraceContext.class.getName());
+
+       /** {@code true} if {@code io.opentelemetry.api.trace.Span} was found 
on the classpath at class-load time. */
+       private static final boolean AVAILABLE;
+
+       private static final Method SPAN_CURRENT;
+       private static final Method SPAN_GET_SPAN_CONTEXT;
+       private static final Method CTX_IS_VALID;
+       private static final Method CTX_GET_TRACE_ID;
+       private static final Method CTX_GET_SPAN_ID;
+
+       static {
+               boolean available = false;
+               Method spanCurrent = null;
+               Method getSpanContext = null;
+               Method isValid = null;
+               Method getTraceId = null;
+               Method getSpanId = null;
+               try {
+                       Class<?> span = 
Class.forName("io.opentelemetry.api.trace.Span");
+                       Class<?> spanContext = 
Class.forName("io.opentelemetry.api.trace.SpanContext");
+                       spanCurrent = span.getMethod("current");
+                       getSpanContext = span.getMethod("getSpanContext");
+                       isValid = spanContext.getMethod("isValid");
+                       getTraceId = spanContext.getMethod("getTraceId");
+                       getSpanId = spanContext.getMethod("getSpanId");
+                       available = true;
+               } catch (Exception e) {
+                       LOG.fine(() -> "OpenTelemetry API not found on 
classpath — trace-id log fields render empty: " + e.getMessage());
+               }
+               AVAILABLE = available;
+               SPAN_CURRENT = spanCurrent;
+               SPAN_GET_SPAN_CONTEXT = getSpanContext;
+               CTX_IS_VALID = isValid;
+               CTX_GET_TRACE_ID = getTraceId;
+               CTX_GET_SPAN_ID = getSpanId;
+       }
+
+       private TraceContext() {}
+
+       /**
+        * Returns whether the OpenTelemetry trace API is available on the 
runtime classpath.
+        *
+        * <p>
+        * When {@code false}, {@link #currentTraceId()} and {@link 
#currentSpanId()} always return the empty
+        * string.
+        *
+        * @return {@code true} if {@code io.opentelemetry.api.trace.Span} was 
found at class-load time.
+        */
+       public static boolean isAvailable() {
+               return AVAILABLE;
+       }
+
+       /**
+        * Returns the trace id of the OTel span active on the current thread.
+        *
+        * @return The 32-char hex trace id, or the empty string when OTel is 
absent or no valid span is active.
+        */
+       public static String currentTraceId() {
+               return field(CTX_GET_TRACE_ID);
+       }
+
+       /**
+        * Returns the span id of the OTel span active on the current thread.
+        *
+        * @return The 16-char hex span id, or the empty string when OTel is 
absent or no valid span is active.
+        */
+       public static String currentSpanId() {
+               return field(CTX_GET_SPAN_ID);
+       }
+
+       @SuppressWarnings({
+               "java:S3011" // Reflective access to the OTel trace API — 
intentional; OpenTelemetry is not a compile dep.
+       })
+       private static String field(Method accessor) {
+               if (! AVAILABLE)
+                       return "";
+               try {
+                       Object span = SPAN_CURRENT.invoke(null);
+                       if (span == null)
+                               return "";
+                       Object ctx = SPAN_GET_SPAN_CONTEXT.invoke(span);
+                       if (ctx == null || ! ((Boolean) 
CTX_IS_VALID.invoke(ctx)).booleanValue())
+                               return "";
+                       Object v = accessor.invoke(ctx);
+                       return v == null ? "" : v.toString();
+               } catch (Exception e) {
+                       LOG.fine(() -> "OTel Span.current() trace-context read 
failed: " + e.getMessage());
+                       return "";
+               }
+       }
+}
diff --git 
a/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogEntryFormatter_Test.java
 
b/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogEntryFormatter_Test.java
index 795beede05..b057056311 100644
--- 
a/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogEntryFormatter_Test.java
+++ 
b/juneau-microservice/juneau-microservice/src/test/java/org/apache/juneau/microservice/resources/LogEntryFormatter_Test.java
@@ -194,4 +194,37 @@ class LogEntryFormatter_Test extends TestBase {
                assertTrue(result.contains("INFO"));
                assertTrue(result.contains("No exception"));
        }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Log correlation: {traceId} / {spanId} placeholders.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_traceIdSpanId_renderWithoutError() {
+               // OTel is not on this module's test classpath, so the 
trace/span fields render empty - but the
+               // placeholders must still be recognized (no literal 
"{traceId}" leaks into the output).
+               var f = new LogEntryFormatter("[{date} {level}] {msg} 
trace={traceId} span={spanId}%n", DATE_FORMAT, false);
+               var r = new LogRecord(Level.INFO, "hello");
+               var result = f.format(r);
+               assertNotNull(result);
+               assertTrue(result.contains("trace= span="), "Expected empty 
trace/span fields, got:\n" + result);
+               assertFalse(result.contains("{traceId}"));
+               assertFalse(result.contains("{spanId}"));
+       }
+
+       @Test void b02_traceIdSpanId_registeredAsParsableFields() {
+               // {msg} uses a greedy (.*) match, so it must come last; put 
the trace/span fields ahead of it.
+               var f = new LogEntryFormatter("[{date} {level}] trace={traceId} 
span={spanId} {msg}", DATE_FORMAT, false);
+               var line = "[2023.01.15 10:30:45 INFO] 
trace=0af7651916cd43dd8448eb211c80319c span=b7ad6b7169203331 hello";
+               var m = f.getLogEntryPattern().matcher(line);
+               assertTrue(m.matches(), "Pattern should match a line with 
populated trace/span ids");
+               assertEquals("0af7651916cd43dd8448eb211c80319c", 
f.getField("traceId", m));
+               assertEquals("b7ad6b7169203331", f.getField("spanId", m));
+       }
+
+       @Test void b03_traceContext_unavailableReturnsEmpty() {
+               // Without OTel on the classpath, the reflective accessor 
degrades to empty strings.
+               assertFalse(TraceContext.isAvailable());
+               assertEquals("", TraceContext.currentTraceId());
+               assertEquals("", TraceContext.currentSpanId());
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-metrics-micrometer/src/main/java/org/apache/juneau/rest/server/metrics/micrometer/MicrometerMetricsRecorder.java
 
b/juneau-rest/juneau-rest-server-metrics-micrometer/src/main/java/org/apache/juneau/rest/server/metrics/micrometer/MicrometerMetricsRecorder.java
index b8d49d23fb..0045e4178e 100644
--- 
a/juneau-rest/juneau-rest-server-metrics-micrometer/src/main/java/org/apache/juneau/rest/server/metrics/micrometer/MicrometerMetricsRecorder.java
+++ 
b/juneau-rest/juneau-rest-server-metrics-micrometer/src/main/java/org/apache/juneau/rest/server/metrics/micrometer/MicrometerMetricsRecorder.java
@@ -165,13 +165,31 @@ public class MicrometerMetricsRecorder implements 
MetricsRecorder {
                        .tag(TAG_URI, defaultIfBlank(uriTemplate, ""))
                        .tag(TAG_STATUS, Integer.toString(statusCode))
                        .tag(TAG_EXCEPTION, exceptionTag(error));
+               builder = applyMetricTags(builder, metricTags);
+               builder.register(registry).record(elapsed);
+       }
+
+       @SuppressWarnings({
+               "java:S6213" // Method name 'record' is part of the established 
MetricsRecorder SPI; renaming this overriding method would be a breaking API 
change.
+       })
+       @Override /* MetricsRecorder */
+       public void record(String metricName, String metricTags, Duration 
elapsed, Throwable error) {
+               // Custom (non-request) observation: a timer named metricName 
carrying only the exception tag
+               // plus any caller-supplied metricTags - no HTTP 
method/uri/status tags (there is no request).
+               var builder = Timer.builder(metricName)
+                       .tag(TAG_EXCEPTION, exceptionTag(error));
+               builder = applyMetricTags(builder, metricTags);
+               builder.register(registry).record(elapsed);
+       }
+
+       private static Timer.Builder applyMetricTags(Timer.Builder builder, 
String metricTags) {
                if (ne(metricTags))
                        for (var pair : metricTags.split(",")) {
                                var kv = pair.split("=", 2);
                                if (kv.length == 2)
                                        builder = builder.tag(kv[0].strip(), 
kv[1].strip());
                        }
-               builder.register(registry).record(elapsed);
+               return builder;
        }
 
        private static String exceptionTag(Throwable error) {
diff --git 
a/juneau-rest/juneau-rest-server-metrics-micrometer/src/test/java/org/apache/juneau/rest/server/metrics/micrometer/MicrometerCustomObservation_Test.java
 
b/juneau-rest/juneau-rest-server-metrics-micrometer/src/test/java/org/apache/juneau/rest/server/metrics/micrometer/MicrometerCustomObservation_Test.java
new file mode 100644
index 0000000000..8e10a42a92
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-metrics-micrometer/src/test/java/org/apache/juneau/rest/server/metrics/micrometer/MicrometerCustomObservation_Test.java
@@ -0,0 +1,107 @@
+/*
+ * 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.server.metrics.micrometer;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.observation.*;
+import org.apache.juneau.rest.server.tracing.*;
+import org.junit.jupiter.api.*;
+
+import io.micrometer.core.instrument.*;
+import io.micrometer.core.instrument.simple.*;
+
+/**
+ * Tests for the custom (non-request) {@code record(name, tags, elapsed, 
error)} override on
+ * {@link MicrometerMetricsRecorder}, driven both directly and through the 
{@link Observer} facade.
+ */
+class MicrometerCustomObservation_Test extends TestBase {
+
+       private SimpleMeterRegistry registry;
+       private MicrometerMetricsRecorder recorder;
+
+       @BeforeEach
+       void setup() {
+               registry = new SimpleMeterRegistry();
+               recorder = new MicrometerMetricsRecorder(registry);
+       }
+
+       private Timer timer(String name) {
+               return registry.find(name).timer();
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // A: direct record(name, tags, elapsed, error) calls.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_recordsTimerWithExceptionTagNone() {
+               recorder.record("order.load", "", Duration.ofMillis(5), null);
+               var t = timer("order.load");
+               assertNotNull(t);
+               assertEquals(1, t.count());
+               assertEquals("None", t.getId().getTag("exception"));
+       }
+
+       @Test void a02_appliesCustomTags() {
+               recorder.record("order.load", "team=payments,region=us-east", 
Duration.ofMillis(3), null);
+               var t = registry.find("order.load").tag("team", 
"payments").tag("region", "us-east").timer();
+               assertNotNull(t);
+               assertEquals(1, t.count());
+       }
+
+       @Test void a03_errorTagFromThrowable() {
+               recorder.record("order.load", "", Duration.ofMillis(1), new 
IllegalStateException("x"));
+               var t = registry.find("order.load").tag("exception", 
"IllegalStateException").timer();
+               assertNotNull(t);
+               assertEquals(1, t.count());
+       }
+
+       @Test void a04_noHttpTagsOnCustomTimer() {
+               recorder.record("order.load", "", Duration.ofMillis(1), null);
+               var t = timer("order.load");
+               assertNull(t.getId().getTag("method"));
+               assertNull(t.getId().getTag("uri"));
+               assertNull(t.getId().getTag("status"));
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B: through the Observer facade (metrics-only).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_observerHappyPath() {
+               var observer = new Observer(recorder, NoOpTracerHook.INSTANCE);
+               try (var o = observer.start("svc.call", "k=v")) {
+                       assertNotSame(Observation.NOOP, o);
+               }
+               var t = registry.find("svc.call").tag("k", 
"v").tag("exception", "None").timer();
+               assertNotNull(t);
+               assertEquals(1, t.count());
+       }
+
+       @Test void b02_observerErrorPath() {
+               var observer = new Observer(recorder, NoOpTracerHook.INSTANCE);
+               try (var o = observer.start("svc.call", "")) {
+                       o.setError(new RuntimeException("boom"));
+               }
+               var t = registry.find("svc.call").tag("exception", 
"RuntimeException").timer();
+               assertNotNull(t);
+               assertEquals(1, t.count());
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-tracing-otel/src/main/java/org/apache/juneau/rest/server/tracing/otel/OtelTracerHook.java
 
b/juneau-rest/juneau-rest-server-tracing-otel/src/main/java/org/apache/juneau/rest/server/tracing/otel/OtelTracerHook.java
index 09a034f51b..4409accfaa 100644
--- 
a/juneau-rest/juneau-rest-server-tracing-otel/src/main/java/org/apache/juneau/rest/server/tracing/otel/OtelTracerHook.java
+++ 
b/juneau-rest/juneau-rest-server-tracing-otel/src/main/java/org/apache/juneau/rest/server/tracing/otel/OtelTracerHook.java
@@ -194,6 +194,18 @@ public class OtelTracerHook implements TracerHook {
                return new OtelScope(span, otelScope);
        }
 
+       @Override /* TracerHook */
+       public Scope startSpan(String spanName) {
+               // Custom (non-request) observation: open an INTERNAL span 
parented to the current context
+               // (so it nests under an active request span when one exists), 
with no HTTP attributes.
+               Span span = tracer.spanBuilder(spanName)
+                       .setSpanKind(SpanKind.INTERNAL)
+                       .setParent(Context.current())
+                       .startSpan();
+               io.opentelemetry.context.Scope otelScope = 
Context.current().with(span).makeCurrent();
+               return new OtelScope(span, otelScope);
+       }
+
        private void stashTraceContext(RestRequest request, Context 
spanContext) {
                var carrier = new HashMap<String,String>(4);
                propagator.inject(spanContext, carrier, MAP_SETTER);
diff --git 
a/juneau-rest/juneau-rest-server-tracing-otel/src/test/java/org/apache/juneau/rest/server/tracing/TracerHook_Contract_Test.java
 
b/juneau-rest/juneau-rest-server-tracing-otel/src/test/java/org/apache/juneau/rest/server/tracing/TracerHook_Contract_Test.java
index 39409f3b0d..91ededd548 100644
--- 
a/juneau-rest/juneau-rest-server-tracing-otel/src/test/java/org/apache/juneau/rest/server/tracing/TracerHook_Contract_Test.java
+++ 
b/juneau-rest/juneau-rest-server-tracing-otel/src/test/java/org/apache/juneau/rest/server/tracing/TracerHook_Contract_Test.java
@@ -159,7 +159,7 @@ class TracerHook_Contract_Test extends TestBase {
        }
 
        @Test void d02_noOpTracer_returnsNoOpScope() {
-               var scope = NoOpTracerHook.INSTANCE.startSpan(null);
+               var scope = NoOpTracerHook.INSTANCE.startSpan((RestRequest) 
null);
                assertSame(NoOpTracerHook.NoOpScope.INSTANCE, scope);
        }
 
diff --git 
a/juneau-rest/juneau-rest-server-tracing-otel/src/test/java/org/apache/juneau/rest/server/tracing/otel/OtelCustomObservation_Test.java
 
b/juneau-rest/juneau-rest-server-tracing-otel/src/test/java/org/apache/juneau/rest/server/tracing/otel/OtelCustomObservation_Test.java
new file mode 100644
index 0000000000..c4a7b5a525
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-tracing-otel/src/test/java/org/apache/juneau/rest/server/tracing/otel/OtelCustomObservation_Test.java
@@ -0,0 +1,114 @@
+/*
+ * 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.server.tracing.otel;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.server.metrics.*;
+import org.apache.juneau.rest.server.observation.*;
+import org.junit.jupiter.api.*;
+
+import io.opentelemetry.api.trace.*;
+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.*;
+
+/**
+ * Tests for the custom (non-request) {@code startSpan(String)} override on 
{@link OtelTracerHook},
+ * verifying an {@code INTERNAL} span is produced (and nested under an active 
parent), driven both
+ * directly and through the {@link Observer} facade.
+ */
+@SuppressWarnings({
+       "resource" // Closeable test fixtures held in static fields; lifecycle 
managed by the test, not a real leak.
+})
+class OtelCustomObservation_Test extends TestBase {
+
+       static final InMemorySpanExporter EXPORTER = 
InMemorySpanExporter.create();
+       static final OpenTelemetrySdk OTEL_SDK = OpenTelemetrySdk.builder()
+               .setTracerProvider(SdkTracerProvider.builder()
+                       .addSpanProcessor(SimpleSpanProcessor.create(EXPORTER))
+                       .build())
+               
.setPropagators(ContextPropagators.create(io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator.getInstance()))
+               .build();
+
+       @BeforeEach
+       void resetExporter() {
+               EXPORTER.reset();
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // A: direct startSpan(name).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_internalSpanProducedAndClosed() {
+               var hook = new OtelTracerHook(OTEL_SDK);
+               try (var scope = hook.startSpan("loadOrder")) {
+                       scope.setStatusCode(200);
+               }
+               var spans = EXPORTER.getFinishedSpanItems();
+               assertEquals(1, spans.size());
+               var s = spans.get(0);
+               assertEquals("loadOrder", s.getName());
+               assertEquals(SpanKind.INTERNAL, s.getKind());
+       }
+
+       @Test void a02_errorRecordedOnSpan() {
+               var hook = new OtelTracerHook(OTEL_SDK);
+               try (var scope = hook.startSpan("loadOrder")) {
+                       scope.setError(new IllegalStateException("boom"));
+               }
+               var s = EXPORTER.getFinishedSpanItems().get(0);
+               assertEquals(StatusCode.ERROR, s.getStatus().getStatusCode());
+               assertEquals("IllegalStateException", 
s.getAttributes().get(OtelTracerHook.ATTR_EXCEPTION_TYPE));
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B: through the Observer facade (tracing-only).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_observerProducesSpan() {
+               var observer = new Observer(NoOpMetricsRecorder.INSTANCE, new 
OtelTracerHook(OTEL_SDK));
+               try (var o = observer.start("svc.call", "")) {
+                       assertNotSame(Observation.NOOP, o);
+               }
+               var spans = EXPORTER.getFinishedSpanItems();
+               assertEquals(1, spans.size());
+               assertEquals("svc.call", spans.get(0).getName());
+               assertEquals(SpanKind.INTERNAL, spans.get(0).getKind());
+       }
+
+       @Test void b02_nestedObservationsShareTrace() {
+               var hook = new OtelTracerHook(OTEL_SDK);
+               var observer = new Observer(NoOpMetricsRecorder.INSTANCE, hook);
+               try (var outer = observer.start("outer", "")) {
+                       assertNotSame(Observation.NOOP, outer);
+                       try (var inner = observer.start("inner", "")) {
+                               assertNotSame(Observation.NOOP, inner);
+                       }
+               }
+               var spans = EXPORTER.getFinishedSpanItems();
+               assertEquals(2, spans.size());
+               // Inner finishes first; both share one trace id (inner is 
parented to outer).
+               var inner = spans.stream().filter(x -> 
x.getName().equals("inner")).findFirst().orElseThrow();
+               var outer = spans.stream().filter(x -> 
x.getName().equals("outer")).findFirst().orElseThrow();
+               assertEquals(outer.getTraceId(), inner.getTraceId());
+               assertEquals(outer.getSpanId(), inner.getParentSpanId());
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/metrics/MetricsRecorder.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/metrics/MetricsRecorder.java
index 930980131a..3f7930cb42 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/metrics/MetricsRecorder.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/metrics/MetricsRecorder.java
@@ -111,4 +111,33 @@ public interface MetricsRecorder {
                "java:S107"   // The 8 parameters form the stable per-request 
metric event contract; a holder object would obscure this SPI signature.
        })
        void record(String opName, String httpMethod, String uriTemplate, int 
statusCode, Duration elapsed, Throwable error, String metricName, String 
metricTags);
+
+       /**
+        * Records one custom (non-request) observation event.
+        *
+        * <p>
+        * Unlike {@link #record(String, String, String, int, Duration, 
Throwable, String, String)}, this
+        * entry point is <b>not</b> tied to an HTTP request &mdash; it is the 
substrate for the explicit
+        * programmatic observation API ({@link 
org.apache.juneau.rest.server.observation.Observations}) so
+        * application code can time an arbitrary block of work. Bridge 
implementations record a timer named
+        * {@code metricName} carrying {@code metricTags} (plus an {@code 
exception} tag derived from
+        * {@code error}).
+        *
+        * <p>
+        * The default implementation does nothing &mdash; a bridge that does 
not override it simply does not
+        * record custom observations, and the no-backend path stays 
zero-allocation. The shipped Micrometer
+        * bridge overrides it.
+        *
+        * @param metricName The timer name (e.g. {@code "order.load"}). Never 
<jk>null</jk>; never blank.
+        * @param metricTags Additional tags as comma-separated {@code 
key=value} pairs (e.g.
+        *      {@code "team=payments,region=us-east"}). Empty string means no 
additional tags. Never <jk>null</jk>.
+        * @param elapsed Wall-clock duration of the observed block. Never 
<jk>null</jk>; never negative.
+        * @param error The exception thrown by the observed block, or 
<jk>null</jk> if it completed normally.
+        */
+       @SuppressWarnings({
+               "java:S6213" // 'record' is the established SPI method name; 
renaming would break the public observability API.
+       })
+       default void record(String metricName, String metricTags, Duration 
elapsed, Throwable error) {
+               // Default no-op: bridges that support custom observations 
override this.
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/Observation.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/Observation.java
new file mode 100644
index 0000000000..ff8458cacb
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/Observation.java
@@ -0,0 +1,98 @@
+/*
+ * 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.server.observation;
+
+/**
+ * Handle to one in-flight custom (non-request) observation.
+ *
+ * <p>
+ * Returned by {@link Observer#start(String, String)} and by the {@link 
Observations} facade. An
+ * observation pairs a metric timer with a tracing span around an arbitrary 
block of work that has no
+ * associated HTTP request &mdash; the request-path equivalent is the 
framework-driven boundary in
+ * {@code RestOpInvoker}.
+ *
+ * <h5 class='topic'>Usage</h5>
+ *
+ * <p>
+ * The handle is {@link AutoCloseable} and is designed for try-with-resources. 
On {@link #close()} it
+ * records the elapsed time to the metrics recorder and ends the span. Mark a 
failure with
+ * {@link #setError(Throwable)} before the block exits so both the timer's 
{@code exception} tag and the
+ * span's error status reflect it:
+ *
+ * <p class='bjava'>
+ *     <jk>try</jk> (Observation <jv>o</jv> = 
Observations.<jsm>observe</jsm>(<js>"order.load"</js>, 
<js>"team=payments"</js>)) {
+ *             <jk>return</jk> loadOrder(<jv>id</jv>);
+ *     } <jk>catch</jk> (RuntimeException <jv>e</jv>) {
+ *             <jv>o</jv>.setError(<jv>e</jv>);
+ *             <jk>throw</jk> <jv>e</jv>;
+ *     }
+ * </p>
+ *
+ * <h5 class='topic'>No-backend contract</h5>
+ *
+ * <p>
+ * When no metrics recorder / tracer is installed, {@link Observations} hands 
back
+ * {@link #NOOP} &mdash; a process-wide singleton whose methods do nothing and 
whose timing is never
+ * taken, so an observation in an unconfigured process costs nothing beyond 
the try-with-resources
+ * scaffolding.
+ *
+ * @since 10.0.0
+ */
+@SuppressWarnings({
+       "resource" // NOOP is an intentional stateless no-op singleton held in 
a static field; its close() is empty, so there is nothing to leak.
+})
+public interface Observation extends AutoCloseable {
+
+       /**
+        * Process-wide no-op observation, returned whenever no backend is 
installed.
+        *
+        * <p>
+        * Drops the error on the floor and does nothing on close.
+        */
+       Observation NOOP = new Observation() {
+               @Override /* Observation */
+               public void setError(Throwable error) {
+                       // Intentionally empty.
+               }
+
+               @Override /* Observation */
+               public void close() {
+                       // Intentionally empty.
+               }
+       };
+
+       /**
+        * Marks the observed block as failed.
+        *
+        * <p>
+        * The throwable is surfaced as the metric timer's {@code exception} 
tag and recorded on the tracing
+        * span (status {@code ERROR} + {@code exception.type} attribute) when 
the observation closes. Safe
+        * to call at most once; a {@code null} argument is treated as "no 
error".
+        *
+        * @param error The exception that caused the block to fail. May be 
<jk>null</jk> (treated as success).
+        */
+       void setError(Throwable error);
+
+       /**
+        * Closes the observation &mdash; records the elapsed timer sample and 
ends the span.
+        *
+        * <p>
+        * Always called via the try-with-resources {@code finally} contract. 
Implementations must not throw.
+        */
+       @Override /* AutoCloseable */
+       void close();
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/Observations.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/Observations.java
new file mode 100644
index 0000000000..ff48639527
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/Observations.java
@@ -0,0 +1,126 @@
+/*
+ * 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.server.observation;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+/**
+ * Static facade over an explicitly-installed default {@link Observer} for 
ergonomic custom
+ * (non-request) observations.
+ *
+ * <p>
+ * {@code juneau-rest-server} has no method-interception / AOP substrate, so 
custom observations are
+ * <b>explicit</b>: wrap the block of work in a try-with-resources over {@link 
#observe(String, String)}.
+ * This mirrors how the request path is instrumented (the framework opens / 
closes the observation around
+ * the {@code @RestOp} handler) but lets application code instrument arbitrary 
work.
+ *
+ * <h5 class='topic'>Installing a backend</h5>
+ *
+ * <p>
+ * The default observer is {@link Observer#NOOP} until a backend is installed 
via {@link #install(Observer)}
+ * &mdash; typically once at startup, after the application's {@code 
MetricsRecorder} / {@code TracerHook}
+ * beans are known. Until then (and in any process that never installs one) 
{@link #observe(String, String)}
+ * hands back {@link Observation#NOOP} and costs nothing.
+ *
+ * <p class='bjava'>
+ *     <jc>// At startup, once the observability beans are built:</jc>
+ *     Observations.<jsm>install</jsm>(<jk>new</jk> 
Observer(<jv>metricsRecorder</jv>, <jv>tracerHook</jv>));
+ *
+ *     <jc>// Anywhere in application code:</jc>
+ *     <jk>try</jk> (Observation <jv>o</jv> = 
Observations.<jsm>observe</jsm>(<js>"order.load"</js>, 
<js>"team=payments"</js>)) {
+ *             <jk>return</jk> loadOrder(<jv>id</jv>);
+ *     } <jk>catch</jk> (RuntimeException <jv>e</jv>) {
+ *             <jv>o</jv>.setError(<jv>e</jv>);
+ *             <jk>throw</jk> <jv>e</jv>;
+ *     }
+ * </p>
+ *
+ * <p>
+ * Code that prefers explicit dependency injection over a global can hold an 
{@link Observer} directly
+ * (resolved from the {@code RestContext} bean store) and call {@link 
Observer#start(String, String)} &mdash;
+ * this facade is a convenience, not the only entry point.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link Observer}
+ *     <li class='jc'>{@link Observation}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@SuppressWarnings({
+       "resource" // observe(...) returns the Observation handle for the 
caller's try-with-resources; closing it here is wrong.
+})
+public class Observations {
+
+       private static final String ARG_observer = "observer";
+
+       private static volatile Observer defaultObserver = Observer.NOOP;
+
+       private Observations() {}
+
+       /**
+        * Installs the process-wide default {@link Observer} used by {@link 
#observe(String, String)}.
+        *
+        * <p>
+        * Intended to be called once at application startup. Passing {@link 
Observer#NOOP} (or calling
+        * {@link #reset()}) restores the no-op default.
+        *
+        * @param observer The observer to install. Must not be <jk>null</jk>.
+        */
+       public static void install(Observer observer) {
+               defaultObserver = assertArgNotNull(ARG_observer, observer);
+       }
+
+       /**
+        * Resets the process-wide default observer back to {@link 
Observer#NOOP}.
+        */
+       public static void reset() {
+               defaultObserver = Observer.NOOP;
+       }
+
+       /**
+        * Returns the currently-installed default {@link Observer}.
+        *
+        * @return The default observer; {@link Observer#NOOP} if none was 
installed. Never <jk>null</jk>.
+        */
+       public static Observer observer() {
+               return defaultObserver;
+       }
+
+       /**
+        * Starts a custom observation against the installed default {@link 
Observer}.
+        *
+        * @param name The observation name &mdash; used as both the span name 
and the metric timer name.
+        *      Must not be <jk>null</jk> or blank.
+        * @param tags Additional metric tags as comma-separated {@code 
key=value} pairs, or <jk>null</jk> /
+        *      empty for none.
+        * @return The started {@link Observation} (a try-with-resources 
handle). Never <jk>null</jk>.
+        */
+       public static Observation observe(String name, String tags) {
+               return defaultObserver.start(name, tags);
+       }
+
+       /**
+        * Starts a custom observation with no additional tags.
+        *
+        * @param name The observation name. Must not be <jk>null</jk> or blank.
+        * @return The started {@link Observation}. Never <jk>null</jk>.
+        */
+       public static Observation observe(String name) {
+               return defaultObserver.start(name, "");
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/Observer.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/Observer.java
new file mode 100644
index 0000000000..fedd261276
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/Observer.java
@@ -0,0 +1,170 @@
+/*
+ * 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.server.observation;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Utils.*;
+
+import java.time.*;
+
+import org.apache.juneau.rest.server.metrics.*;
+import org.apache.juneau.rest.server.tracing.*;
+import org.apache.juneau.rest.server.tracing.Scope;
+
+/**
+ * Factory for custom (non-request) {@link Observation}s, composing a {@link 
MetricsRecorder} and a
+ * {@link TracerHook}.
+ *
+ * <p>
+ * This is the explicit programmatic observation substrate for {@code 
juneau-rest-server}: it reuses the
+ * exact same two SPIs the request path uses ({@link MetricsRecorder}, {@link 
TracerHook}) and the same
+ * {@code metricName} / {@code metricTags} comma-separated tag model, but is 
decoupled from
+ * {@code RestRequest} so application code can instrument an arbitrary block 
of work. Juneau deliberately
+ * has no method-interception / AOP substrate, so observation is explicit (a 
try-with-resources block)
+ * rather than annotation-driven &mdash; consistent with the framework's 
explicit-over-magic philosophy.
+ *
+ * <h5 class='topic'>No-backend contract</h5>
+ *
+ * <p>
+ * An {@code Observer} built from the no-op SPIs ({@link #NOOP}) never takes a 
timestamp and hands back
+ * {@link Observation#NOOP}, so an observation in an unconfigured process 
costs nothing beyond the
+ * try-with-resources scaffolding. {@link #start(String, String)} 
short-circuits to the no-op handle
+ * whenever <i>both</i> backends are the no-op singletons.
+ *
+ * <h5 class='topic'>Wiring</h5>
+ *
+ * <p>
+ * Typically obtained from a {@code RestContext}'s bean store &mdash; register 
the same
+ * {@code @Bean MetricsRecorder} / {@code @Bean TracerHook} you already 
register for request-path
+ * observability, then build an {@code Observer} from them (or install one as 
the {@link Observations}
+ * default). Construct directly for tests:
+ *
+ * <p class='bjava'>
+ *     Observer <jv>o</jv> = <jk>new</jk> Observer(<jk>new</jk> 
MicrometerMetricsRecorder(<jv>registry</jv>), <jk>new</jk> 
OtelTracerHook(<jv>otel</jv>));
+ * </p>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link Observations}
+ *     <li class='jc'>{@link Observation}
+ *     <li class='jc'>{@link MetricsRecorder}
+ *     <li class='jc'>{@link TracerHook}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@SuppressWarnings({
+       "resource" // start(...) returns the Observation for the caller's 
try-with-resources; ActiveObservation holds the tracer Scope it closes in its 
own close().
+})
+public final class Observer {
+
+       /**
+        * Observer backed by the no-op metrics recorder and tracer &mdash; 
produces only
+        * {@link Observation#NOOP}.
+        */
+       public static final Observer NOOP = new 
Observer(NoOpMetricsRecorder.INSTANCE, NoOpTracerHook.INSTANCE);
+
+       private static final String ARG_recorder = "recorder";
+       private static final String ARG_tracer = "tracer";
+       private static final String ARG_name = "name";
+
+       private final MetricsRecorder recorder;
+       private final TracerHook tracer;
+       private final boolean active;
+
+       /**
+        * Constructor.
+        *
+        * @param recorder The metrics recorder to time observations against. 
Must not be <jk>null</jk>;
+        *      use {@link NoOpMetricsRecorder#INSTANCE} to disable metrics.
+        * @param tracer The tracer to open spans against. Must not be 
<jk>null</jk>; use
+        *      {@link NoOpTracerHook#INSTANCE} to disable tracing.
+        */
+       public Observer(MetricsRecorder recorder, TracerHook tracer) {
+               this.recorder = assertArgNotNull(ARG_recorder, recorder);
+               this.tracer = assertArgNotNull(ARG_tracer, tracer);
+               this.active = recorder != NoOpMetricsRecorder.INSTANCE || 
tracer != NoOpTracerHook.INSTANCE;
+       }
+
+       /**
+        * Starts a new observation.
+        *
+        * <p>
+        * When both backends are the no-op singletons this returns {@link 
Observation#NOOP} without taking a
+        * timestamp or opening a span. Otherwise it opens a span via {@link 
TracerHook#startSpan(String)} and
+        * captures a start timestamp; the paired timer sample is recorded on 
{@link Observation#close()}.
+        *
+        * @param name The observation name &mdash; used as both the span name 
and the metric timer name.
+        *      Must not be <jk>null</jk> or blank.
+        * @param tags Additional metric tags as comma-separated {@code 
key=value} pairs (e.g.
+        *      {@code "team=payments,region=us-east"}), or <jk>null</jk> / 
empty for none.
+        * @return The started {@link Observation}. Never <jk>null</jk>.
+        */
+       public Observation start(String name, String tags) {
+               assertArgNotNullOrBlank(ARG_name, name);
+               if (! active)
+                       return Observation.NOOP;
+               var scope = tracer.startSpan(name);
+               return new ActiveObservation(recorder, scope, name, tags == 
null ? "" : tags);
+       }
+
+       /**
+        * Returns whether this observer has at least one live backend (metrics 
or tracing).
+        *
+        * @return <jk>true</jk> if either the metrics recorder or the tracer 
is non-no-op.
+        */
+       public boolean isActive() {
+               return active;
+       }
+
+       private static final class ActiveObservation implements Observation {
+
+               private final MetricsRecorder recorder;
+               private final Scope scope;
+               private final String name;
+               private final String tags;
+               private final long startNanos;
+               private Throwable error;
+
+               ActiveObservation(MetricsRecorder recorder, Scope scope, String 
name, String tags) {
+                       this.recorder = recorder;
+                       this.scope = scope;
+                       this.name = name;
+                       this.tags = tags;
+                       this.startNanos = System.nanoTime();
+               }
+
+               @Override /* Observation */
+               public void setError(Throwable error) {
+                       this.error = error;
+               }
+
+               @Override /* Observation */
+               public void close() {
+                       var elapsed = Duration.ofNanos(System.nanoTime() - 
startNanos);
+                       try {
+                               if (nn(error))
+                                       scope.setError(error);
+                       } finally {
+                               try {
+                                       scope.close();
+                               } finally {
+                                       recorder.record(name, tags, elapsed, 
error);
+                               }
+                       }
+               }
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/package-info.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/package-info.java
new file mode 100644
index 0000000000..55c8e91d8a
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/observation/package-info.java
@@ -0,0 +1,51 @@
+/*
+ * 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.
+ */
+/**
+ * Explicit programmatic observation API for {@code juneau-rest-server} 
&mdash; custom (non-request)
+ * metrics + tracing around an arbitrary block of work.
+ *
+ * <h5 class='topic'>Why explicit (no {@code @Observed} annotation)</h5>
+ *
+ * <p>
+ * Juneau has no method-interception / AOP substrate (no dynamic proxying of 
arbitrary beans, no
+ * bytecode weaving, no annotation-processor agent). Rather than introduce one 
to support an
+ * {@code @Observed}-style annotation, custom observations are 
<b>explicit</b>: application code wraps a
+ * block in a try-with-resources over {@link 
org.apache.juneau.rest.server.observation.Observations#observe(String, String)}.
+ * This is consistent with the framework's explicit-over-magic philosophy 
&mdash; the same reason the
+ * curated dependency bundles register nothing automatically.
+ *
+ * <h5 class='topic'>Reuses the request-path SPIs</h5>
+ *
+ * <p>
+ * Observations are recorded through the very same two SPIs the request path 
uses &mdash;
+ * {@link org.apache.juneau.rest.server.metrics.MetricsRecorder} (via its 
non-request
+ * {@code record(name, tags, elapsed, error)} default method) and
+ * {@link org.apache.juneau.rest.server.tracing.TracerHook} (via its 
non-request {@code startSpan(name)}
+ * default method) &mdash; and the same comma-separated {@code metricName} / 
{@code metricTags} model.
+ * The shipped bridges ({@code MicrometerMetricsRecorder}, {@code 
OtelTracerHook}) override those default
+ * methods; bridges that do not simply skip custom observations.
+ *
+ * <h5 class='topic'>No-backend contract</h5>
+ *
+ * <p>
+ * When no backend is installed, {@link 
org.apache.juneau.rest.server.observation.Observations#observe(String, String)}
+ * returns {@link org.apache.juneau.rest.server.observation.Observation#NOOP} 
&mdash; no timestamp taken,
+ * no span opened, zero allocation beyond the try-with-resources scaffolding.
+ *
+ * @since 10.0.0
+ */
+package org.apache.juneau.rest.server.observation;
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/processor/MdcAsyncListener.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/processor/MdcAsyncListener.java
index 06f08be730..3bb5909089 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/processor/MdcAsyncListener.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/processor/MdcAsyncListener.java
@@ -81,6 +81,19 @@ public final class MdcAsyncListener {
        private static final Method SET_CONTEXT_MAP;
        private static final Method CLEAR;
 
+       /** MDC keys carrying the active trace / span id &mdash; the convention 
used by the OTel logback / log4j appenders. */
+       static final String MDC_TRACE_ID = "trace_id";
+       static final String MDC_SPAN_ID = "span_id";
+
+       /** {@code true} if {@code io.opentelemetry.api.trace.Span} was found 
on the classpath at class-load time. */
+       private static final boolean OTEL_AVAILABLE;
+
+       private static final Method SPAN_CURRENT;
+       private static final Method SPAN_GET_SPAN_CONTEXT;
+       private static final Method CTX_IS_VALID;
+       private static final Method CTX_GET_TRACE_ID;
+       private static final Method CTX_GET_SPAN_ID;
+
        static {
                boolean available = false;
                Method copyOf = null;
@@ -99,6 +112,31 @@ public final class MdcAsyncListener {
                GET_COPY_OF_CONTEXT_MAP = copyOf;
                SET_CONTEXT_MAP = set;
                CLEAR = clr;
+
+               boolean otel = false;
+               Method spanCurrent = null;
+               Method getSpanContext = null;
+               Method isValid = null;
+               Method getTraceId = null;
+               Method getSpanId = null;
+               try {
+                       Class<?> span = 
Class.forName("io.opentelemetry.api.trace.Span");
+                       Class<?> spanContext = 
Class.forName("io.opentelemetry.api.trace.SpanContext");
+                       spanCurrent = span.getMethod("current");
+                       getSpanContext = span.getMethod("getSpanContext");
+                       isValid = spanContext.getMethod("isValid");
+                       getTraceId = spanContext.getMethod("getTraceId");
+                       getSpanId = spanContext.getMethod("getSpanId");
+                       otel = true;
+               } catch (Exception e) {
+                       LOG.fine(() -> "OpenTelemetry API not found on 
classpath — trace/span id is not propagated across async boundaries: " + 
e.getMessage());
+               }
+               OTEL_AVAILABLE = otel;
+               SPAN_CURRENT = spanCurrent;
+               SPAN_GET_SPAN_CONTEXT = getSpanContext;
+               CTX_IS_VALID = isValid;
+               CTX_GET_TRACE_ID = getTraceId;
+               CTX_GET_SPAN_ID = getSpanId;
        }
 
        private MdcAsyncListener() {}
@@ -141,6 +179,10 @@ public final class MdcAsyncListener {
                        return null;
                try {
                        Map<String, String> map = (Map<String, String>) 
GET_COPY_OF_CONTEXT_MAP.invoke(null);
+                       // Enrich the snapshot with the active OTel trace/span 
id (under the conventional OTel
+                       // appender keys) so log correlation survives the 
async-completion hop even when the
+                       // completion thread has lost the OTel context. Only 
when an OTel span is actually active.
+                       map = enrichWithTraceContext(map);
                        return (map == null || map.isEmpty()) ? null : map;
                } catch (Exception e) {
                        LOG.fine(() -> "MDC.getCopyOfContextMap() failed: " + 
e.getMessage());
@@ -148,6 +190,30 @@ public final class MdcAsyncListener {
                }
        }
 
+       @SuppressWarnings({
+               "java:S3011", // Reflective access to the OTel trace API — 
intentional; OpenTelemetry is not a compile dep.
+               "java:S1168"  // null preserves the lazy-skip contract: a null 
map with no active trace must stay null (no MDC to propagate).
+       })
+       private static Map<String, String> enrichWithTraceContext(Map<String, 
String> map) {
+               if (!OTEL_AVAILABLE)
+                       return map;
+               try {
+                       Object span = SPAN_CURRENT.invoke(null);
+                       if (span == null)
+                               return map;
+                       Object ctx = SPAN_GET_SPAN_CONTEXT.invoke(span);
+                       if (ctx == null || ! ((Boolean) 
CTX_IS_VALID.invoke(ctx)).booleanValue())
+                               return map;
+                       var out = (map == null) ? new HashMap<String, 
String>(4) : new HashMap<>(map);
+                       out.put(MDC_TRACE_ID, 
String.valueOf(CTX_GET_TRACE_ID.invoke(ctx)));
+                       out.put(MDC_SPAN_ID, 
String.valueOf(CTX_GET_SPAN_ID.invoke(ctx)));
+                       return out;
+               } catch (Exception e) {
+                       LOG.fine(() -> "OTel trace-context enrichment failed: " 
+ e.getMessage());
+                       return map;
+               }
+       }
+
        /**
         * Wraps a {@link BiConsumer} (typically a {@link 
java.util.concurrent.CompletableFuture#whenComplete
         * whenComplete} callback) so the completion thread sees the request 
thread's MDC context.
diff --git 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/tracing/TracerHook.java
 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/tracing/TracerHook.java
index 3477dad525..465a79cc58 100644
--- 
a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/tracing/TracerHook.java
+++ 
b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/server/tracing/TracerHook.java
@@ -94,4 +94,27 @@ public interface TracerHook {
         *      contract still holds.
         */
        Scope startSpan(RestRequest request);
+
+       /**
+        * Opens a new span for a custom (non-request) observation.
+        *
+        * <p>
+        * Unlike {@link #startSpan(RestRequest)}, this entry point is 
<b>not</b> tied to an in-flight
+        * {@link RestRequest} &mdash; it is the substrate for the explicit 
programmatic observation API
+        * ({@link org.apache.juneau.rest.server.observation.Observations}) so 
application code can trace an
+        * arbitrary block of work that has no associated HTTP request. Bridge 
implementations typically open
+        * a span of kind {@code INTERNAL} (rather than {@code SERVER}) named 
{@code spanName}.
+        *
+        * <p>
+        * The default implementation returns {@link 
NoOpTracerHook.NoOpScope#INSTANCE} &mdash; a bridge that
+        * does not override this method simply does not trace custom 
observations, and the no-backend path
+        * stays zero-allocation. Both shipped bridges (the OpenTelemetry 
{@code TracerHook}) override it.
+        *
+        * @param spanName The span name (e.g. {@code "loadOrder"}). Never 
<jk>null</jk>; never blank.
+        * @return The opened {@link Scope}. Never <jk>null</jk> &mdash; 
implementations that cannot open a
+        *      span must return {@link NoOpTracerHook.NoOpScope#INSTANCE} so 
the close-in-finally contract holds.
+        */
+       default Scope startSpan(String spanName) {
+               return NoOpTracerHook.NoOpScope.INSTANCE;
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/observation/Observations_Test.java
 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/observation/Observations_Test.java
new file mode 100644
index 0000000000..05dbe988d4
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server/src/test/java/org/apache/juneau/rest/server/observation/Observations_Test.java
@@ -0,0 +1,248 @@
+/*
+ * 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.server.observation;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.metrics.*;
+import org.apache.juneau.rest.server.tracing.*;
+import org.apache.juneau.rest.server.tracing.Scope;
+import org.junit.jupiter.api.*;
+
+/**
+ * Unit tests for the explicit programmatic observation API ({@link 
Observations} / {@link Observer} /
+ * {@link Observation}) using in-memory fakes of the {@link MetricsRecorder} / 
{@link TracerHook} SPIs.
+ */
+@SuppressWarnings({
+       "resource" // FakeTracer.startSpan(...) returns a FakeScope by SPI 
contract; the observation handle owns and closes it.
+})
+class Observations_Test {
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Fakes capturing the non-request SPI default-method calls.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       static final class FakeRecorder implements MetricsRecorder {
+               final List<String> events = new ArrayList<>();
+
+               @Override /* MetricsRecorder */
+               public void record(String opName, String httpMethod, String 
uriTemplate, int statusCode, Duration elapsed, Throwable error, String 
metricName, String metricTags) {
+                       // Request-path method unused by these tests.
+               }
+
+               @Override /* MetricsRecorder */
+               public void record(String metricName, String metricTags, 
Duration elapsed, Throwable error) {
+                       assertNotNull(elapsed);
+                       assertFalse(elapsed.isNegative());
+                       events.add(metricName + "|" + metricTags + "|err=" + 
(error == null ? "None" : error.getClass().getSimpleName()));
+               }
+       }
+
+       static final class FakeScope implements Scope {
+               final String name;
+               final List<String> log;
+
+               FakeScope(String name, List<String> log) {
+                       this.name = name;
+                       this.log = log;
+               }
+
+               @Override /* Scope */ public void setStatusCode(int statusCode) 
{ /* unused */ }
+               @Override /* Scope */ public void setError(Throwable error) { 
log.add(name + ":error=" + error.getClass().getSimpleName()); }
+               @Override /* Scope */ public void close() { log.add(name + 
":close"); }
+       }
+
+       static final class FakeTracer implements TracerHook {
+               final List<String> log = new ArrayList<>();
+
+               @Override /* TracerHook */
+               public Scope startSpan(RestRequest request) { return 
NoOpTracerHook.NoOpScope.INSTANCE; }
+
+               @Override /* TracerHook */
+               public Scope startSpan(String spanName) {
+                       log.add("start:" + spanName);
+                       return new FakeScope(spanName, log);
+               }
+       }
+
+       @AfterEach
+       void resetGlobal() {
+               Observations.reset();
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // A: No-backend short-circuit (zero-alloc NOOP path).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void a01_noBackend_observerIsInactive() {
+               assertFalse(Observer.NOOP.isActive());
+       }
+
+       @Test void a02_noBackend_returnsNoopHandle() {
+               try (var o = Observer.NOOP.start("x", "a=b")) {
+                       assertSame(Observation.NOOP, o);
+               }
+       }
+
+       @Test void a03_facadeDefaultsToNoop() {
+               assertSame(Observer.NOOP, Observations.observer());
+               try (var o = Observations.observe("x")) {
+                       assertSame(Observation.NOOP, o);
+               }
+       }
+
+       @Test void a04_noopHandle_setErrorAndCloseAreSafe() {
+               var o = Observation.NOOP;
+               o.setError(new RuntimeException("ignored"));
+               o.close();  // must not throw
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B: Active observer — metrics only, tracing only, both.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_metricsOnly_recordsTimerWithNameAndTags() {
+               var r = new FakeRecorder();
+               var observer = new Observer(r, NoOpTracerHook.INSTANCE);
+               assertTrue(observer.isActive());
+               try (var o = observer.start("order.load", "team=payments")) {
+                       assertNotSame(Observation.NOOP, o);
+               }
+               assertEquals(List.of("order.load|team=payments|err=None"), 
r.events);
+       }
+
+       @Test void b02_tracingOnly_opensAndClosesSpan() {
+               var t = new FakeTracer();
+               var observer = new Observer(NoOpMetricsRecorder.INSTANCE, t);
+               try (var o = observer.start("order.load", null)) {
+                       assertNotSame(Observation.NOOP, o);
+               }
+               assertEquals(List.of("start:order.load", "order.load:close"), 
t.log);
+       }
+
+       @Test void b03_both_recordTimerAndSpan() {
+               var r = new FakeRecorder();
+               var t = new FakeTracer();
+               var observer = new Observer(r, t);
+               try (var o = observer.start("svc.call", "region=us")) {
+                       assertNotSame(Observation.NOOP, o);
+               }
+               assertEquals(List.of("svc.call|region=us|err=None"), r.events);
+               assertEquals(List.of("start:svc.call", "svc.call:close"), 
t.log);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // C: Error path — error tag + span error recorded.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void c01_error_taggedOnTimerAndSpan() {
+               var r = new FakeRecorder();
+               var t = new FakeTracer();
+               var observer = new Observer(r, t);
+               var boom = new IllegalStateException("boom");
+               try (var o = observer.start("svc.call", "")) {
+                       o.setError(boom);
+               }
+               assertEquals(List.of("svc.call||err=IllegalStateException"), 
r.events);
+               // Span: start, then error recorded, then close.
+               assertEquals(List.of("start:svc.call", 
"svc.call:error=IllegalStateException", "svc.call:close"), t.log);
+       }
+
+       @Test void c02_nullErrorTreatedAsSuccess() {
+               var r = new FakeRecorder();
+               var observer = new Observer(r, NoOpTracerHook.INSTANCE);
+               try (var o = observer.start("x", "")) {
+                       o.setError(null);
+               }
+               assertEquals(List.of("x||err=None"), r.events);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // D: Global facade install / observe / reset.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void d01_install_thenObserveUsesInstalledBackend() {
+               var r = new FakeRecorder();
+               Observations.install(new Observer(r, NoOpTracerHook.INSTANCE));
+               try (var o = Observations.observe("biz.metric", "k=v")) {
+                       assertNotSame(Observation.NOOP, o);
+               }
+               assertEquals(List.of("biz.metric|k=v|err=None"), r.events);
+       }
+
+       @Test void d02_observeNoTags_passesEmptyTags() {
+               var r = new FakeRecorder();
+               Observations.install(new Observer(r, NoOpTracerHook.INSTANCE));
+               try (var o = Observations.observe("biz.metric")) {
+                       assertNotSame(Observation.NOOP, o);
+               }
+               assertEquals(List.of("biz.metric||err=None"), r.events);
+       }
+
+       @Test void d03_reset_restoresNoop() {
+               Observations.install(new Observer(new FakeRecorder(), 
NoOpTracerHook.INSTANCE));
+               assertTrue(Observations.observer().isActive());
+               Observations.reset();
+               assertSame(Observer.NOOP, Observations.observer());
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // E: Argument validation.
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void e01_nullRecorder_throws() {
+               assertThrows(IllegalArgumentException.class, () -> new 
Observer(null, NoOpTracerHook.INSTANCE));
+       }
+
+       @Test void e02_nullTracer_throws() {
+               assertThrows(IllegalArgumentException.class, () -> new 
Observer(NoOpMetricsRecorder.INSTANCE, null));
+       }
+
+       @Test void e03_blankName_throws() {
+               var observer = new Observer(new FakeRecorder(), 
NoOpTracerHook.INSTANCE);
+               assertThrows(IllegalArgumentException.class, () -> 
observer.start("  ", ""));
+       }
+
+       @Test void e04_nullName_throws() {
+               var observer = new Observer(new FakeRecorder(), 
NoOpTracerHook.INSTANCE);
+               assertThrows(IllegalArgumentException.class, () -> 
observer.start(null, ""));
+       }
+
+       @Test void e05_install_null_throws() {
+               assertThrows(IllegalArgumentException.class, () -> 
Observations.install(null));
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // F: Default SPI methods are no-ops on the NoOp singletons (zero-cost 
contract).
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void f01_noopTracer_startSpanByName_returnsNoopScope() {
+               assertSame(NoOpTracerHook.NoOpScope.INSTANCE, 
NoOpTracerHook.INSTANCE.startSpan("x"));
+       }
+
+       @Test void f02_noopRecorder_recordCustom_doesNothing() {
+               var called = new AtomicBoolean(false);
+               // Default method on the NoOp recorder must simply do nothing 
(no throw, no side effect we can observe).
+               NoOpMetricsRecorder.INSTANCE.record("x", "", 
Duration.ofMillis(1), null);
+               assertFalse(called.get());
+       }
+}

Reply via email to