diff --git a/docs/3F-PLAN.md b/docs/3F-PLAN.md index d1b2f7d2..f513366f 100644 --- a/docs/3F-PLAN.md +++ b/docs/3F-PLAN.md @@ -330,4 +330,4 @@ Tracked TODOs and known gaps — each a scoped follow-up; none block release. **Testing:** - **Integration coverage.** `bridgefacilitator` unit coverage is ~16% — pure logic (EIP-712 golden+parity, default-strategy capacity/caps, config) is covered; the HTTP/on-chain paths (apiclient, chainreader, redeemer, Run loop) need an httptest-backed API mock + a simulated/forked chain backend. -- **Solver-agnostic metrics seam.** `solver.Deps.Metrics` (the `Registerer()` extension point) is wired but no solver registers collectors yet; add bridge-facilitator metrics (offers sent/won, exposure, locked vs realized, redemptions) and they'll verify the seam. +- **Solver metrics.** The shared `solver.Deps.Metrics` seam is live: RFQ and OEV already register collectors, and `bridgefacilitator` now exports discovery / offer-cache / offer-submit / redeem-flow counters plus a live-offers gauge. Richer economics metrics (offers won, locked vs realized, exposure by adapter) remain a follow-up. diff --git a/internal/solvers/bridgefacilitator/metrics.go b/internal/solvers/bridgefacilitator/metrics.go new file mode 100644 index 00000000..4b3ae27a --- /dev/null +++ b/internal/solvers/bridgefacilitator/metrics.go @@ -0,0 +1,143 @@ +package bridgefacilitator + +import ( + "github.com/go-errors/errors" + "github.com/prometheus/client_golang/prometheus" +) + +// metrics are the 3F solver's collectors, registered on the shared Prometheus registry (served at +// the framework's /metrics). All methods are nil-safe so the solver runs unmetered when no registry +// is provided. +type metrics struct { + discoverPasses prometheus.Counter + auctionsDiscovered prometheus.Counter + offersSubmitted prometheus.Counter + offerSubmitFailures prometheus.Counter + cacheLoaded prometheus.Counter + cacheRebuildFailures prometheus.Counter + liveOffers prometheus.Gauge + redeemableRequests prometheus.Counter + redeemedRequests prometheus.Counter + redeemScanFailures prometheus.Counter + redeemTxFailures prometheus.Counter +} + +func newMetrics(reg prometheus.Registerer) (*metrics, error) { + m := &metrics{ + discoverPasses: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "threef_discover_passes_total", + Help: "Successful 3F auction discovery passes.", + }), + auctionsDiscovered: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "threef_auctions_discovered_total", + Help: "Auctions returned by the 3F API across successful discovery passes.", + }), + offersSubmitted: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "threef_offers_submitted_total", + Help: "3F offers successfully submitted.", + }), + offerSubmitFailures: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "threef_offer_submit_failures_total", + Help: "3F offer submission attempts that failed at the API boundary.", + }), + cacheLoaded: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "threef_offer_cache_loaded_total", + Help: "Live offers loaded from the 3F API into the local dedup cache.", + }), + cacheRebuildFailures: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "threef_offer_cache_rebuild_failures_total", + Help: "Per-adapter 3F offer-cache rebuild failures.", + }), + liveOffers: prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "threef_live_offers", + Help: "Current number of live offers in the local dedup cache.", + }), + redeemableRequests: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "threef_redeemable_requests_total", + Help: "Redeemable 3F requests found across redeem scans before batch capping.", + }), + redeemedRequests: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "threef_redeemed_requests_total", + Help: "3F requests successfully finalized on-chain.", + }), + redeemScanFailures: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "threef_redeem_scan_failures_total", + Help: "Redeem scans that failed before building a finalize transaction.", + }), + redeemTxFailures: prometheus.NewCounter(prometheus.CounterOpts{ + Name: "threef_redeem_tx_failures_total", + Help: "Finalize-request transaction attempts that failed.", + }), + } + for _, c := range []prometheus.Collector{ + m.discoverPasses, m.auctionsDiscovered, m.offersSubmitted, m.offerSubmitFailures, + m.cacheLoaded, m.cacheRebuildFailures, m.liveOffers, m.redeemableRequests, + m.redeemedRequests, m.redeemScanFailures, m.redeemTxFailures, + } { + if err := reg.Register(c); err != nil { + return nil, errors.Errorf("bridgefacilitator: register metric: %w", err) + } + } + return m, nil +} + +func (m *metrics) observeDiscover(auctions int) { + if m != nil { + m.discoverPasses.Inc() + m.auctionsDiscovered.Add(float64(auctions)) + } +} + +func (m *metrics) incOfferSubmitted() { + if m != nil { + m.offersSubmitted.Inc() + } +} + +func (m *metrics) incOfferSubmitFailed() { + if m != nil { + m.offerSubmitFailures.Inc() + } +} + +func (m *metrics) addCacheLoaded(live int) { + if m != nil { + m.cacheLoaded.Add(float64(live)) + } +} + +func (m *metrics) incCacheRebuildFailed() { + if m != nil { + m.cacheRebuildFailures.Inc() + } +} + +func (m *metrics) setLiveOffers(live int) { + if m != nil { + m.liveOffers.Set(float64(live)) + } +} + +func (m *metrics) addRedeemable(found int) { + if m != nil { + m.redeemableRequests.Add(float64(found)) + } +} + +func (m *metrics) addRedeemed(count int) { + if m != nil { + m.redeemedRequests.Add(float64(count)) + } +} + +func (m *metrics) incRedeemScanFailed() { + if m != nil { + m.redeemScanFailures.Inc() + } +} + +func (m *metrics) incRedeemTxFailed() { + if m != nil { + m.redeemTxFailures.Inc() + } +} diff --git a/internal/solvers/bridgefacilitator/metrics_test.go b/internal/solvers/bridgefacilitator/metrics_test.go new file mode 100644 index 00000000..171f86f8 --- /dev/null +++ b/internal/solvers/bridgefacilitator/metrics_test.go @@ -0,0 +1,75 @@ +package bridgefacilitator + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestMetrics_RecordLifecycle(t *testing.T) { + reg := prometheus.NewRegistry() + m, err := newMetrics(reg) + if err != nil { + t.Fatalf("newMetrics: %v", err) + } + + m.observeDiscover(3) + m.incOfferSubmitted() + m.incOfferSubmitFailed() + m.addCacheLoaded(2) + m.incCacheRebuildFailed() + m.setLiveOffers(4) + m.addRedeemable(5) + m.addRedeemed(3) + m.incRedeemScanFailed() + m.incRedeemTxFailed() + + if got := testutil.ToFloat64(m.discoverPasses); got != 1 { + t.Fatalf("threef_discover_passes_total = %v, want 1", got) + } + if got := testutil.ToFloat64(m.auctionsDiscovered); got != 3 { + t.Fatalf("threef_auctions_discovered_total = %v, want 3", got) + } + if got := testutil.ToFloat64(m.offersSubmitted); got != 1 { + t.Fatalf("threef_offers_submitted_total = %v, want 1", got) + } + if got := testutil.ToFloat64(m.offerSubmitFailures); got != 1 { + t.Fatalf("threef_offer_submit_failures_total = %v, want 1", got) + } + if got := testutil.ToFloat64(m.cacheLoaded); got != 2 { + t.Fatalf("threef_offer_cache_loaded_total = %v, want 2", got) + } + if got := testutil.ToFloat64(m.cacheRebuildFailures); got != 1 { + t.Fatalf("threef_offer_cache_rebuild_failures_total = %v, want 1", got) + } + if got := testutil.ToFloat64(m.liveOffers); got != 4 { + t.Fatalf("threef_live_offers = %v, want 4", got) + } + if got := testutil.ToFloat64(m.redeemableRequests); got != 5 { + t.Fatalf("threef_redeemable_requests_total = %v, want 5", got) + } + if got := testutil.ToFloat64(m.redeemedRequests); got != 3 { + t.Fatalf("threef_redeemed_requests_total = %v, want 3", got) + } + if got := testutil.ToFloat64(m.redeemScanFailures); got != 1 { + t.Fatalf("threef_redeem_scan_failures_total = %v, want 1", got) + } + if got := testutil.ToFloat64(m.redeemTxFailures); got != 1 { + t.Fatalf("threef_redeem_tx_failures_total = %v, want 1", got) + } +} + +func TestMetrics_NilSafe(t *testing.T) { + var m *metrics + m.observeDiscover(1) + m.incOfferSubmitted() + m.incOfferSubmitFailed() + m.addCacheLoaded(1) + m.incCacheRebuildFailed() + m.setLiveOffers(1) + m.addRedeemable(1) + m.addRedeemed(1) + m.incRedeemScanFailed() + m.incRedeemTxFailed() +} diff --git a/internal/solvers/bridgefacilitator/offercache.go b/internal/solvers/bridgefacilitator/offercache.go index b710eded..d18129bd 100644 --- a/internal/solvers/bridgefacilitator/offercache.go +++ b/internal/solvers/bridgefacilitator/offercache.go @@ -62,6 +62,17 @@ func (t *offerTracker) liveCoverage(auctionID int64, now time.Time) *big.Int { return total } +// liveCount reports how many offers are still unexpired as of now. +func (t *offerTracker) liveCount(now time.Time) int { + live := 0 + for _, st := range t.offers { + if st.expiry.After(now) { + live++ + } + } + return live +} + // pruneExpired drops entries whose offer has already expired, keeping the map bounded over a long run. func (t *offerTracker) pruneExpired(now time.Time) { for k, st := range t.offers { diff --git a/internal/solvers/bridgefacilitator/redeemer.go b/internal/solvers/bridgefacilitator/redeemer.go index b035dd5c..e2702bcc 100644 --- a/internal/solvers/bridgefacilitator/redeemer.go +++ b/internal/solvers/bridgefacilitator/redeemer.go @@ -11,9 +11,11 @@ import ( func (s *Solver) redeemReady(ctx context.Context, target Target) { ready, err := s.reader.readyToRedeem(ctx, target.Adapter) if err != nil { + s.metrics.incRedeemScanFailed() s.log.Error(err, "redeem: scan ready requests", "adapter", target.Adapter.Hex()) return } + s.metrics.addRedeemable(len(ready)) s.log.V(1).Info("redeem scan", "adapter", target.Adapter.Hex(), "ready", len(ready)) if len(ready) == 0 { return @@ -39,8 +41,10 @@ func (s *Solver) redeemReady(ctx context.Context, target Target) { Label: "redeem", }) if res.Err != nil { + s.metrics.incRedeemTxFailed() s.log.Error(res.Err, "redeem: tx failed", "requests", len(ready)) return } + s.metrics.addRedeemed(len(ready)) s.log.Info("finalized ready requests", "count", len(ready), "tx", res.Hash.Hex()) } diff --git a/internal/solvers/bridgefacilitator/solver.go b/internal/solvers/bridgefacilitator/solver.go index c7ffa5a2..c582fcd2 100644 --- a/internal/solvers/bridgefacilitator/solver.go +++ b/internal/solvers/bridgefacilitator/solver.go @@ -42,6 +42,7 @@ type Solver struct { api *apiClient reader *reader strategy types.Strategy + metrics *metrics log logr.Logger signerAddr common.Address // the solver's own EIP-1271 signer address, set in factory nonceSeq atomic.Uint64 @@ -59,6 +60,12 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { if err != nil { return nil, err } + var mx *metrics + if deps.Metrics != nil { + if mx, err = newMetrics(deps.Metrics.Registerer()); err != nil { + return nil, err + } + } s := &Solver{ cfg: cfg, @@ -66,6 +73,7 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) { api: api, reader: newReader(deps.Chain), strategy: offerStrategy, + metrics: mx, log: deps.Log.WithName(Name), signerAddr: deps.Signer.Address(), offers: newOfferTracker(), @@ -133,6 +141,7 @@ func (s *Solver) rebuildOfferCache(ctx context.Context) { for _, t := range s.cfg.Targets { offers, err := s.api.listOffers(ctx, t.Adapter) if err != nil { + s.metrics.incCacheRebuildFailed() s.log.Error(err, "rebuild offer cache: list offers", "adapter", t.Adapter.Hex()) continue } @@ -154,6 +163,8 @@ func (s *Solver) rebuildOfferCache(ctx context.Context) { live++ } } + s.metrics.addCacheLoaded(live) + s.metrics.setLiveOffers(live) s.log.Info("loaded existing offers into dedup cache", "live", live) } @@ -171,6 +182,7 @@ func (s *Solver) discoverAndOffer(ctx context.Context) { s.log.Error(err, "discover: list auctions") return } + s.metrics.observeDiscover(len(auctions)) s.log.V(1).Info("discovered auctions", "count", len(auctions)) offerings := make([]*adapterOffering, 0, len(s.cfg.Targets)) @@ -192,6 +204,7 @@ func (s *Solver) discoverAndOffer(ctx context.Context) { now := time.Now() s.offers.pruneExpired(now) // keep the dedup map bounded + s.metrics.setLiveOffers(s.offers.liveCount(now)) input := buildStrategyInput(auctions, offerings, s.offers, now) if len(input.Auctions) == 0 { return // no open, offerable auctions this pass @@ -214,13 +227,16 @@ func (s *Solver) discoverAndOffer(ctx context.Context) { continue } if subErr := s.api.createOffer(ctx, dto); subErr != nil { + s.metrics.incOfferSubmitFailed() s.log.Error(subErr, "offer: submit", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex()) continue } if exp, perr := parseUnixTime(dto.Expiration); perr == nil { s.offers.record(offer.Maker, offer.AuctionID, exp, offer.Principal) + s.metrics.setLiveOffers(s.offers.liveCount(time.Now())) } + s.metrics.incOfferSubmitted() s.log.Info("offer submitted", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex(), "request", offer.Request.Hex(), "principal", offer.Principal.String(), "expectedReturn", dto.ExpectedReturn) }