24 KiB
Network Connectivity Observation System
Table of Contents
- Overview
- Architecture
- System Components
- Data Flow
- Technical Specifications
- Banner Display Logic
- Performance Detection Algorithm
- Configuration
Overview
The Network Connectivity Observation System is a comprehensive monitoring solution that tracks network performance and connectivity status in real-time, providing contextual feedback to users through an intelligent banner system.
Key Features
- Real-time Performance Monitoring: Tracks request latency to detect network degradation
- Early Detection System: Identifies slow requests before completion (2s threshold)
- Smart Banner Management: Context-aware banner display with anti-spam mechanisms
- Multi-Server Support: Independent tracking per server URL
- Lifecycle Management: Handles app state transitions (background/foreground)
Architecture
System Diagram
graph TB
subgraph NCSM["NetworkConnectivitySubscriptionManager<br/>Manages 5 Subscriptions"]
direction LR
SUB1[1. AppState]
SUB2[2. NetInfo]
SUB3[3. Active Servers]
SUB4[4. WebSocket State]
SUB5[5. Performance State]
end
NPM["NetworkPerformanceManager<br/>• Request Tracking<br/>• Performance Detection<br/>• Observable Streams<br/>• Sliding Window 20 requests"]
NCM["NetworkConnectivityManager<br/>• updateState()<br/>• updatePerformanceState()<br/>• Banner Priority Logic<br/>• Suppression Control"]
BM[BannerManager<br/>• Show/Hide Banner<br/>• Auto-Hide<br/>• Cleanup]
CB[ConnectionBanner<br/>FloatingBanner]
NCSM -->|updates| NCM
NCM -->|controls| BM
BM -->|renders| CB
style NCSM fill:#e1f5ff
style NCM fill:#fff4e1
style NPM fill:#f0e1ff
style BM fill:#d4edda
System Components
1. NetworkPerformanceManager
Purpose: Monitors request performance and detects network degradation
Key Responsibilities:
- Track active requests with unique IDs
- Detect slow requests (≥2000ms) early via timers
- Maintain sliding window of request outcomes (20 requests)
- Calculate performance state based on slow request percentage
- Emit performance state changes via RxJS observables
Public API:
class NetworkPerformanceManager {
// Start tracking a request, returns unique ID
startRequestTracking(serverUrl: string, url: string): string
// Complete tracking with metrics
completeRequestTracking(serverUrl: string, requestId: string, metrics: ClientResponseMetrics): void
// Cancel tracking on failure
cancelRequestTracking(serverUrl: string, requestId: string): void
// Observe performance state changes
observePerformanceState(serverUrl: string): Observable<NetworkPerformanceState>
// Get current state
getCurrentPerformanceState(serverUrl: string): NetworkPerformanceState
// Cleanup
removeServer(serverUrl: string): void
}
2. NetworkConnectivityManager
Purpose: Orchestrates banner display based on connectivity and performance states
Key Responsibilities:
- Maintain current WebSocket, network, and performance state
- Implement banner display priority logic
- Handle suppression mechanisms
- Manage first connection vs reconnection scenarios
Public API:
class NetworkConnectivityManager {
// Initialize with server URL
init(serverUrl: string | null): void
// Update connection status
setServerConnectionStatus(connected: boolean, serverUrl: string | null): void
// Update WebSocket/network state
updateState(
websocketState: WebsocketConnectedState,
netInfo: {isInternetReachable: boolean | null},
appState: string
): void
// Update performance state
updatePerformanceState(performanceState: NetworkPerformanceState): void
// Cleanup
cleanup(): void
shutdown(): void
}
3. NetworkConnectivitySubscriptionManager
Purpose: Coordinates all network-related subscriptions and server lifecycle
Key Responsibilities:
- Subscribe to app state changes (active/background)
- Subscribe to network info changes
- Subscribe to active server changes
- Subscribe to WebSocket state per server
- Subscribe to performance state per server
Public API:
class NetworkConnectivitySubscriptionManager {
// Initialize all subscriptions
init(): void
// Stop subscriptions (preserves AppState listener)
stop(): void
// Complete shutdown
shutdown(): void
}
4. ClientTracking (Enhanced)
Purpose: Integrate performance tracking into network requests
Integration Points:
class ClientTracking {
doFetchWithTracking = async (url: string, options: ClientOptions) => {
// 1. Start performance tracking
const performanceRequestId = this.startNetworkPerformanceTracking(url);
try {
// 2. Execute request
response = await request(url, this.buildRequestOptions(options));
// 3. Complete tracking with metrics
this.completeNetworkPerformanceTracking(performanceRequestId, url, response.metrics);
} catch (error) {
// 4. Cancel tracking on error
this.cancelNetworkPerformanceTracking(performanceRequestId);
throw error;
}
}
}
Data Flow
1. Request Performance Tracking Flow
flowchart TD
Start[HTTP Request Starts] --> CT[ClientTracking.doFetchWithTracking]
CT -->|startNetworkPerformanceTracking| NPM[NetworkPerformanceManager]
NPM -->|1. Generate unique requestId<br/>2. Set 2s timer<br/>3. Store in activeRequests| Decision{Request Outcome}
Decision -->|Completes < 2s| Complete[Record Outcome<br/>wasEarly: false]
Decision -->|Timer Triggers @ 2s| Early[Early Detection<br/>Record Outcome<br/>wasEarly: true]
Decision -->|Fails| Cancel[Cancel Tracking<br/>No outcome recorded]
Complete --> Update[Update Performance State]
Early --> Update
Update --> Steps[1. Add to outcomes window max 20<br/>2. Calculate slow %<br/>3. Determine state<br/>4. Emit if changed]
style NPM fill:#f0e1ff
style Update fill:#e1f5ff
style Complete fill:#d4edda
style Early fill:#fff3cd
style Cancel fill:#f8d7da
2. Subscription and State Flow
sequenceDiagram
participant Init as app/init/app.ts
participant NCSM as NetworkConnectivitySubscriptionManager
participant NCM as NetworkConnectivityManager
participant WSM as WebsocketManager
participant NPM as NetworkPerformanceManager
participant DB as Active Servers DB
Init->>NCM: 1. initialize()<br/>init(activeServerUrl)
Init->>NCSM: 2. start()<br/>init()
NCSM->>NCSM: AppState.addEventListener()
NCSM->>NCSM: NetInfo.addEventListener()
NCSM->>DB: subscribeActiveServers()
DB->>NCSM: Active Server Changed<br/>findMostRecentServer() → serverUrl
NCSM->>NCM: setServerConnectionStatus(true, serverUrl)
NCSM->>WSM: observeWebsocketState(serverUrl)
NCSM->>NPM: observePerformanceState(serverUrl)
loop State Updates
WSM-->>NCSM: Emit WebSocket State
NCSM->>NCM: updateState(websocketState, netInfo, appState)
NCM->>NCM: updateBanner()<br/>Priority Order:<br/>1. Disconnected<br/>2. Performance<br/>3. Connecting<br/>4. Reconnection<br/>5. Connected
NPM-->>NCSM: Emit Performance State
NCSM->>NCM: updatePerformanceState(performanceState)
NCM->>NCM: updateBanner()
end
Technical Specifications
Performance Detection Algorithm
Constants
const SLOW_REQUEST_THRESHOLD = 2000; // 2 seconds
const SLOW_REQUEST_PERCENTAGE_THRESHOLD = 0.7; // 70%
const REQUEST_OUTCOME_WINDOW_SIZE = 20; // Last 20 requests
const MINIMUM_REQUESTS_FOR_INITIAL_DETECTION = 4; // First detection
const MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION = 10; // Later detections
State Calculation Logic
function calculatePerformanceStateFromOutcomes(
outcomes: RequestOutcome[],
isInitialDetection: boolean
): NetworkPerformanceState {
const minimumRequests = isInitialDetection
? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION // 4 requests
: MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION; // 10 requests
// Not enough data yet for initial detection
if (isInitialDetection && outcomes.length < minimumRequests) {
return 'normal';
}
const slowRequestCount = outcomes.filter(o => o.isSlow).length;
const slowPercentage = slowRequestCount / outcomes.length;
// Slow if ≥70% of requests are slow
return slowPercentage >= SLOW_REQUEST_PERCENTAGE_THRESHOLD
? 'slow'
: 'normal';
}
Early Detection System
// When request tracking starts
startRequestTracking(serverUrl: string, url: string): string {
const requestId = generateUniqueId();
// Set timer for early detection
const checkTimer = setTimeout(() => {
// If still active after 2s, mark as slow
if (activeRequests[requestId]) {
recordRequestOutcome({
timestamp: Date.now(),
isSlow: true,
wasEarlyDetection: true // Flag for early detection
});
clearActiveRequest(requestId);
}
}, SLOW_REQUEST_THRESHOLD); // 2000ms
activeRequests[requestId] = { id, url, startTime, checkTimer };
return requestId;
}
// When request completes
completeRequestTracking(serverUrl: string, requestId: string, metrics: ClientResponseMetrics) {
const wasEarlyDetected = !activeRequests[requestId]; // Already removed by timer
clearActiveRequest(requestId);
// Only record if not already recorded by early detection
if (!wasEarlyDetected) {
recordRequestOutcome({
timestamp: Date.now(),
isSlow: metrics.latency >= SLOW_REQUEST_THRESHOLD,
wasEarlyDetection: false
});
}
}
Sliding Window Management
recordRequestOutcome(serverUrl: string, outcome: RequestOutcome) {
if (!requestOutcomes[serverUrl]) {
requestOutcomes[serverUrl] = [];
}
requestOutcomes[serverUrl].push(outcome);
// Keep only last 20 requests
if (requestOutcomes[serverUrl].length > REQUEST_OUTCOME_WINDOW_SIZE) {
requestOutcomes[serverUrl] = requestOutcomes[serverUrl].slice(-REQUEST_OUTCOME_WINDOW_SIZE);
}
// Recalculate and emit new state
const newState = calculatePerformanceStateFromOutcomes(
requestOutcomes[serverUrl],
isInitialDetection[serverUrl]
);
performanceSubject.next(newState);
}
Banner Display Logic
Priority Order (First Match Wins)
private updateBanner() {
// 0. Hide banner if no server or app in background
if (!currentServerUrl || appState === 'background') {
BannerManager.hideBanner();
return;
}
// 1. HIGHEST PRIORITY: Disconnected state
if (shouldShowDisconnectedBanner(websocketState, isOnAppStart)) {
showConnectivity(false); // Persistent banner
return;
}
// 2. Performance degradation
if (shouldShowPerformanceBanner(performanceState, performanceSuppressed)) {
showPerformance(10000); // Auto-hide after 10s
return;
}
// 3. Connecting state
if (shouldShowConnectingBanner(websocketState, isOnAppStart)) {
showConnectivity(false); // Persistent banner
return;
}
// 4. Reconnection success
if (shouldShowReconnectionBanner(websocketState, previousState, isOnAppStart)) {
showConnectivity(true, 3000); // Auto-hide after 3s
return;
}
// 5. LOWEST PRIORITY: Connected (hide banner)
BannerManager.hideBanner();
}
Banner Decision Functions
Disconnected Banner
shouldShowDisconnectedBanner(
websocketState: WebsocketConnectedState | null,
isOnAppStart: boolean
): boolean {
return websocketState === 'not_connected' && !isOnAppStart;
}
- Shows: When disconnected after initial connection
- Hides: On first connection (app start)
- Duration: Persistent until state changes
- Messages (internationalized):
- "The server is not reachable" (
connection_banner.not_reachable) - when internet is reachable but server is not - "Unable to connect to network" (
connection_banner.not_connected) - when internet is not reachable - "Connection status unknown" (
connection_banner.status_unknown) - when websocketState or netInfo is null
- "The server is not reachable" (
Performance Banner
shouldShowPerformanceBanner(
performanceState: NetworkPerformanceState | null,
performanceSuppressed: boolean
): boolean {
return performanceState === 'slow' && !performanceSuppressed;
}
- Shows: When performance degrades to 'slow'
- Suppression: User can dismiss; suppressed until performance returns to 'normal'
- Duration: Auto-hide after 10 seconds
- Message: "Limited network connection" (
connection_banner.limited_network_connection)
Connecting Banner
shouldShowConnectingBanner(
websocketState: WebsocketConnectedState | null,
isOnAppStart: boolean
): boolean {
return websocketState === 'connecting' && !isOnAppStart;
}
- Shows: When reconnecting after initial connection
- Hides: During first connection
- Duration: Persistent until state changes
- Message: "Connecting..." (
connection_banner.connecting)
Reconnection Banner
shouldShowReconnectionBanner(
websocketState: WebsocketConnectedState | null,
previousWebsocketState: WebsocketConnectedState | null,
isOnAppStart: boolean
): boolean {
return websocketState === 'connected' &&
previousWebsocketState !== 'connected' &&
!isOnAppStart;
}
- Shows: When connection restored after being disconnected/connecting
- Hides: On first connection
- Duration: Auto-hide after 3 seconds
- Message: "Connection restored" (
connection_banner.connected)
Message Internationalization
All banner messages are internationalized using formatMessage() for proper localization:
private getConnectionMessage(): string {
// Guard against uninitialized state
if (!this.websocketState || !this.netInfo) {
return this.intl.formatMessage({
id: 'connection_banner.status_unknown',
defaultMessage: 'Connection status unknown'
});
}
return getConnectionMessageText(
this.websocketState,
this.netInfo.isInternetReachable,
this.intl.formatMessage
);
}
Message Keys:
connection_banner.status_unknown- When websocketState or netInfo is nullconnection_banner.connected- Connection restoredconnection_banner.connecting- Connecting...connection_banner.not_reachable- Server not reachable (but internet is)connection_banner.not_connected- Unable to connect to networkconnection_banner.limited_network_connection- Performance degraded
Suppression Mechanisms
Performance Suppression
// User dismisses performance banner
onDismiss: () => {
this.performanceSuppressedUntilNormal = true;
}
// Reset when performance returns to normal
updatePerformanceState(performanceState: NetworkPerformanceState) {
if (this.performanceSuppressedUntilNormal && performanceState === 'normal') {
this.performanceSuppressedUntilNormal = false;
}
}
First Connection Logic
// isOnAppStart flag prevents banners on initial connection
updateState(websocketState, netInfo, appState) {
this.previousWebsocketState = this.websocketState;
this.websocketState = websocketState;
this.updateBanner();
// Clear flag only after first successful connection
if (websocketState === 'connected' && this.isOnAppStart) {
this.isOnAppStart = false;
}
}
Configuration
User Preference
Low Connectivity Monitor (User Setting):
- Accessible in: Settings → Advanced Settings → Experimental Features
- Controls real-time performance monitoring and banner display
- Enables early detection and network performance tracking
- Independent from
CollectNetworkMetrics - Stored in app-level database via
GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR - Default: Enabled (true)
- Observable via
observeLowConnectivityMonitor()from@queries/app/global
Implementation Details
Actions (app/actions/app/global.ts):
export const storeLowConnectivityMonitor = async (enabled: boolean) => {
return storeGlobal(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR, enabled, false);
};
Queries (app/queries/app/global.ts):
export const observeLowConnectivityMonitor = () => {
const query = queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR);
if (!query) {
return of$(true); // Default to enabled
}
return query.observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(true))),
switchMap((v) => {
if (typeof v === 'boolean') {
return of$(v);
}
return of$(v?.value ?? true);
}),
);
};
Integration in ClientTracking
class ClientTracking {
private lowConnectivityMonitorEnabled = true;
constructor(apiClient: APIClientInterface) {
this.apiClient = apiClient;
// Subscribe to user preference changes
observeLowConnectivityMonitor().subscribe((enabled) => {
this.lowConnectivityMonitorEnabled = enabled;
});
}
startNetworkPerformanceTracking(url: string): string | undefined {
if (!this.lowConnectivityMonitorEnabled) {
return undefined;
}
return NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url);
}
completeNetworkPerformanceTracking(
requestId: string | undefined,
url: string,
metrics: ClientResponseMetrics | undefined
) {
if (!this.lowConnectivityMonitorEnabled || !requestId || !metrics) {
return;
}
NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, requestId, metrics);
}
cancelNetworkPerformanceTracking(requestId: string | undefined) {
if (!this.lowConnectivityMonitorEnabled || !requestId) {
return;
}
NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, requestId);
}
}
Network metrics collection (existing - for telemetry):
if (groupLabel && CollectNetworkMetrics) {
this.incrementRequestCount(groupLabel);
this.trackRequest(groupLabel, url, response.metrics);
this.decrementRequestCount(groupLabel);
}
Network Manager Configuration
Metrics Collection (app/managers/network_manager.ts):
const config = {
sessionConfiguration: {
timeoutIntervalForRequest: managedConfig?.timeout ? parseInt(managedConfig.timeout, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForRequest,
timeoutIntervalForResource: managedConfig?.timeoutVPN ? parseInt(managedConfig.timeoutVPN, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForResource,
waitsForConnectivity: managedConfig?.useVPN === 'true',
collectMetrics: true, // Always enabled to support Low Connectivity Monitor
},
headers,
};
Note: collectMetrics is now always set to true to ensure network metrics are available for the Low Connectivity Monitor. The actual usage of these metrics is controlled dynamically by the user's Low Connectivity Monitor preference in ClientTracking.
Lifecycle Management
App State Transitions
// AppState: active → background
handleAppStateChange(nextAppState: AppStateStatus) {
if (nextAppState !== 'active') {
// 1. Clear all active request timers
clearAllActiveRequests();
// 2. Reset performance state to normal
performanceSubjects.forEach(subject => subject.next('normal'));
// 3. Hide all banners
BannerManager.hideBanner();
// 4. Stop subscriptions (except AppState listener)
NetworkConnectivitySubscriptionManager.stop();
}
}
// AppState: background → active
handleAppStateChange(nextAppState: AppStateStatus) {
if (nextAppState === 'active') {
// Restart all subscriptions
NetworkConnectivitySubscriptionManager.init();
}
}
Server Lifecycle
// Server added/activated
handleActiveServersChange(servers: Server[]) {
const activeServer = findMostRecentServer(servers);
if (activeServer) {
// 1. Set connection status
NetworkConnectivityManager.setServerConnectionStatus(true, activeServer.url);
// 2. Subscribe to WebSocket state
websocketSubscription = WebsocketManager
.observeWebsocketState(activeServer.url)
.subscribe(state => {
NetworkConnectivityManager.updateState(state, netInfo, appState);
});
// 3. Subscribe to performance state
performanceSubscription = NetworkPerformanceManager
.observePerformanceState(activeServer.url)
.subscribe(state => {
NetworkConnectivityManager.updatePerformanceState(state);
});
}
}
// Server removed/logout
terminateSession(serverUrl: string) {
// 1. Clear connection status
NetworkConnectivityManager.setServerConnectionStatus(false, serverUrl);
// 2. Remove performance tracking
NetworkPerformanceManager.removeServer(serverUrl);
// 3. Stop subscriptions
NetworkConnectivitySubscriptionManager.stop();
}
Performance Characteristics
Memory Usage
- Request Outcomes: Max 20 per server (sliding window)
- Active Requests: Cleared on completion or 2s timeout
- Timers: One per active request, auto-cleared
Detection Speed
- Early Detection: 2 seconds (timer-based)
- Initial Detection: 4-8 slow requests (4 minimum)
- Subsequent Detection: 10-20 requests (10 minimum with 70% threshold)
Banner Timing
- Disconnected: Immediate, persistent
- Connecting: Immediate, persistent
- Performance: After threshold met, auto-hide 10s
- Reconnected: Immediate, auto-hide 3s
Error Handling
Request Failure
try {
response = await request(url, options);
completeRequestTracking(requestId, response.metrics);
} catch (error) {
// Cancel tracking - no outcome recorded
cancelRequestTracking(requestId);
throw error;
}
Missing Metrics
completeRequestTracking(requestId, metrics) {
if (!metrics) {
// Silently skip - no outcome recorded
clearActiveRequest(requestId);
return;
}
// ... record outcome
}
Subscription Cleanup
// Safe to call multiple times
stop() {
websocketSubscription?.unsubscribe();
performanceSubscription?.unsubscribe();
netInfoSubscription?.();
activeServersUnsubscriber?.unsubscribe();
// Clear references
websocketSubscription = undefined;
performanceSubscription = undefined;
// ...
}
Testing Strategy
Unit Tests
- NetworkPerformanceManager: Request tracking, state calculation, sliding window
- NetworkConnectivityManager: Banner decision functions, state transitions
- NetworkConnectivitySubscriptionManager: Subscription lifecycle, server changes
Test Coverage
- Early detection (timer triggers)
- Request completion before/after threshold
- State transitions (normal ↔ slow)
- Banner priority order
- Suppression mechanisms
- App lifecycle (background/foreground)
- Server lifecycle (add/remove)
- Error scenarios (missing metrics, failed requests)
Future Enhancements
Potential Improvements
- Adaptive Thresholds: Adjust SLOW_REQUEST_THRESHOLD based on historical performance
- Network Type Awareness: Different thresholds for WiFi vs Cellular
- Exponential Backoff: For repeated performance banners
- Metrics Reporting: Send detection events to analytics
- User Preferences: Allow users to configure sensitivity
Known Limitations
- Single Server Focus: Only monitors most recently active server
- Fixed Thresholds: 2s threshold may not suit all use cases
- No Geographical Context: Doesn't account for server location
- Binary States: Only 'normal' vs 'slow' (no 'excellent' or 'poor')