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 { ... }
}
五个要点:
observationRegistryPostProcessor是staticbean 方法------必须尽早实例化(它是 BPP)。ObservationRegistry用@ConditionalOnMissingBean,你可以整个替换。PropertiesObservationFilterPredicate标了@Order(0): 它同时是ObservationFilter和ObservationPredicate, order 0 意味着它在 predicate 链里最先执行(早于用户的 predicate)。SpelValueExpressionResolver支持在@Observed的 key-value 里写 SpEL。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);
}
三个细节:
compareTo保证本组排在纯 metrics 组之前 (返回-1), 于是本组先认领所有 metrics + tracing handler,纯 metrics 组就拿不到人了。- 包装 :非
TracingAwareMeterObservationHandler的 metrics handler 会被TracingAwareMeterObservationHandler包一层,让它能感知当前 span(用于 exemplar)。 - 注册顺序是 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);
}
}
三个安全约束:
SimpleEvaluationContext而不是StandardEvaluationContext------ 前者禁止调用任意方法、禁止访问类型,是 SpEL 的"沙箱模式"。 因为表达式可能来自配置文件(虽然通常是注解里的字符串),必须防注入。forReadOnlyDataBinding():只读。- 解析结果必须非 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();
}
}
}
}
三处讲究:
if (!this.context.equals(event.getApplicationContext())) return;------ContextClosedEvent会沿容器层级向上传播, 子容器关闭时父容器也会收到(如果父容器注册了监听器)。 这里显式判断"是不是我的容器",避免管理端口子容器关闭时误关父容器的 registry。CopyOnWriteArraySet:注册时写多、遍历时读多?其实注册一次、遍历一次, 但 BPP 可能在多线程下调用track。- 先从 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);
}
注意两点:
percentiles用lookupWithFallbackToAll(有all兜底), 而slo/minimumExpectedValue/maximumExpectedValue用lookup(不兜底) 。 这个不对称很关键:management.metrics.distribution.slo.all=...无效 , 必须写具体 meter 名。我判断原因是 SLO 与 min/max 是数量纲的 (Timer 是纳秒,DistributionSummary 是任意单位), 给"所有 meter"设同一个 SLO 在语义上没意义。 而 percentiles(0--1 之间的比例)是无量纲的,可以做全局默认。.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;
}
三个设计点:
observedTagValues是ConcurrentHashMap.newKeySet(), 运行期持续增长------但只存 tag 值字符串,且数量被maximumTagValues封顶。alreadyWarned是AtomicBoolean+compareAndSet: 只 WARN 一次。因为这类超限在高峰期会每秒触发上万次, 打满日志比指标基数爆炸还可怕。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, ...);
}
}
两级:
management.<product>.metrics.export.enabled(如management.prometheus.metrics.export.enabled)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);
}
SimpleAutowireCandidateResolver:第 23 章HibernateMetricsAutoConfiguration用过同一个类。 它在不触发实例化的前提下解析"哪些 bean 会被当作TaskExecutor注入"------ 严格说它在这里会实例化(要拿到ThreadPoolExecutor), 但避免了getBeansOfType带来的副作用(比如创建 FactoryBean 的产品对象)。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 没生效。可能是:
Tracer是 NOOP(没有任何 bridge 依赖)Tracer是真的但采样率把它丢了(默认 10%)Tracer是真的、也采样了,但 exporter 没配- 日志 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
三个细节:
addLast:放在属性源链末尾。所以用户显式写logging.expect-correlation-id=false会覆盖它(前面的属性源优先)。 这是正确的优先级:显式配置 > 推断。ClassUtils.isPresent(..., application.getClassLoader()): 用的是SpringApplication的 classloader,而不是当前类的------ 因为EnvironmentPostProcessor本身已经被加载了,但Tracer未必在。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()));
};
}
- 没有
default分支 。Java switch 表达式对枚举要求"穷尽或default",这里穷尽 6 个值。 好处是将来 OTel 加第 7 种 sampler 时这里会编译失败------用编译错误代替运行时遗漏。 TRACE_ID_RATIO与PARENT_BASED_TRACE_ID_RATIO复用tracingProperties.getSampling().getProbability()(§5.3 的默认0.10f)。 采样概率是通用属性(在spring-boot-micrometer-tracing),sampler 类型是 OTel 专属属性------ 两个 bridge 共用前者、各自定义后者,这是一条清晰的属性分层。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 的四级优先级(从高到低):
OTEL_SERVICE_NAME环境变量OTEL_RESOURCE_ATTRIBUTES里的service.name=management.opentelemetry.resource-attributes.service.namespring.application.name- 兜底
"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));
}
四个要点:
- 总开关 :
management.opentelemetry.map-environment-variables(默认 true)。 设 false 就完全不映射(比如你就是想手写 Spring 属性)。 addFirst:放在属性源链最前面 ------即OTEL_*环境变量的优先级 高于application.properties! 这是一个反直觉但正确的选择:OTel 规范规定环境变量优先级最高, Kubernetes 运维习惯也是通过环境变量注入。 注意这与 §5.5 的LogCorrelationPropertySource(用addLast)相反。OriginTrackedMapPropertySource+OriginTrackedValue: 带来源追踪(第 7 章 ConfigData 的Origin机制), 所以 Actuator 的env端点会告诉你这个属性来自哪个环境变量。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 没效果"
三个条件同时满足才会生效:
management.observations.annotations.enabled=true(默认 false)- classpath 有
org.aspectj:aspectjweaver(@ConditionalOnClass(Advice.class)) - 对应基础设施存在:
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"
按顺序排查:
- classpath 有
io.micrometer.tracing.Tracer吗? (LogCorrelationEnvironmentPostProcessor靠这个判断) management.tracing.export.enabled是否被设成 false? (它会映射成logging.expect-correlation-id)- 是否显式写了
logging.expect-correlation-id=false? (addLast意味着显式配置会覆盖推断) - 日志 pattern 里有
%correlationId吗? (默认的console-appender.xml里没有,需要自己加或用结构化日志) - 采样率是否把这条请求丢了?(默认 10%)
- 用的是
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. 本章未逐行核实 / 待你实测的项
-
★ 子 Environment 的属性来源 (承接第 25 章 §5.4)。 本章未涉及,但管理端口子容器同样要读
management.metrics.*, 第 25 章的那个疑点在这里会再次出现。 -
★
NoOpMeterRegistryConfiguration与CompositeMeterRegistryConfiguration的互斥是否可靠 (§3.1)。两者都是ConfigurationPhase.REGISTER_BEAN的条件, 相对判定顺序我未确认。自测:不放任何 registry 依赖 → 看是否有CompositeMeterRegistrybean 且getRegistries()为空; 放一个 → 看是否直接用了它;放两个 → 看是否是AutoConfiguredCompositeMeterRegistry。 -
LoggingSystem.EXPECT_CORRELATION_ID_PROPERTY的字面值 (§5.5)。 按命名推断是"logging.expect-correlation-id",未读常量定义确认。 -
service.name为什么是unknown_service而 OTel 默认通常是unknown_service:java(§7.2)。 这是有意偏离还是遗漏?自测:不配任何属性,看导出的 resource 里service.name的值。 -
PrometheusPushgateway的getJob(properties, environment)(§4.3)。 我未读到实现体,推断它优先用spring.application.name,兜底FALLBACK_JOB = "spring"。 -
JvmMetricsAutoConfiguration.VIRTUAL_THREAD_METRICS_CLASS的使用位置 (§4.4)。 常量定义了,但使用它的@ConditionalOnClass(name = ...)我未定位到。 -
ImperativeMethodInvocationProcessor是否有响应式对应物(§5.6)。 名字里的 "Imperative" 暗示应该有,我未在本仓库找到。 -
OnMetricsExportEnabledCondition的两级判定对非法值的处理不一致 (§4.2):getProductOutcome用getProperty(name, Boolean.class, true)(转换失败抛异常),getDefaultOutcome用getProperty(name, "true")+Boolean.parseBoolean(失败返回 false)。 自测:management.defaults.metrics.export.enabled=maybe看是静默关闭还是报错。 -
management.metrics.distribution.slo.all是否真的无效 (§3.5 / §8.7)。 由lookup(不兜底)推得,未实测。 -
把 Spring Boot 的 Logback 日志接进 OTel
SdkLoggerProvider的完整路径 (§7.4)。 我只读到OtlpLoggingAutoConfiguration提供导出能力, 未找到"Logback appender → OTel"的桥接自动配置 (可能在spring-boot-opentelemetry之外或需要手动配置)。 -
SimpleAutowireCandidateResolver.resolveAutowireCandidates是否会实例化 bean (§4.4)。 我在第 23 章说它"不触发实例化",但本处随后就调threadPoolTaskExecutor.getThreadPoolExecutor()(必然已实例化)。 两处语义是否一致未核实。 -
TracingAndMeterObservationHandlerGroup下 handler 的最终执行顺序 (§2.4)。 我推断 tracing 先于 metrics(registerHandlers的调用顺序), 但FirstMatchingCompositeObservationHandler的匹配语义 ("第一个 supports 的为 true 的 handler")我未读 Micrometer 源码确认。 -
ObservationHandlerGroup.compareTo默认返回 0 的后果 (§2.4)。 多个未覆写compareTo的组之间顺序不确定(依赖Collections.sort的稳定性), 理论上LinkedMultiValueMap的插入序会被保留(因为 sort 是稳定的), 但这依赖Collections.sort的稳定性保证。未实测。 -
ApplicationStartedEvent.getTimeTaken()/ApplicationReadyEvent.getTimeTaken()何时为null(§4.8)。签名是@Nullable,我从签名反推"某些启动路径拿不到耗时", 但未追出具体是哪条路径。自测:在SpringApplication.run()的一个ApplicationStartedEvent监听器里打印getTimeTaken(); 再试SpringApplication被setRegisterShutdownHook(false)/ 启动失败回滚等场景。 -
"默认配置下打开
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看是否恢复正常。 -
OTel bridge 的
local-fieldsWARN 是否会在自动配置类被丢弃时仍然打印 (§5.8)。 我说"日志在构造器里,条件不满足时也可能已打出"------这是基于 "自动配置类先实例化再判条件"的推断。Spring 的ConfigurationClassParser对@Import/自动配置的实例化时机比较复杂,可能先判条件再实例化。 自测:配置management.tracing.baggage.local-fields=foo但同时management.opentelemetry.enabled=false,看 WARN 是否出现。
11. 小结与下一章
本章读完了 Spring Boot 4.1.1 的可观测性主干。四条最值得记住的结构性事实:
-
指标与追踪是同一个 Observation 的两条出口 。
ObservationHandlerGroup+FirstMatchingCompositeObservationHandler解决了"两类 handler 抢同一个链"的问题, 而TracingAndMeterObservationHandlerGroup.compareTo保证复合组先认领。 -
MeterRegistry是三态的 :0 个 → 空CompositeMeterRegistry; 1 个 → 直接用;2+ 个 →AutoConfiguredCompositeMeterRegistry(@Primary)。 判定靠NoneNestedConditions+ConfigurationPhase.REGISTER_BEAN。 那个空子类的唯一价值是instanceof打标记。 -
deferBinding是启动期正确性的关键 : 所有MeterBinder绑定延后到afterSingletonsInstantiated。 这是第 23 章"容器查询必须延迟"定律在指标领域的再现。 -
OTel 与 Spring 配置体系是深度打通的 : 一张巨大的
OTEL_*→management.*映射表, 以addFirst注入(优先级高于配置文件)、带 Origin 追踪、可整体关闭, 还能自动把证书文件变成 SSL Bundle。
加上第 24 章的日志,可观测性四根支柱里,日志与健康已讲完, 指标与追踪在本章补齐; 唯独"日志 ↔ traceId 联动"这条缝,是靠一个不起眼的 LogCorrelationEnvironmentPostProcessor(addLast 的一个单属性属性源)缝合的。
下一章(第 27 章)进入交付环节 :可执行归档与 JarLauncher。 那是一个完全不同的世界------没有 Spring 容器、没有自动配置, 只有 java -jar 与一个自定义的 URLClassLoader 在嵌套 jar 里找类。