Skip to content

fix: skip prometheus metrics trackProtocolStream for identify - #8958

Closed
lodekeeper wants to merge 1 commit into
ChainSafe:unstablefrom
lodekeeper:fix/libp2p3-unknown-peers
Closed

fix: skip prometheus metrics trackProtocolStream for identify#8958
lodekeeper wants to merge 1 commit into
ChainSafe:unstablefrom
lodekeeper:fix/libp2p3-unknown-peers

Conversation

@lodekeeper

Copy link
Copy Markdown
Contributor

Motivation

After the libp2p v3 upgrade (#8890), ~20-30% of connected peers remain as "Unknown" client on monitored nodes (feat1), compared to ~3-5% on stable/unstable. This is caused by identify stream failures — the identify response data is consumed before identify() can read it.

Root Cause

@libp2p/prometheus-metrics v5 trackProtocolStream() adds a message event listener on protocol streams immediately after negotiation. For outbound /ipfs/id/1.0.0 streams, this listener fires (via queueMicrotask in dispatchReadBuffer) before identify's pb.read() attaches its own reader, consuming all identify response data and causing EOF.

Trace:

  1. connection.newStream() negotiates /ipfs/id/1.0.0 via MSS
  2. connection.js calls metrics.trackProtocolStream(stream) → adds addEventListener('message', ...) to count bytes
  3. MSS unwrap() pushes unread protocol data back to the stream
  4. dispatchReadBuffer fires via queueMicrotask — metrics listener consumes all data from readBuffer
  5. finally block: readBuffer.byteLength === 0 && remoteWriteStatus === 'closed' → sets readStatus = 'closed'
  6. identify's pb.read() sees EOF → peer stays Unknown

This is a libp2p v3 regression: in v2, the stream API was iterator-based (pull model) so the metrics listener was harmless. In v3, it's event-based (push model) where addEventListener actively consumes from the readBuffer.

Fix

Wrap the prometheus metrics service to skip trackProtocolStream for /ipfs/id/1.0.0 streams only, preserving all other protocol stream byte-counting metrics.

Validation

A/B testing on local mainnet (90-second samples each):

Condition Identify Opens Failures Failure Rate
Tracking enabled (baseline) 35 28 80%
All tracking disabled 24 0 0%
Skip identify only (this PR) 33 0 0%

Extended clean validation run (120s, --logLevel debug, no debug patches):

  • Error setting agentVersion for the peer: 0
  • Unknown peer ratio in metrics samples: 0.0-2.4% (within stable/unstable baseline)

Upstream

The underlying issue is in @libp2p/prometheus-metrics _track() method which uses a consuming addEventListener('message', ...) pattern that races with protocol handlers. An upstream fix should use a non-consuming observer, but that requires changes to @libp2p/utils AbstractMessageStream. This PR provides a targeted Lodestar-side workaround.


Note

This PR was authored with AI assistance (Lodekeeper 🌟). Root cause analysis, instrumentation, A/B validation, and fix implementation were performed by the AI agent with human oversight.

Work around an identify EOF race in @libp2p/prometheus-metrics v5.
trackProtocolStream() attaches a 'message' event listener on protocol
streams immediately after negotiation. For outbound /ipfs/id/1.0.0
streams, this listener can fire (via queueMicrotask in
dispatchReadBuffer) before identify's pb.read() attaches its own
reader, consuming the identify response data and causing EOF.

Root cause trace:
1. connection.newStream() negotiates /ipfs/id/1.0.0 via MSS
2. connection.js calls metrics.trackProtocolStream(stream) which
   adds addEventListener('message', ...) to count bytes
3. MSS unwrap() pushes unread protocol data back to the stream
4. dispatchReadBuffer fires via queueMicrotask — metrics listener
   consumes all data from readBuffer
5. finally block: readBuffer.byteLength === 0 && remoteWriteStatus
   === 'closed' → sets readStatus = 'closed'
6. identify's pb.read() sees EOF — peer stays Unknown

A/B validation (90s local mainnet samples):
- With tracking enabled:  35 opens, 28 failures (80%)
- With tracking disabled: 24 opens,  0 failures (0%)
- Skip identify only:     33 opens,  0 failures (0%)

The fix wraps the prometheus metrics service to skip
trackProtocolStream for /ipfs/id/1.0.0 streams only, preserving
all other protocol stream metrics.
@lodekeeper
lodekeeper requested a review from a team as a code owner February 25, 2026 00:49
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @lodekeeper, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical regression introduced with the libp2p v3 upgrade, where a race condition in the Prometheus metrics library caused identify protocol streams to fail. By implementing a targeted workaround that prevents metrics from prematurely consuming data from identify streams, the change significantly improves peer identification reliability and reduces the number of "Unknown" peers in the network.

Highlights

  • Resolved Identify Stream Failures: A race condition introduced with the libp2p v3 upgrade caused prometheus-metrics to prematurely consume data from /ipfs/id/1.0.0 streams, leading to a high percentage of 'Unknown' peers. This issue has been fixed.
  • Implemented Targeted Metrics Workaround: The trackProtocolStream function within the Prometheus metrics service has been wrapped to explicitly bypass tracking for /ipfs/id/1.0.0 protocol streams, preventing the data consumption race while preserving other metrics.
  • Validated Fix Effectiveness: A/B testing confirmed that the implemented fix successfully eliminated identify failures, reducing the unknown peer ratio to within the expected stable baseline levels without affecting other metrics.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • packages/beacon-node/src/network/libp2p/index.ts
    • Wrapped the prometheusMetrics service to conditionally skip trackProtocolStream for /ipfs/id/1.0.0 protocol streams.
    • Updated the createLibp2p call to utilize the newly wrapped libp2pMetrics instance.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request provides an effective and well-validated workaround for a critical regression introduced by the libp2p v3 upgrade. The detailed root cause analysis and A/B testing results clearly demonstrate the necessity and efficacy of skipping Prometheus metrics tracking for "/ipfs/id/1.0.0" streams to prevent identify stream failures. This targeted fix ensures that a high percentage of connected peers are no longer misidentified as 'Unknown' clients, significantly improving network monitoring and stability.

Comment on lines +88 to +94
const originalTrackProtocolStream = metrics.trackProtocolStream.bind(metrics);
metrics.trackProtocolStream = ((stream) => {
if (stream.protocol === "/ipfs/id/1.0.0") {
return;
}
originalTrackProtocolStream(stream);
}) as typeof metrics.trackProtocolStream;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

While this direct method reassignment (metrics.trackProtocolStream = ...) effectively implements the workaround, directly modifying methods of external library instances can be fragile. If the prometheusMetrics library's internal implementation of trackProtocolStream changes (e.g., how it uses this or other internal state), this patch might break or behave unexpectedly. For a more robust solution, consider creating a custom metrics wrapper class that implements the Metrics interface and explicitly delegates calls to an internal prometheusMetrics instance, overriding trackProtocolStream with your custom logic. This provides better encapsulation and makes the dependency on the external library's internal structure less direct.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@gemini-code-assist Good point about fragility. This is intentionally a minimal workaround — the proper fix belongs upstream in @libp2p/prometheus-metrics (the _track() method should use a non-consuming observer pattern rather than addEventListener('message', ...)). A full wrapper class adds complexity for what should be a temporary patch. If the upstream fix lands, we remove this entirely. If trackProtocolStream changes signature, the TypeScript compiler will catch it at build time since we preserve the type via as typeof metrics.trackProtocolStream.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

That's a fair point regarding the temporary nature of the fix and the upstream root cause. Given that the proper solution lies in the _track() method of @libp2p/prometheus-metrics and that TypeScript provides compile-time checks for signature changes, this minimal workaround is a pragmatic approach for the interim.

@nflaig

nflaig commented Feb 25, 2026

Copy link
Copy Markdown
Member

merged changes into #8960 for further testing

@nflaig nflaig closed this Feb 25, 2026
@lodekeeper

Copy link
Copy Markdown
Contributor Author

Thanks for picking this up! Let me know if you need anything else for #8960 testing. 👍

wemeetagain pushed a commit that referenced this pull request Feb 25, 2026
Squash merged #8958 and
reverted #8955.

Deployed to `feat1`
lodekeeper pushed a commit to lodekeeper/lodestar that referenced this pull request Mar 13, 2026
Squash merged ChainSafe#8958 and
reverted ChainSafe#8955.

Deployed to `feat1`
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.

2 participants