feat: observe BI data source health
This commit is contained in:
@@ -172,7 +172,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
|
|||||||
- 轮换已经进入版本历史的氢能数据库凭据。
|
- 轮换已经进入版本历史的氢能数据库凭据。
|
||||||
- 已提供无敏感值的 `.env.example`;测试、生产值由各环境密钥管理。
|
- 已提供无敏感值的 `.env.example`;测试、生产值由各环境密钥管理。
|
||||||
- 已拆分进程存活检查与无敏感值的配置就绪检查。
|
- 已拆分进程存活检查与无敏感值的配置就绪检查。
|
||||||
- 增加数据源连通性、最后成功时间和错误率监控。
|
- 已增加基于真实业务请求的数据源最后结果、最后成功时间和滚动失败率监控。
|
||||||
|
|
||||||
## 9. 每批验收门槛
|
## 9. 每批验收门槛
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ export async function authMiddleware(c: Context, next: Next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 跳过不需要认证的路径
|
// 跳过不需要认证的路径
|
||||||
if (path.startsWith('/api/health') || path.startsWith('/api/auth/')) {
|
if (
|
||||||
|
path === '/api/health'
|
||||||
|
|| path === '/api/health/readiness'
|
||||||
|
|| path.startsWith('/api/auth/')
|
||||||
|
) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ app.route('/api/vehicle-heatmap', vehicleHeatmapRouter);
|
|||||||
app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter);
|
app.route('/api/hydrogen-heatmap', hydrogenHeatmapRouter);
|
||||||
app.route('/api/analytics', analyticsRouter);
|
app.route('/api/analytics', analyticsRouter);
|
||||||
|
|
||||||
|
app.all('/api/*', (c) => c.json({ error: 'API route not found' }, 404));
|
||||||
|
|
||||||
// Serve static files in production
|
// Serve static files in production
|
||||||
app.use('/*', serveStatic({ root: './dist' }));
|
app.use('/*', serveStatic({ root: './dist' }));
|
||||||
app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
|
app.use('/*', serveStatic({ root: './dist', path: 'index.html' }));
|
||||||
|
|||||||
@@ -50,3 +50,13 @@ test('rejects unpublished metric domains', async () => {
|
|||||||
|
|
||||||
assert.equal(response.status, 400);
|
assert.equal(response.status, 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('returns observed data-source outcomes without sensitive details', async () => {
|
||||||
|
const response = await app.request('/data-sources');
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.equal(payload.semantics, 'observed-requests');
|
||||||
|
assert.ok(Array.isArray(payload.sources));
|
||||||
|
assert.equal(JSON.stringify(payload).includes('password'), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
listMetricDefinitions,
|
listMetricDefinitions,
|
||||||
type MetricDomain,
|
type MetricDomain,
|
||||||
} from '../../../shared/analytics/catalog.js';
|
} from '../../../shared/analytics/catalog.js';
|
||||||
|
import { getDataSourceTelemetry } from '../../source-telemetry.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -26,4 +27,6 @@ app.get('/metrics', (c) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/data-sources', (c) => c.json(getDataSourceTelemetry()));
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { observeDataSource, type DataSourceId } from '../../source-telemetry.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SWR 缓存:始终返回热数据,后台定时刷新。
|
* SWR 缓存:始终返回热数据,后台定时刷新。
|
||||||
*
|
*
|
||||||
@@ -70,24 +72,28 @@ async function runRefresh(key: string) {
|
|||||||
|
|
||||||
export interface CachedOpts {
|
export interface CachedOpts {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
source?: DataSourceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cached<T>(key: string, loader: () => Promise<T>, opts: CachedOpts = {}): Promise<T> {
|
export async function cached<T>(key: string, loader: () => Promise<T>, opts: CachedOpts = {}): Promise<T> {
|
||||||
|
const effectiveLoader = opts.source
|
||||||
|
? () => observeDataSource(opts.source!, loader)
|
||||||
|
: loader;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const hit = cache.get(key) as Entry<T> | undefined;
|
const hit = cache.get(key) as Entry<T> | undefined;
|
||||||
if (hit) {
|
if (hit) {
|
||||||
hit.lastAccess = now;
|
hit.lastAccess = now;
|
||||||
hit.loader = loader;
|
hit.loader = effectiveLoader;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 强制刷新:等待 loader 完成
|
// 强制刷新:等待 loader 完成
|
||||||
if (opts.force) {
|
if (opts.force) {
|
||||||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||||
if (ongoing) return ongoing;
|
if (ongoing) return ongoing;
|
||||||
const p = loader()
|
const p = effectiveLoader()
|
||||||
.then(value => {
|
.then(value => {
|
||||||
const t = Date.now();
|
const t = Date.now();
|
||||||
const next: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
|
const next: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader: effectiveLoader, lastAccess: t };
|
||||||
cache.set(key, next);
|
cache.set(key, next);
|
||||||
scheduleRefresh(key, next);
|
scheduleRefresh(key, next);
|
||||||
return value;
|
return value;
|
||||||
@@ -112,10 +118,10 @@ export async function cached<T>(key: string, loader: () => Promise<T>, opts: Cac
|
|||||||
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
const ongoing = inflight.get(key) as Promise<T> | undefined;
|
||||||
if (ongoing) return ongoing;
|
if (ongoing) return ongoing;
|
||||||
|
|
||||||
const p = loader()
|
const p = effectiveLoader()
|
||||||
.then(value => {
|
.then(value => {
|
||||||
const t = Date.now();
|
const t = Date.now();
|
||||||
const entry: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader, lastAccess: t };
|
const entry: Entry<T> = { value, freshAt: t, expiresAt: t + TTL_MS, loader: effectiveLoader, lastAccess: t };
|
||||||
cache.set(key, entry);
|
cache.set(key, entry);
|
||||||
scheduleRefresh(key, entry);
|
scheduleRefresh(key, entry);
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ app.get('/hydrogen/overview', async (c) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
|
return { kpi, top5, regions, monthly, customers, stations, availableYears, year };
|
||||||
}, { force });
|
}, { force, source: 'hydrogenDatabase' });
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -530,7 +530,7 @@ app.get('/hydrogen/daily', async (c) => {
|
|||||||
// 按日期降序返回
|
// 按日期降序返回
|
||||||
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
const result = ascDays.slice().sort((a, b) => b.date.localeCompare(a.date));
|
||||||
return result;
|
return result;
|
||||||
}, { force });
|
}, { force, source: 'hydrogenDatabase' });
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -615,7 +615,7 @@ app.get('/electric/overview', async (c) => {
|
|||||||
trend: trendArr,
|
trend: trendArr,
|
||||||
latestRecordAt,
|
latestRecordAt,
|
||||||
};
|
};
|
||||||
}, { force });
|
}, { force, source: 'electricDatabase' });
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -688,7 +688,7 @@ app.get('/electric/orders', async (c) => {
|
|||||||
totalFee: Math.round((Number(row.totalFee) || 0) * 100) / 100,
|
totalFee: Math.round((Number(row.totalFee) || 0) * 100) / 100,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
}, { source: 'electricDatabase' });
|
||||||
|
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
@@ -779,7 +779,7 @@ app.get('/electric/monthly', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return months;
|
return months;
|
||||||
}, { force });
|
}, { force, source: 'electricDatabase' });
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -862,7 +862,7 @@ app.get('/etc/overview', async (c) => {
|
|||||||
latestTransactionTime: record.latestTransactionTime ? String(record.latestTransactionTime) : null,
|
latestTransactionTime: record.latestTransactionTime ? String(record.latestTransactionTime) : null,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
}, { force });
|
}, { force, source: 'etcDatabase' });
|
||||||
|
|
||||||
return c.json(data);
|
return c.json(data);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
normalizeOneOsProtocol,
|
normalizeOneOsProtocol,
|
||||||
type OneOsProtocol,
|
type OneOsProtocol,
|
||||||
} from './source-policy.js';
|
} from './source-policy.js';
|
||||||
|
import { observeDataSource } from '../../source-telemetry.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -313,7 +314,7 @@ export async function fetchOneOsDailyMileage(
|
|||||||
} else {
|
} else {
|
||||||
let pending = inflight.get(cacheKey);
|
let pending = inflight.get(cacheKey);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = requestDate(date, protocolPriority).then(result => {
|
pending = observeDataSource('oneOsMileage', () => requestDate(date, protocolPriority)).then(result => {
|
||||||
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
|
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
|
||||||
cache.delete(cacheKey);
|
cache.delete(cacheKey);
|
||||||
if (ttl > 0) {
|
if (ttl > 0) {
|
||||||
@@ -372,12 +373,12 @@ export async function fetchOneOsMileageDates(
|
|||||||
].join(':');
|
].join(':');
|
||||||
let pending = rangeInflight.get(key);
|
let pending = rangeInflight.get(key);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = requestRange(
|
pending = observeDataSource('oneOsMileage', () => requestRange(
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
selectedPlates,
|
selectedPlates,
|
||||||
protocolPriority,
|
protocolPriority,
|
||||||
).finally(() => rangeInflight.delete(key));
|
)).finally(() => rangeInflight.delete(key));
|
||||||
rangeInflight.set(key, pending);
|
rangeInflight.set(key, pending);
|
||||||
}
|
}
|
||||||
const rowsByDate = await pending;
|
const rowsByDate = await pending;
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
clearDataSourceTelemetry,
|
||||||
|
getDataSourceTelemetry,
|
||||||
|
observeDataSource,
|
||||||
|
recordDataSourceOutcome,
|
||||||
|
} from './source-telemetry.js';
|
||||||
|
|
||||||
|
test('reports unobserved sources without treating them as failures', () => {
|
||||||
|
clearDataSourceTelemetry();
|
||||||
|
const report = getDataSourceTelemetry(new Date('2026-08-07T08:00:00Z'));
|
||||||
|
assert.ok(report.sources.every(source => source.state === 'unobserved'));
|
||||||
|
assert.ok(report.sources.every(source => source.failureRate === null));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publishes rolling outcomes and last success/failure timestamps', () => {
|
||||||
|
clearDataSourceTelemetry();
|
||||||
|
recordDataSourceOutcome('oneOsMileage', true, 1_000);
|
||||||
|
recordDataSourceOutcome('oneOsMileage', false, 2_000);
|
||||||
|
const source = getDataSourceTelemetry().sources.find(item => item.source === 'oneOsMileage');
|
||||||
|
assert.deepEqual(source && {
|
||||||
|
state: source.state,
|
||||||
|
attempts: source.attempts,
|
||||||
|
failures: source.failures,
|
||||||
|
failureRate: source.failureRate,
|
||||||
|
lastSuccessAt: source.lastSuccessAt,
|
||||||
|
lastFailureAt: source.lastFailureAt,
|
||||||
|
}, {
|
||||||
|
state: 'failing',
|
||||||
|
attempts: 2,
|
||||||
|
failures: 1,
|
||||||
|
failureRate: 0.5,
|
||||||
|
lastSuccessAt: '1970-01-01T00:00:01.000Z',
|
||||||
|
lastFailureAt: '1970-01-01T00:00:02.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('observes final operation outcomes without retaining error details', async () => {
|
||||||
|
clearDataSourceTelemetry();
|
||||||
|
await assert.rejects(observeDataSource('hydrogenDatabase', async () => {
|
||||||
|
throw new Error('sensitive connection detail');
|
||||||
|
}));
|
||||||
|
const serialized = JSON.stringify(getDataSourceTelemetry());
|
||||||
|
assert.equal(serialized.includes('sensitive connection detail'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps only the latest 100 outcomes', () => {
|
||||||
|
clearDataSourceTelemetry();
|
||||||
|
recordDataSourceOutcome('etcDatabase', false, 1);
|
||||||
|
for (let index = 0; index < 100; index += 1) {
|
||||||
|
recordDataSourceOutcome('etcDatabase', true, index + 2);
|
||||||
|
}
|
||||||
|
const source = getDataSourceTelemetry().sources.find(item => item.source === 'etcDatabase');
|
||||||
|
assert.equal(source?.attempts, 100);
|
||||||
|
assert.equal(source?.failures, 0);
|
||||||
|
assert.equal(source?.failureRate, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
export const DATA_SOURCE_IDS = [
|
||||||
|
'oneOsMileage',
|
||||||
|
'hydrogenDatabase',
|
||||||
|
'electricDatabase',
|
||||||
|
'etcDatabase',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DataSourceId = typeof DATA_SOURCE_IDS[number];
|
||||||
|
|
||||||
|
interface Outcome {
|
||||||
|
success: boolean;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_OUTCOMES = 100;
|
||||||
|
const outcomes = new Map<DataSourceId, Outcome[]>();
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
function lastMatching(entries: Outcome[], success: boolean): Outcome | undefined {
|
||||||
|
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||||
|
if (entries[index].success === success) return entries[index];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordDataSourceOutcome(
|
||||||
|
source: DataSourceId,
|
||||||
|
success: boolean,
|
||||||
|
at = Date.now(),
|
||||||
|
): void {
|
||||||
|
const entries = outcomes.get(source) || [];
|
||||||
|
entries.push({ success, at });
|
||||||
|
if (entries.length > MAX_OUTCOMES) entries.splice(0, entries.length - MAX_OUTCOMES);
|
||||||
|
outcomes.set(source, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function observeDataSource<T>(
|
||||||
|
source: DataSourceId,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
recordDataSourceOutcome(source, true);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
recordDataSourceOutcome(source, false);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataSourceTelemetry(now = new Date()) {
|
||||||
|
return {
|
||||||
|
semantics: 'observed-requests' as const,
|
||||||
|
windowSize: MAX_OUTCOMES,
|
||||||
|
processStartedAt: startedAt,
|
||||||
|
checkedAt: now.toISOString(),
|
||||||
|
sources: DATA_SOURCE_IDS.map(source => {
|
||||||
|
const entries = outcomes.get(source) || [];
|
||||||
|
const failures = entries.filter(entry => !entry.success).length;
|
||||||
|
const last = entries.at(-1);
|
||||||
|
const lastSuccess = lastMatching(entries, true);
|
||||||
|
const lastFailure = lastMatching(entries, false);
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
state: !last ? 'unobserved' as const : last.success ? 'available' as const : 'failing' as const,
|
||||||
|
attempts: entries.length,
|
||||||
|
failures,
|
||||||
|
failureRate: entries.length > 0 ? Math.round(failures / entries.length * 10_000) / 10_000 : null,
|
||||||
|
lastSuccessAt: lastSuccess ? new Date(lastSuccess.at).toISOString() : null,
|
||||||
|
lastFailureAt: lastFailure ? new Date(lastFailure.at).toISOString() : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDataSourceTelemetry(): void {
|
||||||
|
outcomes.clear();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user