Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion network/topics/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,13 @@ func (ctrl *topicsCtrl) setupTopicValidator(name string) error {

opts := []pubsub.ValidatorOpt{pubsub.WithValidatorTimeout(topicValidatorTimeout)}

err = ctrl.ps.RegisterTopicValidator(name, ctrl.msgValidator.ValidatorForTopic(name), opts...)
validator := ctrl.msgValidator.ValidatorForTopic(name)
wrappedValidator := func(ctx context.Context, p peer.ID, pmsg *pubsub.Message) pubsub.ValidationResult {

@momosh-ssv momosh-ssv Jun 12, 2026

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.

Might be worth a test that goes through the wrapper itself — the new test calls recordPubsubMessageReceived directly, so the wiring here stays unverified.

Registering the validator and pushing one message through it, then asserting the counter, would close that gap.

recordPubsubMessageReceived(ctx, name)
return validator(ctx, p, pmsg)
}

err = ctrl.ps.RegisterTopicValidator(name, wrappedValidator, opts...)
if err != nil {
return fmt.Errorf("could not register topic validator: %w", err)
}
Expand Down
22 changes: 22 additions & 0 deletions network/topics/observability.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package topics

import (
"context"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
Expand All @@ -12,6 +14,9 @@ import (
const (
observabilityName = "github.com/ssvlabs/ssv/network/topics"
observabilityNamespace = "ssv.p2p.messages"

pubsubObservabilityNamespace = "ssv.p2p.pubsub.messages"
pubsubTopicAttributeKey = "ssv.p2p.pubsub.topic"

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.

Attribute-key fragmentation for "topic name" (informational — not blocking)

This new key labels exactly the same logical thing (a libp2p topic name) as two existing keys in the codebase:

  • ssv.p2p.message.topic — used by inboundMessageCounter/outboundMessageCounter in this very file (messageTopicAttribute, line 55).
  • ssv.p2p.topic.name — used by peersPerTopicGauge in network/p2p/observability.go:91.

With this PR there are now three keys for the same value across the P2P metrics surface. The most natural correlation an operator wants — "received → inbound → peers per topic" — cannot be done with a single group by topic in PromQL; they'll need to remember which key applies to which counter.

I understand the rationale (the new ssv.p2p.pubsub.* namespace deserves its own attribute prefix), so I'm not suggesting a concrete change here — it's a design call. But it would be worth either reusing one of the existing keys, or opening a follow-up to consolidate all three under a single canonical key (e.g. ssv.p2p.topic.name).

)

var (
Expand All @@ -29,13 +34,23 @@ var (
metric.WithUnit("{message}"),
metric.WithDescription("total number of outbound(broadcasted) messages")))

pubsubMessagesReceivedCounter = metrics.New(
meter.Int64Counter(
observability.InstrumentName(pubsubObservabilityNamespace, "received"),
metric.WithUnit("{message}"),
metric.WithDescription("total number of messages delivered to the pubsub topic validator, before SSV validation runs (compare with ssv_p2p_messages_in_total for the post-validation rate)")))

msgIDHandlerBufferFallbackCounter = metrics.New(
meter.Int64Counter(
observability.InstrumentName(observabilityNamespace, "msg_id_buffer_fallback"),
metric.WithUnit("{event}"),
metric.WithDescription("total number of msg_id add operations processed synchronously because the async buffer was full")))
)

func pubsubTopicAttribute(value string) attribute.KeyValue {
return attribute.String(pubsubTopicAttributeKey, value)
}
Comment thread
julienharbulot marked this conversation as resolved.

func messageTopicAttribute(value string) attribute.KeyValue {
return attribute.String("ssv.p2p.message.topic", value)
}
Expand All @@ -46,3 +61,10 @@ func messageTypeAttribute(value uint64) attribute.KeyValue {
Value: observability.Uint64AttributeValue(value),
}
}

// recordPubsubMessageReceived is called from the topic validator wrapper before the inner SSV
// validator runs, so the counter increments for every message libp2p hands to the validator
// regardless of validation outcome (accept/ignore/reject/timeout).
func recordPubsubMessageReceived(ctx context.Context, topic string) {

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.

Minor: worth a 1-line comment to flag pre-validation timing

A future reader touching the wrapper in controller.go might assume this counts only successful deliveries. Calling out the timing explicitly here means they don't have to chase the call site to find out:

Suggested change
func recordPubsubMessageReceived(ctx context.Context, topic string) {
// recordPubsubMessageReceived is called from the topic validator wrapper before the inner SSV
// validator runs, so the counter increments for every message libp2p hands to the validator
// regardless of validation outcome (accept/ignore/reject/timeout).
func recordPubsubMessageReceived(ctx context.Context, topic string) {

pubsubMessagesReceivedCounter.Add(ctx, 1, metric.WithAttributes(pubsubTopicAttribute(topic)))
}
48 changes: 48 additions & 0 deletions network/topics/observability_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package topics

import (
"testing"

"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
)

func TestRecordPubsubMessageReceived(t *testing.T) {
reader := metric.NewManualReader()
provider := metric.NewMeterProvider(metric.WithReader(reader))
previousProvider := otel.GetMeterProvider()
otel.SetMeterProvider(provider)

@momosh-ssv momosh-ssv Jun 12, 2026

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.

Should we consider moving the provider swap into a TestMain, like protocol/v2/ssv/queue/main_test.go does?

Seems that setting the global provider mid-test leaves the package-level counters re-pointed for whatever runs after, and restoring the previous (delegating) provider in cleanup may not actually rebind them.

Setting a ManualReader-backed provider once before m.Run() would match the existing pattern and avoid the order dependence.

t.Cleanup(func() {
otel.SetMeterProvider(previousProvider)
require.NoError(t, provider.Shutdown(t.Context()))
})

const topic = "ssv.v2.42"
recordPubsubMessageReceived(t.Context(), topic)
recordPubsubMessageReceived(t.Context(), topic)

var rm metricdata.ResourceMetrics
require.NoError(t, reader.Collect(t.Context(), &rm))

for _, scopeMetrics := range rm.ScopeMetrics {
for _, m := range scopeMetrics.Metrics {
if m.Name != "ssv.p2p.pubsub.messages.received" {
continue
}

sum, ok := m.Data.(metricdata.Sum[int64])
require.True(t, ok)
require.Len(t, sum.DataPoints, 1)
require.EqualValues(t, 2, sum.DataPoints[0].Value)

topicAttr, ok := sum.DataPoints[0].Attributes.Value(pubsubTopicAttributeKey)
require.True(t, ok)
require.Equal(t, topic, topicAttr.AsString())
return
}
}

t.Fatal("pubsub received metric was not collected")
}