You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

189 lines
5.1 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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);
}