Skip to content

feat(sdk): x402Metrics wrapper for Prometheus observability - #53

Merged
Eras256 merged 4 commits into
nirium-protocol:mainfrom
Shadow-MMN:feat/x402-metrics-wrapper
Aug 25, 2026
Merged

Eras256 merged 4 commits into
nirium-protocol:mainfrom
Shadow-MMN:feat/x402-metrics-wrapper

Conversation

@Shadow-MMN

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in metrics wrapper for x402Serve() that exposes Prometheus counters and histograms for challenges, verifications, settlements, revenue, and latency — without modifying any payment-verification behavior.

Closes #41

What's included

New

  • packages/sdk/src/metrics.ts — x402Metrics(x402Serve(config), options?), a pure wrapper around an existing x402Serve handler. Returns { handler, metricsHandler, snapshot, reset }.
    • handler — drop-in replacement for the wrapped x402Serve instance; observes outcomes without altering them.
    • metricsHandler — GET /metrics route helper in Prometheus text exposition format, mountable independently of any paid route and requiring no payment itself.
    • snapshot / reset — for tests and diagnostics.
  • packages/sdk/src/metrics.test.ts (28 tests) — mocked x402Serve handler covering challenge/verify/settle counting, revenue correctness across a scripted paid/failed sequence, multi-asset revenue, latency histogram, Prometheus output format, and PII exclusion.
  • packages/sdk/src/x402serve-smoke.test.ts (7 tests) — mocks the ESM-only deps (ws, @x402/fetch, @x402/stellar, mppx) to prove x402Serve's own validation and behavior are unchanged when wrapped by x402Metrics.
  • Schema-validated fixtures — mock 402 response bodies are asserted against the real @x402/core PaymentRequiredV2Schema via parsePaymentRequired() (installed as a devDependency only, never imported at runtime), so the fixtures can't silently drift from what the dependency actually emits on the wire.
  • packages/sdk/jest.config.js, root tsconfig.json (was missing from git — packages/sdk/tsconfig.json's extends: "../../tsconfig.json" was pointing at a file that didn't exist).

Changed

  • packages/sdk/src/index.ts — two-line addition exporting x402Metrics and its types. No changes to x402Serve or any payment logic.
  • packages/sdk/README.md — new "x402 Metrics" section with a usage example, curl example scraping /metrics, and sample Prometheus output.
  • packages/sdk/package.json — added jest, ts-jest, @types/jest, @types/node, @x402/core (test-only) to devDependencies.

Metrics exposed

Metric Type Labels
x402_challenges_total counter route
x402_verify_success_total counter route
x402_verify_fail_total counter route
x402_settle_success_total counter route
x402_settle_fail_total counter route
x402_revenue_total counter route, asset
x402_settlement_latency_seconds histogram route

No payer addresses or other PII are included — aggregate counters/histograms only.

How classification works

Outcomes are read from the real x402 v2 protocol shape rather than inferred from status codes alone:

  • 402 with no error field → challenge issued (fresh payment request)
  • 402 with error present → verify failure
  • 2xx → verify + settle success
  • 5xx → settle failure

Revenue uses the routes config passed to x402Metrics when provided (reliable, since price is known upfront); when not provided, it falls back to parsing the price out of the 402 challenge body (accepts[0].amount, with a defensive || accepts[0].maxAmountRequired fallback for v1-shaped responses).

Testing

  • 35 tests total, all passing (npm run test)
  • Typecheck clean (npm run build)
  • Verified clean from scratch: rm -rf node_modules && npm ci --legacy-peer-deps && npm run build && npm run test
  • No real @x402/express / @x402/core / @x402/stellar payment logic is exercised in tests — everything is mocked at the handler boundary, so tests never hit the live OpenZeppelin facilitator. @x402/core's schema module is used only to validate that mock fixtures match its real shape, not to run any payment flow.

Known limitations

  • Classification is validated against x402 protocol v2 responses, since that's what the installed @x402/core emits by default (x402Version: 2, resource as an object, amount field). A v1-shaped response (maxAmountRequired, flat string resource) is handled defensively in the revenue fallback but isn't exercised by a dedicated test.
  • Revenue reflects the price declared in accepts/route config, not an independently-verified on-chain settlement amount — there's no way to observe the actual settled amount from outside the middleware without deeper integration.
  • No test runs against the real @x402/express middleware end-to-end (only mocked at the handler/schema boundary), per the constraint of not installing/exercising real payment-verification packages in CI.

Note on peer dependencies

packages/sdk/package.json pins @stellar/stellar-sdk@^14.5.0 directly, but @stellar/mpp declares a peer dependency on @stellar/stellar-sdk@^15.1.0. This predates this PR but npm install in this package currently requires --legacy-peer-deps to complete; flagging here in case it's worth a separate fix.

@Eras256 Eras256 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fast turnaround on this — the wrapper's shape (opt-in, doesn't touch the payment path) is right, but there's a classification bug that inverts the core metric this PR is built around.

@x402/core's createPaymentRequiredResponse always sets error on the very first, no-payment-yet 402 (e.g. error: "Payment required") — so isChallenge = !capturedBody?.error is backwards for the most common event in the system: real challenges get counted as verify_fail, and vice versa. Same root cause makes x402_revenue_total empty in production unless options.routes is passed by hand (the README's own example doesn't), and makes real settlement failures (which come back as an empty-body 402, not 5xx, per @x402/express) also get miscounted as fresh challenges. 403 rejections aren't counted at all.

None of this touches payment verification/settlement itself — it's metrics-only, so no money-safety issue — but the flagship metric doesn't measure what it claims to right now. Can you re-check the classifier against a real (or schema-faithful) 402 response body from @x402/core instead of the hand-built fixtures in metrics.test.ts? Happy to re-review once that's addressed.

🤖 Generated with Claude Code

@Shadow-MMN

Copy link
Copy Markdown
Contributor Author

Update: addressed review feedback

The original classifier assumed @x402/core puts an error field in the JSON body of 402
responses, and that a challenge omits it. Traced against the real dependency
(@x402/core@^2.23.0, @x402/express@^2.17.0), that assumption was wrong on two levels:

  • The body is always {} for non-browser clients unless the route config sets
    unpaidResponseBody — which x402Serve never does. The actual PaymentRequired object
    (including error) is sent base64-encoded in the PAYMENT-REQUIRED response header instead.
  • Settlement failures return 402, not 5xx, with a PAYMENT-RESPONSE header and no
    PAYMENT-REQUIRED header — distinguishable from a challenge on that basis.
  • 403 rejections carry { error: reason } directly in the body (no header involved).

Fix: metrics.ts now decodes the PAYMENT-REQUIRED header (case-insensitive key match,
confirmed @x402/express forwards @x402/core's header keys verbatim) instead of reading the
JSON body, and classifies via:

  • 402 + PAYMENT-REQUIRED header → challenge (uses the header's error field, not body)
  • 402 + PAYMENT-RESPONSE header, no PAYMENT-REQUIRED → settlement failure
  • 403 → rejection (new x402_rejections_total counter)
  • 2xx → verify + settle success

Also separated infrastructure errors from settlement failures. Traced FacilitatorResponseError
through both the verify path (verifyPayment) and settlement path (processSettlement) — both
propagate to Express as a 502 via sendFacilitatorError, and were previously being counted as
settleFail, conflating "facilitator unreachable" with "settlement was attempted and rejected."
These are now a separate x402_infra_errors_total counter (covers 5xx from both paths).
SettleError and generic settlement errors are confirmed to route through
buildSettlementFailureResponse (402), so they correctly remain in settleFail — verified this
doesn't need its own bucket.

Revenue extraction now reads pricing from the decoded header's accepts[] (previously tried to
read it from the always-empty body).

Fixtures in metrics.test.ts rebuilt to produce real base64-encoded PAYMENT-REQUIRED/
PAYMENT-RESPONSE headers matching actual @x402/core v2.23+ output, validated against the real
Zod schema via parsePaymentRequired, rather than hand-typed JSON shapes.

Tests: 41 passing (up from 28), including new coverage for settlement-failure detection,
403 rejections, header-based revenue extraction, infra-error vs. settle-fail separation, and
header-casing robustness (uppercase and lowercase PAYMENT-REQUIRED variants).

Known limitations (updated):

  • No integration test against the real @x402/express middleware end-to-end — fixtures are
    schema-faithful mocks of its header/body output, not the live middleware itself.
  • Revenue without options.routes depends on capturing the first challenge's accepts[]; price
    can drift if server-side pricing changes mid-session without a fresh challenge.

@Eras256

Eras256 commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Reviewed the actual implementation and ran the test suite locally: 41/41 passing. This is solid work — x402Metrics wraps x402Serve() as a pure observer (only patches res.json/res.setHeader, never res.send/res.end, avoiding conflicts with @x402/express's internal buffering), and classifies outcomes by decoding the real PAYMENT-REQUIRED header rather than guessing from status code alone. The test fixtures are validated against @x402/core's own schema (parsePaymentRequired), not just internally-consistent mocks — good practice.

Only blocker right now is that this branch is behind main and has a merge conflict in packages/sdk/package.json / package-lock.json (both PRs touched devDependencies). Could you rebase/merge main into feat/x402-metrics-wrapper and push? Once that's resolved this is ready to merge.

@Shadow-MMN

Copy link
Copy Markdown
Contributor Author

Reviewed the actual implementation and ran the test suite locally: 41/41 passing. This is solid work — x402Metrics wraps x402Serve() as a pure observer (only patches res.json/res.setHeader, never res.send/res.end, avoiding conflicts with @x402/express's internal buffering), and classifies outcomes by decoding the real PAYMENT-REQUIRED header rather than guessing from status code alone. The test fixtures are validated against @x402/core's own schema (parsePaymentRequired), not just internally-consistent mocks — good practice.

Only blocker right now is that this branch is behind main and has a merge conflict in packages/sdk/package.json / package-lock.json (both PRs touched devDependencies). Could you rebase/merge main into feat/x402-metrics-wrapper and push? Once that's resolved this is ready to merge.

On it now , will update branch immediately

…pper

# Conflicts:
#	packages/sdk/package-lock.json
#	packages/sdk/package.json
Resolve merge conflict in package-lock.json by regenerating from
combined devDependencies (ours + main's @types/ws@^8.18.1 upgrade).
Lock ws@^8.18.0 in package.json.
@Shadow-MMN
Shadow-MMN requested a review from Eras256 August 24, 2026 20:00
@Shadow-MMN

Copy link
Copy Markdown
Contributor Author

@Eras256 updated the branch as you requested

@Eras256
Eras256 merged commit 909309b into nirium-protocol:main Aug 25, 2026
Eras256 added a commit to M0nsxx/nirium-sdk that referenced this pull request Aug 25, 2026
- Kept main's CommonJS/ts-jest tsconfig (already proven with nirium-protocol#53's tests)
  instead of this branch's NodeNext config, to avoid destabilizing what's
  already merged.
- Dropped an unused `viem` dependency this branch had added (Ethereum
  library, never referenced anywhere in resilient-ws.ts) — same class of
  leftover already caught and removed in PR nirium-protocol#58.
- test/ws-resilient.test.ts uses Node's built-in test runner (node:test),
  not Jest — it was never going to be picked up by jest.config.js's
  `roots: ['<rootDir>/src']` regardless of this merge. Wired it into
  `npm test` via `node --experimental-strip-types --test test/*.test.ts`
  so it actually runs going forward instead of silently never executing.
Eras256 pushed a commit that referenced this pull request Aug 25, 2026
…sh & deduplication (#61)

Resolved the merge conflict against current main (package.json/tsconfig.json/package-lock.json — jest/ts-jest infra added by #53 after this branch was opened). Also dropped an unused viem dependency this branch had picked up, and fixed something the conflict exposed: test/ws-resilient.test.ts uses Node's built-in test runner, not Jest, so it was never actually being executed by `npm test` — wired it in properly. Verified: 43/43 tests passing (41 Jest + 2 real WebSocket reconnect/dedup tests via node --test).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Advanced] Metrics endpoint for x402Serve (requests, settlements, revenue, latency)

2 participants