@luciq/react-native 19.4.0 → 19.6.0-51917-SNAPSHOT

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 (69) 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/proguard-rules.txt +1 -1
  23. package/android/src/main/java/ai/luciq/reactlibrary/LuciqScreenLoadingFrameTracker.java +88 -0
  24. package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqAPMModule.java +193 -10
  25. package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqNetworkLoggerModule.java +29 -7
  26. package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqReactnativeModule.java +36 -12
  27. package/android/src/main/java/ai/luciq/reactlibrary/utils/EventEmitterModule.java +7 -0
  28. package/dist/components/LuciqCaptureScreenLoading.d.ts +8 -0
  29. package/dist/components/LuciqCaptureScreenLoading.js +154 -0
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.js +2 -0
  32. package/dist/modules/APM.d.ts +19 -0
  33. package/dist/modules/APM.js +38 -0
  34. package/dist/modules/Luciq.d.ts +1 -1
  35. package/dist/modules/Luciq.js +179 -12
  36. package/dist/modules/NetworkLogger.d.ts +0 -5
  37. package/dist/modules/NetworkLogger.js +9 -1
  38. package/dist/modules/apm/ScreenLoadingManager.d.ts +99 -0
  39. package/dist/modules/apm/ScreenLoadingManager.js +296 -0
  40. package/dist/native/NativeAPM.d.ts +9 -0
  41. package/dist/native/NativeLuciq.d.ts +1 -1
  42. package/dist/utils/FeatureFlags.d.ts +6 -0
  43. package/dist/utils/FeatureFlags.js +35 -0
  44. package/dist/utils/LuciqUtils.d.ts +25 -0
  45. package/dist/utils/LuciqUtils.js +50 -0
  46. package/dist/utils/RouteMatcher.d.ts +30 -0
  47. package/dist/utils/RouteMatcher.js +67 -0
  48. package/dist/utils/XhrNetworkInterceptor.js +85 -53
  49. package/ios/RNLuciq/LuciqAPMBridge.m +82 -0
  50. package/ios/RNLuciq/LuciqReactBridge.m +1 -1
  51. package/ios/RNLuciq/LuciqScreenLoadingFrameTracker.h +11 -0
  52. package/ios/RNLuciq/LuciqScreenLoadingFrameTracker.m +121 -0
  53. package/ios/RNLuciq/Util/LCQAPM+PrivateAPIs.h +14 -0
  54. package/ios/native.rb +1 -1
  55. package/package.json +4 -2
  56. package/scripts/get-github-app-token.sh +70 -0
  57. package/scripts/notify-github.sh +17 -8
  58. package/src/components/LuciqCaptureScreenLoading.tsx +210 -0
  59. package/src/index.ts +4 -0
  60. package/src/modules/APM.ts +42 -0
  61. package/src/modules/Luciq.ts +210 -12
  62. package/src/modules/NetworkLogger.ts +26 -1
  63. package/src/modules/apm/ScreenLoadingManager.ts +364 -0
  64. package/src/native/NativeAPM.ts +22 -0
  65. package/src/native/NativeLuciq.ts +1 -1
  66. package/src/utils/FeatureFlags.ts +44 -0
  67. package/src/utils/LuciqUtils.ts +64 -0
  68. package/src/utils/RouteMatcher.ts +83 -0
  69. package/src/utils/XhrNetworkInterceptor.ts +128 -55
@@ -0,0 +1,210 @@
1
+ import React, { useState, useRef, useEffect, useLayoutEffect, useContext } from 'react';
2
+ import { View, ViewProps } from 'react-native';
3
+ import { ScreenLoadingManager } from '../modules/apm/ScreenLoadingManager';
4
+ import { Logger } from '../utils/logger';
5
+ import { nowMicros, toEpochMicros } from '../utils/LuciqUtils';
6
+
7
+ // Context to handle nested components
8
+ const ScreenLoadingContext = React.createContext<boolean>(false);
9
+
10
+ export interface LuciqScreenLoadingProps extends ViewProps {
11
+ screenName: string;
12
+ record?: boolean;
13
+ onMeasured?: (ttid: number) => void;
14
+ }
15
+
16
+ export function LuciqCaptureScreenLoading(props: LuciqScreenLoadingProps) {
17
+ const { screenName, record, onMeasured, onLayout, children, ...viewProps } = props;
18
+
19
+ const isNested = useContext(ScreenLoadingContext);
20
+
21
+ // Refs for timestamps (these don't need to trigger re-renders)
22
+ const constructorTimestampRef = useRef<number>(nowMicros()); // microseconds
23
+ const renderStartTimestampRef = useRef<number | undefined>(undefined);
24
+ const renderEndTimestampRef = useRef<number | undefined>(undefined);
25
+ const mountTimestampRef = useRef<number | undefined>(undefined);
26
+
27
+ // Guards to ensure single execution
28
+ const initializedRef = useRef(false);
29
+ const hasFirstRenderCompletedRef = useRef(false);
30
+ const attributesRecordedRef = useRef(false);
31
+ const initialSpanIdRef = useRef<string | null>(null);
32
+
33
+ // Capture render start timestamp ONLY on first render
34
+ if (!hasFirstRenderCompletedRef.current) {
35
+ renderStartTimestampRef.current = nowMicros();
36
+ }
37
+
38
+ // Initialize span - runs once like constructor (lazy initialization)
39
+ if (!initializedRef.current) {
40
+ initializedRef.current = true;
41
+ // Initialize span if conditions are met
42
+ try {
43
+ if (record !== false && ScreenLoadingManager.isFeatureEnabled()) {
44
+ const span = ScreenLoadingManager.createSpan(
45
+ screenName,
46
+ true,
47
+ constructorTimestampRef.current,
48
+ );
49
+ if (span) {
50
+ initialSpanIdRef.current = span.spanId;
51
+ Logger.log(`[LuciqScreenLoading] Span ${span.spanId} created in constructor`);
52
+ }
53
+ }
54
+ } catch (error) {
55
+ Logger.error('[LuciqScreenLoading] Failed to create span:', error);
56
+ }
57
+ }
58
+
59
+ const [spanId, setSpanId] = useState<string | null>(initialSpanIdRef.current);
60
+ const [isMeasured, setIsMeasured] = useState(false);
61
+
62
+ // Ref to avoid stale closure in useLayoutEffect
63
+ const onMeasuredRef = useRef(onMeasured);
64
+ useEffect(() => {
65
+ onMeasuredRef.current = onMeasured;
66
+ }, [onMeasured]);
67
+
68
+ // Refs to track latest values for cleanup (componentWillUnmount)
69
+ const spanIdRef = useRef<string | null>(spanId);
70
+ const isMeasuredRef = useRef(isMeasured);
71
+
72
+ // Keep refs in sync with state
73
+ useEffect(() => {
74
+ spanIdRef.current = spanId;
75
+ }, [spanId]);
76
+
77
+ useEffect(() => {
78
+ isMeasuredRef.current = isMeasured;
79
+ }, [isMeasured]);
80
+
81
+ // Handle nested component detection
82
+ useEffect(() => {
83
+ // Check if we're nested and should ignore this component
84
+ if (isNested && initialSpanIdRef.current) {
85
+ Logger.log(
86
+ `[LuciqScreenLoading] Nested component detected, ignoring span ${initialSpanIdRef.current}`,
87
+ );
88
+ // Cancel the span
89
+ setSpanId(null);
90
+ }
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, []); // Empty deps = componentDidMount
93
+
94
+ // Record lifecycle timestamps after first render completes (synchronous)
95
+ // useLayoutEffect fires synchronously after DOM mutations but before browser paint
96
+ useLayoutEffect(() => {
97
+ // Skip if no span, already recorded, or nested
98
+ if (!spanId || attributesRecordedRef.current || isNested) {
99
+ return;
100
+ }
101
+
102
+ // endSpan is async (native frame timestamp fetch), fire-and-forget from useLayoutEffect
103
+ ScreenLoadingManager.endSpan(spanId)
104
+ .then(() => {
105
+ const completedSpan = ScreenLoadingManager.getActiveSpan(spanId);
106
+ if (completedSpan?.ttid && onMeasuredRef.current) {
107
+ onMeasuredRef.current(completedSpan.ttid / 1000);
108
+ }
109
+ })
110
+ .catch((error) => {
111
+ Logger.warn('[LuciqScreenLoading] Failed to end span:', error);
112
+ });
113
+
114
+ attributesRecordedRef.current = true;
115
+ mountTimestampRef.current = nowMicros();
116
+
117
+ try {
118
+ // Record all timestamps
119
+ ScreenLoadingManager.addSpanAttribute(
120
+ spanId,
121
+ 'cnst_mus_st',
122
+ toEpochMicros(constructorTimestampRef.current),
123
+ );
124
+
125
+ if (renderStartTimestampRef.current) {
126
+ ScreenLoadingManager.addSpanAttribute(
127
+ spanId,
128
+ 'rnd_mus_st',
129
+ toEpochMicros(renderStartTimestampRef.current),
130
+ );
131
+ }
132
+
133
+ ScreenLoadingManager.addSpanAttribute(
134
+ spanId,
135
+ 'mnt_mus_st',
136
+ toEpochMicros(mountTimestampRef.current),
137
+ );
138
+
139
+ // Record all durations
140
+ if (renderStartTimestampRef.current) {
141
+ // Constructor duration: time from component init to first render start
142
+ const constructorDuration =
143
+ renderStartTimestampRef.current - constructorTimestampRef.current;
144
+ ScreenLoadingManager.addSpanAttribute(spanId, 'cnst_mus', constructorDuration);
145
+ }
146
+
147
+ if (renderEndTimestampRef.current && renderStartTimestampRef.current) {
148
+ // Render duration: time spent creating JSX
149
+ const renderDuration = renderEndTimestampRef.current - renderStartTimestampRef.current;
150
+ ScreenLoadingManager.addSpanAttribute(spanId, 'rnd_mus', renderDuration);
151
+ }
152
+
153
+ if (mountTimestampRef.current && renderEndTimestampRef.current) {
154
+ // Mount duration: time from render complete to effect execution
155
+ const mountDuration = mountTimestampRef.current - renderEndTimestampRef.current;
156
+ ScreenLoadingManager.addSpanAttribute(spanId, 'mnt_mus', mountDuration);
157
+ }
158
+
159
+ Logger.log(`[LuciqScreenLoading] Lifecycle measurements for span ${spanId}:`, {
160
+ constructor_us: renderStartTimestampRef.current
161
+ ? renderStartTimestampRef.current - constructorTimestampRef.current
162
+ : undefined,
163
+ render_us:
164
+ renderEndTimestampRef.current && renderStartTimestampRef.current
165
+ ? renderEndTimestampRef.current - renderStartTimestampRef.current
166
+ : undefined,
167
+ mount_us:
168
+ mountTimestampRef.current && renderEndTimestampRef.current
169
+ ? mountTimestampRef.current - renderEndTimestampRef.current
170
+ : undefined,
171
+ });
172
+ } catch (error) {
173
+ Logger.error(`[LuciqScreenLoading] Failed to record attributes for span ${spanId}:`, error);
174
+ }
175
+
176
+ // End the span — mark as measured synchronously to guard against unmount race
177
+ setIsMeasured(true);
178
+
179
+ // eslint-disable-next-line react-hooks/exhaustive-deps
180
+ }, [spanId]); // Run when spanId is set
181
+
182
+ // componentWillUnmount equivalent
183
+ useEffect(() => {
184
+ return () => {
185
+ // Cleanup on unmount if not measured
186
+ if (spanIdRef.current && !isMeasuredRef.current) {
187
+ ScreenLoadingManager.endSpan(spanIdRef.current).catch((error) => {
188
+ Logger.warn('[LuciqScreenLoading] Failed to end span on unmount:', error);
189
+ });
190
+ }
191
+ };
192
+ }, []); // Empty deps = only runs cleanup on unmount
193
+
194
+ // Create the JSX result
195
+ const result = (
196
+ <ScreenLoadingContext.Provider value={spanId !== null}>
197
+ <View {...viewProps} onLayout={onLayout}>
198
+ {children}
199
+ </View>
200
+ </ScreenLoadingContext.Provider>
201
+ );
202
+
203
+ // Capture render end timestamp ONLY on first render (after JSX creation)
204
+ if (!hasFirstRenderCompletedRef.current) {
205
+ renderEndTimestampRef.current = nowMicros();
206
+ hasFirstRenderCompletedRef.current = true;
207
+ }
208
+
209
+ return result;
210
+ }
package/src/index.ts CHANGED
@@ -45,4 +45,8 @@ export type {
45
45
  ThemeConfig,
46
46
  };
47
47
 
48
+ // Screen Loading Component
49
+ export { LuciqCaptureScreenLoading } from './components/LuciqCaptureScreenLoading';
50
+ export type { LuciqScreenLoadingProps } from './components/LuciqCaptureScreenLoading';
51
+
48
52
  export default Luciq;
@@ -7,6 +7,13 @@ import {
7
7
  addCompletedCustomSpan as addCompletedCustomSpanInternal,
8
8
  } from '../utils/CustomSpansManager';
9
9
  import type { CustomSpan } from '../models/CustomSpan';
10
+ import { ScreenLoadingManager } from './apm/ScreenLoadingManager';
11
+ import { Logger } from '../utils/logger';
12
+
13
+ // Initialize Screen Loading on module load
14
+ ScreenLoadingManager.initialize().catch((error) => {
15
+ Logger.error('[APM] Failed to initialize Screen Loading:', error);
16
+ });
10
17
 
11
18
  /**
12
19
  * Enables or disables APM
@@ -195,3 +202,38 @@ export const addCompletedCustomSpan = async (
195
202
  ): Promise<void> => {
196
203
  return addCompletedCustomSpanInternal(name, startDate, endDate);
197
204
  };
205
+
206
+ /**
207
+ * Enables or disables Screen Loading feature
208
+ * @param isEnabled
209
+ */
210
+ export const setScreenLoadingEnabled = (isEnabled: boolean) => {
211
+ try {
212
+ NativeAPM.setScreenLoadingEnabled(isEnabled);
213
+ } catch (error) {
214
+ Logger.error('[APM] Failed to set screen loading enabled:', error);
215
+ }
216
+ };
217
+
218
+ /**
219
+ * Extends the currently running screen loading trace with a new end timestamp.
220
+ */
221
+ export const endScreenLoading = () => {
222
+ ScreenLoadingManager.endScreenLoading();
223
+ };
224
+
225
+ /**
226
+ * Exclude specific routes from automatic screen loading measurement
227
+ * @param routes Array of route names to exclude
228
+ */
229
+ export function excludeScreenLoadingRoutes(routes: string[]): void {
230
+ ScreenLoadingManager.excludeRoutes(routes);
231
+ }
232
+
233
+ /**
234
+ * Include previously excluded routes back into screen loading measurement
235
+ * @param routes Array of route names to include (or empty to clear all exclusions)
236
+ */
237
+ export function includeScreenLoadingRoutes(routes?: string[]): void {
238
+ ScreenLoadingManager.includeRoutes(routes);
239
+ }
@@ -10,7 +10,7 @@ import type { NavigationAction, NavigationState as NavigationStateV4 } from 'rea
10
10
  import type { LuciqConfig } from '../models/LuciqConfig';
11
11
  import Report from '../models/Report';
12
12
  import { emitter, NativeEvents, NativeLuciq } from '../native/NativeLuciq';
13
- import { registerFeatureFlagsListener } from '../utils/FeatureFlags';
13
+ import { registerFeatureFlagsListener, initFeatureFlagsCache } from '../utils/FeatureFlags';
14
14
  import {
15
15
  AutoMaskingType,
16
16
  ColorTheme,
@@ -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.
@@ -81,10 +89,22 @@ function reportCurrentViewForAndroid(screenName: string | null) {
81
89
  * @param config SDK configurations. See {@link LuciqConfig} for more info.
82
90
  */
83
91
  export const init = (config: LuciqConfig) => {
92
+ initFeatureFlagsCache();
93
+
84
94
  if (Platform.OS === 'android') {
85
95
  // Add android feature flags listener for android
86
96
  registerFeatureFlagsListener();
87
97
  addOnFeatureUpdatedListener(config);
98
+
99
+ // Enable the JS XHR interceptor synchronously so cold-start requests
100
+ // (fired before LCQ_ON_FEATURES_UPDATED_CALLBACK arrives) are captured.
101
+ handleNetworkInterceptionMode(config);
102
+
103
+ setApmNetworkFlagsIfChanged({
104
+ isNativeInterceptionFeatureEnabled: isNativeInterceptionFeatureEnabled,
105
+ hasAPMNetworkPlugin: hasAPMNetworkPlugin,
106
+ shouldEnableNativeInterception: shouldEnableNativeInterception,
107
+ });
88
108
  } else {
89
109
  isNativeInterceptionFeatureEnabled = NativeNetworkLogger.isNativeInterceptionEnabled();
90
110
 
@@ -116,7 +136,7 @@ export const init = (config: LuciqConfig) => {
116
136
  reportCurrentViewForAndroid(firstScreen);
117
137
  setTimeout(() => {
118
138
  if (_currentScreen === firstScreen) {
119
- NativeLuciq.reportScreenChange(firstScreen);
139
+ NativeLuciq.reportScreenChange(firstScreen, null);
120
140
  _currentScreen = null;
121
141
  }
122
142
  }, 1000);
@@ -158,12 +178,14 @@ export const setWebViewUserInteractionsTrackingEnabled = (isEnabled: boolean) =>
158
178
  * Handles app state changes and updates APM network flags if necessary.
159
179
  */
160
180
  const handleAppStateChange = async (nextAppState: AppStateStatus, config: LuciqConfig) => {
161
- // Checks if the app has come to the foreground
181
+ // Checks if the app has come to the foreground
162
182
  if (['inactive', 'background'].includes(_currentAppState) && nextAppState === 'active') {
163
183
  const isUpdated = await fetchApmNetworkFlags();
164
184
  if (isUpdated) {
165
185
  refreshAPMNetworkConfigs(config);
166
186
  }
187
+ // Refresh screen loading flags from native
188
+ await ScreenLoadingManager.refreshFlags();
167
189
  }
168
190
 
169
191
  _currentAppState = nextAppState;
@@ -756,6 +778,139 @@ export const onReportSubmitHandler = (handler?: (report: Report) => void) => {
756
778
  NativeLuciq.setPreSendingHandler(handler);
757
779
  };
758
780
 
781
+ /**
782
+ * Helper to clear the state change timeout
783
+ */
784
+ const _clearStateChangeTimeout = (): void => {
785
+ if (_stateChangeTimeout) {
786
+ clearTimeout(_stateChangeTimeout);
787
+ _stateChangeTimeout = undefined;
788
+ }
789
+ };
790
+
791
+ /**
792
+ * Handles React Navigation's __unsafe_action__ event
793
+ * This fires WHEN a navigation action is dispatched (the start of navigation)
794
+ */
795
+ const _onNavigationAction = (event?: any): void => {
796
+ // Check for noop actions that shouldn't create spans
797
+ if (event?.data?.noop) {
798
+ Logger.log('[ScreenLoading] Navigation action is a noop, not starting span');
799
+ return;
800
+ }
801
+
802
+ // Skip non-navigation actions (like SET_PARAMS, OPEN_DRAWER, etc.)
803
+ const actionType = event?.data?.action?.type;
804
+ if (
805
+ actionType &&
806
+ ['SET_PARAMS', 'OPEN_DRAWER', 'CLOSE_DRAWER', 'TOGGLE_DRAWER'].includes(actionType)
807
+ ) {
808
+ Logger.log(`[ScreenLoading] Skipping non-navigation action: ${actionType}`);
809
+ return;
810
+ }
811
+
812
+ // If there's an existing active span, it means navigation was interrupted
813
+ // Discard the previous span as it never completed
814
+ if (_activeNavigationSpanId) {
815
+ Logger.log('[ScreenLoading] Discarding incomplete previous navigation span');
816
+ // Mark the span as cancelled/error since state change never occurred
817
+ const span = ScreenLoadingManager.getActiveSpan(_activeNavigationSpanId);
818
+ if (span) {
819
+ ScreenLoadingManager.endSpan(_activeNavigationSpanId);
820
+ }
821
+ _activeNavigationSpanId = null;
822
+ _clearStateChangeTimeout();
823
+ }
824
+
825
+ // Create a new span for this navigation action
826
+ // We don't know the destination screen yet, so use a placeholder name
827
+ if (ScreenLoadingManager.isFeatureEnabled()) {
828
+ const span = ScreenLoadingManager.createSpan('NavigationPending', false);
829
+ if (span) {
830
+ _activeNavigationSpanId = span.spanId;
831
+ Logger.log(`[ScreenLoading] Started span ${span.spanId} on navigation dispatch`);
832
+
833
+ // Set a safety timeout to discard the span if state never changes
834
+ // This prevents memory leaks from incomplete navigations
835
+ _stateChangeTimeout = setTimeout(() => {
836
+ if (_activeNavigationSpanId === span.spanId) {
837
+ Logger.warn(
838
+ `[ScreenLoading] Navigation span ${span.spanId} timed out - state never changed`,
839
+ );
840
+ ScreenLoadingManager.endSpan(span.spanId);
841
+ _activeNavigationSpanId = null;
842
+ }
843
+ }, STATE_CHANGE_TIMEOUT_MS);
844
+ }
845
+ }
846
+ };
847
+
848
+ /**
849
+ * Handles React Navigation's state event
850
+ * This fires AFTER the navigation state has changed (the screen is mounted)
851
+ */
852
+ const _onNavigationStateChange = (): void => {
853
+ if (!_navigationRef?.current) {
854
+ return;
855
+ }
856
+
857
+ const previousRouteName = _currentRoute;
858
+ const currentRoute = _navigationRef.current.getCurrentRoute();
859
+ const currentRouteName = currentRoute?.name || null;
860
+
861
+ // If no route or same route, ignore
862
+ if (!currentRouteName || previousRouteName === currentRouteName) {
863
+ // Still need to clean up the span if one was created
864
+ if (_activeNavigationSpanId) {
865
+ Logger.log('[ScreenLoading] Navigation resulted in same route, discarding span');
866
+ ScreenLoadingManager.endSpan(_activeNavigationSpanId);
867
+ _activeNavigationSpanId = null;
868
+ _clearStateChangeTimeout();
869
+ }
870
+ return;
871
+ }
872
+
873
+ // Capture the span ID BEFORE clearing it so we can pass it to reportScreenChange
874
+ let spanIdForReport: string | null = _activeNavigationSpanId;
875
+
876
+ // Complete the active navigation span if one exists
877
+ if (_activeNavigationSpanId) {
878
+ // Now that we know the actual route name, check if it's excluded
879
+ if (ScreenLoadingManager.isRouteExcluded(currentRouteName)) {
880
+ Logger.log(`[ScreenLoading] Route "${currentRouteName}" is excluded, discarding span`);
881
+ ScreenLoadingManager.discardSpan(_activeNavigationSpanId);
882
+ spanIdForReport = null;
883
+ _activeNavigationSpanId = null;
884
+ _clearStateChangeTimeout();
885
+ } else {
886
+ const span = ScreenLoadingManager.getActiveSpan(_activeNavigationSpanId);
887
+ if (span) {
888
+ // Update the span name from placeholder to actual screen name
889
+ span.screenName = currentRouteName;
890
+
891
+ // End the span - the native frame tracker will provide the actual render timestamp
892
+ ScreenLoadingManager.endSpan(_activeNavigationSpanId)
893
+ .then(() => {
894
+ Logger.log(`[ScreenLoading] Completed span for navigation to ${currentRouteName}`);
895
+ })
896
+ .catch((error) => {
897
+ Logger.warn('[ScreenLoading] Failed to end navigation span:', error);
898
+ });
899
+ }
900
+
901
+ // Clear the active span and timeout
902
+ _activeNavigationSpanId = null;
903
+ _clearStateChangeTimeout();
904
+ }
905
+ }
906
+
907
+ // Update the current route for the rest of Luciq's tracking
908
+ _currentRoute = currentRouteName;
909
+
910
+ // Report to native
911
+ NativeLuciq.reportScreenChange(currentRouteName, spanIdForReport);
912
+ };
913
+
759
914
  export const onNavigationStateChange = (
760
915
  prevState: NavigationStateV4,
761
916
  currentState: NavigationStateV4,
@@ -765,17 +920,33 @@ export const onNavigationStateChange = (
765
920
  const prevScreen = LuciqUtils.getActiveRouteName(prevState);
766
921
 
767
922
  if (prevScreen !== currentScreen) {
923
+ // Start Screen Loading measurement for v4
924
+ let screenLoadingSpanId: string | null = null;
925
+ if (ScreenLoadingManager.isFeatureEnabled()) {
926
+ const span = ScreenLoadingManager.createSpan(currentScreen || 'Unknown', false);
927
+ if (span) {
928
+ screenLoadingSpanId = span.spanId;
929
+ }
930
+ }
931
+
768
932
  reportCurrentViewForAndroid(currentScreen);
769
933
  if (_currentScreen != null && _currentScreen !== firstScreen) {
770
- NativeLuciq.reportScreenChange(_currentScreen);
934
+ NativeLuciq.reportScreenChange(_currentScreen, screenLoadingSpanId);
771
935
  _currentScreen = null;
772
936
  }
773
937
  _currentScreen = currentScreen;
774
938
  setTimeout(() => {
775
939
  if (currentScreen && _currentScreen === currentScreen) {
776
- NativeLuciq.reportScreenChange(currentScreen);
940
+ NativeLuciq.reportScreenChange(currentScreen, screenLoadingSpanId);
777
941
  _currentScreen = null;
778
942
  }
943
+
944
+ // End Screen Loading measurement for v4
945
+ if (screenLoadingSpanId) {
946
+ ScreenLoadingManager.endSpan(screenLoadingSpanId).catch((error) => {
947
+ Logger.warn('[ScreenLoading] Failed to end span:', error);
948
+ });
949
+ }
779
950
  }, 1000);
780
951
  }
781
952
  };
@@ -785,17 +956,28 @@ export const onStateChange = (state?: NavigationStateV5) => {
785
956
  return;
786
957
  }
787
958
 
959
+ // Delegate to the new state change handler for Screen Loading
960
+ // This handles reportScreenChange when setNavigationListener was called
961
+ _onNavigationStateChange();
962
+
963
+ // When setNavigationListener is used, _onNavigationStateChange already handles
964
+ // reportScreenChange properly - skip legacy logic to avoid duplicate calls
965
+ if (_navigationRef?.current) {
966
+ return;
967
+ }
968
+
969
+ // Fallback: Legacy screen tracking for users who only use onStateChange without setNavigationListener
788
970
  const currentScreen = LuciqUtils.getFullRoute(state);
789
971
  reportCurrentViewForAndroid(currentScreen);
790
972
  if (_currentScreen !== null && _currentScreen !== firstScreen) {
791
- NativeLuciq.reportScreenChange(_currentScreen);
973
+ NativeLuciq.reportScreenChange(_currentScreen, null);
792
974
  _currentScreen = null;
793
975
  }
794
976
 
795
977
  _currentScreen = currentScreen;
796
978
  setTimeout(() => {
797
979
  if (_currentScreen === currentScreen) {
798
- NativeLuciq.reportScreenChange(currentScreen);
980
+ NativeLuciq.reportScreenChange(currentScreen, null);
799
981
  _currentScreen = null;
800
982
  }
801
983
  }, 1000);
@@ -809,13 +991,29 @@ export const onStateChange = (state?: NavigationStateV5) => {
809
991
  export const setNavigationListener = (
810
992
  navigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
811
993
  ) => {
812
- return navigationRef.addListener('state', () => {
813
- onStateChange(navigationRef.getRootState());
814
- });
994
+ // Store the navigationRef for Screen Loading tracking
995
+ _navigationRef = navigationRef;
996
+
997
+ if (!navigationRef?.current) {
998
+ Logger.warn('[Luciq] Navigation ref not available, cannot set listeners');
999
+ return;
1000
+ }
1001
+
1002
+ // Register the __unsafe_action__ listener for span creation
1003
+ // This listener fires on navigation dispatch (start of navigation)
1004
+ navigationRef.current.addListener('__unsafe_action__', _onNavigationAction);
1005
+
1006
+ // NOTE: We do NOT register a 'state' listener here because the user is expected
1007
+ // to pass Luciq.onStateChange to NavigationContainer's onStateChange prop.
1008
+ // Registering both would cause duplicate reportScreenChange calls.
1009
+
1010
+ Logger.log('[Luciq] Registered Screen Loading listener (__unsafe_action__)');
1011
+
1012
+ // return stateListener;
815
1013
  };
816
1014
 
817
1015
  export const reportScreenChange = (screenName: string) => {
818
- NativeLuciq.reportScreenChange(screenName);
1016
+ NativeLuciq.reportScreenChange(screenName, null);
819
1017
  };
820
1018
 
821
1019
  /**
@@ -879,7 +1077,7 @@ export const componentDidAppearListener = (event: ComponentDidAppearEvent) => {
879
1077
  return;
880
1078
  }
881
1079
  if (_lastScreen !== event.componentName) {
882
- NativeLuciq.reportScreenChange(event.componentName);
1080
+ NativeLuciq.reportScreenChange(event.componentName, null);
883
1081
  _lastScreen = event.componentName;
884
1082
  }
885
1083
  };
@@ -39,10 +39,17 @@ function getPortFromUrl(url: string) {
39
39
  * It is enabled by default.
40
40
  * @param isEnabled
41
41
  */
42
+ const NET_TAG = 'LCQ-RN-NET:';
43
+
42
44
  export const setEnabled = (isEnabled: boolean) => {
43
45
  if (isEnabled) {
44
46
  xhr.enableInterception();
45
47
  xhr.setOnDoneCallback(async (network) => {
48
+ Logger.debug(
49
+ NET_TAG,
50
+ `[NetworkLogger] onDoneCallback received: ${network.method} ${network.url}, status=${network.responseCode}`,
51
+ );
52
+
46
53
  // eslint-disable-next-line no-new-func
47
54
  const predicate = Function('network', 'return ' + _requestFilterExpression);
48
55
 
@@ -50,12 +57,17 @@ export const setEnabled = (isEnabled: boolean) => {
50
57
  const MAX_NETWORK_BODY_SIZE_IN_BYTES = await NativeLuciq.getNetworkBodyMaxSize();
51
58
  try {
52
59
  if (_networkDataObfuscationHandler) {
60
+ Logger.debug(NET_TAG, `[NetworkLogger] Running obfuscation handler for ${network.url}`);
53
61
  network = await _networkDataObfuscationHandler(network);
54
62
  }
55
63
 
56
64
  if (__DEV__) {
57
65
  const urlPort = getPortFromUrl(network.url);
58
66
  if (urlPort === LuciqRNConfig.metroDevServerPort) {
67
+ Logger.debug(
68
+ NET_TAG,
69
+ `[NetworkLogger] Skipping Metro dev server request: ${network.url}`,
70
+ );
59
71
  return;
60
72
  }
61
73
  }
@@ -97,10 +109,23 @@ export const setEnabled = (isEnabled: boolean) => {
97
109
  );
98
110
  }
99
111
 
112
+ Logger.debug(
113
+ NET_TAG,
114
+ `[NetworkLogger] Reporting network log to native: ${network.method} ${network.url}`,
115
+ );
100
116
  reportNetworkLog(network);
101
117
  } catch (e) {
102
- Logger.error(e);
118
+ Logger.error(
119
+ NET_TAG,
120
+ `[NetworkLogger] Error processing network log for ${network.url}:`,
121
+ e,
122
+ );
103
123
  }
124
+ } else {
125
+ Logger.debug(
126
+ NET_TAG,
127
+ `[NetworkLogger] Request filtered out by predicate: ${network.method} ${network.url}, expression="${_requestFilterExpression}"`,
128
+ );
104
129
  }
105
130
  });
106
131
  } else {