diff --git a/api/applib/src/main/java/module-info.java b/api/applib/src/main/java/module-info.java
index 7d97d39ab78..99aa89988b8 100644
--- a/api/applib/src/main/java/module-info.java
+++ b/api/applib/src/main/java/module-info.java
@@ -146,6 +146,7 @@
requires transitive spring.web;
requires spring.tx;
requires org.slf4j;
+ requires micrometer.observation;
// JAXB viewmodels
opens org.apache.causeway.applib.annotation;
diff --git a/api/applib/src/main/java/org/apache/causeway/applib/services/iactnlayer/InteractionLayer.java b/api/applib/src/main/java/org/apache/causeway/applib/services/iactnlayer/InteractionLayer.java
index 170931239a0..35fb519407d 100644
--- a/api/applib/src/main/java/org/apache/causeway/applib/services/iactnlayer/InteractionLayer.java
+++ b/api/applib/src/main/java/org/apache/causeway/applib/services/iactnlayer/InteractionLayer.java
@@ -18,36 +18,71 @@
*/
package org.apache.causeway.applib.services.iactnlayer;
+import org.jspecify.annotations.Nullable;
+
import org.apache.causeway.applib.services.iactn.Interaction;
+import org.apache.causeway.commons.functional.Try;
/**
* Binds an {@link Interaction} ("what" is being executed) with
* an {@link InteractionContext} ("who" is executing, "when" and "where").
*
- *
- * {@link InteractionLayer}s are so called because they may be nested (held in a stack). For example the
+ *
{@link InteractionLayer}s are so called because they may be nested (held in a stack). For example the
* {@link org.apache.causeway.applib.services.sudo.SudoService} creates a new temporary layer with a different
* {@link InteractionContext#getUser() user}, while fixtures that mock the clock switch out the
* {@link InteractionContext#getClock() clock}.
- *
*
- *
- * The stack of layers is per-thread, managed by {@link InteractionService} as a thread-local).
- *
+ * The stack of layers is per-thread, managed by {@link InteractionService} as a thread-local).
*
* @since 2.0 {@index}
*/
public record InteractionLayer(
+ @Nullable InteractionLayer parent,
/**
- * Current thread's {@link Interaction} : "what" is being executed
+ * Current thread's {@link Interaction} : WHAT is being executed
*/
Interaction interaction,
/**
- * "who" is performing this {@link #getInteraction()}, also
- * "when" and "where".
+ * WHO is performing this {@link #getInteraction()}, also
+ * WHEN and WHERE.
+ */
+ InteractionContext interactionContext,
+ /**
+ * @since 4.0
*/
- InteractionContext interactionContext
- ) {
+ Runnable onCloseCallback) implements AutoCloseable {
+
+ public boolean isRoot() {
+ return parent==null;
+ }
+
+ public int parentCount() {
+ return parent!=null
+ ? 1 + parent.parentCount()
+ : 0;
+ }
+
+ public int totalLayerCount() {
+ return 1 + parentCount();
+ }
+
+ public InteractionLayer rootLayer() {
+ return parent!=null
+ ? parent.rootLayer()
+ : this;
+ }
+
+ @Override
+ public void close() {
+ Try.run(onCloseCallback::run); // ignores exceptions
+ }
+
+ public void closeAll() {
+ close();
+ if(parent!=null) {
+ parent.closeAll();
+ }
+ }
}
diff --git a/api/applib/src/main/java/org/apache/causeway/applib/services/iactnlayer/InteractionLayerStack.java b/api/applib/src/main/java/org/apache/causeway/applib/services/iactnlayer/InteractionLayerStack.java
new file mode 100644
index 00000000000..88aa872ef62
--- /dev/null
+++ b/api/applib/src/main/java/org/apache/causeway/applib/services/iactnlayer/InteractionLayerStack.java
@@ -0,0 +1,103 @@
+/*
+ * 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.causeway.applib.services.iactnlayer;
+
+import java.util.Optional;
+import java.util.function.Predicate;
+
+import org.jspecify.annotations.Nullable;
+
+import org.apache.causeway.applib.services.iactn.Interaction;
+import org.apache.causeway.commons.internal.observation.ObservationClosure;
+
+import io.micrometer.observation.Observation;
+
+public final class InteractionLayerStack {
+
+ // TODO: reading the javadoc for TransactionSynchronizationManager and looking at the implementations
+ // of TransactionSynchronization (in particular SpringSessionSynchronization), I suspect that this
+ // ThreadLocal would be considered bad practice and instead should be managed using the TransactionSynchronization mechanism.
+ private final ThreadLocal threadLocalLayer = new ThreadLocal<>();
+
+ public Optional currentLayer() {
+ return Optional.ofNullable(threadLocalLayer.get());
+ }
+
+ public InteractionLayer push(
+ final Interaction interaction,
+ final InteractionContext interactionContext,
+ final Observation observation) {
+ var parent = currentLayer().orElse(null);
+ @SuppressWarnings("resource")
+ var newLayer = new InteractionLayer(parent, interaction, interactionContext,
+ // on-close callback
+ new ObservationClosure().startAndOpenScope(observation)::close);
+ threadLocalLayer.set(newLayer);
+ return newLayer;
+ }
+
+ public void clear() {
+ currentLayer().ifPresent(InteractionLayer::closeAll);
+ threadLocalLayer.remove();
+ }
+
+ public boolean isEmpty() {
+ return threadLocalLayer.get()==null;
+ }
+
+ public int size() {
+ return currentLayer()
+ .map(InteractionLayer::totalLayerCount)
+ .orElse(0);
+ }
+
+ @Nullable
+ public InteractionLayer peek() {
+ return threadLocalLayer.get();
+ }
+
+ @Nullable
+ public InteractionLayer pop() {
+ var current = threadLocalLayer.get();
+ if(current==null) return null;
+
+ var newTop = current.parent();
+ current.close();
+ return set(newTop);
+ }
+
+ public void popWhile(final Predicate condition) {
+ while(!isEmpty()) {
+ if(!condition.test(peek())) return;
+ pop();
+ }
+ }
+
+ // -- HELPER
+
+ private InteractionLayer set(@Nullable final InteractionLayer layer) {
+ if(layer != null) {
+ threadLocalLayer.set(layer);
+ } else {
+ threadLocalLayer.remove();
+ }
+ return layer;
+ }
+
+}
diff --git a/api/applib/src/main/java/org/apache/causeway/applib/services/iactnlayer/InteractionService.java b/api/applib/src/main/java/org/apache/causeway/applib/services/iactnlayer/InteractionService.java
index d133392bb42..8778a90480e 100644
--- a/api/applib/src/main/java/org/apache/causeway/applib/services/iactnlayer/InteractionService.java
+++ b/api/applib/src/main/java/org/apache/causeway/applib/services/iactnlayer/InteractionService.java
@@ -20,11 +20,11 @@
import java.util.concurrent.Callable;
+import org.jspecify.annotations.NonNull;
+
import org.apache.causeway.commons.functional.ThrowingRunnable;
import org.apache.causeway.commons.functional.Try;
-import org.jspecify.annotations.NonNull;
-
/**
* A low-level service to programmatically create a short-lived interaction or session.
*
diff --git a/bom/pom.xml b/bom/pom.xml
index bc4aff57104..085eb241c30 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -1803,6 +1803,18 @@ identified
${jdom.version}
+
+ org.springframework.boot
+ spring-boot-starter-opentelemetry
+ ${spring-boot.version}
+
+
+ org.jetbrains
+ annotations
+
+
+
+
org.springframework.boot
spring-boot-starter-quartz
diff --git a/commons/src/main/java/module-info.java b/commons/src/main/java/module-info.java
index 398b9432e33..47a24ab13b0 100644
--- a/commons/src/main/java/module-info.java
+++ b/commons/src/main/java/module-info.java
@@ -50,6 +50,7 @@
exports org.apache.causeway.commons.internal.html;
exports org.apache.causeway.commons.internal.image;
exports org.apache.causeway.commons.internal.ioc;
+ exports org.apache.causeway.commons.internal.observation;
exports org.apache.causeway.commons.internal.os;
exports org.apache.causeway.commons.internal.primitives;
exports org.apache.causeway.commons.internal.proxy;
@@ -67,6 +68,8 @@
requires transitive tools.jackson.core;
requires transitive tools.jackson.databind;
requires transitive tools.jackson.module.jakarta.xmlbind;
+ requires transitive micrometer.commons;
+ requires transitive micrometer.observation;
requires transitive org.jdom2;
requires transitive org.jspecify;
requires transitive org.jsoup;
diff --git a/commons/src/main/java/org/apache/causeway/commons/having/HasTypeSpecificAttributes.java b/commons/src/main/java/org/apache/causeway/commons/having/HasTypeSpecificAttributes.java
index 5e4216112d3..c4563b5445a 100644
--- a/commons/src/main/java/org/apache/causeway/commons/having/HasTypeSpecificAttributes.java
+++ b/commons/src/main/java/org/apache/causeway/commons/having/HasTypeSpecificAttributes.java
@@ -18,6 +18,7 @@
*/
package org.apache.causeway.commons.having;
+import java.util.Optional;
import java.util.function.Function;
public interface HasTypeSpecificAttributes {
@@ -31,6 +32,10 @@ public interface HasTypeSpecificAttributes {
/** get type specific attribute */
T getAttribute(Class type);
+ default Optional lookupAttribute(final Class type) {
+ return Optional.ofNullable(getAttribute(type));
+ }
+
/** remove type specific attribute */
void removeAttribute(Class> type);
diff --git a/commons/src/main/java/org/apache/causeway/commons/internal/observation/ObservationClosure.java b/commons/src/main/java/org/apache/causeway/commons/internal/observation/ObservationClosure.java
new file mode 100644
index 00000000000..28f64f1d763
--- /dev/null
+++ b/commons/src/main/java/org/apache/causeway/commons/internal/observation/ObservationClosure.java
@@ -0,0 +1,96 @@
+/*
+ * 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.causeway.commons.internal.observation;
+
+import java.util.function.Supplier;
+
+import org.jspecify.annotations.Nullable;
+
+import lombok.Getter;
+import lombok.experimental.Accessors;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.Observation.Scope;
+
+/**
+ * Helps if start and stop of an {@link Observation} happen in different code locations.
+ */
+@Getter @Accessors(fluent = true)
+public final class ObservationClosure implements AutoCloseable {
+
+ public static final KeyValue DISCARD_KEY = KeyValue.of("causeway.discard", "");
+
+ private Observation observation;
+ private Scope scope;
+
+ public ObservationClosure startAndOpenScope(final Observation observation) {
+ if(observation==null) return this;
+ this.observation = observation.start();
+ this.scope = observation.openScope();
+ return this;
+ }
+
+ @Override
+ public void close() {
+ if(observation==null) return;
+ if(scope!=null) {
+ this.scope.close();
+ this.scope = null;
+ }
+ observation.stop();
+ }
+
+ public void onError(final Exception ex) {
+ if(observation==null) return;
+ // scope lifecycle terminates before exception handling
+ if(scope!=null) {
+ this.scope.close();
+ this.scope = null;
+ }
+ observation.error(ex);
+ }
+
+ public ObservationClosure tag(final String key, @Nullable final Supplier extends Object> valueSupplier) {
+ if(observation==null || valueSupplier == null) return this;
+ try {
+ observation.highCardinalityKeyValue(key, "" + valueSupplier.get());
+ } catch (Exception e) {
+ observation.highCardinalityKeyValue(key, "EXCEPTION: " + e.getMessage());
+ }
+ return this;
+ }
+
+ public void discard() {
+ discard(this.observation);
+ close();
+ }
+
+ // -- UTILITY
+
+ /**
+ * Denies span export, in collaboration with a Spring registered {@link DiscardedSpanExportingPredicate}.
+ */
+ public static void discard(@Nullable final Observation obs) {
+ if(obs == null)
+ return;
+ obs.lowCardinalityKeyValue(DISCARD_KEY);
+ }
+
+}
diff --git a/core/config/pom.xml b/core/config/pom.xml
index fe7041fef7d..1df34128425 100644
--- a/core/config/pom.xml
+++ b/core/config/pom.xml
@@ -91,6 +91,11 @@
org.hibernate.validator
hibernate-validator
+
+
+ io.micrometer
+ micrometer-tracing
+