Skip to content

Commit 5a09dcd

Browse files
committed
Add optional transport for native-logger
- new module allows you to turn on native support to capture all network requests the app makes rather than just ones made within react native
1 parent db30e12 commit 5a09dcd

27 files changed

Lines changed: 1201 additions & 19 deletions

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,25 @@ If you are running another network logging interceptor, e.g. Reactotron, the log
194194
startNetworkLogging({ forceEnable: true });
195195
```
196196

197+
#### Native Transport Plugin (iOS + Android)
198+
199+
By default, requests are captured via JavaScript XHR interception (`transport: "js"`).
200+
To use native transport, install and register the plugin package:
201+
202+
```bash
203+
yarn add react-native-network-logger-native
204+
```
205+
206+
```ts
207+
import { startNetworkLogging } from 'react-native-network-logger';
208+
import { registerNativeNetworkLoggerTransport } from 'react-native-network-logger-native';
209+
210+
registerNativeNetworkLoggerTransport();
211+
startNetworkLogging({ transport: 'native' });
212+
```
213+
214+
The base package does not include native code, so native pods/gradle setup are only added when you install this plugin package.
215+
197216
#### Integrate with existing navigation
198217

199218
Use your existing back button (e.g. in your navigation header) to navigate within the network logger.
@@ -227,6 +246,23 @@ yarn example start
227246

228247
You should then be able to open the expo server at http://localhost:3000/ and launch the app on iOS or Android.
229248

249+
### Native Example (Expo prebuild)
250+
251+
For the native transport example, use the separate workspace:
252+
253+
```sh
254+
yarn example-native native:ios
255+
```
256+
257+
For Android:
258+
259+
```sh
260+
yarn example-native native:android
261+
```
262+
263+
`native:ios` runs Expo prebuild for iOS, installs pods, and launches the native iOS app.
264+
`native:android` runs Expo prebuild for Android and launches the native Android app.
265+
230266
For more setup and development details, see [Contributing](#Contributing).
231267

232268
## Why

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@
9999
}
100100
},
101101
"workspaces": [
102-
"example"
102+
"example",
103+
"react-native-network-logger-native"
103104
],
104105
"packageManager": "yarn@3.6.1",
105106
"release-it": {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# react-native-network-logger-native
2+
3+
Native transport plugin for `react-native-network-logger`.
4+
5+
## Usage
6+
7+
```ts
8+
import { startNetworkLogging } from 'react-native-network-logger';
9+
import { registerNativeNetworkLoggerTransport } from 'react-native-network-logger-native';
10+
11+
registerNativeNetworkLoggerTransport();
12+
startNetworkLogging({ transport: 'native' });
13+
```
14+
15+
The plugin registers a `native` transport that captures native networking (iOS `NSURLSession`, Android `OkHttp` used by React Native networking).
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
buildscript {
2+
repositories {
3+
google()
4+
mavenCentral()
5+
}
6+
dependencies {
7+
classpath("com.android.tools.build:gradle:8.6.0")
8+
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21")
9+
}
10+
}
11+
12+
apply plugin: 'com.android.library'
13+
apply plugin: 'org.jetbrains.kotlin.android'
14+
15+
android {
16+
namespace "com.rnnetworkloggernative"
17+
compileSdkVersion 35
18+
19+
defaultConfig {
20+
minSdkVersion 24
21+
targetSdkVersion 35
22+
}
23+
24+
compileOptions {
25+
sourceCompatibility JavaVersion.VERSION_17
26+
targetCompatibility JavaVersion.VERSION_17
27+
}
28+
29+
kotlinOptions {
30+
jvmTarget = '17'
31+
}
32+
33+
sourceSets {
34+
main {
35+
manifest.srcFile 'src/main/AndroidManifest.xml'
36+
}
37+
}
38+
}
39+
40+
repositories {
41+
google()
42+
mavenCentral()
43+
}
44+
45+
dependencies {
46+
implementation "com.facebook.react:react-android"
47+
implementation "org.jetbrains.kotlin:kotlin-stdlib"
48+
implementation "com.squareup.okhttp3:okhttp:4.12.0"
49+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2+
package="com.rnnetworkloggernative">
3+
</manifest>
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package com.rnnetworkloggernative
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.ReactApplicationContext
5+
import com.facebook.react.bridge.ReactContextBaseJavaModule
6+
import com.facebook.react.bridge.ReactMethod
7+
import com.facebook.react.bridge.WritableMap
8+
import com.facebook.react.modules.network.CustomClientBuilder
9+
import com.facebook.react.modules.network.NetworkingModule
10+
import java.io.IOException
11+
import java.util.UUID
12+
import okhttp3.Interceptor
13+
import okhttp3.MediaType
14+
import okhttp3.OkHttpClient
15+
import okhttp3.RequestBody
16+
import okhttp3.Response
17+
import okio.Buffer
18+
19+
class RNNetworkLoggerNativeModule(
20+
private val reactContext: ReactApplicationContext,
21+
) : ReactContextBaseJavaModule(reactContext) {
22+
23+
companion object {
24+
private const val MODULE_NAME = "RNNetworkLoggerNativeTransport"
25+
private const val MAX_BODY_BYTES = 1024L * 64L
26+
27+
private var isRunning = false
28+
private var interceptor: RNNetworkLoggerInterceptor? = null
29+
30+
private fun toUtf8Body(body: RequestBody?): String {
31+
if (body == null) return ""
32+
return try {
33+
val buffer = Buffer()
34+
body.writeTo(buffer)
35+
val size = buffer.size
36+
if (size > MAX_BODY_BYTES) {
37+
val clipped = buffer.readUtf8(MAX_BODY_BYTES)
38+
"$clipped... [truncated]"
39+
} else {
40+
buffer.readUtf8()
41+
}
42+
} catch (_: Exception) {
43+
""
44+
}
45+
}
46+
47+
private fun toUtf8ResponseBody(response: Response): String {
48+
val body = response.body ?: return ""
49+
return try {
50+
val peeked = response.peekBody(MAX_BODY_BYTES)
51+
peeked.string()
52+
} catch (_: Exception) {
53+
""
54+
}
55+
}
56+
57+
private fun contentType(mediaType: MediaType?): String {
58+
return mediaType?.toString() ?: ""
59+
}
60+
61+
private fun responseHeaders(response: Response): WritableMap {
62+
val map = Arguments.createMap()
63+
for ((name, value) in response.headers) {
64+
map.putString(name, value)
65+
}
66+
return map
67+
}
68+
69+
private fun requestHeaders(chainRequest: okhttp3.Request, requestId: String) {
70+
val headers = chainRequest.headers
71+
for (index in 0 until headers.size) {
72+
val headerName = headers.name(index)
73+
val headerValue = headers.value(index)
74+
emit(
75+
"networkLoggerRequestHeader",
76+
Arguments.createMap().apply {
77+
putString("id", requestId)
78+
putString("header", headerName)
79+
putString("value", headerValue)
80+
},
81+
)
82+
}
83+
}
84+
85+
private fun emit(event: String, payload: WritableMap) {
86+
val emitter = currentEmitter ?: return
87+
if (!emitter.reactApplicationContext.hasActiveCatalystInstance()) return
88+
emitter.reactApplicationContext
89+
.getJSModule(com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
90+
.emit(event, payload)
91+
}
92+
93+
private var currentEmitter: RNNetworkLoggerNativeModule? = null
94+
}
95+
96+
private class RNNetworkLoggerInterceptor : Interceptor {
97+
override fun intercept(chain: Interceptor.Chain): Response {
98+
val request = chain.request()
99+
val requestId = UUID.randomUUID().toString()
100+
101+
emit(
102+
"networkLoggerRequestOpen",
103+
Arguments.createMap().apply {
104+
putString("id", requestId)
105+
putString("method", request.method)
106+
putString("url", request.url.toString())
107+
},
108+
)
109+
110+
requestHeaders(request, requestId)
111+
112+
emit(
113+
"networkLoggerRequestSend",
114+
Arguments.createMap().apply {
115+
putString("id", requestId)
116+
putString("body", toUtf8Body(request.body))
117+
},
118+
)
119+
120+
return try {
121+
val response = chain.proceed(request)
122+
123+
emit(
124+
"networkLoggerResponseHeaders",
125+
Arguments.createMap().apply {
126+
putString("id", requestId)
127+
putString("contentType", contentType(response.body?.contentType()))
128+
putDouble("responseSize", response.body?.contentLength()?.coerceAtLeast(0)?.toDouble() ?: 0.0)
129+
putMap("responseHeaders", responseHeaders(response))
130+
},
131+
)
132+
133+
emit(
134+
"networkLoggerResponse",
135+
Arguments.createMap().apply {
136+
putString("id", requestId)
137+
putInt("status", response.code)
138+
putInt("timeout", 0)
139+
putString("response", toUtf8ResponseBody(response))
140+
putString("responseURL", response.request.url.toString())
141+
putString("responseType", "text")
142+
},
143+
)
144+
145+
response
146+
} catch (error: IOException) {
147+
emit(
148+
"networkLoggerResponse",
149+
Arguments.createMap().apply {
150+
putString("id", requestId)
151+
putInt("status", 0)
152+
putInt("timeout", 0)
153+
putString("response", error.message ?: "")
154+
putString("responseURL", request.url.toString())
155+
putString("responseType", "text")
156+
},
157+
)
158+
throw error
159+
}
160+
}
161+
}
162+
163+
override fun getName(): String {
164+
return MODULE_NAME
165+
}
166+
167+
override fun initialize() {
168+
super.initialize()
169+
currentEmitter = this
170+
}
171+
172+
override fun invalidate() {
173+
if (currentEmitter === this) {
174+
currentEmitter = null
175+
}
176+
super.invalidate()
177+
}
178+
179+
@ReactMethod
180+
fun start() {
181+
if (isRunning) return
182+
183+
interceptor = RNNetworkLoggerInterceptor()
184+
NetworkingModule.setCustomClientBuilder(
185+
CustomClientBuilder { builder: OkHttpClient.Builder ->
186+
interceptor?.let { builder.addNetworkInterceptor(it) }
187+
},
188+
)
189+
isRunning = true
190+
}
191+
192+
@ReactMethod
193+
fun stop() {
194+
if (!isRunning) return
195+
NetworkingModule.setCustomClientBuilder(null)
196+
interceptor = null
197+
isRunning = false
198+
}
199+
200+
@ReactMethod
201+
fun makeNativeTestRequest(url: String) {
202+
val client = OkHttpClient()
203+
val request = okhttp3.Request.Builder().url(url).build()
204+
client.newCall(request).enqueue(
205+
object : okhttp3.Callback {
206+
override fun onFailure(call: okhttp3.Call, e: IOException) {
207+
// no-op; this endpoint is only for validating interception.
208+
}
209+
210+
override fun onResponse(call: okhttp3.Call, response: Response) {
211+
response.close()
212+
}
213+
},
214+
)
215+
}
216+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.rnnetworkloggernative
2+
3+
import com.facebook.react.ReactPackage
4+
import com.facebook.react.bridge.NativeModule
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.uimanager.ViewManager
7+
8+
class RNNetworkLoggerNativePackage : ReactPackage {
9+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
10+
return listOf(RNNetworkLoggerNativeModule(reactContext))
11+
}
12+
13+
override fun createViewManagers(
14+
reactContext: ReactApplicationContext,
15+
): List<ViewManager<*, *>> {
16+
return emptyList()
17+
}
18+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#import <React/RCTBridgeModule.h>
2+
#import <React/RCTEventEmitter.h>
3+
4+
NS_ASSUME_NONNULL_BEGIN
5+
6+
@interface RNNetworkLoggerNativeTransport : RCTEventEmitter <RCTBridgeModule>
7+
+ (void)emitEvent:(NSString *)name body:(NSDictionary *)body;
8+
@end
9+
10+
NS_ASSUME_NONNULL_END

0 commit comments

Comments
 (0)