@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.
Files changed (58) hide show
  1. package/.claude/agents/codebase-analyzer.md +33 -0
  2. package/.claude/agents/codebase-locator.md +42 -0
  3. package/.claude/agents/codebase-pattern-finder.md +40 -0
  4. package/.claude/commands/apply-pr-reviews.md +253 -0
  5. package/.claude/commands/create-jira-workitem.md +27 -0
  6. package/.claude/commands/create-pr.md +138 -0
  7. package/.claude/commands/create-public-release-notes.md +145 -0
  8. package/.claude/commands/create-rca.md +286 -0
  9. package/.claude/commands/debug-sdk.md +66 -0
  10. package/.claude/commands/describe-pr.md +40 -0
  11. package/.claude/commands/new-api.md +60 -0
  12. package/.claude/commands/new-feature.md +75 -0
  13. package/.claude/commands/pr-review.md +85 -0
  14. package/.claude/commands/research-codebase.md +41 -0
  15. package/.claude/commands/review.md +73 -0
  16. package/.claude/memory/MEMORY.md +1 -0
  17. package/.claude/memory/feedback_pr_title_format.md +10 -0
  18. package/.claude/rules/react-native-typescript.md +46 -0
  19. package/CHANGELOG.md +12 -0
  20. package/CLAUDE.md +125 -0
  21. package/android/native.gradle +1 -1
  22. package/android/src/main/java/ai/luciq/reactlibrary/LuciqScreenLoadingFrameTracker.java +88 -0
  23. package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqAPMModule.java +184 -10
  24. package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqReactnativeModule.java +5 -3
  25. package/dist/components/LuciqCaptureScreenLoading.d.ts +8 -0
  26. package/dist/components/LuciqCaptureScreenLoading.js +154 -0
  27. package/dist/index.d.ts +2 -0
  28. package/dist/index.js +2 -0
  29. package/dist/modules/APM.d.ts +19 -0
  30. package/dist/modules/APM.js +38 -0
  31. package/dist/modules/Luciq.d.ts +1 -1
  32. package/dist/modules/Luciq.js +169 -11
  33. package/dist/modules/apm/ScreenLoadingManager.d.ts +99 -0
  34. package/dist/modules/apm/ScreenLoadingManager.js +296 -0
  35. package/dist/native/NativeAPM.d.ts +9 -0
  36. package/dist/native/NativeLuciq.d.ts +1 -1
  37. package/dist/utils/LuciqUtils.d.ts +25 -0
  38. package/dist/utils/LuciqUtils.js +44 -0
  39. package/dist/utils/RouteMatcher.d.ts +30 -0
  40. package/dist/utils/RouteMatcher.js +67 -0
  41. package/ios/RNLuciq/LuciqAPMBridge.m +82 -0
  42. package/ios/RNLuciq/LuciqReactBridge.m +1 -1
  43. package/ios/RNLuciq/LuciqScreenLoadingFrameTracker.h +11 -0
  44. package/ios/RNLuciq/LuciqScreenLoadingFrameTracker.m +121 -0
  45. package/ios/RNLuciq/Util/LCQAPM+PrivateAPIs.h +14 -0
  46. package/ios/native.rb +1 -1
  47. package/package.json +4 -1
  48. package/scripts/get-github-app-token.sh +70 -0
  49. package/scripts/notify-github.sh +17 -8
  50. package/src/components/LuciqCaptureScreenLoading.tsx +210 -0
  51. package/src/index.ts +4 -0
  52. package/src/modules/APM.ts +42 -0
  53. package/src/modules/Luciq.ts +197 -11
  54. package/src/modules/apm/ScreenLoadingManager.ts +364 -0
  55. package/src/native/NativeAPM.ts +22 -0
  56. package/src/native/NativeLuciq.ts +1 -1
  57. package/src/utils/LuciqUtils.ts +49 -0
  58. package/src/utils/RouteMatcher.ts +83 -0
@@ -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 the app has come to the foreground
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
- return navigationRef.addListener('state', () => {
813
- onStateChange(navigationRef.getRootState());
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();
@@ -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(firstScreen: string): void;
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;