diff --git a/pom.xml b/pom.xml
index cbc803fe7..728d28bf1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -154,6 +154,7 @@
1.78
1.14.13
0.0.5
+ 1.13.5
diff --git a/spring-ws-core/pom.xml b/spring-ws-core/pom.xml
index 9b2d0f93f..da544ab66 100644
--- a/spring-ws-core/pom.xml
+++ b/spring-ws-core/pom.xml
@@ -60,6 +60,12 @@
+
+ io.micrometer
+ micrometer-observation
+ ${micrometer-observation.version}
+ true
+
org.springframework
spring-test
@@ -244,6 +250,12 @@
spring-webflux
test
+
+ io.micrometer
+ micrometer-observation-test
+ ${micrometer-observation.version}
+ test
+
diff --git a/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/DefaultWebServiceTemplateConvention.java b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/DefaultWebServiceTemplateConvention.java
new file mode 100644
index 000000000..baf5452ed
--- /dev/null
+++ b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/DefaultWebServiceTemplateConvention.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.client.core.observation;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.common.KeyValues;
+import org.springframework.ws.client.core.observation.WebServiceTemplateObservationDocumentation.LowCardinalityKeyNames;
+
+/**
+ * ObservationConvention that describes how a WebServiceTemplate is observed.
+ * @author Johan Kindgren
+ */
+public class DefaultWebServiceTemplateConvention implements WebServiceTemplateConvention {
+
+ private static final KeyValue EXCEPTION_NONE = KeyValue.of(LowCardinalityKeyNames.EXCEPTION,
+ KeyValue.NONE_VALUE);
+ private static final String NAME = "webservice.client";
+
+ @Override
+ public KeyValues getHighCardinalityKeyValues(WebServiceTemplateObservationContext context) {
+ if (context.getPath() != null) {
+ return KeyValues.of(path(context));
+ }
+ return KeyValues.empty();
+ }
+
+ @Override
+ public KeyValues getLowCardinalityKeyValues(WebServiceTemplateObservationContext context) {
+ return KeyValues.of(
+ exception(context),
+ host(context),
+ localname(context),
+ namespace(context),
+ outcome(context),
+ soapAction(context));
+ }
+
+ private KeyValue path(WebServiceTemplateObservationContext context) {
+
+ return WebServiceTemplateObservationDocumentation.HighCardinalityKeyNames
+ .PATH
+ .withValue(context.getPath());
+ }
+
+ private KeyValue localname(WebServiceTemplateObservationContext context) {
+ return LowCardinalityKeyNames
+ .LOCALPART
+ .withValue(context.getLocalPart());
+ }
+
+ private KeyValue namespace(WebServiceTemplateObservationContext context) {
+ return LowCardinalityKeyNames
+ .NAMESPACE
+ .withValue(context.getNamespace());
+ }
+ private KeyValue host(WebServiceTemplateObservationContext context) {
+ return LowCardinalityKeyNames
+ .HOST
+ .withValue(context.getHost());
+ }
+
+
+ private KeyValue outcome(WebServiceTemplateObservationContext context) {
+ return LowCardinalityKeyNames
+ .OUTCOME
+ .withValue(context.getOutcome());
+ }
+
+ private KeyValue soapAction(WebServiceTemplateObservationContext context) {
+ return LowCardinalityKeyNames
+ .SOAPACTION
+ .withValue(context.getSoapAction());
+ }
+
+ private KeyValue exception(WebServiceTemplateObservationContext context) {
+ if (context.getError() != null) {
+ return LowCardinalityKeyNames
+ .EXCEPTION
+ .withValue(context.getError().getClass().getSimpleName());
+ }
+ return EXCEPTION_NONE;
+ }
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ public String getContextualName(WebServiceTemplateObservationContext context) {
+ return context.getContextualName();
+ }
+}
diff --git a/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceObservationInterceptor.java b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceObservationInterceptor.java
new file mode 100644
index 000000000..f545c44d2
--- /dev/null
+++ b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceObservationInterceptor.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.client.core.observation;
+
+import io.micrometer.common.util.internal.logging.WarnThenDebugLogger;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+import org.springframework.ws.FaultAwareWebServiceMessage;
+import org.springframework.ws.WebServiceMessage;
+import org.springframework.ws.client.WebServiceClientException;
+import org.springframework.ws.client.support.interceptor.ClientInterceptorAdapter;
+import org.springframework.ws.context.MessageContext;
+import org.springframework.ws.soap.SoapMessage;
+import org.springframework.ws.support.ObservationHelper;
+import org.springframework.ws.transport.HeadersAwareSenderWebServiceConnection;
+import org.springframework.ws.transport.TransportConstants;
+import org.springframework.ws.transport.WebServiceConnection;
+import org.springframework.ws.transport.context.TransportContext;
+import org.springframework.ws.transport.context.TransportContextHolder;
+
+import javax.xml.namespace.QName;
+import javax.xml.transform.Source;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+/**
+ * Interceptor that creates an Observation for each operation.
+ *
+ * @author Johan Kindgren
+ * @see Observation
+ * @see io.micrometer.observation.ObservationConvention
+ */
+public class WebServiceObservationInterceptor extends ClientInterceptorAdapter {
+
+ private static final WarnThenDebugLogger WARN_THEN_DEBUG_LOGGER = new WarnThenDebugLogger(WebServiceObservationInterceptor.class);
+ private static final String OBSERVATION_KEY = "observation";
+ private static final WebServiceTemplateConvention DEFAULT_CONVENTION = new DefaultWebServiceTemplateConvention();
+
+ private final ObservationRegistry observationRegistry;
+
+ private final WebServiceTemplateConvention customConvention;
+ private final ObservationHelper observationHelper;
+
+ public WebServiceObservationInterceptor(
+ @NonNull
+ ObservationRegistry observationRegistry,
+ @NonNull
+ ObservationHelper observationHelper,
+ @Nullable
+ WebServiceTemplateConvention customConvention) {
+
+ this.observationRegistry = observationRegistry;
+ this.observationHelper = observationHelper;
+ this.customConvention = customConvention;
+ }
+
+
+ @Override
+ public boolean handleRequest(MessageContext messageContext) throws WebServiceClientException {
+
+ TransportContext transportContext = TransportContextHolder.getTransportContext();
+ HeadersAwareSenderWebServiceConnection connection =
+ (HeadersAwareSenderWebServiceConnection) transportContext.getConnection();
+
+ Observation observation = WebServiceTemplateObservationDocumentation.WEB_SERVICE_TEMPLATE.start(
+ customConvention,
+ DEFAULT_CONVENTION,
+ () -> new WebServiceTemplateObservationContext(connection),
+ observationRegistry);
+
+ messageContext.setProperty(OBSERVATION_KEY, observation);
+
+ return true;
+ }
+
+ @Override
+ public void afterCompletion(MessageContext messageContext, Exception ex) {
+
+ Observation observation = (Observation) messageContext.getProperty(OBSERVATION_KEY);
+ if (observation == null) {
+ WARN_THEN_DEBUG_LOGGER.log("Missing expected Observation in messageContext; the request will not be observed.");
+ return;
+ }
+
+ WebServiceTemplateObservationContext context = (WebServiceTemplateObservationContext) observation.getContext();
+
+ WebServiceMessage request = messageContext.getRequest();
+ WebServiceMessage response = messageContext.getResponse();
+
+ if (request instanceof SoapMessage soapMessage) {
+
+ Source source = soapMessage.getSoapBody().getPayloadSource();
+ QName root = observationHelper.getRootElement(source);
+ if (root != null) {
+ context.setLocalPart(root.getLocalPart());
+ context.setNamespace(root.getNamespaceURI());
+ }
+ if (soapMessage.getSoapAction() != null && !soapMessage.getSoapAction().equals(TransportConstants.EMPTY_SOAP_ACTION)) {
+ context.setSoapAction(soapMessage.getSoapAction());
+ }
+ }
+
+ if (ex == null) {
+ context.setOutcome("success");
+ } else {
+ context.setError(ex);
+ context.setOutcome("fault");
+ }
+
+ if (response instanceof FaultAwareWebServiceMessage faultAwareResponse) {
+ if (faultAwareResponse.hasFault()) {
+ context.setOutcome("fault");
+ }
+ }
+
+ URI uri = getUriFromConnection();
+ if (uri != null) {
+ context.setHost(uri.getHost());
+ context.setPath(uri.getPath());
+ }
+
+ context.setContextualName("POST");
+
+ observation.stop();
+ }
+
+ URI getUriFromConnection() {
+ TransportContext transportContext = TransportContextHolder.getTransportContext();
+ WebServiceConnection connection = transportContext.getConnection();
+ try {
+ return connection.getUri();
+ } catch (URISyntaxException e) {
+ return null;
+ }
+ }
+}
+
diff --git a/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateConvention.java b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateConvention.java
new file mode 100644
index 000000000..fe2d96daa
--- /dev/null
+++ b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateConvention.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.client.core.observation;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationConvention;
+
+/**
+ * ObservationConvention that can be implemented to create a custom observation.
+ * @author Johan Kindgren
+ */
+public interface WebServiceTemplateConvention extends ObservationConvention {
+
+ @Override
+ default boolean supportsContext(Observation.Context context) {
+ return context instanceof WebServiceTemplateObservationContext;
+ }
+}
diff --git a/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationContext.java b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationContext.java
new file mode 100644
index 000000000..d58e4f2f3
--- /dev/null
+++ b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationContext.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.client.core.observation;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.common.util.internal.logging.WarnThenDebugLogger;
+import io.micrometer.observation.transport.RequestReplySenderContext;
+import org.springframework.ws.transport.HeadersAwareSenderWebServiceConnection;
+import org.springframework.ws.transport.TransportInputStream;
+
+import java.io.IOException;
+/**
+ * ObservationContext used to instrument a WebServiceTemplate operation.
+ * @author Johan Kindgren
+ */
+public class WebServiceTemplateObservationContext extends RequestReplySenderContext {
+
+ private static final WarnThenDebugLogger WARN_THEN_DEBUG_LOGGER = new WarnThenDebugLogger(WebServiceTemplateObservationContext.class);
+
+ public static final String UNKNOWN = "unknown";
+ private String outcome = UNKNOWN;
+ private String localPart = UNKNOWN;
+ private String namespace = UNKNOWN;
+ private String host = UNKNOWN;
+ private String soapAction = KeyValue.NONE_VALUE;
+ private String path = null;
+
+ public WebServiceTemplateObservationContext(HeadersAwareSenderWebServiceConnection connection) {
+ super((carrier, key, value) -> {
+
+ if (carrier != null) {
+ try {
+ carrier.addRequestHeader(key, value);
+ } catch (IOException e) {
+ WARN_THEN_DEBUG_LOGGER.log("Could not add key to carrier", e);
+ }
+ }
+ });
+ setCarrier(connection);
+ }
+
+ public String getOutcome() {
+ return outcome;
+ }
+
+ public void setOutcome(String outcome) {
+ this.outcome = outcome;
+ }
+
+ public String getLocalPart() {
+ return localPart;
+ }
+
+ public void setLocalPart(String localPart) {
+ this.localPart = localPart;
+ }
+
+ public String getNamespace() {
+ return namespace;
+ }
+
+ public void setNamespace(String namespace) {
+ this.namespace = namespace;
+ }
+
+ public String getHost() {
+ return host;
+ }
+
+ public void setHost(String host) {
+ this.host = host;
+ }
+
+ public String getSoapAction() {
+ return soapAction;
+ }
+
+ public void setSoapAction(String soapAction) {
+ this.soapAction = soapAction;
+ }
+
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ public String getPath() {
+ return path;
+ }
+}
diff --git a/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationDocumentation.java b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationDocumentation.java
new file mode 100644
index 000000000..9c4ff4758
--- /dev/null
+++ b/spring-ws-core/src/main/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationDocumentation.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.client.core.observation;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.common.docs.KeyName;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationConvention;
+import io.micrometer.observation.docs.ObservationDocumentation;
+
+/**
+ * ObservationDocumentation for WebSeviceTemplate.
+ *
+ * @author Johan Kindgren
+ */
+enum WebServiceTemplateObservationDocumentation implements ObservationDocumentation {
+ /**
+ * This enum constant defines observation documentation for the WebServiceTemplate.
+ * It provides the default observation convention and low cardinality key names
+ * relevant to WebService operations.
+ */
+ WEB_SERVICE_TEMPLATE {
+
+ @Override
+ public Class extends ObservationConvention extends Observation.Context>> getDefaultConvention() {
+ return DefaultWebServiceTemplateConvention.class;
+ }
+
+ @Override
+ public KeyName[] getLowCardinalityKeyNames() {
+ return LowCardinalityKeyNames.values();
+ }
+
+ @Override
+ public KeyName[] getHighCardinalityKeyNames() {
+ return HighCardinalityKeyNames.values();
+ }
+ };
+
+ enum HighCardinalityKeyNames implements KeyName {
+ /**
+ * Path for the client request.
+ * Optional value.
+ */
+ PATH {
+ @Override
+ public String asString() {
+ return "path";
+ }
+
+ @Override
+ public boolean isRequired() {
+ return false;
+ }
+ }
+ }
+
+ /**
+ * Enum representing low cardinality key names for observing a WebServiceTemplate.
+ */
+ enum LowCardinalityKeyNames implements KeyName {
+
+ /**
+ * Name of the exception thrown during the exchange,
+ * or {@value KeyValue#NONE_VALUE} if no exception happened.
+ */
+ EXCEPTION {
+ @Override
+ public String asString() {
+ return "exception";
+ }
+ },
+
+ /**
+ * Outcome of the WebService exchange.
+ */
+ OUTCOME {
+ @Override
+ public String asString() {
+ return "outcome";
+ }
+ },
+ /**
+ * Namespace of the WebService payload.
+ */
+ NAMESPACE {
+ @Override
+ public String asString() {
+ return "namespace";
+ }
+ },
+
+ /**
+ * Localpart of the WebService payload.
+ */
+ LOCALPART {
+ @Override
+ public String asString() {
+ return "localpart";
+ }
+ },
+ /**
+ * Host for the WebService call.
+ */
+ HOST {
+ @Override
+ public String asString() {
+ return "host";
+ }
+ },
+ /**
+ * Value from the SoapAction header.
+ */
+ SOAPACTION {
+ @Override
+ public String asString() {
+ return "soapaction";
+ }
+ }
+ }
+}
diff --git a/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/DefaultWebServiceEndpointConvention.java b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/DefaultWebServiceEndpointConvention.java
new file mode 100644
index 000000000..21e453425
--- /dev/null
+++ b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/DefaultWebServiceEndpointConvention.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.server.endpoint.observation;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.common.KeyValues;
+import io.micrometer.observation.Observation;
+
+/**
+ * Default ObservationConvention for a WebService Endpoint.
+ * @author Johan Kindgren
+ */
+public class DefaultWebServiceEndpointConvention implements WebServiceEndpointConvention {
+
+ private static final KeyValue EXCEPTION_NONE = KeyValue.of(EndpointObservationDocumentation.LowCardinalityKeyNames.EXCEPTION, KeyValue.NONE_VALUE);
+ private static final String NAME = "webservice.server";
+
+
+ @Override
+ public KeyValues getLowCardinalityKeyValues(WebServiceEndpointContext context) {
+ return KeyValues.of(
+ exception(context),
+ localPart(context),
+ namespace(context),
+ outcome(context),
+ path(context),
+ soapAction(context));
+ }
+
+ @Override
+ public KeyValues getHighCardinalityKeyValues(WebServiceEndpointContext context) {
+ if (context.getPathInfo() != null) {
+ return KeyValues.of(pathInfo(context));
+ }
+ return KeyValues.empty();
+ }
+
+ private KeyValue localPart(WebServiceEndpointContext context) {
+ return EndpointObservationDocumentation
+ .LowCardinalityKeyNames
+ .LOCALPART
+ .withValue(context.getLocalPart());
+ }
+
+ private KeyValue namespace(WebServiceEndpointContext context) {
+ return EndpointObservationDocumentation
+ .LowCardinalityKeyNames
+ .NAMESPACE
+ .withValue(context.getNamespace());
+ }
+
+
+ private KeyValue outcome(WebServiceEndpointContext context) {
+ return EndpointObservationDocumentation
+ .LowCardinalityKeyNames
+ .OUTCOME
+ .withValue(context.getOutcome());
+ }
+
+ private KeyValue soapAction(WebServiceEndpointContext context) {
+ return EndpointObservationDocumentation
+ .LowCardinalityKeyNames
+ .SOAPACTION
+ .withValue(context.getSoapAction());
+ }
+
+ private KeyValue exception(WebServiceEndpointContext context) {
+ if (context.getError() != null) {
+ return EndpointObservationDocumentation
+ .LowCardinalityKeyNames
+ .EXCEPTION
+ .withValue(context.getError().getClass().getSimpleName());
+ } else {
+ return EXCEPTION_NONE;
+ }
+ }
+
+ private KeyValue path(WebServiceEndpointContext context) {
+ return EndpointObservationDocumentation
+ .LowCardinalityKeyNames
+ .PATH
+ .withValue(context.getPath());
+ }
+
+ private KeyValue pathInfo(WebServiceEndpointContext context) {
+ if (context.getPathInfo() != null) {
+ return EndpointObservationDocumentation
+ .HighCardinalityKeyNames
+ .PATH_INFO
+ .withValue(context.getPathInfo());
+ }
+ return null;
+ }
+
+ @Override
+ public String getName() {
+ return NAME;
+ }
+
+ @Override
+ public String getContextualName(WebServiceEndpointContext context) {
+ return context.getContextualName();
+ }
+
+ @Override
+ public boolean supportsContext(Observation.Context context) {
+ return context instanceof WebServiceEndpointContext;
+ }
+
+}
diff --git a/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/EndpointObservationDocumentation.java b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/EndpointObservationDocumentation.java
new file mode 100644
index 000000000..37d782657
--- /dev/null
+++ b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/EndpointObservationDocumentation.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.server.endpoint.observation;
+
+import io.micrometer.common.KeyValue;
+import io.micrometer.common.docs.KeyName;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationConvention;
+import io.micrometer.observation.docs.ObservationDocumentation;
+
+/**
+ * ObservationDocumentation for a WebService Endpoint.
+ *
+ * @author Johan Kindgren
+ */
+enum EndpointObservationDocumentation implements ObservationDocumentation {
+ /**
+ * An enumeration for ObservationDocumentation related to WebService Endpoint.
+ *
+ * The {@code WEB_SERVICE_ENDPOINT} provides default conventions and low cardinality key names for
+ * observing a WebService endpoint.
+ *
+ * This implementation returns the {@link DefaultWebServiceEndpointConvention} class as the default convention,
+ * and an array of {@link LowCardinalityKeyNames} for low cardinality key names.
+ */
+ WEB_SERVICE_ENDPOINT {
+ @Override
+ public Class extends ObservationConvention extends Observation.Context>> getDefaultConvention() {
+ return DefaultWebServiceEndpointConvention.class;
+ }
+
+ @Override
+ public KeyName[] getLowCardinalityKeyNames() {
+ return LowCardinalityKeyNames.values();
+ }
+
+ @Override
+ public KeyName[] getHighCardinalityKeyNames() {
+ return HighCardinalityKeyNames.values();
+ }
+ };
+
+ enum HighCardinalityKeyNames implements KeyName {
+
+ /**
+ * Possible
+ */
+ PATH_INFO {
+ @Override
+ public String asString() {
+ return "pathinfo";
+ }
+
+ @Override
+ public boolean isRequired() {
+ return false;
+ }
+ }
+ }
+
+ /**
+ * Enum representing low cardinality key names for observing a WebService endpoint.
+ */
+ enum LowCardinalityKeyNames implements KeyName {
+
+ /**
+ * Name of the exception thrown during the exchange,
+ * or {@value KeyValue#NONE_VALUE} if no exception happened.
+ */
+ EXCEPTION {
+ @Override
+ public String asString() {
+ return "exception";
+ }
+ },
+
+ /**
+ * Outcome of the WebService exchange.
+ */
+ OUTCOME {
+ @Override
+ public String asString() {
+ return "outcome";
+ }
+ },
+ /**
+ * Namespace of the WebService payload.
+ */
+ NAMESPACE {
+ @Override
+ public String asString() {
+ return "namespace";
+ }
+ },
+ /**
+ * Localpart of the WebService payload.
+ */
+ LOCALPART {
+ @Override
+ public String asString() {
+ return "localpart";
+ }
+ },
+
+ /**
+ * Value from the SoapAction header.
+ */
+ SOAPACTION {
+ @Override
+ public String asString() {
+ return "soapaction";
+ }
+ },
+ /**
+ * Path for the current request.
+ */
+ PATH {
+ @Override
+ public String asString() {
+ return "path";
+ }
+ }
+ }
+}
diff --git a/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptor.java b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptor.java
new file mode 100644
index 000000000..7f11f0974
--- /dev/null
+++ b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptor.java
@@ -0,0 +1,149 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.server.endpoint.observation;
+
+import io.micrometer.common.util.internal.logging.WarnThenDebugLogger;
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationRegistry;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+import org.springframework.ws.FaultAwareWebServiceMessage;
+import org.springframework.ws.WebServiceMessage;
+import org.springframework.ws.context.MessageContext;
+import org.springframework.ws.server.endpoint.interceptor.EndpointInterceptorAdapter;
+import org.springframework.ws.soap.SoapMessage;
+import org.springframework.ws.support.ObservationHelper;
+import org.springframework.ws.transport.HeadersAwareReceiverWebServiceConnection;
+import org.springframework.ws.transport.TransportConstants;
+import org.springframework.ws.transport.context.TransportContext;
+import org.springframework.ws.transport.context.TransportContextHolder;
+import org.springframework.ws.transport.http.HttpServletConnection;
+
+import javax.xml.namespace.QName;
+import javax.xml.transform.Source;
+
+/**
+ * Interceptor implementation that creates an observation for a WebService Endpoint.
+ * @author Johan Kindgren
+ */
+public class ObservationInterceptor extends EndpointInterceptorAdapter {
+
+ private static final WarnThenDebugLogger WARN_THEN_DEBUG_LOGGER = new WarnThenDebugLogger(ObservationInterceptor.class);
+ private static final String OBSERVATION_KEY = "observation";
+ private static final WebServiceEndpointConvention DEFAULT_CONVENTION = new DefaultWebServiceEndpointConvention();
+
+ private final ObservationRegistry observationRegistry;
+ private final ObservationHelper observationHelper;
+ private final WebServiceEndpointConvention customConvention;
+
+ public ObservationInterceptor(
+ @NonNull
+ ObservationRegistry observationRegistry,
+ @NonNull
+ ObservationHelper observationHelper,
+ @Nullable
+ WebServiceEndpointConvention customConvention) {
+ this.observationRegistry = observationRegistry;
+ this.observationHelper = observationHelper;
+ this.customConvention = customConvention;
+ }
+
+ @Override
+ public boolean handleRequest(MessageContext messageContext, Object endpoint) throws Exception {
+
+ TransportContext transportContext = TransportContextHolder.getTransportContext();
+ HeadersAwareReceiverWebServiceConnection connection =
+ (HeadersAwareReceiverWebServiceConnection) transportContext.getConnection();
+
+ Observation observation = EndpointObservationDocumentation.WEB_SERVICE_ENDPOINT.start(
+ customConvention,
+ DEFAULT_CONVENTION,
+ () -> new WebServiceEndpointContext(connection),
+ observationRegistry);
+
+ messageContext.setProperty(OBSERVATION_KEY, observation);
+
+ return true;
+ }
+
+ @Override
+ public void afterCompletion(MessageContext messageContext, Object endpoint, @Nullable Exception ex) {
+
+ Observation observation = (Observation) messageContext.getProperty(OBSERVATION_KEY);
+ if (observation == null) {
+ WARN_THEN_DEBUG_LOGGER.log("Missing expected Observation in messageContext; the request will not be observed.");
+ return;
+ }
+
+ WebServiceEndpointContext context = (WebServiceEndpointContext) observation.getContext();
+
+ WebServiceMessage request = messageContext.getRequest();
+ WebServiceMessage response = messageContext.getResponse();
+
+ if (request instanceof SoapMessage soapMessage) {
+
+ Source source = soapMessage.getSoapBody().getPayloadSource();
+ QName root = observationHelper.getRootElement(source);
+ if (root != null) {
+ context.setLocalPart(root.getLocalPart());
+ context.setNamespace(root.getNamespaceURI());
+ }
+ String action = soapMessage.getSoapAction();
+ if (!TransportConstants.EMPTY_SOAP_ACTION.equals(action)) {
+ context.setSoapAction(soapMessage.getSoapAction());
+ } else {
+ context.setSoapAction("none");
+ }
+ }
+
+ if (ex == null) {
+ context.setOutcome("success");
+ } else {
+ context.setError(ex);
+ context.setOutcome("fault");
+ }
+
+ if (response instanceof FaultAwareWebServiceMessage faultAwareResponse) {
+ if (faultAwareResponse.hasFault()) {
+ context.setOutcome("fault");
+ }
+ }
+
+ TransportContext transportContext = TransportContextHolder.getTransportContext();
+ HeadersAwareReceiverWebServiceConnection connection =
+ (HeadersAwareReceiverWebServiceConnection) transportContext.getConnection();
+
+ if (connection instanceof HttpServletConnection servletConnection) {
+ HttpServletRequest servletRequest = servletConnection.getHttpServletRequest();
+ String servletPath = servletRequest.getServletPath();
+ String pathInfo = servletRequest.getPathInfo();
+
+ if (pathInfo != null) {
+ context.setContextualName("POST " + servletPath + "/{pathInfo}");
+ context.setPath(servletPath + "/{pathInfo}");
+ context.setPathInfo(pathInfo);
+ } else {
+ context.setPath(servletPath);
+ context.setContextualName("POST " + servletPath);
+ }
+ } else {
+ context.setContextualName("POST");
+ }
+
+ observation.stop();
+ }
+}
diff --git a/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointContext.java b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointContext.java
new file mode 100644
index 000000000..c4d9d3d0f
--- /dev/null
+++ b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointContext.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.server.endpoint.observation;
+
+import io.micrometer.common.util.internal.logging.WarnThenDebugLogger;
+import io.micrometer.observation.transport.RequestReplyReceiverContext;
+import org.springframework.ws.transport.HeadersAwareReceiverWebServiceConnection;
+import org.springframework.ws.transport.TransportInputStream;
+
+import java.io.IOException;
+import java.util.Iterator;
+
+/**
+ * ObservationContext that describes how a WebService Endpoint is observed.
+ * @author Johan Kindgren
+ */
+public class WebServiceEndpointContext extends RequestReplyReceiverContext {
+
+ private static final WarnThenDebugLogger WARN_THEN_DEBUG_LOGGER = new WarnThenDebugLogger(WebServiceEndpointContext.class);
+ private static final String UNKNOWN = "unknown";
+
+ private String outcome = UNKNOWN;
+ private String localPart = UNKNOWN;
+ private String namespace = UNKNOWN;
+ private String soapAction = UNKNOWN;
+ private String path = UNKNOWN;
+ private String pathInfo = null;
+
+ public WebServiceEndpointContext(HeadersAwareReceiverWebServiceConnection connection) {
+ super((carrier, key) -> {
+ try {
+ Iterator headers = carrier.getRequestHeaders(key);
+ if (headers.hasNext()) {
+ return headers.next();
+ }
+ } catch (IOException e) {
+ WARN_THEN_DEBUG_LOGGER.log("Could not read key from carrier", e);
+ }
+ return null;
+ });
+ setCarrier(connection);
+ }
+
+ public String getOutcome() {
+ return outcome;
+ }
+
+ public void setOutcome(String outcome) {
+ this.outcome = outcome;
+ }
+
+ public String getLocalPart() {
+ return localPart;
+ }
+
+ public void setLocalPart(String localPart) {
+ this.localPart = localPart;
+ }
+
+ public String getNamespace() {
+ return namespace;
+ }
+
+ public void setNamespace(String namespace) {
+ this.namespace = namespace;
+ }
+
+ public String getSoapAction() {
+ return soapAction;
+ }
+
+ public void setSoapAction(String soapAction) {
+ this.soapAction = soapAction;
+ }
+
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public void setPathInfo(String pathInfo) {
+ this.pathInfo = pathInfo;
+ }
+
+ public String getPathInfo() {
+ return pathInfo;
+ }
+}
diff --git a/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointConvention.java b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointConvention.java
new file mode 100644
index 000000000..4b5dd65ef
--- /dev/null
+++ b/spring-ws-core/src/main/java/org/springframework/ws/server/endpoint/observation/WebServiceEndpointConvention.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.server.endpoint.observation;
+
+import io.micrometer.observation.Observation;
+import io.micrometer.observation.ObservationConvention;
+
+/**
+ * ObservationConvention that describes how a WebService Endpoint is observed.
+ * @author Johan Kindgren
+ */
+public interface WebServiceEndpointConvention extends ObservationConvention {
+
+ @Override
+ default boolean supportsContext(Observation.Context context) {
+ return context instanceof WebServiceEndpointContext;
+ }
+
+}
diff --git a/spring-ws-core/src/main/java/org/springframework/ws/support/ObservationHelper.java b/spring-ws-core/src/main/java/org/springframework/ws/support/ObservationHelper.java
new file mode 100644
index 000000000..39f77716e
--- /dev/null
+++ b/spring-ws-core/src/main/java/org/springframework/ws/support/ObservationHelper.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.support;
+
+import io.micrometer.common.util.internal.logging.WarnThenDebugLogger;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.springframework.ws.server.endpoint.observation.ObservationInterceptor;
+import org.w3c.dom.Node;
+import org.xml.sax.Attributes;
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import javax.xml.namespace.QName;
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+import javax.xml.transform.Source;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.sax.SAXSource;
+import javax.xml.transform.stream.StreamSource;
+import java.io.IOException;
+
+/**
+ * Helper class for observation tasks.
+ * @author Johan Kindgren
+ */
+public class ObservationHelper {
+
+ private final Log logger = LogFactory.getLog(getClass());
+
+ private static final WarnThenDebugLogger WARN_THEN_DEBUG_LOGGER = new WarnThenDebugLogger(ObservationInterceptor.class);
+ private static final QName UNKNOWN_Q_NAME = new QName("unknown", "unknow");
+
+ private final SAXParser saxParser;
+
+ public ObservationHelper() {
+ SAXParserFactory parserFactory = SAXParserFactory.newNSInstance();
+ SAXParser parser = null;
+ try {
+ parser = parserFactory.newSAXParser();
+ } catch (ParserConfigurationException | SAXException e) {
+ logger.warn("Could not create SAX parser, observation keys for Root element can be reported as 'unknown'.", e);
+ }
+ saxParser = parser;
+ }
+
+
+ /**
+ * Try to find the root element QName for the given source.
+ * If it isn't possible to extract the QName, a QName with the values 'unknown:unknown' is returned.
+ */
+ public QName getRootElement(Source source) {
+ if (source instanceof DOMSource) {
+ Node payload = ((DOMSource) source).getNode();
+ if (payload.getNodeType() == Node.ELEMENT_NODE) {
+ return new QName(payload.getNamespaceURI(), payload.getLocalName());
+ }
+ return UNKNOWN_Q_NAME;
+ }
+ if (source instanceof StreamSource) {
+ if (saxParser == null) {
+ WARN_THEN_DEBUG_LOGGER.log("SaxParser not available, reporting Root element as 'unknown'");
+ return UNKNOWN_Q_NAME;
+ }
+ RootElementSAXHandler handler = new RootElementSAXHandler();
+ try {
+ saxParser.parse(getInputSource((StreamSource) source), handler);
+ return handler.getRootElementName();
+ } catch (SAXException | IOException e) {
+ WARN_THEN_DEBUG_LOGGER.log("Exception while handling request, reporting Root element as 'unknown'", e);
+ return UNKNOWN_Q_NAME;
+ }
+ }
+ if (source instanceof SAXSource) {
+ if (saxParser == null) {
+ WARN_THEN_DEBUG_LOGGER.log("SaxParser not available, reporting Root element as 'unknown'");
+ return UNKNOWN_Q_NAME;
+ }
+ RootElementSAXHandler handler = new RootElementSAXHandler();
+ try {
+ saxParser.parse(getInputSource((SAXSource) source), handler);
+ return handler.getRootElementName();
+ } catch (SAXException | IOException e) {
+ WARN_THEN_DEBUG_LOGGER.log("Exception while handling request, reporting Root element as 'unknown'", e);
+ return UNKNOWN_Q_NAME;
+ }
+ }
+ return UNKNOWN_Q_NAME;
+ }
+
+ InputSource getInputSource(StreamSource source) {
+
+ if (source.getInputStream() != null) {
+ return new InputSource(source.getInputStream());
+ }
+ return new InputSource(source.getReader());
+ }
+
+ InputSource getInputSource(SAXSource source) {
+ return source.getInputSource();
+ }
+
+
+
+ /**
+ * DefaultHandler that extracts the root elements namespace and name.
+ * @author Johan Kindgren
+ */
+ static class RootElementSAXHandler extends DefaultHandler {
+
+ private QName rootElementName = null;
+
+ @Override
+ public void startElement(String uri, String localName, String qName, Attributes attributes) {
+ if (rootElementName == null) {
+ rootElementName = new QName(uri, localName);
+ }
+ }
+
+ public QName getRootElementName() {
+ return rootElementName;
+ }
+ }
+}
diff --git a/spring-ws-core/src/test/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationIntegrationTest.java b/spring-ws-core/src/test/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationIntegrationTest.java
new file mode 100644
index 000000000..3ca5e06ff
--- /dev/null
+++ b/spring-ws-core/src/test/java/org/springframework/ws/client/core/observation/WebServiceTemplateObservationIntegrationTest.java
@@ -0,0 +1,474 @@
+/*
+ * Copyright 2005-2022 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.client.core.observation;
+
+import io.micrometer.observation.tck.TestObservationRegistry;
+import io.micrometer.observation.tck.TestObservationRegistryAssert;
+import jakarta.activation.CommandMap;
+import jakarta.activation.DataHandler;
+import jakarta.activation.MailcapCommandMap;
+import jakarta.mail.util.ByteArrayDataSource;
+import jakarta.servlet.ServletConfig;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.xml.soap.*;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.oxm.Marshaller;
+import org.springframework.oxm.XmlMappingException;
+import org.springframework.ws.client.WebServiceTransportException;
+import org.springframework.ws.client.core.AbstractSoap12WebServiceTemplateIntegrationTestCase;
+import org.springframework.ws.client.core.WebServiceTemplate;
+import org.springframework.ws.client.support.interceptor.ClientInterceptor;
+import org.springframework.ws.soap.SoapMessage;
+import org.springframework.ws.soap.client.SoapFaultClientException;
+import org.springframework.ws.soap.saaj.SaajSoapMessageFactory;
+import org.springframework.ws.support.ObservationHelper;
+import org.springframework.ws.transport.http.HttpComponentsMessageSender;
+import org.springframework.ws.transport.support.FreePortScanner;
+import org.springframework.xml.transform.StringResult;
+import org.springframework.xml.transform.StringSource;
+import org.springframework.xml.transform.TransformerFactoryUtils;
+import org.xmlunit.assertj.XmlAssert;
+
+import javax.xml.transform.Result;
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerConfigurationException;
+import javax.xml.transform.TransformerException;
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.Iterator;
+import java.util.StringTokenizer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/**
+ * Verifies observation for a WebServiceTemplate
+ * @author Johan Kindgren
+ */
+public class WebServiceTemplateObservationIntegrationTest {
+
+ private TestObservationRegistry observationRegistry;
+ private ObservationHelper observationHelper;
+
+ private static Server jettyServer;
+
+ private static String baseUrl;
+
+ private WebServiceTemplate template;
+
+ private String messagePayload = "";
+
+ @BeforeAll
+ public static void startJetty() throws Exception {
+
+ int port = FreePortScanner.getFreePort();
+ baseUrl = "http://localhost:" + port;
+
+ jettyServer = new Server(port);
+ Connector connector = new ServerConnector(jettyServer);
+ jettyServer.addConnector(connector);
+
+ ServletContextHandler jettyContext = new ServletContextHandler();
+ jettyContext.setContextPath("/");
+
+ jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.EchoSoapServlet.class, "/soap/echo");
+ jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.SoapReceiverFaultServlet.class, "/soap/receiverFault");
+ jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.SoapSenderFaultServlet.class, "/soap/senderFault");
+ jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.NoResponseSoapServlet.class, "/soap/noResponse");
+ jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.AttachmentsServlet.class, "/soap/attachment");
+
+ ServletHolder notfound = jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.ErrorServlet.class, "/errors/notfound");
+ notfound.setInitParameter("sc", "404");
+
+ ServletHolder errors = jettyContext.addServlet(AbstractSoap12WebServiceTemplateIntegrationTestCase.ErrorServlet.class, "/errors/server");
+ errors.setInitParameter("sc", "500");
+
+ jettyServer.setHandler(jettyContext);
+ jettyServer.start();
+ }
+
+ @AfterAll
+ public static void stopJetty() throws Exception {
+
+ if (jettyServer.isRunning()) {
+ jettyServer.stop();
+ }
+ }
+
+ /**
+ * A workaround for the faulty XmlDataContentHandler in the SAAJ RI, which cannot handle mime types such as "text/xml;
+ * charset=UTF-8", causing issues with Axiom. We basically reset the command map
+ */
+ @BeforeEach
+ public void removeXmlDataContentHandler() throws SOAPException {
+
+ MessageFactory messageFactory = MessageFactory.newInstance();
+ SOAPMessage message = messageFactory.createMessage();
+ message.createAttachmentPart();
+ CommandMap.setDefaultCommandMap(new MailcapCommandMap());
+ }
+
+ @BeforeEach
+ public void createWebServiceTemplate() throws Exception {
+ observationRegistry = TestObservationRegistry.create();
+ observationHelper = new ObservationHelper();
+
+ template = new WebServiceTemplate(new SaajSoapMessageFactory(MessageFactory.newInstance(SOAPConstants.SOAP_1_2_PROTOCOL)));
+ template.setMessageSender(new HttpComponentsMessageSender());
+ template.setInterceptors(new ClientInterceptor[]{
+ new WebServiceObservationInterceptor(observationRegistry, observationHelper, null)
+ });
+ }
+
+
+ @Test
+ public void sendSourceAndReceiveToResult() {
+
+ StringResult result = new StringResult();
+ boolean b = template.sendSourceAndReceiveToResult(baseUrl + "/soap/echo", new StringSource(messagePayload), result);
+
+ assertThat(b).isTrue();
+ XmlAssert.assertThat(result.toString()).and(messagePayload).ignoreWhitespace().areIdentical();
+
+ TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert ->
+ observationContextAssert
+ .hasLowCardinalityKeyValue("outcome", "success")
+ .hasLowCardinalityKeyValue("exception", "none")
+ .hasLowCardinalityKeyValue("host", "localhost")
+ .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws")
+ .hasLowCardinalityKeyValue("localpart", "root")
+ .hasHighCardinalityKeyValue("path", "/soap/echo")
+ .hasContextualNameEqualTo("POST")
+ );
+ }
+
+ @Test
+ public void sendSourceAndReceiveToResultNoResponse() {
+
+ boolean b = template.sendSourceAndReceiveToResult(baseUrl + "/soap/noResponse", new StringSource(messagePayload),
+ new StringResult());
+ assertThat(b).isFalse();
+
+ TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert ->
+ observationContextAssert
+ .hasLowCardinalityKeyValue("outcome", "success")
+ .hasLowCardinalityKeyValue("exception", "none")
+ .hasLowCardinalityKeyValue("host", "localhost")
+ .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws")
+ .hasLowCardinalityKeyValue("localpart", "root")
+ .hasHighCardinalityKeyValue("path", "/soap/noResponse")
+ .hasContextualNameEqualTo("POST")
+ );
+ }
+
+
+ @Test
+ public void marshalSendAndReceiveNoResponse() throws TransformerConfigurationException {
+
+ final Transformer transformer = TransformerFactoryUtils.newInstance().newTransformer();
+ final Object requestObject = new Object();
+ Marshaller marshaller = new Marshaller() {
+
+ @Override
+ public void marshal(Object graph, Result result) throws XmlMappingException, IOException {
+
+ assertThat(requestObject).isEqualTo(graph);
+
+ try {
+ transformer.transform(new StringSource(messagePayload), result);
+ } catch (TransformerException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public boolean supports(Class> clazz) {
+
+ assertThat(clazz).isEqualTo(Object.class);
+ return true;
+ }
+ };
+
+ template.setMarshaller(marshaller);
+ Object result = template.marshalSendAndReceive(baseUrl + "/soap/noResponse", requestObject);
+
+ assertThat(result).isNull();
+
+ TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert ->
+ observationContextAssert
+ .hasLowCardinalityKeyValue("outcome", "success")
+ .hasLowCardinalityKeyValue("exception", "none")
+ .hasLowCardinalityKeyValue("host", "localhost")
+ .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws")
+ .hasLowCardinalityKeyValue("localpart", "root")
+ .hasHighCardinalityKeyValue("path", "/soap/noResponse")
+ .hasContextualNameEqualTo("POST")
+ );
+ }
+
+ @Test
+ public void notFound() {
+
+ assertThatExceptionOfType(WebServiceTransportException.class)
+ .isThrownBy(() -> template.sendSourceAndReceiveToResult(baseUrl + "/errors/notfound",
+ new StringSource(messagePayload), new StringResult()));
+
+ TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert ->
+ observationContextAssert
+ .hasLowCardinalityKeyValue("outcome", "fault")
+ .hasLowCardinalityKeyValue("exception", "WebServiceTransportException")
+ .hasLowCardinalityKeyValue("host", "localhost")
+ .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws")
+ .hasLowCardinalityKeyValue("localpart", "root")
+ .hasHighCardinalityKeyValue("path", "/errors/notfound")
+ .hasContextualNameEqualTo("POST")
+ );
+
+ }
+
+ @Test
+ public void receiverFault() {
+
+ Result result = new StringResult();
+
+ assertThatExceptionOfType(SoapFaultClientException.class).isThrownBy(() -> template
+ .sendSourceAndReceiveToResult(baseUrl + "/soap/receiverFault", new StringSource(messagePayload), result));
+
+ TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert ->
+ observationContextAssert
+ .hasLowCardinalityKeyValue("outcome", "fault")
+ .hasLowCardinalityKeyValue("exception", "SoapFaultClientException")
+ .hasLowCardinalityKeyValue("host", "localhost")
+ .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws")
+ .hasLowCardinalityKeyValue("localpart", "root")
+ .hasHighCardinalityKeyValue("path", "/soap/receiverFault")
+ .hasContextualNameEqualTo("POST")
+ );
+ }
+
+ @Test
+ public void senderFault() {
+
+ Result result = new StringResult();
+
+ assertThatExceptionOfType(SoapFaultClientException.class).isThrownBy(() -> template
+ .sendSourceAndReceiveToResult(baseUrl + "/soap/senderFault", new StringSource(messagePayload), result));
+
+ TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert ->
+ observationContextAssert
+ .hasLowCardinalityKeyValue("outcome", "fault")
+ .hasLowCardinalityKeyValue("exception", "SoapFaultClientException")
+ .hasLowCardinalityKeyValue("host", "localhost")
+ .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws")
+ .hasLowCardinalityKeyValue("localpart", "root")
+ .hasHighCardinalityKeyValue("path", "/soap/senderFault")
+ .hasContextualNameEqualTo("POST")
+ );
+ }
+
+ @Test
+ public void attachment() {
+
+ template.sendSourceAndReceiveToResult(baseUrl + "/soap/attachment", new StringSource(messagePayload), message -> {
+
+ SoapMessage soapMessage = (SoapMessage) message;
+ final String attachmentContent = "content";
+ soapMessage.addAttachment("attachment-1",
+ new DataHandler(new ByteArrayDataSource(attachmentContent, "text/plain")));
+ }, new StringResult());
+
+ TestObservationRegistryAssert.assertThat(observationRegistry).hasAnObservation(observationContextAssert ->
+ observationContextAssert
+ .hasLowCardinalityKeyValue("outcome", "success")
+ .hasLowCardinalityKeyValue("exception", "none")
+ .hasLowCardinalityKeyValue("host", "localhost")
+ .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws")
+ .hasLowCardinalityKeyValue("localpart", "root")
+ .hasHighCardinalityKeyValue("path", "/soap/attachment")
+ .hasContextualNameEqualTo("POST")
+ );
+ }
+
+ /**
+ * Servlet that returns and error message for a given status code.
+ */
+ @SuppressWarnings("serial")
+ public static class ErrorServlet extends HttpServlet {
+
+ private int sc;
+
+ private ErrorServlet(int sc) {
+ this.sc = sc;
+ }
+
+ @Override
+ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
+ resp.sendError(sc);
+ }
+ }
+
+ /**
+ * Abstract SOAP Servlet
+ */
+ @SuppressWarnings("serial")
+ public abstract static class AbstractSoapServlet extends HttpServlet {
+
+ protected MessageFactory messageFactory = null;
+
+ @Override
+ public void init(ServletConfig servletConfig) throws ServletException {
+
+ super.init(servletConfig);
+
+ try {
+ messageFactory = MessageFactory.newInstance(SOAPConstants.SOAP_1_2_PROTOCOL);
+ } catch (SOAPException ex) {
+ throw new ServletException("Unable to create message factory" + ex.getMessage());
+ }
+ }
+
+ @Override
+ public void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException {
+
+ try {
+ MimeHeaders headers = getHeaders(req);
+ SOAPMessage request = messageFactory.createMessage(headers, req.getInputStream());
+ SOAPMessage reply = onMessage(request);
+
+ if (reply != null) {
+ reply.saveChanges();
+ SOAPBody replyBody = reply.getSOAPBody();
+ if (!replyBody.hasFault()) {
+ resp.setStatus(HttpServletResponse.SC_OK);
+ } else {
+ if (replyBody.getFault().getFaultCodeAsQName().equals(SOAPConstants.SOAP_SENDER_FAULT)) {
+ resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+
+ } else {
+ resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
+ }
+ }
+ putHeaders(reply.getMimeHeaders(), resp);
+ reply.writeTo(resp.getOutputStream());
+ } else {
+ resp.setStatus(HttpServletResponse.SC_ACCEPTED);
+ }
+ } catch (Exception ex) {
+ throw new ServletException("SAAJ POST failed " + ex.getMessage(), ex);
+ }
+ }
+
+ private MimeHeaders getHeaders(HttpServletRequest httpServletRequest) {
+
+ Enumeration> enumeration = httpServletRequest.getHeaderNames();
+ MimeHeaders headers = new MimeHeaders();
+
+ while (enumeration.hasMoreElements()) {
+ String headerName = (String) enumeration.nextElement();
+ String headerValue = httpServletRequest.getHeader(headerName);
+ StringTokenizer values = new StringTokenizer(headerValue, ",");
+ while (values.hasMoreTokens()) {
+ headers.addHeader(headerName, values.nextToken().trim());
+ }
+ }
+
+ return headers;
+ }
+
+ private void putHeaders(MimeHeaders headers, HttpServletResponse res) {
+
+ Iterator> it = headers.getAllHeaders();
+
+ while (it.hasNext()) {
+ MimeHeader header = (MimeHeader) it.next();
+ String[] values = headers.getHeader(header.getName());
+ for (String value : values) {
+ res.addHeader(header.getName(), value);
+ }
+ }
+ }
+
+ protected abstract SOAPMessage onMessage(SOAPMessage message) throws SOAPException;
+ }
+
+ @SuppressWarnings("serial")
+ public static class EchoSoapServlet extends AbstractSoap12WebServiceTemplateIntegrationTestCase.AbstractSoapServlet {
+
+ @Override
+ protected SOAPMessage onMessage(SOAPMessage message) {
+ return message;
+ }
+ }
+
+ @SuppressWarnings("serial")
+ public static class NoResponseSoapServlet extends AbstractSoap12WebServiceTemplateIntegrationTestCase.AbstractSoapServlet {
+
+ @Override
+ protected SOAPMessage onMessage(SOAPMessage message) {
+ return null;
+ }
+ }
+
+ @SuppressWarnings("serial")
+ public static class SoapReceiverFaultServlet extends AbstractSoap12WebServiceTemplateIntegrationTestCase.AbstractSoapServlet {
+
+ @Override
+ protected SOAPMessage onMessage(SOAPMessage message) throws SOAPException {
+
+ SOAPMessage response = messageFactory.createMessage();
+ SOAPBody body = response.getSOAPBody();
+ body.addFault(SOAPConstants.SOAP_RECEIVER_FAULT, "Receiver Fault");
+ return response;
+ }
+ }
+
+ @SuppressWarnings("serial")
+ public static class SoapSenderFaultServlet extends AbstractSoap12WebServiceTemplateIntegrationTestCase.AbstractSoapServlet {
+
+ @Override
+ protected SOAPMessage onMessage(SOAPMessage message) throws SOAPException {
+
+ SOAPMessage response = messageFactory.createMessage();
+ SOAPBody body = response.getSOAPBody();
+ body.addFault(SOAPConstants.SOAP_SENDER_FAULT, "Sender Fault");
+ return response;
+ }
+ }
+
+ @SuppressWarnings("serial")
+ public static class AttachmentsServlet extends AbstractSoap12WebServiceTemplateIntegrationTestCase.AbstractSoapServlet {
+
+ @Override
+ protected SOAPMessage onMessage(SOAPMessage message) {
+
+ assertThat(message.countAttachments()).isEqualTo(1);
+ return null;
+ }
+ }
+
+}
diff --git a/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/MyEndpoint.java b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/MyEndpoint.java
new file mode 100644
index 000000000..1540791ac
--- /dev/null
+++ b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/MyEndpoint.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.server.endpoint.observation;
+
+import jakarta.xml.bind.annotation.XmlRootElement;
+import org.springframework.ws.server.endpoint.annotation.Endpoint;
+import org.springframework.ws.server.endpoint.annotation.PayloadRoot;
+import org.springframework.ws.server.endpoint.annotation.RequestPayload;
+import org.springframework.ws.server.endpoint.annotation.ResponsePayload;
+
+/**
+ * Testing endpoint.
+ * @author Johan Kindgren
+ */
+@Endpoint
+public class MyEndpoint {
+
+ private static final String NAMESPACE_URI = "http://springframework.org/spring-ws";
+
+ @PayloadRoot(namespace = NAMESPACE_URI, localPart = "request")
+ @ResponsePayload
+ public MyResponse handleRequest(@RequestPayload MyRequest request) {
+ MyResponse myResponse = new MyResponse();
+ myResponse.setMessage("Hello " + request.getName());
+ return myResponse;
+ }
+
+
+
+ @XmlRootElement(namespace = NAMESPACE_URI, name = "request")
+ static class MyRequest {
+
+ private String name;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+
+ @XmlRootElement(namespace = NAMESPACE_URI, name = "response")
+ static class MyResponse {
+
+ private String message;
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+ }
+}
+
+
diff --git a/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptorIntegrationTest.java b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptorIntegrationTest.java
new file mode 100644
index 000000000..47aa838c3
--- /dev/null
+++ b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/ObservationInterceptorIntegrationTest.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.server.endpoint.observation;
+
+import io.micrometer.observation.tck.TestObservationRegistry;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.junit.jupiter.api.*;
+import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
+import org.springframework.ws.client.core.WebServiceTemplate;
+import org.springframework.ws.transport.http.MessageDispatcherServlet;
+import org.springframework.ws.transport.support.FreePortScanner;
+
+import javax.xml.transform.Transformer;
+import javax.xml.transform.TransformerConfigurationException;
+import javax.xml.transform.TransformerFactory;
+
+import static io.micrometer.observation.tck.TestObservationRegistryAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Verifies observation for a WebService Endpoint.
+ * @author Johan Kindgren
+ */
+public class ObservationInterceptorIntegrationTest {
+
+ private static AnnotationConfigWebApplicationContext applicationContext;
+ private WebServiceTemplate webServiceTemplate;
+ private TestObservationRegistry registry;
+ private static Server server;
+
+ private final String requestPayload = "";
+
+ private TransformerFactory transformerFactory = TransformerFactory.newInstance();
+ private Transformer transformer;
+
+ private static String baseUrl;
+
+ @BeforeAll
+ public static void startServer() throws Exception {
+
+ int port = FreePortScanner.getFreePort();
+
+ baseUrl = "http://localhost:" + port + "/ws";
+
+ server = new Server(port);
+ ServletContextHandler context = new ServletContextHandler(ServletContextHandler.SESSIONS);
+ context.setContextPath("/");
+
+ applicationContext = new AnnotationConfigWebApplicationContext();
+ applicationContext.scan(WebServiceConfig.class.getPackage().getName());
+
+ MessageDispatcherServlet dispatcherServlet = new MessageDispatcherServlet(applicationContext);
+ dispatcherServlet.setTransformWsdlLocations(true);
+
+ ServletHolder servletHolder = new ServletHolder(dispatcherServlet);
+ context.addServlet(servletHolder, "/ws/*");
+
+ server.setHandler(context);
+ server.start();
+ }
+
+ @AfterAll
+ static void tearDown() throws Exception {
+ applicationContext.close();
+ server.stop();
+ }
+
+ @BeforeEach
+ void setUp() throws TransformerConfigurationException {
+
+ webServiceTemplate = applicationContext.getBean(WebServiceTemplate.class);
+ registry = applicationContext.getBean(TestObservationRegistry.class);
+
+ transformer = transformerFactory.newTransformer();
+ }
+
+ @Test
+ void testObservationInterceptorBehavior() {
+
+ MyEndpoint.MyRequest request = new MyEndpoint.MyRequest();
+ request.setName("John");
+ MyEndpoint.MyResponse response = (MyEndpoint.MyResponse) webServiceTemplate.marshalSendAndReceive(baseUrl, request);
+
+ // Assertions based on expected behavior of ObservationInterceptor
+ assertNotNull(response);
+
+ assertThat(registry).hasAnObservation(observationContextAssert ->
+ observationContextAssert
+ .hasLowCardinalityKeyValue("outcome", "success")
+ .hasLowCardinalityKeyValue("exception", "none")
+ .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws")
+ .hasLowCardinalityKeyValue("localpart", "request")
+ .hasLowCardinalityKeyValue("soapaction", "none")
+ .hasLowCardinalityKeyValue("path", "/ws")
+ .hasContextualNameEqualTo("POST /ws")
+ .hasNameEqualTo("webservice.server")
+ );
+ }
+
+ @Test
+ void testPathWithVariable() {
+
+ MyEndpoint.MyRequest request = new MyEndpoint.MyRequest();
+ request.setName("John");
+ MyEndpoint.MyResponse response = (MyEndpoint.MyResponse) webServiceTemplate.marshalSendAndReceive(baseUrl + "/1234", request);
+
+ // Assertions based on expected behavior of ObservationInterceptor
+ assertNotNull(response);
+
+ assertThat(registry).hasAnObservation(observationContextAssert ->
+ observationContextAssert
+ .hasLowCardinalityKeyValue("outcome", "success")
+ .hasLowCardinalityKeyValue("exception", "none")
+ .hasLowCardinalityKeyValue("namespace", "http://springframework.org/spring-ws")
+ .hasLowCardinalityKeyValue("localpart", "request")
+ .hasLowCardinalityKeyValue("soapaction", "none")
+ .hasLowCardinalityKeyValue("path", "/ws/{pathInfo}")
+ .hasContextualNameEqualTo("POST /ws/{pathInfo}")
+ .hasHighCardinalityKeyValue("pathinfo", "/1234")
+ .hasNameEqualTo("webservice.server")
+ );
+ }
+}
\ No newline at end of file
diff --git a/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/WebServiceConfig.java b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/WebServiceConfig.java
new file mode 100644
index 000000000..38026f7fa
--- /dev/null
+++ b/spring-ws-core/src/test/java/org/springframework/ws/server/endpoint/observation/WebServiceConfig.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2005-2024 the original author or authors.
+ *
+ * Licensed 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.springframework.ws.server.endpoint.observation;
+
+import io.micrometer.observation.ObservationRegistry;
+import io.micrometer.observation.tck.TestObservationRegistry;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.oxm.jaxb.Jaxb2Marshaller;
+import org.springframework.ws.client.core.WebServiceTemplate;
+import org.springframework.ws.config.annotation.EnableWs;
+import org.springframework.ws.config.annotation.WsConfigurerAdapter;
+import org.springframework.ws.server.EndpointInterceptor;
+import org.springframework.ws.support.ObservationHelper;
+
+import java.util.List;
+
+/**
+ * Verifies observation for a WebService Endpoint.
+ * @author Johan Kindgren
+ */
+@EnableWs
+@Configuration
+public class WebServiceConfig extends WsConfigurerAdapter {
+
+ @Autowired
+ private ObservationRegistry observationRegistry;
+
+ @Bean
+ public Jaxb2Marshaller marshaller() {
+ Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
+ marshaller.setClassesToBeBound(MyEndpoint.MyRequest.class, MyEndpoint.MyResponse.class);
+ return marshaller;
+ }
+
+ @Bean
+ public ObservationRegistry observationRegistry() {
+ return TestObservationRegistry.create();
+ }
+
+ @Bean
+ public WebServiceTemplate webServiceTemplate(Jaxb2Marshaller marshaller) {
+ WebServiceTemplate webServiceTemplate = new WebServiceTemplate();
+ webServiceTemplate.setMarshaller(marshaller);
+ webServiceTemplate.setUnmarshaller(marshaller);
+ return webServiceTemplate;
+ }
+
+ @Bean
+ public EndpointInterceptor observationInterceptor() {
+ return new ObservationInterceptor(observationRegistry, observationHelper(),null); // Replace with your actual interceptor
+ }
+ @Bean
+ public ObservationHelper observationHelper() {
+ return new ObservationHelper();
+ }
+
+ @Override
+ public void addInterceptors(List interceptors) {
+ interceptors.add(observationInterceptor());
+ }
+
+}
\ No newline at end of file
diff --git a/spring-ws-core/src/test/java/org/springframework/ws/support/ObservationHelperTest.java b/spring-ws-core/src/test/java/org/springframework/ws/support/ObservationHelperTest.java
new file mode 100644
index 000000000..0a1b756e4
--- /dev/null
+++ b/spring-ws-core/src/test/java/org/springframework/ws/support/ObservationHelperTest.java
@@ -0,0 +1,65 @@
+package org.springframework.ws.support;
+
+import org.dom4j.Namespace;
+import org.dom4j.dom.DOMElement;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.xml.transform.StringSource;
+import org.xml.sax.InputSource;
+import org.xml.sax.XMLReader;
+import org.xmlunit.builder.Input;
+
+import javax.xml.namespace.QName;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+import javax.xml.transform.sax.SAXSource;
+import java.io.StringReader;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ObservationHelperTest {
+
+ private ObservationHelper helper;
+
+ @BeforeEach
+ void setUp() {
+ helper = new ObservationHelper();
+ }
+
+ @Test
+ void getRootElementStreamSource() {
+
+ StringSource source = new StringSource("");
+
+ QName name = helper.getRootElement(source);
+ assertThat(name.getLocalPart()).isEqualTo("root");
+ assertThat(name.getNamespaceURI()).isEqualTo("http://springframework.org/spring-ws");
+ }
+
+ @Test
+ void getRootElementDomSource() {
+
+ DOMElement payloadElement = new DOMElement(
+ new org.dom4j.QName("root",
+ new Namespace(null, "http://springframework.org/spring-ws")));
+ payloadElement.addElement("child");
+
+ QName name = helper.getRootElement(Input.from(payloadElement).build());
+ assertThat(name.getLocalPart()).isEqualTo("root");
+ assertThat(name.getNamespaceURI()).isEqualTo("http://springframework.org/spring-ws");
+ }
+
+ @Test
+ void getRootElementSaxSource() throws Exception {
+ StringReader reader = new StringReader("");
+
+ SAXParserFactory factory = SAXParserFactory.newInstance();
+ SAXParser saxParser = factory.newSAXParser();
+ XMLReader xmlReader = saxParser.getXMLReader();
+
+ SAXSource saxSource = new SAXSource(xmlReader, new InputSource(reader));
+ QName name = helper.getRootElement(saxSource);
+ assertThat(name.getLocalPart()).isEqualTo("root");
+ assertThat(name.getNamespaceURI()).isEqualTo("http://springframework.org/spring-ws");
+ }
+}
\ No newline at end of file