verae-zapier-simulator/src/monitors.js

220 lines
7.7 KiB
JavaScript

/**
* Scan a trace for assumption breaks, delays, failures, recoveries,
* and suggest changes before the Zapier app goes live.
* @module monitors
*/
const ASSUMPTIONS = [
{
id: 'zapier-no-nats',
title: 'Zapier never publishes or subscribes to NATS',
check(events) {
const bad = events.filter(
(e) =>
e.module === 'zapier-platform-app' &&
(String(e.address).startsWith('verae.') || String(e.hop).includes('nats')),
);
return bad.length
? { ok: false, detail: `Zapier hop used NATS address ${bad[0].address}` }
: { ok: true, detail: 'Zapier hops are HTTPS only' };
},
},
{
id: 'chain-no-bytes',
title: 'Main chain never receives file bytes or private metadata',
check(events) {
const chain = events.filter((e) => e.module === 'verae-chain-client' && e.payload);
for (const e of chain) {
const p = e.payload;
if (p.contentBase64 || p.files || p.privateMetadata) {
return { ok: false, detail: `chain payload included ${Object.keys(p).join(',')}` };
}
}
return { ok: true, detail: 'chain payloads are hash/root only' };
},
},
{
id: 'bloom-silence',
title: 'Bloom miss produces no archive.reply packet',
check(events) {
const leaks = events.filter(
(e) => e.module.startsWith('archive-') || e.module.startsWith('tree-node'),
).filter((e) => e.status === 'silent' && e.address.includes('archive.reply'));
return leaks.length
? { ok: false, detail: 'silent hop still addressed a reply subject' }
: { ok: true, detail: 'misses logged as silent with no reply address' };
},
},
{
id: 'archive-query-broadcast',
title: 'archive.query is broadcast (not a shared queue group)',
check(events) {
const queries = events.filter((e) => e.address === 'verae.archive.query');
const replies = events.filter((e) => String(e.address).startsWith('verae.archive.reply.'));
if (!queries.length) return { ok: true, detail: 'no archive query this run' };
return { ok: true, detail: `${queries.length} query hop(s), ${replies.length} reply hop(s)` };
},
},
];
const DELAY_MS = 200;
/**
* @param {object[]} events
* @param {object} [ctx]
*/
export function analyze(events, ctx = {}) {
const findings = [];
const suggestions = [];
for (const a of ASSUMPTIONS) {
const r = a.check(events);
findings.push({
kind: 'assumption',
id: a.id,
title: a.title,
ok: r.ok,
detail: r.detail,
});
}
const errors = events.filter((e) => e.status === 'error');
const retries = events.filter((e) => e.status === 'retry');
const delays = events.filter((e) => e.status === 'delay' || (e.durationMs || 0) >= DELAY_MS);
for (const e of errors) {
findings.push({
kind: 'error',
id: `err-${e.seq}`,
title: `${e.module} ${e.hop} failed`,
ok: false,
detail: e.note || e.address,
});
}
for (const e of delays) {
findings.push({
kind: 'delay',
id: `delay-${e.seq}`,
title: `${e.module} took ${e.durationMs || e.tMs}ms`,
ok: false,
detail: e.note || 'exceeds 200ms hop budget used for Zapier wait steps',
});
}
if (retries.length) {
findings.push({
kind: 'recovery',
id: 'retries',
title: `${retries.length} recovery retry hop(s)`,
ok: true,
detail: retries.map((e) => `${e.module}:${e.hop}`).join(', '),
});
}
const last = events[events.length - 1];
if (last?.status === 'error' && retries.length === 0) {
suggestions.push({
id: 'add-retry',
severity: 'high',
text: 'Add a single retry on jobs.watch / jobs.events drop before returning pending to Zapier. Zapier will otherwise mark the step failed with no jobId recovery path.',
});
}
const lookup = events.find((e) => e.hop === 'hash_lookup' || e.module === 'hash-lookup');
const treeQuery = events.some((e) => e.payload?.kinds?.includes?.('tree') || e.hop === 'tree_query');
const chainMiss = events.some(
(e) => e.module === 'verae-chain-client' && e.hop === 'lookup' && e.status === 'silent',
);
if (chainMiss && !treeQuery) {
suggestions.push({
id: 'tree-lookup-action',
severity: 'high',
text: 'Central chain miss for a SHA-256 that may live only in a bulk Merkle summary. Add Zapier search “Find Hash (tree nodes)” that sets includeTree=true so middleware broadcasts verae.archive.query kinds=["tree"].',
});
}
const puts = events.filter((e) => e.address === 'verae.archive.put');
const queries = events.filter((e) => e.address === 'verae.archive.query');
if (puts.length && !queries.length && ctx.includeAttached) {
suggestions.push({
id: 'query-after-put',
severity: 'high',
text: 'Archive puts ran but wait path never published verae.archive.query. Wire aggregator into createTimestampAndWait when includeAttached is true.',
});
}
if (puts.length && !ctx.includeAttached && ctx.action !== 'batch_timestamp') {
suggestions.push({
id: 'include-attached-default',
severity: 'medium',
text: 'Metadata/files were stored off-chain but the Zapier step did not request includeAttached. Document the flag and default it on for “Create Timestamp and Wait”.',
});
}
const billing = events.find((e) => e.payload?.status === 402 || e.note?.includes?.('402'));
if (billing) {
suggestions.push({
id: '402-upgrade-url',
severity: 'medium',
text: 'Map HTTP 402 to Zapier QuotaExceeded and always include details.upgradeUrl from zappier-edge so the user can reload credit without leaving the Zap editor.',
});
}
const waitPending = events.some((e) => e.payload?.status === 'pending' && e.hop === 'wait');
if (waitPending) {
suggestions.push({
id: 'wait-timeout',
severity: 'medium',
text: 'Wait returned pending. Pair every Create Timestamp (async) Zap with the Timestamp Completed REST Hook, or raise WAIT_TIMEOUT_MS only after measuring chain poll interval.',
});
}
const natsFromZapier = events.filter(
(e) => e.module === 'zapier-platform-app' && String(e.address).startsWith('verae.'),
);
if (natsFromZapier.length) {
suggestions.push({
id: 'never-bind-nats',
severity: 'high',
text: 'Zapier cloud must not reach NATS. Keep NS1 nats-server on 127.0.0.1:4222; only middleware workers subscribe.',
});
}
if (ctx.action === 'batch_timestamp') {
suggestions.push({
id: 'batch-docs',
severity: 'low',
text: 'User docs must say batch items are not individually itemized on the main chain. Lookup of a member hash requires the tree-node search, not Find Timestamp by SHA256 alone.',
});
}
const silentAll =
queries.length > 0 &&
!events.some((e) => String(e.address).startsWith('verae.archive.reply.') && e.status === 'ok');
if (silentAll && (ctx.includeAttached || ctx.includeTree)) {
findings.push({
kind: 'error',
id: 'all-archives-silent',
title: 'All archives/tree nodes silent during query',
ok: false,
detail: 'Aggregator completed with zero replies. Distinguish “hash unknown” from “archives unreachable”.',
});
suggestions.push({
id: 'archive-quorum',
severity: 'high',
text: 'Treat “zero replies + known puts” as archive outage, not a miss. Require at least one reply OR an explicit bloom-miss count from a live node heartbeat.',
});
}
const ok = findings.filter((f) => f.ok === false).length === 0;
return {
ok,
findings,
suggestions,
stats: {
hops: events.length,
errors: errors.length,
retries: retries.length,
delays: delays.length,
natsHops: events.filter((e) => String(e.address).startsWith('verae.')).length,
},
};
}