/** * 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(); 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).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 { 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:不拋錯,不影響主流程 } }