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,