Skip to content

Commit 8977e48

Browse files
committed
feat(observability): correlate agent relay delivery
Signed-off-by: Tal Weiss <major.tal@gmail.com>
1 parent 119a848 commit 8977e48

9 files changed

Lines changed: 238 additions & 30 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/buzz-acp/src/relay.rs

Lines changed: 122 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ use std::time::Duration;
2929
const EVENT_CHANNEL_CAPACITY_DEFAULT: usize = 256;
3030
/// Capacity of the command channel from harness to background task.
3131
const CMD_CHANNEL_CAPACITY: usize = 64;
32+
/// Upgrade-response header emitted by buzz-relay for joining client and server
33+
/// diagnostics without exposing a tenant or identity.
34+
const RELAY_CONNECTION_ID_HEADER: &str = "x-buzz-connection-id";
3235

3336
/// Read the event channel capacity from the environment, falling back to the
3437
/// compiled-in default. Parsed once at call-site (connect time).
@@ -121,7 +124,7 @@ use buzz_core::kind::{
121124
KIND_TYPING_INDICATOR,
122125
};
123126
use futures_util::{SinkExt, StreamExt};
124-
use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag};
127+
use nostr::{Event, EventBuilder, Keys, Kind, RelayUrl, Tag, Timestamp};
125128
use serde_json::{json, Value};
126129
use tokio::sync::mpsc;
127130
use tokio::time::timeout;
@@ -620,7 +623,7 @@ impl HarnessRelay {
620623
// jittered backoff. A terminal error (bad URL, bad auth tag,
621624
// rejected/invalid signing key) fails immediately — see
622625
// `is_terminal_connect_error`.
623-
let (ws, handshake_buffer) =
626+
let (ws, handshake_buffer, relay_connection_id) =
624627
retry_initial_connect(|| do_connect(relay_url, keys, auth_tag.as_ref())).await?;
625628

626629
let (event_tx, event_rx) = mpsc::channel::<Option<BuzzEvent>>(event_channel_capacity());
@@ -644,6 +647,7 @@ impl HarnessRelay {
644647
bg_relay_url,
645648
bg_agent_pubkey_hex,
646649
bg_auth_tag,
650+
relay_connection_id,
647651
)
648652
.await;
649653
});
@@ -990,6 +994,15 @@ impl TwoGenDedup {
990994

991995
/// State maintained by the background WebSocket task.
992996
struct BgState {
997+
/// Opaque connection ID supplied by the relay in the WebSocket upgrade.
998+
/// This joins harness logs to one exact server-side connection without
999+
/// logging message content or client identity.
1000+
relay_connection_id: Option<String>,
1001+
/// Monotonic socket generation within this harness process.
1002+
connection_generation: u64,
1003+
/// Relay connection establishment time, used to identify events that
1004+
/// predate the current socket and therefore likely arrived via replay.
1005+
connected_at: u64,
9931006
/// Active subscriptions: channel_id → subscription_id string.
9941007
active_subscriptions: HashMap<Uuid, String>,
9951008
/// Most recent `created_at` timestamp seen per channel (for `since` filter).
@@ -1078,6 +1091,9 @@ struct BgState {
10781091
impl BgState {
10791092
fn new() -> Self {
10801093
Self {
1094+
relay_connection_id: None,
1095+
connection_generation: 0,
1096+
connected_at: 0,
10811097
active_subscriptions: HashMap::new(),
10821098
last_seen: HashMap::new(),
10831099
seen_ids: TwoGenDedup::new(SEEN_ID_LIMIT),
@@ -1102,6 +1118,12 @@ impl BgState {
11021118
}
11031119
}
11041120

1121+
fn note_connection(&mut self, relay_connection_id: Option<String>) {
1122+
self.relay_connection_id = relay_connection_id;
1123+
self.connection_generation = self.connection_generation.saturating_add(1);
1124+
self.connected_at = Timestamp::now().as_secs();
1125+
}
1126+
11051127
/// Record a received event for dedup and `since` tracking.
11061128
/// Returns `true` if the event is new (not a duplicate).
11071129
fn record_event(&mut self, channel_id: Uuid, event: &Event) -> bool {
@@ -1557,8 +1579,10 @@ async fn run_background_task(
15571579
relay_url: String,
15581580
agent_pubkey_hex: String,
15591581
auth_tag: Option<nostr::Tag>,
1582+
initial_relay_connection_id: Option<String>,
15601583
) {
15611584
let mut state = BgState::new();
1585+
state.note_connection(initial_relay_connection_id);
15621586

15631587
let handshake_ok = process_handshake_buffer(
15641588
&mut ws,
@@ -1822,11 +1846,20 @@ async fn run_background_task(
18221846
}
18231847
}
18241848
Some(Err(e)) => {
1825-
warn!("WebSocket error in background task: {e}");
1849+
warn!(
1850+
relay_connection_id = state.relay_connection_id.as_deref().unwrap_or("unavailable"),
1851+
connection_generation = state.connection_generation,
1852+
error = %e,
1853+
"WebSocket error in background task"
1854+
);
18261855
true
18271856
}
18281857
None => {
1829-
debug!("WebSocket stream ended");
1858+
warn!(
1859+
relay_connection_id = state.relay_connection_id.as_deref().unwrap_or("unavailable"),
1860+
connection_generation = state.connection_generation,
1861+
"WebSocket stream ended without a Close frame"
1862+
);
18301863
true
18311864
}
18321865
};
@@ -1953,7 +1986,12 @@ async fn run_background_task(
19531986
_ = ping_interval.tick() => {
19541987
if ping_sent && last_pong.elapsed() > PONG_TIMEOUT {
19551988
// No pong received after our last ping — connection is dead.
1956-
warn!("no pong received within {:?} — connection dead, reconnecting", PONG_TIMEOUT);
1989+
warn!(
1990+
relay_connection_id = state.relay_connection_id.as_deref().unwrap_or("unavailable"),
1991+
connection_generation = state.connection_generation,
1992+
timeout_secs = PONG_TIMEOUT.as_secs(),
1993+
"no pong received — connection dead, reconnecting"
1994+
);
19571995
// Use try_send to avoid blocking on backpressure during recovery.
19581996
let _ = event_tx.try_send(None);
19591997
match try_autonomous_reconnect(
@@ -2156,6 +2194,18 @@ async fn handle_ws_message(
21562194
let ts = event.created_at.as_secs();
21572195
let event_id_hex = event.id.to_hex();
21582196
if state.record_event(channel_id, &event) {
2197+
let received_at = Timestamp::now().as_secs();
2198+
debug!(
2199+
relay_connection_id = state.relay_connection_id.as_deref().unwrap_or("unavailable"),
2200+
connection_generation = state.connection_generation,
2201+
subscription_id = %subscription_id,
2202+
channel_id = %channel_id,
2203+
event_id = %event_id_hex,
2204+
event_created_at = ts,
2205+
delivery_age_secs = received_at.saturating_sub(ts),
2206+
event_predates_connection = ts < state.connected_at,
2207+
"relay channel event received"
2208+
);
21592209
let buzz_event = BuzzEvent {
21602210
channel_id,
21612211
event: *event,
@@ -2390,8 +2440,24 @@ async fn handle_ws_message(
23902440
}
23912441
true
23922442
}
2393-
Message::Close(_) => {
2394-
debug!("relay sent Close frame");
2443+
Message::Close(frame) => {
2444+
match frame {
2445+
Some(frame) => warn!(
2446+
relay_connection_id = state.relay_connection_id.as_deref().unwrap_or("unavailable"),
2447+
connection_generation = state.connection_generation,
2448+
close_code = ?frame.code,
2449+
close_reason = %frame.reason,
2450+
"relay sent Close frame"
2451+
),
2452+
None => warn!(
2453+
relay_connection_id = state
2454+
.relay_connection_id
2455+
.as_deref()
2456+
.unwrap_or("unavailable"),
2457+
connection_generation = state.connection_generation,
2458+
"relay sent Close frame without code or reason"
2459+
),
2460+
}
23952461
false
23962462
}
23972463
// Binary, Pong, Frame — ignore
@@ -2938,9 +3004,18 @@ async fn try_autonomous_reconnect(
29383004
backoffs.len()
29393005
);
29403006
match do_connect(relay_url, keys, auth_tag).await {
2941-
Ok((new_ws, handshake_buffer)) => {
3007+
Ok((new_ws, handshake_buffer, relay_connection_id)) => {
29423008
*ws = new_ws;
2943-
info!("autonomous reconnect succeeded (attempt {})", attempt + 1);
3009+
state.note_connection(relay_connection_id);
3010+
info!(
3011+
relay_connection_id = state
3012+
.relay_connection_id
3013+
.as_deref()
3014+
.unwrap_or("unavailable"),
3015+
connection_generation = state.connection_generation,
3016+
attempt = attempt + 1,
3017+
"autonomous reconnect succeeded"
3018+
);
29443019
let handshake_ok = process_handshake_buffer(
29453020
ws,
29463021
handshake_buffer,
@@ -3076,9 +3151,18 @@ async fn wait_for_reconnect(
30763151
loop {
30773152
info!("attempting relay reconnect to {relay_url}…");
30783153
match do_connect(relay_url, keys, auth_tag).await {
3079-
Ok((new_ws, handshake_buffer)) => {
3154+
Ok((new_ws, handshake_buffer, relay_connection_id)) => {
30803155
*ws = new_ws;
3081-
info!("relay reconnected to {relay_url}");
3156+
state.note_connection(relay_connection_id);
3157+
info!(
3158+
relay_connection_id = state
3159+
.relay_connection_id
3160+
.as_deref()
3161+
.unwrap_or("unavailable"),
3162+
connection_generation = state.connection_generation,
3163+
relay_url,
3164+
"relay reconnected"
3165+
);
30823166
let handshake_ok = process_handshake_buffer(
30833167
ws,
30843168
handshake_buffer,
@@ -3842,16 +3926,24 @@ async fn do_connect(
38423926
relay_url: &str,
38433927
keys: &Keys,
38443928
auth_tag: Option<&nostr::Tag>,
3845-
) -> Result<(WsStream, VecDeque<RelayMessage>), RelayError> {
3929+
) -> Result<(WsStream, VecDeque<RelayMessage>, Option<String>), RelayError> {
38463930
let parsed = relay_url
38473931
.parse::<url::Url>()
38483932
.map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;
38493933

3850-
let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str()))
3934+
let (ws, response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str()))
38513935
.await
38523936
.map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure
38533937
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
3854-
debug!("connected to relay at {relay_url}");
3938+
let relay_connection_id = response
3939+
.headers()
3940+
.get(RELAY_CONNECTION_ID_HEADER)
3941+
.and_then(|value| value.to_str().ok())
3942+
.map(str::to_owned);
3943+
info!(
3944+
relay_connection_id = relay_connection_id.as_deref().unwrap_or("unavailable"),
3945+
relay_url, "connected to relay"
3946+
);
38553947

38563948
let mut ws = ws;
38573949
let mut buffer: VecDeque<RelayMessage> = VecDeque::new();
@@ -3874,7 +3966,7 @@ async fn do_connect(
38743966
};
38753967

38763968
debug!("NIP-42 authentication successful (event {event_id})");
3877-
Ok((ws, buffer))
3969+
Ok((ws, buffer, relay_connection_id))
38783970
}
38793971

38803972
/// Wait for an `AUTH` challenge from the relay, buffering any other messages.
@@ -4400,6 +4492,21 @@ mod tests {
44004492
);
44014493
}
44024494

4495+
#[test]
4496+
fn connection_diagnostics_advance_generation_and_replace_relay_id() {
4497+
let mut state = BgState::new();
4498+
4499+
state.note_connection(Some("first".to_string()));
4500+
let first_connected_at = state.connected_at;
4501+
assert_eq!(state.connection_generation, 1);
4502+
assert_eq!(state.relay_connection_id.as_deref(), Some("first"));
4503+
4504+
state.note_connection(Some("second".to_string()));
4505+
assert_eq!(state.connection_generation, 2);
4506+
assert_eq!(state.relay_connection_id.as_deref(), Some("second"));
4507+
assert!(state.connected_at >= first_connected_at);
4508+
}
4509+
44034510
#[tokio::test]
44044511
async fn fresh_reconnect_preserves_gate_until_pending_replay_resumes() {
44054512
let (mut client, mut server) = test_ws_pair().await;

crates/buzz-pubsub/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ tracing = { workspace = true }
2121
thiserror = { workspace = true }
2222
nostr = { workspace = true }
2323
futures-util = { workspace = true }
24+
metrics = { workspace = true }
2425

2526
[dev-dependencies]
2627
tokio = { workspace = true }

crates/buzz-pubsub/src/subscriber.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,23 @@ pub(crate) async fn run_subscriber(
5656
// established and ran successfully, so reset backoff to the initial
5757
// value — a brief Redis restart should reconnect quickly.
5858
backoff_secs = BACKOFF_INITIAL_SECS;
59+
metrics::gauge!("buzz_pubsub_connected").set(0.0);
60+
metrics::counter!("buzz_pubsub_disconnects_total", "reason" => "clean")
61+
.increment(1);
5962
tracing::warn!("Redis pub/sub stream ended (clean disconnect) — reconnecting in {backoff_secs}s");
6063
}
6164
Err(e) => {
65+
metrics::gauge!("buzz_pubsub_connected").set(0.0);
66+
metrics::counter!("buzz_pubsub_disconnects_total", "reason" => "error")
67+
.increment(1);
6268
tracing::error!("Redis pub/sub error: {e} — reconnecting in {backoff_secs}s");
6369
}
6470
}
6571

6672
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
6773
backoff_secs = (backoff_secs * 2).min(BACKOFF_MAX_SECS);
6874

75+
metrics::counter!("buzz_pubsub_reconnect_attempts_total").increment(1);
6976
tracing::info!("Attempting to reconnect to Redis pub/sub...");
7077
}
7178
}
@@ -101,6 +108,9 @@ async fn connect_and_subscribe(
101108
topic_count = active_topics.len(),
102109
"Redis pub/sub subscriber connected with dynamic scoped subscriptions"
103110
);
111+
metrics::gauge!("buzz_pubsub_connected").set(1.0);
112+
metrics::counter!("buzz_pubsub_connections_total").increment(1);
113+
record_topic_gauges(&desired_topics, &active_topics).await;
104114

105115
loop {
106116
tokio::select! {
@@ -110,13 +120,15 @@ async fn connect_and_subscribe(
110120
let channel = topic.redis_channel();
111121
if active_topics.insert(channel.clone()) {
112122
sink.subscribe(&channel).await?;
123+
record_topic_gauges(&desired_topics, &active_topics).await;
113124
}
114125
}
115126
SubscriptionCommand::UnsubscribeIfIdle(topic) => {
116127
if desired_refcount(&desired_topics, topic).await == 0 {
117128
let channel = topic.redis_channel();
118129
if active_topics.remove(&channel) {
119130
sink.unsubscribe(&channel).await?;
131+
record_topic_gauges(&desired_topics, &active_topics).await;
120132
}
121133
}
122134
}
@@ -171,6 +183,17 @@ async fn connect_and_subscribe(
171183
}
172184
}
173185

186+
async fn record_topic_gauges(desired_topics: &DesiredTopics, active_topics: &HashSet<String>) {
187+
let desired_count = desired_topics
188+
.lock()
189+
.await
190+
.values()
191+
.filter(|count| **count > 0)
192+
.count();
193+
metrics::gauge!("buzz_pubsub_topics_desired").set(desired_count as f64);
194+
metrics::gauge!("buzz_pubsub_topics_active").set(active_topics.len() as f64);
195+
}
196+
174197
async fn desired_refcount(desired_topics: &DesiredTopics, topic: EventTopicKey) -> usize {
175198
desired_topics
176199
.lock()

crates/buzz-relay/src/connection.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,8 @@ pub async fn handle_connection(
125125
state: Arc<AppState>,
126126
addr: SocketAddr,
127127
tenant: TenantContext,
128+
conn_id: Uuid,
128129
) {
129-
let conn_id = Uuid::new_v4();
130130
let cancel = CancellationToken::new();
131131
let community_id = tenant.community();
132132
let registry = Arc::clone(&state.community_connections);

0 commit comments

Comments
 (0)