feat(aigc): 面五 game-cloud 生成业务埋点 GenMetrics(观测阶段四·flag 默认关)
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

新增 GenMetrics 组件(@ConditionalOnProperty huijing.aigc.metrics.enabled,默认关=零装配零副作用),
把 MVP 要盯的生成指标以 micrometer 暴露给已活的 /actuator/prometheus:
- gen_task_total{status} Counter:终态计成功率(succeeded/failed/timed_out,超时据真实 failureReason 还原)
- gen_queue_depth Gauge:@Scheduled 定时刷 countGlobalInflight→内存 AtomicInteger,scrape 零 DB(勿全表重查)
- gen_gate_fail_total{gate} Counter:九门逐门失败,源 trace.sevenGateVerdict.guards(复用 ReadinessScorer.guardPass:H_progress 嵌套取 .pass)
- gen_duration_seconds Timer(直方图):源 trace.wallS
- llm_cost DistributionSummary:源 trace.cost.totalRmb(worker token×倍率自估值、非 new-api 权威计费)

埋点挂 DifyCallbackServiceImpl.postCommitBestEffort(post-commit best-effort、realSuccess 判定前、
覆盖成功/失败/超时三类终态),null 判空 + 内部吞异常双保险,绝不进 DifyCallbackTxService 事务方法、
不咬回调主链(§8 旁路铁律)。micrometer-core 经 spring-ai-commons 传递,无需改 pom。

GenMetricsTest 7 例(终态三标签/耗时成本/逐门guards嵌套裸/队列gauge/脏trace吞/开关装配三态/抛错不咬主链)
+ 主链回归 DifyCallbackServiceImplTest 20 + DifyCallbackTxTraceTest 6,主控亲跑 BUILD SUCCESS 全绿。
面一 actuator 已活;dev 重部署后 profile 开 flag 即出指标(下一窗口深冒烟真验)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-05 19:23:20 -07:00
parent dee9158504
commit 7e21b331a5
3 changed files with 555 additions and 0 deletions

View File

@ -0,0 +1,257 @@
package com.wanxiang.huijing.game.module.aigc.framework.metrics;
import com.wanxiang.huijing.game.module.aigc.controller.admin.task.vo.DifyCallbackReqVO;
import com.wanxiang.huijing.game.module.aigc.dal.dataobject.task.AigcTaskDO;
import com.wanxiang.huijing.game.module.aigc.dal.mysql.task.AigcTaskMapper;
import com.wanxiang.huijing.game.module.aigc.enums.AigcTaskStatusEnum;
import com.wanxiang.huijing.game.module.aigc.enums.FailureReasonEnum;
import com.wanxiang.huijing.framework.tenant.core.util.TenantUtils;
import io.micrometer.core.instrument.DistributionSummary;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 生成业务语义埋点观测体系阶段四 · 面五
*
* <p> MVP 真正要盯的几个生成指标以 micrometer 形式暴露给 {@code /actuator/prometheus}生成成功率单次耗时
* 单次成本九门逐门失败在飞队列深度这些值此前只落在产物目录的 json 或回调的 trace 字段里没有一处能实时看
* 能告警本组件是把它们变成可采集指标的收口
*
* <p><b>开关与装配旁路铁律</b>整组埋点由 {@code huijing.aigc.metrics.enabled} 控制<b>默认关</b>
* {@link ConditionalOnProperty} 保证关时本组件根本不装配 Bean @Scheduled meter 注册
* 对生成主链零字节影响生产 profile 显式置 true 才开开启的前提是面一已给单体引入 actuator + micrometer
* 提供 {@link MeterRegistry} bean本组件只消费该 bean不负责装 actuator故未做面一的环境保持开关关即可
*
* <p><b>埋点绝不咬主链硬红线</b>{@link #recordTerminal} 全程吞异常调用方{@code DifyCallbackServiceImpl}
* 的提交后 best-effort 再兜一层双保险确保任何埋点故障都只留痕绝不外抛去影响回调的成败判定或后续通知/回填
*
* <p><b>Micrometer 点名 Prometheus </b>PrometheusMeterRegistry 自动转 snake_case 并补后缀
* <ul>
* <li>{@code gen.task}{status} {@code gen_task_total{status}}Counter成功率 = succeeded / 全部</li>
* <li>{@code gen.gate.fail}{gate} {@code gen_gate_fail_total{gate}}Counter九门逐门失败</li>
* <li>{@code gen.queue.depth} {@code gen_queue_depth}Gauge在飞任务数</li>
* <li>{@code gen.duration} {@code gen_duration_seconds}Timer基准单位秒直方图供 P50/P95</li>
* <li>{@code llm.cost} {@code llm_cost}DistributionSummary单次成本自估值</li>
* </ul>
*
* @author 绘境AI观测体系阶段四 · 面五 game-cloud 业务埋点
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "huijing.aigc.metrics.enabled", havingValue = "true")
public class GenMetrics {
/** gen_task_total{status}:任务终态计数(成功率分母 = 各 status 之和)。 */
static final String M_TASK = "gen.task";
/** gen_gate_fail_total{gate}:九门逐门失败计数。 */
static final String M_GATE_FAIL = "gen.gate.fail";
/** gen_queue_depth当前在飞queued+running任务数。 */
static final String M_QUEUE_DEPTH = "gen.queue.depth";
/** gen_duration_seconds单次生成端到端耗时。 */
static final String M_DURATION = "gen.duration";
/** llm_cost单次生成成本自估值。 */
static final String M_LLM_COST = "llm.cost";
private final MeterRegistry registry;
private final AigcTaskMapper aigcTaskMapper;
/**
* gen_queue_depth 的承载值{@link #refreshQueueDepth} 定时刷Gauge 只读它
* 之所以不把 Gauge 直接绑到 {@code countGlobalInflight} {@link #refreshQueueDepth} 注释
*/
private final AtomicInteger queueDepth = new AtomicInteger(0);
/** gen_duration_seconds构造期注册一次、复用源=worker 侧算好的 trace.wallS。 */
private final Timer genDuration;
/** llm_cost构造期注册一次、复用源=worker 侧 trace.cost.totalRmb 自估值)。 */
private final DistributionSummary llmCost;
/**
* @param registry micrometer 指标注册表面一装 actuator+micrometer 后在席开关开时必需
* @param aigcTaskMapper 任务 Mapper复用其既有全局在飞计数 {@code countGlobalInflight}不新增查询
*/
public GenMetrics(MeterRegistry registry, AigcTaskMapper aigcTaskMapper) {
this.registry = registry;
this.aigcTaskMapper = aigcTaskMapper;
// Gauge 绑到内存 AtomicIntegerscrape 时零 DB真实计数由 @Scheduled 定时刷入 refreshQueueDepth
Gauge.builder(M_QUEUE_DEPTH, queueDepth, AtomicInteger::doubleValue)
.description("当前在飞queued+running生成任务数")
.register(registry);
// Timer 基准单位为秒meter 名故意不带 seconds Prometheus 渲染补 _seconds 后缀否则会双后缀
this.genDuration = Timer.builder(M_DURATION)
.description("单次生成端到端耗时(源=worker 侧 trace.wallS")
.publishPercentileHistogram() // _bucket Prometheus 跨实例聚合 P50/P95
.register(registry);
this.llmCost = DistributionSummary.builder(M_LLM_COST)
.description("单次生成成本¥——worker 按 token×倍率的自估值非 new-api 权威计费流水")
.register(registry);
log.info("[GenMetrics] 生成业务埋点已装配huijing.aigc.metrics.enabled=true");
}
/**
* 终态收口埋点回调事务提交后由 best-effort 段调用一次埋全 gen_task_total / gen_duration / llm_cost / gen_gate_fail
*
* <p>覆盖全部终态成功业务失败以及超时收尸stale RUNNING 收尸为 failed+failureReason=timeout均经
* 回调唯一写入路径到达提交后段故此处一个挂点即全覆盖无需另挂 watchdog
*
* <p><b>全程吞异常</b>埋点是纯旁路任何解析/注册异常只留痕绝不外抛旁路铁律
*
* @param reqVO 回调入参 trace 载荷wallS / cost.totalRmb / sevenGateVerdict.guards
* @param task 提交后按 traceId 回查到的任务取终态 status + failureReason可能为 null=未定位到
*/
public void recordTerminal(DifyCallbackReqVO reqVO, AigcTaskDO task) {
try {
recordTaskTerminal(task);
Map<String, Object> trace = reqVO == null ? null : reqVO.getTrace();
if (trace != null && !trace.isEmpty()) {
recordDuration(trace);
recordCost(trace);
recordGateFails(trace);
}
} catch (Exception e) {
// 埋点整体兜底任何异常只留痕绝不外抛回调主链不受影响
log.error("[GenMetrics] 终态埋点异常best-effort不影响回调主链traceId={}",
reqVO == null ? null : reqVO.getTraceId(), e);
}
}
/** gen_task_total{status}:按任务终态计数。 */
private void recordTaskTerminal(AigcTaskDO task) {
if (task == null || task.getStatus() == null) {
return; // 回调未定位到任务 无终态可计属异常情形另有 warn 留痕在回调侧
}
String statusLabel = resolveStatusLabel(task);
if (statusLabel != null) {
registry.counter(M_TASK, "status", statusLabel).increment();
}
}
/**
* 终态标签口径对齐设计 §6 succeeded / failed / timed_out 三分
* <ul>
* <li>SUCCEEDED(2) {@code succeeded}</li>
* <li>FAILED(3) failureReason=timeout {@code timed_out}系统状态机不写 status=4§1.3-7 回调门禁只 succeeded/failed
* 两值超时收尸落 failed+timeout此处按<b>真实 failureReason</b> 把超时从 failed 里还原成 timed_out据实映射非编造</li>
* <li>FAILED(3) 其余 {@code failed}</li>
* <li>其余终态canceled 枚举名小写防御正常回调路不产 canceled</li>
* </ul>
*/
private String resolveStatusLabel(AigcTaskDO task) {
AigcTaskStatusEnum st = AigcTaskStatusEnum.of(task.getStatus());
if (st == null) {
return null; // 非法状态值不计避免脏标签污染指标基数
}
if (st == AigcTaskStatusEnum.FAILED
&& FailureReasonEnum.TIMEOUT.getReason().equals(task.getFailureReason())) {
return "timed_out";
}
return st.name().toLowerCase();
}
/** gen_duration_secondstrace.wallSworker 已算)→ Timer.record。缺失/非数/负值跳过。 */
private void recordDuration(Map<String, Object> trace) {
Object wallS = trace.get("wallS");
if (wallS instanceof Number) {
double sec = ((Number) wallS).doubleValue();
if (sec >= 0) {
// wallS 是秒级浮点生成通常上百秒按毫秒记入 Timer精度足够
genDuration.record((long) (sec * 1000), TimeUnit.MILLISECONDS);
}
}
}
/**
* llm_costtrace.cost.totalRmb DistributionSummary.recordsum=累计花费count=生成笔数
* 值是 worker token×倍率的自估值 new-api 权威计费流水取价失败降级时无 totalRmb此处自然跳过
*/
@SuppressWarnings("unchecked")
private void recordCost(Map<String, Object> trace) {
Object cost = trace.get("cost");
if (cost instanceof Map) {
Object totalRmb = ((Map<String, Object>) cost).get("totalRmb");
if (totalRmb instanceof Number) {
double rmb = ((Number) totalRmb).doubleValue();
if (rmb >= 0) {
llmCost.record(rmb);
}
}
}
}
/**
* gen_gate_fail_total{gate}九门逐门失败计数 = trace.sevenGateVerdict.guards{门名: pass}
*
* <p>guards 在便宜档主路与 tier2 两条生成线都逐门产出result_out / service._extract_trace 同源故本项可做到真逐门
* 不必降级为合成分门判真值解析复用 {@code ReadinessScorer.guardPass} 同款口径H_progress 是嵌套对象
* {@code {pass,checks,latch,...}} 取其 .pass其余门为裸 bool只有明确未过的门才 +1
*/
@SuppressWarnings("unchecked")
private void recordGateFails(Map<String, Object> trace) {
Object verdict = trace.get("sevenGateVerdict");
if (!(verdict instanceof Map)) {
return;
}
Object guards = ((Map<String, Object>) verdict).get("guards");
if (!(guards instanceof Map)) {
return;
}
for (Map.Entry<String, Object> gate : ((Map<String, Object>) guards).entrySet()) {
if (!guardPass(gate.getValue())) {
registry.counter(M_GATE_FAIL, "gate", gate.getKey()).increment();
}
}
}
/** 门判真值(对齐 ReadinessScorer.guardPass嵌套对象取 .pass裸 bool/数字/字符串宽松判真。 */
@SuppressWarnings("unchecked")
private static boolean guardPass(Object gate) {
if (gate instanceof Map) {
return asBool(((Map<String, Object>) gate).get("pass"));
}
return asBool(gate);
}
/** 宽松布尔解析trace 来自 JSON 反序列化,类型可能为 Boolean/String/Number。 */
private static boolean asBool(Object v) {
if (v instanceof Boolean) {
return (Boolean) v;
}
if (v instanceof Number) {
return ((Number) v).doubleValue() != 0;
}
return v != null && "true".equalsIgnoreCase(String.valueOf(v));
}
/**
* gen_queue_depth 定时刷新默认 15s可配 {@code huijing.aigc.metrics.queue-depth-refresh-ms}
*
* <p><b>为何不把 Gauge 直接绑 {@code countGlobalInflight}</b>直接绑会让每次 Prometheus scrape以及 micrometer
* 内部 gauge 轮询都打一次全表 COUNTDB 压力与 scrape 频率耦合不可控改为定时刷进 {@link #queueDepth}
* AtomicInteger把查询频率钉死在固定周期scrape 时只读内存零 DB符合勿每次 scrape 打全表重查
* 复用既有控制平面计数 {@code countGlobalInflight}D12 背压同一口径不新增查询
*
* <p><b>跨租户</b>定时线程无租户上下文 {@code TenantUtils.executeIgnore} 包裹取全局在飞口径
* 与执行器 watchdog 收尸扫描同款<b>best-effort</b>刷新失败只留痕保留上次值绝不外抛
*/
@Scheduled(fixedRateString = "${huijing.aigc.metrics.queue-depth-refresh-ms:15000}")
public void refreshQueueDepth() {
try {
// lambda(非方法引用)对齐执行器既有 executeIgnore(Callable) 用法,消除 Runnable/Callable 重载歧义
Long inflight = TenantUtils.executeIgnore(() -> aigcTaskMapper.countGlobalInflight());
queueDepth.set(inflight == null ? 0 : inflight.intValue());
} catch (Exception e) {
log.warn("[GenMetrics] 在飞队列深度刷新失败(保留上次值,不阻断)", e);
}
}
}

View File

@ -4,6 +4,7 @@ import com.wanxiang.huijing.game.module.aigc.controller.admin.task.vo.DifyCallba
import com.wanxiang.huijing.game.module.aigc.dal.dataobject.task.AigcTaskDO;
import com.wanxiang.huijing.game.module.aigc.dal.mysql.task.AigcTaskMapper;
import com.wanxiang.huijing.game.module.aigc.enums.AigcTaskStatusEnum;
import com.wanxiang.huijing.game.module.aigc.framework.metrics.GenMetrics;
import com.wanxiang.huijing.game.module.community.api.CommunityNotifyApi;
import com.wanxiang.huijing.game.module.studio.api.SourceProjectApi;
import com.wanxiang.huijing.game.module.studio.dto.SourceProjectLandReqDTO;
@ -13,6 +14,7 @@ import com.wanxiang.huijing.framework.common.exception.enums.GlobalErrorCodeCons
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@ -85,6 +87,16 @@ public class DifyCallbackServiceImpl implements DifyCallbackService {
@Resource
private SourceProjectApi sourceProjectApi;
/**
* 生成业务埋点组件观测阶段四 · 面五;可选注入
*
* <p><b>可选required=false的原因</b>埋点由开关 {@code huijing.aigc.metrics.enabled} 控制默认关关时
* {@link GenMetrics} 整个不装配({@code @ConditionalOnProperty}),此处注入为 null故本类在提交后段调用前必判空
* ({@link #recordGenMetricsQuietly}),关时天然旁路零埋点副作用(旁路铁律)开时(生产 profile)才在席
*/
@Autowired(required = false)
private GenMetrics genMetrics;
@Override
public Boolean handleCallback(DifyCallbackReqVO reqVO) {
log.info("[handleCallback] 收到 Dify 回调 traceId={}, status={}, templateId={}",
@ -144,6 +156,10 @@ public class DifyCallbackServiceImpl implements DifyCallbackService {
private void postCommitBestEffort(DifyCallbackReqVO reqVO, SourceLanding landing) {
try {
AigcTaskDO task = aigcTaskMapper.selectByTraceId(reqVO.getTraceId());
// ===== 面五 观测埋点(终态收口):post-commit best-effort,独立兜底与下方通知/回填解耦 =====
// 置于 realSuccess 判定之前,使成功/失败/超时收尸(failed+timeout)三类终态都被计入;
// genMetrics 关时为 null 天然旁路;开时其内部再吞一层异常,双保险确保埋点绝不咬主链不跳过下方源态回填
recordGenMetricsQuietly(reqVO, task);
boolean realSuccess = task != null
&& Objects.equals(task.getStatus(), AigcTaskStatusEnum.SUCCEEDED.getStatus())
&& task.getVersionId() != null;
@ -165,6 +181,28 @@ public class DifyCallbackServiceImpl implements DifyCallbackService {
}
}
/**
* 面五 观测埋点静默调用提交后 best-effort把终态计数/耗时/成本/门失败交给 {@link GenMetrics}
*
* <p><b>双保险不咬主链</b> 开关关时 {@code genMetrics} null 直接旁路,零副作用; 开时即便 {@code recordTerminal}
* 意外抛错,本方法 catch 吞掉若不兜,异常会冒进 {@code postCommitBestEffort} 外层 catch连带跳过其后的通知与
* 源态回填(那才是真正咬主链)故此处必须独立吞异常,不能省
*
* @param reqVO 回调入参( trace 载荷)
* @param task 提交后回查到的任务(取终态 status/failureReason;可能为 null)
*/
private void recordGenMetricsQuietly(DifyCallbackReqVO reqVO, AigcTaskDO task) {
if (genMetrics == null) {
return; // 埋点开关关 / 组件未装配 零埋点副作用(旁路铁律)
}
try {
genMetrics.recordTerminal(reqVO, task);
} catch (Exception e) {
log.error("[handleCallback] 生成业务埋点失败(best-effort,不影响主链、不跳过源态回填)traceId={}",
reqVO.getTraceId(), e);
}
}
/**
* 本次落源结果U2/B8 §5.2持源行 ID + gameId + sourceHash建包成功回填 version_id建包失败标孤儿复用
* landing nullreqVO sourceProject / 落源失败 后续两步均旁路best-effort不阻断主链

View File

@ -0,0 +1,260 @@
package com.wanxiang.huijing.game.module.aigc.framework.metrics;
import com.wanxiang.huijing.game.module.aigc.controller.admin.task.vo.DifyCallbackReqVO;
import com.wanxiang.huijing.game.module.aigc.dal.dataobject.task.AigcTaskDO;
import com.wanxiang.huijing.game.module.aigc.dal.mysql.task.AigcTaskMapper;
import com.wanxiang.huijing.game.module.aigc.enums.AigcTaskStatusEnum;
import com.wanxiang.huijing.game.module.aigc.service.callback.DifyCallbackServiceImpl;
import com.wanxiang.huijing.game.module.aigc.service.callback.DifyCallbackTxService;
import com.wanxiang.huijing.game.module.community.api.CommunityNotifyApi;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link GenMetrics}观测阶段四 · 面五 生成业务埋点单元测试 Mockito + SimpleMeterRegistry不依赖 DB/Spring 全量上下文
*
* <p>覆盖三条硬要求 开关开时终态各 status 正确递增 gen_task_total含超时还原 timed_out+ 耗时/成本/门失败真落表
* 开关关时组件不装配ApplicationContextRunner 断言 Bean 缺席 埋点内部抛错不影响回调主流程返回与源态回填
*
* @author 绘境AI观测阶段四 · 面五
*/
class GenMetricsTest {
// ============================== 用例-agen_task_total{status} 按终态递增 ==============================
/**
* 三类终态各自递增对应 status 标签succeeded / failed(非超时) / timed_out(failed+failureReason=timeout 还原)
* 坐实成功率分母口径 = succeeded + failed + timed_out且超时据真实 failureReason 还原非编造
*/
@Test
void testGenTaskTotal_incrementsByTerminalStatus() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
GenMetrics metrics = new GenMetrics(registry, mock(AigcTaskMapper.class));
metrics.recordTerminal(reqWithTrace(true, 1), taskOf(AigcTaskStatusEnum.SUCCEEDED, null));
metrics.recordTerminal(reqWithTrace(false, 3), taskOf(AigcTaskStatusEnum.FAILED, "llm_error"));
metrics.recordTerminal(reqWithTrace(false, 3), taskOf(AigcTaskStatusEnum.FAILED, "timeout"));
assertEquals1(registry.get(GenMetrics.M_TASK).tag("status", "succeeded").counter().count());
assertEquals1(registry.get(GenMetrics.M_TASK).tag("status", "failed").counter().count());
// 超时:status failed(3)failureReason=timeout 还原为 timed_out 标签
assertEquals1(registry.get(GenMetrics.M_TASK).tag("status", "timed_out").counter().count());
}
// ============================== 用例-bgen_duration_seconds / llm_cost 真落表 ==============================
/**
* 单次终态回调 gen_duration_seconds 记入一笔trace.wallS llm_cost 记入一笔trace.cost.totalRmb
*/
@Test
void testDurationAndCost_recordedFromTrace() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
GenMetrics metrics = new GenMetrics(registry, mock(AigcTaskMapper.class));
metrics.recordTerminal(reqWithTrace(true, 1), taskOf(AigcTaskStatusEnum.SUCCEEDED, null));
// 耗时:count=1总时长wallS(23.4s)
assertTrue(registry.get(GenMetrics.M_DURATION).timer().count() == 1L, "gen_duration 应记入一笔");
double sec = registry.get(GenMetrics.M_DURATION).timer().totalTime(TimeUnit.SECONDS);
assertTrue(Math.abs(sec - 23.4) < 0.1, "gen_duration 总时长应≈wallS=23.4s,实际=" + sec);
// 成本:count=1总额totalRmb(0.031)
assertTrue(registry.get(GenMetrics.M_LLM_COST).summary().count() == 1L, "llm_cost 应记入一笔");
double rmb = registry.get(GenMetrics.M_LLM_COST).summary().totalAmount();
assertTrue(Math.abs(rmb - 0.031) < 1e-6, "llm_cost 总额应≈totalRmb=0.031,实际=" + rmb);
}
// ============================== 用例-cgen_gate_fail_total{gate} 逐门失败 ==============================
/**
* guards = {C_frame:true(), H_progress:{pass:false}(嵌套·未过), E_live:false(·未过)}
* 仅两个未过门各 +1过门不计;嵌套 H_progress .pass 判定(复用 ReadinessScorer 口径)
*/
@Test
void testGenGateFail_perFailingGate() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
GenMetrics metrics = new GenMetrics(registry, mock(AigcTaskMapper.class));
DifyCallbackReqVO reqVO = new DifyCallbackReqVO();
reqVO.setTraceId("t-gate");
Map<String, Object> guards = new LinkedHashMap<>();
guards.put("C_frame", true); // 不计
guards.put("H_progress", Map.of("pass", false)); // 嵌套未过 +1
guards.put("E_live", false); // 裸未过 +1
Map<String, Object> verdict = new LinkedHashMap<>();
verdict.put("guards", guards);
Map<String, Object> trace = new LinkedHashMap<>();
trace.put("sevenGateVerdict", verdict);
reqVO.setTrace(trace);
metrics.recordTerminal(reqVO, taskOf(AigcTaskStatusEnum.FAILED, "llm_error"));
assertEquals1(registry.get(GenMetrics.M_GATE_FAIL).tag("gate", "H_progress").counter().count());
assertEquals1(registry.get(GenMetrics.M_GATE_FAIL).tag("gate", "E_live").counter().count());
// 过门不产计数器
assertNull(registry.find(GenMetrics.M_GATE_FAIL).tag("gate", "C_frame").counter(),
"过门 C_frame 不应产失败计数");
}
// ============================== 用例-dgen_queue_depth 定时刷 Gauge 读内存 ==============================
/**
* refreshQueueDepth 读全局在飞计数刷 AtomicIntegerGauge 暴露该值(scrape 时零 DB)
*/
@Test
void testQueueDepthGauge_refreshedFromInflightCount() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
AigcTaskMapper mapper = mock(AigcTaskMapper.class);
when(mapper.countGlobalInflight()).thenReturn(7L);
GenMetrics metrics = new GenMetrics(registry, mapper);
// 刷新前默认 0
assertTrue(registry.get(GenMetrics.M_QUEUE_DEPTH).gauge().value() == 0.0, "初始应为 0");
metrics.refreshQueueDepth();
assertTrue(registry.get(GenMetrics.M_QUEUE_DEPTH).gauge().value() == 7.0, "刷新后应为在飞计数 7");
}
// ============================== 用例-e trace 不抛不产脏计数(埋点内部健壮性) ==============================
/**
* trace 字段类型不符(wallS 非数cost Mapguards 缺失) recordTerminal 不抛不误记 status 计数正常
*/
@Test
void testMalformedTrace_swallowedNoBogusMetric() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
GenMetrics metrics = new GenMetrics(registry, mock(AigcTaskMapper.class));
DifyCallbackReqVO reqVO = new DifyCallbackReqVO();
reqVO.setTraceId("t-bad");
Map<String, Object> trace = new LinkedHashMap<>();
trace.put("wallS", "not-a-number");
trace.put("cost", "not-a-map");
reqVO.setTrace(trace);
assertDoesNotThrow(() -> metrics.recordTerminal(reqVO, taskOf(AigcTaskStatusEnum.SUCCEEDED, null)));
// status 仍正常计;耗时/成本 meter 虽在构造期已注册(供复用),但脏数据一笔都不应记入(count=0)
assertEquals1(registry.get(GenMetrics.M_TASK).tag("status", "succeeded").counter().count());
assertTrue(registry.get(GenMetrics.M_DURATION).timer().count() == 0L, "非数 wallS 不应记入 gen_duration");
assertTrue(registry.get(GenMetrics.M_LLM_COST).summary().count() == 0L, "非 Map cost 不应记入 llm_cost");
}
// ============================== 用例开关控制装配(关时组件不装配) ==============================
/**
* ApplicationContextRunner 断言 @ConditionalOnProperty 行为
* 缺省/显式 false GenMetrics Bean 缺席(关时零埋点副作用);true 装配
*/
@Test
void testConditionalAssembly_offByDefault_onWhenEnabled() {
ApplicationContextRunner runner = new ApplicationContextRunner()
.withBean(MeterRegistry.class, SimpleMeterRegistry::new)
.withBean(AigcTaskMapper.class, () -> mock(AigcTaskMapper.class))
.withUserConfiguration(GenMetrics.class);
// 缺省(未设开关) 不装配
runner.run(context -> assertThat(context).doesNotHaveBean(GenMetrics.class));
// 显式 false 不装配
runner.withPropertyValues("huijing.aigc.metrics.enabled=false")
.run(context -> assertThat(context).doesNotHaveBean(GenMetrics.class));
// 显式 true 装配
runner.withPropertyValues("huijing.aigc.metrics.enabled=true")
.run(context -> assertThat(context).hasSingleBean(GenMetrics.class));
}
// ============================== 用例埋点抛错不咬回调主链(红线) ==============================
/**
* genMetrics.recordTerminal 抛错 回调 handleCallback return TRUE且提交后的源态通知照常发出
* (证明埋点异常被独立吞不冒进外层 catch 跳过后续 notify/回填)旁路铁律硬红线
*/
@Test
void testGenMetricsThrows_doesNotBlockCallback() {
DifyCallbackTxService txService = mock(DifyCallbackTxService.class);
when(txService.handleCallbackTx(any(DifyCallbackReqVO.class))).thenReturn(Boolean.TRUE);
DifyCallbackServiceImpl callbackService = new DifyCallbackServiceImpl(txService);
AigcTaskMapper mapper = mock(AigcTaskMapper.class);
AigcTaskDO succeeded = taskOf(AigcTaskStatusEnum.SUCCEEDED, null);
succeeded.setCreatorUserId(1000L);
succeeded.setGameId(5L);
succeeded.setVersionId(2048L); // realSuccess=true 提交后 notify 会触发
when(mapper.selectByTraceId("t-red")).thenReturn(succeeded);
CommunityNotifyApi notifyApi = mock(CommunityNotifyApi.class);
// 抛错的埋点组件
GenMetrics throwing = mock(GenMetrics.class);
doThrow(new RuntimeException("埋点炸了")).when(throwing).recordTerminal(any(), any());
ReflectionTestUtils.setField(callbackService, "aigcTaskMapper", mapper);
ReflectionTestUtils.setField(callbackService, "communityNotifyApi", notifyApi);
ReflectionTestUtils.setField(callbackService, "genMetrics", throwing);
DifyCallbackReqVO reqVO = reqWithTrace(true, 1);
reqVO.setTraceId("t-red"); // sourceProject 落源整段旁路,不需 mock sourceProjectApi
// 红线:埋点抛错被吞,主链照常 return TRUE
assertTrue(callbackService.handleCallback(reqVO), "埋点抛错不应影响回调主流程返回");
// 埋点确实被调(且抛了,但被独立吞)
verify(throwing).recordTerminal(any(), any());
// 提交后源态通知照常发出(证明埋点异常没有跳过后续 best-effort 步骤)
verify(notifyApi).notifyGenerateDone(1000L, 5L, 2048L);
}
// ============================== 测试夹具 ==============================
/** 断计数器为 1micrometer count 返回 double。 */
private static void assertEquals1(double actual) {
assertTrue(actual == 1.0, "计数应为 1实际=" + actual);
}
/** 构造指定终态 + failureReason 的任务 DO。 */
private static AigcTaskDO taskOf(AigcTaskStatusEnum status, String failureReason) {
AigcTaskDO task = new AigcTaskDO();
task.setId(77L);
task.setTraceId("t-x");
task.setStatus(status.getStatus());
task.setFailureReason(failureReason);
return task;
}
/** 构造带 9d tracewallS/cost/sevenGateVerdict.guards的回调入参。 */
private static DifyCallbackReqVO reqWithTrace(boolean pass, int repairs) {
DifyCallbackReqVO reqVO = new DifyCallbackReqVO();
reqVO.setTraceId("t-x");
reqVO.setStatus(pass ? "succeeded" : "failed");
Map<String, Object> trace = new LinkedHashMap<>();
trace.put("pass", pass);
trace.put("repairs", repairs);
trace.put("wallS", 23.4);
Map<String, Object> guards = new LinkedHashMap<>();
guards.put("C_frame", true);
guards.put("H_progress", Map.of("pass", pass));
Map<String, Object> verdict = new LinkedHashMap<>();
verdict.put("pass", pass);
verdict.put("guards", guards);
trace.put("sevenGateVerdict", verdict);
Map<String, Object> cost = new LinkedHashMap<>();
cost.put("totalRmb", 0.031);
trace.put("cost", cost);
reqVO.setTrace(trace);
return reqVO;
}
}