PAYATHON 2026

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 视为业务事件还是错误。迁移后,应通过集成测试确认:

  1. @ExceptionHandler 中的 Span.current() 是否有效。
  2. span 是否包含 exception event。
  3. domain.error.code 是否成功导出。
  4. span 的最终状态是否为 ERROR。
  5. instrumentation 是否会在请求结束时覆盖应用设置的状态。

直接在异常映射点调用普通的 span recorder,做法简单,便于测试,也不依赖 Spring AOP。

备注:内容仅供参考。