@luciq/react-native 19.4.0-47504-SNAPSHOT → 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 (67) 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 +184 -19
  25. package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqNetworkLoggerModule.java +7 -29
  26. package/android/src/main/java/ai/luciq/reactlibrary/RNLuciqReactnativeModule.java +14 -34
  27. package/android/src/main/java/ai/luciq/reactlibrary/utils/EventEmitterModule.java +0 -7
  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 +170 -13
  36. package/dist/modules/NetworkLogger.d.ts +5 -0
  37. package/dist/modules/NetworkLogger.js +1 -9
  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 +0 -6
  43. package/dist/utils/FeatureFlags.js +0 -35
  44. package/dist/utils/LuciqUtils.d.ts +25 -0
  45. package/dist/utils/LuciqUtils.js +44 -6
  46. package/dist/utils/RouteMatcher.d.ts +30 -0
  47. package/dist/utils/RouteMatcher.js +67 -0
  48. package/dist/utils/XhrNetworkInterceptor.js +53 -85
  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 +5 -1
  56. package/src/components/LuciqCaptureScreenLoading.tsx +210 -0
  57. package/src/index.ts +4 -0
  58. package/src/modules/APM.ts +42 -0
  59. package/src/modules/Luciq.ts +198 -14
  60. package/src/modules/NetworkLogger.ts +1 -26
  61. package/src/modules/apm/ScreenLoadingManager.ts +364 -0
  62. package/src/native/NativeAPM.ts +22 -0
  63. package/src/native/NativeLuciq.ts +1 -1
  64. package/src/utils/FeatureFlags.ts +0 -44
  65. package/src/utils/LuciqUtils.ts +49 -15
  66. package/src/utils/RouteMatcher.ts +83 -0
  67. package/src/utils/XhrNetworkInterceptor.ts +55 -128
@@ -1,8 +1,6 @@
1
1
  import LuciqConstants from './LuciqConstants';
2
2
  import { stringifyIfNotString, generateW3CHeader } from './LuciqUtils';
3
- import { getCachedW3cFlags } from './FeatureFlags';
4
- import { Logger } from './logger';
5
- const TAG = 'LCQ-RN-NET:';
3
+ import { FeatureFlags } from '../utils/FeatureFlags';
6
4
  const XMLHttpRequest = global.XMLHttpRequest;
7
5
  let originalXHROpen = XMLHttpRequest.prototype.open;
8
6
  let originalXHRSend = XMLHttpRequest.prototype.send;
@@ -10,34 +8,40 @@ let originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
10
8
  let onProgressCallback;
11
9
  let onDoneCallback;
12
10
  let isInterceptorEnabled = false;
13
- const networkMap = new WeakMap();
14
- const createNetworkData = () => ({
15
- id: '',
16
- url: '',
17
- method: '',
18
- requestBody: '',
19
- requestBodySize: 0,
20
- responseBody: '',
21
- responseBodySize: 0,
22
- responseCode: 0,
23
- requestHeaders: {},
24
- responseHeaders: {},
25
- contentType: '',
26
- errorDomain: '',
27
- errorCode: 0,
28
- startTime: 0,
29
- duration: 0,
30
- gqlQueryName: '',
31
- serverErrorMessage: '',
32
- requestContentType: '',
33
- isW3cHeaderFound: null,
34
- partialId: null,
35
- networkStartTimeInSeconds: null,
36
- w3cGeneratedHeader: null,
37
- w3cCaughtHeader: null,
38
- });
39
- const getTraceparentHeader = (networkData) => {
40
- const { isW3cExternalTraceIDEnabled, isW3cExternalGeneratedHeaderEnabled, isW3cCaughtHeaderEnabled, } = getCachedW3cFlags();
11
+ let network;
12
+ const _reset = () => {
13
+ network = {
14
+ id: '',
15
+ url: '',
16
+ method: '',
17
+ requestBody: '',
18
+ requestBodySize: 0,
19
+ responseBody: '',
20
+ responseBodySize: 0,
21
+ responseCode: 0,
22
+ requestHeaders: {},
23
+ responseHeaders: {},
24
+ contentType: '',
25
+ errorDomain: '',
26
+ errorCode: 0,
27
+ startTime: 0,
28
+ duration: 0,
29
+ gqlQueryName: '',
30
+ serverErrorMessage: '',
31
+ requestContentType: '',
32
+ isW3cHeaderFound: null,
33
+ partialId: null,
34
+ networkStartTimeInSeconds: null,
35
+ w3cGeneratedHeader: null,
36
+ w3cCaughtHeader: null,
37
+ };
38
+ };
39
+ const getTraceparentHeader = async (networkData) => {
40
+ const [isW3cExternalTraceIDEnabled, isW3cExternalGeneratedHeaderEnabled, isW3cCaughtHeaderEnabled,] = await Promise.all([
41
+ FeatureFlags.isW3ExternalTraceID(),
42
+ FeatureFlags.isW3ExternalGeneratedHeader(),
43
+ FeatureFlags.isW3CaughtHeader(),
44
+ ]);
41
45
  return injectHeaders(networkData, {
42
46
  isW3cExternalTraceIDEnabled,
43
47
  isW3cExternalGeneratedHeaderEnabled,
@@ -81,57 +85,39 @@ export default {
81
85
  onProgressCallback = callback;
82
86
  },
83
87
  enableInterception() {
88
+ // Prevents infinite calls to XMLHttpRequest.open when enabling interception multiple times
84
89
  if (isInterceptorEnabled) {
85
- Logger.debug(TAG, 'enableInterception called but already enabled, skipping');
86
90
  return;
87
91
  }
88
- Logger.debug(TAG, 'Enabling XHR network interception');
89
92
  originalXHROpen = XMLHttpRequest.prototype.open;
90
93
  originalXHRSend = XMLHttpRequest.prototype.send;
91
94
  originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
92
95
  // An error code that signifies an issue with the RN client.
93
96
  const clientErrorCode = 9876;
94
97
  XMLHttpRequest.prototype.open = function (method, url, ...args) {
95
- const networkData = createNetworkData();
96
- networkData.url = url;
97
- networkData.method = method;
98
- networkMap.set(this, networkData);
99
- Logger.debug(TAG, `[open] ${method} ${url}`);
98
+ _reset();
99
+ network.url = url;
100
+ network.method = method;
100
101
  originalXHROpen.apply(this, [method, url, ...args]);
101
102
  };
102
103
  XMLHttpRequest.prototype.setRequestHeader = function (header, value) {
104
+ // According to the HTTP RFC, headers are case-insensitive, so we convert
105
+ // them to lower-case to make accessing headers predictable.
106
+ // This avoid issues like failing to get the Content-Type header for a request
107
+ // because the header is set as 'Content-Type' instead of 'content-type'.
103
108
  const key = header.toLowerCase();
104
- const networkData = networkMap.get(this);
105
- if (networkData) {
106
- networkData.requestHeaders[key] = stringifyIfNotString(value);
107
- }
108
- else {
109
- Logger.debug(TAG, `[setRequestHeader] No networkData found in WeakMap for header "${key}" — request may have been GC'd or open() was not called`);
110
- }
109
+ network.requestHeaders[key] = stringifyIfNotString(value);
111
110
  originalXHRSetRequestHeader.apply(this, [header, value]);
112
111
  };
113
- XMLHttpRequest.prototype.send = function (data) {
114
- const networkData = networkMap.get(this);
115
- if (!networkData) {
116
- Logger.debug(TAG, '[send] No networkData found in WeakMap — falling back to original send (open() was not intercepted)');
117
- originalXHRSend.apply(this, [data]);
118
- return;
119
- }
120
- Logger.debug(TAG, `[send] ${networkData.method} ${networkData.url}`);
121
- const cloneNetwork = JSON.parse(JSON.stringify(networkData));
112
+ XMLHttpRequest.prototype.send = async function (data) {
113
+ const cloneNetwork = JSON.parse(JSON.stringify(network));
122
114
  cloneNetwork.requestBody = data ? data : '';
123
115
  if (typeof cloneNetwork.requestBody !== 'string') {
124
116
  cloneNetwork.requestBody = JSON.stringify(cloneNetwork.requestBody);
125
117
  }
126
- let isReported = false;
127
118
  if (this.addEventListener) {
128
119
  this.addEventListener('readystatechange', async () => {
129
120
  if (!isInterceptorEnabled) {
130
- Logger.debug(TAG, `[readystatechange] Interceptor disabled, ignoring state=${this.readyState} for ${cloneNetwork.url}`);
131
- return;
132
- }
133
- if (isReported) {
134
- Logger.debug(TAG, `[readystatechange] Already reported, ignoring state=${this.readyState} for ${cloneNetwork.url}`);
135
121
  return;
136
122
  }
137
123
  if (this.readyState === this.HEADERS_RECEIVED) {
@@ -160,7 +146,6 @@ export default {
160
146
  cloneNetwork.requestContentType =
161
147
  cloneNetwork.requestHeaders['content-type'].split(';')[0];
162
148
  }
163
- Logger.debug(TAG, `[readystatechange] HEADERS_RECEIVED for ${cloneNetwork.url}, contentType=${cloneNetwork.contentType}`);
164
149
  }
165
150
  if (this.readyState === this.DONE) {
166
151
  cloneNetwork.duration = Date.now() - cloneNetwork.startTime;
@@ -181,11 +166,11 @@ export default {
181
166
  cloneNetwork.requestBody =
182
167
  typeof _response === 'string' ? _response : JSON.stringify(_response);
183
168
  cloneNetwork.responseBody = '';
169
+ // Detect a more descriptive error message.
184
170
  if (typeof _response === 'string' && _response.length > 0) {
185
171
  cloneNetwork.errorDomain = _response;
186
172
  }
187
173
  cloneNetwork.responseBody = `ERROR: ${cloneNetwork.errorDomain}`;
188
- Logger.debug(TAG, `[readystatechange] DONE with client error for ${cloneNetwork.url}, errorDomain=${cloneNetwork.errorDomain}`);
189
174
  // @ts-ignore
190
175
  }
191
176
  else if (this._timedOut) {
@@ -194,7 +179,6 @@ export default {
194
179
  cloneNetwork.responseCode = 0;
195
180
  cloneNetwork.contentType = 'text/plain';
196
181
  cloneNetwork.responseBody = `ERROR: ${cloneNetwork.errorDomain}`;
197
- Logger.debug(TAG, `[readystatechange] DONE with timeout for ${cloneNetwork.url}`);
198
182
  }
199
183
  // Only set response body if not already set by error handlers
200
184
  if (!cloneNetwork.errorDomain) {
@@ -241,20 +225,16 @@ export default {
241
225
  else {
242
226
  delete cloneNetwork.gqlQueryName;
243
227
  }
244
- isReported = true;
245
- Logger.debug(TAG, `[readystatechange] DONE for ${cloneNetwork.method} ${cloneNetwork.url} — status=${cloneNetwork.responseCode}, duration=${cloneNetwork.duration}ms, hasCallback=${!!onDoneCallback}`);
246
228
  if (onDoneCallback) {
247
229
  onDoneCallback(cloneNetwork);
248
230
  }
249
- else {
250
- Logger.debug(TAG, `[readystatechange] WARNING: onDoneCallback is null, network log for ${cloneNetwork.url} will be LOST`);
251
- }
252
231
  }
253
232
  });
254
233
  const downloadUploadProgressCallback = (event) => {
255
234
  if (!isInterceptorEnabled) {
256
235
  return;
257
236
  }
237
+ // check if will be able to compute progress
258
238
  if (event.lengthComputable && onProgressCallback) {
259
239
  const totalBytesSent = event.loaded;
260
240
  const totalBytesExpectedToSend = event.total - event.loaded;
@@ -263,43 +243,31 @@ export default {
263
243
  };
264
244
  this.addEventListener('progress', downloadUploadProgressCallback);
265
245
  this.upload.addEventListener('progress', downloadUploadProgressCallback);
246
+ // Handler for abort events (works with fetch, Axios, and any XHR-based requests)
266
247
  this.addEventListener('abort', () => {
267
248
  if (!isInterceptorEnabled) {
268
- Logger.debug(TAG, `[abort] Interceptor disabled, ignoring abort for ${cloneNetwork.url}`);
269
- return;
270
- }
271
- if (isReported) {
272
- Logger.debug(TAG, `[abort] Already reported via readystatechange DONE, ignoring duplicate abort for ${cloneNetwork.url}`);
273
249
  return;
274
250
  }
275
- isReported = true;
276
251
  cloneNetwork.duration = Date.now() - cloneNetwork.startTime;
277
252
  cloneNetwork.responseCode = 0;
278
253
  cloneNetwork.errorCode = clientErrorCode;
279
254
  cloneNetwork.errorDomain = 'cancelled';
280
255
  cloneNetwork.responseBody = `ERROR: ${cloneNetwork.errorDomain}`;
281
- Logger.debug(TAG, `[abort] Request cancelled: ${cloneNetwork.method} ${cloneNetwork.url}, duration=${cloneNetwork.duration}ms, hasCallback=${!!onDoneCallback}`);
282
- if (onDoneCallback) {
283
- onDoneCallback(cloneNetwork);
284
- }
285
- else {
286
- Logger.debug(TAG, `[abort] WARNING: onDoneCallback is null, cancelled log for ${cloneNetwork.url} will be LOST`);
287
- }
288
256
  });
289
257
  }
290
258
  cloneNetwork.startTime = Date.now();
291
- const traceparent = getTraceparentHeader(cloneNetwork);
259
+ const traceparent = await getTraceparentHeader(cloneNetwork);
292
260
  if (traceparent) {
293
261
  this.setRequestHeader('Traceparent', traceparent);
294
- Logger.debug(TAG, `[send] Injected traceparent header for ${cloneNetwork.url}`);
262
+ }
263
+ if (this.readyState === this.UNSENT) {
264
+ return; // Prevent sending the request if not opened
295
265
  }
296
266
  originalXHRSend.apply(this, [data]);
297
267
  };
298
268
  isInterceptorEnabled = true;
299
- Logger.debug(TAG, 'XHR network interception enabled');
300
269
  },
301
270
  disableInterception() {
302
- Logger.debug(TAG, 'Disabling XHR network interception');
303
271
  isInterceptorEnabled = false;
304
272
  XMLHttpRequest.prototype.send = originalXHRSend;
305
273
  XMLHttpRequest.prototype.open = originalXHROpen;
@@ -8,6 +8,7 @@
8
8
  #import <LuciqSDK/LCQTypes.h>
9
9
  #import <React/RCTUIManager.h>
10
10
  #import "Util/LCQAPM+PrivateAPIs.h"
11
+ #import "LuciqScreenLoadingFrameTracker.h"
11
12
 
12
13
  @implementation LuciqAPMBridge
13
14
 
@@ -145,7 +146,88 @@ RCT_EXPORT_METHOD(isAPMEnabled:(RCTPromiseResolveBlock)resolve
145
146
  }
146
147
  }
147
148
 
149
+ // Screen Loading methods
150
+ RCT_EXPORT_METHOD(initScreenFrameTracking:(RCTPromiseResolveBlock)resolve
151
+ rejecter:(RCTPromiseRejectBlock)reject)
152
+ {
153
+ dispatch_async(dispatch_get_main_queue(), ^{
154
+ [[LuciqScreenLoadingFrameTracker sharedInstance] initializeFrameTracking];
155
+ resolve(nil);
156
+ });
157
+ }
158
+
159
+ RCT_EXPORT_METHOD(setActiveScreenSpanId:(NSString *)spanId)
160
+ {
161
+ dispatch_async(dispatch_get_main_queue(), ^{
162
+ [[LuciqScreenLoadingFrameTracker sharedInstance] startTrackingForSpanId:spanId];
163
+ });
164
+ }
165
+
166
+ RCT_EXPORT_METHOD(getScreenTimeToDisplay:(NSString *)spanId
167
+ resolver:(RCTPromiseResolveBlock)resolve
168
+ rejecter:(RCTPromiseRejectBlock)reject)
169
+ {
170
+ dispatch_async(dispatch_get_main_queue(), ^{
171
+ NSNumber *timestamp = [[LuciqScreenLoadingFrameTracker sharedInstance] getFrameTimestampForSpanId:spanId];
172
+ resolve(timestamp);
173
+ });
174
+ }
175
+
176
+ RCT_EXPORT_METHOD(isScreenLoadingEnabled:(RCTPromiseResolveBlock)resolve
177
+ rejecter:(RCTPromiseRejectBlock)reject){
148
178
 
179
+ BOOL isScreenLoadingEnabled = LCQAPM.screenLoadingEnabled;
180
+ resolve(@(isScreenLoadingEnabled));
181
+ }
182
+
183
+ RCT_EXPORT_METHOD(isEndScreenLoadingEnabled:(RCTPromiseResolveBlock)resolve
184
+ rejecter:(RCTPromiseRejectBlock)reject){
185
+
186
+ BOOL isEndScreenLoadingEnabled = LCQAPM.endScreenLoadingEnabled;
187
+ resolve(@(isEndScreenLoadingEnabled));
188
+ }
189
+
190
+ // uiTraceId is unused on iOS but required to keep the React Native Bridge call
191
+ // signature consistent with Android, which uses it.
192
+ RCT_EXPORT_METHOD(endScreenLoading:(double)timeStampMicro
193
+ uiTraceId:(double)uiTraceId){
194
+ [LCQAPM endScreenLoadingCPWithEndTimestampMUS:timeStampMicro];
195
+ }
196
+
197
+ RCT_EXPORT_METHOD(setScreenLoadingEnabled:(BOOL)isEnabled){
198
+ LCQAPM.screenLoadingEnabled = isEnabled;
199
+ }
200
+
201
+ - (NSMutableDictionary<NSString *, NSNumber *> *)buildStagesMapFromAttributes:(NSDictionary *)stages {
202
+ NSMutableDictionary<NSString *, NSNumber *> *stagesMap = [NSMutableDictionary dictionary];
203
+ NSArray<NSString *> *keys = @[@"cnst_mus_st" , @"cnst_mus",@"rnd_mus_st", @"rnd_mus", @"mnt_mus_st" ,@"mnt_mus", @"lyt_mus_st" , @"lyt_mus"];
204
+ for (NSString *key in keys) {
205
+ if (stages[key])
206
+ stagesMap[key] = @([stages[key] longLongValue]);
207
+ }
208
+ return stagesMap;
209
+ }
210
+
211
+ // Syncs screen loading data to native layer for reporting
212
+ RCT_EXPORT_METHOD(syncScreenLoading:(double)spanId
213
+ screenName:(NSString *)screenName
214
+ startTimestamp:(double)startTimestamp
215
+ ttid_us:(double)ttid_us
216
+ attributes:(NSDictionary *)stages){
217
+
218
+ NSMutableDictionary<NSString *, NSNumber *> *stagesMap = [self buildStagesMapFromAttributes:stages];
219
+ [LCQAPM reportScreenLoadingCPWithStartTimestampMUS:startTimestamp durationMUS:ttid_us stages:stagesMap];
220
+ }
221
+
222
+ // Syncs manual screen loading measurements to native layer for reporting (no span ID)
223
+ RCT_EXPORT_METHOD(syncManualScreenLoading:(NSString *)screenName
224
+ startTimestamp:(double)startTimestamp
225
+ ttid_mus:(double)ttid_mus
226
+ attributes:(NSDictionary *)stages){
227
+
228
+ NSMutableDictionary<NSString *, NSNumber *> *stagesMap = [self buildStagesMapFromAttributes:stages];
229
+ [LCQAPM reportScreenLoadingCPUITraceWithName:screenName screenLoadingStartMUS:startTimestamp screenLoadingDurationMUS:ttid_mus stages:stagesMap];
230
+ }
149
231
 
150
232
  @synthesize description;
151
233
 
@@ -453,7 +453,7 @@ RCT_EXPORT_METHOD(show) {
453
453
  [[NSRunLoop mainRunLoop] performSelector:@selector(show) target:[Luciq class] argument:nil order:0 modes:@[NSDefaultRunLoopMode]];
454
454
  }
455
455
 
456
- RCT_EXPORT_METHOD(reportScreenChange:(NSString *)screenName) {
456
+ RCT_EXPORT_METHOD(reportScreenChange:(NSString *)screenName spanId:(NSString * _Nullable)spanId) {
457
457
  SEL setPrivateApiSEL = NSSelectorFromString(@"logViewDidAppearEvent:");
458
458
  if ([[Luciq class] respondsToSelector:setPrivateApiSEL]) {
459
459
  NSInvocation *inv = [NSInvocation invocationWithMethodSignature:[[Luciq class] methodSignatureForSelector:setPrivateApiSEL]];
@@ -0,0 +1,11 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ @interface LuciqScreenLoadingFrameTracker : NSObject
4
+
5
+ + (instancetype)sharedInstance;
6
+ - (void)startTrackingForSpanId:(NSString *)spanId;
7
+ - (NSNumber *)getFrameTimestampForSpanId:(NSString *)spanId;
8
+ - (void)initializeFrameTracking;
9
+ - (void)cleanup;
10
+
11
+ @end
@@ -0,0 +1,121 @@
1
+ #import "LuciqScreenLoadingFrameTracker.h"
2
+ #import <QuartzCore/CADisplayLink.h>
3
+
4
+ @interface LuciqScreenLoadingFrameTracker ()
5
+ @property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *spanIdToTimestamp;
6
+ @property (nonatomic, strong) NSMutableSet<NSString *> *activeSpanIds;
7
+ @property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *spanIdToTrackingStart;
8
+ @property (nonatomic, assign) NSInteger maxStorageCapacity;
9
+ @property (nonatomic, assign) BOOL isTracking;
10
+ @property (nonatomic, strong) CADisplayLink *displayLink;
11
+ @end
12
+
13
+ @implementation LuciqScreenLoadingFrameTracker
14
+
15
+ + (instancetype)sharedInstance {
16
+ static LuciqScreenLoadingFrameTracker *instance = nil;
17
+ static dispatch_once_t onceToken;
18
+ dispatch_once(&onceToken, ^{
19
+ instance = [[self alloc] init];
20
+ });
21
+ return instance;
22
+ }
23
+
24
+ - (instancetype)init {
25
+ if (self = [super init]) {
26
+ self.spanIdToTimestamp = [NSMutableDictionary dictionary];
27
+ self.activeSpanIds = [NSMutableSet set];
28
+ self.spanIdToTrackingStart = [NSMutableDictionary dictionary];
29
+ self.maxStorageCapacity = 50;
30
+ self.isTracking = NO;
31
+ }
32
+ return self;
33
+ }
34
+
35
+ - (void)initializeFrameTracking {
36
+ if (self.isTracking) {
37
+ return;
38
+ }
39
+
40
+ self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDisplayLink:)];
41
+ [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
42
+ self.isTracking = YES;
43
+ }
44
+
45
+ - (void)handleDisplayLink:(CADisplayLink *)displayLink {
46
+ [self frameRenderedWithTimestamp:displayLink.timestamp];
47
+ }
48
+
49
+ - (void)frameRenderedWithTimestamp:(NSTimeInterval)timestamp {
50
+ if (self.activeSpanIds.count > 0) {
51
+ // timestamp is monotonic (seconds since boot, from CADisplayLink / mach_absolute_time)
52
+ // Convert to epoch microseconds using the same approach as Android:
53
+ // figure out how long ago the frame was rendered, then subtract from current epoch
54
+ NSTimeInterval currentUptime = [[NSProcessInfo processInfo] systemUptime];
55
+ NSTimeInterval currentEpoch = [[NSDate date] timeIntervalSince1970];
56
+ NSTimeInterval timeSinceFrame = currentUptime - timestamp;
57
+ NSTimeInterval frameEpochSeconds = currentEpoch - timeSinceFrame;
58
+ NSTimeInterval epochTimestampMicroseconds = frameEpochSeconds * 1000000;
59
+ NSNumber *timestampNumber = @(epochTimestampMicroseconds);
60
+
61
+ NSMutableSet<NSString *> *resolvedSpanIds = [NSMutableSet set];
62
+ for (NSString *spanId in self.activeSpanIds) {
63
+ NSNumber *trackingStart = self.spanIdToTrackingStart[spanId];
64
+ if (trackingStart && timestamp < trackingStart.doubleValue) {
65
+ NSLog(@"[ScreenLoading] Skipping frame for span %@ (VSync %.6fs < tracking start %.6fs)", spanId, timestamp, trackingStart.doubleValue);
66
+ continue;
67
+ }
68
+ self.spanIdToTimestamp[spanId] = timestampNumber;
69
+ [resolvedSpanIds addObject:spanId];
70
+ [self.spanIdToTrackingStart removeObjectForKey:spanId];
71
+ NSLog(@"[ScreenLoading] Frame rendered for span %@ at %.0fμs", spanId, epochTimestampMicroseconds);
72
+ }
73
+ [self.activeSpanIds minusSet:resolvedSpanIds];
74
+
75
+ // Cleanup if exceeding capacity
76
+ if (self.spanIdToTimestamp.count > self.maxStorageCapacity) {
77
+ [self cleanupStorage];
78
+ }
79
+ }
80
+ }
81
+
82
+ - (void)startTrackingForSpanId:(NSString *)spanId {
83
+ [self.activeSpanIds addObject:spanId];
84
+ self.spanIdToTrackingStart[spanId] = @([[NSProcessInfo processInfo] systemUptime]);
85
+ NSLog(@"[ScreenLoading] Started tracking for span %@", spanId);
86
+ }
87
+
88
+ - (NSNumber *)getFrameTimestampForSpanId:(NSString *)spanId {
89
+ NSNumber *timestamp = self.spanIdToTimestamp[spanId];
90
+ if (timestamp) {
91
+ [self.spanIdToTimestamp removeObjectForKey:spanId];
92
+ NSLog(@"[ScreenLoading] Retrieved timestamp %@μs for span %@", timestamp, spanId);
93
+ }
94
+ return timestamp;
95
+ }
96
+
97
+ - (void)cleanup {
98
+ if (self.isTracking) {
99
+ [self.displayLink invalidate];
100
+ self.displayLink = nil;
101
+ self.isTracking = NO;
102
+ }
103
+ [self.spanIdToTimestamp removeAllObjects];
104
+ [self.activeSpanIds removeAllObjects];
105
+ }
106
+
107
+ - (void)cleanupStorage {
108
+ NSArray *sortedKeys = [self.spanIdToTimestamp keysSortedByValueUsingComparator:^NSComparisonResult(NSNumber *obj1, NSNumber *obj2) {
109
+ return [obj1 compare:obj2];
110
+ }];
111
+
112
+ NSInteger itemsToRemove = self.spanIdToTimestamp.count - 30;
113
+ if (itemsToRemove > 0) {
114
+ for (NSInteger i = 0; i < itemsToRemove; i++) {
115
+ [self.spanIdToTimestamp removeObjectForKey:sortedKeys[i]];
116
+ }
117
+ }
118
+ }
119
+
120
+ @end
121
+
@@ -10,7 +10,21 @@
10
10
  @interface LCQAPM (PrivateAPIs)
11
11
 
12
12
  @property (class, atomic, assign) BOOL networkEnabled;
13
+ @property (class, atomic, readonly) BOOL endScreenLoadingEnabled;
14
+
15
+ typedef void (^LCQDisplayLinkObservationCallback)(NSTimeInterval currentTimestamp, NSTimeInterval targetTimestamp);
13
16
 
14
17
  + (BOOL)customSpansEnabled;
15
18
 
19
+ + (void)endScreenLoadingCPWithEndTimestampMUS:(double)endTimestampMUS;
20
+ + (void)reportScreenLoadingCPWithStartTimestampMUS:(double)startTimestampMUS
21
+ durationMUS:(double)durationMUS
22
+ stages:(nullable NSDictionary<NSString *, NSNumber *> *)stages;;
23
+
24
+ + (void)startObservingDisplayLinkWithCallback:(LCQDisplayLinkObservationCallback _Nonnull)callback;
25
+ + (void)stopObservingDisplayLink;
26
+ + (void)reportScreenLoadingCPUITraceWithName:(NSString *_Nonnull)name
27
+ screenLoadingStartMUS:(double)screenLoadingStartMUS
28
+ screenLoadingDurationMUS:(double)screenLoadingDurationMUS
29
+ stages:(nullable NSDictionary<NSString *, NSNumber *> *)stages;
16
30
  @end
package/ios/native.rb CHANGED
@@ -1,4 +1,4 @@
1
- $luciq= { :version => '19.5.1' }
1
+ $luciq= { :version => '19.6.1' }
2
2
 
3
3
  def use_luciq! (spec = nil)
4
4
  version = $luciq[:version]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@luciq/react-native",
3
3
  "description": "Luciq is the Agentic Observability Platform built for Mobile.",
4
- "version": "19.4.0-47504-SNAPSHOT",
4
+ "version": "19.6.0",
5
5
  "author": "Luciq (https://luciq.ai)",
6
6
  "repository": "github:luciqai/luciq-reactnative-sdk",
7
7
  "homepage": "https://www.luciq.ai/platforms/react-native",
@@ -43,17 +43,20 @@
43
43
  },
44
44
  "devDependencies": {
45
45
  "@apollo/client": "^3.7.0",
46
+ "@instabug/danger-plugin-coverage": "Instabug/danger-plugin-coverage",
46
47
  "@react-native-community/eslint-config": "^3.1.0",
47
48
  "@react-navigation/native": "^6.1.7",
48
49
  "@rollup/plugin-commonjs": "^25.0.3",
49
50
  "@rollup/plugin-json": "^6.0.0",
50
51
  "@rollup/plugin-node-resolve": "^15.0.1",
51
52
  "@rollup/plugin-typescript": "^11.0.0",
53
+ "@testing-library/react-native": "^13.3.3",
52
54
  "@trivago/prettier-plugin-sort-imports": "^4.2.0",
53
55
  "@types/jest": "^29.5.3",
54
56
  "@types/minimatch": "3.0.4",
55
57
  "@types/node": "^20.4.8",
56
58
  "@types/react-native": "^0.72.2",
59
+ "@types/react-test-renderer": "^19.1.0",
57
60
  "axios": "1.11.0",
58
61
  "babel-core": "7.0.0-bridge.0",
59
62
  "babel-jest": "^29.6.2",
@@ -74,6 +77,7 @@
74
77
  "react-native": "^0.72.3",
75
78
  "react-native-navigation": "7.36.0",
76
79
  "react-navigation": "^4.4.4",
80
+ "react-test-renderer": "18.3.1",
77
81
  "rollup": "^3.27.2",
78
82
  "rollup-plugin-cleanup": "^3.2.1",
79
83
  "rollup-plugin-copy": "^3.5.0",