@luciq/react-native 19.4.0 → 19.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/codebase-analyzer.md +33 -0
- package/.claude/agents/codebase-locator.md +42 -0
- package/.claude/agents/codebase-pattern-finder.md +40 -0
- package/.claude/commands/apply-pr-reviews.md +253 -0
- package/.claude/commands/create-jira-workitem.md +27 -0
- package/.claude/commands/create-pr.md +138 -0
- package/.claude/commands/create-public-release-notes.md +145 -0
- package/.claude/commands/create-rca.md +286 -0
- package/.claude/commands/debug-sdk.md +66 -0
- package/.claude/commands/describe-pr.md +40 -0
- package/.claude/commands/new-api.md +60 -0
- package/.claude/commands/new-feature.md +75 -0
- package/.claude/commands/pr-review.md +85 -0
- package/.claude/commands/research-codebase.md +41 -0
- package/.claude/commands/review.md +73 -0
- package/.claude/memory/MEMORY.md +1 -0
- package/.claude/memory/feedback_pr_title_format.md +10 -0
- package/.claude/rules/react-native-typescript.md +46 -0
- package/CHANGELOG.md +12 -0
- package/CLAUDE.md +125 -0
- package/android/native.gradle +1 -1
- package/android/src/main/java/ai/luciq/reactlibrary/LuciqScreenLoadingFrameTracker.java +88 -0
- package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqAPMModule.java +184 -10
- package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqReactnativeModule.java +5 -3
- package/dist/components/LuciqCaptureScreenLoading.d.ts +8 -0
- package/dist/components/LuciqCaptureScreenLoading.js +154 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/modules/APM.d.ts +19 -0
- package/dist/modules/APM.js +38 -0
- package/dist/modules/Luciq.d.ts +1 -1
- package/dist/modules/Luciq.js +169 -11
- package/dist/modules/apm/ScreenLoadingManager.d.ts +99 -0
- package/dist/modules/apm/ScreenLoadingManager.js +296 -0
- package/dist/native/NativeAPM.d.ts +9 -0
- package/dist/native/NativeLuciq.d.ts +1 -1
- package/dist/utils/LuciqUtils.d.ts +25 -0
- package/dist/utils/LuciqUtils.js +44 -0
- package/dist/utils/RouteMatcher.d.ts +30 -0
- package/dist/utils/RouteMatcher.js +67 -0
- package/ios/RNLuciq/LuciqAPMBridge.m +82 -0
- package/ios/RNLuciq/LuciqReactBridge.m +1 -1
- package/ios/RNLuciq/LuciqScreenLoadingFrameTracker.h +11 -0
- package/ios/RNLuciq/LuciqScreenLoadingFrameTracker.m +121 -0
- package/ios/RNLuciq/Util/LCQAPM+PrivateAPIs.h +14 -0
- package/ios/native.rb +1 -1
- package/package.json +4 -1
- package/scripts/get-github-app-token.sh +70 -0
- package/scripts/notify-github.sh +17 -8
- package/src/components/LuciqCaptureScreenLoading.tsx +210 -0
- package/src/index.ts +4 -0
- package/src/modules/APM.ts +42 -0
- package/src/modules/Luciq.ts +197 -11
- package/src/modules/apm/ScreenLoadingManager.ts +364 -0
- package/src/native/NativeAPM.ts +22 -0
- package/src/native/NativeLuciq.ts +1 -1
- package/src/utils/LuciqUtils.ts +49 -0
- package/src/utils/RouteMatcher.ts +83 -0
package/src/modules/Luciq.ts
CHANGED
|
@@ -29,6 +29,7 @@ import LuciqUtils, {
|
|
|
29
29
|
} from '../utils/LuciqUtils';
|
|
30
30
|
import * as NetworkLogger from './NetworkLogger';
|
|
31
31
|
import { captureUnhandledRejections } from '../utils/UnhandledRejectionTracking';
|
|
32
|
+
import { ScreenLoadingManager } from './apm/ScreenLoadingManager';
|
|
32
33
|
import type { ReproConfig } from '../models/ReproConfig';
|
|
33
34
|
import type { FeatureFlag } from '../models/FeatureFlag';
|
|
34
35
|
import { addAppStateListener } from '../utils/AppStatesHandler';
|
|
@@ -48,6 +49,13 @@ let isNativeInterceptionFeatureEnabled = false; // Checks the value of "cp_nativ
|
|
|
48
49
|
let hasAPMNetworkPlugin = false; // Android only: checks if the APM plugin is installed.
|
|
49
50
|
let shouldEnableNativeInterception = false; // For Android: used to disable APM logging inside reportNetworkLog() -> NativeAPM.networkLogAndroid(), For iOS: used to control native interception (true == enabled , false == disabled)
|
|
50
51
|
|
|
52
|
+
// Screen Loading tracking variables
|
|
53
|
+
let _navigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList> | null = null;
|
|
54
|
+
let _currentRoute: string | null = null;
|
|
55
|
+
let _activeNavigationSpanId: string | null = null;
|
|
56
|
+
let _stateChangeTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
57
|
+
const STATE_CHANGE_TIMEOUT_MS = 2000; // Safety timeout if state never changes
|
|
58
|
+
|
|
51
59
|
/**
|
|
52
60
|
* Enables or disables Luciq functionality.
|
|
53
61
|
* @param isEnabled A boolean to enable/disable Luciq.
|
|
@@ -116,7 +124,7 @@ export const init = (config: LuciqConfig) => {
|
|
|
116
124
|
reportCurrentViewForAndroid(firstScreen);
|
|
117
125
|
setTimeout(() => {
|
|
118
126
|
if (_currentScreen === firstScreen) {
|
|
119
|
-
NativeLuciq.reportScreenChange(firstScreen);
|
|
127
|
+
NativeLuciq.reportScreenChange(firstScreen, null);
|
|
120
128
|
_currentScreen = null;
|
|
121
129
|
}
|
|
122
130
|
}, 1000);
|
|
@@ -158,12 +166,14 @@ export const setWebViewUserInteractionsTrackingEnabled = (isEnabled: boolean) =>
|
|
|
158
166
|
* Handles app state changes and updates APM network flags if necessary.
|
|
159
167
|
*/
|
|
160
168
|
const handleAppStateChange = async (nextAppState: AppStateStatus, config: LuciqConfig) => {
|
|
161
|
-
// Checks if
|
|
169
|
+
// Checks if the app has come to the foreground
|
|
162
170
|
if (['inactive', 'background'].includes(_currentAppState) && nextAppState === 'active') {
|
|
163
171
|
const isUpdated = await fetchApmNetworkFlags();
|
|
164
172
|
if (isUpdated) {
|
|
165
173
|
refreshAPMNetworkConfigs(config);
|
|
166
174
|
}
|
|
175
|
+
// Refresh screen loading flags from native
|
|
176
|
+
await ScreenLoadingManager.refreshFlags();
|
|
167
177
|
}
|
|
168
178
|
|
|
169
179
|
_currentAppState = nextAppState;
|
|
@@ -756,6 +766,139 @@ export const onReportSubmitHandler = (handler?: (report: Report) => void) => {
|
|
|
756
766
|
NativeLuciq.setPreSendingHandler(handler);
|
|
757
767
|
};
|
|
758
768
|
|
|
769
|
+
/**
|
|
770
|
+
* Helper to clear the state change timeout
|
|
771
|
+
*/
|
|
772
|
+
const _clearStateChangeTimeout = (): void => {
|
|
773
|
+
if (_stateChangeTimeout) {
|
|
774
|
+
clearTimeout(_stateChangeTimeout);
|
|
775
|
+
_stateChangeTimeout = undefined;
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Handles React Navigation's __unsafe_action__ event
|
|
781
|
+
* This fires WHEN a navigation action is dispatched (the start of navigation)
|
|
782
|
+
*/
|
|
783
|
+
const _onNavigationAction = (event?: any): void => {
|
|
784
|
+
// Check for noop actions that shouldn't create spans
|
|
785
|
+
if (event?.data?.noop) {
|
|
786
|
+
Logger.log('[ScreenLoading] Navigation action is a noop, not starting span');
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Skip non-navigation actions (like SET_PARAMS, OPEN_DRAWER, etc.)
|
|
791
|
+
const actionType = event?.data?.action?.type;
|
|
792
|
+
if (
|
|
793
|
+
actionType &&
|
|
794
|
+
['SET_PARAMS', 'OPEN_DRAWER', 'CLOSE_DRAWER', 'TOGGLE_DRAWER'].includes(actionType)
|
|
795
|
+
) {
|
|
796
|
+
Logger.log(`[ScreenLoading] Skipping non-navigation action: ${actionType}`);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// If there's an existing active span, it means navigation was interrupted
|
|
801
|
+
// Discard the previous span as it never completed
|
|
802
|
+
if (_activeNavigationSpanId) {
|
|
803
|
+
Logger.log('[ScreenLoading] Discarding incomplete previous navigation span');
|
|
804
|
+
// Mark the span as cancelled/error since state change never occurred
|
|
805
|
+
const span = ScreenLoadingManager.getActiveSpan(_activeNavigationSpanId);
|
|
806
|
+
if (span) {
|
|
807
|
+
ScreenLoadingManager.endSpan(_activeNavigationSpanId);
|
|
808
|
+
}
|
|
809
|
+
_activeNavigationSpanId = null;
|
|
810
|
+
_clearStateChangeTimeout();
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Create a new span for this navigation action
|
|
814
|
+
// We don't know the destination screen yet, so use a placeholder name
|
|
815
|
+
if (ScreenLoadingManager.isFeatureEnabled()) {
|
|
816
|
+
const span = ScreenLoadingManager.createSpan('NavigationPending', false);
|
|
817
|
+
if (span) {
|
|
818
|
+
_activeNavigationSpanId = span.spanId;
|
|
819
|
+
Logger.log(`[ScreenLoading] Started span ${span.spanId} on navigation dispatch`);
|
|
820
|
+
|
|
821
|
+
// Set a safety timeout to discard the span if state never changes
|
|
822
|
+
// This prevents memory leaks from incomplete navigations
|
|
823
|
+
_stateChangeTimeout = setTimeout(() => {
|
|
824
|
+
if (_activeNavigationSpanId === span.spanId) {
|
|
825
|
+
Logger.warn(
|
|
826
|
+
`[ScreenLoading] Navigation span ${span.spanId} timed out - state never changed`,
|
|
827
|
+
);
|
|
828
|
+
ScreenLoadingManager.endSpan(span.spanId);
|
|
829
|
+
_activeNavigationSpanId = null;
|
|
830
|
+
}
|
|
831
|
+
}, STATE_CHANGE_TIMEOUT_MS);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Handles React Navigation's state event
|
|
838
|
+
* This fires AFTER the navigation state has changed (the screen is mounted)
|
|
839
|
+
*/
|
|
840
|
+
const _onNavigationStateChange = (): void => {
|
|
841
|
+
if (!_navigationRef?.current) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const previousRouteName = _currentRoute;
|
|
846
|
+
const currentRoute = _navigationRef.current.getCurrentRoute();
|
|
847
|
+
const currentRouteName = currentRoute?.name || null;
|
|
848
|
+
|
|
849
|
+
// If no route or same route, ignore
|
|
850
|
+
if (!currentRouteName || previousRouteName === currentRouteName) {
|
|
851
|
+
// Still need to clean up the span if one was created
|
|
852
|
+
if (_activeNavigationSpanId) {
|
|
853
|
+
Logger.log('[ScreenLoading] Navigation resulted in same route, discarding span');
|
|
854
|
+
ScreenLoadingManager.endSpan(_activeNavigationSpanId);
|
|
855
|
+
_activeNavigationSpanId = null;
|
|
856
|
+
_clearStateChangeTimeout();
|
|
857
|
+
}
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Capture the span ID BEFORE clearing it so we can pass it to reportScreenChange
|
|
862
|
+
let spanIdForReport: string | null = _activeNavigationSpanId;
|
|
863
|
+
|
|
864
|
+
// Complete the active navigation span if one exists
|
|
865
|
+
if (_activeNavigationSpanId) {
|
|
866
|
+
// Now that we know the actual route name, check if it's excluded
|
|
867
|
+
if (ScreenLoadingManager.isRouteExcluded(currentRouteName)) {
|
|
868
|
+
Logger.log(`[ScreenLoading] Route "${currentRouteName}" is excluded, discarding span`);
|
|
869
|
+
ScreenLoadingManager.discardSpan(_activeNavigationSpanId);
|
|
870
|
+
spanIdForReport = null;
|
|
871
|
+
_activeNavigationSpanId = null;
|
|
872
|
+
_clearStateChangeTimeout();
|
|
873
|
+
} else {
|
|
874
|
+
const span = ScreenLoadingManager.getActiveSpan(_activeNavigationSpanId);
|
|
875
|
+
if (span) {
|
|
876
|
+
// Update the span name from placeholder to actual screen name
|
|
877
|
+
span.screenName = currentRouteName;
|
|
878
|
+
|
|
879
|
+
// End the span - the native frame tracker will provide the actual render timestamp
|
|
880
|
+
ScreenLoadingManager.endSpan(_activeNavigationSpanId)
|
|
881
|
+
.then(() => {
|
|
882
|
+
Logger.log(`[ScreenLoading] Completed span for navigation to ${currentRouteName}`);
|
|
883
|
+
})
|
|
884
|
+
.catch((error) => {
|
|
885
|
+
Logger.warn('[ScreenLoading] Failed to end navigation span:', error);
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Clear the active span and timeout
|
|
890
|
+
_activeNavigationSpanId = null;
|
|
891
|
+
_clearStateChangeTimeout();
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Update the current route for the rest of Luciq's tracking
|
|
896
|
+
_currentRoute = currentRouteName;
|
|
897
|
+
|
|
898
|
+
// Report to native
|
|
899
|
+
NativeLuciq.reportScreenChange(currentRouteName, spanIdForReport);
|
|
900
|
+
};
|
|
901
|
+
|
|
759
902
|
export const onNavigationStateChange = (
|
|
760
903
|
prevState: NavigationStateV4,
|
|
761
904
|
currentState: NavigationStateV4,
|
|
@@ -765,17 +908,33 @@ export const onNavigationStateChange = (
|
|
|
765
908
|
const prevScreen = LuciqUtils.getActiveRouteName(prevState);
|
|
766
909
|
|
|
767
910
|
if (prevScreen !== currentScreen) {
|
|
911
|
+
// Start Screen Loading measurement for v4
|
|
912
|
+
let screenLoadingSpanId: string | null = null;
|
|
913
|
+
if (ScreenLoadingManager.isFeatureEnabled()) {
|
|
914
|
+
const span = ScreenLoadingManager.createSpan(currentScreen || 'Unknown', false);
|
|
915
|
+
if (span) {
|
|
916
|
+
screenLoadingSpanId = span.spanId;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
768
920
|
reportCurrentViewForAndroid(currentScreen);
|
|
769
921
|
if (_currentScreen != null && _currentScreen !== firstScreen) {
|
|
770
|
-
NativeLuciq.reportScreenChange(_currentScreen);
|
|
922
|
+
NativeLuciq.reportScreenChange(_currentScreen, screenLoadingSpanId);
|
|
771
923
|
_currentScreen = null;
|
|
772
924
|
}
|
|
773
925
|
_currentScreen = currentScreen;
|
|
774
926
|
setTimeout(() => {
|
|
775
927
|
if (currentScreen && _currentScreen === currentScreen) {
|
|
776
|
-
NativeLuciq.reportScreenChange(currentScreen);
|
|
928
|
+
NativeLuciq.reportScreenChange(currentScreen, screenLoadingSpanId);
|
|
777
929
|
_currentScreen = null;
|
|
778
930
|
}
|
|
931
|
+
|
|
932
|
+
// End Screen Loading measurement for v4
|
|
933
|
+
if (screenLoadingSpanId) {
|
|
934
|
+
ScreenLoadingManager.endSpan(screenLoadingSpanId).catch((error) => {
|
|
935
|
+
Logger.warn('[ScreenLoading] Failed to end span:', error);
|
|
936
|
+
});
|
|
937
|
+
}
|
|
779
938
|
}, 1000);
|
|
780
939
|
}
|
|
781
940
|
};
|
|
@@ -785,17 +944,28 @@ export const onStateChange = (state?: NavigationStateV5) => {
|
|
|
785
944
|
return;
|
|
786
945
|
}
|
|
787
946
|
|
|
947
|
+
// Delegate to the new state change handler for Screen Loading
|
|
948
|
+
// This handles reportScreenChange when setNavigationListener was called
|
|
949
|
+
_onNavigationStateChange();
|
|
950
|
+
|
|
951
|
+
// When setNavigationListener is used, _onNavigationStateChange already handles
|
|
952
|
+
// reportScreenChange properly - skip legacy logic to avoid duplicate calls
|
|
953
|
+
if (_navigationRef?.current) {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// Fallback: Legacy screen tracking for users who only use onStateChange without setNavigationListener
|
|
788
958
|
const currentScreen = LuciqUtils.getFullRoute(state);
|
|
789
959
|
reportCurrentViewForAndroid(currentScreen);
|
|
790
960
|
if (_currentScreen !== null && _currentScreen !== firstScreen) {
|
|
791
|
-
NativeLuciq.reportScreenChange(_currentScreen);
|
|
961
|
+
NativeLuciq.reportScreenChange(_currentScreen, null);
|
|
792
962
|
_currentScreen = null;
|
|
793
963
|
}
|
|
794
964
|
|
|
795
965
|
_currentScreen = currentScreen;
|
|
796
966
|
setTimeout(() => {
|
|
797
967
|
if (_currentScreen === currentScreen) {
|
|
798
|
-
NativeLuciq.reportScreenChange(currentScreen);
|
|
968
|
+
NativeLuciq.reportScreenChange(currentScreen, null);
|
|
799
969
|
_currentScreen = null;
|
|
800
970
|
}
|
|
801
971
|
}, 1000);
|
|
@@ -809,13 +979,29 @@ export const onStateChange = (state?: NavigationStateV5) => {
|
|
|
809
979
|
export const setNavigationListener = (
|
|
810
980
|
navigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
|
|
811
981
|
) => {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
982
|
+
// Store the navigationRef for Screen Loading tracking
|
|
983
|
+
_navigationRef = navigationRef;
|
|
984
|
+
|
|
985
|
+
if (!navigationRef?.current) {
|
|
986
|
+
Logger.warn('[Luciq] Navigation ref not available, cannot set listeners');
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Register the __unsafe_action__ listener for span creation
|
|
991
|
+
// This listener fires on navigation dispatch (start of navigation)
|
|
992
|
+
navigationRef.current.addListener('__unsafe_action__', _onNavigationAction);
|
|
993
|
+
|
|
994
|
+
// NOTE: We do NOT register a 'state' listener here because the user is expected
|
|
995
|
+
// to pass Luciq.onStateChange to NavigationContainer's onStateChange prop.
|
|
996
|
+
// Registering both would cause duplicate reportScreenChange calls.
|
|
997
|
+
|
|
998
|
+
Logger.log('[Luciq] Registered Screen Loading listener (__unsafe_action__)');
|
|
999
|
+
|
|
1000
|
+
// return stateListener;
|
|
815
1001
|
};
|
|
816
1002
|
|
|
817
1003
|
export const reportScreenChange = (screenName: string) => {
|
|
818
|
-
NativeLuciq.reportScreenChange(screenName);
|
|
1004
|
+
NativeLuciq.reportScreenChange(screenName, null);
|
|
819
1005
|
};
|
|
820
1006
|
|
|
821
1007
|
/**
|
|
@@ -879,7 +1065,7 @@ export const componentDidAppearListener = (event: ComponentDidAppearEvent) => {
|
|
|
879
1065
|
return;
|
|
880
1066
|
}
|
|
881
1067
|
if (_lastScreen !== event.componentName) {
|
|
882
|
-
NativeLuciq.reportScreenChange(event.componentName);
|
|
1068
|
+
NativeLuciq.reportScreenChange(event.componentName, null);
|
|
883
1069
|
_lastScreen = event.componentName;
|
|
884
1070
|
}
|
|
885
1071
|
};
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { NativeAPM } from '../../native/NativeAPM';
|
|
2
|
+
import { Logger } from '../../utils/logger';
|
|
3
|
+
import { fromEpochMicros, nowMicros, toEpochMicros } from '../../utils/LuciqUtils';
|
|
4
|
+
|
|
5
|
+
export interface ScreenLoadingSpan {
|
|
6
|
+
spanId: string;
|
|
7
|
+
screenName: string;
|
|
8
|
+
startTimestamp: number;
|
|
9
|
+
endTimestamp?: number;
|
|
10
|
+
ttid?: number;
|
|
11
|
+
status: 'pending' | 'measuring' | 'completed' | 'error';
|
|
12
|
+
isManual: boolean;
|
|
13
|
+
attributes: Map<string, number>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Automatic Screen Loading Measurement
|
|
18
|
+
*
|
|
19
|
+
* Start point: `__unsafe_action__` navigation event (below).
|
|
20
|
+
* - Fires when a navigation action is dispatched, before the target screen mounts.
|
|
21
|
+
* - `ScreenLoadingManager.createSpan()` records `nowMicros()` as the span start.
|
|
22
|
+
*
|
|
23
|
+
* End point: `_onNavigationStateChange()` (called from `onStateChange`).
|
|
24
|
+
* - Fires after navigation state has settled and the new screen is mounted.
|
|
25
|
+
* - `ScreenLoadingManager.endSpan()` fetches the native frame timestamp from
|
|
26
|
+
* CADisplayLink (iOS) / Choreographer (Android) to mark actual render completion.
|
|
27
|
+
* - The TTID is: native frame timestamp − span start.
|
|
28
|
+
*
|
|
29
|
+
*
|
|
30
|
+
* Manual Screen Loading Measurement
|
|
31
|
+
*
|
|
32
|
+
* Start point: Component instantiation (lazy init block before first render).
|
|
33
|
+
* - `nowMicros()` is captured as `constructorTimestampRef` and passed to
|
|
34
|
+
* `ScreenLoadingManager.createSpan()` as the span's start timestamp.
|
|
35
|
+
*
|
|
36
|
+
* End point: `useLayoutEffect` (fires synchronously after React commits DOM
|
|
37
|
+
* mutations, before the browser paints).
|
|
38
|
+
* - `ScreenLoadingManager.endSpan()` fetches the native frame timestamp
|
|
39
|
+
* from CADisplayLink (iOS) / Choreographer (Android) to mark the actual
|
|
40
|
+
* render completion. The TTID is: native frame timestamp − span start.
|
|
41
|
+
*
|
|
42
|
+
* Both approaches share the same `endSpan()` path so TTID values are comparable.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
class ScreenLoadingManagerClass {
|
|
46
|
+
private activeSpans: Map<string, ScreenLoadingSpan> = new Map();
|
|
47
|
+
private isInitialized: boolean = false;
|
|
48
|
+
private isEnabled: boolean = false;
|
|
49
|
+
private isEndScreenLoadingEnabled: boolean = false;
|
|
50
|
+
private isFrameTrackingInitialized: boolean = false;
|
|
51
|
+
private activeSpanId: string | null = null;
|
|
52
|
+
private maxConcurrentSpans: number = 50;
|
|
53
|
+
private excludedRoutes: Set<string> = new Set();
|
|
54
|
+
|
|
55
|
+
async initialize(): Promise<void> {
|
|
56
|
+
if (this.isInitialized) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Check feature flags
|
|
62
|
+
this.isEnabled = await NativeAPM.isScreenLoadingEnabled();
|
|
63
|
+
this.isEndScreenLoadingEnabled = await NativeAPM.isEndScreenLoadingEnabled();
|
|
64
|
+
if (this.isEnabled) {
|
|
65
|
+
await NativeAPM.initScreenFrameTracking();
|
|
66
|
+
this.isFrameTrackingInitialized = true;
|
|
67
|
+
Logger.log('[ScreenLoading] Manager initialized, feature enabled');
|
|
68
|
+
} else {
|
|
69
|
+
Logger.log('[ScreenLoading] Feature disabled by flag');
|
|
70
|
+
}
|
|
71
|
+
this.isInitialized = true;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
Logger.error('[ScreenLoading] Failed to initialize:', error);
|
|
74
|
+
this.isEnabled = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async refreshFlags(): Promise<void> {
|
|
79
|
+
try {
|
|
80
|
+
this.isEnabled = await NativeAPM.isScreenLoadingEnabled();
|
|
81
|
+
this.isEndScreenLoadingEnabled = await NativeAPM.isEndScreenLoadingEnabled();
|
|
82
|
+
|
|
83
|
+
if (this.isEnabled && !this.isFrameTrackingInitialized) {
|
|
84
|
+
await NativeAPM.initScreenFrameTracking();
|
|
85
|
+
this.isFrameTrackingInitialized = true;
|
|
86
|
+
Logger.log('[ScreenLoading] Frame tracking initialized after flag refresh');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
Logger.log(
|
|
90
|
+
`[ScreenLoading] Flags refreshed - enabled: ${this.isEnabled}, endScreenLoading: ${this.isEndScreenLoadingEnabled}`,
|
|
91
|
+
);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
Logger.error('[ScreenLoading] Failed to refresh flags:', error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Exclude specific routes from automatic screen loading measurement
|
|
99
|
+
* @param routes Array of route names to exclude
|
|
100
|
+
*/
|
|
101
|
+
excludeRoutes(routes: string[]): void {
|
|
102
|
+
routes.forEach((route) => this.excludedRoutes.add(route));
|
|
103
|
+
Logger.log('[ScreenLoading] Excluded routes:', Array.from(this.excludedRoutes));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Include previously excluded routes back into screen loading measurement
|
|
108
|
+
* @param routes Array of route names to include (or empty to clear all exclusions)
|
|
109
|
+
*/
|
|
110
|
+
includeRoutes(routes?: string[]): void {
|
|
111
|
+
if (!routes || routes.length === 0) {
|
|
112
|
+
this.excludedRoutes.clear();
|
|
113
|
+
Logger.log('[ScreenLoading] Cleared all route exclusions');
|
|
114
|
+
} else {
|
|
115
|
+
routes.forEach((route) => this.excludedRoutes.delete(route));
|
|
116
|
+
Logger.log('[ScreenLoading] Removed exclusions for:', routes);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if a route is excluded from measurement
|
|
122
|
+
*/
|
|
123
|
+
isRouteExcluded(routeName: string): boolean {
|
|
124
|
+
return this.excludedRoutes.has(routeName);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create a new screen loading span
|
|
129
|
+
* @param screenName Name of the screen
|
|
130
|
+
* @param isManual Whether the span is manual (not automatically created)
|
|
131
|
+
* @param startTimestampParam Optional start timestamp in microseconds (defaults to nowMicros())
|
|
132
|
+
* @returns The created span or null if the feature is not enabled
|
|
133
|
+
*/
|
|
134
|
+
createSpan(
|
|
135
|
+
screenName: string,
|
|
136
|
+
isManual: boolean = false,
|
|
137
|
+
startTimestampParam?: number,
|
|
138
|
+
): ScreenLoadingSpan | null {
|
|
139
|
+
if (!this.isEnabled) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check if route is excluded (only for automatic tracking)
|
|
144
|
+
if (!isManual && this.isRouteExcluded(screenName)) {
|
|
145
|
+
Logger.log(`[ScreenLoading] Route "${screenName}" is excluded from automatic measurement`);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Cleanup if exceeding capacity
|
|
150
|
+
if (this.activeSpans.size >= this.maxConcurrentSpans) {
|
|
151
|
+
this.cleanupOldestSpans();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const spanId = Date.now().toString();
|
|
155
|
+
const startTimestamp = startTimestampParam ?? nowMicros();
|
|
156
|
+
|
|
157
|
+
const span: ScreenLoadingSpan = {
|
|
158
|
+
spanId,
|
|
159
|
+
screenName,
|
|
160
|
+
startTimestamp,
|
|
161
|
+
status: 'pending',
|
|
162
|
+
isManual,
|
|
163
|
+
attributes: new Map<string, number>(),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
this.activeSpans.set(spanId, span);
|
|
167
|
+
|
|
168
|
+
// Register with native for frame tracking
|
|
169
|
+
try {
|
|
170
|
+
NativeAPM.setActiveScreenSpanId(spanId);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
Logger.error(`[ScreenLoading] Failed to set active span ID ${spanId}:`, error);
|
|
173
|
+
}
|
|
174
|
+
if (!isManual) {
|
|
175
|
+
this.activeSpanId = spanId;
|
|
176
|
+
}
|
|
177
|
+
span.status = 'measuring';
|
|
178
|
+
|
|
179
|
+
Logger.log(
|
|
180
|
+
`[ScreenLoading] Created span ${spanId} for screen "${screenName}" (${isManual ? 'manual' : 'automatic'})`,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
return span;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* End a screen loading span
|
|
188
|
+
* @param spanId The ID of the span to end
|
|
189
|
+
*/
|
|
190
|
+
async endSpan(spanId: string): Promise<void> {
|
|
191
|
+
if (!this.isEnabled) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const span = this.activeSpans.get(spanId);
|
|
196
|
+
if (!span || span.status === 'completed') {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
// Get frame timestamp from native with retry logic
|
|
202
|
+
// The native frame callback (CADisplayLink/Choreographer) may not have executed yet
|
|
203
|
+
// if endSpan is called very quickly after createSpan. Retry up to 3 times with
|
|
204
|
+
// a delay of ~20ms (slightly more than one frame at 60fps) between attempts.
|
|
205
|
+
const maxRetries = 3;
|
|
206
|
+
const retryDelayMs = 20;
|
|
207
|
+
let frameTimestamp: number | null = null;
|
|
208
|
+
|
|
209
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
210
|
+
frameTimestamp = await NativeAPM.getScreenTimeToDisplay(spanId);
|
|
211
|
+
|
|
212
|
+
if (frameTimestamp) {
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Wait for next frame before retrying (only if not last attempt)
|
|
217
|
+
if (attempt < maxRetries - 1) {
|
|
218
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (frameTimestamp) {
|
|
223
|
+
// Native returns epoch microseconds; convert to monotonic for consistent internal math
|
|
224
|
+
span.endTimestamp = fromEpochMicros(frameTimestamp);
|
|
225
|
+
span.ttid = span.endTimestamp - span.startTimestamp;
|
|
226
|
+
|
|
227
|
+
span.status = 'completed';
|
|
228
|
+
|
|
229
|
+
// Log the measurement
|
|
230
|
+
this.logScreenLoading(span);
|
|
231
|
+
} else {
|
|
232
|
+
span.status = 'error';
|
|
233
|
+
Logger.warn(`[ScreenLoading] No frame timestamp available for span ${spanId}`);
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
span.status = 'error';
|
|
237
|
+
Logger.error(`[ScreenLoading] Failed to get timestamp for span ${spanId}:`, error);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Cleanup after a delay
|
|
241
|
+
setTimeout(() => {
|
|
242
|
+
this.activeSpans.delete(spanId);
|
|
243
|
+
}, 5000);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Log a screen loading span
|
|
248
|
+
* @param span The span to log
|
|
249
|
+
*/
|
|
250
|
+
private logScreenLoading(span: ScreenLoadingSpan): void {
|
|
251
|
+
// Convert Map to plain object for JSON serialization (JSON.stringify cannot serialize Maps)
|
|
252
|
+
const attributesObject = Object.fromEntries(span.attributes);
|
|
253
|
+
|
|
254
|
+
const startEpochUs = Math.round(toEpochMicros(span.startTimestamp));
|
|
255
|
+
const endEpochUs = span.endTimestamp ? Math.round(toEpochMicros(span.endTimestamp)) : undefined;
|
|
256
|
+
|
|
257
|
+
const logData = {
|
|
258
|
+
span_id: span.spanId,
|
|
259
|
+
screen_name: span.screenName,
|
|
260
|
+
start_timestamp_us: startEpochUs,
|
|
261
|
+
end_timestamp_us: endEpochUs,
|
|
262
|
+
ttid_us: span.ttid ? Math.round(span.ttid) : undefined,
|
|
263
|
+
ttid_ms: span.ttid ? Math.round(span.ttid) / 1000 : undefined,
|
|
264
|
+
is_manual: span.isManual,
|
|
265
|
+
attributes: attributesObject,
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
Logger.log('[ScreenLoading] Measurement:', JSON.stringify(logData, null, 2));
|
|
269
|
+
|
|
270
|
+
// Sync screen loading data to native layer (also pass converted object)
|
|
271
|
+
try {
|
|
272
|
+
if (span.isManual) {
|
|
273
|
+
NativeAPM.syncManualScreenLoading(
|
|
274
|
+
span.screenName,
|
|
275
|
+
startEpochUs,
|
|
276
|
+
Math.round(span.ttid!),
|
|
277
|
+
attributesObject,
|
|
278
|
+
);
|
|
279
|
+
} else {
|
|
280
|
+
NativeAPM.syncScreenLoading(
|
|
281
|
+
Number(span.spanId),
|
|
282
|
+
span.screenName,
|
|
283
|
+
startEpochUs,
|
|
284
|
+
Math.round(span.ttid!),
|
|
285
|
+
attributesObject,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
Logger.error(`[ScreenLoading] Failed to sync screen loading for span ${span.spanId}:`, error);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* End a screen loading span using the current timestamp and active span ID
|
|
295
|
+
*/
|
|
296
|
+
endScreenLoading(): void {
|
|
297
|
+
if (!this.isEndScreenLoadingFeatureEnabled()) {
|
|
298
|
+
Logger.error('[ScreenLoading] End screen loading feature is not enabled');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!this.activeSpanId) {
|
|
302
|
+
Logger.warn('[ScreenLoading] No active span to end screen loading');
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const timeStampMicro = Math.round(toEpochMicros(nowMicros()));
|
|
307
|
+
const uiTraceId = Number(this.activeSpanId);
|
|
308
|
+
NativeAPM.endScreenLoading(timeStampMicro, uiTraceId);
|
|
309
|
+
Logger.log(
|
|
310
|
+
`[ScreenLoading] endScreenLoading() was called at ${timeStampMicro} for ui trace id "${uiTraceId}"`,
|
|
311
|
+
);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
Logger.error('[ScreenLoading] Failed to end screen loading:', error);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Discard a span without logging or syncing it to native.
|
|
319
|
+
* Used when a span should be silently dropped (e.g., excluded route resolved after creation).
|
|
320
|
+
*/
|
|
321
|
+
discardSpan(spanId: string): void {
|
|
322
|
+
const span = this.activeSpans.get(spanId);
|
|
323
|
+
if (span) {
|
|
324
|
+
this.activeSpans.delete(spanId);
|
|
325
|
+
Logger.log(`[ScreenLoading] Discarded span ${spanId} for screen "${span.screenName}"`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
getActiveSpan(spanId: string): ScreenLoadingSpan | undefined {
|
|
330
|
+
return this.activeSpans.get(spanId);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
getAllActiveSpans(): ScreenLoadingSpan[] {
|
|
334
|
+
return Array.from(this.activeSpans.values());
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
addSpanAttribute(spanId: string, key: string, value: number): void {
|
|
338
|
+
const span = this.activeSpans.get(spanId);
|
|
339
|
+
if (span) {
|
|
340
|
+
span.attributes.set(key, value);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private cleanupOldestSpans(): void {
|
|
345
|
+
const sortedSpans = Array.from(this.activeSpans.entries()).sort(
|
|
346
|
+
(a, b) => a[1].startTimestamp - b[1].startTimestamp,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const toRemove = Math.max(0, sortedSpans.length - 30);
|
|
350
|
+
for (let i = 0; i < toRemove; i++) {
|
|
351
|
+
this.activeSpans.delete(sortedSpans[i][0]);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
isFeatureEnabled(): boolean {
|
|
356
|
+
return this.isEnabled;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
isEndScreenLoadingFeatureEnabled(): boolean {
|
|
360
|
+
return this.isEnabled && this.isEndScreenLoadingEnabled;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export const ScreenLoadingManager = new ScreenLoadingManagerClass();
|
package/src/native/NativeAPM.ts
CHANGED
|
@@ -54,6 +54,28 @@ export interface ApmNativeModule extends NativeModule {
|
|
|
54
54
|
isCustomSpanEnabled(): Promise<boolean>;
|
|
55
55
|
|
|
56
56
|
isAPMEnabled(): Promise<boolean>;
|
|
57
|
+
|
|
58
|
+
// Screen Loading methods
|
|
59
|
+
initScreenFrameTracking(): Promise<void>;
|
|
60
|
+
setActiveScreenSpanId(spanId: string): void;
|
|
61
|
+
getScreenTimeToDisplay(spanId: string): Promise<number | null>;
|
|
62
|
+
isScreenLoadingEnabled(): Promise<boolean>;
|
|
63
|
+
isEndScreenLoadingEnabled(): Promise<boolean>;
|
|
64
|
+
endScreenLoading(timeStampMicro: number, uiTraceId: number): void;
|
|
65
|
+
setScreenLoadingEnabled(isEnabled: boolean): void;
|
|
66
|
+
syncScreenLoading(
|
|
67
|
+
spanId: number,
|
|
68
|
+
screenName: string,
|
|
69
|
+
startTimestamp: number,
|
|
70
|
+
durationUS: number,
|
|
71
|
+
attributes: Record<string, any>,
|
|
72
|
+
): void;
|
|
73
|
+
syncManualScreenLoading(
|
|
74
|
+
screenName: string,
|
|
75
|
+
startTimestamp: number,
|
|
76
|
+
durationUS: number,
|
|
77
|
+
attributes: Record<string, any>,
|
|
78
|
+
): void;
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
export const NativeAPM = NativeModules.LCQAPM;
|
|
@@ -92,7 +92,7 @@ export interface LuciqNativeModule extends NativeModule {
|
|
|
92
92
|
sessionReplay: ReproStepsMode,
|
|
93
93
|
): void;
|
|
94
94
|
setTrackUserSteps(isEnabled: boolean): void;
|
|
95
|
-
reportScreenChange(
|
|
95
|
+
reportScreenChange(screenName: string, spanId: string | null): void;
|
|
96
96
|
reportCurrentViewChange(screenName: string): void;
|
|
97
97
|
addPrivateView(nativeTag: number | null): void;
|
|
98
98
|
removePrivateView(nativeTag: number | null): void;
|