|
|
import {
|
|
|
METRIC_COUNTER,
|
|
|
METRICS_COUNTERS_KEY,
|
|
|
METRICS_LATENCY_KEY,
|
|
|
METRICS_LATENCY_MAX_SAMPLES,
|
|
|
METRICS_QUOTE_START_PREFIX,
|
|
|
METRICS_QUOTE_START_TTL_SECONDS,
|
|
|
} from "@/lib/constants/metrics";
|
|
|
import { getRedis } from "@/lib/redis";
|
|
|
|
|
|
export type MetricsCounters = Record<
|
|
|
(typeof METRIC_COUNTER)[keyof typeof METRIC_COUNTER],
|
|
|
number
|
|
|
>;
|
|
|
|
|
|
export type MetricsRates = {
|
|
|
api_success_rate: number;
|
|
|
rpa_success_rate: number;
|
|
|
realtime_rate: number;
|
|
|
cache_hit_rate: number;
|
|
|
};
|
|
|
|
|
|
export type MetricsSnapshot = MetricsRates & {
|
|
|
counters: MetricsCounters;
|
|
|
p95_latency_ms: number;
|
|
|
latency_sample_count: number;
|
|
|
};
|
|
|
|
|
|
type DoneSource = "cache" | "rpa" | "stale";
|
|
|
|
|
|
function quoteStartKey(quoteId: string): string {
|
|
|
return `${METRICS_QUOTE_START_PREFIX}${quoteId}`;
|
|
|
}
|
|
|
|
|
|
async function incrCounter(field: string, by = 1): Promise<void> {
|
|
|
await getRedis().hincrby(METRICS_COUNTERS_KEY, field, by);
|
|
|
}
|
|
|
|
|
|
/** 安全埋点:不阻塞主流程,失败仅打日志 */
|
|
|
export function safeRecord(task: () => Promise<void>): void {
|
|
|
void task().catch((error) => {
|
|
|
console.error("[metrics] 采集失败:", error);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
/** 有效 POST 进入编排(校验通过后) */
|
|
|
export async function recordPostTotal(): Promise<void> {
|
|
|
await incrCounter(METRIC_COUNTER.POST_TOTAL);
|
|
|
}
|
|
|
|
|
|
/** L1 / L2 缓存命中 */
|
|
|
export async function recordCacheHit(tier: "l1" | "l2"): Promise<void> {
|
|
|
await incrCounter(
|
|
|
tier === "l1"
|
|
|
? METRIC_COUNTER.CACHE_HIT_L1
|
|
|
: METRIC_COUNTER.CACHE_HIT_L2,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 触发 RPA 路径 */
|
|
|
export async function recordRpaTriggered(quoteId: string): Promise<void> {
|
|
|
await incrCounter(METRIC_COUNTER.RPA_TRIGGERED);
|
|
|
await markQuoteStarted(quoteId);
|
|
|
}
|
|
|
|
|
|
/** 记录询价开始时间(用于 P95) */
|
|
|
export async function markQuoteStarted(quoteId: string): Promise<void> {
|
|
|
await getRedis().set(
|
|
|
quoteStartKey(quoteId),
|
|
|
String(Date.now()),
|
|
|
"EX",
|
|
|
METRICS_QUOTE_START_TTL_SECONDS,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
async function recordDoneLatency(quoteId: string): Promise<void> {
|
|
|
const redis = getRedis();
|
|
|
const startKey = quoteStartKey(quoteId);
|
|
|
const startedAt = await redis.get(startKey);
|
|
|
if (startedAt) {
|
|
|
await redis.del(startKey);
|
|
|
const latencyMs = Math.max(0, Date.now() - Number(startedAt));
|
|
|
await redis.lpush(METRICS_LATENCY_KEY, String(latencyMs));
|
|
|
await redis.ltrim(METRICS_LATENCY_KEY, 0, METRICS_LATENCY_MAX_SAMPLES - 1);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/** 报价完成 done(含 cache / rpa / stale) */
|
|
|
export async function recordPostDone(params: {
|
|
|
quoteId: string;
|
|
|
sourceType: DoneSource;
|
|
|
isRealtime: boolean;
|
|
|
}): Promise<void> {
|
|
|
await incrCounter(METRIC_COUNTER.POST_DONE);
|
|
|
await incrCounter(METRIC_COUNTER.DONE_TOTAL);
|
|
|
|
|
|
if (params.isRealtime && params.sourceType !== "stale") {
|
|
|
await incrCounter(METRIC_COUNTER.DONE_REALTIME);
|
|
|
}
|
|
|
|
|
|
if (params.sourceType === "rpa") {
|
|
|
await incrCounter(METRIC_COUNTER.RPA_DONE);
|
|
|
}
|
|
|
|
|
|
await recordDoneLatency(params.quoteId);
|
|
|
}
|
|
|
|
|
|
export function computeRate(numerator: number, denominator: number): number {
|
|
|
if (denominator <= 0) {
|
|
|
return 0;
|
|
|
}
|
|
|
return Math.round((numerator / denominator) * 10_000) / 100;
|
|
|
}
|
|
|
|
|
|
export function computeP95(samples: number[]): number {
|
|
|
if (samples.length === 0) {
|
|
|
return 0;
|
|
|
}
|
|
|
const sorted = [...samples].sort((a, b) => a - b);
|
|
|
const index = Math.ceil(sorted.length * 0.95) - 1;
|
|
|
return sorted[Math.max(0, index)];
|
|
|
}
|
|
|
|
|
|
export function computeRates(counters: MetricsCounters): MetricsRates {
|
|
|
const cacheHits =
|
|
|
counters[METRIC_COUNTER.CACHE_HIT_L1] +
|
|
|
counters[METRIC_COUNTER.CACHE_HIT_L2];
|
|
|
|
|
|
return {
|
|
|
api_success_rate: computeRate(
|
|
|
counters[METRIC_COUNTER.POST_DONE],
|
|
|
counters[METRIC_COUNTER.POST_TOTAL],
|
|
|
),
|
|
|
rpa_success_rate: computeRate(
|
|
|
counters[METRIC_COUNTER.RPA_DONE],
|
|
|
counters[METRIC_COUNTER.RPA_TRIGGERED],
|
|
|
),
|
|
|
realtime_rate: computeRate(
|
|
|
counters[METRIC_COUNTER.DONE_REALTIME],
|
|
|
counters[METRIC_COUNTER.DONE_TOTAL],
|
|
|
),
|
|
|
cache_hit_rate: computeRate(
|
|
|
cacheHits,
|
|
|
counters[METRIC_COUNTER.POST_TOTAL],
|
|
|
),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
async function readCounters(): Promise<MetricsCounters> {
|
|
|
const raw = await getRedis().hgetall(METRICS_COUNTERS_KEY);
|
|
|
const counters = {} as MetricsCounters;
|
|
|
|
|
|
for (const value of Object.values(METRIC_COUNTER)) {
|
|
|
counters[value] = Number(raw[value] ?? 0);
|
|
|
}
|
|
|
|
|
|
return counters;
|
|
|
}
|
|
|
|
|
|
async function readLatencySamples(): Promise<number[]> {
|
|
|
const raw = await getRedis().lrange(METRICS_LATENCY_KEY, 0, -1);
|
|
|
return raw.map((item) => Number(item)).filter((item) => Number.isFinite(item));
|
|
|
}
|
|
|
|
|
|
/** 汇总 GD-4 四指标 + P95 */
|
|
|
export async function getMetricsSnapshot(): Promise<MetricsSnapshot> {
|
|
|
const [counters, samples] = await Promise.all([
|
|
|
readCounters(),
|
|
|
readLatencySamples(),
|
|
|
]);
|
|
|
|
|
|
return {
|
|
|
...computeRates(counters),
|
|
|
counters,
|
|
|
p95_latency_ms: computeP95(samples),
|
|
|
latency_sample_count: samples.length,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
/** 测试用:清空指标 */
|
|
|
export async function resetMetrics(): Promise<void> {
|
|
|
const redis = getRedis();
|
|
|
const keys = await redis.keys(`${METRICS_QUOTE_START_PREFIX}*`);
|
|
|
if (keys.length > 0) {
|
|
|
await redis.del(...keys);
|
|
|
}
|
|
|
await redis.del(METRICS_COUNTERS_KEY, METRICS_LATENCY_KEY);
|
|
|
}
|