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/21 22:00:38 UTC

(causeway) 07/14: CAUSEWAY-3975: adds Boot 2.7 observation substrate

This is an automated email from the ASF dual-hosted git repository.

danhaywood pushed a commit to branch CAUSEWAY-3975
in repository https://gitbox.apache.org/repos/asf/causeway.git

commit 9b8b66d5b7be700fd847f7c1d0191f304edcb385
Author: Dan Haywood <da...@haywood-associates.co.uk>
AuthorDate: Mon Sep 21 19:47:29 2026 +0100

    CAUSEWAY-3975: adds Boot 2.7 observation substrate
---
 bom/pom.xml                                        |  25 +++
 core/config/pom.xml                                |  43 +++++
 core/config/src/main/java/module-info.java         |   8 +-
 .../org/apache/causeway/OrgApacheCauseway.java     |  34 ++++
 .../core/config/CausewayModuleCoreConfig.java      |   2 +
 .../CausewayObservationConfiguration.java          |  96 +++++++++++
 .../CausewayObservationIntegration.java            |  85 ++++++++++
 .../config/observation/ObservationClosure.java     |  76 +++++++++
 .../CausewayObservationConfigurationTest.java      | 118 +++++++++++++
 ...fecycleRecordsThrowableAndCleansUp.approved.txt |  17 ++
 .../config/observation/ObservationClosureTest.java | 183 +++++++++++++++++++++
 ....partialAndRepeatedCleanupAreNoops.approved.txt |  13 ++
 ...ycleClosesScopeAndStopsExactlyOnce.approved.txt |  17 ++
 .../add-boot27-observation-substrate/design.md     |   8 +
 .../add-boot27-observation-substrate/tasks.md      |  34 ++--
 regressiontests/tracing-compatibility/pom.xml      |  46 +-----
 .../tracing/MicrometerTracingAgentFixture.java     |  59 +++----
 .../MicrometerTracingCompatibilityTest.java        |   6 +
 18 files changed, 774 insertions(+), 96 deletions(-)

diff --git a/bom/pom.xml b/bom/pom.xml
index 95dccb94096..1483cb582ce 100644
--- a/bom/pom.xml
+++ b/bom/pom.xml
@@ -178,6 +178,8 @@ identified
         <lombok.version>1.18.44</lombok.version> <!-- overriding Spring -->
 
         <maven-release-plugin.version>3.0.1</maven-release-plugin.version>
+        <micrometer-observation.version>1.10.13</micrometer-observation.version>
+        <micrometer-tracing.version>1.0.12</micrometer-tracing.version>
         <momentjs.version>2.29.4</momentjs.version> <!-- keep in sync:
         org.apache.causeway.viewer.wicket.ui.components.scalars.datepicker.MomentJsReference -->
 
@@ -187,6 +189,7 @@ identified
         <npm.version>8.19.2</npm.version>
 
         <ognl.version>3.4.3</ognl.version>
+        <opentelemetry-api.version>1.19.0</opentelemetry-api.version>
         <osgi.version>8.0.0</osgi.version>
 
         <picocontainer.version>2.15</picocontainer.version>
@@ -1814,6 +1817,28 @@ identified
                 <version>${momentjs.version}</version>
             </dependency>
 
+            <dependency>
+                <groupId>io.micrometer</groupId>
+                <artifactId>micrometer-observation</artifactId>
+                <version>${micrometer-observation.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.micrometer</groupId>
+                <artifactId>micrometer-tracing</artifactId>
+                <version>${micrometer-tracing.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>io.micrometer</groupId>
+                <artifactId>micrometer-tracing-bridge-otel</artifactId>
+                <version>${micrometer-tracing.version}</version>
+            </dependency>
+
+            <dependency>
+                <groupId>io.opentelemetry</groupId>
+                <artifactId>opentelemetry-api</artifactId>
+                <version>${opentelemetry-api.version}</version>
+            </dependency>
+
             <dependency>
                 <groupId>io.swagger.core.v3</groupId>
                 <artifactId>swagger-core</artifactId>
diff --git a/core/config/pom.xml b/core/config/pom.xml
index 8cf663d8f57..2af5a9b09b2 100644
--- a/core/config/pom.xml
+++ b/core/config/pom.xml
@@ -93,6 +93,38 @@
             <artifactId>causeway-applib</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-observation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-tracing</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.micrometer</groupId>
+            <artifactId>micrometer-tracing-bridge-otel</artifactId>
+            <exclusions>
+                <!-- The Java agent owns the SDK; Causeway needs only the bridge and OTel API. -->
+                <exclusion>
+                    <groupId>io.opentelemetry</groupId>
+                    <artifactId>opentelemetry-sdk</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>io.opentelemetry</groupId>
+                    <artifactId>opentelemetry-sdk-common</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>io.opentelemetry</groupId>
+                    <artifactId>opentelemetry-sdk-trace</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>io.opentelemetry</groupId>
+            <artifactId>opentelemetry-api</artifactId>
+        </dependency>
+
         <dependency>
 			<groupId>org.hibernate</groupId>
 			<artifactId>hibernate-validator</artifactId>
@@ -136,6 +168,17 @@
             <artifactId>causeway-core-internaltestsupport</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>com.approvaltests</groupId>
+            <artifactId>approvaltests</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <!-- required by ApprovalTests (version managed by Spring Boot) -->
+            <groupId>com.google.code.gson</groupId>
+            <artifactId>gson</artifactId>
+            <scope>test</scope>
+        </dependency>
 
     </dependencies>
 
diff --git a/core/config/src/main/java/module-info.java b/core/config/src/main/java/module-info.java
index 0d733bb2dd0..10622b9961c 100644
--- a/core/config/src/main/java/module-info.java
+++ b/core/config/src/main/java/module-info.java
@@ -28,6 +28,7 @@
     exports org.apache.causeway.core.config.metamodel.facets;
     exports org.apache.causeway.core.config.metamodel.services;
     exports org.apache.causeway.core.config.metamodel.specloader;
+    exports org.apache.causeway.core.config.observation;
     exports org.apache.causeway.core.config.presets;
     exports org.apache.causeway.core.config.progmodel;
     exports org.apache.causeway.core.config.util;
@@ -45,6 +46,10 @@
     requires java.validation;
     requires java.ws.rs;
     requires java.inject;
+    requires transitive micrometer.observation;
+    requires micrometer.tracing;
+    requires micrometer.tracing.bridge.otel;
+    requires io.opentelemetry.api;
     requires org.apache.logging.log4j;
     requires org.eclipse.persistence.core;
     requires org.hibernate.validator;
@@ -59,4 +64,5 @@
 
     opens org.apache.causeway.core.config to spring.core, org.hibernate.validator;
     opens org.apache.causeway.core.config.environment to spring.core;
-}
\ No newline at end of file
+    opens org.apache.causeway.core.config.observation to spring.core;
+}
diff --git a/core/config/src/main/java/org/apache/causeway/OrgApacheCauseway.java b/core/config/src/main/java/org/apache/causeway/OrgApacheCauseway.java
new file mode 100644
index 00000000000..02ad8d15559
--- /dev/null
+++ b/core/config/src/main/java/org/apache/causeway/OrgApacheCauseway.java
@@ -0,0 +1,34 @@
+/*
+ *  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;
+
+import lombok.experimental.UtilityClass;
+
+/**
+ * Namespace anchor for identifiers that represent Apache Causeway as a whole.
+ *
+ * <p>Infrastructure integrations can derive the stable {@code org.apache.causeway}
+ * namespace from this class rather than from an implementation class whose package
+ * may change during refactoring.</p>
+ *
+ * @since 2.2
+ */
+@UtilityClass
+public class OrgApacheCauseway {
+}
diff --git a/core/config/src/main/java/org/apache/causeway/core/config/CausewayModuleCoreConfig.java b/core/config/src/main/java/org/apache/causeway/core/config/CausewayModuleCoreConfig.java
index 1c029f18f3f..0ec5b679878 100644
--- a/core/config/src/main/java/org/apache/causeway/core/config/CausewayModuleCoreConfig.java
+++ b/core/config/src/main/java/org/apache/causeway/core/config/CausewayModuleCoreConfig.java
@@ -30,6 +30,7 @@
 import org.apache.causeway.core.config.environment.CausewayLocaleInitializer;
 import org.apache.causeway.core.config.environment.CausewaySystemEnvironment;
 import org.apache.causeway.core.config.environment.CausewayTimeZoneInitializer;
+import org.apache.causeway.core.config.observation.CausewayObservationConfiguration;
 import org.apache.causeway.core.config.validators.PatternOptionalStringConstraintValidator;
 import org.apache.causeway.core.config.viewer.web.WebAppContextPath;
 
@@ -41,6 +42,7 @@
     CausewayBeanFactoryPostProcessorForSpring.class,
     CausewayLocaleInitializer.class,
     CausewayTimeZoneInitializer.class,
+    CausewayObservationConfiguration.class,
     PatternOptionalStringConstraintValidator.class,
     RestfulPathProvider.class,
 
diff --git a/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationConfiguration.java b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationConfiguration.java
new file mode 100644
index 00000000000..f71d1d7362d
--- /dev/null
+++ b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationConfiguration.java
@@ -0,0 +1,96 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *        http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.apache.causeway.core.config.observation;
+
+import java.util.Collections;
+
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Profile;
+
+import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.tracing.Tracer;
+import io.micrometer.tracing.handler.DefaultTracingObservationHandler;
+import io.micrometer.tracing.otel.bridge.OtelBaggageManager;
+import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext;
+import io.micrometer.tracing.otel.bridge.OtelTracer;
+import io.opentelemetry.api.GlobalOpenTelemetry;
+
+import org.apache.causeway.OrgApacheCauseway;
+
+/**
+ * Boot 2.7-specific observation wiring for Causeway framework instrumentation.
+ *
+ * <p>The {@code observation} Spring profile opts into a registry backed by the
+ * OpenTelemetry Java agent's {@link GlobalOpenTelemetry} context. Without the
+ * profile, framework observations use {@link ObservationRegistry#NOOP}.</p>
+ *
+ * @since 2.2
+ */
+@Configuration(proxyBeanMethods = false)
+@Import({
+        CausewayObservationConfiguration.Inactive.class,
+        CausewayObservationConfiguration.Active.class,
+})
+public class CausewayObservationConfiguration {
+
+    public static final String REGISTRY_BEAN_NAME = "causewayObservationRegistry";
+
+    @Bean
+    public CausewayObservationIntegration causewayObservationIntegration(
+            @Qualifier(REGISTRY_BEAN_NAME) final ObservationRegistry observationRegistry) {
+        return new CausewayObservationIntegration(observationRegistry);
+    }
+
+    @Configuration(proxyBeanMethods = false)
+    @Profile("!observation")
+    public static class Inactive {
+
+        @Bean(name = REGISTRY_BEAN_NAME)
+        public ObservationRegistry causewayObservationRegistry() {
+            return ObservationRegistry.NOOP;
+        }
+    }
+
+    @Configuration(proxyBeanMethods = false)
+    @Profile("observation")
+    public static class Active {
+
+        @Bean(name = REGISTRY_BEAN_NAME)
+        public ObservationRegistry causewayObservationRegistry() {
+            final OtelCurrentTraceContext currentTraceContext = new OtelCurrentTraceContext();
+            final OtelBaggageManager baggageManager = new OtelBaggageManager(
+                    currentTraceContext,
+                    Collections.emptyList(),
+                    Collections.emptyList());
+            final Tracer tracer = new OtelTracer(
+                    GlobalOpenTelemetry.getTracer(OrgApacheCauseway.class.getPackageName()),
+                    currentTraceContext,
+                    event -> { },
+                    baggageManager);
+
+            final ObservationRegistry observationRegistry = ObservationRegistry.create();
+            observationRegistry.observationConfig()
+                    .observationHandler(new DefaultTracingObservationHandler(tracer));
+            return observationRegistry;
+        }
+    }
+}
diff --git a/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationIntegration.java b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationIntegration.java
new file mode 100644
index 00000000000..70c485514d7
--- /dev/null
+++ b/core/config/src/main/java/org/apache/causeway/core/config/observation/CausewayObservationIntegration.java
@@ -0,0 +1,85 @@
+/*
+ *  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.config.observation;
+
+import java.util.Objects;
+import java.util.function.UnaryOperator;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
+
+/**
+ * Internal entry point for Causeway framework observations.
+ *
+ * <p>Framework components use this integration rather than depending on an
+ * OpenTelemetry implementation or performing optional registry lookup.</p>
+ *
+ * @since 2.2
+ */
+public final class CausewayObservationIntegration {
+
+    private final ObservationRegistry observationRegistry;
+
+    public CausewayObservationIntegration(final ObservationRegistry observationRegistry) {
+        this.observationRegistry = observationRegistry != null
+                ? observationRegistry
+                : ObservationRegistry.NOOP;
+    }
+
+    public ObservationRegistry observationRegistry() {
+        return observationRegistry;
+    }
+
+    public boolean isNoop() {
+        return observationRegistry.isNoop();
+    }
+
+    public Observation createNotStarted(final Class<?> beanType, final String name) {
+        Objects.requireNonNull(beanType, "beanType");
+        Objects.requireNonNull(name, "name");
+        return Observation.createNotStarted(name, observationRegistry)
+                .lowCardinalityKeyValue("causeway.bean", beanType.getSimpleName());
+    }
+
+    public ObservationProvider provider(final Class<?> beanType) {
+        return name -> createNotStarted(beanType, name);
+    }
+
+    public ObservationProvider provider(
+            final Class<?> beanType,
+            final UnaryOperator<Observation> customizer) {
+        Objects.requireNonNull(customizer, "customizer");
+        return name -> customizer.apply(createNotStarted(beanType, name));
+    }
+
+    public static UnaryOperator<Observation> withModuleName(final String moduleName) {
+        Objects.requireNonNull(moduleName, "moduleName");
+        final String normalizedModuleName = moduleName.startsWith("causeway.")
+                || moduleName.startsWith("causeway-")
+                        ? moduleName.substring(9)
+                        : moduleName;
+        return observation -> observation.lowCardinalityKeyValue(
+                "causeway.module", normalizedModuleName);
+    }
+
+    @FunctionalInterface
+    public interface ObservationProvider {
+        Observation get(String name);
+    }
+}
diff --git a/core/config/src/main/java/org/apache/causeway/core/config/observation/ObservationClosure.java b/core/config/src/main/java/org/apache/causeway/core/config/observation/ObservationClosure.java
new file mode 100644
index 00000000000..0f9cbc01e36
--- /dev/null
+++ b/core/config/src/main/java/org/apache/causeway/core/config/observation/ObservationClosure.java
@@ -0,0 +1,76 @@
+/*
+ *  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.config.observation;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.Observation.Scope;
+
+/**
+ * Owns an observation whose start and stop occur in different framework callbacks.
+ *
+ * @since 2.2
+ */
+public final class ObservationClosure implements AutoCloseable {
+
+    private Observation observation;
+    private Scope scope;
+
+    public synchronized ObservationClosure startAndOpenScope(final Observation observation) {
+        if (observation == null) {
+            return this;
+        }
+        if (this.observation != null || this.scope != null) {
+            throw new IllegalStateException("Observation lifecycle is already active");
+        }
+
+        this.observation = observation.start();
+        try {
+            this.scope = this.observation.openScope();
+        } catch (RuntimeException | Error ex) {
+            this.observation.error(ex);
+            close();
+            throw ex;
+        }
+        return this;
+    }
+
+    public synchronized void onError(final Throwable failure) {
+        if (observation != null && failure != null) {
+            observation.error(failure);
+        }
+    }
+
+    @Override
+    public synchronized void close() {
+        final Scope scopeToClose = scope;
+        final Observation observationToStop = observation;
+        scope = null;
+        observation = null;
+
+        try {
+            if (scopeToClose != null) {
+                scopeToClose.close();
+            }
+        } finally {
+            if (observationToStop != null) {
+                observationToStop.stop();
+            }
+        }
+    }
+}
diff --git a/core/config/src/test/java/org/apache/causeway/core/config/observation/CausewayObservationConfigurationTest.java b/core/config/src/test/java/org/apache/causeway/core/config/observation/CausewayObservationConfigurationTest.java
new file mode 100644
index 00000000000..f64884ae8fb
--- /dev/null
+++ b/core/config/src/test/java/org/apache/causeway/core/config/observation/CausewayObservationConfigurationTest.java
@@ -0,0 +1,118 @@
+/*
+ *  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.config.observation;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationHandler;
+import io.micrometer.observation.ObservationRegistry;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CausewayObservationConfigurationTest {
+
+    @Test
+    void inactiveProfileUsesCausewayNoopRegistryAndIgnoresApplicationRegistry() {
+        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
+            final ObservationRegistry applicationRegistry = ObservationRegistry.create();
+            context.registerBean(
+                    "applicationObservationRegistry",
+                    ObservationRegistry.class,
+                    () -> applicationRegistry);
+            context.register(CausewayObservationConfiguration.class);
+            context.refresh();
+
+            final CausewayObservationIntegration integration = context.getBean(
+                    CausewayObservationIntegration.class);
+            assertTrue(integration.isNoop());
+            assertSame(ObservationRegistry.NOOP, integration.observationRegistry());
+            assertNotSame(applicationRegistry, integration.observationRegistry());
+
+            integration.createNotStarted(getClass(), "causeway.test.inactive")
+                    .observe(() -> { });
+            assertNull(integration.observationRegistry().getCurrentObservation());
+        }
+    }
+
+    @Test
+    void providerAddsStableCausewayMetadataAndAppliesCustomization() {
+        final ObservationRegistry registry = ObservationRegistry.create();
+        registry.observationConfig().observationHandler(new ObservationHandler<Observation.Context>() {
+            @Override
+            public boolean supportsContext(final Observation.Context context) {
+                return true;
+            }
+        });
+        final CausewayObservationIntegration integration = new CausewayObservationIntegration(registry);
+
+        final Observation observation = integration.provider(
+                getClass(),
+                CausewayObservationIntegration.withModuleName("causeway-core-config"))
+                .get("causeway.test.metadata");
+
+        observation.start();
+        try {
+            assertEquals("causeway.test.metadata", observation.getContext().getName());
+            assertEquals(
+                    getClass().getSimpleName(),
+                    observation.getContext().getLowCardinalityKeyValue("causeway.bean").getValue());
+            assertEquals(
+                    "core-config",
+                    observation.getContext().getLowCardinalityKeyValue("causeway.module").getValue());
+        } finally {
+            observation.stop();
+        }
+    }
+
+    @Test
+    void activeProfileUsesRealRegistryAndStartsWithoutJavaAgent() {
+        try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
+            final ObservationRegistry applicationRegistry = ObservationRegistry.create();
+            context.getEnvironment().setActiveProfiles("observation");
+            context.registerBean(
+                    "applicationObservationRegistry",
+                    ObservationRegistry.class,
+                    () -> applicationRegistry);
+            context.register(CausewayObservationConfiguration.class);
+            context.refresh();
+
+            final CausewayObservationIntegration integration = context.getBean(
+                    CausewayObservationIntegration.class);
+            assertFalse(integration.isNoop());
+            assertNotSame(applicationRegistry, integration.observationRegistry());
+
+            final Observation observation = integration.createNotStarted(
+                    getClass(), "causeway.test.active");
+            observation.start();
+            try (Observation.Scope ignored = observation.openScope()) {
+                assertSame(observation, integration.observationRegistry().getCurrentObservation());
+            } finally {
+                observation.stop();
+            }
+            assertNull(integration.observationRegistry().getCurrentObservation());
+        }
+    }
+}
diff --git a/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.failingLifecycleRecordsThrowableAndCleansUp.approved.txt b/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.failingLifecycleRecordsThrowableAndCleansUp.approved.txt
new file mode 100644
index 00000000000..86f2c696bbe
--- /dev/null
+++ b/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.failingLifecycleRecordsThrowableAndCleansUp.approved.txt
@@ -0,0 +1,17 @@
+Scenario: Failing observation lifecycle
+
+This approval checks:
+- The reported Throwable reaches the observation handler with its type and message.
+- The error callback occurs before scope closure and observation stop.
+- Cleanup closes the scope before stopping the observation.
+- No current observation remains after cleanup.
+
+Observed state:
+- current observation after close: <none>
+
+Observed callback order:
+1. onStart(name=causeway.test.failure)
+2. onScopeOpened(name=causeway.test.failure)
+3. onError(type=AssertionError, message=expected)
+4. onScopeClosed(name=causeway.test.failure)
+5. onStop(name=causeway.test.failure)
\ No newline at end of file
diff --git a/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.java b/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.java
new file mode 100644
index 00000000000..b7a52b11530
--- /dev/null
+++ b/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.java
@@ -0,0 +1,183 @@
+/*
+ *  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.config.observation;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.approvaltests.Approvals;
+import org.junit.jupiter.api.Test;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationHandler;
+import io.micrometer.observation.ObservationRegistry;
+
+class ObservationClosureTest {
+
+    @Test
+    void successfulLifecycleClosesScopeAndStopsExactlyOnce() {
+        final RecordingHandler handler = new RecordingHandler();
+        final ObservationRegistry registry = registryWith(handler);
+        final Observation observation = Observation.createNotStarted("causeway.test.success", registry);
+        final ObservationClosure closure = new ObservationClosure();
+
+        closure.startAndOpenScope(observation);
+        final String currentAfterStart = currentObservationName(registry);
+        closure.close();
+        closure.close();
+
+        Approvals.verify(report(
+                "Successful observation lifecycle",
+                List.of(
+                        "Starting opens the observation scope and makes the observation current.",
+                        "The first close closes the scope before stopping the observation.",
+                        "The second close produces no additional callbacks.",
+                        "No current observation remains after cleanup."),
+                List.of(
+                        "current observation after start: " + currentAfterStart,
+                        "current observation after two closes: " + currentObservationName(registry)),
+                handler));
+    }
+
+    @Test
+    void failingLifecycleRecordsThrowableAndCleansUp() {
+        final RecordingHandler handler = new RecordingHandler();
+        final ObservationRegistry registry = registryWith(handler);
+        final Observation observation = Observation.createNotStarted("causeway.test.failure", registry);
+        final ObservationClosure closure = new ObservationClosure();
+
+        closure.startAndOpenScope(observation);
+        closure.onError(new AssertionError("expected"));
+        closure.close();
+
+        Approvals.verify(report(
+                "Failing observation lifecycle",
+                List.of(
+                        "The reported Throwable reaches the observation handler with its type and message.",
+                        "The error callback occurs before scope closure and observation stop.",
+                        "Cleanup closes the scope before stopping the observation.",
+                        "No current observation remains after cleanup."),
+                List.of("current observation after close: " + currentObservationName(registry)),
+                handler));
+    }
+
+    @Test
+    void partialAndRepeatedCleanupAreNoops() {
+        final List<String> completedOperations = new ArrayList<>();
+        final ObservationClosure closure = new ObservationClosure();
+
+        closure.startAndOpenScope(null);
+        completedOperations.add("start with null observation: completed");
+        closure.onError(new IllegalStateException("ignored"));
+        completedOperations.add("error before start: completed");
+        closure.close();
+        completedOperations.add("first close: completed");
+        closure.close();
+        completedOperations.add("second close: completed");
+
+        final List<String> report = new ArrayList<>(List.of(
+                "Scenario: Defensive cleanup without an active observation",
+                "",
+                "This approval checks:",
+                "- A null observation is accepted as a no-op.",
+                "- Reporting an error before start is accepted as a no-op.",
+                "- Closing before start is accepted as a no-op.",
+                "- Repeated close is accepted as a no-op.",
+                "",
+                "Observed operations:"));
+        completedOperations.forEach(operation -> report.add("- " + operation));
+        Approvals.verify(String.join("\n", report));
+    }
+
+    private static String report(
+            final String scenario,
+            final List<String> checks,
+            final List<String> observedState,
+            final RecordingHandler handler) {
+        final List<String> lines = new ArrayList<>();
+        lines.add("Scenario: " + scenario);
+        lines.add("");
+        lines.add("This approval checks:");
+        checks.forEach(check -> lines.add("- " + check));
+        lines.add("");
+        lines.add("Observed state:");
+        observedState.forEach(state -> lines.add("- " + state));
+        lines.add("");
+        lines.add("Observed callback order:");
+        lines.addAll(handler.numberedEvents());
+        return String.join("\n", lines);
+    }
+
+    private static String currentObservationName(final ObservationRegistry registry) {
+        final Observation current = registry.getCurrentObservation();
+        return current != null ? current.getContext().getName() : "<none>";
+    }
+
+    private static ObservationRegistry registryWith(final RecordingHandler handler) {
+        final ObservationRegistry registry = ObservationRegistry.create();
+        registry.observationConfig().observationHandler(handler);
+        return registry;
+    }
+
+    private static final class RecordingHandler
+    implements ObservationHandler<Observation.Context> {
+
+        private final List<String> events = new ArrayList<>();
+
+        @Override
+        public void onStart(final Observation.Context context) {
+            events.add("onStart(name=" + context.getName() + ")");
+        }
+
+        @Override
+        public void onScopeOpened(final Observation.Context context) {
+            events.add("onScopeOpened(name=" + context.getName() + ")");
+        }
+
+        @Override
+        public void onScopeClosed(final Observation.Context context) {
+            events.add("onScopeClosed(name=" + context.getName() + ")");
+        }
+
+        @Override
+        public void onError(final Observation.Context context) {
+            final Throwable failure = context.getError();
+            events.add("onError(type=" + failure.getClass().getSimpleName()
+                    + ", message=" + failure.getMessage() + ")");
+        }
+
+        @Override
+        public void onStop(final Observation.Context context) {
+            events.add("onStop(name=" + context.getName() + ")");
+        }
+
+        @Override
+        public boolean supportsContext(final Observation.Context context) {
+            return true;
+        }
+
+        private List<String> numberedEvents() {
+            final List<String> numberedEvents = new ArrayList<>();
+            for (int i = 0; i < events.size(); i++) {
+                numberedEvents.add((i + 1) + ". " + events.get(i));
+            }
+            return numberedEvents;
+        }
+    }
+}
diff --git a/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.partialAndRepeatedCleanupAreNoops.approved.txt b/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.partialAndRepeatedCleanupAreNoops.approved.txt
new file mode 100644
index 00000000000..b12e6442613
--- /dev/null
+++ b/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.partialAndRepeatedCleanupAreNoops.approved.txt
@@ -0,0 +1,13 @@
+Scenario: Defensive cleanup without an active observation
+
+This approval checks:
+- A null observation is accepted as a no-op.
+- Reporting an error before start is accepted as a no-op.
+- Closing before start is accepted as a no-op.
+- Repeated close is accepted as a no-op.
+
+Observed operations:
+- start with null observation: completed
+- error before start: completed
+- first close: completed
+- second close: completed
\ No newline at end of file
diff --git a/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.successfulLifecycleClosesScopeAndStopsExactlyOnce.approved.txt b/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.successfulLifecycleClosesScopeAndStopsExactlyOnce.approved.txt
new file mode 100644
index 00000000000..6c1b696d245
--- /dev/null
+++ b/core/config/src/test/java/org/apache/causeway/core/config/observation/ObservationClosureTest.successfulLifecycleClosesScopeAndStopsExactlyOnce.approved.txt
@@ -0,0 +1,17 @@
+Scenario: Successful observation lifecycle
+
+This approval checks:
+- Starting opens the observation scope and makes the observation current.
+- The first close closes the scope before stopping the observation.
+- The second close produces no additional callbacks.
+- No current observation remains after cleanup.
+
+Observed state:
+- current observation after start: causeway.test.success
+- current observation after two closes: <none>
+
+Observed callback order:
+1. onStart(name=causeway.test.success)
+2. onScopeOpened(name=causeway.test.success)
+3. onScopeClosed(name=causeway.test.success)
+4. onStop(name=causeway.test.success)
\ No newline at end of file
diff --git a/openspec/changes/add-boot27-observation-substrate/design.md b/openspec/changes/add-boot27-observation-substrate/design.md
index ecf5a07615e..181e1d97c49 100644
--- a/openspec/changes/add-boot27-observation-substrate/design.md
+++ b/openspec/changes/add-boot27-observation-substrate/design.md
@@ -102,6 +102,14 @@ Its real-agent OTLP assertion will continue to prove that the production substra
 
 This avoids creating a second agent harness and turns the Phase 1 proof into a regression guard for the production integration.
 
+## Validated Phase 3 Integration Recipe
+
+Later core instrumentation should inject `CausewayObservationIntegration`, create an `ObservationProvider` for the concrete framework bean type, and optionally compose `withModuleName(...)` for stable low-cardinality module metadata.
+At the beginning of framework work, it should obtain an unstarted observation from that provider and pass it to a new `ObservationClosure` through `startAndOpenScope(...)`.
+A catch path should pass any `Throwable` to `onError(...)` and rethrow it, while a `finally` path should always invoke `close()`.
+Consumers should not inject the qualified registry directly, look up optional beans, or call OpenTelemetry APIs.
+This recipe behaves identically at the call site when the profile is inactive, active without an agent, or active with an agent.
+
 ## Risks / Trade-offs
 
 - **[Risk] Adding bridge dependencies to core configuration increases every application's runtime classpath even when observation is inactive.** → Keep initialization profile-gated, exclude SDK artifacts, and accept the small classpath cost in preference to a new maintenance-only abstraction layer.
diff --git a/openspec/changes/add-boot27-observation-substrate/tasks.md b/openspec/changes/add-boot27-observation-substrate/tasks.md
index 1478b35de00..a69317a80ce 100644
--- a/openspec/changes/add-boot27-observation-substrate/tasks.md
+++ b/openspec/changes/add-boot27-observation-substrate/tasks.md
@@ -1,28 +1,28 @@
 ## 1. Add the Validated Production Dependencies
 
-- [ ] 1.1 Add centrally managed properties and dependency entries for Micrometer Observation 1.10.13, Micrometer Tracing and OpenTelemetry bridge 1.0.12, and OpenTelemetry API 1.19.0 without overriding `micrometer.version`.
-- [ ] 1.2 Add the required dependencies to `core/config`, excluding bridge-transitive OpenTelemetry SDK artifacts and keeping the Java agent and OTLP collector dependencies test-only.
-- [ ] 1.3 Update Java module declarations for the new production API usage and verify that Micrometer Core remains at Spring Boot 2.7.18's managed 1.9.17 version with dependency convergence passing.
+- [x] 1.1 Add centrally managed properties and dependency entries for Micrometer Observation 1.10.13, Micrometer Tracing and OpenTelemetry bridge 1.0.12, and OpenTelemetry API 1.19.0 without overriding `micrometer.version`.
+- [x] 1.2 Add the required dependencies to `core/config`, excluding bridge-transitive OpenTelemetry SDK artifacts and keeping the Java agent and OTLP collector dependencies test-only.
+- [x] 1.3 Update Java module declarations for the new production API usage and verify that Micrometer Core remains at Spring Boot 2.7.18's managed 1.9.17 version with dependency convergence passing.
 
 ## 2. Implement the Observation Substrate
 
-- [ ] 2.1 Add profile-specific core configuration that supplies a qualified `ObservationRegistry.NOOP` when `observation` is inactive and a qualified real registry when it is active.
-- [ ] 2.2 Configure the active registry with `OtelCurrentTraceContext`, `OtelBaggageManager`, an `OtelTracer` obtained from `GlobalOpenTelemetry.getTracer(...)`, and `DefaultTracingObservationHandler` without constructing an SDK or exporter.
-- [ ] 2.3 Add the internal Causeway observation integration that exposes no-op status and consistent creation of unstarted observations or observation providers from the qualified registry.
-- [ ] 2.4 Add Java 11-compatible lifecycle support for start, scope, `Throwable` recording, idempotent cleanup, and clearing retained state.
-- [ ] 2.5 Import the observation configuration from `CausewayModuleCoreConfig` and document the internal contract for later framework instrumentation.
+- [x] 2.1 Add profile-specific core configuration that supplies a qualified `ObservationRegistry.NOOP` when `observation` is inactive and a qualified real registry when it is active.
+- [x] 2.2 Configure the active registry with `OtelCurrentTraceContext`, `OtelBaggageManager`, an `OtelTracer` obtained from `GlobalOpenTelemetry.getTracer(...)`, and `DefaultTracingObservationHandler` without constructing an SDK or exporter.
+- [x] 2.3 Add the internal Causeway observation integration that exposes no-op status and consistent creation of unstarted observations or observation providers from the qualified registry.
+- [x] 2.4 Add Java 11-compatible lifecycle support for start, scope, `Throwable` recording, idempotent cleanup, and clearing retained state.
+- [x] 2.5 Import the observation configuration from `CausewayModuleCoreConfig` and document the internal contract for later framework instrumentation.
 
 ## 3. Verify Profile and Lifecycle Behavior
 
-- [ ] 3.1 Add focused tests proving that the inactive profile selects the Causeway no-op registry, creates no tracing bridge, and ignores unrelated application registries.
-- [ ] 3.2 Add focused tests proving that the active profile creates the real Causeway registry and starts safely without the Java agent.
-- [ ] 3.3 Add lifecycle tests covering successful completion, recorded failures, partial initialization, repeated cleanup, and removal of the current observation from the registry.
-- [ ] 3.4 Update the tracing compatibility child fixture to obtain and use the production Causeway integration instead of constructing duplicate Micrometer bridge wiring.
-- [ ] 3.5 Run the real-agent compatibility test and verify that the production-substrate observation remains the parent or expected semantic ancestor of the agent-generated JDBC span.
+- [x] 3.1 Add focused tests proving that the inactive profile selects the Causeway no-op registry, creates no tracing bridge, and ignores unrelated application registries.
+- [x] 3.2 Add focused tests proving that the active profile creates the real Causeway registry and starts safely without the Java agent.
+- [x] 3.3 Add lifecycle tests covering successful completion, recorded failures, partial initialization, repeated cleanup, and removal of the current observation from the registry.
+- [x] 3.4 Update the tracing compatibility child fixture to obtain and use the production Causeway integration instead of constructing duplicate Micrometer bridge wiring.
+- [x] 3.5 Run the real-agent compatibility test and verify that the production-substrate observation remains the parent or expected semantic ancestor of the agent-generated JDBC span.
 
 ## 4. Validate the Production Integration
 
-- [ ] 4.1 Run targeted `core/config` tests and representative application-context startup tests with and without the `observation` profile.
-- [ ] 4.2 Run the tracing compatibility module in the regression-test reactor on the Java 11 compilation target and capture the resolved dependency graph and span-parentage evidence.
-- [ ] 4.3 Confirm that production source contains no `OpenTelemetrySdk` construction, exporter configuration, semantic framework spans, or public applib API changes.
-- [ ] 4.4 Record the validated internal integration recipe for Phase 3 and validate the completed OpenSpec change.
+- [x] 4.1 Run targeted `core/config` tests and representative application-context startup tests with and without the `observation` profile.
+- [x] 4.2 Run the tracing compatibility module in the regression-test reactor on the Java 11 compilation target and capture the resolved dependency graph and span-parentage evidence.
+- [x] 4.3 Confirm that production source contains no `OpenTelemetrySdk` construction, exporter configuration, semantic framework spans, or public applib API changes.
+- [x] 4.4 Record the validated internal integration recipe for Phase 3 and validate the completed OpenSpec change.
diff --git a/regressiontests/tracing-compatibility/pom.xml b/regressiontests/tracing-compatibility/pom.xml
index 704f1c8d92b..e96f20c78d0 100644
--- a/regressiontests/tracing-compatibility/pom.xml
+++ b/regressiontests/tracing-compatibility/pom.xml
@@ -37,9 +37,6 @@
         <maven.install.skip>true</maven.install.skip>
         <maven.deploy.skip>true</maven.deploy.skip>
 
-        <micrometer-observation.version>1.10.13</micrometer-observation.version>
-        <micrometer-tracing.version>1.0.12</micrometer-tracing.version>
-        <opentelemetry-api.version>1.19.0</opentelemetry-api.version>
         <opentelemetry-javaagent.version>1.31.0</opentelemetry-javaagent.version>
         <opentelemetry-proto.version>1.0.0-alpha</opentelemetry-proto.version>
 
@@ -54,52 +51,19 @@
             <scope>test</scope>
         </dependency>
 
-        <!-- Keep this version under Spring Boot 2.7.18 dependency management. -->
         <dependency>
-            <groupId>io.micrometer</groupId>
-            <artifactId>micrometer-core</artifactId>
+            <groupId>org.apache.causeway.core</groupId>
+            <artifactId>causeway-core-config</artifactId>
             <scope>test</scope>
         </dependency>
 
+        <!-- Keep this version under Spring Boot 2.7.18 dependency management. -->
         <dependency>
             <groupId>io.micrometer</groupId>
-            <artifactId>micrometer-observation</artifactId>
-            <version>${micrometer-observation.version}</version>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-            <groupId>io.micrometer</groupId>
-            <artifactId>micrometer-tracing</artifactId>
-            <version>${micrometer-tracing.version}</version>
-            <scope>test</scope>
-        </dependency>
-        <dependency>
-            <groupId>io.micrometer</groupId>
-            <artifactId>micrometer-tracing-bridge-otel</artifactId>
-            <version>${micrometer-tracing.version}</version>
-            <scope>test</scope>
-            <exclusions>
-                <!-- The Java agent owns the SDK; this fixture needs only the bridge and OTel API. -->
-                <exclusion>
-                    <groupId>io.opentelemetry</groupId>
-                    <artifactId>opentelemetry-sdk</artifactId>
-                </exclusion>
-                <exclusion>
-                    <groupId>io.opentelemetry</groupId>
-                    <artifactId>opentelemetry-sdk-common</artifactId>
-                </exclusion>
-                <exclusion>
-                    <groupId>io.opentelemetry</groupId>
-                    <artifactId>opentelemetry-sdk-trace</artifactId>
-                </exclusion>
-            </exclusions>
-        </dependency>
-        <dependency>
-            <groupId>io.opentelemetry</groupId>
-            <artifactId>opentelemetry-api</artifactId>
-            <version>${opentelemetry-api.version}</version>
+            <artifactId>micrometer-core</artifactId>
             <scope>test</scope>
         </dependency>
+
         <dependency>
             <groupId>io.opentelemetry.javaagent</groupId>
             <artifactId>opentelemetry-javaagent</artifactId>
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 8384908ae8c..b5d38152fb3 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
@@ -22,27 +22,27 @@
 import java.sql.DriverManager;
 import java.sql.ResultSet;
 import java.sql.Statement;
-import java.util.Collections;
 
-import io.micrometer.observation.Observation;
-import io.micrometer.observation.ObservationRegistry;
-import io.micrometer.tracing.Tracer;
-import io.micrometer.tracing.handler.DefaultTracingObservationHandler;
-import io.micrometer.tracing.otel.bridge.OtelBaggageManager;
-import io.micrometer.tracing.otel.bridge.OtelCurrentTraceContext;
-import io.micrometer.tracing.otel.bridge.OtelTracer;
-import io.opentelemetry.api.GlobalOpenTelemetry;
 import org.springframework.boot.SpringBootConfiguration;
 import org.springframework.boot.WebApplicationType;
 import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
 import org.springframework.boot.builder.SpringApplicationBuilder;
 import org.springframework.context.ConfigurableApplicationContext;
+import org.springframework.context.annotation.Import;
+
+import org.apache.causeway.core.config.observation.CausewayObservationConfiguration;
+import org.apache.causeway.core.config.observation.CausewayObservationIntegration;
+import org.apache.causeway.core.config.observation.ObservationClosure;
 
 /**
- * Runs in a child JVM so the compatibility test can attach the real OpenTelemetry Java agent.
+ * Child-process application launched by {@link MicrometerTracingCompatibilityTest}.
+ *
+ * <p>The fixture needs a {@code main} method because the automated JUnit test starts a new JVM
+ * with the real OpenTelemetry {@code -javaagent} attached before application classes load.</p>
  */
 @SpringBootConfiguration(proxyBeanMethods = false)
 @EnableAutoConfiguration
+@Import(CausewayObservationConfiguration.class)
 public final class MicrometerTracingAgentFixture {
 
     static final String OBSERVATION_NAME = "causeway.compatibility.jdbc";
@@ -52,48 +52,33 @@ private MicrometerTracingAgentFixture() {
     }
 
     public static void main(final String[] args) throws Exception {
-        try (ConfigurableApplicationContext ignored = new SpringApplicationBuilder(
+        try (ConfigurableApplicationContext context = new SpringApplicationBuilder(
                 MicrometerTracingAgentFixture.class)
                 .web(WebApplicationType.NONE)
+                .profiles("observation")
                 .logStartupInfo(false)
                 .properties("spring.main.banner-mode=off")
                 .run(args)) {
-            final ObservationRegistry observationRegistry = observationRegistry();
-            final Observation observation = Observation.createNotStarted(
-                    OBSERVATION_NAME, observationRegistry);
+            final CausewayObservationIntegration observationIntegration = context.getBean(
+                    CausewayObservationIntegration.class);
+            final ObservationClosure observationClosure = new ObservationClosure()
+                    .startAndOpenScope(observationIntegration.createNotStarted(
+                            MicrometerTracingAgentFixture.class,
+                            OBSERVATION_NAME));
 
-            observation.start();
-            try (Observation.Scope scope = observation.openScope()) {
+            try {
                 executeJdbcWork();
-            } catch (Exception ex) {
-                observation.error(ex);
+            } catch (Exception | Error ex) {
+                observationClosure.onError(ex);
                 throw ex;
             } finally {
-                observation.stop();
+                observationClosure.close();
             }
 
             System.out.println(SUCCESS_MARKER);
         }
     }
 
-    private static ObservationRegistry observationRegistry() {
-        final OtelCurrentTraceContext currentTraceContext = new OtelCurrentTraceContext();
-        final OtelBaggageManager baggageManager = new OtelBaggageManager(
-                currentTraceContext,
-                Collections.emptyList(),
-                Collections.emptyList());
-        final Tracer tracer = new OtelTracer(
-                GlobalOpenTelemetry.getTracer("org.apache.causeway.compatibility"),
-                currentTraceContext,
-                event -> { },
-                baggageManager);
-
-        final ObservationRegistry observationRegistry = ObservationRegistry.create();
-        observationRegistry.observationConfig()
-                .observationHandler(new DefaultTracingObservationHandler(tracer));
-        return observationRegistry;
-    }
-
     private static void executeJdbcWork() throws Exception {
         Class.forName("org.h2.Driver");
         try (Connection connection = DriverManager.getConnection(
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 a08188020fc..dd2329e068a 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
@@ -47,6 +47,12 @@
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
+/**
+ * Automated JUnit coverage for the production observation substrate with and without the agent.
+ *
+ * <p>Each test launches {@link MicrometerTracingAgentFixture} in a child JVM because Java agents
+ * must be attached during JVM startup.</p>
+ */
 class MicrometerTracingCompatibilityTest {
 
     private static final Duration PROCESS_TIMEOUT = Duration.ofSeconds(45);