Browse Source
This commit overhauls the TestExecutionListener for Micrometer's ObservationRegistry that was introduced in the previous commit. Specifically, this commit: - Renames the listener to MicrometerObservationRegistryTestExecutionListener since the use of a ThreadLocal is an implementation detail that may change over time. - Makes the listener package-private instead of public in order to allow the team greater flexibility in evolving this feature. - Eagerly loads the ObservationThreadLocalAccessor class and verifies that it has a getObservationRegistry() method to ensure that the listener is properly skipped when SpringFactoriesLoader attempts to load it, if Micrometer 1.10.8+ is not on the classpath. - Switches the listener's automatic registration order to 2500 in order to register it after the DependencyInjectionTestExecutionListener. - Only tracks the previous ObservationRegistry in beforeTestMethod() if the test's ApplicationContext contains an ObservationRegistry bean. - Properly removes the TestContext attribute for the previous ObservationRegistry in afterTestMethod(). - Introduces DEBUG logging for diagnostics. - Adds an entry in the Javadoc for TestExecutionListener as well as in the Testing chapter in the reference manual. Closes gh-30658pull/30724/head
9 changed files with 268 additions and 187 deletions
@ -0,0 +1,142 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2002-2023 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 |
||||||
|
* |
||||||
|
* https://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.test.context.observation; |
||||||
|
|
||||||
|
import java.lang.reflect.Method; |
||||||
|
|
||||||
|
import io.micrometer.observation.ObservationRegistry; |
||||||
|
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; |
||||||
|
import org.apache.commons.logging.Log; |
||||||
|
import org.apache.commons.logging.LogFactory; |
||||||
|
import org.junit.platform.launcher.TestExecutionListener; |
||||||
|
|
||||||
|
import org.springframework.context.ApplicationContext; |
||||||
|
import org.springframework.core.Conventions; |
||||||
|
import org.springframework.test.context.TestContext; |
||||||
|
import org.springframework.test.context.support.AbstractTestExecutionListener; |
||||||
|
import org.springframework.util.Assert; |
||||||
|
import org.springframework.util.ReflectionUtils; |
||||||
|
|
||||||
|
/** |
||||||
|
* {@code TestExecutionListener} which provides support for Micrometer's |
||||||
|
* {@link ObservationRegistry}. |
||||||
|
* |
||||||
|
* <p>This listener updates the {@link ObservationThreadLocalAccessor} with the |
||||||
|
* {@code ObservationRegistry} obtained from the test's {@link ApplicationContext}, |
||||||
|
* if present. |
||||||
|
* |
||||||
|
* @author Marcin Grzejszczak |
||||||
|
* @author Sam Brannen |
||||||
|
* @since 6.0.10 |
||||||
|
*/ |
||||||
|
class MicrometerObservationRegistryTestExecutionListener extends AbstractTestExecutionListener { |
||||||
|
|
||||||
|
private static final Log logger = LogFactory.getLog(MicrometerObservationRegistryTestExecutionListener.class); |
||||||
|
|
||||||
|
private static final String OBSERVATION_THREAD_LOCAL_ACCESSOR_CLASS_NAME = |
||||||
|
"io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Attribute name for a {@link TestContext} attribute which contains the |
||||||
|
* {@link ObservationRegistry} that was previously stored in the |
||||||
|
* {@link ObservationThreadLocalAccessor}. |
||||||
|
* <p>After each test method, the previously stored {@code ObservationRegistry} |
||||||
|
* will be restored. If tests run concurrently this might cause issues unless |
||||||
|
* the {@code ObservationRegistry} is always the same (which should typically |
||||||
|
* be the case). |
||||||
|
*/ |
||||||
|
private static final String PREVIOUS_OBSERVATION_REGISTRY = Conventions.getQualifiedAttributeName( |
||||||
|
MicrometerObservationRegistryTestExecutionListener.class, "previousObservationRegistry"); |
||||||
|
|
||||||
|
|
||||||
|
static { |
||||||
|
// Trigger eager resolution of Micrometer Observation types during static
|
||||||
|
// initialization of this class to ensure that this listener can be properly
|
||||||
|
// skipped when SpringFactoriesLoader attempts to load it, if micrometer-observation
|
||||||
|
// is not in the classpath or if the version of ObservationThreadLocalAccessor
|
||||||
|
// present does not include the getObservationRegistry() method.
|
||||||
|
String errorMessage = |
||||||
|
"MicrometerObservationRegistryTestExecutionListener requires micrometer-observation 1.10.8 or higher"; |
||||||
|
Class<?> clazz; |
||||||
|
try { |
||||||
|
clazz = Class.forName(OBSERVATION_THREAD_LOCAL_ACCESSOR_CLASS_NAME, true, |
||||||
|
TestExecutionListener.class.getClassLoader()); |
||||||
|
} |
||||||
|
catch (Throwable ex) { |
||||||
|
throw new IllegalStateException(errorMessage, ex); |
||||||
|
} |
||||||
|
|
||||||
|
Method method = ReflectionUtils.findMethod(clazz, "getObservationRegistry"); |
||||||
|
Assert.state(method != null, errorMessage); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* Returns {@code 2500}. |
||||||
|
*/ |
||||||
|
@Override |
||||||
|
public final int getOrder() { |
||||||
|
return 2500; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* If the test's {@link ApplicationContext} contains an {@link ObservationRegistry} |
||||||
|
* bean, this method retrieves the {@code ObservationRegistry} currently stored |
||||||
|
* in {@link ObservationThreadLocalAccessor}, saves a reference to the original |
||||||
|
* registry as a {@link TestContext} attribute (to be restored in |
||||||
|
* {@link #afterTestMethod(TestContext)}), and sets the registry from the test's |
||||||
|
* {@code ApplicationContext} in {@link ObservationThreadLocalAccessor}. |
||||||
|
* @param testContext the test context for the test; never {@code null} |
||||||
|
* @see #afterTestMethod(TestContext) |
||||||
|
*/ |
||||||
|
@Override |
||||||
|
public void beforeTestMethod(TestContext testContext) { |
||||||
|
testContext.getApplicationContext().getBeanProvider(ObservationRegistry.class) |
||||||
|
.ifAvailable(registry -> { |
||||||
|
if (logger.isDebugEnabled()) { |
||||||
|
logger.debug(""" |
||||||
|
Registering ObservationRegistry from ApplicationContext in \ |
||||||
|
ObservationThreadLocalAccessor for test class \ |
||||||
|
""" + testContext.getTestClass().getName()); |
||||||
|
} |
||||||
|
ObservationThreadLocalAccessor accessor = ObservationThreadLocalAccessor.getInstance(); |
||||||
|
testContext.setAttribute(PREVIOUS_OBSERVATION_REGISTRY, accessor.getObservationRegistry()); |
||||||
|
accessor.setObservationRegistry(registry); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Retrieves the original {@link ObservationRegistry} that was saved in |
||||||
|
* {@link #beforeTestMethod(TestContext)} and sets it in |
||||||
|
* {@link ObservationThreadLocalAccessor}. |
||||||
|
* @param testContext the test context for the test; never {@code null} |
||||||
|
* @see #beforeTestMethod(TestContext) |
||||||
|
*/ |
||||||
|
@Override |
||||||
|
public void afterTestMethod(TestContext testContext) { |
||||||
|
ObservationRegistry previousObservationRegistry = |
||||||
|
(ObservationRegistry) testContext.removeAttribute(PREVIOUS_OBSERVATION_REGISTRY); |
||||||
|
if (previousObservationRegistry != null) { |
||||||
|
if (logger.isDebugEnabled()) { |
||||||
|
logger.debug("Restoring ObservationRegistry in ObservationThreadLocalAccessor for test class " + |
||||||
|
testContext.getTestClass().getName()); |
||||||
|
} |
||||||
|
ObservationThreadLocalAccessor.getInstance().setObservationRegistry(previousObservationRegistry); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -1,92 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2002-2023 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 |
|
||||||
* |
|
||||||
* https://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.test.context.observation; |
|
||||||
|
|
||||||
import io.micrometer.observation.ObservationRegistry; |
|
||||||
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; |
|
||||||
|
|
||||||
import org.springframework.context.ApplicationContext; |
|
||||||
import org.springframework.core.Conventions; |
|
||||||
import org.springframework.test.context.TestContext; |
|
||||||
import org.springframework.test.context.TestExecutionListener; |
|
||||||
import org.springframework.test.context.support.AbstractTestExecutionListener; |
|
||||||
|
|
||||||
/** |
|
||||||
* {@code ObservationThreadLocalTestExecutionListener} is an implementation of the {@link TestExecutionListener} |
|
||||||
* SPI that updates the {@link ObservationThreadLocalAccessor} with the {@link ObservationRegistry} |
|
||||||
* taken from the {@link ApplicationContext} present in the {@link TestContext}. |
|
||||||
* |
|
||||||
* <p>This implementation is not thread-safe. |
|
||||||
* |
|
||||||
* @author Marcin Grzejszczak |
|
||||||
* @since 6.1 |
|
||||||
*/ |
|
||||||
public class MicrometerObservationThreadLocalTestExecutionListener extends AbstractTestExecutionListener { |
|
||||||
|
|
||||||
/** |
|
||||||
* Attribute name for a {@link TestContext} attribute which contains the previously |
|
||||||
* set {@link ObservationRegistry} on the {@link ObservationThreadLocalAccessor}. |
|
||||||
* <p>After all tests from the current test class have completed, the previously stored {@link ObservationRegistry} |
|
||||||
* will be restored. If tests are ran concurrently this might cause issues |
|
||||||
* unless the {@link ObservationRegistry} is always the same (which should be the case most frequently). |
|
||||||
*/ |
|
||||||
private static final String PREVIOUS_OBSERVATION_REGISTRY = Conventions.getQualifiedAttributeName( |
|
||||||
MicrometerObservationThreadLocalTestExecutionListener.class, "previousObservationRegistry"); |
|
||||||
|
|
||||||
/** |
|
||||||
* Retrieves the current {@link ObservationRegistry} stored |
|
||||||
* on {@link ObservationThreadLocalAccessor} instance and stores it |
|
||||||
* in the {@link TestContext} attributes and overrides it with |
|
||||||
* one stored in {@link ApplicationContext} associated with |
|
||||||
* the {@link TestContext}. |
|
||||||
* @param testContext the test context for the test; never {@code null} |
|
||||||
*/ |
|
||||||
@Override |
|
||||||
public void beforeTestMethod(TestContext testContext) { |
|
||||||
testContext.setAttribute(PREVIOUS_OBSERVATION_REGISTRY, |
|
||||||
ObservationThreadLocalAccessor.getInstance().getObservationRegistry()); |
|
||||||
testContext.getApplicationContext() |
|
||||||
.getBeanProvider(ObservationRegistry.class) |
|
||||||
.ifAvailable(observationRegistry -> |
|
||||||
ObservationThreadLocalAccessor.getInstance() |
|
||||||
.setObservationRegistry(observationRegistry)); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Retrieves the previously stored {@link ObservationRegistry} and sets it back |
|
||||||
* on the {@link ObservationThreadLocalAccessor} instance. |
|
||||||
* @param testContext the test context for the test; never {@code null} |
|
||||||
*/ |
|
||||||
@Override |
|
||||||
public void afterTestMethod(TestContext testContext) { |
|
||||||
ObservationRegistry previousObservationRegistry = |
|
||||||
(ObservationRegistry) testContext.getAttribute(PREVIOUS_OBSERVATION_REGISTRY); |
|
||||||
if (previousObservationRegistry != null) { |
|
||||||
ObservationThreadLocalAccessor.getInstance() |
|
||||||
.setObservationRegistry(previousObservationRegistry); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
|
|
||||||
/** |
|
||||||
* Returns {@code 3500}. |
|
||||||
*/ |
|
||||||
@Override |
|
||||||
public final int getOrder() { |
|
||||||
return 3500; |
|
||||||
} |
|
||||||
} |
|
||||||
@ -0,0 +1,107 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2002-2023 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 |
||||||
|
* |
||||||
|
* https://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.test.context.observation; |
||||||
|
|
||||||
|
import java.util.HashMap; |
||||||
|
import java.util.Map; |
||||||
|
|
||||||
|
import io.micrometer.observation.ObservationRegistry; |
||||||
|
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; |
||||||
|
import org.junit.jupiter.api.BeforeEach; |
||||||
|
import org.junit.jupiter.api.Test; |
||||||
|
|
||||||
|
import org.springframework.context.support.StaticApplicationContext; |
||||||
|
import org.springframework.test.context.TestContext; |
||||||
|
import org.springframework.test.context.TestExecutionListener; |
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||||
|
import static org.mockito.ArgumentMatchers.any; |
||||||
|
import static org.mockito.ArgumentMatchers.anyString; |
||||||
|
import static org.mockito.BDDMockito.given; |
||||||
|
import static org.mockito.BDDMockito.willAnswer; |
||||||
|
import static org.mockito.Mockito.mock; |
||||||
|
|
||||||
|
/** |
||||||
|
* Unit tests for {@link MicrometerObservationRegistryTestExecutionListener}. |
||||||
|
* |
||||||
|
* @author Marcin Grzejszczak |
||||||
|
* @author Sam Brannen |
||||||
|
* @since 6.0.10 |
||||||
|
*/ |
||||||
|
class MicrometerObservationRegistryTestExecutionListenerTests { |
||||||
|
|
||||||
|
private final ObservationRegistry originalObservationRegistry = globalObservationRegistry(); |
||||||
|
|
||||||
|
private final TestContext testContext = mock(); |
||||||
|
|
||||||
|
private final StaticApplicationContext applicationContext = new StaticApplicationContext(); |
||||||
|
|
||||||
|
private final Map<String, Object> attributes = new HashMap<>(); |
||||||
|
|
||||||
|
private final TestExecutionListener listener = new MicrometerObservationRegistryTestExecutionListener(); |
||||||
|
|
||||||
|
|
||||||
|
@BeforeEach |
||||||
|
@SuppressWarnings({ "unchecked", "rawtypes" }) // for raw Class testClass
|
||||||
|
void configureTestContextMock() { |
||||||
|
willAnswer(invocation -> attributes.put(invocation.getArgument(0), invocation.getArgument(1))) |
||||||
|
.given(testContext).setAttribute(anyString(), any()); |
||||||
|
given(testContext.removeAttribute(anyString())) |
||||||
|
.willAnswer(invocation -> attributes.get(invocation.getArgument(0, String.class))); |
||||||
|
given(testContext.getApplicationContext()).willReturn(applicationContext); |
||||||
|
Class testClass = getClass(); |
||||||
|
given(testContext.getTestClass()).willReturn(testClass); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void observationRegistryIsNotOverridden() throws Exception { |
||||||
|
assertGlobalObservationRegistryIsSameAsOriginal(); |
||||||
|
|
||||||
|
listener.beforeTestMethod(testContext); |
||||||
|
assertGlobalObservationRegistryIsSameAsOriginal(); |
||||||
|
|
||||||
|
listener.afterTestMethod(testContext); |
||||||
|
assertGlobalObservationRegistryIsSameAsOriginal(); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void observationRegistryIsOverriddenByBeanFromApplicationContext() throws Exception { |
||||||
|
assertGlobalObservationRegistryIsSameAsOriginal(); |
||||||
|
|
||||||
|
ObservationRegistry testObservationRegistry = ObservationRegistry.create(); |
||||||
|
applicationContext.getDefaultListableBeanFactory().registerSingleton("observationRegistry", testObservationRegistry); |
||||||
|
|
||||||
|
listener.beforeTestMethod(testContext); |
||||||
|
ObservationRegistry globalObservationRegistry = globalObservationRegistry(); |
||||||
|
assertThat(globalObservationRegistry) |
||||||
|
.as("The global ObservationRegistry should have been replaced with the one from the application context") |
||||||
|
.isNotSameAs(originalObservationRegistry) |
||||||
|
.isSameAs(testObservationRegistry); |
||||||
|
|
||||||
|
listener.afterTestMethod(testContext); |
||||||
|
assertGlobalObservationRegistryIsSameAsOriginal(); |
||||||
|
} |
||||||
|
|
||||||
|
private void assertGlobalObservationRegistryIsSameAsOriginal() { |
||||||
|
assertThat(globalObservationRegistry()).isSameAs(originalObservationRegistry); |
||||||
|
} |
||||||
|
|
||||||
|
private static ObservationRegistry globalObservationRegistry() { |
||||||
|
return ObservationThreadLocalAccessor.getInstance().getObservationRegistry(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -1,84 +0,0 @@ |
|||||||
/* |
|
||||||
* Copyright 2002-2023 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 |
|
||||||
* |
|
||||||
* https://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.test.context.observation; |
|
||||||
|
|
||||||
import java.util.HashMap; |
|
||||||
import java.util.Map; |
|
||||||
|
|
||||||
import io.micrometer.observation.ObservationRegistry; |
|
||||||
import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; |
|
||||||
import org.junit.jupiter.api.BeforeEach; |
|
||||||
import org.junit.jupiter.api.Test; |
|
||||||
|
|
||||||
import org.springframework.context.support.StaticApplicationContext; |
|
||||||
import org.springframework.test.context.TestContext; |
|
||||||
|
|
||||||
import static org.assertj.core.api.BDDAssertions.then; |
|
||||||
import static org.mockito.ArgumentMatchers.any; |
|
||||||
import static org.mockito.ArgumentMatchers.anyString; |
|
||||||
import static org.mockito.BDDMockito.given; |
|
||||||
import static org.mockito.BDDMockito.willAnswer; |
|
||||||
import static org.mockito.Mockito.mock; |
|
||||||
|
|
||||||
class MicrometerObservationThreadLocalTestExecutionListenerTests { |
|
||||||
|
|
||||||
ObservationRegistry originalObservationRegistry = ObservationThreadLocalAccessor.getInstance().getObservationRegistry(); |
|
||||||
|
|
||||||
TestContext testContext = mock(); |
|
||||||
|
|
||||||
StaticApplicationContext applicationContext = new StaticApplicationContext(); |
|
||||||
|
|
||||||
Map<String, Object> attributes = new HashMap<>(); |
|
||||||
|
|
||||||
MicrometerObservationThreadLocalTestExecutionListener listener = new MicrometerObservationThreadLocalTestExecutionListener(); |
|
||||||
|
|
||||||
@BeforeEach |
|
||||||
void setup() { |
|
||||||
willAnswer(invocation -> attributes.put(invocation.getArgument(0), invocation.getArgument(1))).given(testContext).setAttribute(anyString(), any()); |
|
||||||
given(testContext.getAttribute(anyString())).willAnswer(invocation -> attributes.get(invocation.getArgument(0, String.class))); |
|
||||||
given(testContext.getApplicationContext()).willReturn(applicationContext); |
|
||||||
} |
|
||||||
|
|
||||||
@Test |
|
||||||
void observationRegistryShouldNotBeOverridden() throws Exception { |
|
||||||
listener.beforeTestMethod(testContext); |
|
||||||
thenObservationRegistryOnOTLAIsSameAsOriginal(); |
|
||||||
listener.afterTestMethod(testContext); |
|
||||||
thenObservationRegistryOnOTLAIsSameAsOriginal(); |
|
||||||
} |
|
||||||
|
|
||||||
@Test |
|
||||||
void observationRegistryOverriddenByBeanFromTestContext() throws Exception { |
|
||||||
ObservationRegistry newObservationRegistry = ObservationRegistry.create(); |
|
||||||
applicationContext.getDefaultListableBeanFactory().registerSingleton("observationRegistry", newObservationRegistry); |
|
||||||
|
|
||||||
listener.beforeTestMethod(testContext); |
|
||||||
ObservationRegistry otlaObservationRegistry = ObservationThreadLocalAccessor.getInstance().getObservationRegistry(); |
|
||||||
then(otlaObservationRegistry) |
|
||||||
.as("During the test we want the original ObservationRegistry to be replaced with the one present in this application context") |
|
||||||
.isNotSameAs(originalObservationRegistry) |
|
||||||
.isSameAs(newObservationRegistry); |
|
||||||
|
|
||||||
listener.afterTestMethod(testContext); |
|
||||||
thenObservationRegistryOnOTLAIsSameAsOriginal(); |
|
||||||
} |
|
||||||
|
|
||||||
private void thenObservationRegistryOnOTLAIsSameAsOriginal() { |
|
||||||
then(ObservationThreadLocalAccessor.getInstance().getObservationRegistry()).isSameAs(originalObservationRegistry); |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
||||||
Loading…
Reference in new issue