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:
+111
@@ -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/failure,2xx 之外计失败);</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();
|
||||
}
|
||||
}
|
||||
+13
-5
@@ -4,7 +4,6 @@ import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.nanri.aiimage.config.BrandCheckProperties;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -27,7 +26,6 @@ import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class BrandCheckClient {
|
||||
|
||||
@@ -37,8 +35,15 @@ public class BrandCheckClient {
|
||||
private static final int BRAND_CHECK_CONCURRENCY = 3;
|
||||
|
||||
private final BrandCheckProperties properties;
|
||||
private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics;
|
||||
private volatile RestClient sharedRestClient;
|
||||
|
||||
public BrandCheckClient(BrandCheckProperties properties,
|
||||
com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) {
|
||||
this.properties = properties;
|
||||
this.externalCallMetrics = externalCallMetrics;
|
||||
}
|
||||
|
||||
private final ExecutorService checkExecutor = Executors.newFixedThreadPool(
|
||||
BRAND_CHECK_CONCURRENCY, namedThreadFactory("brand-check"));
|
||||
|
||||
@@ -162,9 +167,12 @@ public class BrandCheckClient {
|
||||
}
|
||||
synchronized (this) {
|
||||
if (sharedRestClient == null) {
|
||||
sharedRestClient = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(properties.getReadTimeoutMillis()))
|
||||
.build();
|
||||
RestClient.Builder builder = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(properties.getReadTimeoutMillis()));
|
||||
if (externalCallMetrics != null) {
|
||||
builder.requestInterceptor(externalCallMetrics.interceptor("brand"));
|
||||
}
|
||||
sharedRestClient = builder.build();
|
||||
}
|
||||
return sharedRestClient;
|
||||
}
|
||||
|
||||
+20
-5
@@ -6,7 +6,6 @@ import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -27,7 +26,6 @@ import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class SimilarAsinCozeClient {
|
||||
|
||||
@@ -39,6 +37,7 @@ public class SimilarAsinCozeClient {
|
||||
private final SimilarAsinProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final CozeCredentialPoolService cozeCredentialPoolService;
|
||||
private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics;
|
||||
private final AtomicLong credentialCursor = new AtomicLong();
|
||||
private final AtomicLong historyResponseLogCounter = new AtomicLong();
|
||||
/**
|
||||
@@ -206,6 +205,9 @@ public class SimilarAsinCozeClient {
|
||||
failureMessage(ex));
|
||||
}
|
||||
if (attemptIndex < 3) {
|
||||
if (externalCallMetrics != null) {
|
||||
externalCallMetrics.recordRetry("coze");
|
||||
}
|
||||
sleepBeforeRetry(attemptIndex);
|
||||
}
|
||||
}
|
||||
@@ -751,9 +753,12 @@ public class SimilarAsinCozeClient {
|
||||
}
|
||||
synchronized (this) {
|
||||
if (sharedRestClient == null) {
|
||||
sharedRestClient = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(properties.getCozeReadTimeoutMillis()))
|
||||
.build();
|
||||
RestClient.Builder builder = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(properties.getCozeReadTimeoutMillis()));
|
||||
if (externalCallMetrics != null) {
|
||||
builder.requestInterceptor(externalCallMetrics.interceptor("coze"));
|
||||
}
|
||||
sharedRestClient = builder.build();
|
||||
}
|
||||
return sharedRestClient;
|
||||
}
|
||||
@@ -872,6 +877,16 @@ public class SimilarAsinCozeClient {
|
||||
|| !normalize(row.getPuzzleImg2()).isBlank();
|
||||
}
|
||||
|
||||
public SimilarAsinCozeClient(SimilarAsinProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
CozeCredentialPoolService cozeCredentialPoolService,
|
||||
com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.cozeCredentialPoolService = cozeCredentialPoolService;
|
||||
this.externalCallMetrics = externalCallMetrics;
|
||||
}
|
||||
|
||||
private void ensureSuccess(JsonNode root) {
|
||||
if (root.path("code").asInt(-1) != 0) {
|
||||
throw new IllegalStateException(root.path("msg").asText("Coze response code is not 0"));
|
||||
|
||||
+19
-5
@@ -6,7 +6,6 @@ import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.ZiniaoProperties;
|
||||
import com.nanri.aiimage.modules.ziniao.model.cache.ZiniaoShopCacheDto;
|
||||
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoStaffItemVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
@@ -21,11 +20,23 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
|
||||
private final ZiniaoProperties ziniaoProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics;
|
||||
|
||||
public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties, ObjectMapper objectMapper) {
|
||||
this(ziniaoProperties, objectMapper, null);
|
||||
}
|
||||
|
||||
public ZiniaoClientImpl(ZiniaoProperties ziniaoProperties,
|
||||
ObjectMapper objectMapper,
|
||||
com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) {
|
||||
this.ziniaoProperties = ziniaoProperties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.externalCallMetrics = externalCallMetrics;
|
||||
}
|
||||
|
||||
/** Task 77:单例 RestClient(共享连接池),避免每次调用新建短命客户端。 */
|
||||
private volatile RestClient sharedRestClient;
|
||||
@@ -257,9 +268,12 @@ public class ZiniaoClientImpl implements ZiniaoClient {
|
||||
}
|
||||
synchronized (this) {
|
||||
if (sharedRestClient == null) {
|
||||
sharedRestClient = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(ziniaoProperties.getReadTimeoutSeconds() * 1000))
|
||||
.build();
|
||||
RestClient.Builder builder = RestClient.builder()
|
||||
.requestFactory(HttpClientPool.requestFactory(ziniaoProperties.getReadTimeoutSeconds() * 1000));
|
||||
if (externalCallMetrics != null) {
|
||||
builder.requestInterceptor(externalCallMetrics.interceptor("ziniao"));
|
||||
}
|
||||
sharedRestClient = builder.build();
|
||||
}
|
||||
return sharedRestClient;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user