telemetry: surface program errors from finalized transactions - #4152
Conversation
The Go telemetry SDK executor treated any finalized transaction as a success. Finalization only means the cluster agreed on the transaction: an instruction the program rejected finalizes too, carrying the rejection in err, which the executor never read. The executor now returns a *ProgramError holding the ledger's error and the program's log output, and the device telemetry submitter treats it as a permanent failure for the tick: it logs the rejection with the program's explanation and leaves the remaining attempts unspent instead of burying the reason under a backoff loop. Seen on chi-dn-dzd4, whose metrics_publisher had been set to a key the agent did not hold: the init half of the init->write path skips preflight, so the rejection only arrived on the finalized transaction, and the agent looped init -> write -> "account not found" for over an hour with nothing naming the cause.
…ount there The e2e SDK telemetry tests re-initialize an existing account and, before the executor surfaced program errors, read the rejection off res.Meta.Err themselves. They now assert the error the SDK returns. That exposed a path the submitter got right only by accident. An init rejected with AccountAlreadyExists (1010) still leaves the write with what it needed, and the old silent-success behavior meant the write ran and succeeded. Surfacing the rejection would have turned that into a skipped tick and a spurious error, so the write now runs whether or not the init was accepted, and only a write that still finds nothing there reports the init failure as the reason.
nikw9944
left a comment
There was a problem hiding this comment.
The SDK change opens an onchain duplicate-write path in the internet-latency-collector that the "not in scope" note doesn't account for.
Must fix — controlplane/internet-latency-collector/internal/exporter/submitter.go:241 re-sends the whole slice from index 0 each attempt, because its SubmitSamples returns no written count, unlike the device submitter (controlplane/telemetry/internal/telemetry/submitter.go:87-90).
Worth addressing — ProgramError.Err has no accessor, so custom codes on the finalized path skip the sentinel mapping at client.go:304-330; and ProgramLogMessages keeps only Program log: -prefixed lines, dropping unprefixed CPI reason lines.
The core executor change is right, and the written accounting survives the new early break — I traced both.
- High — the SDK change turns silent loss into onchain duplicate writes in the internet-latency-collector.
controlplane/internet-latency-collector/internal/exporter/submitter.go:241. That retry loop re-sends the whole slice from index 0 each attempt, because itsSubmitSamplesreturns no written count — unlike the device submitter, which threadswrittenfor exactly this reason (controlplane/telemetry/internal/telemetry/submitter.go:87-90).
Five findings from review on 3393f70. The SDK's log filter was an allowlist described as a denylist: keeping only "Program log:" lines dropped the reason a native program logs through CPI, which for the system program is the line that says an agent could not fund the account it was creating. It now drops the runtime's own bookkeeping and keeps everything else. Custom error codes arriving on the finalized path skipped the sentinel mapping, so a caller's account-full and missing-account handling worked on the preflight side of a condition and not the other. ProgramError gains CustomErrorCode(), and the write methods map 1006 and 1011 onto the same sentinels preflight returns. The internet-latency collector re-sent a partition from index 0 on every attempt and requeued all of it on failure, so a partial write appended its earlier batches again. It now threads a written count the way the device submitter has since #4145. Its per-batch debug log also reports the batch size rather than the whole partition, which slicing would otherwise have made the remainder. The retries-exhausted assertion is now a log assertion: TestSubmitter_RetainsEverySampleAcrossTheStalenessBound drives that package-level counter from a sibling parallel test, so a zero delta was racy. And the new metric's comment claimed it was distinct from the write/init failure types when a rejected init increments both.
|
Thanks — all five addressed in 911d56d. Individual replies are on the threads; the must-fix and one framing correction are worth putting here. The duplicate-write path is fixed rather than deferred. One correction on the framing. That duplicate path is already reachable on And one stale finding. The Still out of scope in the collector, now tracked as malbeclabs/infra#2181: it spends its attempts retrying a permanent rejection, and returns early on a harmless init rejection instead of writing anyway. Its own retry loop absorbs both within the tick, so the cost is wasted attempts, not delay or duplication. Verification: 228 non-test lines, all affected packages green, lint clean, both e2e SDK telemetry tests run locally against cEOS. The only failing test in the tree is the pre-existing |
nikw9944
left a comment
There was a problem hiding this comment.
911d56de addresses all five findings, and I verified each in the code rather than taking the replies at face value: the collector now threads a written count and resumes at tmp[written:], ProgramError.CustomErrorCode() plus sentinelForProgramError map 1006/1011 on the finalized path, the log filter is inverted to a denylist with a system-program CPI test, the metric comment states the overlap, and the racy counter delta is now a log assertion. Build, the three affected suites, and lint all pass at head.
One thing left, not previously reported: controlplane/telemetry/internal/telemetry/submitter.go:164 fires for any post-init write failure, not just ErrAccountNotFound, so an unrelated write rejection is reported as an init failure and its own reason is dropped.
Also a correction on my side: my "turns silent loss into duplication" framing overstated the novelty. The duplication was already reachable on main via an RPC timeout mid-partition, since both the retry and the requeue restarted at index 0. The fix closes both triggers.
Resolves: malbeclabs/infra#1703
Summary of Changes
err, which the executor never read. It now returns a*telemetry.ProgramErrorholding the ledger's error and the program's log output.Errorand leaves the tick's remaining attempts unspent, rather than spending them on an instruction the ledger has already refused. Newsubmitter_program_errortype on the existing errors counter.ProgramError.Error()leads with the program's explanation (Program log:lines, minus the runtime's invoke/consumed boilerplate and the instruction-name echo), so a caller that only prints the error still gets the reason. This is the checksmartcontract/sdk/go/serviceability/executor.goalready made; telemetry was the outlier.ErrSamplesAccountFull/ErrAccountNotFoundits preflight equivalent does, through the newProgramError.CustomErrorCode(). Preflight catches nearly all of these, but a write that simulated cleanly and then failed against the bank it landed on reported its code only through the finalized transaction, so a caller's account-full handling worked on one side of preflight and not the other.SubmitSamples, the way the device submitter has since telemetry: count and correctly report samples dropped when account is full #4145. It re-sent a partition from index 0 on every attempt and requeued all of it on failure, so any failure part-way through a multi-batch partition appended the earlier batches a second time. Reachable today from an RPC timeout mid-partition; surfacing program rejections adds another way in.AccountAlreadyExists(1010) was being hidden by the same silence, and the old code survived it by accident: init "succeeded", the write ran, the account was there, samples landed. Surfacing the rejection without this would have turned a self-healing path into a skipped tick plus a "rejected by the program" error on a device that is fine.submitter_failed_to_initialize_accountlikewise waits for the write's verdict instead of firing on a failure the write absorbs.This is what left chi-dn-dzd4 silent for over an hour. Its
metrics_publisherhad been set to a key the agent did not hold, and the init half of the init→write path skips preflight — soUnauthorizedAgent(0x3e9) only arrived on the finalized transaction, which the SDK read as success. The agent looped init → write →account not foundevery few seconds, and the only error it printed named the missing account, not the authorization failure that caused it.Diff Breakdown
Mostly tests: ~228 non-test lines across the SDK executor and client, the device submitter, and the internet-latency collector.
Key files (click to expand)
smartcontract/sdk/go/telemetry/executor.go— newProgramErrortype withCustomErrorCode()and the program-log filter;waitForTransactionFinalizedreturns it when the finalized signature status or the transaction meta carries an error, fetching the logs best effort so a node that cannot return the transaction costs the logs rather than replacing the rejection with an RPC errorcontrolplane/telemetry/internal/telemetry/submitter.go—errors.Asbranch inTick's retry loop (count, log atError, stop the tick's attempts), and the init→write restructure that keeps a rejected-but-harmless init from ending the submissionsmartcontract/sdk/go/telemetry/client.go—sentinelForProgramErrormaps finalized-path custom codes onto the sentinels preflight already returnscontrolplane/internet-latency-collector/internal/exporter/submitter.go—SubmitSamplesreturns how many samples it wrote, so a retry resumes at the first unwritten one and only the remainder is requeuedcontrolplane/telemetry/internal/metrics/metrics.go—ErrorTypeSubmitterProgramError, which narrows why a submission failed and overlaps the write/init types rather than replacing theme2e/sdk_device_telemetry_test.go,e2e/sdk_internet_telemetry_test.go— the "initialize again" cases assertedNoErrorand then read the rejection offres.Meta.Errby hand, which is what the SDK's silence forced; they now assert the error it returnsTesting Verification
TestSDK_Telemetry_Executor_FinalizedWithProgramErrorreproduces the chi-dn-dzd4 transaction — finalized,Custom: 1001, with the "not authorized for origin device" program log — across three shapes: the rejection on the signature status, on the transaction meta only (a node that returns a clean status), and with the logs unfetchable. All three must return an error rather than a signature; the error message carries the custom code in every case and the program's explanation whenever the logs were available, with the boilerplate stripped.does_not_retry_a_submission_the_program_rejecteddrives a full submitter tick withMaxAttempts: 5and asserts the init is not re-sent,submitter_program_errorincrements once,submitter_retries_exhausteddoes not, the reason reaches the log, and the samples are requeued for the next tick.writes_anyway_when_the_account_already_existscovers the 1010 path the e2e run caught: init rejected, write attempted anyway, samples land, nothing requeued, and no "rejected by the program" error logged.retries_resume_at_the_first_unwritten_samplein the collector: a 262-sample partition whose first batch lands and second fails. Verified it fails without the fix — 262 samples requeued, meaning the next tick would re-send the 245 already onchain — and passes with it at 17.CustomErrorCodeis table-tested across the numeric types a JSON decoder can hand back (json.Number,float64,int,uint64) plus the shapes that carry no code, a negative code, and one pastuint32.ProgramLogMessagesasserts a system-program CPI failure survives the filter:Transfer: insufficient lamports 0, need 890880is kept, the invoke/consumed/failed lines and the instruction echo are dropped, and the reason reachesError().FinalizedCustomErrorsMapToSentinelsdrives the client with a finalized status carrying 1006 and 1011 and assertsErrSamplesAccountFull/ErrAccountNotFound.TestE2E_SDK_Telemetry_DeviceLatencySamplesandTestE2E_SDK_Telemetry_InternetLatencySamples, including the twotry_to_initialize_..._againsubtests that surfaced the 1010 path.-race -count=2includingTestSubmitter_RetainsEverySampleAcrossTheStalenessBound, which shares the retries-exhausted counter.Meta == nil,GetTransactionreturning nil) keep their original error messages — a clean signature status still falls through to them unchanged.smartcontract/sdk/go/telemetry,controlplane/telemetry/..., andcontrolplane/internet-latency-collector/.... One unrelated pre-existing failure incontrolplane/telemetry/internal/netns(TestRunInNamespace_EmptyNameErrorsneeds namespace privileges) reproduces identically on a cleanmaincheckout.Not in scope
controlplane/internet-latency-collector/internal/exporter/submitter.gogets the duplicate-write fix but not the rejection classification: it still spends its attempts retrying a permanent rejection, and returns early on a harmless init rejection rather than writing anyway. Its own retry loop absorbs both within the tick, so the cost is wasted attempts rather than delay or duplication.sdk/geolocation/go/executor.gois the third copy of this pattern and still has the gap — itswaitForTransactionFinalizednever readsstatus.Erreither. Worth its own issue; not touched here.ProgramErrorinstead of theErrSamplesAccountFullsentinel, so it requeues and takes the drop path on the next tick instead of immediately. Previously that race reported success and lost the samples with no signal at all, so this is strictly better; mappingmeta.errcustom codes back onto the sentinels would need a parser inclient.goand is a separate change.