@@ -29,6 +29,9 @@ use std::time::Duration;
2929const EVENT_CHANNEL_CAPACITY_DEFAULT : usize = 256 ;
3030/// Capacity of the command channel from harness to background task.
3131const 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} ;
123126use futures_util:: { SinkExt , StreamExt } ;
124- use nostr:: { Event , EventBuilder , Keys , Kind , RelayUrl , Tag } ;
127+ use nostr:: { Event , EventBuilder , Keys , Kind , RelayUrl , Tag , Timestamp } ;
125128use serde_json:: { json, Value } ;
126129use tokio:: sync:: mpsc;
127130use 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.
992996struct 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 {
10781091impl 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 ;
0 commit comments