diff --git a/README.md b/README.md index 50b082c..bbb10ba 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,12 @@ npm run index # start indexer (separate terminal) | GET | `/api/v1/tokens/:address/transfers` | Token transfer history | | GET | `/health` | Health check | +## Decode Fallback Metric + +The indexer's `GET /metrics` (Prometheus registry defined in [`indexer/src/metrics.js`](./indexer/src/metrics.js)) exposes `soroban_decode_fallback_total{contract_id="..."}`: a counter of events the decoder could not attribute to a recognised function — it fell back to `function: "unknown"` and returned only `raw_topics`. Each occurrence also emits a structured debug log (`component: "decoder"`, `event: "decode_fallback"`, with `contract_id`, `ledger`, `tx_hash`) for sampling. + +A rising fallback rate means a growing share of on-chain activity is going through with no human-readable description — typically an unregistered contract ABI, an unrecognised event signature, or a decoder regression — and should be investigated before it silently erodes the product's core value (human-readable event descriptions). + ## Registering a Contract ABI ```bash diff --git a/indexer/package.json b/indexer/package.json index cb1a90d..f4e30b5 100644 --- a/indexer/package.json +++ b/indexer/package.json @@ -7,6 +7,7 @@ "dev": "node --watch src/index.js", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand test/api/*.test.js", "test:decoder": "node --test tests/decoder.test.js", + "test:decode-fallback": "node --test tests/decodeFallback.test.js", "test:decoder-parity": "node --test tests/decoder-parity.test.js", "test:scval": "node --test tests/scval.test.js", "test:coverage": "node --experimental-test-coverage --test-coverage-include=src/scval.js --test-coverage-include=src/xdr_decoder.js --test-coverage-include=src/int128.js --test-coverage-lines=88 --test-coverage-branches=70 --test-coverage-functions=88 --test tests/decoder.test.js tests/decoder-parity.test.js tests/scval.test.js", diff --git a/indexer/src/decoder.js b/indexer/src/decoder.js index 57b0a36..3ec97b1 100644 --- a/indexer/src/decoder.js +++ b/indexer/src/decoder.js @@ -7,6 +7,7 @@ import { decodeRwaEvent } from "./rwaDecoder.js"; import { parseHeuristic } from "./heuristicParser.js"; import { parseTTLHostFunction, formatTTLExtension } from "./ttlExtensionParser.js"; import { parseZkHostFunctions, computeZkCostDelta } from "./zkHostFunctions.js"; +import { decodeFallbackTotal } from "./metrics.js"; /** * Raw Soroban RPC event as received from the RPC poller. The `txMeta` field @@ -138,6 +139,31 @@ const NATIVE_SAC_IDS = new Set([ "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", // mainnet ]); +/** @param {string} fnName */ +export function isDecodeFallback(fnName) { + return fnName === "unknown"; +} + +/** + * Record a decode fallback: increments the Prometheus counter (labeled by + * contract) and emits a structured debug log for sampling/investigation. + * @param {string} contractId + * @param {{ ledger?: number, txHash?: string }} ev + * @param {{ debug: (...args: any[]) => void }} [logger] + */ +export function recordDecodeFallback(contractId, ev, logger = console) { + decodeFallbackTotal.inc({ contract_id: contractId }); + logger.debug( + JSON.stringify({ + component: "decoder", + event: "decode_fallback", + contract_id: contractId, + ledger: ev.ledger, + tx_hash: ev.txHash, + }), + ); +} + /** * Decode a raw Soroban RPC event into a human-readable record. * Uses the ABI template when available; falls back to a generic description. @@ -151,6 +177,9 @@ export async function decode(ev) { // First topic is typically the function name symbol const fnName = typeof topics[0] === "symbol" || typeof topics[0] === "string" ? String(topics[0]) : "unknown"; + if (isDecodeFallback(fnName)) { + recordDecodeFallback(contractId, ev); + } // Detect native XLM wrap/unwrap on the SAC contract if (NATIVE_SAC_IDS.has(contractId)) { diff --git a/indexer/src/metrics.js b/indexer/src/metrics.js index 65318e1..089b3ac 100644 --- a/indexer/src/metrics.js +++ b/indexer/src/metrics.js @@ -62,6 +62,20 @@ export const decoderSchemaViolationsTotal = new Counter({ registers: [registry], }); +/** + * Events the decoder could not attribute to a recognised function — it fell + * back to function: "unknown" and raw_topics only. A rising rate means a + * growing share of events are going through with no human-readable + * description (unregistered ABI, unrecognised event signature, or a decoder + * regression) and should be investigated. + */ +export const decodeFallbackTotal = new Counter({ + name: "soroban_decode_fallback_total", + help: "Total events that fell back to unrecognized (function: unknown) decoding, by contract", + labelNames: ["contract_id"], + registers: [registry], +}); + /** * Update DB pool gauges from a pg.Pool instance. * Call this periodically (e.g. every 15 s). diff --git a/indexer/tests/decodeFallback.test.js b/indexer/tests/decodeFallback.test.js new file mode 100644 index 0000000..a274d3a --- /dev/null +++ b/indexer/tests/decodeFallback.test.js @@ -0,0 +1,75 @@ +/** + * Unit tests for the decode-fallback metric (indexer/src/decoder.js). + * + * Covers issue #51: decoding an unrecognized event must increment + * soroban_decode_fallback_total (labeled by contract) and emit a structured + * debug log; decoding a known event must not. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { isDecodeFallback, recordDecodeFallback } from "../src/decoder.js"; +import { decodeFallbackTotal } from "../src/metrics.js"; + +async function counterValue(contractId) { + const { values } = await decodeFallbackTotal.get(); + const match = values.find((v) => v.labels.contract_id === contractId); + return match ? match.value : 0; +} + +describe("isDecodeFallback", () => { + it("is true for the unrecognized-function sentinel", () => { + assert.equal(isDecodeFallback("unknown"), true); + }); + + it("is false for a recognized function name", () => { + assert.equal(isDecodeFallback("transfer"), false); + }); +}); + +describe("recordDecodeFallback", () => { + it("increments the fallback counter for an unrecognized event", async () => { + const contractId = "CFALLBACKTEST0000000000000000000000000000000000000001"; + const before = await counterValue(contractId); + + recordDecodeFallback(contractId, { ledger: 12345, txHash: "deadbeef" }); + + const after = await counterValue(contractId); + assert.equal(after, before + 1); + }); + + it("labels the counter by contract so unrelated contracts stay isolated", async () => { + const contractA = "CFALLBACKTEST0000000000000000000000000000000000000002"; + const contractB = "CFALLBACKTEST0000000000000000000000000000000000000003"; + + recordDecodeFallback(contractA, { ledger: 1, txHash: "aa" }); + + assert.equal(await counterValue(contractA), 1); + assert.equal(await counterValue(contractB), 0); + }); + + it("does not increment for a known event (decode() only calls it when isDecodeFallback is true)", async () => { + const contractId = "CFALLBACKTEST0000000000000000000000000000000000000004"; + // Mirrors the gate in decode(): a known fnName never reaches recordDecodeFallback. + const fnName = "transfer"; + if (isDecodeFallback(fnName)) { + recordDecodeFallback(contractId, { ledger: 1, txHash: "bb" }); + } + assert.equal(await counterValue(contractId), 0); + }); + + it("emits a structured debug log with contract, ledger, and tx hash", () => { + const calls = []; + const stubLogger = { debug: (...args) => calls.push(args) }; + const contractId = "CFALLBACKTEST0000000000000000000000000000000000000005"; + + recordDecodeFallback(contractId, { ledger: 999, txHash: "cafebabe" }, stubLogger); + + assert.equal(calls.length, 1); + const logged = JSON.parse(calls[0][0]); + assert.equal(logged.component, "decoder"); + assert.equal(logged.event, "decode_fallback"); + assert.equal(logged.contract_id, contractId); + assert.equal(logged.ledger, 999); + assert.equal(logged.tx_hash, "cafebabe"); + }); +});