feat: network-awarness [rn]#24
Conversation
Add network connectivity awareness to the React Native SDK so events queue in memory while offline and flush on reconnect. - NetworkMonitor TS wrapper + native bridge modules (iOS NWPathMonitor, Android ConnectivityManager) - CircuitBreaker.reset() clears stale backoff on reconnect - Dispatcher guards flush with isNetworkAvailable() - MetaRouterAnalyticsClient wires network monitor via DI - Add maxOfflineDiskEvents to InitOptions (for PR 2) - Comprehensive tests for all new behavior Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| @@ -0,0 +1,119 @@ | |||
| package com.metarouter.reactnative | |||
There was a problem hiding this comment.
native kotlin monitor
|
|
||
| override fun getName(): String = "MetaRouterNetworkMonitor" | ||
|
|
||
| @Volatile |
There was a problem hiding this comment.
ensure that reads + writes go to main memory, ensures that if two threads are on diff cores we aren't reading stale cached copy. Helps to ensure we don't run into subtle caching issues.
| private var networkCallback: ConnectivityManager.NetworkCallback? = null | ||
|
|
||
| init { | ||
| connectivityManager = try { |
There was a problem hiding this comment.
grab reference to connectivity service
| @Volatile | ||
| private var isConnected: Boolean = true | ||
|
|
||
| private var connectivityManager: ConnectivityManager? = null |
There was a problem hiding this comment.
allow null for graceful degradation if not avail
| } | ||
|
|
||
| // Snapshot initial state | ||
| connectivityManager?.let { cm -> |
There was a problem hiding this comment.
this .let means skip entire block if null
|
|
||
| // Snapshot initial state | ||
| connectivityManager?.let { cm -> | ||
| isConnected = try { |
There was a problem hiding this comment.
sets initial state
| } | ||
|
|
||
| override fun onLost(network: Network) { | ||
| val activeNet = cm.activeNetwork |
There was a problem hiding this comment.
this is a bit smarter bc if wifi drops we can't just respond to the onLost event we need to check if cellular connection still exists via the activeNet
| override fun onAvailable(network: Network) { | ||
| if (!isConnected) { | ||
| isConnected = true | ||
| sendEvent(true) |
There was a problem hiding this comment.
this sendEvent is sending the event across the react native bridge 💪 🌉 .
| ) == true | ||
| if (!hasInternet && isConnected) { | ||
| isConnected = false | ||
| sendEvent(false) |
There was a problem hiding this comment.
send false, no longer connected
|
|
||
| try { | ||
| cm.registerNetworkCallback(request, cb) | ||
| networkCallback = cb |
There was a problem hiding this comment.
register with the service then save callback for cleanup
|
|
||
| private fun sendEvent(connected: Boolean) { | ||
| val params = Arguments.createMap().apply { | ||
| putBoolean("isConnected", connected) |
There was a problem hiding this comment.
defining shape of JS bridge event {isConnected: boolean}
| } | ||
| } | ||
|
|
||
| @ReactMethod |
There was a problem hiding this comment.
react methods we expose
| promise.resolve(isConnected) | ||
| } | ||
|
|
||
| @ReactMethod |
There was a problem hiding this comment.
need to have these events otherwise there are warnings, no-op
| // Required for RN event emitter | ||
| } | ||
|
|
||
| override fun invalidate() { |
There was a problem hiding this comment.
unregister callback on termination
| import com.facebook.react.uimanager.ViewManager | ||
|
|
||
| class MetaRouterQueueStoragePackage : ReactPackage { | ||
| class MetaRouterPackage : ReactPackage { |
There was a problem hiding this comment.
renaming bc now the native modules include more than just queue
| @@ -1,3 +1,4 @@ | |||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | |||
| package="com.metarouter.reactnative"> | |||
| <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> | |||
There was a problem hiding this comment.
we need this in the sdk now since we are accessing network state, this gets merged with the consuming app's manifest
| @@ -0,0 +1,76 @@ | |||
| #import <React/RCTBridgeModule.h> | |||
| #import <React/RCTEventEmitter.h> | |||
There was a problem hiding this comment.
objective c implentation
|
|
||
| @implementation MetaRouterNetworkMonitor { | ||
| nw_path_monitor_t _monitor; | ||
| dispatch_queue_t _monitorQueue; |
There was a problem hiding this comment.
for iOS we need to set up this queue so _isConnected is only ever read or written from one thread on a time. Objective c concurrency pattern
|
|
||
| RCT_EXPORT_MODULE() | ||
|
|
||
| + (BOOL)requiresMainQueueSetup { |
There was a problem hiding this comment.
don't initialize or block main thread while setting up
| if (self) { | ||
| _isConnected = YES; // optimistic default | ||
| _hasListeners = NO; | ||
| _monitorQueue = dispatch_queue_create("com.metarouter.network.monitor", DISPATCH_QUEUE_SERIAL); |
There was a problem hiding this comment.
info on the queue: https://en.wikipedia.org/wiki/Grand_Central_Dispatch
| _monitorQueue = dispatch_queue_create("com.metarouter.network.monitor", DISPATCH_QUEUE_SERIAL); | ||
| _monitor = nw_path_monitor_create(); | ||
|
|
||
| __weak typeof(self) weakSelf = self; |
There was a problem hiding this comment.
using weak reference here so that this can be properly deallocated
|
|
||
| __weak typeof(self) weakSelf = self; | ||
| nw_path_monitor_set_update_handler(_monitor, ^(nw_path_t path) { | ||
| __strong typeof(weakSelf) strongSelf = weakSelf; |
There was a problem hiding this comment.
if this is nil (so reference was deallocated) then we go ahead and return.
| __strong typeof(weakSelf) strongSelf = weakSelf; | ||
| if (!strongSelf) return; | ||
|
|
||
| BOOL connected = (nw_path_get_status(path) == nw_path_status_satisfied); |
There was a problem hiding this comment.
nw path gives us overall status. not wifi vs. cellular
| @@ -1,6 +1,6 @@ | |||
| import CircuitBreaker from "./circuitBreaker"; | |||
There was a problem hiding this comment.
quotes cleanup again
| expect(breaker.nextAllowedAt()).toBe(nowMs + 400); | ||
| }); | ||
|
|
||
| describe('reset()', () => { |
There was a problem hiding this comment.
testing the reset circuit breaker function we added that is utilized with online to offline transitions
|
|
||
| constructor() { | ||
| try { | ||
| const { NativeModules, NativeEventEmitter } = require('react-native'); |
There was a problem hiding this comment.
wire up native module
| // Get initial state from native — skip if a live event already arrived | ||
| MetaRouterNetworkMonitor.getCurrentStatus() | ||
| .then((connected: boolean) => { | ||
| if (this.receivedNativeEvent) return; |
There was a problem hiding this comment.
if a real event comes through we use that rather than initial state from connection
| } | ||
| } | ||
|
|
||
| export class StubNetworkMonitor implements NetworkReachability { |
| }); | ||
|
|
||
| describe('network awareness', () => { | ||
| it('events enqueue while offline, no HTTP attempts', async () => { |
There was a problem hiding this comment.
current behavior, we just queue in memory but will be adding writes to disk based queue in the upcoming story
| expect(d.getQueueRef().length).toBe(0); | ||
| }); | ||
|
|
||
| it('resetCircuitBreaker() resets circuit state', async () => { |
There was a problem hiding this comment.
covers case where circuit breaker is set to open (requests are paused), device goes offline, then on state change back online we want to clear state of circuit breaker to enable requests to freely flow.
|
|
||
| const doFlush = async () => { | ||
| while (this.queue.length) { | ||
| if (!this.opts.isNetworkAvailable()) { |
There was a problem hiding this comment.
don't make requests while offline 🎊
| constructor(options: InitOptions) { | ||
| constructor( | ||
| options: InitOptions, | ||
| deps?: { networkMonitor?: NetworkReachability } |
| debug?: boolean; | ||
| /** Max bytes (UTF-8) held in memory queue; oldest are dropped once cap is hit (default: 5MB) */ | ||
| maxQueueBytes?: number; | ||
| /** Max events stored on disk during extended offline periods (default: 10000) */ |
There was a problem hiding this comment.
this is just the plumbing for us to start flushing events to disk while offline in next story
| const wasOffline = this.networkStatus === 'disconnected'; | ||
| this.networkStatus = status; | ||
|
|
||
| if (wasOffline && status === 'connected') { |
There was a problem hiding this comment.
i'm adding a debounce to this in an upcoming story : sc-37713
4ff5823 to
9c3abf3
Compare
brandon-metarouter
left a comment
There was a problem hiding this comment.
Again, left just a few take it or leave it logging related comments.
| connectivityManager = try { | ||
| reactContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager | ||
| } catch (_: Exception) { | ||
| null |
There was a problem hiding this comment.
Log here for debugging or intentionally swallowing exception when setting connectivityManager to null?
| val caps = network?.let { cm.getNetworkCapabilities(it) } | ||
| caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true | ||
| } catch (_: Exception) { | ||
| true // fallback |
There was a problem hiding this comment.
Same here, wasn't sure if you wanted to log the exception at lest.
| cm.registerNetworkCallback(request, cb) | ||
| networkCallback = cb | ||
| } catch (_: SecurityException) { | ||
| // Missing ACCESS_NETWORK_STATE — fallback to always connected |
| .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) | ||
| .emit("onConnectivityChange", params) | ||
| } catch (_: Exception) { | ||
| // JS runtime not ready yet — ignore |
| } | ||
| }) | ||
| .catch(() => { | ||
| /* fallback: stay connected */ |
There was a problem hiding this comment.
Again, just Logging checks (if needed)
|
|
||
| /** Simulate a network transition from tests */ | ||
| simulate(status: NetworkStatus): void { | ||
| if (status !== this._currentStatus) { |
There was a problem hiding this comment.
This simulate is checking if (status !== this._currentStatus) 💪
* feat: network reachability monitor + dispatcher integration [rn] Add network connectivity awareness to the React Native SDK so events queue in memory while offline and flush on reconnect. - NetworkMonitor TS wrapper + native bridge modules (iOS NWPathMonitor, Android ConnectivityManager) - CircuitBreaker.reset() clears stale backoff on reconnect - Dispatcher guards flush with isNetworkAvailable() - MetaRouterAnalyticsClient wires network monitor via DI - Add maxOfflineDiskEvents to InitOptions (for PR 2) - Comprehensive tests for all new behavior * fix: review cleaning up race conditions * fix: package naming * fix: adding network monitor logging ---------
Summary
PR 1 of 2 of Network Awareness. This PR Adds in Network Monitoring + Awareness to stop transmitting events via HTTP when there is no network connection (offline mode).
The risk window is: extended offline + high event volume = dropped events. For most real-world mobile sessions (brief subway tunnels, elevator rides, spotty coverage), 2000 in-memory events is plenty. The Part 2 disk overflow is for the long-tail case — hours offline with continuous event generation.
NetworkMonitorTypeScript wrapper (src/analytics/utils/networkMonitor.ts) — wraps native bridge module withNetworkReachabilityinterface,StubNetworkMonitortest double, and graceful fallback to always-connected when native module unavailableios/MetaRouterNetworkMonitor.m) —NWPathMonitoron a background serial queue, emitsonConnectivityChangeevents viaNativeEventEmitterandroid/.../MetaRouterNetworkMonitorModule.kt) —ConnectivityManager.NetworkCallbackwithNET_CAPABILITY_INTERNET,ACCESS_NETWORK_STATEpermission declaredCircuitBreaker.reset()— clears all stale state (failures, openCount, backoff) on offline→online transitionisNetworkAvailablecallback short-circuits flush loop when offline;resetCircuitBreaker()exposed for reconnectMetaRouterAnalyticsClientwiring — DI viadepsconstructor param, subscribes to network changes ininit(), resets circuit breaker + triggers flush on reconnect,networkStatusingetDebugInfo(), cleanup inreset()maxOfflineDiskEventsadded toInitOptions(plumbed through for PR 2: offline disk overflow)Key design decisions
flush()short-circuits viaisNetworkAvailable()guard (simpler than pause/resume, no conflicts with app lifecycle)depsparam for DI keeps testing concerns out ofInitOptionsTicket: https://app.shortcut.com/metarouter/story/36910/network-monitoring-dispatcher-awareness-react-native
Pt. 2: https://app.shortcut.com/metarouter/story/37597/offline-disk-overflow-event-capping-react-native
Test plan
npx jest --no-cache)npx tsc --noEmit)