Skip to content

feat: network-awarness [rn]#24

Merged
choudlet merged 6 commits into
mainfrom
chrish/sc-36910/story-network-reachability-awareness
Apr 10, 2026
Merged

feat: network-awarness [rn]#24
choudlet merged 6 commits into
mainfrom
chrish/sc-36910/story-network-reachability-awareness

Conversation

@choudlet

@choudlet choudlet commented Apr 8, 2026

Copy link
Copy Markdown
Collaborator

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.

  • New NetworkMonitor TypeScript wrapper (src/analytics/utils/networkMonitor.ts) — wraps native bridge module with NetworkReachability interface, StubNetworkMonitor test double, and graceful fallback to always-connected when native module unavailable
  • iOS native module (ios/MetaRouterNetworkMonitor.m) — NWPathMonitor on a background serial queue, emits onConnectivityChange events via NativeEventEmitter
  • Android native module (android/.../MetaRouterNetworkMonitorModule.kt) — ConnectivityManager.NetworkCallback with NET_CAPABILITY_INTERNET, ACCESS_NETWORK_STATE permission declared
  • CircuitBreaker.reset() — clears all stale state (failures, openCount, backoff) on offline→online transition
  • Dispatcher network guardisNetworkAvailable callback short-circuits flush loop when offline; resetCircuitBreaker() exposed for reconnect
  • MetaRouterAnalyticsClient wiring — DI via deps constructor param, subscribes to network changes in init(), resets circuit breaker + triggers flush on reconnect, networkStatus in getDebugInfo(), cleanup in reset()
  • maxOfflineDiskEvents added to InitOptions (plumbed through for PR 2: offline disk overflow)

Key design decisions

  • Flush loop continues ticking while offline; flush() short-circuits via isNetworkAvailable() guard (simpler than pause/resume, no conflicts with app lifecycle)
  • Native bridge modules use same platform APIs as iOS/Android native SDKs
  • Constructor deps param for DI keeps testing concerns out of InitOptions

Ticket: 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

  • 183/183 tests pass (npx jest --no-cache)
  • TypeScript compiles cleanly (npx tsc --noEmit)
  • StubNetworkMonitor: transitions, dedup, stop/unsub
  • NetworkMonitor: graceful fallback in test env
  • CircuitBreaker.reset(): OPEN→CLOSED, clears openCount/failures
  • Dispatcher: offline guard (no HTTP), online flush, resetCircuitBreaker
  • Client: networkStatus in debugInfo, offline→online flush, reset cleanup
  • Manual: verify iOS NWPathMonitor events in RN app
  • Manual: verify Android ConnectivityManager events in RN app

choudlet and others added 4 commits April 7, 2026 19:58
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

native kotlin monitor


override fun getName(): String = "MetaRouterNetworkMonitor"

@Volatile

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

grab reference to connectivity service

@Volatile
private var isConnected: Boolean = true

private var connectivityManager: ConnectivityManager? = null

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allow null for graceful degradation if not avail

}

// Snapshot initial state
connectivityManager?.let { cm ->

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this .let means skip entire block if null


// Snapshot initial state
connectivityManager?.let { cm ->
isConnected = try {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sets initial state

}

override fun onLost(network: Network) {
val activeNet = cm.activeNetwork

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this sendEvent is sending the event across the react native bridge 💪 🌉 .

) == true
if (!hasInternet && isConnected) {
isConnected = false
sendEvent(false)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send false, no longer connected


try {
cm.registerNetworkCallback(request, cb)
networkCallback = cb

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

register with the service then save callback for cleanup


private fun sendEvent(connected: Boolean) {
val params = Arguments.createMap().apply {
putBoolean("isConnected", connected)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defining shape of JS bridge event {isConnected: boolean}

}
}

@ReactMethod

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

react methods we expose

promise.resolve(isConnected)
}

@ReactMethod

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to have these events otherwise there are warnings, no-op

// Required for RN event emitter
}

override fun invalidate() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unregister callback on termination

import com.facebook.react.uimanager.ViewManager

class MetaRouterQueueStoragePackage : ReactPackage {
class MetaRouterPackage : ReactPackage {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" />

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

objective c implentation


@implementation MetaRouterNetworkMonitor {
nw_path_monitor_t _monitor;
dispatch_queue_t _monitorQueue;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_monitorQueue = dispatch_queue_create("com.metarouter.network.monitor", DISPATCH_QUEUE_SERIAL);
_monitor = nw_path_monitor_create();

__weak typeof(self) weakSelf = self;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nw path gives us overall status. not wifi vs. cellular

@@ -1,6 +1,6 @@
import CircuitBreaker from "./circuitBreaker";

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quotes cleanup again

expect(breaker.nextAllowedAt()).toBe(nowMs + 400);
});

describe('reset()', () => {

@choudlet choudlet Apr 9, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

testing the reset circuit breaker function we added that is utilized with online to offline transitions


constructor() {
try {
const { NativeModules, NativeEventEmitter } = require('react-native');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if a real event comes through we use that rather than initial state from connection

}
}

export class StubNetworkMonitor implements NetworkReachability {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for testing

});

describe('network awareness', () => {
it('events enqueue while offline, no HTTP attempts', async () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't make requests while offline 🎊

constructor(options: InitOptions) {
constructor(
options: InitOptions,
deps?: { networkMonitor?: NetworkReachability }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DI for testing

Comment thread src/analytics/types.ts
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) */

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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') {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm adding a debounce to this in an upcoming story : sc-37713

@choudlet choudlet force-pushed the chrish/sc-36910/story-network-reachability-awareness branch from 4ff5823 to 9c3abf3 Compare April 9, 2026 22:02

@brandon-metarouter brandon-metarouter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log?

.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("onConnectivityChange", params)
} catch (_: Exception) {
// JS runtime not ready yet — ignore

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log?

Comment thread src/analytics/utils/networkMonitor.ts Outdated
}
})
.catch(() => {
/* fallback: stay connected */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, just Logging checks (if needed)


/** Simulate a network transition from tests */
simulate(status: NetworkStatus): void {
if (status !== this._currentStatus) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This simulate is checking if (status !== this._currentStatus) 💪

@choudlet choudlet merged commit 54dd4a7 into main Apr 10, 2026
2 checks passed
choudlet added a commit that referenced this pull request Apr 10, 2026
* 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

---------
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants