feat(observability): propagate async trace context

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-07-31 11:22:12 +08:00
parent f805076c5c
commit 91fb155ef1
7 changed files with 604 additions and 3 deletions

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@ -19,12 +20,15 @@ import java.util.concurrent.ThreadPoolExecutor;
public class AsyncConfig {
@Bean(name = "skillhubEventExecutor")
public Executor skillhubEventExecutor() {
public Executor skillhubEventExecutor(
ContextPropagatingTaskDecorator contextPropagatingTaskDecorator
) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("skillhub-event-");
executor.setTaskDecorator(contextPropagatingTaskDecorator);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;

View file

@ -26,7 +26,10 @@ public class SkillScannerConfig {
@Bean
@ConditionalOnProperty(prefix = "skillhub.security.scanner", name = "enabled", havingValue = "true")
public HttpClient scannerHttpClient(SkillScannerProperties properties) {
public HttpClient scannerHttpClient(
WebClient.Builder webClientBuilder,
SkillScannerProperties properties
) {
int readTimeoutMs = properties.getReadTimeoutMs();
int connectTimeoutMs = properties.getConnectTimeoutMs();
@ -52,7 +55,7 @@ public class SkillScannerConfig {
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(100 * 1024 * 1024))
.build();
WebClient webClient = WebClient.builder()
WebClient webClient = webClientBuilder.clone()
.clientConnector(new ReactorClientHttpConnector(reactorClient))
.exchangeStrategies(strategies)
.build();

View file

@ -0,0 +1,37 @@
package com.iflytek.skillhub.observability;
import io.micrometer.context.ThreadLocalAccessor;
/**
* Captures and restores the authoritative Request ID scope for asynchronous execution.
*/
public final class RequestIdThreadLocalAccessor implements ThreadLocalAccessor<String> {
public static final String KEY = "skillhub.request-id";
private final RequestIdAccessor requestIdAccessor;
public RequestIdThreadLocalAccessor(RequestIdAccessor requestIdAccessor) {
this.requestIdAccessor = requestIdAccessor;
}
@Override
public Object key() {
return KEY;
}
@Override
public String getValue() {
return requestIdAccessor.current();
}
@Override
public void setValue(String value) {
requestIdAccessor.replace(value);
}
@Override
public void setValue() {
requestIdAccessor.replace(null);
}
}

View file

@ -0,0 +1,51 @@
package com.iflytek.skillhub.observability;
import com.iflytek.skillhub.observability.tracing.SkillHubObservabilityProperties;
import com.iflytek.skillhub.observability.tracing.TracingMode;
import io.micrometer.context.ContextRegistry;
import io.micrometer.context.ContextSnapshotFactory;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.tracing.Tracer;
import io.micrometer.tracing.contextpropagation.ObservationAwareSpanThreadLocalAccessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.support.ContextPropagatingTaskDecorator;
/**
* Defines the context captured by SkillHub-managed asynchronous executors.
*/
@Configuration(proxyBeanMethods = false)
public class SkillHubContextPropagationConfiguration {
@Bean
ContextRegistry skillHubContextRegistry(
RequestIdAccessor requestIdAccessor,
SkillHubObservabilityProperties observabilityProperties,
ObservationRegistry observationRegistry,
Tracer tracer
) {
ContextRegistry registry = new ContextRegistry()
.loadContextAccessors()
.loadThreadLocalAccessors();
registry.registerThreadLocalAccessor(
new RequestIdThreadLocalAccessor(requestIdAccessor)
);
if (observabilityProperties.getTracingMode() == TracingMode.OTEL_SDK) {
registry.registerThreadLocalAccessor(
new ObservationAwareSpanThreadLocalAccessor(observationRegistry, tracer)
);
}
return registry;
}
@Bean
ContextPropagatingTaskDecorator skillHubContextPropagatingTaskDecorator(
ContextRegistry skillHubContextRegistry
) {
ContextSnapshotFactory snapshotFactory = ContextSnapshotFactory.builder()
.contextRegistry(skillHubContextRegistry)
.clearMissing(true)
.build();
return new ContextPropagatingTaskDecorator(snapshotFactory);
}
}

View file

@ -0,0 +1,320 @@
package com.iflytek.skillhub.observability;
import com.iflytek.skillhub.config.AsyncConfig;
import com.iflytek.skillhub.observability.tracing.SkillHubTracingConfiguration;
import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.task.TaskDecorator;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.FutureTask;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ContextPropagationConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestApplication.class)
.withPropertyValues(
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=none"
);
@Test
void requestIdShouldPropagateRestoreNestedScopeAndNotLeakOnThreadReuse() {
contextRunner
.withPropertyValues("skillhub.observability.tracing-mode=none")
.run(context -> {
RequestIdAccessor requestIdAccessor =
context.getBean(RequestIdAccessor.class);
TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
ExecutorService worker = Executors.newSingleThreadExecutor();
try {
ContextValues propagated;
try (RequestIdAccessor.Scope ignored =
requestIdAccessor.open("request-one")) {
propagated = execute(worker, taskDecorator, () -> {
assertThat(requestIdAccessor.current())
.isEqualTo("request-one");
try (RequestIdAccessor.Scope nested =
requestIdAccessor.open("nested")) {
assertThat(requestIdAccessor.current())
.isEqualTo("nested");
}
return currentValues(requestIdAccessor, null);
});
}
assertThat(propagated.requestId()).isEqualTo("request-one");
assertThat(propagated.mdcRequestId()).isEqualTo("request-one");
try (RequestIdAccessor.Scope ignored =
requestIdAccessor.open("request-failure")) {
assertThatThrownBy(() -> execute(
worker,
taskDecorator,
() -> {
throw new IllegalStateException("expected failure");
}
)).hasCauseInstanceOf(IllegalStateException.class);
}
ContextValues clean = execute(
worker,
taskDecorator,
() -> currentValues(requestIdAccessor, null)
);
assertThat(clean.requestId()).isNull();
assertThat(clean.mdcRequestId()).isNull();
} finally {
worker.shutdownNow();
MDC.clear();
}
});
}
@Test
void configuredEventExecutorShouldPropagateRequestId() {
contextRunner
.withPropertyValues("skillhub.observability.tracing-mode=none")
.run(context -> {
RequestIdAccessor requestIdAccessor =
context.getBean(RequestIdAccessor.class);
Executor executor = context.getBean("skillhubEventExecutor", Executor.class);
try (RequestIdAccessor.Scope ignored =
requestIdAccessor.open("configured-executor")) {
FutureTask<ContextValues> task = new FutureTask<>(
() -> currentValues(requestIdAccessor, null)
);
executor.execute(task);
assertThat(task.get(5, TimeUnit.SECONDS).requestId())
.isEqualTo("configured-executor");
} finally {
MDC.clear();
}
});
}
@Test
void callerRunsPolicyShouldRestoreCallerScopeAndLeaveWorkerClean() {
contextRunner
.withPropertyValues("skillhub.observability.tracing-mode=none")
.run(context -> {
RequestIdAccessor requestIdAccessor =
context.getBean(RequestIdAccessor.class);
TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
ThreadPoolTaskExecutor executor = callerRunsExecutor(taskDecorator);
CountDownLatch workerStarted = new CountDownLatch(1);
CountDownLatch releaseWorker = new CountDownLatch(1);
FutureTask<Void> blockingTask = new FutureTask<>(() -> {
workerStarted.countDown();
releaseWorker.await(5, TimeUnit.SECONDS);
return null;
});
try {
executor.execute(blockingTask);
assertThat(workerStarted.await(5, TimeUnit.SECONDS)).isTrue();
AtomicReference<ContextValues> callerRunValues =
new AtomicReference<>();
String callerThread = Thread.currentThread().getName();
try (RequestIdAccessor.Scope ignored =
requestIdAccessor.open("caller-request")) {
executor.execute(() -> {
assertThat(Thread.currentThread().getName())
.isEqualTo(callerThread);
callerRunValues.set(currentValues(
requestIdAccessor,
null
));
});
assertThat(requestIdAccessor.current())
.isEqualTo("caller-request");
assertThat(MDC.get(RequestIdAccessor.MDC_KEY))
.isEqualTo("caller-request");
}
assertThat(callerRunValues.get().requestId())
.isEqualTo("caller-request");
releaseWorker.countDown();
blockingTask.get(5, TimeUnit.SECONDS);
FutureTask<ContextValues> cleanTask = new FutureTask<>(
() -> currentValues(requestIdAccessor, null)
);
executor.execute(cleanTask);
ContextValues clean = cleanTask.get(5, TimeUnit.SECONDS);
assertThat(clean.requestId()).isNull();
assertThat(clean.mdcRequestId()).isNull();
} finally {
releaseWorker.countDown();
executor.shutdown();
MDC.clear();
}
});
}
@Test
void otelSpanShouldPropagateAndBeClearedAfterTask() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.sampling.probability=1.0"
)
.run(context -> {
RequestIdAccessor requestIdAccessor =
context.getBean(RequestIdAccessor.class);
TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
Tracer tracer = context.getBean(Tracer.class);
ExecutorService worker = Executors.newSingleThreadExecutor();
Span span = tracer.nextSpan().name("parent").start();
try {
ContextValues propagated;
try (Tracer.SpanInScope ignored = tracer.withSpan(span)) {
propagated = execute(
worker,
taskDecorator,
() -> currentValues(requestIdAccessor, tracer)
);
}
assertThat(propagated.traceId())
.isEqualTo(span.context().traceId());
assertThat(propagated.mdcTraceId())
.isEqualTo(span.context().traceId());
ContextValues clean = execute(
worker,
taskDecorator,
() -> currentValues(requestIdAccessor, tracer)
);
assertThat(clean.traceId()).isNull();
assertThat(clean.mdcTraceId()).isNull();
} finally {
span.end();
worker.shutdownNow();
MDC.clear();
}
});
}
@Test
void otelObservationShouldPropagateItsTraceAndRestoreWorker() {
contextRunner
.withPropertyValues(
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.sampling.probability=1.0"
)
.run(context -> {
RequestIdAccessor requestIdAccessor =
context.getBean(RequestIdAccessor.class);
TaskDecorator taskDecorator = context.getBean(TaskDecorator.class);
Tracer tracer = context.getBean(Tracer.class);
ObservationRegistry observationRegistry =
context.getBean(ObservationRegistry.class);
ExecutorService worker = Executors.newSingleThreadExecutor();
Observation observation = Observation
.createNotStarted("parent-observation", observationRegistry)
.start();
try {
ContextValues propagated;
String parentTraceId;
try (Observation.Scope ignored = observation.openScope()) {
assertThat(tracer.currentSpan()).isNotNull();
parentTraceId = tracer.currentSpan().context().traceId();
propagated = execute(
worker,
taskDecorator,
() -> currentValues(requestIdAccessor, tracer)
);
}
assertThat(propagated.traceId()).isEqualTo(parentTraceId);
assertThat(propagated.mdcTraceId()).isEqualTo(parentTraceId);
ContextValues clean = execute(
worker,
taskDecorator,
() -> currentValues(requestIdAccessor, tracer)
);
assertThat(clean.traceId()).isNull();
assertThat(clean.mdcTraceId()).isNull();
} finally {
observation.stop();
worker.shutdownNow();
MDC.clear();
}
});
}
private ContextValues currentValues(
RequestIdAccessor requestIdAccessor,
Tracer tracer
) {
Span currentSpan = tracer == null ? null : tracer.currentSpan();
return new ContextValues(
requestIdAccessor.current(),
MDC.get(RequestIdAccessor.MDC_KEY),
currentSpan == null ? null : currentSpan.context().traceId(),
MDC.get("traceId")
);
}
private <T> T execute(
Executor executor,
TaskDecorator taskDecorator,
Callable<T> action
) throws Exception {
FutureTask<T> task = new FutureTask<>(action);
executor.execute(taskDecorator.decorate(task));
return task.get(5, TimeUnit.SECONDS);
}
private ThreadPoolTaskExecutor callerRunsExecutor(TaskDecorator taskDecorator) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(0);
executor.setTaskDecorator(taskDecorator);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
private record ContextValues(
String requestId,
String mdcRequestId,
String traceId,
String mdcTraceId
) {
}
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
@Import({
SkillHubTracingConfiguration.class,
SkillHubContextPropagationConfiguration.class,
RequestIdAccessor.class,
AsyncConfig.class
})
static class TestApplication {
}
}

View file

@ -0,0 +1,178 @@
package com.iflytek.skillhub.observability.tracing;
import com.iflytek.skillhub.auth.oauth.GitLabClaimsExtractor;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.config.SkillScannerConfig;
import com.iflytek.skillhub.config.SkillScannerProperties;
import com.iflytek.skillhub.infra.http.HttpClient;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import io.micrometer.tracing.Span;
import io.micrometer.tracing.Tracer;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
class HttpTracePropagationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(TestApplication.class)
.withPropertyValues(
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=none",
"skillhub.observability.tracing-mode=otel-sdk",
"management.tracing.sampling.probability=1.0",
"skillhub.security.scanner.enabled=true"
);
@Test
void shouldPropagateW3cContextToScannerButNotExternalGitLab() throws Exception {
try (HeaderCaptureServer scannerServer =
new HeaderCaptureServer("text/plain", "scanner-ok");
HeaderCaptureServer gitLabServer =
new HeaderCaptureServer(
"application/json",
"""
[
{
"email": "alice@gitlab.example",
"confirmed_at": "2026-04-16T08:00:00Z"
}
]
"""
)) {
contextRunner.run(context -> {
Tracer tracer = context.getBean(Tracer.class);
HttpClient scannerClient =
context.getBean("scannerHttpClient", HttpClient.class);
GitLabClaimsExtractor gitLabClaimsExtractor =
context.getBean(GitLabClaimsExtractor.class);
Span span = tracer.nextSpan().name("outbound-boundary").start();
try (Tracer.SpanInScope ignored = tracer.withSpan(span)) {
assertThat(scannerClient.get(
scannerServer.url("/health"),
String.class
)).isEqualTo("scanner-ok");
OAuthClaims claims = gitLabClaimsExtractor.extract(
gitLabRequest(gitLabServer.url("/api/v4/user")),
new DefaultOAuth2User(
List.of(),
Map.of(
"id", 42,
"username", "alice",
"email", "alice+pending@gitlab.example"
),
"username"
)
);
assertThat(claims.email())
.isEqualTo("alice@gitlab.example");
} finally {
span.end();
}
String scannerTraceparent = scannerServer.traceparent();
assertThat(scannerTraceparent)
.matches("^00-[0-9a-f]{32}-[0-9a-f]{16}-0[01]$");
assertThat(scannerTraceparent.substring(3, 35))
.isEqualTo(span.context().traceId());
assertThat(gitLabServer.traceparent()).isNull();
});
}
}
private OAuth2UserRequest gitLabRequest(String userInfoUri) {
ClientRegistration registration = ClientRegistration
.withRegistrationId("gitlab")
.clientId("client-id")
.clientSecret("client-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope("read_user", "email")
.authorizationUri("https://gitlab.example/oauth/authorize")
.tokenUri("https://gitlab.example/oauth/token")
.userInfoUri(userInfoUri)
.userNameAttributeName("username")
.clientName("GitLab")
.build();
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"test-token",
Instant.now(),
Instant.now().plusSeconds(3600)
);
return new OAuth2UserRequest(registration, accessToken);
}
private static final class HeaderCaptureServer implements AutoCloseable {
private final HttpServer server;
private final AtomicReference<String> traceparent = new AtomicReference<>();
private HeaderCaptureServer(String contentType, String body) throws IOException {
byte[] response = body.getBytes(StandardCharsets.UTF_8);
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", exchange -> respond(
exchange,
contentType,
response
));
server.start();
}
private void respond(
HttpExchange exchange,
String contentType,
byte[] response
) throws IOException {
traceparent.set(exchange.getRequestHeaders().getFirst("traceparent"));
exchange.getResponseHeaders().set("Content-Type", contentType);
exchange.sendResponseHeaders(200, response.length);
try (var responseBody = exchange.getResponseBody()) {
responseBody.write(response);
}
}
private String url(String path) {
return "http://127.0.0.1:" + server.getAddress().getPort() + path;
}
private String traceparent() {
return traceparent.get();
}
@Override
public void close() {
server.stop(0);
}
}
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
@Import({
SkillHubTracingConfiguration.class,
SkillScannerConfig.class,
SkillScannerProperties.class,
GitLabClaimsExtractor.class
})
static class TestApplication {
}
}

View file

@ -27,6 +27,14 @@ public class GitLabClaimsExtractor implements OAuthClaimsExtractor {
private final RestClient restClient;
/**
* Uses an external-service client that is intentionally not customized with application
* tracing. Trace context must not be propagated to a user-configured GitLab host.
*/
public GitLabClaimsExtractor() {
this(RestClient.builder());
}
public GitLabClaimsExtractor(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)