220 lines
7.7 KiB
JavaScript
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,
|
|
},
|
|
};
|
|
}
|