You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@causeway.apache.org by da...@apache.org on 2026/09/24 16:22:27 UTC
(causeway) 33/41: CAUSEWAY-4065: adds application-defined spans
This is an automated email from the ASF dual-hosted git repository.
danhaywood pushed a commit to branch CAUSEWAY-4066
in repository https://gitbox.apache.org/repos/asf/causeway.git
commit ea32b739d0f3af311bad82645bafbab58ea26dc8
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Thu Sep 24 14:10:52 2026 +0100
CAUSEWAY-4065: adds application-defined spans
---
adoc/micrometer-tracing-operations.adoc | 33 +++
api/applib/src/main/java/module-info.java | 1 +
.../services/span/ApplicationSpanService.java | 67 ++++++
.../applib/services/span/package-info.java | 23 ++
.../observation/CausewayObservationNaming.java | 51 +++++
.../observation/CausewayObservationNamingTest.java | 57 +++++
.../runtimeservices/src/main/java/module-info.java | 1 +
.../CausewayModuleCoreRuntimeServices.java | 2 +
.../executor/MemberExecutorServiceDefault.java | 20 +-
.../span/ApplicationSpanServiceDefault.java | 129 +++++++++++
.../executor/MemberExecutorServiceDefaultTest.java | 42 ++++
.../span/ApplicationSpanServiceDefaultTest.java | 243 +++++++++++++++++++++
.../changes/add-application-defined-spans/tasks.md | 34 +--
.../tracing/MicrometerTracingAgentFixture.java | 78 +++++--
.../MicrometerTracingCompatibilityTest.java | 69 +++++-
15 files changed, 812 insertions(+), 38 deletions(-)
diff --git a/adoc/micrometer-tracing-operations.adoc b/adoc/micrometer-tracing-operations.adoc
index e635546e7b5..4dd7e934550 100644
--- a/adoc/micrometer-tracing-operations.adoc
+++ b/adoc/micrometer-tracing-operations.adoc
@@ -283,10 +283,43 @@ Provide authentication through `OTEL_EXPORTER_OTLP_HEADERS` or the destination-s
Store header values, tokens, and other credentials as Container Apps secrets and expose them through secret-referenced environment variables rather than putting them in the image or repository.
See link:https://learn.microsoft.com/en-us/azure/container-apps/environment-variables[Manage environment variables on Azure Container Apps].
+== Define Application Spans
+
+Inject `ApplicationSpanService` when an application needs to make a synchronous operation or phase visible beneath the current Causeway span.
+The service exposes no Micrometer or OpenTelemetry type and does not create or configure a telemetry SDK or exporter.
+
+[source,java]
+----
+@Inject ApplicationSpanService applicationSpanService;
+
+Invoice result = applicationSpanService.call("calculate", () -> calculateInvoice());
+applicationSpanService.run("loadLines", () -> repository.loadLines());
+----
+
+The suffix identifies a static low-cardinality operation or phase and is appended with one plain space.
+It must be non-null, non-blank, and no longer than 46 characters.
+Do not put object identifiers, titles, bookmarks, arguments, record values, user identities, tenant identities, or other per-invocation data in the suffix.
+An invalid suffix is rejected before application work starts.
+
+Within a current member execution, a span is displayed as `<logical-type-name>#<member-logical-name> <suffix>`.
+The member identifier excludes parentheses and parameter signatures.
+Outside a current member execution, the fallback is `app <suffix>`.
+Causeway first removes the logical-type namespace and then truncates only the member-identity portion when necessary to keep the display name within 50 characters, so the complete application suffix is always retained.
+
+The stable observation name is `causeway.application.span`.
+The low-cardinality `causeway.application.span.suffix` attribute contains the suffix, and `causeway.member.id` contains the complete canonical member identifier when one is available.
+Nested calls naturally become children of the current Causeway or application span.
+
+`call` and `run` complete their span before returning and therefore do not support asynchronous work that outlives the call.
+Each supplied operation runs exactly once and returns its result unchanged.
+The original checked exception, runtime exception, or error is recorded on the span and escapes unchanged and unwrapped, without a declared checked exception on the service API.
+When Causeway observation is inactive, the same work, result, and exception behavior is retained without exporting a span.
+
== What Causeway Adds
With the `observation` profile active, Causeway adds these core semantic observation categories:
+* `causeway.application.span` covers an application-defined synchronous operation and uses a display name such as `demo.Customer#updateName load`;
* `causeway.root.interaction` covers one top-level Causeway interaction and retains that stable span display name;
* `causeway.action.invocation` covers an action invocation and uses a display name such as `act demo.Customer#updateName`;
* `causeway.property.access` and `causeway.collection.access` cover evaluation of mixed-in associations and use display names such as `prop demo.Customer#calculatedTotal` and `coll demo.Customer#recentOrders`.
diff --git a/api/applib/src/main/java/module-info.java b/api/applib/src/main/java/module-info.java
index 9c7dedf05ec..7599563c92e 100644
--- a/api/applib/src/main/java/module-info.java
+++ b/api/applib/src/main/java/module-info.java
@@ -101,6 +101,7 @@
exports org.apache.causeway.applib.services.scratchpad;
exports org.apache.causeway.applib.services.session;
exports org.apache.causeway.applib.services.sitemap;
+ exports org.apache.causeway.applib.services.span;
exports org.apache.causeway.applib.services.sudo;
exports org.apache.causeway.applib.services.swagger;
exports org.apache.causeway.applib.services.tablecol;
diff --git a/api/applib/src/main/java/org/apache/causeway/applib/services/span/ApplicationSpanService.java b/api/applib/src/main/java/org/apache/causeway/applib/services/span/ApplicationSpanService.java
new file mode 100644
index 00000000000..42b94a712d4
--- /dev/null
+++ b/api/applib/src/main/java/org/apache/causeway/applib/services/span/ApplicationSpanService.java
@@ -0,0 +1,67 @@
+/*
+ * 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.span;
+
+import java.util.concurrent.Callable;
+
+import org.apache.causeway.commons.functional.ThrowingRunnable;
+
+/**
+ * Executes synchronous application work within an application-defined child span.
+ *
+ * <p>
+ * The suffix must be non-null, non-blank, no longer than 46 characters, and
+ * identify a static operation or phase such as {@code load} or {@code process}.
+ * It must not contain object identifiers, titles, bookmarks, arguments, record
+ * values, user or tenant identities, or other per-invocation data.
+ * </p>
+ *
+ * <p>
+ * The service follows the exception convention of
+ * {@link org.apache.causeway.applib.services.iactnlayer.InteractionService}:
+ * checked exceptions are not declared, but the original checked exception,
+ * runtime exception, or error escapes unchanged and unwrapped.
+ * </p>
+ *
+ * <p>
+ * Spans are synchronous and cannot outlive a service call.
+ * When Causeway observation is inactive, supplied work still runs exactly once
+ * with unchanged result and exception behavior.
+ * </p>
+ *
+ * @since 2.2 {@index}
+ */
+public interface ApplicationSpanService {
+
+ /**
+ * Executes a value-returning operation within an application-defined span.
+ *
+ * @param suffix static operation or phase identifier, at most 46 characters
+ * @param callable work to execute exactly once
+ */
+ <T> T call(String suffix, Callable<T> callable);
+
+ /**
+ * Executes a non-returning operation within an application-defined span.
+ *
+ * @param suffix static operation or phase identifier, at most 46 characters
+ * @param runnable work to execute exactly once
+ */
+ void run(String suffix, ThrowingRunnable runnable);
+}
diff --git a/api/applib/src/main/java/org/apache/causeway/applib/services/span/package-info.java b/api/applib/src/main/java/org/apache/causeway/applib/services/span/package-info.java
new file mode 100644
index 00000000000..9fb450f05b6
--- /dev/null
+++ b/api/applib/src/main/java/org/apache/causeway/applib/services/span/package-info.java
@@ -0,0 +1,23 @@
+/*
+ * 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.
+ */
+/**
+ * Application-facing synchronous span operations that do not expose a
+ * telemetry SDK or registry.
+ */
+package org.apache.causeway.applib.services.span;
diff --git a/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationNaming.java b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationNaming.java
index 4cd8a29c06d..874d8e7bd8c 100644
--- a/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationNaming.java
+++ b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationNaming.java
@@ -20,6 +20,8 @@
import java.util.Objects;
+import org.springframework.lang.Nullable;
+
/**
* Creates compact, bounded-cardinality contextual names for semantic observations.
*
@@ -28,6 +30,7 @@
public final class CausewayObservationNaming {
public static final int MAX_CONTEXTUAL_NAME_LENGTH = 50;
+ public static final int MAX_APPLICATION_SPAN_SUFFIX_LENGTH = 46;
private CausewayObservationNaming() {}
@@ -71,6 +74,35 @@ public static String forLogicalMember(
return bounded(prefix + simpleTypeName(typeName) + "#" + memberName);
}
+ /**
+ * Builds an application span name while preserving the complete suffix.
+ */
+ public static String forApplicationSpan(
+ final @Nullable String logicalMemberIdentifier,
+ final String suffix) {
+ final String validatedSuffix = requireApplicationSpanSuffix(suffix);
+ final String identifier = logicalMemberIdentifier != null
+ ? requireText(logicalMemberIdentifier, "logicalMemberIdentifier")
+ : "app";
+ final String fullName = identifier + " " + validatedSuffix;
+ if(fullName.length() <= MAX_CONTEXTUAL_NAME_LENGTH) {
+ return fullName;
+ }
+
+ final String compactIdentifier = compactLogicalMemberIdentifier(identifier);
+ final String compactName = compactIdentifier + " " + validatedSuffix;
+ if(compactName.length() <= MAX_CONTEXTUAL_NAME_LENGTH) {
+ return compactName;
+ }
+
+ final int identifierLength = MAX_CONTEXTUAL_NAME_LENGTH
+ - 1
+ - validatedSuffix.length();
+ return compactIdentifier.substring(0, identifierLength)
+ + " "
+ + validatedSuffix;
+ }
+
public static String forRenderRegion(
final String region,
final String identifier) {
@@ -93,6 +125,25 @@ public static String bounded(final String contextualName) {
: name.substring(0, MAX_CONTEXTUAL_NAME_LENGTH);
}
+ private static String compactLogicalMemberIdentifier(final String identifier) {
+ final int memberSeparator = identifier.indexOf('#');
+ if(memberSeparator < 1 || memberSeparator + 1 >= identifier.length()) {
+ return identifier;
+ }
+ return simpleTypeName(identifier.substring(0, memberSeparator))
+ + identifier.substring(memberSeparator);
+ }
+
+ private static String requireApplicationSpanSuffix(final String suffix) {
+ final String validated = requireText(suffix, "suffix");
+ if(validated.length() > MAX_APPLICATION_SPAN_SUFFIX_LENGTH) {
+ throw new IllegalArgumentException(String.format(
+ "suffix must be at most %d characters",
+ MAX_APPLICATION_SPAN_SUFFIX_LENGTH));
+ }
+ return validated;
+ }
+
private static String regionIdentifier(final String identifier) {
final String canonicalIdentifier = requireText(identifier, "identifier");
if("<default>".equals(canonicalIdentifier)) {
diff --git a/core/config/src/test/java/org/apache/causeway/core/config/observation/CausewayObservationNamingTest.java b/core/config/src/test/java/org/apache/causeway/core/config/observation/CausewayObservationNamingTest.java
index bfd4ebeabf2..fc55aaf6a6e 100644
--- a/core/config/src/test/java/org/apache/causeway/core/config/observation/CausewayObservationNamingTest.java
+++ b/core/config/src/test/java/org/apache/causeway/core/config/observation/CausewayObservationNamingTest.java
@@ -21,6 +21,7 @@
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CausewayObservationNamingTest {
@@ -79,6 +80,62 @@ void exactlyFiftyCharactersAreNotAltered() {
CausewayObservationNaming.bounded(contextualName));
}
+ @Test
+ void applicationSpanNameUsesCanonicalMemberIdentifier() {
+ assertEquals("demo.Customer#updateName load",
+ CausewayObservationNaming.forApplicationSpan(
+ "demo.Customer#updateName", "load"));
+ assertEquals("demo.Customer#updateName LoadURL",
+ CausewayObservationNaming.forApplicationSpan(
+ "demo.Customer#updateName", "LoadURL"));
+ }
+
+ @Test
+ void applicationSpanNameFallsBackToAppWithoutCurrentMember() {
+ assertEquals("app load",
+ CausewayObservationNaming.forApplicationSpan(null, "load"));
+ }
+
+ @Test
+ void applicationSpanNameCompactsNamespaceBeforeTruncating() {
+ assertEquals("Customer#updateName load",
+ CausewayObservationNaming.forApplicationSpan(
+ "aVeryLongApplicationNamespaceThatExceedsTheLimit.Customer#updateName",
+ "load"));
+ }
+
+ @Test
+ void applicationSpanNameTruncatesOnlyMemberIdentity() {
+ final String contextualName = CausewayObservationNaming.forApplicationSpan(
+ "demo.CustomerWithAnUnusuallyLongLogicalTypeName#performAnUnusuallyLongOperation",
+ "processBatch");
+
+ assertEquals(CausewayObservationNaming.MAX_CONTEXTUAL_NAME_LENGTH,
+ contextualName.length());
+ assertTrue(contextualName.endsWith(" processBatch"));
+ }
+
+ @Test
+ void applicationSpanFallbackFitsAtMaximumSuffixLength() {
+ final String suffix = "x".repeat(
+ CausewayObservationNaming.MAX_APPLICATION_SPAN_SUFFIX_LENGTH);
+
+ assertEquals("app " + suffix,
+ CausewayObservationNaming.forApplicationSpan(null, suffix));
+ assertEquals(CausewayObservationNaming.MAX_CONTEXTUAL_NAME_LENGTH,
+ CausewayObservationNaming.forApplicationSpan(null, suffix).length());
+ }
+
+ @Test
+ void applicationSpanSuffixMustBeNonBlankAndAtMostFortySixCharacters() {
+ assertThrows(NullPointerException.class,
+ () -> CausewayObservationNaming.forApplicationSpan(null, null));
+ assertThrows(IllegalArgumentException.class,
+ () -> CausewayObservationNaming.forApplicationSpan(null, " \t"));
+ assertThrows(IllegalArgumentException.class,
+ () -> CausewayObservationNaming.forApplicationSpan(null, "x".repeat(47)));
+ }
+
@Test
void renderRegionsUseMemberIdsAndDefaultFieldsetName() {
assertEquals("render fieldset default",
diff --git a/core/runtimeservices/src/main/java/module-info.java b/core/runtimeservices/src/main/java/module-info.java
index e8a87d7f84e..f2dccff959f 100644
--- a/core/runtimeservices/src/main/java/module-info.java
+++ b/core/runtimeservices/src/main/java/module-info.java
@@ -41,6 +41,7 @@
exports org.apache.causeway.core.runtimeservices.serializing;
exports org.apache.causeway.core.runtimeservices.session;
exports org.apache.causeway.core.runtimeservices.sitemap;
+ exports org.apache.causeway.core.runtimeservices.span;
exports org.apache.causeway.core.runtimeservices.spring;
exports org.apache.causeway.core.runtimeservices.transaction;
exports org.apache.causeway.core.runtimeservices.urlencoding;
diff --git a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/CausewayModuleCoreRuntimeServices.java b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/CausewayModuleCoreRuntimeServices.java
index b90d5898bcf..fc1b40814ff 100644
--- a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/CausewayModuleCoreRuntimeServices.java
+++ b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/CausewayModuleCoreRuntimeServices.java
@@ -63,6 +63,7 @@
import org.apache.causeway.core.runtimeservices.session.InteractionIdGeneratorDefault;
import org.apache.causeway.core.runtimeservices.session.InteractionServiceDefault;
import org.apache.causeway.core.runtimeservices.sitemap.SitemapServiceDefault;
+import org.apache.causeway.core.runtimeservices.span.ApplicationSpanServiceDefault;
import org.apache.causeway.core.runtimeservices.spring.SpringBeansService;
import org.apache.causeway.core.runtimeservices.transaction.TransactionServiceSpring;
import org.apache.causeway.core.runtimeservices.urlencoding.UrlEncodingServiceWithCompression;
@@ -80,6 +81,7 @@
CausewayModuleCoreCodegenByteBuddy.class,
// @Service's
+ ApplicationSpanServiceDefault.class,
BookmarkServiceDefault.class,
CommandDtoFactoryDefault.class,
CommandExecutorServiceDefault.class,
diff --git a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/executor/MemberExecutorServiceDefault.java b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/executor/MemberExecutorServiceDefault.java
index e307a331192..317845e289b 100644
--- a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/executor/MemberExecutorServiceDefault.java
+++ b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/executor/MemberExecutorServiceDefault.java
@@ -32,6 +32,7 @@
import io.micrometer.observation.Observation;
+import org.apache.causeway.applib.Identifier;
import org.apache.causeway.applib.annotation.PriorityPrecedence;
import org.apache.causeway.applib.services.clock.ClockService;
import org.apache.causeway.applib.services.command.Command;
@@ -65,6 +66,7 @@
import org.apache.causeway.core.metamodel.facets.actions.action.invocation.IdentifierUtil;
import org.apache.causeway.core.metamodel.facets.members.publish.execution.ExecutionPublishingFacet;
import org.apache.causeway.core.metamodel.interactions.InteractionHead;
+import org.apache.causeway.core.metamodel.interactions.managed.ActionInteractionHead;
import org.apache.causeway.core.metamodel.object.ManagedObject;
import org.apache.causeway.core.metamodel.object.ManagedObjects;
import org.apache.causeway.core.metamodel.object.MmEntityUtils;
@@ -243,7 +245,11 @@ private ManagedObject invokeActionInternally(
? RuleChecking.CHECKED
: RuleChecking.SKIPPED;
val actionInvocation = new ActionInvocation(
- interaction, actionId, targetPojo, argumentPojos, ruleChecking);
+ interaction,
+ domainFacingActionIdentifier(head, owningAction),
+ targetPojo,
+ argumentPojos,
+ ruleChecking);
// sets up startedAt and completedAt on the execution, also manages the execution call graph
interaction.execute(actionExecutor, actionInvocation, InteractionInternal.Context.of(clockService, metricsService(), commandPublisherProvider.get(), deadlockRecognizer));
@@ -427,6 +433,18 @@ private void setCommandResultIfEntityScalar(
.ifPresent(bookmark -> command.updater().setResult(Try.success(bookmark)));
}
+ static Identifier domainFacingActionIdentifier(
+ final InteractionHead head,
+ final ObjectAction owningAction) {
+ if(owningAction.isDeclaredOnMixin()
+ && head instanceof ActionInteractionHead) {
+ return ((ActionInteractionHead) head)
+ .getMetaModel()
+ .getFeatureIdentifier();
+ }
+ return owningAction.getFeatureIdentifier();
+ }
+
static void nominateSemanticTraceActionIfEligible(
final boolean mixedInAssociation,
final String commandLogicalMemberIdentifier,
diff --git a/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/span/ApplicationSpanServiceDefault.java b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/span/ApplicationSpanServiceDefault.java
new file mode 100644
index 00000000000..b28b655c261
--- /dev/null
+++ b/core/runtimeservices/src/main/java/org/apache/causeway/core/runtimeservices/span/ApplicationSpanServiceDefault.java
@@ -0,0 +1,129 @@
+/*
+ * 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.core.runtimeservices.span;
+
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+
+import javax.annotation.Priority;
+import javax.inject.Inject;
+import javax.inject.Named;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+
+import io.micrometer.observation.Observation;
+
+import org.apache.causeway.applib.Identifier;
+import org.apache.causeway.applib.annotation.PriorityPrecedence;
+import org.apache.causeway.applib.services.iactn.Execution;
+import org.apache.causeway.applib.services.iactn.Interaction;
+import org.apache.causeway.applib.services.iactnlayer.InteractionService;
+import org.apache.causeway.applib.services.span.ApplicationSpanService;
+import org.apache.causeway.commons.functional.ThrowingRunnable;
+import org.apache.causeway.core.config.observation.CausewayObservationIntegration;
+import org.apache.causeway.core.config.observation.CausewayObservationIntegration.ObservationProvider;
+import org.apache.causeway.core.config.observation.CausewayObservationNaming;
+import org.apache.causeway.core.config.observation.ObservationClosure;
+import org.apache.causeway.core.runtimeservices.CausewayModuleCoreRuntimeServices;
+
+import lombok.SneakyThrows;
+
+/**
+ * Default implementation of {@link ApplicationSpanService}.
+ *
+ * @since 2.2
+ */
+@Service
+@Named(CausewayModuleCoreRuntimeServices.NAMESPACE + ".ApplicationSpanServiceDefault")
+@Priority(PriorityPrecedence.MIDPOINT)
+@Qualifier("Default")
+public final class ApplicationSpanServiceDefault implements ApplicationSpanService {
+
+ public static final String OBSERVATION_NAME = "causeway.application.span";
+ public static final String MEMBER_ID_ATTRIBUTE = "causeway.member.id";
+ public static final String SUFFIX_ATTRIBUTE = "causeway.application.span.suffix";
+
+ private final InteractionService interactionService;
+ private final ObservationProvider observationProvider;
+
+ @Inject
+ public ApplicationSpanServiceDefault(
+ final InteractionService interactionService,
+ final CausewayObservationIntegration observationIntegration) {
+ this.interactionService = interactionService;
+ this.observationProvider = observationIntegration.provider(
+ getClass(),
+ CausewayObservationIntegration.withModuleName(
+ CausewayModuleCoreRuntimeServices.NAMESPACE));
+ }
+
+ @Override
+ @SneakyThrows
+ public <T> T call(final String suffix, final Callable<T> callable) {
+ final Optional<String> memberIdentifier = currentLogicalMemberIdentifier();
+ final String contextualName = CausewayObservationNaming.forApplicationSpan(
+ memberIdentifier.orElse(null),
+ suffix);
+ Objects.requireNonNull(callable, "callable");
+
+ Observation observation = observationProvider.get(OBSERVATION_NAME)
+ .contextualName(contextualName)
+ .lowCardinalityKeyValue(SUFFIX_ATTRIBUTE, suffix);
+ if(memberIdentifier.isPresent()) {
+ observation = observation.lowCardinalityKeyValue(
+ MEMBER_ID_ATTRIBUTE,
+ memberIdentifier.get());
+ }
+
+ final ObservationClosure closure = new ObservationClosure();
+ try {
+ closure.startAndOpenScope(observation);
+ return callable.call();
+ } catch (Throwable failure) {
+ closure.onError(failure);
+ throw failure;
+ } finally {
+ closure.close();
+ }
+ }
+
+ @Override
+ public void run(final String suffix, final ThrowingRunnable runnable) {
+ Objects.requireNonNull(runnable, "runnable");
+ call(suffix, () -> {
+ runnable.run();
+ return null;
+ });
+ }
+
+ private Optional<String> currentLogicalMemberIdentifier() {
+ return interactionService.currentInteraction()
+ .map(Interaction::getCurrentExecution)
+ .map(Execution::getLogicalMemberIdentifier)
+ .map(ApplicationSpanServiceDefault::canonicalIdentifier);
+ }
+
+ private static String canonicalIdentifier(final Identifier identifier) {
+ return identifier.logicalTypeName()
+ + "#"
+ + identifier.memberLogicalName();
+ }
+}
diff --git a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/executor/MemberExecutorServiceDefaultTest.java b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/executor/MemberExecutorServiceDefaultTest.java
index 008d35ca68c..385a7d13d28 100644
--- a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/executor/MemberExecutorServiceDefaultTest.java
+++ b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/executor/MemberExecutorServiceDefaultTest.java
@@ -30,6 +30,8 @@
import org.junit.jupiter.api.Test;
+import org.apache.causeway.applib.Identifier;
+import org.apache.causeway.applib.id.LogicalType;
import org.apache.causeway.applib.services.bookmark.Bookmark;
import org.apache.causeway.applib.services.command.Command;
import org.apache.causeway.applib.services.command.CommandRecordingSuppressed;
@@ -40,9 +42,11 @@
import io.micrometer.observation.ObservationRegistry;
import org.apache.causeway.core.metamodel.interactions.InteractionHead;
+import org.apache.causeway.core.metamodel.interactions.managed.ActionInteractionHead;
import org.apache.causeway.core.metamodel.object.ManagedObject;
import org.apache.causeway.core.metamodel.object.PackedManagedObject;
import org.apache.causeway.core.metamodel.services.publishing.CommandPublisher;
+import org.apache.causeway.core.metamodel.spec.feature.ObjectAction;
import org.apache.causeway.core.metamodel.spec.feature.ObjectMember;
class MemberExecutorServiceDefaultTest {
@@ -88,6 +92,36 @@ void prepare_command_for_publishing_does_nothing_for_suppressed_owner() {
verifyNoInteractions(commandPublisherProvider);
}
+ @Test
+ void mixedInActionInvocationUsesDomainFacingIdentifier() {
+ final ObjectAction owningAction = mock(ObjectAction.class);
+ final ObjectAction domainFacingAction = mock(ObjectAction.class);
+ final ActionInteractionHead head = mock(ActionInteractionHead.class);
+ final Identifier implementationIdentifier = actionIdentifier(
+ "demo.Customer_updateName", "act");
+ final Identifier domainIdentifier = actionIdentifier(
+ "demo.Customer", "updateName");
+ when(owningAction.isDeclaredOnMixin()).thenReturn(true);
+ when(owningAction.getFeatureIdentifier()).thenReturn(implementationIdentifier);
+ when(head.getMetaModel()).thenReturn(domainFacingAction);
+ when(domainFacingAction.getFeatureIdentifier()).thenReturn(domainIdentifier);
+
+ assertThat(MemberExecutorServiceDefault.domainFacingActionIdentifier(
+ head, owningAction)).isSameAs(domainIdentifier);
+ }
+
+ @Test
+ void ordinaryActionInvocationRetainsOwningActionIdentifier() {
+ final ObjectAction owningAction = mock(ObjectAction.class);
+ final InteractionHead head = mock(InteractionHead.class);
+ final Identifier identifier = actionIdentifier("demo.Customer", "updateName");
+ when(owningAction.isDeclaredOnMixin()).thenReturn(false);
+ when(owningAction.getFeatureIdentifier()).thenReturn(identifier);
+
+ assertThat(MemberExecutorServiceDefault.domainFacingActionIdentifier(
+ head, owningAction)).isSameAs(identifier);
+ }
+
@Test
void set_command_result_records_scalar_bookmarkable_entity() {
var service = newService();
@@ -166,6 +200,14 @@ private Bookmark bookmark(final String identifier) {
return Bookmark.forLogicalTypeNameAndIdentifier("demo.Customer", identifier);
}
+ private Identifier actionIdentifier(
+ final String logicalTypeName,
+ final String actionName) {
+ return Identifier.actionIdentifier(
+ LogicalType.eager(Object.class, logicalTypeName),
+ actionName);
+ }
+
private ManagedObject entityAdapter(final Bookmark bookmark) {
var adapter = mock(ManagedObject.class);
when(adapter.getSpecialization()).thenReturn(ManagedObject.Specialization.ENTITY);
diff --git a/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/span/ApplicationSpanServiceDefaultTest.java b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/span/ApplicationSpanServiceDefaultTest.java
new file mode 100644
index 00000000000..c5646b28185
--- /dev/null
+++ b/core/runtimeservices/src/test/java/org/apache/causeway/core/runtimeservices/span/ApplicationSpanServiceDefaultTest.java
@@ -0,0 +1,243 @@
+/*
+ * 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.core.runtimeservices.span;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationHandler;
+import io.micrometer.observation.ObservationRegistry;
+
+import org.apache.causeway.applib.Identifier;
+import org.apache.causeway.applib.id.LogicalType;
+import org.apache.causeway.applib.services.iactn.Execution;
+import org.apache.causeway.applib.services.iactn.Interaction;
+import org.apache.causeway.applib.services.iactnlayer.InteractionService;
+import org.apache.causeway.core.config.observation.CausewayObservationIntegration;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ApplicationSpanServiceDefaultTest {
+
+ @Test
+ void callUsesStableNameContextualIdentityAndLowCardinalityAttributes() {
+ final RecordingHandler handler = new RecordingHandler();
+ final ObservationRegistry registry = registryWith(handler);
+ final ApplicationSpanServiceDefault service = serviceWith(
+ currentInteraction("demo.Customer", "updateName"),
+ registry);
+ final AtomicInteger calls = new AtomicInteger();
+
+ assertEquals("result", service.call("load", () -> {
+ calls.incrementAndGet();
+ return "result";
+ }));
+ assertEquals(1, calls.get());
+ assertNull(registry.getCurrentObservation());
+
+ final Observation.Context context = handler.stopped.get(0);
+ assertEquals(ApplicationSpanServiceDefault.OBSERVATION_NAME, context.getName());
+ assertEquals("demo.Customer#updateName load", context.getContextualName());
+ assertEquals("demo.Customer#updateName",
+ value(context, ApplicationSpanServiceDefault.MEMBER_ID_ATTRIBUTE));
+ assertEquals("load",
+ value(context, ApplicationSpanServiceDefault.SUFFIX_ATTRIBUTE));
+ assertTrue(context.getContextualName().indexOf('(') < 0);
+ }
+
+ @Test
+ void callFallsBackToAppNameWithoutCurrentExecution() {
+ final RecordingHandler handler = new RecordingHandler();
+ final ApplicationSpanServiceDefault service = serviceWith(
+ noCurrentInteraction(),
+ registryWith(handler));
+
+ service.call("load", () -> "done");
+
+ final Observation.Context context = handler.stopped.get(0);
+ assertEquals("app load", context.getContextualName());
+ assertEquals(null,
+ value(context, ApplicationSpanServiceDefault.MEMBER_ID_ATTRIBUTE));
+ }
+
+ @Test
+ void invalidSuffixIsRejectedBeforeApplicationWork() {
+ final AtomicInteger calls = new AtomicInteger();
+ final ApplicationSpanServiceDefault service = serviceWith(
+ noCurrentInteraction(),
+ ObservationRegistry.NOOP);
+
+ assertThrows(NullPointerException.class,
+ () -> service.call(null, () -> calls.incrementAndGet()));
+ assertThrows(IllegalArgumentException.class,
+ () -> service.call(" ", () -> calls.incrementAndGet()));
+ assertThrows(IllegalArgumentException.class,
+ () -> service.call("x".repeat(47), () -> calls.incrementAndGet()));
+ assertEquals(0, calls.get());
+ }
+
+ @Test
+ void originalCheckedExceptionEscapesUnchangedAndIsRecorded() {
+ final RecordingHandler handler = new RecordingHandler();
+ final ObservationRegistry registry = registryWith(handler);
+ final ApplicationSpanServiceDefault service = serviceWith(
+ noCurrentInteraction(),
+ registry);
+ final CheckedFailure failure = new CheckedFailure("expected");
+
+ final CheckedFailure thrown = assertThrows(CheckedFailure.class,
+ () -> service.call("fail", () -> {
+ throw failure;
+ }));
+
+ assertSame(failure, thrown);
+ assertSame(failure, handler.stopped.get(0).getError());
+ assertNull(registry.getCurrentObservation());
+ }
+
+ @Test
+ void uncheckedExceptionsAndErrorsEscapeUnchanged() {
+ final ApplicationSpanServiceDefault service = serviceWith(
+ noCurrentInteraction(),
+ ObservationRegistry.NOOP);
+ final IllegalStateException runtimeFailure =
+ new IllegalStateException("runtime");
+ final AssertionError error = new AssertionError("error");
+
+ assertSame(runtimeFailure, assertThrows(IllegalStateException.class,
+ () -> service.call("runtime", () -> {
+ throw runtimeFailure;
+ })));
+ assertSame(error, assertThrows(AssertionError.class,
+ () -> service.run("error", () -> {
+ throw error;
+ })));
+ }
+
+ @Test
+ void runAndNoopRegistryExecuteWorkExactlyOnce() {
+ final AtomicInteger calls = new AtomicInteger();
+ final ApplicationSpanServiceDefault service = serviceWith(
+ noCurrentInteraction(),
+ ObservationRegistry.NOOP);
+
+ service.run("process", calls::incrementAndGet);
+
+ assertEquals(1, calls.get());
+ }
+
+ @Test
+ void nestedCallsUseTheCurrentApplicationSpanAsParent() {
+ final RecordingHandler handler = new RecordingHandler();
+ final ApplicationSpanServiceDefault service = serviceWith(
+ noCurrentInteraction(),
+ registryWith(handler));
+
+ service.run("outer", () -> service.run("inner", () -> {}));
+
+ assertEquals(List.of("app outer <- <root>", "app inner <- app outer"),
+ handler.started);
+ }
+
+ private static InteractionService currentInteraction(
+ final String logicalTypeName,
+ final String memberLogicalName) {
+ final InteractionService interactionService = mock(InteractionService.class);
+ final Interaction interaction = mock(Interaction.class);
+ final Execution<?, ?> execution = mock(Execution.class);
+ final Identifier identifier = Identifier.actionIdentifier(
+ LogicalType.eager(Object.class, logicalTypeName),
+ memberLogicalName);
+ when(interactionService.currentInteraction()).thenReturn(Optional.of(interaction));
+ doReturn(execution).when(interaction).getCurrentExecution();
+ when(execution.getLogicalMemberIdentifier()).thenReturn(identifier);
+ return interactionService;
+ }
+
+ private static InteractionService noCurrentInteraction() {
+ final InteractionService interactionService = mock(InteractionService.class);
+ when(interactionService.currentInteraction()).thenReturn(Optional.empty());
+ return interactionService;
+ }
+
+ private static ApplicationSpanServiceDefault serviceWith(
+ final InteractionService interactionService,
+ final ObservationRegistry registry) {
+ return new ApplicationSpanServiceDefault(
+ interactionService,
+ new CausewayObservationIntegration(registry));
+ }
+
+ private static ObservationRegistry registryWith(final RecordingHandler handler) {
+ final ObservationRegistry registry = ObservationRegistry.create();
+ registry.observationConfig().observationHandler(handler);
+ return registry;
+ }
+
+ private static String value(final Observation.Context context, final String key) {
+ final KeyValue keyValue = context.getLowCardinalityKeyValue(key);
+ return keyValue != null ? keyValue.getValue() : null;
+ }
+
+ private static final class RecordingHandler
+ implements ObservationHandler<Observation.Context> {
+
+ private final List<String> started = new ArrayList<>();
+ private final List<Observation.Context> stopped = new ArrayList<>();
+
+ @Override
+ public void onStart(final Observation.Context context) {
+ final String parentName = context.getParentObservation() != null
+ ? context.getParentObservation().getContextView().getContextualName()
+ : "<root>";
+ started.add(context.getContextualName() + " <- " + parentName);
+ }
+
+ @Override
+ public void onStop(final Observation.Context context) {
+ stopped.add(context);
+ }
+
+ @Override
+ public boolean supportsContext(final Observation.Context context) {
+ return true;
+ }
+ }
+
+ private static final class CheckedFailure extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ private CheckedFailure(final String message) {
+ super(message);
+ }
+ }
+}
diff --git a/openspec/changes/add-application-defined-spans/tasks.md b/openspec/changes/add-application-defined-spans/tasks.md
index bf94bf028f0..0c3bc14beff 100644
--- a/openspec/changes/add-application-defined-spans/tasks.md
+++ b/openspec/changes/add-application-defined-spans/tasks.md
@@ -1,28 +1,28 @@
## 1. Public API
-- [ ] 1.1 Add `org.apache.causeway.applib.services.span.ApplicationSpanService` with synchronous `call(String, Callable<T>)` and `run(String, ThrowingRunnable)` operations that do not expose telemetry implementation types or declare checked exceptions.
-- [ ] 1.2 Document the 46-character suffix limit, static low-cardinality naming expectations, unchanged sneaky-throw exception semantics, inactive behavior, and the exclusion of asynchronous span lifecycles.
-- [ ] 1.3 Add the new service package to applib documentation and module exports where required by the existing module structure.
+- [x] 1.1 Add `org.apache.causeway.applib.services.span.ApplicationSpanService` with synchronous `call(String, Callable<T>)` and `run(String, ThrowingRunnable)` operations that do not expose telemetry implementation types or declare checked exceptions.
+- [x] 1.2 Document the 46-character suffix limit, static low-cardinality naming expectations, unchanged sneaky-throw exception semantics, inactive behavior, and the exclusion of asynchronous span lifecycles.
+- [x] 1.3 Add the new service package to applib documentation and module exports where required by the existing module structure.
## 2. Naming and Runtime Implementation
-- [ ] 2.1 Correct `ActionInvocation` construction so declared and contributed mixin actions expose their domain-facing `Identifier`, while retaining the implementation identifier for invocation internals.
-- [ ] 2.2 Extend observation naming support with a 50-character application-span builder that formats canonical identities without action signatures, tries the full identity, then its namespace-free form, and finally truncates only the identity while preserving the complete suffix.
-- [ ] 2.3 Validate null, blank, and longer-than-46-character suffixes before supplied application work is invoked.
-- [ ] 2.4 Implement the runtime service using `InteractionService.currentInteraction()`, the current execution's domain-facing logical member identifier, and the existing `CausewayObservationIntegration` registry.
-- [ ] 2.5 Configure each application observation with stable name `causeway.application.span`, the suffix-preserving contextual name using one plain-space separator, `causeway.member.id` when available, and `causeway.application.span.suffix`.
-- [ ] 2.6 Register the implementation in runtime service composition and ensure nested spans use the current observation scope without adding an SDK, registry, sampler, or exporter.
+- [x] 2.1 Correct `ActionInvocation` construction so declared and contributed mixin actions expose their domain-facing `Identifier`, while retaining the implementation identifier for invocation internals.
+- [x] 2.2 Extend observation naming support with a 50-character application-span builder that formats canonical identities without action signatures, tries the full identity, then its namespace-free form, and finally truncates only the identity while preserving the complete suffix.
+- [x] 2.3 Validate null, blank, and longer-than-46-character suffixes before supplied application work is invoked.
+- [x] 2.4 Implement the runtime service using `InteractionService.currentInteraction()`, the current execution's domain-facing logical member identifier, and the existing `CausewayObservationIntegration` registry.
+- [x] 2.5 Configure each application observation with stable name `causeway.application.span`, the suffix-preserving contextual name using one plain-space separator, `causeway.member.id` when available, and `causeway.application.span.suffix`.
+- [x] 2.6 Register the implementation in runtime service composition and ensure nested spans use the current observation scope without adding an SDK, registry, sampler, or exporter.
## 3. Unit and Integration Tests
-- [ ] 3.1 Add naming tests for signature-free canonical identifiers, namespace compaction, suffix-preserving identifier truncation, fallback `app <suffix>` names, 46-character suffixes, exact 50-character boundaries, casing, and invalid suffixes.
-- [ ] 3.2 Add service tests proving callable return values, exactly-once runnable and callable execution, unchanged checked and unchecked failure behavior, error reporting, scope cleanup, and no-op registry behavior.
-- [ ] 3.3 Add interaction tests proving domain-facing `ActionInvocation` identity for declared and mixin actions, DTO and publishing compatibility, nested wrapper invocations, absent current executions, canonical metadata, and nested application-span parentage.
-- [ ] 3.4 Extend the tracing compatibility regression to assert that an exported application-defined span is a child of its action span, carries the expected stable metadata, and preserves its suffix.
-- [ ] 3.5 Verify the no-agent tracing regression still starts successfully and does not create an application-owned OpenTelemetry SDK or exporter.
+- [x] 3.1 Add naming tests for signature-free canonical identifiers, namespace compaction, suffix-preserving identifier truncation, fallback `app <suffix>` names, 46-character suffixes, exact 50-character boundaries, casing, and invalid suffixes.
+- [x] 3.2 Add service tests proving callable return values, exactly-once runnable and callable execution, unchanged checked and unchecked failure behavior, error reporting, scope cleanup, and no-op registry behavior.
+- [x] 3.3 Add interaction tests proving domain-facing `ActionInvocation` identity for declared and mixin actions, DTO and publishing compatibility, nested wrapper invocations, absent current executions, canonical metadata, and nested application-span parentage.
+- [x] 3.4 Extend the tracing compatibility regression to assert that an exported application-defined span is a child of its action span, carries the expected stable metadata, and preserves its suffix.
+- [x] 3.5 Verify the no-agent tracing regression still starts successfully and does not create an application-owned OpenTelemetry SDK or exporter.
## 4. Documentation and Verification
-- [ ] 4.1 Update the tracing operations guide with injection and usage examples, naming examples, profile behavior, cardinality guidance, and the prohibition on instance-specific suffixes.
-- [ ] 4.2 Run focused applib, core configuration, runtime services, and tracing compatibility tests on the supported Java and Maven toolchain.
-- [ ] 4.3 Run strict OpenSpec validation and confirm all application-defined-span scenarios are covered by automated tests or explicit documentation checks.
+- [x] 4.1 Update the tracing operations guide with injection and usage examples, naming examples, profile behavior, cardinality guidance, and the prohibition on instance-specific suffixes.
+- [x] 4.2 Run focused applib, core configuration, runtime services, and tracing compatibility tests on the supported Java and Maven toolchain.
+- [x] 4.3 Run strict OpenSpec validation and confirm all application-defined-span scenarios are covered by automated tests or explicit documentation checks.
diff --git a/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingAgentFixture.java b/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingAgentFixture.java
index ad982f1da89..cea70b0c752 100644
--- a/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingAgentFixture.java
+++ b/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingAgentFixture.java
@@ -47,6 +47,7 @@
import org.apache.causeway.applib.services.clock.ClockService;
import org.apache.causeway.applib.services.iactnlayer.InteractionContext;
import org.apache.causeway.applib.services.inject.ServiceInjector;
+import org.apache.causeway.applib.services.span.ApplicationSpanService;
import org.apache.causeway.applib.services.xactn.TransactionState;
import org.apache.causeway.commons.collections.Can;
import org.apache.causeway.commons.internal.reflection._MethodFacades;
@@ -72,6 +73,7 @@
import org.apache.causeway.core.runtimeservices.executor.MemberExecutorServiceDefault;
import org.apache.causeway.core.runtimeservices.session.InteractionIdGenerator;
import org.apache.causeway.core.runtimeservices.session.InteractionServiceDefault;
+import org.apache.causeway.core.runtimeservices.span.ApplicationSpanServiceDefault;
import org.apache.causeway.core.runtimeservices.transaction.TransactionServiceSpring;
import org.apache.causeway.core.webapp.modules.observation.CausewayForegroundTraceFilter;
import org.apache.causeway.viewer.wicket.ui.observation.WicketRenderObservationDescriptor;
@@ -119,6 +121,10 @@ public final class MicrometerTracingAgentFixture {
static final String PROMPT_RENDER_NAME =
"prompt causeway.TracingFixture#executeJdbc";
static final String VIEW_TRACE_NAME = "view causeway.TracingFixture";
+ static final String APPLICATION_ACTION_NAME = "app jdbcWork";
+ static final String APPLICATION_OUTER_NAME = "app outer";
+ static final String APPLICATION_INNER_NAME = "app inner";
+ static final String APPLICATION_FAILURE_NAME = "app failure";
static final String ACTION_ID = "causeway.TracingFixture#executeJdbc()";
static final String OBJECT_TYPE = "causeway.TracingFixture";
static final String COLLECTION_ID = OBJECT_TYPE + "#roles";
@@ -143,6 +149,8 @@ public static void main(final String[] args) throws Exception {
executeHttpRequest(context, "/trace", 204);
executeHttpRequest(context, "/trace/prompt", 204);
executeHttpRequest(context, "/trace/view", 204);
+ executeHttpRequest(context, "/trace/application", 204);
+ executeHttpRequest(context, "/trace/application/failure", 500);
executeHttpRequest(context, "/trace/unsupported", 204);
executeHttpRequest(context, "/trace/failure", 500);
System.out.println(SUCCESS_MARKER);
@@ -171,6 +179,7 @@ public static final class SemanticTracingController {
private final CausewayObservationIntegration observationIntegration;
private final InteractionServiceDefault interactionService;
private final MemberExecutorServiceDefault memberExecutorService;
+ private final ApplicationSpanService applicationSpanService;
private final ActionExecutor actionExecutor;
public SemanticTracingController(
@@ -178,7 +187,11 @@ public SemanticTracingController(
this.observationIntegration = observationIntegration;
this.interactionService = interactionService(observationIntegration);
this.memberExecutorService = memberExecutorService(observationIntegration);
- this.actionExecutor = actionExecutor(this::completeEntityChanges);
+ this.applicationSpanService = new ApplicationSpanServiceDefault(
+ interactionService, observationIntegration);
+ this.actionExecutor = actionExecutor(
+ this::completeEntityChanges,
+ applicationSpanService);
}
@GetMapping("/trace")
@@ -223,6 +236,27 @@ public void view() {
});
}
+ @GetMapping("/trace/application")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ public void application() {
+ interactionService.run(
+ mock(InteractionContext.class),
+ () -> applicationSpanService.run(
+ "outer",
+ () -> applicationSpanService.call(
+ "inner",
+ () -> "done")));
+ }
+
+ @GetMapping("/trace/application/failure")
+ public void applicationFailure() {
+ interactionService.run(
+ mock(InteractionContext.class),
+ () -> applicationSpanService.run("failure", () -> {
+ throw new IllegalStateException("expected application span failure");
+ }));
+ }
+
@GetMapping("/trace/unsupported")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void unsupported() {
@@ -444,8 +478,11 @@ private static MemberExecutorServiceDefault memberExecutorService(
}
private static ActionExecutor actionExecutor(
- final Runnable auditTrailWrite) throws Exception {
- final JdbcAction targetPojo = new JdbcAction(auditTrailWrite);
+ final Runnable auditTrailWrite,
+ final ApplicationSpanService applicationSpanService) throws Exception {
+ final JdbcAction targetPojo = new JdbcAction(
+ auditTrailWrite,
+ applicationSpanService);
final ManagedObject target = mock(ManagedObject.class);
when(target.getPojo()).thenReturn(targetPojo);
final InteractionHead head = mock(InteractionHead.class);
@@ -479,28 +516,35 @@ private static ActionExecutor actionExecutor(
public static final class JdbcAction {
private final Runnable auditTrailWrite;
+ private final ApplicationSpanService applicationSpanService;
- private JdbcAction(final Runnable auditTrailWrite) {
+ private JdbcAction(
+ final Runnable auditTrailWrite,
+ final ApplicationSpanService applicationSpanService) {
this.auditTrailWrite = auditTrailWrite;
+ this.applicationSpanService = applicationSpanService;
}
- public String executeJdbc() throws Exception {
- Class.forName("org.h2.Driver");
- try (Connection connection = DriverManager.getConnection(
- "jdbc:h2:mem:causeway-tracing;DB_CLOSE_DELAY=-1")) {
- try (Statement statement = connection.createStatement()) {
- statement.execute("create table trace_probe (id integer primary key, name varchar(32))");
- statement.executeUpdate("insert into trace_probe (id, name) values (1, 'compatible')");
- try (ResultSet resultSet = statement.executeQuery(
- "select name from trace_probe where id = 1")) {
- if (!resultSet.next() || !"compatible".equals(resultSet.getString(1))) {
- throw new IllegalStateException("Unexpected JDBC probe result");
+ public String executeJdbc() {
+ final String result = applicationSpanService.call("jdbcWork", () -> {
+ Class.forName("org.h2.Driver");
+ try (Connection connection = DriverManager.getConnection(
+ "jdbc:h2:mem:causeway-tracing;DB_CLOSE_DELAY=-1")) {
+ try (Statement statement = connection.createStatement()) {
+ statement.execute("create table trace_probe (id integer primary key, name varchar(32))");
+ statement.executeUpdate("insert into trace_probe (id, name) values (1, 'compatible')");
+ try (ResultSet resultSet = statement.executeQuery(
+ "select name from trace_probe where id = 1")) {
+ if (!resultSet.next() || !"compatible".equals(resultSet.getString(1))) {
+ throw new IllegalStateException("Unexpected JDBC probe result");
+ }
}
}
}
- }
+ return "compatible";
+ });
auditTrailWrite.run();
- return "compatible";
+ return result;
}
}
}
diff --git a/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingCompatibilityTest.java b/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingCompatibilityTest.java
index d8837f30f47..5a86bf0c74a 100644
--- a/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingCompatibilityTest.java
+++ b/regressiontests/tracing-compatibility/src/test/java/org/apache/causeway/regressiontests/tracing/MicrometerTracingCompatibilityTest.java
@@ -207,11 +207,17 @@ void javaAgentExportsHttpRootInteractionActionAndJdbcAncestry() throws Exception
.filter(span -> auditTrailWriteSpan.spanId.equals(span.parentSpanId))
.filter(span -> "h2".equals(span.attributes.get("db.system")))
.count();
+ final ExportedSpan actionApplicationSpan = childSpanNamed(
+ spans,
+ actionSpan,
+ MicrometerTracingAgentFixture.APPLICATION_ACTION_NAME,
+ result.output);
final ExportedSpan jdbcSpan = spans.stream()
- .filter(span -> actionSpan.traceId.equals(span.traceId))
- .filter(span -> actionSpan.spanId.equals(span.parentSpanId))
+ .filter(span -> actionApplicationSpan.traceId.equals(span.traceId))
+ .filter(span -> actionApplicationSpan.spanId.equals(span.parentSpanId))
+ .filter(span -> "h2".equals(span.attributes.get("db.system")))
.findFirst()
- .orElseGet(() -> fail("JDBC span is not a child of the action.\n"
+ .orElseGet(() -> fail("JDBC span is not a child of the application span.\n"
+ describe(spans) + "\n" + result.output));
final ExportedSpan httpSpan = ancestorWithAttribute(
spans,
@@ -238,6 +244,12 @@ void javaAgentExportsHttpRootInteractionActionAndJdbcAncestry() throws Exception
assertEquals(actionSpan.traceId, jdbcSpan.traceId);
assertEquals(MicrometerTracingAgentFixture.ACTION_ID,
actionSpan.attributes.get("causeway.action.id"));
+ assertEquals("jdbcWork", actionApplicationSpan.attributes.get(
+ "causeway.application.span.suffix"));
+ assertEquals("ApplicationSpanServiceDefault",
+ actionApplicationSpan.attributes.get("causeway.bean"));
+ assertEquals("runtimeservices",
+ actionApplicationSpan.attributes.get("causeway.module"));
assertEquals(1L, spans.stream()
.filter(span -> actionSpan.traceId.equals(span.traceId))
.filter(span -> MicrometerTracingAgentFixture.ENTITY_CHANGE_EVALUATION_NAME
@@ -333,6 +345,44 @@ void javaAgentExportsHttpRootInteractionActionAndJdbcAncestry() throws Exception
assertEquals(MicrometerTracingAgentFixture.OBJECT_TYPE,
viewEntrySpan.attributes.get(OBJECT_TYPE_ATTRIBUTE));
+ final ExportedSpan applicationEntrySpan = spanWithAttribute(
+ spans, "http.route", "/trace/application", result.output);
+ final ExportedSpan applicationRootSpan = spanNamedInTrace(
+ spans,
+ applicationEntrySpan.traceId,
+ MicrometerTracingAgentFixture.ROOT_INTERACTION_NAME,
+ result.output);
+ final ExportedSpan applicationOuterSpan = childSpanNamed(
+ spans,
+ applicationRootSpan,
+ MicrometerTracingAgentFixture.APPLICATION_OUTER_NAME,
+ result.output);
+ final ExportedSpan applicationInnerSpan = childSpanNamed(
+ spans,
+ applicationOuterSpan,
+ MicrometerTracingAgentFixture.APPLICATION_INNER_NAME,
+ result.output);
+ assertEquals("outer", applicationOuterSpan.attributes.get(
+ "causeway.application.span.suffix"));
+ assertEquals("inner", applicationInnerSpan.attributes.get(
+ "causeway.application.span.suffix"));
+ assertFalse(applicationOuterSpan.attributes.containsKey("causeway.member.id"));
+
+ final ExportedSpan applicationFailureEntrySpan = spanWithAttribute(
+ spans, "http.route", "/trace/application/failure", result.output);
+ final ExportedSpan applicationFailureRootSpan = spanNamedInTrace(
+ spans,
+ applicationFailureEntrySpan.traceId,
+ MicrometerTracingAgentFixture.ROOT_INTERACTION_NAME,
+ result.output);
+ final ExportedSpan applicationFailureSpan = childSpanNamed(
+ spans,
+ applicationFailureRootSpan,
+ MicrometerTracingAgentFixture.APPLICATION_FAILURE_NAME,
+ result.output);
+ assertEquals("500", applicationFailureEntrySpan.attributes.get("http.status_code"));
+ assertEquals("STATUS_CODE_ERROR", applicationFailureSpan.statusCode);
+
final ExportedSpan unsupportedEntrySpan = spanWithAttribute(
spans, "http.route", "/trace/unsupported", result.output);
assertEquals("GET /trace/unsupported", unsupportedEntrySpan.name);
@@ -439,6 +489,19 @@ private static ExportedSpan spanNamed(
+ describe(spans) + "\n" + processOutput));
}
+ private static ExportedSpan spanNamedInTrace(
+ final List<ExportedSpan> spans,
+ final String traceId,
+ final String name,
+ final String processOutput) {
+ return spans.stream()
+ .filter(span -> traceId.equals(span.traceId))
+ .filter(span -> name.equals(span.name))
+ .findFirst()
+ .orElseGet(() -> fail("Span not exported in expected trace: " + name + "\n"
+ + describe(spans) + "\n" + processOutput));
+ }
+
private static ExportedSpan childSpanNamed(
final List<ExportedSpan> spans,
final ExportedSpan parent,