Webflux 异常处理时如何用领域异常信息丰富 OTeL spans
结论
建议在 @ExceptionHandler 中处理:异常转换为 HTTP 响应前,获取当前 OTel span,通过 recordException(...) 记录异常事件,再用 setStatus(StatusCode.ERROR) 将 span 标记为错误。
这种做法不需要 Spring AOP,也不依赖方法参数名或切点表达式,更适合 GraalVM Native Image。相比在 WebFilter 中根据响应状态码推断领域异常,它还能保留原始异常信息。异常一旦被 @ControllerAdvice 处理,外层过滤器便无法取得原始 Throwable。
recordException(...) 只添加 exception event,不会自动把 span 状态设为 ERROR,因此需要分别调用这两个 API。
在 @ExceptionHandler 中记录异常
可以把 span 记录逻辑封装为普通组件,再由各个异常处理方法调用:
import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import org.springframework.stereotype.Component;
@Component
public class DomainExceptionSpanRecorder {
private static final AttributeKey<String> DOMAIN_ERROR_CODE =
AttributeKey.stringKey("domain.error.code");
public void record(DomainException exception) {
Span span = Span.current();
if (!span.getSpanContext().isValid() || !span.isRecording()) {
return;
}
String errorCode = exception.getErrorCode();
span.recordException(
exception,
Attributes.of(DOMAIN_ERROR_CODE, errorCode)
);
span.setAttribute(DOMAIN_ERROR_CODE, errorCode);
span.setStatus(StatusCode.ERROR, errorCode);
}
}
在全局异常处理器中调用该组件:
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class MyGlobalExceptionHandler {
private final DomainExceptionSpanRecorder spanRecorder;
public MyGlobalExceptionHandler(DomainExceptionSpanRecorder spanRecorder) {
this.spanRecorder = spanRecorder;
}
@ExceptionHandler(DomainException.class)
public ResponseEntity<Object> handleDomainException(DomainException ex) {
spanRecorder.record(ex);
ErrorResponse body = new ErrorResponse(
ex.getErrorCode(),
ex.getClientMessage()
);
return ResponseEntity
.status(mapStatus(ex))
.body(body);
}
private HttpStatus mapStatus(DomainException ex) {
return switch (ex.getErrorCode()) {
case "RESOURCE_NOT_FOUND" -> HttpStatus.NOT_FOUND;
case "RESOURCE_CONFLICT" -> HttpStatus.CONFLICT;
default -> HttpStatus.BAD_REQUEST;
};
}
}
Span.current() 返回当前请求上下文中的 span。如果 WebFlux 的 OTel instrumentation 已正确传播上下文,而且记录操作发生在异常处理方法执行期间,通常可以取得对应的 HTTP server span。
没有有效 span 时,OTel API 不会写入实际记录。提前检查 isValid() 和 isRecording(),还能省去构造异常属性等额外开销。
为什么不建议从 ResponseEntity 反推异常
ResponseEntity 只能表示最终响应,无法完整说明响应产生的原因。例如,两个请求都可能返回 409,其中一个源于领域状态冲突,另一个源于幂等约束。仅检查 HTTP status 无法可靠区分这两种情况。
@ExceptionHandler 可以同时取得以下信息:
- 原始
Throwable - 领域错误码
- 最终 HTTP status
- 返回给客户端的错误模型
所以,异常处理器是记录领域错误信息最直接的位置。虽然这会让 span 记录逻辑与异常处理层产生少量显式关联,但通常比依赖方法签名和 AOP 切点更容易维护。
Reactor 上下文的注意事项
不要保存 Span.current() 的返回值,再交给其他线程或延迟任务使用。OTel 的当前 span 通常依赖上下文传播,并不是可以任意跨线程使用的全局变量。
同步记录可以这样写:
@ExceptionHandler(DomainException.class)
public ResponseEntity<Object> handleDomainException(DomainException ex) {
spanRecorder.record(ex);
return createResponse(ex);
}
如果异常处理器返回 Mono<ResponseEntity<?>>,也建议在处理器调用时立即记录:
@ExceptionHandler(DomainException.class)
public Mono<ResponseEntity<Object>> handleDomainException(DomainException ex) {
spanRecorder.record(ex);
return Mono.just(createResponse(ex));
}
如果必须在 publishOn、自定义线程池或异步回调之后修改 span,需要先确认 OTel 与 Reactor 的上下文传播配置正确,否则 Span.current() 可能返回无效 span。不要只用 ThreadLocal 保存请求 span。
是否要把所有领域异常标记为 ERROR
这取决于应用的观测策略。按照常见的 HTTP server span 语义,由客户端请求导致的 4xx 未必属于服务端错误。例如:
- 查询不存在的资源返回
404 - 乐观锁或业务状态冲突返回
409 - 参数或业务规则校验失败返回
400
如果这些情况属于正常业务分支,可以只记录 exception event 和领域错误码,不设置 StatusCode.ERROR:
span.recordException(exception);
span.setAttribute("domain.error.code", exception.getErrorCode());
如果团队希望所有领域拒绝都进入 APM 的错误分析,可以统一设置:
span.setStatus(StatusCode.ERROR, exception.getErrorCode());
还可以根据异常类型或错误码分类处理:
public void record(DomainException exception) {
Span span = Span.current();
if (!span.isRecording()) {
return;
}
String errorCode = exception.getErrorCode();
span.recordException(
exception,
Attributes.of(
AttributeKey.stringKey("domain.error.code"),
errorCode
)
);
span.setAttribute("domain.error.code", errorCode);
if (exception.isUnexpectedFailure()) {
span.setStatus(StatusCode.ERROR, errorCode);
}
}
分类处理能防止大量可预期的 400 或 404 淹没真正的服务故障。
不要把完整堆栈写入普通属性
recordException(exception) 会按照 OTel API 的异常事件模型记录异常类型、消息和堆栈,通常不需要再把 stack trace 写入字符串属性:
// 不建议
span.setAttribute("domain.error.stacktrace", stackTraceAsString);
重复保存堆栈会增大 span 体积,还可能触发后端的属性长度限制。领域错误码可以作为普通属性记录,但错误消息、请求参数和业务对象可能包含敏感信息,写入前需要过滤。
属性值也要控制基数。适合记录的是稳定枚举值:
RESOURCE_NOT_FOUND
RESOURCE_CONFLICT
ORDER_ALREADY_PAID
包含订单号、用户 ID 或随机文本的动态值不适合作为属性值。
WebExceptionHandler 可以替代现有映射
WebFlux 提供了 WebExceptionHandler。如果应用准备让它完全接管领域异常到 HTTP 响应的转换,可以在这里记录 span:
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebExceptionHandler;
import reactor.core.publisher.Mono;
@Component
@Order(-2)
public class DomainWebExceptionHandler implements WebExceptionHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable throwable) {
if (!(throwable instanceof DomainException ex)) {
return Mono.error(throwable);
}
Span span = Span.current();
span.recordException(ex);
span.setAttribute("domain.error.code", ex.getErrorCode());
span.setStatus(StatusCode.ERROR, ex.getErrorCode());
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
// 此处还需要编码并写入响应体。
return exchange.getResponse().setComplete();
}
}
已经由 @ExceptionHandler 成功处理的异常不会继续传递给外层 WebExceptionHandler。选择这个方案,就需要用它替换现有的 @ControllerAdvice 映射,不能把它作为映射之后的额外观察层。
自行实现 WebExceptionHandler 还要处理响应体编码、Content-Type、内容协商和响应提交状态。对于基于注解的 controller,如果只是补充 span 信息,改用该方案往往会增加不必要的工作。
关于 Spring Boot 4
升级后,这个问题也不会必然自动消失。即使新版提供了更完整的原生 OTel instrumentation,框架仍然很难判断由应用代码正常处理的领域异常是否应算作 span error。
从框架的角度看,请求已经生成合法响应。应用需要自行决定将 400、404 或 409 视为业务事件还是错误。迁移后,应通过集成测试确认:
@ExceptionHandler中的Span.current()是否有效。- span 是否包含 exception event。
domain.error.code是否成功导出。- span 的最终状态是否为
ERROR。 - instrumentation 是否会在请求结束时覆盖应用设置的状态。
直接在异常映射点调用普通的 span recorder,做法简单,便于测试,也不依赖 Spring AOP。
备注:内容仅供参考。
