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 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 +