Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: add a better way to measure startup time #4

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,93 +11,73 @@
import com.facebook.react.bridge.ReactMarker;
import com.facebook.react.bridge.ReactMarkerConstants;
import com.facebook.react.uimanager.events.RCTEventEmitter;
import android.os.Process;
import android.os.SystemClock;
import java.math.BigInteger;
import android.util.Log;

@ReactModule(name = NativeInstrumentationModule.NAME)
public class NativeInstrumentationModule extends ReactContextBaseJavaModule implements RCTEventEmitter {
public static final String NAME = "NativeInstrumentation";
private static Long startTime = null;

private static Double cachedStartStartupTime = null;
private static Double cachedEndStartupTime = null;
private static Double cachedStartupDuration = null;
private static boolean hasAppRestarted = false;
private static int bundleLoadCounter = 0;

static {
ReactMarker.addListener((name, tag, instanceKey) -> {
long currentTime = System.currentTimeMillis();

if (name == ReactMarkerConstants.PRE_RUN_JS_BUNDLE_START) {
android.util.Log.d(NAME, String.format("JS bundle load started at: %d", currentTime));
initializeNativeInstrumentation();
if (!hasAppRestarted) {
if (bundleLoadCounter > 0) {
hasAppRestarted = true;
}
bundleLoadCounter++;
}
}
});
}

public NativeInstrumentationModule(ReactApplicationContext reactContext) {
super(reactContext);
android.util.Log.d(NAME, "Module constructor called");
}

@Override
public String getName() {
return NAME;
}

public static void initializeNativeInstrumentation() {
android.util.Log.d(NAME, "Initializing native instrumentation...");
cachedStartStartupTime = null;
cachedEndStartupTime = null;
cachedStartupDuration = null;
startTime = System.currentTimeMillis();
android.util.Log.d(NAME, String.format("Initialized with start time: %d (previous metrics cleared)", startTime));
}
@ReactMethod(isBlockingSynchronousMethod = true)
public double getStartupTimeSync() throws Exception {
try {
long currentTime = System.currentTimeMillis();
long processStartTime = Process.getStartUptimeMillis();
long currentUptime = SystemClock.uptimeMillis();

/**
* Creates a fresh WritableMap with startup metrics.
* Note: Each WritableMap can only be consumed once when passed through the React Native bridge.
* This method ensures we always create a new instance for each request.
*
* Each map can be consumed once by the JS side (i.e., going through the bridge).
*
* @return A new WritableMap instance containing the startup metrics
*/
private WritableMap createStartupMetricsMap(double startStartupTime, double endStartupTime, double startupDuration) {
WritableMap params = Arguments.createMap();
params.putDouble("startStartupTime", startStartupTime);
params.putDouble("endStartupTime", endStartupTime);
params.putDouble("startupDuration", startupDuration);
return params;
}
long startupTime = currentTime - currentUptime + processStartTime;

@ReactMethod
public void getStartupTime(Promise promise) {
android.util.Log.d(NAME, "Getting startup time...");
return BigInteger.valueOf(startupTime).doubleValue();
} catch (Exception e) {
Log.e(NAME, "Error calculating startup time", e);

if (startTime == null) {
android.util.Log.e(NAME, "Error: Start time was not initialized");
promise.reject("NO_START_TIME", "[NativeInstrumentation] Start time was not initialized");
return;
throw e;
}
}

if (cachedStartupDuration != null) {
android.util.Log.d(NAME, "Returning cached metrics");
promise.resolve(createStartupMetricsMap(cachedStartStartupTime, cachedEndStartupTime, cachedStartupDuration));
return;
}
@ReactMethod
public void getStartupTime(Promise promise) {
try {
WritableMap response = Arguments.createMap();

long endTime = System.currentTimeMillis();
double duration = (endTime - startTime) / 1000.0;
double startupTime = getStartupTimeSync();

android.util.Log.d(NAME, String.format(
"Calculating metrics - Start: %d, End: %d, Duration: %f seconds",
startTime, endTime, duration
));
response.putDouble("startupTime", startupTime);

cachedStartStartupTime = (double) startTime;
cachedEndStartupTime = (double) endTime;
cachedStartupDuration = duration;
promise.resolve(response);
} catch (Exception e) {
promise.reject("STARTUP_TIME_ERROR", "Failed to get startup time: " + e.getMessage(), e);
}
}

android.util.Log.d(NAME, "Metrics cached and being returned");
promise.resolve(createStartupMetricsMap(cachedStartStartupTime, cachedEndStartupTime, cachedStartupDuration));
@ReactMethod
public void getHasAppRestarted(Promise promise) {
promise.resolve(hasAppRestarted);
}

@ReactMethod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,13 @@
public class NativeInstrumentationPackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
android.util.Log.d("NativeInstrumentation", "Creating view managers (none needed)");
return Collections.emptyList();
}

@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
android.util.Log.d("NativeInstrumentation", "Creating native modules");
List<NativeModule> modules = new ArrayList<>();
modules.add(new NativeInstrumentationModule(reactContext));
android.util.Log.d("NativeInstrumentation", "Native instrumentation module added to modules list");
return modules;
}
}
3 changes: 2 additions & 1 deletion packages/react-native-tracing/ios/NativeInstrumentation.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ NS_ASSUME_NONNULL_BEGIN
- (void)getStartupTime:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject;

+ (void)initializeNativeInstrumentation;
- (void)getHasAppRestarted:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject;

@end

Expand Down
5 changes: 4 additions & 1 deletion packages/react-native-tracing/ios/NativeInstrumentation.m
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ @interface RCT_EXTERN_REMAP_MODULE(NativeInstrumentation, NativeInstrumentation,

RCT_EXTERN_METHOD(getStartupTime:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(getHasAppRestarted:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

@end
@end
90 changes: 58 additions & 32 deletions packages/react-native-tracing/ios/NativeInstrumentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,7 @@ import React

@objc(NativeInstrumentation)
public class NativeInstrumentation: NSObject, RCTBridgeModule {
private static var startTime: TimeInterval?
private static var cachedMetrics: [String: Double]?

@objc
public static func initializeNativeInstrumentation() {
NativeInstrumentation.cachedMetrics = nil
NativeInstrumentation.startTime = Date().timeIntervalSince1970
}

override init() {
super.init()
}
private static var hasAppRestarted: Bool = false

@objc
public static func requiresMainQueueSetup() -> Bool {
Expand All @@ -26,28 +15,65 @@ public class NativeInstrumentation: NSObject, RCTBridgeModule {
return "NativeInstrumentation"
}

@objc
public func getStartupTime(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
guard let startTime = NativeInstrumentation.startTime else {
reject("NO_START_TIME", "[NativeInstrumentation] Start time was not initialized", nil)
override init() {
super.init()
NotificationCenter.default.addObserver(
self,
selector: #selector(handleBundleLoadStart(_:)),
name: NSNotification.Name("RCTJavaScriptWillStartLoadingNotification"),
object: nil
)
}

@objc private func handleBundleLoadStart(_ notification: Notification) {
if NativeInstrumentation.hasAppRestarted {
return
}

if let metrics = NativeInstrumentation.cachedMetrics {
resolve(metrics)
return

NativeInstrumentation.hasAppRestarted = true
}

@objc
public func getStartupTime(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
lucasbento marked this conversation as resolved.
Show resolved Hide resolved
do {
var mib = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
var size = MemoryLayout<kinfo_proc>.size
var kp = kinfo_proc()

let result = mib.withUnsafeMutableBytes { mibBytes in
withUnsafeMutablePointer(to: &size) { sizeBytes in
withUnsafeMutablePointer(to: &kp) { kpBytes in
sysctl(mibBytes.baseAddress?.assumingMemoryBound(to: Int32.self), 4,
kpBytes,
sizeBytes,
nil, 0)
}
}
}

let startTimeMs: Int64
if result == 0 {
let startTime = kp.kp_proc.p_un.__p_starttime
startTimeMs = Int64(startTime.tv_sec) * 1000 + Int64(startTime.tv_usec) / 1000
} else {
throw NSError(domain: "NativeInstrumentation",
code: Int(result),
userInfo: [NSLocalizedDescriptionKey: "Failed to get process info"])
}

let response = ["startupTime": startTimeMs]
resolve(response)
} catch {
reject("STARTUP_TIME_ERROR", "Failed to get startup time: \(error.localizedDescription)", error)
}

let endTime = Date().timeIntervalSince1970
let duration = endTime - startTime

let metrics: [String: Double] = [
"startStartupTime": startTime,
"endStartupTime": endTime,
"startupDuration": duration
]

NativeInstrumentation.cachedMetrics = metrics
resolve(metrics)
}

@objc
public func getHasAppRestarted(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) {
resolve(NativeInstrumentation.hasAppRestarted)
}

deinit {
NotificationCenter.default.removeObserver(self)
}
}
26 changes: 18 additions & 8 deletions packages/react-native-tracing/src/wrapHOC.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
import React, { useEffect } from 'react';
// @ts-ignore
// eslint-disable-next-line import/namespace
import { Alert, NativeModules } from 'react-native';

import { api } from './dependencies';

interface StartupMetrics {
startStartupTime: number;
endStartupTime: number;
startupDuration: number;
startupTime: number;
}

// TODO(@lucasbento): figure out where to best place this function
const measureStartupTime = async (): Promise<void> => {
try {
const metrics: StartupMetrics =
await require('react-native')['NativeModules']['NativeInstrumentation'].getStartupTime();
const hasAppRestarted = await NativeModules['NativeInstrumentation'].getHasAppRestarted();

if (hasAppRestarted) {
return;
}

const metrics: StartupMetrics = await NativeModules['NativeInstrumentation'].getStartupTime();

const currentTime = Date.now();
const startupDuration = currentTime - metrics.startupTime;

api.pushMeasurement({
type: 'app_startup_time',
values: {
startup_duration: metrics.startupDuration,
startup_duration: startupDuration,
},
timestamp: new Date().toISOString(),
});
} catch (error) {
} catch (error: unknown) {
console.warn('[NativeInstrumentation] Failed to measure startup time:', error);
}
};
Expand All @@ -31,6 +40,7 @@ export function wrap<P extends object>(WrappedComponent: React.ComponentType<P>)
measureStartupTime();
}, []);

// @ts-ignore
return <WrappedComponent {...props} />;
};
}