You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/06/18 21:35:19 UTC

(juneau) branch master updated: feat: custom observation API, OTLP bundle, and OpenTelemetry log correlation

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 <ja...@apache.org>
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());
+	}
+}