Spring Boot 4.1.1 深度源码解析-第 26 章 · 指标、观测与链路追踪:Micrometer 与 OpenTelemetry

Spring Boot 4.1.1 深度源码解析-第 26 章 · 指标、观测与链路追踪:Micrometer 与 OpenTelemetry

本章目标:把"一个 HTTP 请求进来,最后变成一条 Prometheus 时序 + 一个 trace span" 这条链路上所有 Spring Boot 侧的类读完。 读完之后你应该能回答:为什么 @Observed 默认不生效; 为什么 MeterRegistry 有时是 AutoConfiguredCompositeMeterRegistry 有时是 CompositeMeterRegistry; 为什么 management.tracing.exemplars.include=all 配 Prometheus 会直接启动失败; 以及 OTEL_EXPORTER_OTLP_ENDPOINT 这个环境变量是怎么变成 Spring 属性的。


0. 从一个指标出发

bash 复制代码
$ curl -s http://localhost:8080/actuator/metrics/http.server.requests | head -20
{"name":"http.server.requests",
 "measurements":[{"statistic":"COUNT","value":3.0}, ...],
 "availableTags":[{"tag":"exception","values":["none"]}, ...]}

这个 http.server.requests 从哪儿来?在 Spring Boot 4.1.1 里,它不再 直接由 Micrometer 的 TimedAspect 产生,而是走一条三段式管线:

ruby 复制代码
业务代码 / 框架埋点
    │  Observation.start()  ──── 统一的"观测"抽象
    ▼
ObservationRegistry
    ├── ObservationPredicate     是否观测(management.observations.enable.*)
    ├── ObservationFilter        改写 KeyValues(management.observations.key-values.*)
    ├── ObservationConvention    命名与标签约定
    └── ObservationHandler<?>    真正干活的人
            ├── DefaultMeterObservationHandler      → 产生指标(MeterRegistry)
            ├── DefaultTracingObservationHandler    → 产生 span(Tracer)
            └── PropagatingSender/ReceiverTracing...  → 传播上下文

关键洞察:指标和追踪是同一个"观测"的两条出口 。 这就是为什么 Spring Boot 3.x 之后 http.server.requests 既能出指标又能出 span------ 它们不是两套埋点,是一套埋点两个 handler。

本章沿着这条管线往下读,然后分别展开指标侧(§3--§4)与追踪侧(§5--§7)。


1. 模块拓扑:8 个模块,两条世界线

ruby 复制代码
module/spring-boot-micrometer-metrics/          # 指标:MeterRegistry、导出、端点
module/spring-boot-micrometer-metrics-test/     # @AutoConfigureMetrics 的 imports
module/spring-boot-micrometer-observation/      # 观测:ObservationRegistry、@Observed 切面
module/spring-boot-micrometer-tracing/          # 追踪抽象:Tracer、ObservationHandler、Exemplars
module/spring-boot-micrometer-tracing-brave/    # 实现 A:Brave + Zipkin
module/spring-boot-micrometer-tracing-opentelemetry/  # 实现 B:OTel + Zipkin/OTLP
module/spring-boot-micrometer-tracing-test/     # @AutoConfigureTracing 的 imports
module/spring-boot-opentelemetry/               # OpenTelemetry SDK、Resource、日志导出

一个必须一开始就分清的边界:

Micrometer 世界 OpenTelemetry 世界
埋点抽象 ObservationRegistry (io.micrometer.observation) OpenTelemetrySdk (io.opentelemetry.sdk)
谁翻译 MicrometerTracingAutoConfiguration 提供 TracingObservationHandler OpenTelemetryTracingAutoConfiguration 提供 Tracer 实现
导出 Micrometer 的 *MeterRegistry(Prometheus / OTLP / ...) OTel 的 Sdk*Provider + Exporter
配置前缀 management.metrics.* / management.tracing.* management.opentelemetry.* / management.otlp.*

spring-boot-opentelemetry 是 SDK 提供方,spring-boot-micrometer-tracing-opentelemetry 是把 OTel 的 Tracer 适配成 Micrometer Tracer 的桥。两者经常一起出现,但不是一回事。 这个区分在排查"我配了 OTel 却没有 span"时至关重要。

1.1 spring-boot-micrometer-metrics 的 26 个自动配置

arduino 复制代码
CompositeMeterRegistryAutoConfiguration
MetricsAspectsAutoConfiguration
MetricsAutoConfiguration
MetricsEndpointAutoConfiguration
export.appoptics / atlas / datadog / dynatrace / elastic / ganglia / graphite / humio
     / influx / jmx / kairos / newrelic / otlp / prometheus / simple / stackdriver / statsd   (18 个)
jvm.JvmMetricsAutoConfiguration
logging.log4j2.Log4J2MetricsAutoConfiguration
logging.logback.LogbackMetricsAutoConfiguration
ssl.SslMetricsAutoConfiguration
startup.StartupTimeMetricsListenerAutoConfiguration
system.SystemMetricsAutoConfiguration
task.TaskExecutorMetricsAutoConfiguration

18 个导出器 :appoptics、atlas、datadog、dynatrace、elastic、ganglia、graphite、humio、 influx、jmx、kairos、newrelic、otlp、prometheus、simple、stackdriver、statsd(17 个前缀 + simple)。 它们全部由 @ConditionalOnEnabledMetricsExport("<name>") 控制,默认全部开启 ------ 但绝大多数还要靠 @ConditionalOnClass(对应的 MeterRegistry), 所以 classpath 里没有依赖时静默跳过。

1.2 测试切片的两个 .imports

spring-boot-micrometer-metrics-test 提供 META-INF/spring/org.springframework.boot.micrometer.metrics.test.autoconfigure.AutoConfigureMetrics.imports:

arduino 复制代码
CompositeMeterRegistryAutoConfiguration
MetricsAutoConfiguration
export.simple.SimpleMetricsExportAutoConfiguration
ObservationAutoConfiguration

spring-boot-micrometer-tracing-test 提供 ...AutoConfigureTracing.imports:

复制代码
ObservationAutoConfiguration
MicrometerTracingAutoConfiguration
NoopTracerAutoConfiguration

注意 metrics 切片里带了 SimpleMetricsExportAutoConfiguration(内存注册表,便于断言), 而 tracing 切片里带了 NoopTracerAutoConfiguration(Tracer.NOOP,不真发数据)。 这是第 29 章要讲的测试切片机制的一个具体实例。


2. Observation API:Spring Boot 侧的装配

2.1 ObservationAutoConfiguration

java 复制代码
@AutoConfiguration
@ConditionalOnClass(ObservationRegistry.class)
@EnableConfigurationProperties(ObservationProperties.class)
public final class ObservationAutoConfiguration {
    @Bean
    static ObservationRegistryPostProcessor observationRegistryPostProcessor(...) { ... }

    @Bean
    @ConditionalOnMissingBean
    ObservationRegistry observationRegistry() {
        return ObservationRegistry.create();
    }

    @Bean
    @Order(0)
    PropertiesObservationFilterPredicate propertiesObservationFilter(ObservationProperties properties) { ... }

    @Bean
    @ConditionalOnMissingBean(ValueExpressionResolver.class)
    SpelValueExpressionResolver spelValueExpressionResolver() { ... }

    @Configuration(proxyBeanMethods = false)
    @ConditionalOnClass(Advice.class)
    @ConditionalOnBooleanProperty("management.observations.annotations.enabled")
    static class ObservedAspectConfiguration { ... }
}

五个要点:

  1. observationRegistryPostProcessor 是 static bean 方法------必须尽早实例化(它是 BPP)。
  2. ObservationRegistry 用 @ConditionalOnMissingBean,你可以整个替换。
  3. PropertiesObservationFilterPredicate 标了 @Order(0) : 它同时是 ObservationFilter 和 ObservationPredicate, order 0 意味着它在 predicate 链里最先执行(早于用户的 predicate)。
  4. SpelValueExpressionResolver 支持在 @Observed 的 key-value 里写 SpEL。
  5. ObservedAspect 需要 management.observations.annotations.enabled=true(默认 false!) ------这回答了"为什么我的 @Observed 没效果"。

第 5 点是本章最常被问的问题。@Observed 注解的处理依赖 AspectJ 的 Advice 类 (@ConditionalOnClass(Advice.class)),并且默认关闭。 要启用必须显式写:

properties 复制代码
management.observations.annotations.enabled=true

并且 classpath 要有 org.aspectj:aspectjweaver。

2.2 ObservationRegistryPostProcessor:延迟创建 configurer

java 复制代码
class ObservationRegistryPostProcessor implements BeanPostProcessor {
    private volatile @Nullable ObservationRegistryConfigurer configurer;

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (bean instanceof ObservationRegistry registry) {
            getConfigurer().configure(registry);
        }
        return bean;
    }

    private ObservationRegistryConfigurer getConfigurer() {
        ObservationRegistryConfigurer configurer = this.configurer;
        if (configurer == null) {
            configurer = new ObservationRegistryConfigurer(...);
            this.configurer = configurer;
        }
        return configurer;
    }
}

为什么是 BPP 而不是在 @Bean 方法里直接配置? 因为 ObservationRegistry 可能由用户 提供(@ConditionalOnMissingBean), Spring Boot 不能假设自己创建它。BPP 会拦截任何 ObservationRegistry 类型的 bean, 包括用户自定义的。这是"定制别人创建的对象"的标准手段(第 23 章 JdbcConnectionDetailsBeanPostProcessor 是同一套路)。

volatile + 懒创建 ObservationRegistryConfigurer: 因为 BPP 本身在构造时拿到的只是 6 个 ObjectProvider, 真正解析要等到有 registry 时------避免在容器早期触发 bean 解析。 这呼应第 23 章的"容器查询必须延迟"定律。

2.3 ObservationRegistryConfigurer.configure() 的五步

java 复制代码
void configure(ObservationRegistry registry) {
    registerObservationPredicates(registry);
    registerGlobalObservationConventions(registry);
    registerHandlers(registry);
    registerFilters(registry);
    customize(registry);
}

顺序有讲究吗?从代码看,predicates / conventions / handlers / filters 都是往 registry.observationConfig() 里累加,彼此独立;最后才跑 customizer------ 因为 customizer 是给用户"最终否决权"的钩子(可能想清掉自动加的东西)。

四步的排序都用了 orderedStream():

java 复制代码
private void registerObservationPredicates(ObservationRegistry registry) {
    this.observationPredicates.orderedStream().forEach(registry.observationConfig()::observationPredicate);
}

customize 用 LambdaSafe:

java 复制代码
LambdaSafe.callbacks(ObservationRegistryCustomizer.class, this.customizers.orderedStream().toList(), registry)
    .withLogger(ObservationRegistryConfigurer.class)
    .invoke((customizer) -> customizer.customize(registry));

withLogger(...) 指定异常时用哪个类的 logger(默认是 LambdaSafe 自己)。 某个 customizer 抛异常只记日志,不影响其他 customizer。

2.4 ObservationHandlerGroup:把 handler 分组打包

这是 Spring Boot 4.0 新增的一个精巧设计。问题背景: ObservationRegistry 内部只有一个 ObservationHandler 槽位链。 如果你同时想要 metrics 和 tracing,就得把两类 handler 装进同一个链, 而 Micrometer 的 DefaultMeterObservationHandler 与 DefaultTracingObservationHandler 都会接受所有 context(都返回 true), 于是"谁先谁后"变成不确定行为。

ObservationHandlerGroup 的解法:把同类 handler 打包成一个 FirstMatchingCompositeObservationHandler------组内只要第一个匹配的生效。

java 复制代码
public interface ObservationHandlerGroup extends Comparable<ObservationHandlerGroup> {
    default boolean isMember(ObservationHandler<?> handler) {
        return handlerType().isInstance(handler);
    }
    default void registerMembers(ObservationConfig config, List<ObservationHandler<?>> members) {
        config.observationHandler(new FirstMatchingCompositeObservationHandler(members));
    }
    @Override default int compareTo(ObservationHandlerGroup other) { return 0; }
    Class<?> handlerType();
    static <H extends ObservationHandler<?>> ObservationHandlerGroup of(Class<H> handlerType) { ... }
}

ObservationHandlerGroups.register():

java 复制代码
void register(ObservationConfig config, List<ObservationHandler<?>> handlers) {
    MultiValueMap<ObservationHandlerGroup, ObservationHandler<?>> grouped = new LinkedMultiValueMap<>();
    List<ObservationHandler<?>> unclaimed = new ArrayList<>();
    for (ObservationHandler<?> handler : handlers) {
        ObservationHandlerGroup group = findGroup(handler);
        if (group == null) unclaimed.add(handler);
        else grouped.add(group, handler);
    }
    for (ObservationHandlerGroup group : this.groups) {
        List<ObservationHandler<?>> members = grouped.get(group);
        if (!CollectionUtils.isEmpty(members)) group.registerMembers(config, members);
    }
    if (!CollectionUtils.isEmpty(unclaimed)) {
        for (ObservationHandler<?> handler : unclaimed) config.observationHandler(handler);
    }
}

三步:归组 → 按组的顺序注册 → 无人认领的直接注册 。 findGroup 是"第一个认领的组赢":

java 复制代码
private @Nullable ObservationHandlerGroup findGroup(ObservationHandler<?> handler) {
    for (ObservationHandlerGroup group : this.groups) {
        if (group.isMember(handler)) return group;
    }
    return null;
}

this.groups 在构造时已排序(Collections.sort(sortedGroups)), 排序依据是 compareTo。

MetricsAutoConfiguration 贡献的组:

java 复制代码
@Bean
ObservationHandlerGroup metricsObservationHandlerGroup() {
    return ObservationHandlerGroup.of(MeterObservationHandler.class);
}

而 MicrometerTracingAutoConfiguration 贡献的组更聪明:

java 复制代码
@Bean
ObservationHandlerGroup tracingObservationHandlerGroup(Tracer tracer) {
    return ClassUtils.isPresent("io.micrometer.core.instrument.MeterRegistry", null)
            ? new TracingAndMeterObservationHandlerGroup(tracer)
            : ObservationHandlerGroup.of(TracingObservationHandler.class);
}

有 Micrometer 在就用复合组,没有就用纯 tracing 组。 用 ClassUtils.isPresent 字符串探测,避免编译期依赖。

TracingAndMeterObservationHandlerGroup:

java 复制代码
@Override
public boolean isMember(ObservationHandler<?> handler) {
    return handler instanceof MeterObservationHandler || handler instanceof TracingObservationHandler;
}
@Override
public int compareTo(ObservationHandlerGroup other) {
    if (other instanceof TracingAndMeterObservationHandlerGroup) return 0;
    return MeterObservationHandler.class.isAssignableFrom(other.handlerType()) ? -1 : 1;
}
@Override
public void registerMembers(ObservationConfig config, List<ObservationHandler<?>> members) {
    List<ObservationHandler<?>> tracingHandlers = new ArrayList<>(members.size());
    List<ObservationHandler<?>> metricsHandlers = new ArrayList<>(members.size());
    for (ObservationHandler<?> member : members) {
        if (member instanceof MeterObservationHandler<?> meterObservationHandler
                && !(member instanceof TracingAwareMeterObservationHandler<?>)) {
            metricsHandlers.add(new TracingAwareMeterObservationHandler<>(meterObservationHandler, this.tracer));
        }
        else {
            tracingHandlers.add(member);
        }
    }
    registerHandlers(config, tracingHandlers);
    registerHandlers(config, metricsHandlers);
}

三个细节:

  1. compareTo 保证本组排在纯 metrics 组之前 (返回 -1), 于是本组先认领所有 metrics + tracing handler,纯 metrics 组就拿不到人了。
  2. 包装 :非 TracingAwareMeterObservationHandler 的 metrics handler 会被 TracingAwareMeterObservationHandler 包一层,让它能感知当前 span(用于 exemplar)。
  3. 注册顺序是 tracing 先、metrics 后 。 由于每个组被包成 FirstMatchingCompositeObservationHandler, 而这个 composite 是按成员顺序匹配的,所以tracing handler 先执行。

registerHandlers 还有一个优化:

java 复制代码
private void registerHandlers(ObservationConfig config, List<ObservationHandler<?>> handlers) {
    if (handlers.size() == 1) {
        config.observationHandler(handlers.get(0));
    }
    else if (!handlers.isEmpty()) {
        config.observationHandler(new FirstMatchingCompositeObservationHandler(handlers));
    }
}

只有一个成员时不包 composite,直接注册------少一层调用开销。 空列表则不注册任何东西。

2.5 PropertiesObservationFilterPredicate:前缀最长匹配

它同时实现 ObservationFilter 和 ObservationPredicate, 一个 bean 干两件事(所以 ObservationAutoConfiguration 里只注册一次)。

公共 key-values:

java 复制代码
private static ObservationFilter createCommonKeyValuesFilter(ObservationProperties properties) {
    if (properties.getKeyValues().isEmpty()) {
        return (context) -> context;                       // ★ 空配置时返回恒等函数
    }
    KeyValues keyValues = KeyValues.of(properties.getKeyValues().entrySet(), Entry::getKey, Entry::getValue);
    return (context) -> context.addLowCardinalityKeyValues(keyValues);
}

management.observations.key-values.* 的每一对会加到每一个 observation 上, 且是 low cardinality (低基数)------这意味着它们适合做标签, 不会撑爆时序数据库。KeyValues 在配置为空时只构建一次, thereafter 复用。

启停判定:

java 复制代码
@Override
public boolean test(String name, Context context) {
    return lookupWithFallbackToAll(this.properties.getEnable(), name, true);
}

private static <T> T lookupWithFallbackToAll(Map<String, T> values, String name, T defaultValue) {
    if (values.isEmpty()) return defaultValue;
    return doLookup(values, name, () -> values.getOrDefault("all", defaultValue));
}

private static <T> T doLookup(Map<String, T> values, String name, Supplier<T> defaultValue) {
    while (StringUtils.hasLength(name)) {
        T result = values.get(name);
        if (result != null) return result;
        int lastDot = name.lastIndexOf('.');
        name = (lastDot != -1) ? name.substring(0, lastDot) : "";
    }
    return defaultValue.get();
}

逐段砍尾部、最长匹配优先:

vbscript 复制代码
management.observations.enable.http.server.requests=false
  → 查 "http.server.requests" 命中 → false
  → 查 "http.server" → 命中 → 用它
  → 查 "http" → 命中
  → 查 "all" → 兜底
  → 都没命中 → 默认值 true

所以 management.observations.enable.http=false 会关掉 http.server.requests、http.client.requests 等所有 http.*。 而 management.observations.enable.all=false 是全局关闭。

注意 values.isEmpty() 的短路:没配任何 enable 时直接返回默认 true , 连 doLookup 都不跑。这是热路径上的性能优化 (每个 observation 创建时都会调用这个 predicate)。

2.6 SpelValueExpressionResolver

java 复制代码
@Override
public String resolve(String expression, @Nullable Object parameter) {
    try {
        SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build();
        Expression expressionToEvaluate = this.expressionParser.parseExpression(expression);
        String value = expressionToEvaluate.getValue(context, parameter, String.class);
        Assert.state(value != null, "'value' must not be null");
        return value;
    }
    catch (Exception ex) {
        throw new IllegalStateException("Unable to evaluate SpEL expression '%s'".formatted(expression), ex);
    }
}

三个安全约束:

  1. SimpleEvaluationContext 而不是 StandardEvaluationContext------ 前者禁止调用任意方法、禁止访问类型,是 SpEL 的"沙箱模式"。 因为表达式可能来自配置文件(虽然通常是注解里的字符串),必须防注入。
  2. forReadOnlyDataBinding():只读。
  3. 解析结果必须非 null ,否则 Assert.state 失败; 解析异常包成 IllegalStateException 并带上原表达式。

SpelExpressionParser 是实例字段(不是 static), SpEL parser 有内部缓存,一个共享实例就够。

2.7 @Observed 切面与 MetricsAspects

两个自动配置各自提供切面,且都靠 @ConditionalOnBooleanProperty("management.observations.annotations.enabled"):

自动配置 提供的切面 处理注解
ObservationAutoConfiguration.ObservedAspectConfiguration ObservedAspect @Observed
MetricsAspectsAutoConfiguration CountedAspect + TimedAspect @Counted / @Timed
MicrometerTracingAutoConfiguration.SpanAspectConfiguration SpanAspect @NewSpan / @SpanTag

ObservedAspect 的构造:

java 复制代码
ObservedAspect observedAspect = new ObservedAspect(observationRegistry);
observationKeyValueAnnotationHandler.ifAvailable(observedAspect::setObservationKeyValueAnnotationHandler);

ObservationKeyValueAnnotationHandler 的构造:

java 复制代码
new ObservationKeyValueAnnotationHandler(beanFactory::getBean, (ignored) -> valueExpressionResolver)

第二个参数是 Function<Object, ValueExpressionResolver>, 这里写成 (ignored) -> valueExpressionResolver------忽略输入、恒定返回同一个解析器。 这种"明明是函数却恒定"的签名是为了让第三方能按上下文返回不同解析器(比如按租户隔离)。

MetricsAspectsAutoConfiguration 的排序声明值得记:

java 复制代码
@AutoConfiguration(after = { MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class,
        ObservationAutoConfiguration.class })
@ConditionalOnClass({ MeterRegistry.class, Advice.class })
@ConditionalOnBooleanProperty("management.observations.annotations.enabled")
@ConditionalOnBean(MeterRegistry.class)

四个条件叠加:@Timed 切面需要 MeterRegistry 已存在, 所以排在 CompositeMeterRegistryAutoConfiguration 之后。

TagAnnotationHandlersConfiguration 内部类只在有 ValueExpressionResolver 时才生效:

java 复制代码
@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(ValueExpressionResolver.class)
static class TagAnnotationHandlersConfiguration { ... }

而这个 bean 由 ObservationAutoConfiguration 提供------ 跨自动配置的 @ConditionalOnBean 依赖 ,靠 after 保证顺序。


3. MeterRegistry 的复合与过滤

3.1 三态:CompositeMeterRegistryAutoConfiguration 的条件

java 复制代码
@AutoConfiguration
@Import({ NoOpMeterRegistryConfiguration.class, CompositeMeterRegistryConfiguration.class })
@ConditionalOnClass(CompositeMeterRegistry.class)
public final class CompositeMeterRegistryAutoConfiguration {
}

类体是空的 ,两个 @Import 互斥地提供 MeterRegistry。

CompositeMeterRegistryConfiguration:

java 复制代码
@Configuration(proxyBeanMethods = false)
@Conditional(MultipleNonPrimaryMeterRegistriesCondition.class)
class CompositeMeterRegistryConfiguration {

    @Bean
    @Primary
    AutoConfiguredCompositeMeterRegistry compositeMeterRegistry(Clock clock, List<MeterRegistry> registries) {
        return new AutoConfiguredCompositeMeterRegistry(clock, registries);
    }

    static class MultipleNonPrimaryMeterRegistriesCondition extends NoneNestedConditions {
        MultipleNonPrimaryMeterRegistriesCondition() {
            super(ConfigurationPhase.REGISTER_BEAN);
        }
        @ConditionalOnMissingBean(MeterRegistry.class)
        static class NoMeterRegistryCondition { }
        @ConditionalOnSingleCandidate(MeterRegistry.class)
        static class SingleInjectableMeterRegistry { }
    }
}

NoneNestedConditions = "下面所有条件都不匹配才匹配"(NOR)。 所以复合注册表只在**"有 MeterRegistry 且不止一个可注入候选"**时创建:

容器里的 MeterRegistry NoMeterRegistryCondition SingleInjectableMeterRegistry NOR 结果 谁提供
0 个 匹配 --- false NoOpMeterRegistryConfiguration
1 个 不匹配 匹配 false 就是那一个(直接用)
2 个及以上 不匹配 不匹配 true AutoConfiguredCompositeMeterRegistry

ConfigurationPhase.REGISTER_BEAN 是这个条件能工作的关键------ 必须在所有 bean 定义都登记完 之后才能数清有几个 MeterRegistry。 (第 15 章讲过 phase;第 22 章 gRPC 的 MissingNetworkGrpcServerCondition 同理。)

NoOpMeterRegistryConfiguration:

java 复制代码
@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(Clock.class)
@ConditionalOnMissingBean(MeterRegistry.class)
class NoOpMeterRegistryConfiguration {
    @Bean
    CompositeMeterRegistry noOpMeterRegistry(Clock clock) {
        return new CompositeMeterRegistry(clock);
    }
}

一个空的 CompositeMeterRegistry ------一个成员都没有。 它的作用是"让依赖 MeterRegistry 的地方不 NPE"。 你注册指标它照单全收,只是没人导出。

未核实 :两个 @Configuration 都注册 CompositeMeterRegistry 类型的 bean, 靠 @ConditionalOnMissingBean(MeterRegistry.class) 互斥。 但 NoOpMeterRegistryConfiguration 的 @ConditionalOnMissingBean 在 ConfigurationPhase.REGISTER_BEAN 下的判定时机与 CompositeMeterRegistryConfiguration 的 NoneNestedConditions(同为 REGISTER_BEAN) 的相对顺序我未确认(§10)。

3.2 AutoConfiguredCompositeMeterRegistry:一个只有类型语义的空类

java 复制代码
class AutoConfiguredCompositeMeterRegistry extends CompositeMeterRegistry {
    AutoConfiguredCompositeMeterRegistry(Clock clock, List<MeterRegistry> registries) {
        super(clock, registries);
    }
}

类体完全为空 。它存在的唯一目的是"打标记"------ 让 MeterRegistryPostProcessor.isAutoConfiguredComposite(...) 能用 instanceof 区分"Spring Boot 建的复合注册表"与"用户自己建的"。 这是类型即标签 的手法(第 22 章 AutoConfiguredCompositeMeterRegistry 之外, 第 25 章 SystemHealthDescriptor 也是)。

为什么要区分?因为用户自建的 CompositeMeterRegistry 已经把成员加好了, 不该再被自动配置重复添加;而 Spring Boot 建的需要把容器里所有 registry 收进来。

3.3 MeterRegistryPostProcessor:BPP + SmartInitializingSingleton

java 复制代码
class MeterRegistryPostProcessor implements BeanPostProcessor, SmartInitializingSingleton {
    private volatile boolean deferBinding = true;
    private final Set<MeterRegistry> deferredBindings = new LinkedHashSet<>();

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (bean instanceof MeterRegistry meterRegistry) {
            postProcessMeterRegistry(meterRegistry);
        }
        return bean;
    }

    @Override
    public void afterSingletonsInstantiated() {
        synchronized (this.deferredBindings) {
            this.deferBinding = false;
            this.deferredBindings.forEach(this::applyBinders);
        }
    }

    private void postProcessMeterRegistry(MeterRegistry meterRegistry) {
        this.meterRegistryCloser.getObject().track(meterRegistry);
        // Customizers must be applied before binders, as they may add custom tags or
        // alter timer or summary configuration.
        applyCustomizers(meterRegistry);
        applyFilters(meterRegistry);
        addToGlobalRegistryIfNecessary(meterRegistry);
        if (isBindable(meterRegistry)) {
            applyBinders(meterRegistry);
        }
    }
}

顺序是硬约束(源码注释明写):customizers 必须在 binders 之前, 因为 customizer 可能加通用标签或改 timer 配置,而 binder 注册的指标必须看到这些改动。

deferBinding 机制是这里最精妙的部分:

java 复制代码
void applyBinders(MeterRegistry meterRegistry) {
    if (this.deferBinding) {
        synchronized (this.deferredBindings) {
            if (this.deferBinding) {
                this.deferredBindings.add(meterRegistry);
                return;
            }
        }
    }
    this.binders.orderedStream().forEach((binder) -> binder.bindTo(meterRegistry));
}

双检查 + volatile 标志。在容器启动完成前(afterSingletonsInstantiated 之前), 所有 binder 绑定请求被攒起来 ;一旦所有单例就绪,deferBinding = false 并批量执行。

为什么要延迟?因为 MeterBinder(如 JvmMemoryMetrics)本身可能依赖其他 bean, 而过早调用 binder.bindTo(registry) 会触发过早实例化, 可能拿到一个"半成品"的 bean(典型:被 BPP 处理之前的对象)。 这与第 23 章 EntityManagerFactoryBuilder 把 AsyncTaskExecutor 改成 Supplier 是同一个问题、同一种解法。

isBindable 的三态判定:

java 复制代码
private boolean isBindable(MeterRegistry meterRegistry) {
    return isAutoConfiguredComposite(meterRegistry)
            || isCompositeWithOnlyUserDefinedComposites(meterRegistry)
            || noCompositeMeterRegistries();
}

配合 CompositeMeterRegistries 枚举:

java 复制代码
enum CompositeMeterRegistries {
    NONE, AUTO_CONFIGURED, ONLY_USER_DEFINED;

    private static CompositeMeterRegistries of(ApplicationContext context) {
        if (hasBeansOfType(AutoConfiguredCompositeMeterRegistry.class, context)) return AUTO_CONFIGURED;
        return hasBeansOfType(CompositeMeterRegistry.class, context) ? ONLY_USER_DEFINED : NONE;
    }
    private static boolean hasBeansOfType(Class<?> type, ApplicationContext context) {
        return context.getBeanNamesForType(type, false, false).length > 0;
    }
}

getBeanNamesForType(type, false, false) 的后两个 false 是 includeNonSingletons 和 allowEagerInit------禁止提前初始化, 只数 bean 定义。这是在 BPP 里查询容器的安全姿势。

applyFilters 的分流:

java 复制代码
private void applyFilters(MeterRegistry meterRegistry) {
    if (this.filters != null) {
        Stream<MeterFilter> filters = this.filters.orderedStream();
        if (isAutoConfiguredComposite(meterRegistry)) {
            filters = filters.filter(MaximumAllowableTagsMeterFilter.class::isInstance);
        }
        filters.forEach(meterRegistry.config()::meterFilter);
    }
}

只给自动配置的复合注册表加 MaximumAllowableTagsMeterFilter 。 原因:复合注册表会把 filter 应用到所有成员; 而基数限制过滤器(如 management.metrics.web.server.max-uri-tags=100)只需要 在复合层做一次,不需要在每个成员注册表上重复。其他用户定义的 filter 则不加------ 因为用户的 filter 应该由用户自己决定加给谁。

3.4 全局注册表与关闭

java 复制代码
private void addToGlobalRegistryIfNecessary(MeterRegistry meterRegistry) {
    if (this.properties.getObject().isUseGlobalRegistry() && !isGlobalRegistry(meterRegistry)) {
        Metrics.addRegistry(meterRegistry);
        this.meterRegistryCloser.getObject().trackAddedToGlobalRegistry(meterRegistry);
    }
}

management.metrics.use-global-registry 默认 true 。 Metrics.globalRegistry 是 Micrometer 的静态单例------ 这是 Spring Boot 里少见的"鼓励用全局静态"的地方 , 因为第三方库(不经过 Spring 容器的)只能用 Metrics.globalRegistry 注册指标。

代价是:同一 JVM 多个 ApplicationContext 会互相污染。 所以有了 MeterRegistryCloser:

java 复制代码
class MeterRegistryCloser implements ApplicationListener<ContextClosedEvent> {
    private final Set<MeterRegistry> registriesToClose = new CopyOnWriteArraySet<>();
    private final Set<MeterRegistry> registriesToRemoveFromGlobalRegistry = new CopyOnWriteArraySet<>();

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        if (!this.context.equals(event.getApplicationContext())) {
            return;                                   // ★ 只处理自己的容器
        }
        for (MeterRegistry meterRegistry : this.registriesToRemoveFromGlobalRegistry) {
            Metrics.removeRegistry(meterRegistry);
        }
        for (MeterRegistry meterRegistry : this.registriesToClose) {
            if (!meterRegistry.isClosed()) {
                meterRegistry.close();
            }
        }
    }
}

三处讲究:

  1. if (!this.context.equals(event.getApplicationContext())) return; ------ContextClosedEvent 会沿容器层级向上传播, 子容器关闭时父容器也会收到(如果父容器注册了监听器)。 这里显式判断"是不是我的容器",避免管理端口子容器关闭时误关父容器的 registry。
  2. CopyOnWriteArraySet:注册时写多、遍历时读多?其实注册一次、遍历一次, 但 BPP 可能在多线程下调用 track。
  3. 先从 global 摘除、再 close------顺序反了会有短暂窗口让新指标写进正在关闭的 registry。

用 ApplicationListener<ContextClosedEvent> 而不是 @PreDestroy, 是因为 close() 要在尽可能早的关闭阶段执行(让最后的指标 flush 出去), 而 bean 销毁顺序不可控。

3.5 PropertiesMeterFilter:配置如何变成 Micrometer 语义

java 复制代码
@Override
public MeterFilterReply accept(Meter.Id id) {
    boolean enabled = lookupWithFallbackToAll(this.properties.getEnable(), id, true);
    return enabled ? MeterFilterReply.NEUTRAL : MeterFilterReply.DENY;
}

注意 MeterFilterReply.NEUTRAL 而不是 ACCEPT------ NEUTRAL 表示"我没意见,让别人决定",不会短路后续的 filter。 这是 filter 链的礼仪:只有在确定要拒绝时才 DENY。

通用标签:

java 复制代码
private static MeterFilter createMapFilter(Map<String, String> tags) {
    if (tags.isEmpty()) {
        return new MeterFilter() { };         // ★ 空实现,不做任何事
    }
    Tags commonTags = Tags.of(tags.entrySet().stream().map(PropertiesMeterFilter::asTag).toList());
    return MeterFilter.commonTags(commonTags);
}

new MeterFilter() { } 是 MeterFilter 接口的空实现 (MeterFilter 所有方法都有默认实现)。空配置时零开销。

分布统计配置是这一段最复杂的:

java 复制代码
@Override
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
    Distribution distribution = this.properties.getDistribution();
    return DistributionStatisticConfig.builder()
        .percentilesHistogram(lookupWithFallbackToAll(distribution.getPercentilesHistogram(), id, null))
        .percentiles(lookupWithFallbackToAll(distribution.getPercentiles(), id, null))
        .serviceLevelObjectives(convertServiceLevelObjectives(id.getType(), lookup(distribution.getSlo(), id)))
        .minimumExpectedValue(convertMeterValue(id.getType(), lookup(distribution.getMinimumExpectedValue(), id)))
        .maximumExpectedValue(convertMeterValue(id.getType(), lookup(distribution.getMaximumExpectedValue(), id)))
        .expiry(lookupWithFallbackToAll(distribution.getExpiry(), id, null))
        .bufferLength(lookupWithFallbackToAll(distribution.getBufferLength(), id, null))
        .build()
        .merge(config);
}

注意两点:

  1. percentiles 用 lookupWithFallbackToAll(有 all 兜底), 而 slo / minimumExpectedValue / maximumExpectedValue 用 lookup(不兜底) 。 这个不对称很关键:management.metrics.distribution.slo.all=... 无效 , 必须写具体 meter 名。我判断原因是 SLO 与 min/max 是数量纲的 (Timer 是纳秒,DistributionSummary 是任意单位), 给"所有 meter"设同一个 SLO 在语义上没意义。 而 percentiles(0--1 之间的比例)是无量纲的,可以做全局默认。
  2. .merge(config) 放在最后------ DistributionStatisticConfig.builder()...build() 会产生一个"部分配置"对象, merge 时 null 字段表示"不覆盖"。这是 Micrometer 的惯用法: 未配置的维度留 null,让它保持已有值。

MeterValue 的类型感知:

java 复制代码
public @Nullable Double getValue(Type meterType) {
    if (meterType == Type.DISTRIBUTION_SUMMARY) return getDistributionSummaryValue();
    if (meterType == Type.TIMER || meterType == Type.LONG_TASK_TIMER) {
        Long timerValue = getTimerValue();
        if (timerValue != null) return timerValue.doubleValue();
    }
    return null;
}
private @Nullable Double getDistributionSummaryValue() {
    return (this.value instanceof Double doubleValue) ? doubleValue : null;
}
private @Nullable Long getTimerValue() {
    if (this.value instanceof Double doubleValue) return TimeUnit.MILLISECONDS.toNanos(doubleValue.longValue());
    if (this.value instanceof Duration duration) return duration.toNanos();
    return null;
}

同一个配置值,对 Timer 和 DistributionSummary 解释不同 : 纯数字对 Timer 会被当成毫秒 再转纳秒(MILLISECONDS.toNanos), 对 DistributionSummary 则原样使用。 所以 management.metrics.distribution.minimum-expected-value.http.server.requests=100 的意思是"100 毫秒",而 ...=100ms 或 ...=PT0.1S 也行 (DurationStyle.detectAndParse)。

MeterValue.valueOf(String):

java 复制代码
public static MeterValue valueOf(String value) {
    if (onlyDigits(value)) return new MeterValue(Double.parseDouble(value));
    Duration duration = safeParseDuration(value);
    if (duration != null) return new MeterValue(duration);
    return new MeterValue(Double.parseDouble(value));       // ★ 走到这里会抛 NumberFormatException
}

先试纯数字,再试 Duration,最后再试一次 Double (失败就抛)。 onlyDigits 只认 Character.isDigit,所以 1.5 不是纯数字 → 走 Duration 解析 → 失败 → 走 Double.parseDouble("1.5") → 成功。这个"三连尝试"覆盖了 100 / 100ms / 1.5 三种写法。

ServiceLevelObjectiveBoundary 还带一个 AOT 提示注册器:

java 复制代码
static class ServiceLevelObjectiveBoundaryHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) {
        hints.reflection().registerType(ServiceLevelObjectiveBoundary.class, MemberCategory.INVOKE_PUBLIC_METHODS);
    }
}

因为 ServiceLevelObjectiveBoundary.valueOf(String) 是静态工厂方法 , Binder 在运行时通过反射调用它(第 8 章讲的 valueOf 绑定约定), Native Image 下必须显式注册。(第 28 章展开 RuntimeHints。)

3.6 MaximumAllowableTagsMeterFilter:基数爆炸的最后防线

java 复制代码
@Override
public MeterFilterReply accept(Id id) {
    if (this.meterNamePrefix == null) return logAndDeny();
    String tagValue = id.getName().startsWith(this.meterNamePrefix) ? id.getTag(this.tagKey) : null;
    if (tagValue != null && !this.observedTagValues.contains(tagValue)) {
        if (this.observedTagValues.size() >= this.maximumTagValues) {
            return logAndDeny();
        }
        this.observedTagValues.add(tagValue);
    }
    return MeterFilterReply.NEUTRAL;
}

private MeterFilterReply logAndDeny() {
    if (this.logger.isWarnEnabled() && this.alreadyWarned.compareAndSet(false, true)) {
        this.logger.warn(this.message.get());
    }
    return MeterFilterReply.DENY;
}

三个设计点:

  1. observedTagValues 是 ConcurrentHashMap.newKeySet() , 运行期持续增长------但只存 tag 值字符串,且数量被 maximumTagValues 封顶。
  2. alreadyWarned 是 AtomicBoolean + compareAndSet : 只 WARN 一次。因为这类超限在高峰期会每秒触发上万次, 打满日志比指标基数爆炸还可怕。
  3. message 是 Supplier<String>,延迟构造字符串------ 只在真的要打日志时才拼。

默认 management.metrics.web.client.max-uri-tags = 100、 management.metrics.web.server.max-uri-tags = 100。 超过 100 个不同 URI 之后的 http.server.requests 直接被丢弃(DENY), 不是截断------所以你会看到指标数量突然停在某个值。

这是生产事故的高频来源 :REST 风格路径 /users/{id} 如果没走 WebMvcTagsProvider 的 URI 模板化,就会变成 /users/1、/users/2...... 瞬间撑爆。第 19/20 章讲过 MVC/WebFlux 的 URI 模板处理,那里是第一道防线, 这里是最后一道。


4. 指标维度:端点、导出与内置 binder

4.1 MetricsEndpoint

java 复制代码
@Endpoint(id = "metrics")
public class MetricsEndpoint {
    @ReadOperation
    public MetricNamesDescriptor listNames() { ... }

    @ReadOperation
    public @Nullable MetricDescriptor metric(@Selector String requiredMetricName, @Nullable List<String> tag) { ... }
}

listNames 对复合注册表递归展开:

java 复制代码
private void collectNames(Set<String> names, MeterRegistry registry) {
    if (registry instanceof CompositeMeterRegistry compositeMeterRegistry) {
        compositeMeterRegistry.getRegistries().forEach((member) -> collectNames(names, member));
    }
    else {
        registry.getMeters().stream().map(this::getName).forEach(names::add);
    }
}

用 TreeSet 排序,所以输出是字母序。

metric(...) 的"第一个非空的成员胜出":

java 复制代码
private Collection<Meter> findFirstMatchingMeters(CompositeMeterRegistry composite, String name, Iterable<Tag> tags) {
    return composite.getRegistries().stream()
        .map((registry) -> findFirstMatchingMeters(registry, name, tags))
        .filter((matching) -> !matching.isEmpty())
        .findFirst()
        .orElse(Collections.emptyList());
}

只查第一个有结果的成员注册表,不是合并所有成员。 因为同名指标在多个 registry 里内容应该相同(都是同一个 meter 定义的), 合并反而会重复计数。

tag 参数解析的错误处理:

java 复制代码
private Tag parseTag(String tag) {
    String[] parts = tag.split(":", 2);
    if (parts.length != 2) {
        throw new InvalidEndpointRequestException(
                "Each tag parameter must be in the form 'key:value' but was: " + tag,
                "Each tag parameter must be in the form 'key:value'");
    }
    return Tag.of(parts[0], parts[1]);
}

split(":", 2) 的 limit=2 保证 uri:/api/v1 只切一刀,值里可以有冒号。 InvalidEndpointRequestException 是 Actuator 的专用异常, 会被翻译成 400 而不是 500。

4.2 导出器的两级开关

java 复制代码
class OnMetricsExportEnabledCondition extends SpringBootCondition {
    private static final String PROPERTY_TEMPLATE = "management.%s.metrics.export.enabled";
    private static final String DEFAULT_PROPERTY_NAME = "management.defaults.metrics.export.enabled";

    @Override
    public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
        String endpointName = annotationAttributes.getString("value");
        ConditionOutcome outcome = getProductOutcome(context, endpointName);
        if (outcome != null) return outcome;
        return getDefaultOutcome(context);
    }
    private @Nullable ConditionOutcome getProductOutcome(ConditionContext context, String productName) {
        String enabledProperty = PROPERTY_TEMPLATE.formatted(productName);
        if (environment.containsProperty(enabledProperty)) {
            boolean match = environment.getProperty(enabledProperty, Boolean.class, true);
            return new ConditionOutcome(match, ...);
        }
        return null;                                   // ★ 没配 → 交给下一级
    }
    private ConditionOutcome getDefaultOutcome(ConditionContext context) {
        boolean match = Boolean.parseBoolean(context.getEnvironment().getProperty(DEFAULT_PROPERTY_NAME, "true"));
        return new ConditionOutcome(match, ...);
    }
}

两级:

  1. management.<product>.metrics.export.enabled(如 management.prometheus.metrics.export.enabled)
  2. management.defaults.metrics.export.enabled(默认 true)

注意 getDefaultOutcome 用的是 getProperty(name, "true") + Boolean.parseBoolean, 而 getProductOutcome 用的是 getProperty(name, Boolean.class, true) 。 两者对非法值的处理不同:前者 parseBoolean 遇到非法值返回 false , 后者走 Spring 转换会抛异常。这是一个不一致,但只在写错值时才暴露。 DEFAULT_PROPERTY_NAME 叫 management.defaults.metrics.export.enabled (带 defaults 复数),与第 25 章 health 的 management.health.defaults.enabled 一致命名风格。

4.3 Prometheus:三个 bean + 两个可选配置

java 复制代码
@AutoConfiguration(
        before = { CompositeMeterRegistryAutoConfiguration.class, SimpleMetricsExportAutoConfiguration.class },
        after = MetricsAutoConfiguration.class)
@ConditionalOnBean(Clock.class)
@ConditionalOnClass(PrometheusMeterRegistry.class)
@ConditionalOnEnabledMetricsExport("prometheus")
@EnableConfigurationProperties(PrometheusProperties.class)
public final class PrometheusMetricsExportAutoConfiguration {
    @Bean @ConditionalOnMissingBean
    PrometheusConfig prometheusConfig(PrometheusProperties prometheusProperties) { ... }

    @Bean @ConditionalOnMissingBean
    PrometheusMeterRegistry prometheusMeterRegistry(PrometheusConfig prometheusConfig,
            PrometheusRegistry prometheusRegistry, Clock clock, ObjectProvider<SpanContext> spanContext) {
        return new PrometheusMeterRegistry(prometheusConfig, prometheusRegistry, clock, spanContext.getIfAvailable());
    }

    @Bean @ConditionalOnMissingBean
    PrometheusRegistry prometheusRegistry() { return new PrometheusRegistry(); }
    ...
}

注意 before = { CompositeMeterRegistryAutoConfiguration.class, SimpleMetricsExportAutoConfiguration.class } ------导出器必须排在复合注册表之前,这样复合注册表才能把 Prometheus registry 收进去。

ObjectProvider<SpanContext> spanContext 是 exemplar 的钩子(§6)。

scrape 端点 (/actuator/prometheus):

java 复制代码
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ PrometheusScrapeEndpoint.class, ConditionalOnAvailableEndpoint.class })
@ConditionalOnAvailableEndpoint(PrometheusScrapeEndpoint.class)
static class PrometheusScrapeEndpointConfiguration { ... }

@ConditionalOnClass(ConditionalOnAvailableEndpoint.class)------条件类上再加条件类 , 因为 PrometheusScrapeEndpoint 可能在没有 actuator 的情况下被加载。 这是防御性写法。

Pushgateway:

java 复制代码
private PushGateway initializePushGateway(...) {
    ...
    MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
        entries.put("management.prometheus.metrics.export.pushgateway.token", properties.getToken());
        entries.put("management.prometheus.metrics.export.pushgateway.username", properties.getUsername());
    });
    if (StringUtils.hasText(properties.getToken())) {
        builder.bearerToken(properties.getToken());
    }
    else if (StringUtils.hasText(properties.getUsername())) {
        builder.basicAuth(properties.getUsername(), properties.getPassword());
    }
    ...
}

token(Bearer)与 username(Basic)互斥 , 同时配抛 MutuallyExclusiveConfigurationPropertiesException (第 25 章 PropertiesEndpointAccessResolver 里见过同一个异常)。 且 token 优先。

FALLBACK_JOB = "spring"------没配 job 名时用 spring。 job 还会尝试从 spring.application.name 取(getJob(properties, environment), 实现未展开,列 §10)。

4.4 内置 binder 一览

自动配置 提供的 binder 条件
JvmMetricsAutoConfiguration JvmGcMetrics JvmHeapPressureMetrics JvmMemoryMetrics JvmThreadMetrics ClassLoaderMetrics JvmInfoMetrics @ConditionalOnBean(MeterRegistry.class)
SystemMetricsAutoConfiguration ProcessorMetrics UptimeMetrics FileDescriptorMetrics DiskSpaceMetricsBinder 同上
LogbackMetricsAutoConfiguration LogbackMetrics ch.qos.logback 在
Log4J2MetricsAutoConfiguration Log4J2Metrics Log4j2 在
SslMetricsAutoConfiguration SslMeterBinder SslBundles 在
TaskExecutorMetricsAutoConfiguration ExecutorServiceMetrics(按 executor 实例) @ConditionalOnBean({ Executor.class, MeterRegistry.class })
StartupTimeMetricsListenerAutoConfiguration StartupTimeMetricsListener(不是 binder,是 listener) @ConditionalOnBean(MeterRegistry.class)

JvmMetricsAutoConfiguration 里有一处虚拟线程的支持:

java 复制代码
private static final String VIRTUAL_THREAD_METRICS_CLASS =
        "io.micrometer.java21.instrument.binder.jdk.VirtualThreadMetrics";

用字符串常量而非 Class 引用------因为 micrometer-java11/java21 是可选依赖。 我未读到这个常量的实际使用位置(应该在某个 @ConditionalOnClass(name = ...) 里),列 §10。

TaskExecutorMetricsAutoConfiguration 用了两种特殊机制:

java 复制代码
@Autowired
void bindTaskExecutorsToRegistry(ConfigurableListableBeanFactory beanFactory, MeterRegistry registry) {
    SimpleAutowireCandidateResolver.resolveAutowireCandidates(beanFactory, TaskExecutor.class)
        .forEach((beanName, executor) -> { ... });
}

@Bean
static LazyInitializationExcludeFilter eagerTaskExecutorMetrics() {
    return LazyInitializationExcludeFilter.forBeanTypes(TaskExecutorMetricsAutoConfiguration.class);
}
  1. SimpleAutowireCandidateResolver :第 23 章 HibernateMetricsAutoConfiguration 用过同一个类。 它在不触发实例化的前提下解析"哪些 bean 会被当作 TaskExecutor 注入"------ 严格说它在这里会实例化(要拿到 ThreadPoolExecutor), 但避免了 getBeansOfType 带来的副作用(比如创建 FactoryBean 的产品对象)。
  2. LazyInitializationExcludeFilter.forBeanTypes(TaskExecutorMetricsAutoConfiguration.class): 懒初始化时本类必须提前实例化,否则 executor 已经跑起来了却没监控。

safeGetThreadPoolExecutor 吞掉 IllegalStateException:

java 复制代码
private @Nullable ThreadPoolExecutor safeGetThreadPoolExecutor(ThreadPoolTaskExecutor taskExecutor) {
    try { return taskExecutor.getThreadPoolExecutor(); }
    catch (IllegalStateException ex) { return null; }
}

ThreadPoolTaskExecutor.getThreadPoolExecutor() 在 executor 已 shutdown 时抛 IllegalStateException。这里静默跳过------监控不该让应用启动失败。

4.5 ValidationFailureAnalyzer

java 复制代码
class ValidationFailureAnalyzer extends AbstractFailureAnalyzer<ValidationException> {
    @Override
    protected FailureAnalysis analyze(Throwable rootFailure, ValidationException cause) {
        StringBuilder description = new StringBuilder(String.format("Invalid Micrometer configuration detected:%n"));
        for (Invalid<?> failure : cause.getValidation().failures()) {
            description.append(String.format("%n  - %s was '%s' but it %s",
                    failure.getProperty(), failure.getValue(), failure.getMessage()));
        }
        return new FailureAnalysis(description.toString(),
                "Update your application to correct the invalid configuration.", cause);
    }
}

Micrometer 的 ValidationException 携带一组 失败项(不是单个), 所以这里循环拼接成一份诊断报告。这是第 16 章 FailureAnalyzer 体系的又一个实例: 把第三方库的多项错误聚合成一份人类可读的报告。

4.6 SimpleMetricsExportAutoConfiguration:兜底导出器

前面 §4.2 讲了"两级开关"这个通用机制,现在看两个具体导出器。它们演示了两种 完全不同、但都很典型的装配策略。

java 复制代码
@AutoConfiguration(before = CompositeMeterRegistryAutoConfiguration.class, after = MetricsAutoConfiguration.class)
@ConditionalOnBean(Clock.class)
@EnableConfigurationProperties(SimpleProperties.class)
@ConditionalOnMissingBean(MeterRegistry.class)
@ConditionalOnEnabledMetricsExport("simple")
public final class SimpleMetricsExportAutoConfiguration {

	@Bean
	SimpleMeterRegistry simpleMeterRegistry(SimpleConfig config, Clock clock) {
		return new SimpleMeterRegistry(config, clock);
	}

	@Bean
	@ConditionalOnMissingBean
	SimpleConfig simpleConfig(SimpleProperties simpleProperties) {
		return new SimplePropertiesConfigAdapter(simpleProperties);
	}

}

SimpleMeterRegistry 的行为是"只在内存里保留,不往任何地方推"。它存在的唯一理由: 让 /actuator/metrics 端点(§4.1)在你没有配置任何真实导出器时依然能返回数据。 这就是类注释里 "only used as a fallback" 的全部含义。

但真正值得停下来看的是排序 。@ConditionalOnMissingBean(MeterRegistry.class) 的正确性完全依赖于"我是最后一个被求值的"。如果某个真实导出器在我之后才求值, 它看到的就是"容器里已经有 MeterRegistry 了"(我刚注册的 SimpleMeterRegistry), 于是它自己的 @ConditionalOnMissingBean 不成立,静默失效------ 用户以为配了 Prometheus,实际拿到一个什么都不导出的内存注册表,而且没有任何报错。

Spring Boot 的自动配置排序只在显式声明 的顺序对之间生效;没有声明关系的两个类之间, 顺序由拓扑排序的剩余自由度决定(实践中接近字母序,但不是契约 )。 因此 Spring Boot 用了最笨也最可靠的办法:让每一个真实导出器都显式把 Simple 排在自己后面 。 我在本仓库数了一遍:export/ 下 16 个导出器自动配置,全部 16 个都这么做了:

java 复制代码
// OtlpMetricsExportAutoConfiguration
@AutoConfiguration(
		before = { CompositeMeterRegistryAutoConfiguration.class, SimpleMetricsExportAutoConfiguration.class },
		after = MetricsAutoConfiguration.class)

这形成一个星形拓扑:Simple 是唯一的汇点,所有真实导出器都指向它 。 代价是每新增一个导出器都要记得加这一行(漏了不报错,只会在特定组合下出错); 好处是顺序完全确定。对比第 14 章的 beforeName / afterName: 能直接引用 Class 时用 before,不能(跨模块、可选依赖)时才用字符串形式。

第二个细节:@ConditionalOnBean(Clock.class) 配 after = MetricsAutoConfiguration.class。 Clock 是 Micrometer 的时间抽象,由 MetricsAutoConfiguration 提供。 这里用 @ConditionalOnBean 而不是 @ConditionalOnClass------ 要的是"容器里真的有一个统一的时钟实例":所有 MeterRegistry 共享同一个 Clock, 否则两个注册表对"现在"的理解不一致,聚合出的速率与分位数不可比。 (测试里注入 MockClock 时,@ConditionalOnBean 也保证它会被所有注册表采用。)

最后一个语义陷阱:@ConditionalOnEnabledMetricsExport("simple")(§4.2)默认 matchIfMissing = true,也就是什么都不配时 simple 是"启用"的 。 它之所以仍不总是生效,靠的是 @ConditionalOnMissingBean(MeterRegistry.class) 而不是开关本身------ simple 的"兜底"语义由条件实现,不由开关实现。

4.7 OTLP 指标导出:平台线程 / 虚拟线程两套 bean

java 复制代码
@AutoConfiguration(
		before = { CompositeMeterRegistryAutoConfiguration.class, SimpleMetricsExportAutoConfiguration.class },
		after = MetricsAutoConfiguration.class)
@ConditionalOnBean(Clock.class)
@ConditionalOnClass({ OtlpMeterRegistry.class, OpenTelemetryProperties.class })
@ConditionalOnEnabledMetricsExport("otlp")
@EnableConfigurationProperties({ OtlpMetricsProperties.class, OpenTelemetryProperties.class })
public final class OtlpMetricsExportAutoConfiguration {
	...
}

@ConditionalOnClass 里出现 OpenTelemetryProperties.class 值得单独指出: OTLP 指标导出用的是 Micrometer 自己的 micrometer-registry-otlp, 但它要求 spring-boot-opentelemetry 模块也在类路径上 。 这是 §1"两条世界线"的一次交汇------指标走 Micrometer、而 OTLP 通用配置在 OTel 模块里。 所以只加 micrometer-registry-otlp 依赖是不够的,自动配置根本不会激活。

两个互斥的 bean 方法是本节的核心:

java 复制代码
@Bean
@ConditionalOnMissingBean
@ConditionalOnThreading(Threading.PLATFORM)
OtlpMeterRegistry otlpMeterRegistry(OtlpConfig otlpConfig, Clock clock, OtlpMetricsSender metricsSender,
		ObjectProvider<ExemplarContextProvider> exemplarContextProvider) {
	return builder(otlpConfig, clock, metricsSender, exemplarContextProvider).build();
}

@Bean
@ConditionalOnMissingBean
@ConditionalOnThreading(Threading.VIRTUAL)
OtlpMeterRegistry otlpMeterRegistryVirtualThreads(OtlpConfig otlpConfig, Clock clock,
		OtlpMetricsSender metricsSender, ObjectProvider<ExemplarContextProvider> exemplarContextProvider) {
	VirtualThreadTaskExecutor executor = new VirtualThreadTaskExecutor("otlp-meter-registry-");
	return builder(otlpConfig, clock, metricsSender, exemplarContextProvider)
		.threadFactory(executor.getVirtualThreadFactory())
		.build();
}

两个方法返回同一个类型 、bean 名不同,靠 @ConditionalOnThreading 保证只有一个生效。 这是第 12 章讲 @ConditionalOnThreading 时容易漏掉的一个用法变体: 它不只用于"要不要注册这个 bean",还用于"注册两个里的哪一个"。

虚拟线程版怎么用 VirtualThreadTaskExecutor 也值得看:

java 复制代码
VirtualThreadTaskExecutor executor = new VirtualThreadTaskExecutor("otlp-meter-registry-");
...
.threadFactory(executor.getVirtualThreadFactory())

它要的不是 executor,而是 executor 内部的线程工厂 。 VirtualThreadTaskExecutor 在这里被当成"带命名能力的虚拟线程工厂提供者"使用。 为什么不直接 Executors.newVirtualThreadPerTaskExecutor()? 因为那个 API 不支持自定义线程名------而 Micrometer 的推送线程必须可诊断: 你在 jstack 里要能一眼看出 otlp-meter-registry-N 是谁。 顺便一提,这个 executor 创建后没有被注册为 bean、也没有被 close------ 它只是工厂的来源,线程生命周期由 Micrometer 自己管。

超时计算有一处容易看错:

java 复制代码
Duration connectTimeout = this.properties.getConnectTimeout();
Duration timeout = connectTimeout.plus(this.properties.getReadTimeout());
JdkClientHttpSender httpSender = new JdkClientHttpSender(connectTimeout, timeout, connectionDetails.getSslBundle());
return new OtlpHttpMetricsSender(httpSender);

第二个参数是总超时 ,等于 connect + read 之和,不是 read 的独立值。 所以 management.otlp.metrics.connect-timeout=5s + read-timeout=10s 得到的实际总超时是 15s。这和很多 HTTP 客户端"两个超时各自独立"的直觉不一样。

最后是 Exemplar 的接线,它把 §6 的内容和这里接上:

java 复制代码
private OtlpMeterRegistry.Builder builder(OtlpConfig otlpConfig, Clock clock, OtlpMetricsSender metricsSender,
		ObjectProvider<ExemplarContextProvider> exemplarContextProvider) {
	OtlpMeterRegistry.Builder builder = OtlpMeterRegistry.builder(otlpConfig)
		.clock(clock)
		.metricsSender(metricsSender);
	exemplarContextProvider.ifAvailable(builder::exemplarContextProvider);
	return builder;
}

ifAvailable 而不是 getObject():Exemplar 是可选增强 , 没有 ExemplarContextProvider bean 时 OTLP registry 照样工作,只是数据点不带 trace 引用。 这对应 §6.2 的"OTLP 侧 ALL 合法"------合法性由 OtlpMetricsExemplarContextProvider bean 自身的条件决定,这里只负责"有就接上,没有就算了"。

内部类 PropertiesOtlpMetricsConnectionDetails 把属性适配成 OtlpMetricsConnectionDetails 接口,与 §7.5 的 Docker Compose / Testcontainers 实现形成对照: 同一个接口,三种来源 (属性 / compose / testcontainers), 自动配置只依赖接口,完全不知道数据从哪来。 它的 getSslBundle() 里有一条延迟到运行时的校验:

java 复制代码
Assert.notNull(this.sslBundles, "SSL bundle name has been set but no SSL bundles found in context");

配置绑定阶段无法知道 SslBundles 是否存在(它自己也是个 bean), 只能在真正取 bundle 时断言。这是 §4.5 ValidationFailureAnalyzer 之外的另一条 "配置错误暴露路径":直接抛异常,由容器包成 BeanCreationException。

4.8 StartupTimeMetricsListener:把启动耗时变成指标

java 复制代码
@AutoConfiguration(after = { MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class })
@ConditionalOnClass(MeterRegistry.class)
@ConditionalOnBean(MeterRegistry.class)
public final class StartupTimeMetricsListenerAutoConfiguration {

	@Bean
	@ConditionalOnMissingBean
	StartupTimeMetricsListener startupTimeMetrics(MeterRegistry meterRegistry) {
		return new StartupTimeMetricsListener(meterRegistry);
	}

}

注意它是 after = CompositeMeterRegistryAutoConfiguration------ 要等到复合注册表(§3.1)建好之后才注册,否则注入的是某个单一成员而不是复合, 指标就只会进到一个导出器里。

listener 本身:

java 复制代码
public class StartupTimeMetricsListener implements SmartApplicationListener {

	public static final String APPLICATION_STARTED_TIME_METRIC_NAME = "application.started.time";
	public static final String APPLICATION_READY_TIME_METRIC_NAME = "application.ready.time";
	...
	@Override
	public boolean supportsEventType(Class<? extends ApplicationEvent> eventType) {
		return ApplicationStartedEvent.class.isAssignableFrom(eventType)
				|| ApplicationReadyEvent.class.isAssignableFrom(eventType);
	}

	@Override
	public void onApplicationEvent(ApplicationEvent event) {
		if (event instanceof ApplicationStartedEvent startedEvent) {
			onApplicationStarted(startedEvent);
		}
		if (event instanceof ApplicationReadyEvent readyEvent) {
			onApplicationReady(readyEvent);
		}
	}

	private void registerGauge(String name, String description, @Nullable Duration timeTaken,
			SpringApplication springApplication) {
		if (timeTaken != null) {
			Iterable<Tag> tags = createTagsFrom(springApplication);
			TimeGauge.builder(name, timeTaken::toMillis, TimeUnit.MILLISECONDS)
				.tags(tags)
				.description(description)
				.register(this.meterRegistry);
		}
	}

	private Iterable<Tag> createTagsFrom(SpringApplication springApplication) {
		Class<?> mainClass = springApplication.getMainApplicationClass();
		return (mainClass != null) ? this.tags.and("main.application.class", mainClass.getName()) : this.tags;
	}

}

三个值得讲的决策。

1. 用 TimeGauge 而不是 Timer。 启动耗时是已经发生完的一次性数值 , 不是持续采样的分布。用 Timer 会让 Prometheus 侧出现 application_started_time_seconds_count{...} 1.0 这种单样本分布------ 浪费存储,还让人误以为可以算 p99。 TimeGauge.builder(name, timeTaken::toMillis, TimeUnit.MILLISECONDS) 的做法是 把一个固定的 Duration 包成 gauge,每次抓取返回同一个值。 这是 Micrometer 里"把一次性事件表达成指标"的标准手法。 Duration 不可变,所以这个 gauge 的值恒定、也不需要生命周期清理。

2. main.application.class 这个 tag 的基数风险。 它取自 springApplication.getMainApplicationClass(),对绝大多数应用是恒定值 ,基数安全。 但如果你用 SpringApplicationBuilder 动态推导主类、 或在同一个 JVM 里跑多个 SpringApplication(测试里很常见), 这个 tag 就会产生多个时间序列------这是有意的,它让"哪个应用"可区分。

3. timeTaken != null 的防御。 ApplicationStartedEvent.getTimeTaken() 声明为 @Nullable。 我没有逐行追出它何时为 null(列 §10),但从签名看 Spring Boot 把它建模成 "某些启动路径下拿不到耗时"。这里的处理是直接跳过 而不是记为 0: 记为 0 会污染平均值/最小值,而缺一个样本只是少一个点。 缺失优于错误,这是指标埋点的一条通用原则。

这两个指标与第 22 章的 ApplicationStartup / StartupStep 互补而非替代: StartupStep 是分层耗时(细,看树状结构,回答"慢在哪一步"), application.started.time / application.ready.time 是两个总耗时 (粗,回答"这次发布是不是变慢了")。


5. 链路追踪:从 Tracer 到 span

5.1 一个 NOOP 兜底:NoopTracerAutoConfiguration

java 复制代码
@AutoConfiguration(before = MicrometerTracingAutoConfiguration.class)
@ConditionalOnClass(Tracer.class)
@ConditionalOnMissingBean(Tracer.class)
public final class NoopTracerAutoConfiguration {
    @Bean
    Tracer noopTracer() {
        return Tracer.NOOP;
    }
}

这是理解整个 tracing 自动配置的钥匙 。 它的存在使得 MicrometerTracingAutoConfiguration 可以无条件写 @ConditionalOnBean(Tracer.class)------因为一定有(要么是真实的,要么是 NOOP)。

Tracer.NOOP 产生的 span 全都是 NoopSpan,context().traceId() 返回空串。 所以"有 tracing 依赖但没有 exporter"时,你仍然会得到 span 对象,只是不会被上报。

这带来一个重要的排查结论: 日志里没有 traceId ≠ tracing 没生效。可能是:

  1. Tracer 是 NOOP(没有任何 bridge 依赖)
  2. Tracer 是真的但采样率把它丢了(默认 10%)
  3. Tracer 是真的、也采样了,但 exporter 没配
  4. 日志 pattern 里没有 %correlationId(见 §5.5)

5.2 MicrometerTracingAutoConfiguration 的三个 order 常量

java 复制代码
public static final int DEFAULT_TRACING_OBSERVATION_HANDLER_ORDER = Ordered.LOWEST_PRECEDENCE - 1000;
public static final int RECEIVER_TRACING_OBSERVATION_HANDLER_ORDER = 1000;
public static final int SENDER_TRACING_OBSERVATION_HANDLER_ORDER = 2000;

三个 handler,三种 order:

handler order 语义
DefaultTracingObservationHandler LOWEST_PRECEDENCE - 1000(= Integer.MAX_VALUE - 1000) 兜底:任何 context 都创建 span
PropagatingReceiverTracingObservationHandler 1000 从请求头接收上下文(服务端入站)
PropagatingSenderTracingObservationHandler 2000 把上下文注入请求头(客户端出站)

注意 LOWEST_PRECEDENCE - 1000 是下溢回绕 吗? Ordered.LOWEST_PRECEDENCE = Integer.MAX_VALUE,减 1000 仍是很大的正数,没有溢出。 但它比 LOWEST_PRECEDENCE 小 ,所以在排序里更靠前------ 这是刻意的:让兜底 handler 排在"最低优先级"之前一点点, 保证它一定在链的末尾(因为用户自定义 handler 通常用 0 或小的 order)。

PropagatingReceiver 的 order(1000) < PropagatingSender 的 order(2000), 而它们都被包进 FirstMatchingCompositeObservationHandler (§2.4 TracingAndMeterObservationHandlerGroup.registerMembers 按成员顺序注册, 成员顺序来自 observationHandlers.orderedStream())。 所以接收先于发送------符合直觉:先处理入站上下文,再考虑出站传播。

两个 Propagating handler 都有 @ConditionalOnBean(Propagator.class):

java 复制代码
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(Propagator.class)
@Order(SENDER_TRACING_OBSERVATION_HANDLER_ORDER)
PropagatingSenderTracingObservationHandler<?> propagatingSenderTracingObservationHandler(
        Tracer tracer, Propagator propagator) { ... }

Propagator 由 bridge 模块提供(Brave 或 OTel)。没有就只有兜底 handler。

5.3 TracingProperties:默认是"采样 10%"

java 复制代码
public static class Sampling {
    private float probability = 0.10f;
}

默认采样率 10% 。这是很多人"配了 Zipkin 但只看到十分之一请求"的原因。 改:management.tracing.sampling.probability=1.0。

Propagation 的 produce / consume 分离:

java 复制代码
private @Nullable List<PropagationType> type;
private List<PropagationType> produce = List.of(PropagationType.W3C);
private List<PropagationType> consume = List.of(PropagationType.values());

public enum PropagationType { W3C, B3, B3_MULTI }

发出的请求只带 W3C 头,但接收时三种都认 (PropagationType.values())。 这是一个"宽进严出"的策略:对外只发标准格式,对内兼容老系统。 也可以 management.tracing.propagation.type=w3c,b3 同时设置 produce 和 consume。

Baggage:enabled 默认 true,correlation.enabled 默认 true, remoteFields / localFields / tagFields 默认空(需要显式开启才能传播 baggage)。

Exemplars:

java 复制代码
public static class Exemplars {
    private Include include = Include.SAMPLED_TRACES;
    public enum Include { ALL, NONE, SAMPLED_TRACES }
}

默认 SAMPLED_TRACES------只有被采样的 trace 才作为 exemplar。这个默认值在 Prometheus 场景下会引发一个启动失败,见 §6。

5.4 ConditionalOnEnabledTracingExport 的两级判定

java 复制代码
class OnEnabledTracingExportCondition extends SpringBootCondition {
    private static final String GLOBAL_PROPERTY = "management.tracing.export.enabled";
    private static final String EXPORTER_PROPERTY = "management.tracing.export.%s.enabled";

    @Override
    public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
        String tracingExporter = getExporterName(metadata);
        if (StringUtils.hasLength(tracingExporter)) {
            Boolean exporterTracingEnabled = context.getEnvironment()
                .getProperty(EXPORTER_PROPERTY.formatted(tracingExporter), Boolean.class);
            if (exporterTracingEnabled != null) {
                return new ConditionOutcome(exporterTracingEnabled, ...);
            }
        }
        Boolean globalTracingEnabled = context.getEnvironment().getProperty(GLOBAL_PROPERTY, Boolean.class);
        if (globalTracingEnabled != null) {
            return new ConditionOutcome(globalTracingEnabled, ...);
        }
        return ConditionOutcome.match(...because("tracing is enabled by default"));
    }
}

三级:具体导出器 → 全局 → 默认开启 。 与 §4.2 的 metrics 导出条件同构,但这里第三级是无条件 match (metrics 那边是读 management.defaults.metrics.export.enabled 默认 "true")。 两者行为等价,实现不同。

5.5 LogCorrelationEnvironmentPostProcessor:一个跨模块的隐式联动

这是本章最容易被忽略、但影响最大的一处:

java 复制代码
class LogCorrelationEnvironmentPostProcessor implements EnvironmentPostProcessor {
    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
        if (ClassUtils.isPresent("io.micrometer.tracing.Tracer", application.getClassLoader())) {
            environment.getPropertySources().addLast(new LogCorrelationPropertySource(this, environment));
        }
    }

    private static class LogCorrelationPropertySource extends EnumerablePropertySource<Object> {
        private static final String NAME = "logCorrelation";
        private final Environment environment;

        @Override
        public String[] getPropertyNames() {
            return new String[] { LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY };
        }

        @Override
        public @Nullable Object getProperty(String name) {
            if (name.equals(LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY)) {
                return this.environment.getProperty("management.tracing.export.enabled", Boolean.class, Boolean.TRUE);
            }
            return null;
        }
    }
}

注册在 module/spring-boot-micrometer-tracing/src/main/resources/META-INF/spring.factories:

ini 复制代码
org.springframework.boot.EnvironmentPostProcessor=\
org.springframework.boot.micrometer.tracing.autoconfigure.LogCorrelationEnvironmentPostProcessor

它做的事:如果 classpath 有 io.micrometer.tracing.Tracer, 就把 management.tracing.export.enabled(默认 true)映射成 LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY。

而第 24 章讲过:logging.expect-correlation-id=true 是让 %correlationId 在日志 pattern 里真正生效的开关 (CorrelationIdFormatter.DEFAULT = of("traceId(32),spanId(16)"))。

于是形成一条跨模块隐式联动:

ini 复制代码
classpath 有 micrometer-tracing
    ↓  EnvironmentPostProcessor
logging.expect-correlation-id = management.tracing.export.enabled(默认 true)
    ↓  第 24 章的 CorrelationIdFormatter
日志里出现 traceId / spanId

三个细节:

  1. addLast :放在属性源链末尾。所以用户显式写 logging.expect-correlation-id=false 会覆盖它(前面的属性源优先)。 这是正确的优先级:显式配置 > 推断。
  2. ClassUtils.isPresent(..., application.getClassLoader()) : 用的是 SpringApplication 的 classloader,而不是当前类的------ 因为 EnvironmentPostProcessor 本身已经被加载了,但 Tracer 未必在。
  3. getPropertyNames() 只返回一个名字 : 这是一个"单属性"的属性源,专为这一件事服务。 它 implements EnumerablePropertySource 是为了让 Actuator 的 env 端点能枚举到它 (第 24 章 PropertySourceInfo 相关机制)。

未核实 :LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY 的字面值 我在本仓库读到的引用是常量,没看到字面量定义 (它应该在 core/spring-boot 的 LoggingSystem 里)。 按命名惯例推断是 "logging.expect-correlation-id"(§10)。

5.6 SpanAspect(@NewSpan / @SpanTag)

java 复制代码
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Advice.class)
@ConditionalOnBooleanProperty("management.observations.annotations.enabled")
static class SpanAspectConfiguration {
    @Bean @ConditionalOnMissingBean(NewSpanParser.class)
    DefaultNewSpanParser newSpanParser() { return new DefaultNewSpanParser(); }

    @Bean @ConditionalOnMissingBean @ConditionalOnBean(ValueExpressionResolver.class)
    SpanTagAnnotationHandler spanTagAnnotationHandler(BeanFactory beanFactory,
            ValueExpressionResolver valueExpressionResolver) { ... }

    @Bean @ConditionalOnMissingBean(MethodInvocationProcessor.class)
    ImperativeMethodInvocationProcessor imperativeMethodInvocationProcessor(...) { ... }

    @Bean @ConditionalOnMissingBean
    SpanAspect spanAspect(MethodInvocationProcessor methodInvocationProcessor) { ... }
}

同样受 management.observations.annotations.enabled 控制(默认 false)。 注意 ImperativeMethodInvocationProcessor------名字里的 "Imperative" 暗示 还有一个响应式版本(我未在本仓库找到,§10)。

四个 bean 形成一条链: SpanAspect → MethodInvocationProcessor → NewSpanParser + Tracer + SpanTagAnnotationHandler。 SpanTagAnnotationHandler 又依赖 ValueExpressionResolver(§2.6 的 SpEL)。

5.7 Brave bridge:排序、互斥校验与一个全限定名的坑

Micrometer Tracing 只是一套门面 (io.micrometer.tracing.Tracer), 真正的实现要由 Brave 或 OpenTelemetry 提供。

java 复制代码
@AutoConfiguration(before = { MicrometerTracingAutoConfiguration.class, NoopTracerAutoConfiguration.class })
@ConditionalOnClass({ Tracer.class, BraveTracer.class })
@EnableConfigurationProperties({ TracingProperties.class, BraveTracingProperties.class })
@Import({ BravePropagationConfigurations.PropagationWithoutBaggage.class,
		BravePropagationConfigurations.PropagationWithBaggage.class,
		BravePropagationConfigurations.NoPropagation.class })
public final class BraveAutoConfiguration {

	private static final String DEFAULT_APPLICATION_NAME = "application";
	...
}

before = { MicrometerTracingAutoConfiguration.class, NoopTracerAutoConfiguration.class } 是两个 bridge 共用的一字不差的声明(§5.8 的 OTel 版完全相同)。为什么必须排在这两个之前:

  • 排在 MicrometerTracingAutoConfiguration 之前:后者要基于已经存在的 io.micrometer.tracing.Tracer 做后续装配(ObservationHandler、baggage 等);
  • 排在 NoopTracerAutoConfiguration 之前:它的条件是 @ConditionalOnMissingBean(Tracer.class)(§5.1)。Brave 先注册真 Tracer,NOOP 就自动退场; 用户没引任何 bridge 时,NOOP 才兜上。

这正是 §5.1"NOOP 兜底"能成立的前提:NOOP 必须被排在所有真实实现之后 。 两个 bridge 都写了显式 before,而不是依赖字母序巧合------ 依赖巧合的代码在重构包结构时就会碎掉。

三处 IncompatibleConfigurationException:启动期的语义互斥校验
java 复制代码
if (this.braveTracingProperties.isSpanJoiningSupported()) {
	if (this.tracingProperties.getPropagation().getType() != null
			&& this.tracingProperties.getPropagation().getType().contains(PropagationType.W3C)) {
		throw new IncompatibleConfigurationException("management.tracing.propagation.type",
				"management.brave.tracing.span-joining-supported");
	}
	if (this.tracingProperties.getPropagation().getType() == null
			&& this.tracingProperties.getPropagation().getProduce().contains(PropagationType.W3C)) {
		throw new IncompatibleConfigurationException("management.tracing.propagation.produce",
				"management.brave.tracing.span-joining-supported");
	}
	if (this.tracingProperties.getPropagation().getType() == null
			&& this.tracingProperties.getPropagation().getConsume().contains(PropagationType.W3C)) {
		throw new IncompatibleConfigurationException("management.tracing.propagation.consume",
				"management.brave.tracing.span-joining-supported");
	}
}

(背景:span joining 是 Brave 的特性------收到远端 span 时若本地已有同 id 的 span 就复用而非新建, 它与 W3C 传播格式的语义冲突。)

三个判断的结构 比内容更值得看:第一个要求 type != null(§5.3 说过 type 是 produce/consume 的快捷设置,显式给了就只看它); 后两个都要求 type == null(没用快捷方式才分别看 produce 和 consume)。

把 §5.3 的默认值代进去(produce 默认 W3C、consume 默认全部), 得到一个反直觉的结论:默认配置下只要你打开 management.brave.tracing.span-joining-supported=true,启动就会失败 。 必须先显式设 management.tracing.propagation.type=b3 才能用这个 Brave 特性。

IncompatibleConfigurationException 来自 org.springframework.boot.context.properties, 会被第 16 章讲的 FailureAnalyzer 体系转成可读报告。 校验的代价只有花在报错信息上才算花值了------用户看到的是"这两个属性不能同时设置", 而不是一个裸异常栈。

其余几处
java 复制代码
String applicationName = environment.getProperty("spring.application.name", DEFAULT_APPLICATION_NAME);
Builder builder = Tracing.newBuilder()
	.currentTraceContext(currentTraceContext)
	.traceId128Bit(true)
	.supportsJoin(this.braveTracingProperties.isSpanJoiningSupported())
	.propagationFactory(propagationFactory)
	.sampler(sampler)
	.localServiceName(applicationName);
spanHandlers.forEach(builder::addSpanHandler);
tracingCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
  • environment.getProperty(..., DEFAULT_APPLICATION_NAME):直接读 Environment 而不是 @Value 。 两者都能表达缺省值,但 @Value("${spring.application.name:application}") 会把默认值埋进 注解字符串、其它代码无法复用;这里提成 static final 常量。
  • traceId128Bit(true):硬编码,没有对应的配置属性。128 位 traceId 是 W3C 的强制要求, 有意不让你关掉。
  • Sampler.create(this.tracingProperties.getSampling().getProbability()):接 §5.3 的默认 0.10f。 Brave 只有概率采样一种,与 OTel 的 6 种形成对比。
java 复制代码
@Bean
@ConditionalOnMissingBean
@Order(Ordered.HIGHEST_PRECEDENCE)
CompositeSpanHandler compositeSpanHandler(ObjectProvider<SpanExportingPredicate> predicates,
		ObjectProvider<SpanReporter> reporters, ObjectProvider<SpanFilter> filters) {
	return new CompositeSpanHandler(predicates.orderedStream().toList(), reporters.orderedStream().toList(),
			filters.orderedStream().toList());
}

@Order(HIGHEST_PRECEDENCE) 加在 bean 方法 上而不是类上,影响的是 spanHandlers.forEach(builder::addSpanHandler) 拿到的列表顺序------ 聚合器必须最先 拿到 span,才能在内部按 predicate → filter → reporter 分发。 三个 ObjectProvider 都用 orderedStream():用户的扩展点保持可排序。

最后是一个很容易踩的坑:

java 复制代码
@Bean
@ConditionalOnMissingBean(io.micrometer.tracing.Tracer.class)
BraveTracer braveTracerBridge(brave.Tracer tracer, CurrentTraceContext currentTraceContext) { ... }

条件里写的是全限定名 ,参数里是简名 brave.Tracer。因为本类 import brave.Tracer, 若条件写成 @ConditionalOnMissingBean(Tracer.class) 指的就是 Brave 的 Tracer------ 而那个 bean 刚在上一行注册过,条件恒不成立,bridge 永远不注册。 同名类型在条件注解里必须写全限定名,编译器不会帮你发现这个错误。

5.8 OpenTelemetry bridge:同一个门面的另一种实现

java 复制代码
@AutoConfiguration(before = { MicrometerTracingAutoConfiguration.class, NoopTracerAutoConfiguration.class })
@ConditionalOnClass({ OtelTracer.class, SdkTracerProvider.class, OpenTelemetry.class })
@EnableConfigurationProperties({ TracingProperties.class, OpenTelemetryTracingProperties.class })
@Import({ PropagationWithoutBaggage.class, PropagationWithBaggage.class, NoPropagation.class })
public final class OpenTelemetryTracingAutoConfiguration {

	OpenTelemetryTracingAutoConfiguration(TracingProperties tracingProperties,
			OpenTelemetryTracingProperties openTelemetryTracingProperties) {
		this.tracingProperties = tracingProperties;
		if (!CollectionUtils.isEmpty(this.tracingProperties.getBaggage().getLocalFields())) {
			logger.warn("Local fields are not supported when using OpenTelemetry!");
		}
		this.openTelemetryTracingProperties = openTelemetryTracingProperties;
	}
	...
}

与 Brave 版的差异从构造器 就开始了:OTel 版在这里打了一条 WARN------ baggage 的 local-fields 在 OTel 下不支持。

为什么放在构造器?这是 final 类、没有 @PostConstruct、也没实现生命周期接口, 构造器是唯一的初始化钩子。代价是条件不满足、该类被丢弃时日志也已经打出 ------ 可接受,因为它只在用户显式配置 local-fields 时触发,频率极低。

local-fields 是 Micrometer Tracing 里"只在本地线程内传递、不跨服务传播"的 baggage 字段, OTel 的 baggage 模型没有这个区分,只能警告了事。 这是门面抽象与实现能力不匹配的典型实例:门面定义了概念,实现不支持, 自动配置的职责是"尽早、清晰地告诉用户",而不是静默忽略。

6 种 sampler:一个穷尽的 switch 表达式
java 复制代码
Sampler otelSampler() {
	return switch (this.openTelemetryTracingProperties.getSampler()) {
		case ALWAYS_ON -> Sampler.alwaysOn();
		case ALWAYS_OFF -> Sampler.alwaysOff();
		case TRACE_ID_RATIO -> Sampler.traceIdRatioBased(this.tracingProperties.getSampling().getProbability());
		case PARENT_BASED_ALWAYS_ON -> Sampler.parentBased(Sampler.alwaysOn());
		case PARENT_BASED_ALWAYS_OFF -> Sampler.parentBased(Sampler.alwaysOff());
		case PARENT_BASED_TRACE_ID_RATIO ->
			Sampler.parentBased(Sampler.traceIdRatioBased(this.tracingProperties.getSampling().getProbability()));
	};
}
  1. 没有 default 分支 。Java switch 表达式对枚举要求"穷尽或 default",这里穷尽 6 个值。 好处是将来 OTel 加第 7 种 sampler 时这里会编译失败------用编译错误代替运行时遗漏。
  2. TRACE_ID_RATIO 与 PARENT_BASED_TRACE_ID_RATIO 复用 tracingProperties.getSampling().getProbability()(§5.3 的默认 0.10f)。 采样概率是通用属性(在 spring-boot-micrometer-tracing),sampler 类型是 OTel 专属属性------ 两个 bridge 共用前者、各自定义后者,这是一条清晰的属性分层。
  3. PARENT_BASED_* 语义是"上游已决定就跟随、没决定才用我的规则"。 这是分布式追踪里唯一正确的默认:否则调用链会因某个服务采样率低而断掉,你看到的 trace 是残缺的。
其余几处
java 复制代码
Tracer otelTracer(OpenTelemetry openTelemetry) {
	return openTelemetry.getTracer("org.springframework.boot", SpringBootVersion.getVersion());
}

instrumentation scope 的名字与版本写死为 "org.springframework.boot" 和当前 Spring Boot 版本, 符合 OTel 规范(每个 instrumentation library 声明身份)。副作用是你在 Grafana/Jaeger 里 看到的 span 带上 Spring Boot 版本号,可用来对比升级前后的行为------意外的好处。

SpanLimits 的 6 个限制(最大属性数 / 事件数 / 链接数、每事件与每链接的属性数、 属性值最大长度)全部来自配置,是"span 太大"的防御,分总数 与细粒度两层------ 因为一条 span 的体积既可能由"很多属性"造成,也可能由"一个超长属性"造成。

BatchSpanProcessor 的装配与 §7.4 的 BatchLogRecordProcessor 结构完全对称 : 同样 5 个批处理参数(timeout / maxBatchSize / maxQueueSize / scheduleDelay / includeUnsampled)、 同样 composite 聚合、同样 meterProvider.ifAvailable(builder::setMeterProvider) 让自监控(导出失败率、队列长度)可选启用。

两个 bridge 的对照
维度 Brave OpenTelemetry
排序声明 before { MicrometerTracing, NoopTracer } 完全相同
采样策略 Sampler.create(probability),一种 6 种 switch
traceId 长度 硬编码 128 位 由 SDK 决定
service name spring.application.name,默认 "application" 由 Resource 决定(§7.2),默认 unknown_service
启动期互斥校验 3 处 IncompatibleConfigurationException 无
明确不支持的能力 无 baggage.local-fields(WARN)
span 事件机制 无对应物 EventPublisher + Slf4JEventListener

最有意思的是最后一行。OTel 侧多了一整套事件机制: OTelEventPublisher 把事件广播给所有 EventListener, 默认只有 Slf4JEventListener(把 span 事件写进日志)。 OTel 把 span 事件当作一等公民,Brave 则留给 handler 自行处理; Spring Boot 的处理是为 OTel 多配一个默认监听器、为 Brave 什么都不做------ 让每个 bridge 保持本色,不做人为拉平。


6. Exemplars:Prometheus 与 OTLP 的一个关键差异

Exemplar 是"把一条 trace 的 id 挂在某个 metric 数据点上"的机制, 让你可以从 Prometheus 的某个延迟桶直接跳到对应的 trace。

6.1 Prometheus 侧:不支持 ALL

java 复制代码
static class LazyTracingSpanContext implements SpanContext {
    LazyTracingSpanContext(ObjectProvider<Tracer> tracerProvider, Include include) {
        if (include == Include.ALL) {
            throw new InvalidConfigurationPropertyValueException("management.tracing.exemplars.include", "all",
                    "Prometheus doesn't support including 'all' traces as exemplars.");
        }
        this.tracer = SingletonSupplier.of(tracerProvider::getObject);
        this.include = include;
    }
    ...
    @Override
    public boolean isCurrentSpanSampled() {
        Span currentSpan = currentSpan();
        if (currentSpan == null) return false;
        return switch (this.include) {
            case ALL -> throw new UnsupportedOperationException("Including 'all' traces as exemplars is not supported");
            case NONE -> false;
            case SAMPLED_TRACES -> isSampled(currentSpan);
        };
    }
}

构造时就抛 InvalidConfigurationPropertyValueException ------ 即 management.tracing.exemplars.include=all + Prometheus 依赖 = 启动失败。 原因写在异常消息里:Prometheus 的 exemplar 存储有上限, 不可能把每条 trace 都挂上去。

注意还有一处运行时的 UnsupportedOperationException 作为第二道防线 (case ALL -> 分支)------虽然构造时已经拦了,但理论上 LazyTracingSpanContext 可能被其它路径构造。

6.2 OTLP 侧:ALL 是合法的

java 复制代码
private boolean isExemplar(@Nullable Span span) {
    if (span == null) return false;
    return switch (this.include) {
        case ALL -> true;
        case NONE -> false;
        case SAMPLED_TRACES -> isSampled(span);
    };
}

OTLP 用 OtlpExemplarContext(traceId, spanId),没有数量限制, 所以 ALL 合法。同一个配置属性,在两个后端下语义不同。

6.3 两者的共同点:Lazy + SingletonSupplier + 关断条件

java 复制代码
Bean: @ConditionalOnMissingBean(SpanContext.class) / @ConditionalOnMissingBean(ExemplarContextProvider.class)
      @Conditional(...NotDisabledCondition)

static class SpanContextNotDisabledCondition extends NoneNestedConditions {
    SpanContextNotDisabledCondition() { super(ConfigurationPhase.REGISTER_BEAN); }
    @ConditionalOnProperty(value = "management.tracing.exemplars.include", havingValue = "none")
    static class NoneIncluded { }
}

NoneNestedConditions + havingValue = "none": management.tracing.exemplars.include=none 时连 bean 都不创建。 这是"关掉功能就彻底不加载"的做法(而不是加载一个空实现)。

SingletonSupplier.of(tracerProvider::getObject): Tracer 的获取被延迟到第一次真正需要时。 因为 Tracer bean 可能与本 bean 有循环依赖风险(Tracer ← ObservationRegistry ← handlers)。

两个自动配置的排序都用了 beforeName:

java 复制代码
@AutoConfiguration(
        beforeName = "org.springframework.boot.micrometer.metrics.autoconfigure.export.prometheus.PrometheusMetricsExportAutoConfiguration",
        after = MicrometerTracingAutoConfiguration.class)

用字符串类名 而不是 before = Xxx.class------ 因为 spring-boot-micrometer-tracing 不(编译期)依赖 spring-boot-micrometer-metrics。这是跨可选模块排序的标准手法 (第 23 章 HibernateMetricsAutoConfiguration 用 afterName 同理)。

必须排在导出自动配置之前 :因为导出器构造时要注入 SpanContext (§4.3 PrometheusMeterRegistry 的第 4 个参数), exemplar 的 provider bean 必须先存在。


7. OpenTelemetry:SDK、Resource 与环境变量桥

7.1 OpenTelemetrySdkAutoConfiguration:两个互斥 bean

java 复制代码
@Bean
@ConditionalOnMissingBean(OpenTelemetry.class)
@ConditionalOnEnabledOpenTelemetry
OpenTelemetrySdk openTelemetrySdk(ObjectProvider<SdkTracerProvider> ..., ObjectProvider<ContextPropagators> ...,
        ObjectProvider<SdkLoggerProvider> ..., ObjectProvider<SdkMeterProvider> ...) {
    OpenTelemetrySdkBuilder builder = OpenTelemetrySdk.builder();
    openTelemetrySdkTracerProvider.ifAvailable(builder::setTracerProvider);
    openTelemetryContextPropagators.ifAvailable(builder::setPropagators);
    openTelemetrySdkLoggerProvider.ifAvailable(builder::setLoggerProvider);
    openTelemetrySdkMeterProvider.ifAvailable(builder::setMeterProvider);
    return builder.build();
}

@Bean
@ConditionalOnMissingBean(OpenTelemetry.class)
@Conditional(OnDisabledOpenTelemetryCondition.class)
OpenTelemetrySdk disabledOpenTelemetrySdk(ObjectProvider<ContextPropagators> openTelemetryContextPropagators) {
    OpenTelemetrySdkBuilder builder = OpenTelemetrySdk.builder();
    openTelemetryContextPropagators.ifAvailable(builder::setPropagators);
    return builder.build();
}

static class OnDisabledOpenTelemetryCondition extends NoneNestedConditions {
    OnDisabledOpenTelemetryCondition() { super(ConfigurationPhase.PARSE_CONFIGURATION); }
    @ConditionalOnEnabledOpenTelemetry
    static class EnabledCondition { }
}

两个 bean 方法返回同一类型 OpenTelemetrySdk,靠条件互斥:

  • 启用时:装 tracer / logger / meter provider
  • 禁用时:什么都不装(但仍保留 propagators)

OnDisabledOpenTelemetryCondition 用 NoneNestedConditions 嵌套 @ConditionalOnEnabledOpenTelemetry------"启用"的反面 。 它的 phase 是 PARSE_CONFIGURATION(比 REGISTER_BEAN 早), 因为它只判断 @ConditionalOnBooleanProperty,不需要看 bean 定义。

而 @ConditionalOnEnabledOpenTelemetry 本身:

java 复制代码
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.METHOD })
@Documented
@ConditionalOnBooleanProperty(name = "management.opentelemetry.enabled", matchIfMissing = true)
public @interface ConditionalOnEnabledOpenTelemetry {
}

一个没有属性的注解,纯粹是 @ConditionalOnBooleanProperty 的语义化别名 。 这种"给属性条件起个名字"的做法在 Spring Boot 里很常见 (ConditionalOnEnabledMetricsExport 是带参数的版本)。 好处是:条件文案读起来是 @ConditionalOnEnabledOpenTelemetry 而不是一堆属性名,且改名时只需改一处。

management.opentelemetry.enabled 默认 true。

7.2 Resource:service.name 的四级来源

java 复制代码
@Bean
@ConditionalOnMissingBean
Resource openTelemetryResource(Environment environment, OpenTelemetryProperties properties) {
    return Resource.getDefault().merge(toResource(environment, properties));
}

Resource.getDefault() 是 OTel 的默认值(含 service.name=unknown_service:java 、telemetry.sdk.* 等),merge 用户配置。

OpenTelemetryResourceAttributes.applyTo:

java 复制代码
public void applyTo(BiConsumer<String, String> consumer) {
    Map<String, String> attributes = getResourceAttributesFromEnv();
    this.resourceAttributes.forEach((name, value) -> {
        if (StringUtils.hasLength(name) && value != null) attributes.put(name, value);
    });
    attributes.computeIfAbsent("service.name", (key) -> getApplicationName());
    attributes.computeIfAbsent("service.namespace", (key) -> getServiceNamespace());
    attributes.forEach(consumer);
}
private String getApplicationName() {
    return this.environment.getProperty("spring.application.name", DEFAULT_SERVICE_NAME);
}
private @Nullable String getServiceNamespace() {
    return this.environment.getProperty("spring.application.group");
}

service.name 的四级优先级(从高到低):

  1. OTEL_SERVICE_NAME 环境变量
  2. OTEL_RESOURCE_ATTRIBUTES 里的 service.name=
  3. management.opentelemetry.resource-attributes.service.name
  4. spring.application.name
  5. 兜底 "unknown_service"

注意第 4 级:spring.application.name 未设置时用 DEFAULT_SERVICE_NAME = "unknown_service"。 而 OTel 官方规范里的 Resource.getDefault() 通常是 unknown_service:java------ 这里被显式覆盖成了 unknown_service (没有 :java 后缀)。 这是一个与 OTel 默认值的有意偏离,我未在源码里找到原因注释。

service.namespace 的来源很特别:spring.application.group (不是 spring.application.namespace)。这是一个 Spring Boot 特有的属性 (用于服务分组),被映射到了 OTel 的 namespace 概念。

OTEL_RESOURCE_ATTRIBUTES 的解析:

java 复制代码
private Map<String, String> getResourceAttributesFromEnv() {
    Map<String, String> attributes = new LinkedHashMap<>();
    for (String attribute : StringUtils.tokenizeToStringArray(getEnv("OTEL_RESOURCE_ATTRIBUTES"), ",")) {
        int index = attribute.indexOf('=');
        if (index > 0) {
            String key = attribute.substring(0, index);
            String value = attribute.substring(index + 1);
            attributes.put(key.trim(), StringUtils.uriDecode(value.trim(), StandardCharsets.UTF_8));
        }
    }
    String otelServiceName = getEnv("OTEL_SERVICE_NAME");
    if (otelServiceName != null) attributes.put("service.name", otelServiceName);
    return attributes;
}

index > 0 而不是 index >= 0------=value 这种空 key 会被跳过 。 StringUtils.uriDecode:OTel 规范要求 resource attributes 的值做 URL 解码 (因为值里可能有空格等特殊字符)。

systemEnvironment 是一个 Function<String, String>,默认 System::getenv, 构造器可注入------为测试留的缝。

7.3 OpenTelemetryEnvironmentVariableEnvironmentPostProcessor:最大的一张映射表

这是 Spring Boot 4.x 里我见过的最大的一张"标准环境变量 → Spring 属性"映射表。 它让你可以完全不写 Spring 配置 ,只靠 OTel 官方环境变量 (OTEL_*)就完成全部配置。

注册在 spring-boot-opentelemetry/src/main/resources/META-INF/spring.factories:

ini 复制代码
org.springframework.boot.EnvironmentPostProcessor=\
org.springframework.boot.opentelemetry.autoconfigure.OpenTelemetryEnvironmentVariableEnvironmentPostProcessor

主流程:

java 复制代码
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
    if (!environment.getProperty(ENABLED_PROPERTY, Boolean.class, true)) {
        return;
    }
    Map<String, OriginTrackedValue> map = new HashMap<>();
    mapEnabled(map);
    mapPropagators(map);
    mapSampler(map);
    mapMetricsEnvironmentVariables(map);
    mapTracesEnvironmentVariables(map);
    mapLogsEnvironmentVariables(map);
    if (map.isEmpty()) return;
    environment.getPropertySources()
        .addFirst(new OriginTrackedMapPropertySource("openTelemetryEnvironmentVariables", map));
}

四个要点:

  1. 总开关 :management.opentelemetry.map-environment-variables(默认 true)。 设 false 就完全不映射(比如你就是想手写 Spring 属性)。
  2. addFirst :放在属性源链最前面 ------即 OTEL_* 环境变量的优先级 高于 application.properties! 这是一个反直觉但正确的选择:OTel 规范规定环境变量优先级最高, Kubernetes 运维习惯也是通过环境变量注入。 注意这与 §5.5 的 LogCorrelationPropertySource(用 addLast)相反。
  3. OriginTrackedMapPropertySource + OriginTrackedValue : 带来源追踪(第 7 章 ConfigData 的 Origin 机制), 所以 Actuator 的 env 端点会告诉你这个属性来自哪个环境变量。
  4. if (map.isEmpty()) return;:一个环境变量都没设时完全不添加属性源。

映射表(节选最有代表性的):

环境变量 Spring 属性 转换
OTEL_SDK_DISABLED management.opentelemetry.enabled 取反(true→false)
OTEL_TRACES_SAMPLER management.opentelemetry.tracing.sampler always_on→ALWAYS_ON 等 6 种
OTEL_TRACES_SAMPLER_ARG management.tracing.sampling.probability 仅 ratio 型有效,校验 0.0--1.0
OTEL_PROPAGATORS management.tracing.propagation.type tracecontext→W3C、b3→B3、b3multi→B3_MULTI
OTEL_PROPAGATORS management.tracing.baggage.enabled 是否含 baggage
OTEL_EXPORTER_OTLP_ENDPOINT management.otlp.metrics.export.url 等 自动拼路径 v1/metrics / v1/traces / v1/logs
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL ...transport grpc→GRPC、http/protobuf→HTTP
OTEL_METRICS_EXEMPLAR_FILTER management.tracing.exemplars.include always_on→ALL、always_off→NONE、trace_based→SAMPLED_TRACES
OTEL_EXPORTER_OTLP_*_CERTIFICATE 等 spring.ssl.bundle.pem.<name>.* 自动建 SSL bundle

几个精妙之处:

a. 端点自动拼路径:

java 复制代码
this.environmentVariables
    .getString("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "OTEL_EXPORTER_OTLP_ENDPOINT",
            (value) -> combineUrl(value, "v1/metrics"))
    .addToMap(map, "management.otlp.metrics.export.url");

private String combineUrl(String base, String path) {
    Assert.state(!path.startsWith("/"), "'path' must not start with '/'");
    if (base.endsWith("/")) return base + path;
    return base + "/" + path;
}

第一个参数是"专用变量",第二个是"通用兜底变量",第三个是值转换器。 OTel 规范规定 OTEL_EXPORTER_OTLP_ENDPOINT 是通用基地址, 实际发送时要拼 /v1/metrics 等路径------这里自动做了。 Assert.state(!path.startsWith("/")) 保证不会拼出 //。

b. 语义取反:

java 复制代码
private @Nullable String mapDisabledFlag(String name, String value) {
    // OTEL_SDK_DISABLED: true means disabled
    // management.opentelemetry.enabled: true means enabled
    return switch (value) {
        case "true" -> "false";
        case "false" -> "true";
        default -> { this.logger.warn("Invalid value for environment variable '%s': '%s'".formatted(name, value)); yield null; }
    };
}

注释把语义反转写得很清楚。非法值不抛异常、只 WARN 并返回 null(即不加这个属性)。 "让应用继续启动"优先于"严格校验"------这是 EnvironmentPostProcessor 的普遍取向, 因为此时还没有 FailureAnalyzer 机制可用(第 16 章讲过 EPP 阶段太早)。

c. none 的特殊处理(三处都是同一模式):

java 复制代码
private String mapBaggageEnabled(String name, String value) {
    Set<String> propagators = toSet(value);
    if (propagators.contains("none")) {
        if (propagators.size() > 1) {
            this.logger.warn("Environment variable '%s' contains 'none', but also contains more elements: '%s'"
                .formatted(name, value));
        }
        return "false";
    }
    return Boolean.toString(propagators.contains("baggage"));
}

none 与其它值同时出现时,仍然按 none 处理(关掉),但打一条 WARN。 "接受配置但告知可疑"------比直接报错友好。

d. SSL bundle 自动生成:

java 复制代码
private void addSslBundle(Map<String, OriginTrackedValue> map, String bundleKey, String bundleName,
        EnvVariable certificateFile, EnvVariable clientKeyFile, EnvVariable clientCertificateFile) {
    EnvVariable firstPresent = this.environmentVariables.getFirstPresent(certificateFile, clientKeyFile, clientCertificateFile);
    if (firstPresent == null) return;
    map.put(bundleKey, OriginTrackedValue.of(bundleName, this.environmentVariables.getOrigin(firstPresent)));
    if (certificateFile.isPresent()) {
        map.put("spring.ssl.bundle.pem.%s.truststore.certificate".formatted(bundleName),
                OriginTrackedValue.of("file:%s".formatted(certificateFile.value()), ...));
    }
    ...
}

三个文件变量中任意一个 存在,就自动创建一个名为 opentelemetry-metrics / opentelemetry-tracing / opentelemetry-logging 的 PEM 型 SSL bundle,并把三个文件分别填进 truststore / keystore。 即:OTel 的 OTEL_EXPORTER_OTLP_TRACES_CERTIFICATE=/path/ca.pem 自动变成 spring.ssl.bundle.pem.opentelemetry-tracing.truststore.certificate=file:/path/ca.pem。

这是两个配置体系的深度打通,省掉了用户手写 SSL bundle 的麻烦。 (第 30 章展开 SSL Bundle 机制本身。)

OpenTelemetryEnvironmentVariables 的类型化读取:

java 复制代码
EnvVariable getInt(String name) { ... 引用了 OTel 规范注释 URL ... }
EnvVariable getBoolean(String name) { ... }
EnvVariable getDuration(String name) { ... }
EnvVariable getTimeout(String name) { ... }

每个方法上方都有 // https://opentelemetry.io/docs/specs/otel/configuration/... 注释, 标明该方法遵循 OTel 规范的哪一条。这是很好的工程实践------ 把规范链接写进代码,后来者不用猜"为什么要这么解析"。

DeferredLogFactory:

java 复制代码
public OpenTelemetryEnvironmentVariableEnvironmentPostProcessor(DeferredLogFactory logFactory) {
    this(logFactory, OpenTelemetryEnvironmentVariables.forSystemEnv(logFactory));
}

DeferredLogFactory------第 4/24 章讲过: EnvironmentPostProcessor 阶段日志系统还没初始化, 用它拿到一个"缓存日志、等日志系统就绪后回放"的 Log。

7.4 OTel 日志导出

java 复制代码
@AutoConfiguration(after = OpenTelemetrySdkAutoConfiguration.class)
@ConditionalOnClass(SdkLoggerProvider.class)
@ConditionalOnEnabledOpenTelemetry
@EnableConfigurationProperties(OpenTelemetryLoggingProperties.class)
public final class OpenTelemetryLoggingAutoConfiguration {
    @Bean @ConditionalOnMissingBean
    BatchLogRecordProcessor openTelemetryBatchLogRecordProcessor(ObjectProvider<LogRecordExporter> logRecordExporters) {
        LogRecordExporter exporter = LogRecordExporter.composite(logRecordExporters.orderedStream().toList());
        Export export = this.properties.getExport();
        return BatchLogRecordProcessor.builder(exporter)
            .setExporterTimeout(export.getTimeout())
            .setMaxQueueSize(export.getMaxQueueSize())
            .setScheduleDelay(export.getScheduleDelay())
            .setMaxExportBatchSize(export.getMaxBatchSize())
            .build();
    }
    ...
}

默认值:timeout = 30s、maxBatchSize = 512、maxQueueSize = 2048、scheduleDelay = 1s。

LogRecordExporter.composite(...):多个 exporter 合成一个。 所以你可以同时有 OTLP gRPC 和 OTLP HTTP 两个 exporter。

注意这里与第 24 章的关系:Spring Boot 的日志走 Logback → SLF4J, 而 OTel 的日志导出是另一条独立通道 (SdkLoggerProvider), 需要显式 appender 才会把日志灌进去。 OtlpLoggingAutoConfiguration 提供 OTLP 导出能力, 但把 Spring Boot 日志接进 OTel 还需要额外的桥接配置(列 §10 未核实)。

7.5 连接详情:Docker Compose 与 Testcontainers

前面几节反复出现的 OtlpMetricsConnectionDetails / OtlpLoggingConnectionDetails, 在这里把来源讲完。这是第 20 章讲的 ConnectionDetails 模式在可观测性模块的应用。

java 复制代码
class OpenTelemetryMetricsDockerComposeConnectionDetailsFactory
		extends DockerComposeConnectionDetailsFactory<OtlpMetricsConnectionDetails> {

	private static final String[] OPENTELEMETRY_IMAGE_NAMES = { "otel/opentelemetry-collector-contrib",
			"grafana/otel-lgtm" };

	private static final int OTLP_PORT = 4318;

	OpenTelemetryMetricsDockerComposeConnectionDetailsFactory() {
		super(OPENTELEMETRY_IMAGE_NAMES,
				"org.springframework.boot.micrometer.metrics.autoconfigure.export.otlp.OtlpMetricsExportAutoConfiguration");
	}
	...
}

两点值得注意。其一,第二个构造参数是类名字符串,不是 Class 对象。 DockerComposeConnectionDetailsFactory 的语义是"只在这个自动配置类真的存在时才生效", 判断方式是运行时按名字查。若用 Class 字面量,工厂类就会强依赖自动配置类, 用户排除该模块时触发 NoClassDefFoundError。 字符串是可选的、Class 是强制的------这是条件注解体系里反复出现的取舍。

其二,镜像名是数组、端口是常量。 grafana/otel-lgtm 是 Grafana 的 Loki/Grafana/Tempo/Mimir 一体化镜像,内部同样开 4318, 所以对它俩来说"是同一个东西"。4318 是 OTLP/HTTP 标准端口(4317 是 OTLP/gRPC)。

三个来源的差异只有"端口怎么拿、URL 怎么拼":

来源 工厂 端口获取 URL
Docker Compose OpenTelemetryMetricsDockerComposeConnectionDetailsFactory source.ports().get(4318) "%s://%s:%d/v1/metrics"
Testcontainers(collector) OpenTelemetryMetricsContainerConnectionDetailsFactory getContainer().getMappedPort(4318) 同上
Testcontainers(LGTM) GrafanaOpenTelemetryMetricsContainerConnectionDetailsFactory 不用端口 getOtlpHttpUrl() + "/v1/metrics"

ports().get(4318) 与 getMappedPort(4318) 的差别正对应两种运行方式: Docker Compose 的 ports() 已经是映射好的宿主端口 (map,直接查); Testcontainers 的 4318 是容器声明端口 ,宿主端口随机,必须 getMappedPort 转换。

LGTM 那侧更特殊,它不自己算端口,而是用 Testcontainers 的 LgtmStackContainer.getOtlpHttpUrl()(返回含随机端口的完整 URL),只需要在后面拼路径; getSslBundle() != null 时用 url.replaceFirst("^http://", "https://") 换协议 (^ 锚定保证只换开头 scheme,不会误伤路径里的 http://)。 另外它的构造参数是 super(ANY_CONNECTION_NAME, "...")------不按镜像名匹配 , 因为类型参数已经是具体的 LgtmStackContainer,类型本身就是最强的匹配条件。

日志侧多了一个维度:OtlpLoggingConnectionDetails.getUrl(Transport) 是带参数的, 因为日志导出同时支持 HTTP 和 gRPC,而指标导出只走 HTTP:

java 复制代码
@Override
public String getUrl(Transport transport) {
	int port = switch (transport) {
		case HTTP -> this.httpPort;
		case GRPC -> this.grpcPort;
	};
	String scheme = (this.sslBundle != null) ? "https" : "http";
	return "%s://%s:%d/v1/logs".formatted(scheme, this.host, port);
}

所以日志侧的 details 同时持有 grpcPort(4317) 与 httpPort(4318)。

最后,OtlpLoggingAutoConfiguration 是个空类体:

java 复制代码
@AutoConfiguration(after = OpenTelemetrySdkAutoConfiguration.class)
@ConditionalOnClass(SdkLoggerProvider.class)
@ConditionalOnEnabledOpenTelemetry
@Import({ ConnectionDetails.class, Exporters.class })
public final class OtlpLoggingAutoConfiguration {
}

所有内容都在 @Import 的两个内部配置类里。这种"外壳 + 分组内部类"写法的好处是 条件注解可以分层 :外壳管"要不要 OTLP 日志导出", ConnectionDetails 管"连到哪",Exporters 管"用哪个协议导出",每层条件互不干扰。


8. 决策速查表

8.1 "我的 @Observed / @Timed / @NewSpan 没效果"

三个条件同时满足才会生效:

  1. management.observations.annotations.enabled=true(默认 false)
  2. classpath 有 org.aspectj:aspectjweaver(@ConditionalOnClass(Advice.class))
  3. 对应基础设施存在:ObservedAspect 要 ObservationRegistry; CountedAspect/TimedAspect 要 MeterRegistry;SpanAspect 要 Tracer

另外:@Observed 标注的方法必须是 Spring bean 的方法且可被 AOP 代理 (非 private、非 final、非同类内部调用)。

8.2 "我要 100% 采样"

properties 复制代码
management.tracing.sampling.probability=1.0

默认是 0.10。等价的环境变量写法: OTEL_TRACES_SAMPLER=traceidratio + OTEL_TRACES_SAMPLER_ARG=1.0 (注意 OTEL_TRACES_SAMPLER_ARG 只在 sampler 是 ratio 型时才被读取, 否则打 WARN 并忽略)。

8.3 "我要 Prometheus 抓取端点"

properties 复制代码
management.endpoints.web.exposure.include=health,prometheus

/actuator/prometheus 由 PrometheusScrapeEndpointConfiguration 提供, 受 @ConditionalOnAvailableEndpoint(PrometheusScrapeEndpoint.class) 控制------ 必须显式暴露,因为默认只暴露 health。

8.4 "日志里没有 traceId"

按顺序排查:

  1. classpath 有 io.micrometer.tracing.Tracer 吗? (LogCorrelationEnvironmentPostProcessor 靠这个判断)
  2. management.tracing.export.enabled 是否被设成 false? (它会映射成 logging.expect-correlation-id)
  3. 是否显式写了 logging.expect-correlation-id=false? (addLast 意味着显式配置会覆盖推断)
  4. 日志 pattern 里有 %correlationId 吗? (默认的 console-appender.xml 里没有,需要自己加或用结构化日志)
  5. 采样率是否把这条请求丢了?(默认 10%)
  6. 用的是 Tracer.NOOP 吗?(没有 bridge 依赖)

8.5 "我要关掉某个指标族"

properties 复制代码
management.metrics.enable.http.server.requests=false    # 精确
management.metrics.enable.http=false                    # 前缀(逐段砍尾部最长匹配)
management.metrics.enable.all=false                     # 全局

注意 PropertiesMeterFilter 是 MeterFilter, 它与 management.observations.enable.*(PropertiesObservationFilterPredicate) 是两个不同的开关:

属性 作用层 效果
management.observations.enable.* Observation 不创建 observation(指标和 span 都没有)
management.metrics.enable.* MeterFilter observation 照常创建,只是不注册 meter(span 还在)

想彻底省开销用前者,想只关指标保 span 用后者。

8.6 "我要给所有指标打通用标签"

properties 复制代码
management.metrics.tags.application=${spring.application.name}
management.metrics.tags.region=cn-north

走 PropertiesMeterFilter.createMapFilter → MeterFilter.commonTags。 空配置时返回空实现的 filter(零开销)。

8.7 "我要配置直方图"

properties 复制代码
management.metrics.distribution.percentiles-histogram.http.server.requests=true
management.metrics.distribution.slo.http.server.requests=100ms,300ms,1s
management.metrics.distribution.minimum-expected-value.http.server.requests=10ms

slo / minimum-expected-value / maximum-expected-value 不支持 all 键 (用 lookup 而非 lookupWithFallbackToAll); percentiles-histogram / percentiles / expiry / bufferLength 支持 all。

对 Timer,纯数字被解释为毫秒;对 DistributionSummary,原样使用。

8.8 "我要用纯 OTel 环境变量配置"

什么都不用写,只设环境变量即可:

bash 复制代码
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=0.5
OTEL_SERVICE_NAME=my-app

这些会通过 OpenTelemetryEnvironmentVariableEnvironmentPostProcessor 以 addFirst 注入,优先级高于 application.properties。

想禁用映射:management.opentelemetry.map-environment-variables=false。

8.9 "我的指标突然不再增长"

高度怀疑 MaximumAllowableTagsMeterFilter: management.metrics.web.server.max-uri-tags(默认 100)超限后 直接 DENY 新的 meter id,且只 WARN 一次 。 在启动日志里搜 "Reached the maximum number of 'uri' tags for" ------ 搜不到不代表没发生(可能日志级别或时机问题), 直接看 http.server.requests 的 availableTags 里 uri 的数量是否卡在 100。


9. 从本章提炼的定律

延续前 25 章编号。

第五十一定律:一个埋点,多个出口

ObservationRegistry 的存在意义就是解耦"埋点"与"消费" 。 @Observed 写一次,metrics / tracing / 日志都能拿到数据。 推论:不要为一个新指标另起一套埋点,先看能不能复用已有 observation; 也不要以为"关掉 metrics"就等于"不观测"------它们是两条独立的出口管线。

第五十二定律:给可选功能一个 NOOP 兜底 bean,下游就能无条件依赖

NoopTracerAutoConfiguration 注册 Tracer.NOOP, 于是 MicrometerTracingAutoConfiguration 可以直接写 @ConditionalOnBean(Tracer.class) 而不用担心"没有 bridge 依赖"。 同理 §3.1 的 NoOpMeterRegistryConfiguration 提供空 CompositeMeterRegistry。 "保证 bean 一定存在"比"到处判空"干净得多。

第五十三定律:需要区分"谁建的"时,用一个空子类打类型标记

AutoConfiguredCompositeMeterRegistry extends CompositeMeterRegistry { }------ 类体为空,只为了 instanceof 能区分"Boot 建的"与"用户建的"。 进而决定要不要加 filter、要不要收成员、要不要绑 binder。 这是类型即元数据的手法,比在对象上挂 boolean 字段更类型安全。

第五十四定律:@ConditionalOnXxx 起语义化名字,让条件自我解释

@ConditionalOnEnabledOpenTelemetry(无属性,就是 @ConditionalOnBooleanProperty("management.opentelemetry.enabled", matchIfMissing = true) 的别名) 让调用点读起来是"如果 OTel 启用"。 推论:当一个布尔属性在 5 个以上地方被判定时,就该给它起个注解名字。

第五十五定律:跨模块排序用 beforeName / afterName

PrometheusExemplarsAutoConfiguration 用 beforeName = "...PrometheusMetricsExportAutoConfiguration"(字符串)而非 before = Xxx.class, 因为两个模块之间不应有编译期依赖 。 第 23 章 HibernateMetricsAutoConfiguration 的 afterName 同理。 字符串类名 = 模块解耦的代价与手段。

第五十六定律:Binder 的绑定必须延迟到单例就绪

MeterRegistryPostProcessor 的 deferBinding + SmartInitializingSingleton 把 MeterBinder.bindTo() 攒到所有单例就绪后再批量执行。 原因:binder 可能依赖其他 bean,过早调用会拿到未经过 BPP 处理的半成品。 第 23 章 EntityManagerFactoryBuilder 把 AsyncTaskExecutor 改成 Supplier 是同一问题。

第五十七定律:全局静态状态必须由容器生命周期负责清理

management.metrics.use-global-registry 默认 true, 会把 registry 加进 Micrometer 的静态 Metrics.globalRegistry。 代价是必须有 MeterRegistryCloser 在 ContextClosedEvent 时 先 Metrics.removeRegistry 再 close(), 且要 if (!this.context.equals(event.getApplicationContext())) return; 过滤掉子容器事件。 凡是有静态注册表/线程池/缓存的地方,都要配对一个"谁负责清理"的答案。

第五十八定律:基数限制要"只告警一次 + 直接拒绝"

MaximumAllowableTagsMeterFilter 用 AtomicBoolean.compareAndSet 保证只 WARN 一次, 超限后 DENY。 理由是:告警的目的是让人知道,重复一万次不会让人更知道,只会打满磁盘。 推论:任何"限流/熔断/降载"的日志都必须有去重机制。

第五十九定律:标准环境变量的映射要"带来源 + 可整体关闭 + 优先级高于配置文件"

OpenTelemetryEnvironmentVariableEnvironmentPostProcessor: 用 OriginTrackedMapPropertySource(带来源)、 management.opentelemetry.map-environment-variables 总开关、 addFirst(优先级最高)。 三者缺一不可------没有来源就没法排查,没有开关就没法逃生, 优先级不够就"配了不生效"。

第六十定律:把规范链接写进代码

OpenTelemetryEnvironmentVariables 的每个解析方法上都有一条 // https://opentelemetry.io/docs/specs/otel/configuration/...。 这是把"为什么这么写"固化在代码里的最低成本方式, 比写在设计文档里更容易被后来者看到。


10. 本章未逐行核实 / 待你实测的项

  1. ★ 子 Environment 的属性来源 (承接第 25 章 §5.4)。 本章未涉及,但管理端口子容器同样要读 management.metrics.*, 第 25 章的那个疑点在这里会再次出现。

  2. ★ NoOpMeterRegistryConfiguration 与 CompositeMeterRegistryConfiguration 的互斥是否可靠 (§3.1)。两者都是 ConfigurationPhase.REGISTER_BEAN 的条件, 相对判定顺序我未确认。自测:不放任何 registry 依赖 → 看是否有 CompositeMeterRegistry bean 且 getRegistries() 为空; 放一个 → 看是否直接用了它;放两个 → 看是否是 AutoConfiguredCompositeMeterRegistry。

  3. LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY 的字面值 (§5.5)。 按命名推断是 "logging.expect-correlation-id",未读常量定义确认。

  4. service.name 为什么是 unknown_service 而 OTel 默认通常是 unknown_service:java (§7.2)。 这是有意偏离还是遗漏?自测:不配任何属性,看导出的 resource 里 service.name 的值。

  5. PrometheusPushgateway 的 getJob(properties, environment) (§4.3)。 我未读到实现体,推断它优先用 spring.application.name,兜底 FALLBACK_JOB = "spring"。

  6. JvmMetricsAutoConfiguration.VIRTUAL_THREAD_METRICS_CLASS 的使用位置 (§4.4)。 常量定义了,但使用它的 @ConditionalOnClass(name = ...) 我未定位到。

  7. ImperativeMethodInvocationProcessor 是否有响应式对应物(§5.6)。 名字里的 "Imperative" 暗示应该有,我未在本仓库找到。

  8. OnMetricsExportEnabledCondition 的两级判定对非法值的处理不一致 (§4.2): getProductOutcome 用 getProperty(name, Boolean.class, true)(转换失败抛异常), getDefaultOutcome 用 getProperty(name, "true") + Boolean.parseBoolean(失败返回 false)。 自测:management.defaults.metrics.export.enabled=maybe 看是静默关闭还是报错。

  9. management.metrics.distribution.slo.all 是否真的无效 (§3.5 / §8.7)。 由 lookup(不兜底)推得,未实测。

  10. 把 Spring Boot 的 Logback 日志接进 OTel SdkLoggerProvider 的完整路径 (§7.4)。 我只读到 OtlpLoggingAutoConfiguration 提供导出能力, 未找到"Logback appender → OTel"的桥接自动配置 (可能在 spring-boot-opentelemetry 之外或需要手动配置)。

  11. SimpleAutowireCandidateResolver.resolveAutowireCandidates 是否会实例化 bean (§4.4)。 我在第 23 章说它"不触发实例化",但本处随后就调 threadPoolTaskExecutor.getThreadPoolExecutor()(必然已实例化)。 两处语义是否一致未核实。

  12. TracingAndMeterObservationHandlerGroup 下 handler 的最终执行顺序 (§2.4)。 我推断 tracing 先于 metrics(registerHandlers 的调用顺序), 但 FirstMatchingCompositeObservationHandler 的匹配语义 ("第一个 supports 的为 true 的 handler")我未读 Micrometer 源码确认。

  13. ObservationHandlerGroup.compareTo 默认返回 0 的后果 (§2.4)。 多个未覆写 compareTo 的组之间顺序不确定(依赖 Collections.sort 的稳定性), 理论上 LinkedMultiValueMap 的插入序会被保留(因为 sort 是稳定的), 但这依赖 Collections.sort 的稳定性保证。未实测。

  14. ApplicationStartedEvent.getTimeTaken() / ApplicationReadyEvent.getTimeTaken() 何时为 null (§4.8)。签名是 @Nullable,我从签名反推"某些启动路径拿不到耗时", 但未追出具体是哪条路径。自测:在 SpringApplication.run() 的一个 ApplicationStartedEvent 监听器里打印 getTimeTaken(); 再试 SpringApplication 被 setRegisterShutdownHook(false) / 启动失败回滚等场景。

  15. "默认配置下打开 span-joining-supported=true 就启动失败"是否成立 (§5.7)。 这是我把 §5.3 的默认值(produce 默认含 W3C)代入 §5.7 的三处判断推出来的结论, 逻辑链完整但我未跑起来验证。自测:只设 management.brave.tracing.span-joining-supported=true,看启动是否抛 IncompatibleConfigurationException;再补 management.tracing.propagation.type=b3 看是否恢复正常。

  16. OTel bridge 的 local-fields WARN 是否会在自动配置类被丢弃时仍然打印 (§5.8)。 我说"日志在构造器里,条件不满足时也可能已打出"------这是基于 "自动配置类先实例化再判条件"的推断。Spring 的 ConfigurationClassParser 对 @Import/自动配置的实例化时机比较复杂,可能先判条件再实例化。 自测:配置 management.tracing.baggage.local-fields=foo 但同时 management.opentelemetry.enabled=false,看 WARN 是否出现。


11. 小结与下一章

本章读完了 Spring Boot 4.1.1 的可观测性主干。四条最值得记住的结构性事实:

  1. 指标与追踪是同一个 Observation 的两条出口 。 ObservationHandlerGroup + FirstMatchingCompositeObservationHandler 解决了"两类 handler 抢同一个链"的问题, 而 TracingAndMeterObservationHandlerGroup.compareTo 保证复合组先认领。

  2. MeterRegistry 是三态的 :0 个 → 空 CompositeMeterRegistry; 1 个 → 直接用;2+ 个 → AutoConfiguredCompositeMeterRegistry(@Primary)。 判定靠 NoneNestedConditions + ConfigurationPhase.REGISTER_BEAN。 那个空子类的唯一价值是 instanceof 打标记。

  3. deferBinding 是启动期正确性的关键 : 所有 MeterBinder 绑定延后到 afterSingletonsInstantiated。 这是第 23 章"容器查询必须延迟"定律在指标领域的再现。

  4. OTel 与 Spring 配置体系是深度打通的 : 一张巨大的 OTEL_* → management.* 映射表, 以 addFirst 注入(优先级高于配置文件)、带 Origin 追踪、可整体关闭, 还能自动把证书文件变成 SSL Bundle。

加上第 24 章的日志,可观测性四根支柱里,日志与健康已讲完, 指标与追踪在本章补齐; 唯独"日志 ↔ traceId 联动"这条缝,是靠一个不起眼的 LogCorrelationEnvironmentPostProcessor(addLast 的一个单属性属性源)缝合的。

下一章(第 27 章)进入交付环节 :可执行归档与 JarLauncher。 那是一个完全不同的世界------没有 Spring 容器、没有自动配置, 只有 java -jar 与一个自定义的 URLClassLoader 在嵌套 jar 里找类。

相关推荐
imDwAaY1 小时前
Java String 核心原理:一篇讲透字符串常见面试题
后端
imDwAaY1 小时前
API 和 SPI 有什么区别?一篇讲透两者的设计思想
后端
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 章节复盘:微服务骨架与手机号验证码登录
java·微服务·交友
data analyse 4561 小时前
Session数怎么计算?明确会话切分、超时与跨页规则
java·服务器·数据库
for_ever_love__1 小时前
Agentic RAG 实战:让 Agent 自己决定怎么检索、检索几次
人工智能·后端·agent·控制·自适应·rag·检索策略
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 19 章 · Spring MVC 自动配置:DispatcherServlet、消息转换
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 28 章 AOT 与 GraalVM Native与序列化模型
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 27 章 · 可执行归档与 Loader:`JarLauncher` 与嵌套 jar
java·后端·开源
鶴哥只手遮天1 小时前
Spring Boot 4.1.1 深度源码解析-第 25 章 · Actuator 与健康:端点机制、`HealthIndicator`、管理端口子上下文
java·后端·开源