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> — {@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 — 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()} — 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 — 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 — 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 — 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} — 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 — 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)}
+ * — 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)} —
+ * 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 — 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 — 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 — 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 —
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 — 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}
— 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
— 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 —
+ * {@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) — 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}
— 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 — 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} — 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} — 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> —
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());
+ }
+}