task-78: 为所有外部调用增加耗时、重试、失败率和 payload 字节指标

新增 ExternalCallMetricsRecorder:通过 RestClient 拦截器统一记录
aiimage.external-call.duration(耗时)、aiimage.external-call.total
(失败率)、aiimage.external-call.payload.bytes(请求字节)与
aiimage.external-call.retry.total(重试次数);Coze/品牌检查/紫鸟
三个外部客户端全部接入,指标注册表缺失时静默降级,不改变调用语义。
This commit is contained in:
2026-08-30 22:01:02 +08:00
parent f1d1fe5c78
commit 4c70ca4101
10 changed files with 569 additions and 33 deletions
@@ -0,0 +1,111 @@
package com.nanri.aiimage.metrics;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
/**
* Task 78:外部调用统一指标记录器。
* 所有外部 HTTP 客户端(Coze / 品牌检查 / 紫鸟)在构建 RestClient 时挂载
* {@link #interceptor(String)} 拦截器,统一记录:
* <ul>
* <li>耗时:{@code aiimage.external-call.duration}client + result 标签);</li>
* <li>失败率:{@code aiimage.external-call.total}result=success/failure2xx 之外计失败);</li>
* <li>payload 字节:{@code aiimage.external-call.payload.bytes}(请求体字节数);</li>
* <li>重试次数:{@code aiimage.external-call.retry.total}(客户端重试循环内调用)。</li>
* </ul>
* 指标注册表通过 ObjectProvider 懒获取,未配置 Micrometer 时全部静默跳过,
* 不改变既有调用语义。
*/
@Component
public class ExternalCallMetricsRecorder {
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
public ExternalCallMetricsRecorder(ObjectProvider<MeterRegistry> meterRegistryProvider) {
this.meterRegistryProvider = meterRegistryProvider;
}
/** 单元测试入口:直接绑定一个指标注册表。 */
public ExternalCallMetricsRecorder(MeterRegistry registry) {
this(registry == null ? null : new ObjectProvider<MeterRegistry>() {
@Override
public MeterRegistry getObject() {
return registry;
}
@Override
public MeterRegistry getObject(Object... args) {
return registry;
}
@Override
public MeterRegistry getIfAvailable() {
return registry;
}
@Override
public MeterRegistry getIfUnique() {
return registry;
}
});
}
/** RestClient 拦截器:记录单次 HTTP 调用的耗时、payload 字节与成功/失败。 */
public ClientHttpRequestInterceptor interceptor(String client) {
return (request, body, execution) -> {
long startedAt = System.nanoTime();
long payloadBytes = body == null ? 0L : body.length;
try {
ClientHttpResponse response = execution.execute(request, body);
boolean success = response.getStatusCode().is2xxSuccessful();
record(client, success ? "success" : "failure", startedAt, payloadBytes);
return response;
} catch (Exception ex) {
record(client, "failure", startedAt, payloadBytes);
throw ex;
}
};
}
/** 重试循环内每次进入下一次尝试前调用。 */
public void recordRetry(String client) {
MeterRegistry registry = meterRegistry();
if (registry != null) {
registry.counter("aiimage.external-call.retry.total", "client", client).increment();
}
}
private void record(String client, String result, long startedAt, long payloadBytes) {
MeterRegistry registry = meterRegistry();
if (registry == null) {
return;
}
long durationNanos = System.nanoTime() - startedAt;
registry.counter("aiimage.external-call.total", "client", client, "result", result).increment();
if (durationNanos >= 0L) {
Timer.builder("aiimage.external-call.duration")
.tag("client", client)
.tag("result", result)
.register(registry)
.record(durationNanos, TimeUnit.NANOSECONDS);
}
if (payloadBytes >= 0L) {
DistributionSummary.builder("aiimage.external-call.payload.bytes")
.tag("client", client)
.baseUnit("bytes")
.register(registry)
.record(payloadBytes);
}
}
private MeterRegistry meterRegistry() {
return meterRegistryProvider == null ? null : meterRegistryProvider.getIfAvailable();
}
}