From 4f56eccc2d49e5d75cfa0e30f8ec1b484e2bf716 Mon Sep 17 00:00:00 2001 From: Jose Javier Alvarez Rodriguez Date: Fri, 14 Aug 2026 12:06:33 +0000 Subject: [PATCH 1/2] logging improvements --- packages/guardian-coordinator/README.md | 37 ++++ .../guardian-coordinator/log-contract.json | 12 ++ packages/guardian-coordinator/package.json | 3 +- packages/guardian-coordinator/src/cli.ts | 33 +++- packages/guardian-coordinator/src/index.ts | 81 ++++++-- .../tests/coordinator.test.ts | 181 +++++++++++++++--- .../tests/log-contract.test.ts | 131 +++++++++++++ 7 files changed, 435 insertions(+), 43 deletions(-) create mode 100644 packages/guardian-coordinator/log-contract.json create mode 100644 packages/guardian-coordinator/tests/log-contract.test.ts diff --git a/packages/guardian-coordinator/README.md b/packages/guardian-coordinator/README.md index 911f4d9..5cdc982 100644 --- a/packages/guardian-coordinator/README.md +++ b/packages/guardian-coordinator/README.md @@ -41,6 +41,8 @@ Optional env: - `POLL_INTERVAL_MS` default `5000` - `PAGE_SIZE` default `100` +- `REQUEST_TIMEOUT_MS` default `10000`; per-request timeout for grunt-api calls. A hung + request aborts and surfaces as a failed poll instead of freezing the loop. - `CHAIN_IDS` comma-separated signing-request filter - `FACILITIES` comma-separated signing-request filter (facility, or whitelist book for `request_whitelisting`) @@ -72,6 +74,41 @@ Optional env: scan per request contract — need longer than the default. - `GUARDIAN_SWAP_PRICE_TOLERANCE_BPS` default `1` +## Lifecycle and heartbeat lines + +The CLI emits three JSON lines of its own, shaped alike: + +```json +{"level":"info","event":"guardian.startup","build":"1.2.3","chains":[1]} +{"level":"info","event":"guardian.heartbeat","ok":true,"fetched":0,"signed":0,"skipped":0,"failed":0,"durationMs":12} +{"level":"fatal","event":"guardian.fatal","err":"COORDINATOR_BASE_URL is required"} +``` + +- `guardian.startup` — stdout, once, after config and signer construction succeed. + It means the config parsed and the signer was constructed — not that the signer or + RPCs were exercised. The first heartbeat is the first proof of live work. +- `guardian.heartbeat` — stdout, one per poll cycle, including cycles whose poll + failed (`ok: false`); its absence means the loop is dead or wedged. The gap between + heartbeats is poll duration + `POLL_INTERVAL_MS`, not just the interval: a busy + cycle (many requests × `GUARDIAN_SIGN_TIMEOUT_MS`, plus event scans) legitimately + stretches it. Size any absence alert to the worst-case cycle duration, not to the + poll interval, or a burst of signing work will page you for nothing. +- `guardian.fatal` — stderr, right before the process exits with code 1, whether the + failure happened at boot (bad config) or later. Carries a `stack` field, appended + after `err`, when the thrown value has one. + +The serialized shapes are a monitoring contract pinned by tests in +`tests/coordinator.test.ts`; changing them breaks downstream alerting. + +`log-contract.json` at the package root is the machine-readable version of this +contract: one regex per guaranteed line (the three lines above plus the +`submitted guardian signature for` / `guardian coordinator poll failed:` / +`failed guardian signing request` / `skipping malformed guardian signing request:` +prefixes). Monitoring should build its rules from that file. +`tests/log-contract.test.ts` verifies it in both directions — every pattern is +emitted by a real code path, and every emitted line matches a pattern — so +adding, removing, or rewording a log line without updating the contract fails CI. + For `remote_http`, the coordinator sends: ```json diff --git a/packages/guardian-coordinator/log-contract.json b/packages/guardian-coordinator/log-contract.json new file mode 100644 index 0000000..df1d692 --- /dev/null +++ b/packages/guardian-coordinator/log-contract.json @@ -0,0 +1,12 @@ +{ + "description": "Log lines the guardian-coordinator guarantees on stdout/stderr. Regex-based monitoring must build its rules from these patterns. tests/log-contract.test.ts verifies both directions: every pattern is emitted by a real code path, and every emitted line matches a pattern. Changing a pattern is a breaking change to downstream alerting.", + "patterns": { + "startup": "^\\{\"level\":\"info\",\"event\":\"guardian\\.startup\",\"build\":", + "heartbeat": "^\\{\"level\":\"info\",\"event\":\"guardian\\.heartbeat\",\"ok\":(true|false),\"fetched\":\\d+,\"signed\":\\d+,\"skipped\":\\d+,\"failed\":\\d+,\"durationMs\":\\d+\\}$", + "fatal": "^\\{\"level\":\"fatal\",\"event\":\"guardian\\.fatal\",\"err\":", + "submitted": "^submitted guardian signature for ", + "poll_failed": "^guardian coordinator poll failed: ", + "signing_failed": "^failed guardian signing request ", + "malformed_request": "^skipping malformed guardian signing request: " + } +} diff --git a/packages/guardian-coordinator/package.json b/packages/guardian-coordinator/package.json index 9fe7c34..4c396c8 100644 --- a/packages/guardian-coordinator/package.json +++ b/packages/guardian-coordinator/package.json @@ -21,7 +21,8 @@ "files": [ "dist", "src", - "README.md" + "README.md", + "log-contract.json" ], "publishConfig": { "access": "public", diff --git a/packages/guardian-coordinator/src/cli.ts b/packages/guardian-coordinator/src/cli.ts index f92c48c..b88a556 100644 --- a/packages/guardian-coordinator/src/cli.ts +++ b/packages/guardian-coordinator/src/cli.ts @@ -477,16 +477,39 @@ function nonNegativeInt(value: string | undefined, fallback: number): number { return parsed; } -async function main(): Promise { - await runGuardianCoordinator({ - ...loadCoordinatorConfig(process.env), - guardian: buildGuardianFromEnv(process.env), +/** + * Fork-owned lifecycle lines, shaped like the heartbeat. Their serialized + * form is a monitoring contract pinned by tests — do not change casually. + */ +export function startupLine(guardian: CoordinatorGuardian): string { + return JSON.stringify({ + level: "info", + event: "guardian.startup", + build: guardian.metadata.build, + chains: guardian.metadata.supportedChains, + }); +} + +export function fatalLine(error: unknown): string { + return JSON.stringify({ + level: "fatal", + event: "guardian.fatal", + err: error instanceof Error ? error.message : String(error), + // Appended last so the prefix monitoring matches on stays stable. + ...(error instanceof Error && error.stack ? { stack: error.stack } : {}), }); } +async function main(): Promise { + const config = loadCoordinatorConfig(process.env); + const guardian = buildGuardianFromEnv(process.env); + console.log(startupLine(guardian)); + await runGuardianCoordinator({ ...config, guardian }); +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); + console.error(fatalLine(error)); process.exitCode = 1; }); } diff --git a/packages/guardian-coordinator/src/index.ts b/packages/guardian-coordinator/src/index.ts index 5339d41..d38ebf6 100644 --- a/packages/guardian-coordinator/src/index.ts +++ b/packages/guardian-coordinator/src/index.ts @@ -19,6 +19,7 @@ import { z } from "zod"; const DEFAULT_POLL_INTERVAL_MS = 5_000; const DEFAULT_PAGE_SIZE = 100; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; const TOKEN_ID = "guardian-coordinator"; const TOKEN_INFO = { tokenId: TOKEN_ID, @@ -52,6 +53,7 @@ export type CoordinatorConnectionConfig = { pageSize: number; chainIds?: Set; facilities?: Set; + requestTimeoutMs?: number; }; export type GuardianCoordinatorOptions = CoordinatorConnectionConfig & { @@ -98,28 +100,64 @@ export function loadCoordinatorConfig( coordinatorApiKey: required(env, "COORDINATOR_API_KEY"), pollIntervalMs: positiveInt(env.POLL_INTERVAL_MS, DEFAULT_POLL_INTERVAL_MS), pageSize: positiveInt(env.PAGE_SIZE, DEFAULT_PAGE_SIZE), + requestTimeoutMs: positiveInt(env.REQUEST_TIMEOUT_MS, DEFAULT_REQUEST_TIMEOUT_MS), chainIds: numberSet(env.CHAIN_IDS), facilities: stringSet(env.FACILITIES), }; } +export type PollCycleStats = { + fetched: number; + signed: number; + skipped: number; + failed: number; +}; + export async function runGuardianCoordinator(options: GuardianCoordinatorOptions): Promise { - const logger = options.logger ?? console; for (;;) { - try { - await runGuardianCoordinatorOnce(options); - } catch (error) { - logger.error( - `guardian coordinator poll failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } + await runGuardianCoordinatorCycle(options); await sleep(options.pollIntervalMs); } } +/** + * One poll cycle plus its heartbeat line. The heartbeat is emitted even + * when the poll throws (`ok: false`): it signals the loop is alive, not + * that it succeeded, so a missing-heartbeat alert fires only when the + * process is dead or wedged. The serialized shape is a monitoring + * contract pinned by a test — do not change it casually. + */ +export async function runGuardianCoordinatorCycle( + options: GuardianCoordinatorOptions, +): Promise { + const logger = options.logger ?? console; + const startedAt = Date.now(); + const stats: PollCycleStats = { fetched: 0, signed: 0, skipped: 0, failed: 0 }; + let ok = true; + try { + await runGuardianCoordinatorOnce(options, stats); + } catch (error) { + ok = false; + logger.error( + `guardian coordinator poll failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + logger.log( + JSON.stringify({ + level: "info", + event: "guardian.heartbeat", + ok, + ...stats, + durationMs: Date.now() - startedAt, + }), + ); + return stats; +} + export async function runGuardianCoordinatorOnce( options: GuardianCoordinatorOptions, -): Promise { + stats: PollCycleStats = { fetched: 0, signed: 0, skipped: 0, failed: 0 }, +): Promise { const fetcher = options.fetcher ?? fetch; const logger = options.logger ?? console; @@ -133,20 +171,27 @@ export async function runGuardianCoordinatorOnce( if (filter.facility !== undefined) url.searchParams.set("facility", filter.facility); const requests = zSigningRequestPage.parse( - await requestJson(fetcher, "GET", url.toString(), { - headers: { "x-api-key": options.coordinatorApiKey }, - }), + await requestJson( + fetcher, + "GET", + url.toString(), + { headers: { "x-api-key": options.coordinatorApiKey } }, + options.requestTimeoutMs, + ), ); + stats.fetched += requests.items.length; for (const item of requests.items) { const row = zSigningRequest.safeParse(item); if (!row.success) { + stats.failed++; logger.error(`skipping malformed guardian signing request: ${row.error.message}`); continue; } const request = row.data; if (request.mySubmission !== null) { + stats.skipped++; continue; } @@ -167,6 +212,7 @@ export async function runGuardianCoordinatorOnce( (options.chainIds && !options.chainIds.has(parsed.body.chainId)) || (options.facilities && !options.facilities.has(target.address.toLowerCase())) ) { + stats.skipped++; continue; } @@ -182,9 +228,12 @@ export async function runGuardianCoordinatorOnce( }, body: JSON.stringify({ chainId: parsed.body.chainId, signature: signed.signature }), }, + options.requestTimeoutMs, ); + stats.signed++; logger.log(`submitted guardian signature for ${request.id}`); } catch (error) { + stats.failed++; logger.error( `failed guardian signing request ${request.id}: ${ error instanceof Error ? error.message : String(error) @@ -196,6 +245,7 @@ export async function runGuardianCoordinatorOnce( if (requests.items.length === 0 || page * requests.pageSize >= requests.total) break; } } + return stats; } async function signWithGuardian( @@ -276,8 +326,13 @@ async function requestJson( method: string, url: string, init: RequestInit = {}, + timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS, ): Promise { - const response = await fetcher(url, { ...init, method }); + const response = await fetcher(url, { + ...init, + method, + signal: AbortSignal.timeout(timeoutMs), + }); const text = await response.text(); let body: unknown = null; if (text.length > 0) { diff --git a/packages/guardian-coordinator/tests/coordinator.test.ts b/packages/guardian-coordinator/tests/coordinator.test.ts index 6294b13..7f3d591 100644 --- a/packages/guardian-coordinator/tests/coordinator.test.ts +++ b/packages/guardian-coordinator/tests/coordinator.test.ts @@ -6,10 +6,15 @@ import { privateKeyToAccount } from "viem/accounts"; import { loadCoordinatorConfig, + runGuardianCoordinatorCycle, runGuardianCoordinatorOnce, type GuardianCoordinatorOptions, } from "../src/index.js"; -import { buildGuardianFromEnv as buildCliGuardianFromEnv } from "../src/cli.js"; +import { + buildGuardianFromEnv as buildCliGuardianFromEnv, + fatalLine, + startupLine, +} from "../src/cli.js"; import { awsKmsSignTypedData, gcpKmsSignTypedData } from "../src/kms-signers.js"; const REQUEST_ID = "550e8400-e29b-41d4-a716-446655440000"; @@ -86,6 +91,23 @@ const quiet = { error() {}, }; +function recordingLogger(): { + logger: GuardianCoordinatorOptions["logger"]; + logs: string[]; + errors: string[]; +} { + const logs: string[] = []; + const errors: string[] = []; + return { + logger: { + log: (...args: unknown[]) => void logs.push(args.map(String).join(" ")), + error: (...args: unknown[]) => void errors.push(args.map(String).join(" ")), + }, + logs, + errors, + }; +} + afterEach(() => { vi.unstubAllGlobals(); }); @@ -125,7 +147,12 @@ describe("guardian coordinator", () => { throw new Error(`unexpected call ${url}`); }; - await expect(runGuardianCoordinatorOnce(options)).resolves.toBeUndefined(); + await expect(runGuardianCoordinatorOnce(options)).resolves.toEqual({ + fetched: 1, + signed: 1, + skipped: 0, + failed: 0, + }); expect(signCalls).toHaveLength(1); expect(submitBodies).toEqual([{ chainId: 1, signature: SIGNATURE }]); }); @@ -210,21 +237,21 @@ describe("guardian coordinator", () => { return json({ status: "accepted", quorumReached: true, submissionCount: 1 }); }; - await expect(runGuardianCoordinatorOnce(options)).resolves.toBeUndefined(); + await expect(runGuardianCoordinatorOnce(options)).resolves.toEqual({ + fetched: 4, + signed: 4, + skipped: 0, + failed: 0, + }); expect(seen).toEqual(["set_request", "set_fund", "swap", "request_whitelisting"]); expect(submitted).toHaveLength(4); }); it("reports request whitelisting rows when the optional signer is unavailable", async () => { - const errors: string[] = []; + const { logger, errors } = recordingLogger(); const calls: string[] = []; const options = optionsFor({ signRequestWhitelisting: undefined }); - options.logger = { - log() {}, - error(message) { - errors.push(String(message)); - }, - }; + options.logger = logger; options.fetcher = async (input) => { const url = String(input); calls.push(url); @@ -246,7 +273,12 @@ describe("guardian coordinator", () => { throw new Error(`unexpected call ${url}`); }; - await expect(runGuardianCoordinatorOnce(options)).resolves.toBeUndefined(); + await expect(runGuardianCoordinatorOnce(options)).resolves.toEqual({ + fetched: 1, + signed: 0, + skipped: 0, + failed: 1, + }); expect(calls).toHaveLength(1); expect( errors.some((message) => @@ -256,7 +288,7 @@ describe("guardian coordinator", () => { }); it("skips malformed rows without dropping valid rows in the same page", async () => { - const errors: string[] = []; + const { logger, errors } = recordingLogger(); const signCalls: string[] = []; const submitBodies: unknown[] = []; const options = optionsFor({ @@ -265,12 +297,7 @@ describe("guardian coordinator", () => { return Result.ok(SIGNING_SUCCESS); }, }); - options.logger = { - log() {}, - error(message) { - errors.push(String(message)); - }, - }; + options.logger = logger; options.fetcher = async (input, init) => { const url = String(input); if (url.startsWith("http://coordinator.test/v1/guardian/signing-requests?")) { @@ -288,7 +315,12 @@ describe("guardian coordinator", () => { throw new Error(`unexpected call ${url}`); }; - await expect(runGuardianCoordinatorOnce(options)).resolves.toBeUndefined(); + await expect(runGuardianCoordinatorOnce(options)).resolves.toEqual({ + fetched: 2, + signed: 1, + skipped: 0, + failed: 1, + }); expect(errors.some((message) => message.includes("skipping malformed"))).toBe(true); expect(signCalls).toHaveLength(1); expect(submitBodies).toEqual([{ chainId: 1, signature: SIGNATURE }]); @@ -304,7 +336,12 @@ describe("guardian coordinator", () => { return json({ total: 0, page: 1, pageSize: 100, items: [] }); }; - await expect(runGuardianCoordinatorOnce(options)).resolves.toBeUndefined(); + await expect(runGuardianCoordinatorOnce(options)).resolves.toEqual({ + fetched: 0, + signed: 0, + skipped: 0, + failed: 0, + }); expect( urls.map((url) => { const params = new URL(url).searchParams; @@ -328,7 +365,12 @@ describe("guardian coordinator", () => { return json({ total: 0, page: 1, pageSize: 100, items: [] }); }; - await expect(runGuardianCoordinatorOnce(options)).resolves.toBeUndefined(); + await expect(runGuardianCoordinatorOnce(options)).resolves.toEqual({ + fetched: 0, + signed: 0, + skipped: 0, + failed: 0, + }); expect(urls).toHaveLength(1); const params = new URL(urls[0]!).searchParams; expect(params.has("chainId")).toBe(false); @@ -353,7 +395,12 @@ describe("guardian coordinator", () => { }); }; - await expect(runGuardianCoordinatorOnce(options)).resolves.toBeUndefined(); + await expect(runGuardianCoordinatorOnce(options)).resolves.toEqual({ + fetched: 1, + signed: 0, + skipped: 1, + failed: 0, + }); expect(signCalls).toHaveLength(0); }); @@ -372,7 +419,12 @@ describe("guardian coordinator", () => { throw new Error(`unexpected call ${url}`); }; - await expect(runGuardianCoordinatorOnce(options)).resolves.toBeUndefined(); + await expect(runGuardianCoordinatorOnce(options)).resolves.toEqual({ + fetched: 1, + signed: 0, + skipped: 0, + failed: 1, + }); expect(calls).toHaveLength(1); }); @@ -399,7 +451,12 @@ describe("guardian coordinator", () => { throw new Error(`unexpected call ${url}`); }; - await expect(runGuardianCoordinatorOnce(options)).resolves.toBeUndefined(); + await expect(runGuardianCoordinatorOnce(options)).resolves.toEqual({ + fetched: 1, + signed: 0, + skipped: 0, + failed: 1, + }); expect(signCalls).toHaveLength(0); expect(calls).toHaveLength(1); }); @@ -416,7 +473,15 @@ describe("guardian coordinator", () => { coordinatorBaseUrl: "https://coordinator.test", chainIds: new Set([1, 8453]), facilities: new Set([FACILITY]), + requestTimeoutMs: 10_000, }); + expect( + loadCoordinatorConfig({ + COORDINATOR_BASE_URL: "https://coordinator.test/", + COORDINATOR_API_KEY: "guardian-key", + REQUEST_TIMEOUT_MS: "2500", + }).requestTimeoutMs, + ).toBe(2_500); }); it("ignores empty comma-only coordinator filters", () => { @@ -668,6 +733,74 @@ describe("guardian coordinator", () => { /positive integer/, ); }); + + it("emits a heartbeat line with cycle stats after a successful poll", async () => { + const { logger, logs } = recordingLogger(); + const options = optionsFor(); + options.logger = logger; + options.fetcher = async (input, init) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + if (String(input).startsWith("http://coordinator.test/v1/guardian/signing-requests?")) { + return json({ total: 1, page: 1, pageSize: 100, items: [SIGNING_REQUEST] }); + } + return json({ status: "accepted", quorumReached: true, submissionCount: 1 }); + }; + + await runGuardianCoordinatorCycle(options); + + // The exact serialized shape is the contract regex-based monitoring + // matches on; changing it is a breaking change to alerting. + const line = logs.find((l) => l.includes("guardian.heartbeat")); + expect(line).toMatch( + /^\{"level":"info","event":"guardian\.heartbeat","ok":true,"fetched":1,"signed":1,"skipped":0,"failed":0,"durationMs":\d+\}$/, + ); + }); + + it("emits the heartbeat with ok:false when the poll itself fails", async () => { + const { logger, logs, errors } = recordingLogger(); + const options = optionsFor(); + options.logger = logger; + options.fetcher = async () => { + throw new TypeError("fetch failed"); + }; + + await runGuardianCoordinatorCycle(options); + + expect(errors.some((m) => m.includes("guardian coordinator poll failed"))).toBe(true); + expect(logs.find((l) => l.includes("guardian.heartbeat"))).toMatch(/"ok":false/); + }); + + it("pins the startup and fatal lifecycle lines regex-based monitoring depends on", () => { + const guardian = buildCliGuardianFromEnv({ + ...CLI_ENV, + GUARDIAN_SIGNER_KEY: `0x${"11".repeat(32)}`, + BUILD_ID: "1.2.3", + }); + expect(startupLine(guardian)).toBe( + '{"level":"info","event":"guardian.startup","build":"1.2.3","chains":[1]}', + ); + expect(fatalLine(new Error("COORDINATOR_BASE_URL is required"))).toMatch( + /^\{"level":"fatal","event":"guardian\.fatal","err":"COORDINATOR_BASE_URL is required","stack":"Error: COORDINATOR_BASE_URL is required/, + ); + expect(fatalLine("boom")).toBe('{"level":"fatal","event":"guardian.fatal","err":"boom"}'); + }); + + it("aborts a hung coordinator request after requestTimeoutMs, still heartbeating", async () => { + const { logger, logs, errors } = recordingLogger(); + const options = optionsFor(); + options.logger = logger; + options.requestTimeoutMs = 20; + options.fetcher = (_input, init) => + new Promise((_, reject) => { + const signal = init?.signal; + signal?.addEventListener("abort", () => reject(signal.reason)); + }); + + await runGuardianCoordinatorCycle(options); + + expect(errors.some((m) => m.includes("guardian coordinator poll failed"))).toBe(true); + expect(logs.find((l) => l.includes("guardian.heartbeat"))).toMatch(/"ok":false/); + }); }); const CLI_ENV = { diff --git a/packages/guardian-coordinator/tests/log-contract.test.ts b/packages/guardian-coordinator/tests/log-contract.test.ts new file mode 100644 index 0000000..1a8e4f5 --- /dev/null +++ b/packages/guardian-coordinator/tests/log-contract.test.ts @@ -0,0 +1,131 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { Result } from "better-result"; +import type { SigningSuccess } from "@3flabs/guardian"; + +import { runGuardianCoordinatorCycle, type GuardianCoordinatorOptions } from "../src/index.js"; +import { fatalLine, startupLine } from "../src/cli.js"; + +/** + * Verifies log-contract.json in both directions against real emissions: + * every pattern is produced by at least one code path, and every + * produced line matches at least one pattern. Monitoring builds its + * regexes from that file, so a failure here means downstream alerting + * would break. + */ +const contract = JSON.parse( + readFileSync(new URL("../log-contract.json", import.meta.url), "utf8"), +) as { patterns: Record }; + +const FACILITY = "0x2222222222222222222222222222222222222222"; +const GUARDIAN = "0x0000000000000000000000000000000000000001"; +const HASH = `0x${"b".repeat(64)}` as `0x${string}`; +const SIGNATURE = `0x${"a".repeat(130)}` as `0x${string}`; + +const VALID_BODY = { + chainId: 1, + facility: FACILITY, + intent: { id: "7" }, + requestContract: "0x3333333333333333333333333333333333333333", + deadline: 1_800_000_000, +}; + +const VALID_ROW = { + id: "550e8400-e29b-41d4-a716-446655440000", + kind: "set_request", + chainId: 1, + facility: FACILITY, + payloadHash: HASH, + body: VALID_BODY, + mySubmission: null, +}; + +const SIGNING_SUCCESS = { + guardian: GUARDIAN, + signature: SIGNATURE, + payloadHash: HASH, + signedAt: "2026-08-14T10:00:00Z", + checks: [], +} satisfies SigningSuccess; + +function optionsWith( + fetcher: GuardianCoordinatorOptions["fetcher"], + logger: GuardianCoordinatorOptions["logger"], +): GuardianCoordinatorOptions { + return { + coordinatorBaseUrl: "http://coordinator.test", + coordinatorApiKey: "guardian-key", + pollIntervalMs: 5_000, + pageSize: 100, + fetcher, + logger, + guardian: { + metadata: { build: "1.2.3", guardianSigner: GUARDIAN, supportedChains: [1] }, + getChainClient: () => Result.ok({} as never), + signTypedData: async () => Result.ok(SIGNATURE), + signIntentRequestBinding: async () => Result.ok(SIGNING_SUCCESS), + signIntentFundBinding: async () => Result.ok(SIGNING_SUCCESS), + signIntentSwap: async () => Result.ok(SIGNING_SUCCESS), + }, + }; +} + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +describe("log contract", () => { + it("matches every emitted line and exercises every pattern", async () => { + const lines: string[] = []; + const logger = { + log: (...args: unknown[]) => void lines.push(args.map(String).join(" ")), + error: (...args: unknown[]) => void lines.push(args.map(String).join(" ")), + }; + + // One page driving the submitted, malformed and signing-failed + // paths: a valid row, a row with an unknown kind, and a row whose + // chainId contradicts its body. + await runGuardianCoordinatorCycle( + optionsWith(async (input) => { + if (String(input).startsWith("http://coordinator.test/v1/guardian/signing-requests?")) { + return json({ + total: 3, + page: 1, + pageSize: 100, + items: [VALID_ROW, { ...VALID_ROW, kind: "unknown" }, { ...VALID_ROW, chainId: 2 }], + }); + } + return json({ status: "accepted", quorumReached: true, submissionCount: 1 }); + }, logger), + ); + + // A failing poll drives poll_failed plus the ok:false heartbeat. + await runGuardianCoordinatorCycle( + optionsWith(async () => { + throw new TypeError("fetch failed"); + }, logger), + ); + + const guardian = optionsWith(undefined, undefined).guardian; + lines.push(startupLine(guardian), fatalLine(new Error("COORDINATOR_BASE_URL is required"))); + + const patterns = Object.entries(contract.patterns).map( + ([name, source]) => [name, new RegExp(source)] as const, + ); + for (const [name, pattern] of patterns) { + expect( + lines.some((line) => pattern.test(line)), + `pattern "${name}" was not emitted by any exercised code path`, + ).toBe(true); + } + for (const line of lines) { + expect( + patterns.some(([, pattern]) => pattern.test(line)), + `emitted line not covered by log-contract.json: ${line}`, + ).toBe(true); + } + }); +}); From d96ee5b1586d6861a8797d64fc57ecde54a1608e Mon Sep 17 00:00:00 2001 From: Jose Javier Alvarez Rodriguez Date: Wed, 19 Aug 2026 20:48:36 +0000 Subject: [PATCH 2/2] chore: changeset for coordinator heartbeat and log contract --- .changeset/coordinator-heartbeat-log-contract.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/coordinator-heartbeat-log-contract.md diff --git a/.changeset/coordinator-heartbeat-log-contract.md b/.changeset/coordinator-heartbeat-log-contract.md new file mode 100644 index 0000000..1fd6945 --- /dev/null +++ b/.changeset/coordinator-heartbeat-log-contract.md @@ -0,0 +1,5 @@ +--- +"@3flabs/guardian-coordinator": patch +--- + +Emit a per-poll heartbeat line and `guardian.startup` / `guardian.fatal` lifecycle lines from the coordinator, bound grunt-api calls with a configurable request timeout (`REQUEST_TIMEOUT_MS`), and pin every guaranteed log line in `log-contract.json` so regex-based monitoring can rely on it.