ea1c0571c1
- registry 新增 POST /analytics/record:ANALYTICS_KV 計數器(stats:{hash_id}:{version})
為唯一真相源,衍生值(success_rate/avg_duration_ms/call_count)回填 comp: 記錄
——查詢讀取端讀哪就寫哪,不開第二真相源。KV 無 CAS,誠實標註非原子。
- cypher-executor execution-evaluator 從 stub 改真實作:執行收尾(/cypher/execute
成功與 ExecutionError 路徑+webhook 路徑)對 trace 裡每顆 Component 節點
fire-and-forget 回寫,waitUntil 包、不增加執行同步延遲(仿 recordRecipeStats 慣例)。
成敗判定=trace error 或 output.success===false(makeHttpRunner 非 2xx 不 throw)。
- registry 位置沿 search-nodes 慣例:REGISTRY_BASE_URL 覆蓋,未設走 wasmWorkerUrl。
- 新增單測 13 個全綠;本地雙 wrangler dev 端到端實測:http_request 跑 5 次
(3 成功+2 失敗)→ success_rate 1→0.6、call_count 0→5,/cypher/search 同步可見。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
97 lines
3.4 KiB
TypeScript
97 lines
3.4 KiB
TypeScript
/**
|
||
* Execution Analytics — 零件執行後的統計回寫
|
||
*
|
||
* SDD: system-dev/docs/3-specs/arcrun-core-mvp/design.md「執行統計設計」
|
||
* 執行完成處(cypher-handlers / webhook-handlers 收尾)對本次用到的**每顆零件**
|
||
* fire-and-forget POST registry `/analytics/record`——統計失敗不影響執行、不增加同步延遲
|
||
* (呼叫端一律用 waitUntil 包,仿 recordRecipeStats / recordTelemetry 既有慣例)。
|
||
*
|
||
* 每顆零件的成敗判定來源=執行 trace(per-node):
|
||
* - trace step 有 `error` → 失敗(runner throw)
|
||
* - output 是物件且 `success === false` → 失敗(makeHttpRunner 對非 2xx 不 throw,回這種)
|
||
* - 其餘 → 成功
|
||
* FOREACH 重複執行同一節點 → trace 有幾筆就記幾次(每次真實執行都算一次樣本)。
|
||
*/
|
||
|
||
import type { GraphNode, TraceStep } from '../types';
|
||
import { wasmWorkerUrl } from '../lib/component-loader';
|
||
|
||
/** 本模組需要的環境子集(傳整份 Bindings 也相容,仿 SearchNodesEnv 慣例)。 */
|
||
export type AnalyticsEnv = {
|
||
WORKER_SUBDOMAIN?: string;
|
||
/** registry 位置覆蓋(可選;本地 wrangler dev / self-hosted 用)。未設 → wasmWorkerUrl('registry', WORKER_SUBDOMAIN)。 */
|
||
REGISTRY_BASE_URL?: string;
|
||
};
|
||
|
||
export interface ComponentVerdict {
|
||
component_id: string;
|
||
success: boolean;
|
||
duration_ms: number;
|
||
}
|
||
|
||
/** 從執行 trace 導出每顆零件的成敗(只算 type=Component 且有 componentId 的節點)。 */
|
||
export function componentVerdictsFromTrace(
|
||
nodes: GraphNode[],
|
||
trace: TraceStep[],
|
||
): ComponentVerdict[] {
|
||
const componentByNodeId = new Map<string, string>();
|
||
for (const n of nodes) {
|
||
if (n.type === 'Component' && n.componentId) componentByNodeId.set(n.id, n.componentId);
|
||
}
|
||
|
||
const verdicts: ComponentVerdict[] = [];
|
||
for (const step of trace) {
|
||
const componentId = componentByNodeId.get(step.nodeId);
|
||
if (!componentId) continue;
|
||
|
||
const out = step.output;
|
||
const outputSaysFailed =
|
||
typeof out === 'object' && out !== null && !Array.isArray(out) &&
|
||
(out as Record<string, unknown>).success === false;
|
||
|
||
verdicts.push({
|
||
component_id: componentId,
|
||
success: !step.error && !outputSaysFailed,
|
||
duration_ms: Math.max(0, Number(step.duration_ms) || 0),
|
||
});
|
||
}
|
||
return verdicts;
|
||
}
|
||
|
||
/**
|
||
* 對本次執行用到的每顆零件回寫統計到 registry(design.md「Analytics Record」)。
|
||
* 永不 throw;呼叫端用 waitUntil 包,不阻擋主流程。
|
||
*/
|
||
export async function recordComponentStats(
|
||
env: AnalyticsEnv,
|
||
nodes: GraphNode[],
|
||
trace: TraceStep[],
|
||
): Promise<void> {
|
||
try {
|
||
const base = (
|
||
env.REGISTRY_BASE_URL ??
|
||
(env.WORKER_SUBDOMAIN ? wasmWorkerUrl('registry', env.WORKER_SUBDOMAIN) : undefined)
|
||
)?.replace(/\/$/, '');
|
||
if (!base) return;
|
||
|
||
const verdicts = componentVerdictsFromTrace(nodes, trace);
|
||
if (verdicts.length === 0) return;
|
||
|
||
await Promise.all(
|
||
verdicts.map(v =>
|
||
fetch(`${base}/analytics/record`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
canonical_id: v.component_id,
|
||
success: v.success,
|
||
duration_ms: v.duration_ms,
|
||
}),
|
||
}).catch(() => undefined), // 統計失敗不影響執行
|
||
),
|
||
);
|
||
} catch {
|
||
// fire-and-forget:不拋錯,不影響主流程
|
||
}
|
||
}
|