Skip to content

Commit fc20a34

Browse files
authored
feat: add gossipsub message payload event for raw wire-bytes capture (#877)
* feat: add gossipsub message payload event for raw wire-bytes capture Adds LIBP2P_TRACE_GOSSIPSUB_MESSAGE_PAYLOAD end-to-end: proto event and payload messages, clmimicry handler (opt-in via gossipSubMessagePayloadEnabled, off by default), event-ingester registration, ClickHouse table libp2p_gossipsub_message_payload, and the consumoor route. The table's sorting key and distributed sharding key are content-derived (message IDs are content hashes), so identical messages captured by multiple vantage points deduplicate on merge. Payload bytes are stored exactly as received off the wire, so capture works for any topic without a decoder, including validation-rejected messages. Claude-Session: https://claude.ai/code/session_01KDvdo7uVQpxKWgk2gLnWpE * refactor: append validated payload route fields unconditionally validate() already rejects events missing the wallclock fields or message ID, so the sentinel-zero fallback branches were unreachable and read as if missing data could silently insert epoch-zero timestamps. Claude-Session: https://claude.ai/code/session_01KDvdo7uVQpxKWgk2gLnWpE * refactor: drop observation-scoped columns from payload table The table deduplicates by content-derived message ID across capture clients, so per-observation columns were nondeterministic: whichever row survived the merge won. peer_id_unique_key and reject_reason are dropped (peer attribution and reject reasons live in libp2p_deliver_message and libp2p_reject_message), and outcome moves into the sorting key so a message delivered by one client and rejected by another keeps one row per outcome instead of racing. Claude-Session: https://claude.ai/code/session_01KDvdo7uVQpxKWgk2gLnWpE * refactor: drop outcome column from payload table The table stores message content keyed by content-derived message ID; validation outcome is a fact about an observation, not the bytes, and lives per-vantage in libp2p_deliver_message and libp2p_reject_message. This also collapses fork-contested messages (delivered by some clients, rejected by others) to a single row. The proto event keeps outcome and reject_reason for stream consumers. Claude-Session: https://claude.ai/code/session_01KDvdo7uVQpxKWgk2gLnWpE * fix: accept empty message data in payload handler A nil Data slice is a legitimate zero-byte message, and rejecting it meant exactly that class of message was never archived. Also documents that dedup is best-effort for messages received across wallclock slot or partition boundaries. Claude-Session: https://claude.ai/code/session_01KDvdo7uVQpxKWgk2gLnWpE
1 parent a83a609 commit fc20a34

22 files changed

Lines changed: 6161 additions & 4212 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
DROP TABLE IF EXISTS libp2p_gossipsub_message_payload ON CLUSTER '{cluster}';
2+
DROP TABLE IF EXISTS libp2p_gossipsub_message_payload_local ON CLUSTER '{cluster}';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
CREATE TABLE IF NOT EXISTS libp2p_gossipsub_message_payload_local ON CLUSTER '{cluster}'
2+
(
3+
`updated_date_time` DateTime COMMENT 'Timestamp when the record was last updated' CODEC(DoubleDelta, ZSTD(1)),
4+
`event_date_time` DateTime64(3) COMMENT 'Timestamp of the event' CODEC(DoubleDelta, ZSTD(1)),
5+
`wallclock_slot` UInt32 COMMENT 'Slot number of the wall clock when the message was received' CODEC(DoubleDelta, ZSTD(1)),
6+
`wallclock_slot_start_date_time` DateTime COMMENT 'Start date and time of the wall clock slot when the message was received' CODEC(DoubleDelta, ZSTD(1)),
7+
`wallclock_epoch` UInt32 COMMENT 'Epoch number of the wall clock when the message was received' CODEC(DoubleDelta, ZSTD(1)),
8+
`wallclock_epoch_start_date_time` DateTime COMMENT 'Start date and time of the wall clock epoch when the message was received' CODEC(DoubleDelta, ZSTD(1)),
9+
`topic_layer` LowCardinality(String) COMMENT 'Layer of the topic',
10+
`topic_fork_digest_value` LowCardinality(String) COMMENT 'Fork digest value of the topic',
11+
`topic_name` LowCardinality(String) COMMENT 'Name of the topic',
12+
`topic_encoding` LowCardinality(String) COMMENT 'Encoding of the topic',
13+
`message_id` String COMMENT 'Gossipsub message ID, derived from the message contents' CODEC(ZSTD(1)),
14+
`message_size` UInt32 COMMENT 'Size of the message payload in bytes' CODEC(ZSTD(1)),
15+
`message_data` String COMMENT 'Raw gossipsub message payload as received off the wire (snappy-framed SSZ)' CODEC(ZSTD(1)),
16+
`meta_client_name` LowCardinality(String) COMMENT 'Name of the client that generated the event',
17+
`meta_client_version` LowCardinality(String) COMMENT 'Version of the client that generated the event',
18+
`meta_client_implementation` LowCardinality(String) COMMENT 'Implementation of the client that generated the event',
19+
`meta_client_os` LowCardinality(String) COMMENT 'Operating system of the client that generated the event',
20+
`meta_client_ip` Nullable(IPv6) COMMENT 'IP address of the client that generated the event' CODEC(ZSTD(1)),
21+
`meta_client_geo_city` LowCardinality(String) COMMENT 'City of the client that generated the event' CODEC(ZSTD(1)),
22+
`meta_client_geo_country` LowCardinality(String) COMMENT 'Country of the client that generated the event' CODEC(ZSTD(1)),
23+
`meta_client_geo_country_code` LowCardinality(String) COMMENT 'Country code of the client that generated the event' CODEC(ZSTD(1)),
24+
`meta_client_geo_continent_code` LowCardinality(String) COMMENT 'Continent code of the client that generated the event' CODEC(ZSTD(1)),
25+
`meta_client_geo_longitude` Nullable(Float64) COMMENT 'Longitude of the client that generated the event' CODEC(ZSTD(1)),
26+
`meta_client_geo_latitude` Nullable(Float64) COMMENT 'Latitude of the client that generated the event' CODEC(ZSTD(1)),
27+
`meta_client_geo_autonomous_system_number` Nullable(UInt32) COMMENT 'Autonomous system number of the client that generated the event' CODEC(ZSTD(1)),
28+
`meta_client_geo_autonomous_system_organization` Nullable(String) COMMENT 'Autonomous system organization of the client that generated the event' CODEC(ZSTD(1)),
29+
`meta_network_name` LowCardinality(String) COMMENT 'Ethereum network name'
30+
)
31+
ENGINE = ReplicatedReplacingMergeTree('/clickhouse/{installation}/{cluster}/tables/{shard}/{database}/{table}', '{replica}', updated_date_time)
32+
PARTITION BY (meta_network_name, toDate(wallclock_slot_start_date_time))
33+
ORDER BY (meta_network_name, wallclock_slot_start_date_time, topic_fork_digest_value, topic_name, message_id)
34+
COMMENT 'Contains raw gossipsub message payloads keyed by message ID. Message IDs are content-derived, and the sorting key deliberately excludes observation-specific columns (peer, client, receive time, validation outcome) so identical messages captured by multiple clients deduplicate on merge. Deduplication is best-effort: clients that receive the same message on opposite sides of a wallclock slot or partition boundary keep one row per side. Per-observation detail lives in the libp2p_deliver_message and libp2p_reject_message tables.';
35+
36+
CREATE TABLE IF NOT EXISTS libp2p_gossipsub_message_payload ON CLUSTER '{cluster}'
37+
AS libp2p_gossipsub_message_payload_local
38+
ENGINE = Distributed('{cluster}', currentDatabase(), 'libp2p_gossipsub_message_payload_local', cityHash64(meta_network_name, topic_fork_digest_value, topic_name, message_id))
39+
COMMENT 'Contains raw gossipsub message payloads keyed by message ID. The sharding key is content-derived so duplicates from multiple capture clients land on the same shard and deduplicate.';

example-cl-mimicry.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ outputs:
130130
# gossipSubAttestationEnabled: false
131131
# gossipSubBlobSidecarEnabled: false
132132
# gossipSubDataColumnSidecarEnabled: false
133+
# gossipSubMessagePayloadEnabled: false
133134

134135
tracing:
135136
enabled: false

pkg/clickhouse/route/libp2p/libp2p_gossipsub_message_payload.gen.go

Lines changed: 216 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package libp2p
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/ethpandaops/xatu/pkg/clickhouse/route"
8+
"github.com/ethpandaops/xatu/pkg/proto/xatu"
9+
)
10+
11+
var libp2pGossipsubMessagePayloadEventNames = []xatu.Event_Name{
12+
xatu.Event_LIBP2P_TRACE_GOSSIPSUB_MESSAGE_PAYLOAD,
13+
}
14+
15+
func init() {
16+
r, err := route.NewStaticRoute(
17+
libp2pGossipsubMessagePayloadTableName,
18+
libp2pGossipsubMessagePayloadEventNames,
19+
func() route.ColumnarBatch { return newlibp2pGossipsubMessagePayloadBatch() },
20+
)
21+
if err != nil {
22+
route.RecordError(err)
23+
24+
return
25+
}
26+
27+
if err := route.Register(r); err != nil {
28+
route.RecordError(err)
29+
}
30+
}
31+
32+
func (b *libp2pGossipsubMessagePayloadBatch) FlattenTo(
33+
event *xatu.DecoratedEvent,
34+
) error {
35+
if event == nil || event.GetEvent() == nil {
36+
return nil
37+
}
38+
39+
if event.GetLibp2PTraceGossipsubMessagePayload() == nil {
40+
return fmt.Errorf("nil libp2p_trace_gossipsub_message_payload payload: %w", route.ErrInvalidEvent)
41+
}
42+
43+
if err := b.validate(event); err != nil {
44+
return err
45+
}
46+
47+
b.appendRuntime(event)
48+
b.appendMetadata(event)
49+
b.appendPayload(event)
50+
b.appendClientAdditionalData(event)
51+
b.rows++
52+
53+
return nil
54+
}
55+
56+
func (b *libp2pGossipsubMessagePayloadBatch) validate(event *xatu.DecoratedEvent) error {
57+
additional := event.GetMeta().GetClient().GetLibp2PTraceGossipsubMessagePayload()
58+
if additional == nil {
59+
return fmt.Errorf("nil additional data: %w", route.ErrInvalidEvent)
60+
}
61+
62+
if additional.GetMessageId() == nil {
63+
return fmt.Errorf("nil MessageId: %w", route.ErrInvalidEvent)
64+
}
65+
66+
if additional.GetWallclockSlot() == nil {
67+
return fmt.Errorf("nil WallclockSlot: %w", route.ErrInvalidEvent)
68+
}
69+
70+
if additional.GetWallclockEpoch() == nil {
71+
return fmt.Errorf("nil WallclockEpoch: %w", route.ErrInvalidEvent)
72+
}
73+
74+
return nil
75+
}
76+
77+
func (b *libp2pGossipsubMessagePayloadBatch) appendRuntime(event *xatu.DecoratedEvent) {
78+
b.UpdatedDateTime.Append(time.Now())
79+
80+
if ts := event.GetEvent().GetDateTime(); ts != nil {
81+
b.EventDateTime.Append(ts.AsTime())
82+
} else {
83+
b.EventDateTime.Append(time.Time{})
84+
}
85+
}
86+
87+
func (b *libp2pGossipsubMessagePayloadBatch) appendPayload(event *xatu.DecoratedEvent) {
88+
payload := event.GetLibp2PTraceGossipsubMessagePayload()
89+
90+
b.MessageData.AppendBytes(payload.GetData())
91+
}
92+
93+
//nolint:gosec // G115: proto uint64 values are bounded by ClickHouse column schema
94+
func (b *libp2pGossipsubMessagePayloadBatch) appendClientAdditionalData(
95+
event *xatu.DecoratedEvent,
96+
) {
97+
// validate() rejects the event before any append when the additional data,
98+
// wallclock fields, or message ID are missing, so required fields are
99+
// appended unconditionally rather than falling back to sentinel zeros.
100+
additional := event.GetMeta().GetClient().GetLibp2PTraceGossipsubMessagePayload()
101+
102+
wallclockSlot := additional.GetWallclockSlot()
103+
b.WallclockSlot.Append(uint32(wallclockSlot.GetNumber().GetValue()))
104+
b.WallclockSlotStartDateTime.Append(wallclockSlot.GetStartDateTime().AsTime())
105+
106+
wallclockEpoch := additional.GetWallclockEpoch()
107+
b.WallclockEpoch.Append(uint32(wallclockEpoch.GetNumber().GetValue()))
108+
b.WallclockEpochStartDateTime.Append(wallclockEpoch.GetStartDateTime().AsTime())
109+
110+
b.MessageID.Append(wrappedStringValue(additional.GetMessageId()))
111+
112+
if msgSize := additional.GetMessageSize(); msgSize != nil {
113+
b.MessageSize.Append(msgSize.GetValue())
114+
} else {
115+
b.MessageSize.Append(0)
116+
}
117+
118+
if topic := wrappedStringValue(additional.GetTopic()); topic != "" {
119+
parsed := parseTopicFields(topic)
120+
b.TopicLayer.Append(parsed.Layer)
121+
b.TopicForkDigestValue.Append(parsed.ForkDigestValue)
122+
b.TopicName.Append(parsed.Name)
123+
b.TopicEncoding.Append(parsed.Encoding)
124+
} else {
125+
b.TopicLayer.Append("")
126+
b.TopicForkDigestValue.Append("")
127+
b.TopicName.Append("")
128+
b.TopicEncoding.Append("")
129+
}
130+
}

0 commit comments

Comments
 (0)