@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
@@ -1,6 +1,8 @@
1
1
  import LuciqConstants from './LuciqConstants';
2
2
  import { stringifyIfNotString, generateW3CHeader } from './LuciqUtils';
3
- import { FeatureFlags } from '../utils/FeatureFlags';
3
+ import { getCachedW3cFlags } from './FeatureFlags';
4
+ import { Logger } from './logger';
5
+ const TAG = 'LCQ-RN-NET:';
4
6
  const XMLHttpRequest = global.XMLHttpRequest;
5
7
  let originalXHROpen = XMLHttpRequest.prototype.open;
6
8
  let originalXHRSend = XMLHttpRequest.prototype.send;
@@ -8,40 +10,34 @@ let originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
8
10
  let onProgressCallback;
9
11
  let onDoneCallback;
10
12
  let isInterceptorEnabled = false;
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
- ]);
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();
45
41
  return injectHeaders(networkData, {
46
42
  isW3cExternalTraceIDEnabled,
47
43
  isW3cExternalGeneratedHeaderEnabled,
@@ -85,39 +81,57 @@ export default {
85
81
  onProgressCallback = callback;
86
82
  },
87
83
  enableInterception() {
88
- // Prevents infinite calls to XMLHttpRequest.open when enabling interception multiple times
89
84
  if (isInterceptorEnabled) {
85
+ Logger.debug(TAG, 'enableInterception called but already enabled, skipping');
90
86
  return;
91
87
  }
88
+ Logger.debug(TAG, 'Enabling XHR network interception');
92
89
  originalXHROpen = XMLHttpRequest.prototype.open;
93
90
  originalXHRSend = XMLHttpRequest.prototype.send;
94
91
  originalXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
95
92
  // An error code that signifies an issue with the RN client.
96
93
  const clientErrorCode = 9876;
97
94
  XMLHttpRequest.prototype.open = function (method, url, ...args) {
98
- _reset();
99
- network.url = url;
100
- network.method = method;
95
+ const networkData = createNetworkData();
96
+ networkData.url = url;
97
+ networkData.method = method;
98
+ networkMap.set(this, networkData);
99
+ Logger.debug(TAG, `[open] ${method} ${url}`);
101
100
  originalXHROpen.apply(this, [method, url, ...args]);
102
101
  };
103
102
  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'.
108
103
  const key = header.toLowerCase();
109
- network.requestHeaders[key] = stringifyIfNotString(value);
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
+ }
110
111
  originalXHRSetRequestHeader.apply(this, [header, value]);
111
112
  };
112
- XMLHttpRequest.prototype.send = async function (data) {
113
- const cloneNetwork = JSON.parse(JSON.stringify(network));
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));
114
122
  cloneNetwork.requestBody = data ? data : '';
115
123
  if (typeof cloneNetwork.requestBody !== 'string') {
116
124
  cloneNetwork.requestBody = JSON.stringify(cloneNetwork.requestBody);
117
125
  }
126
+ let isReported = false;
118
127
  if (this.addEventListener) {
119
128
  this.addEventListener('readystatechange', async () => {
120
129
  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}`);
121
135
  return;
122
136
  }
123
137
  if (this.readyState === this.HEADERS_RECEIVED) {
@@ -146,6 +160,7 @@ export default {
146
160
  cloneNetwork.requestContentType =
147
161
  cloneNetwork.requestHeaders['content-type'].split(';')[0];
148
162
  }
163
+ Logger.debug(TAG, `[readystatechange] HEADERS_RECEIVED for ${cloneNetwork.url}, contentType=${cloneNetwork.contentType}`);
149
164
  }
150
165
  if (this.readyState === this.DONE) {
151
166
  cloneNetwork.duration = Date.now() - cloneNetwork.startTime;
@@ -166,11 +181,11 @@ export default {
166
181
  cloneNetwork.requestBody =
167
182
  typeof _response === 'string' ? _response : JSON.stringify(_response);
168
183
  cloneNetwork.responseBody = '';
169
- // Detect a more descriptive error message.
170
184
  if (typeof _response === 'string' && _response.length > 0) {
171
185
  cloneNetwork.errorDomain = _response;
172
186
  }
173
187
  cloneNetwork.responseBody = `ERROR: ${cloneNetwork.errorDomain}`;
188
+ Logger.debug(TAG, `[readystatechange] DONE with client error for ${cloneNetwork.url}, errorDomain=${cloneNetwork.errorDomain}`);
174
189
  // @ts-ignore
175
190
  }
176
191
  else if (this._timedOut) {
@@ -179,6 +194,7 @@ export default {
179
194
  cloneNetwork.responseCode = 0;
180
195
  cloneNetwork.contentType = 'text/plain';
181
196
  cloneNetwork.responseBody = `ERROR: ${cloneNetwork.errorDomain}`;
197
+ Logger.debug(TAG, `[readystatechange] DONE with timeout for ${cloneNetwork.url}`);
182
198
  }
183
199
  // Only set response body if not already set by error handlers
184
200
  if (!cloneNetwork.errorDomain) {
@@ -225,16 +241,20 @@ export default {
225
241
  else {
226
242
  delete cloneNetwork.gqlQueryName;
227
243
  }
244
+ isReported = true;
245
+ Logger.debug(TAG, `[readystatechange] DONE for ${cloneNetwork.method} ${cloneNetwork.url} — status=${cloneNetwork.responseCode}, duration=${cloneNetwork.duration}ms, hasCallback=${!!onDoneCallback}`);
228
246
  if (onDoneCallback) {
229
247
  onDoneCallback(cloneNetwork);
230
248
  }
249
+ else {
250
+ Logger.debug(TAG, `[readystatechange] WARNING: onDoneCallback is null, network log for ${cloneNetwork.url} will be LOST`);
251
+ }
231
252
  }
232
253
  });
233
254
  const downloadUploadProgressCallback = (event) => {
234
255
  if (!isInterceptorEnabled) {
235
256
  return;
236
257
  }
237
- // check if will be able to compute progress
238
258
  if (event.lengthComputable && onProgressCallback) {
239
259
  const totalBytesSent = event.loaded;
240
260
  const totalBytesExpectedToSend = event.total - event.loaded;
@@ -243,31 +263,43 @@ export default {
243
263
  };
244
264
  this.addEventListener('progress', downloadUploadProgressCallback);
245
265
  this.upload.addEventListener('progress', downloadUploadProgressCallback);
246
- // Handler for abort events (works with fetch, Axios, and any XHR-based requests)
247
266
  this.addEventListener('abort', () => {
248
267
  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}`);
249
273
  return;
250
274
  }
275
+ isReported = true;
251
276
  cloneNetwork.duration = Date.now() - cloneNetwork.startTime;
252
277
  cloneNetwork.responseCode = 0;
253
278
  cloneNetwork.errorCode = clientErrorCode;
254
279
  cloneNetwork.errorDomain = 'cancelled';
255
280
  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
+ }
256
288
  });
257
289
  }
258
290
  cloneNetwork.startTime = Date.now();
259
- const traceparent = await getTraceparentHeader(cloneNetwork);
291
+ const traceparent = getTraceparentHeader(cloneNetwork);
260
292
  if (traceparent) {
261
293
  this.setRequestHeader('Traceparent', traceparent);
262
- }
263
- if (this.readyState === this.UNSENT) {
264
- return; // Prevent sending the request if not opened
294
+ Logger.debug(TAG, `[send] Injected traceparent header for ${cloneNetwork.url}`);
265
295
  }
266
296
  originalXHRSend.apply(this, [data]);
267
297
  };
268
298
  isInterceptorEnabled = true;
299
+ Logger.debug(TAG, 'XHR network interception enabled');
269
300
  },
270
301
  disableInterception() {
302
+ Logger.debug(TAG, 'Disabling XHR network interception');
271
303
  isInterceptorEnabled = false;
272
304
  XMLHttpRequest.prototype.send = originalXHRSend;
273
305
  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",
4
+ "version": "19.6.0-51917-SNAPSHOT",
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,18 +43,19 @@
43
43
  },
44
44
  "devDependencies": {
45
45
  "@apollo/client": "^3.7.0",
46
- "@instabug/danger-plugin-coverage": "Instabug/danger-plugin-coverage",
47
46
  "@react-native-community/eslint-config": "^3.1.0",
48
47
  "@react-navigation/native": "^6.1.7",
49
48
  "@rollup/plugin-commonjs": "^25.0.3",
50
49
  "@rollup/plugin-json": "^6.0.0",
51
50
  "@rollup/plugin-node-resolve": "^15.0.1",
52
51
  "@rollup/plugin-typescript": "^11.0.0",
52
+ "@testing-library/react-native": "^13.3.3",
53
53
  "@trivago/prettier-plugin-sort-imports": "^4.2.0",
54
54
  "@types/jest": "^29.5.3",
55
55
  "@types/minimatch": "3.0.4",
56
56
  "@types/node": "^20.4.8",
57
57
  "@types/react-native": "^0.72.2",
58
+ "@types/react-test-renderer": "^19.1.0",
58
59
  "axios": "1.11.0",
59
60
  "babel-core": "7.0.0-bridge.0",
60
61
  "babel-jest": "^29.6.2",
@@ -75,6 +76,7 @@
75
76
  "react-native": "^0.72.3",
76
77
  "react-native-navigation": "7.36.0",
77
78
  "react-navigation": "^4.4.4",
79
+ "react-test-renderer": "18.3.1",
78
80
  "rollup": "^3.27.2",
79
81
  "rollup-plugin-cleanup": "^3.2.1",
80
82
  "rollup-plugin-copy": "^3.5.0",
@@ -0,0 +1,70 @@
1
+ #!/bin/bash
2
+
3
+ # Generates a GitHub App installation token using openssl + curl.
4
+ # No external dependencies required.
5
+ #
6
+ # Usage: bash get-github-app-token.sh <APP_ID_ENV> <PRIVATE_KEY_ENV> <INSTALLATION_ID_ENV>
7
+ # Example: bash get-github-app-token.sh AND_LUCIQ_APP_ID AND_LUCIQ_PRIVATE_KEY AND_LUCIQ_INSTALLATION_ID
8
+ # Example: bash get-github-app-token.sh AND_INSTABUG_APP_ID AND_INSTABUG_PRIVATE_KEY AND_INSTABUG_INSTALLATION_ID
9
+
10
+ set -euo pipefail
11
+
12
+ APP_ID_ENV="${1:?Usage: $0 <APP_ID_ENV> <PRIVATE_KEY_ENV> <INSTALLATION_ID_ENV>}"
13
+ PRIVATE_KEY_ENV="${2:?Usage: $0 <APP_ID_ENV> <PRIVATE_KEY_ENV> <INSTALLATION_ID_ENV>}"
14
+ INSTALL_ID_ENV="${3:?Usage: $0 <APP_ID_ENV> <PRIVATE_KEY_ENV> <INSTALLATION_ID_ENV>}"
15
+
16
+ APP_ID="${!APP_ID_ENV:?Error: $APP_ID_ENV is not set}"
17
+ PRIVATE_KEY="${!PRIVATE_KEY_ENV:?Error: $PRIVATE_KEY_ENV is not set}"
18
+ INSTALL_ID="${!INSTALL_ID_ENV:?Error: $INSTALL_ID_ENV is not set}"
19
+
20
+ # Reconstruct PEM file from flattened env var
21
+ # CircleCI flattens multiline env vars into a single line,
22
+ # so we extract header/footer and re-wrap the base64 body at 64 chars
23
+ PEM_FILE=$(mktemp)
24
+ chmod 600 "$PEM_FILE"
25
+ trap 'rm -f "$PEM_FILE"' EXIT
26
+
27
+ BODY=$(printf '%s' "$PRIVATE_KEY" | sed 's/-----BEGIN RSA PRIVATE KEY-----//;s/-----END RSA PRIVATE KEY-----//;s/ //g')
28
+ {
29
+ echo "-----BEGIN RSA PRIVATE KEY-----"
30
+ echo "$BODY" | fold -w 64
31
+ echo "-----END RSA PRIVATE KEY-----"
32
+ } > "$PEM_FILE"
33
+
34
+ # Base64url encode (RFC 4648): replace +/ with -_, strip =
35
+ b64url() {
36
+ openssl base64 -A | tr '+/' '-_' | tr -d '='
37
+ }
38
+
39
+ NOW=$(date +%s)
40
+ IAT=$((NOW - 60)) # 60s clock skew allowance per GitHub docs
41
+ EXP=$((NOW + 600)) # 10min max JWT lifetime per GitHub docs
42
+
43
+ # Create JWT header and payload
44
+ HEADER=$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)
45
+ PAYLOAD=$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' "$IAT" "$EXP" "$APP_ID" | b64url)
46
+
47
+ # Sign with RSA-SHA256
48
+ SIGNATURE=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -sha256 -sign "$PEM_FILE" -binary | b64url)
49
+
50
+ JWT_TOKEN="${HEADER}.${PAYLOAD}.${SIGNATURE}"
51
+
52
+ # Exchange JWT for installation token
53
+ RESPONSE=$(curl -sf -X POST \
54
+ -H "Authorization: Bearer $JWT_TOKEN" \
55
+ -H "Accept: application/vnd.github+json" \
56
+ -H "X-GitHub-Api-Version: 2022-11-28" \
57
+ "https://api.github.com/app/installations/${INSTALL_ID}/access_tokens") || {
58
+ echo "Error: GitHub API request failed (HTTP error)" >&2
59
+ exit 1
60
+ }
61
+
62
+ TOKEN=$(echo "$RESPONSE" | jq -r '.token // empty')
63
+
64
+ if [ -z "$TOKEN" ]; then
65
+ ERROR_MSG=$(echo "$RESPONSE" | jq -r '.message // "unknown error"')
66
+ echo "Error: Failed to get installation token: $ERROR_MSG" >&2
67
+ exit 1
68
+ fi
69
+
70
+ echo "$TOKEN"
@@ -1,15 +1,24 @@
1
1
  #!/bin/bash
2
2
 
3
+ set -euo pipefail
4
+
3
5
  pr_url="https://api.github.com/repos/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/pulls?head=$CIRCLE_PROJECT_USERNAME:$CIRCLE_BRANCH&state=open"
4
6
  pr_response=$(curl --location --request GET "$pr_url" --header "Authorization: Bearer $RELEASE_GITHUB_TOKEN")
5
7
 
6
- if [ $(echo "$pr_response" | jq length) -eq 0 ]; then
7
- echo "No PR found to update"
8
- else
9
- pr_comment_url=$(echo "$pr_response" | jq -r ".[]._links.comments.href")
8
+ if ! echo "$pr_response" | jq -e 'type == "array"' >/dev/null; then
9
+ echo "Unexpected GitHub API response (not an array):"
10
+ echo "$pr_response"
11
+ exit 1
12
+ fi
10
13
 
11
- curl --location --request POST "$pr_comment_url" \
12
- --header 'Content-Type: application/json' \
13
- --header "Authorization: Bearer $RELEASE_GITHUB_TOKEN" \
14
- --data-raw "$1"
14
+ if [ "$(echo "$pr_response" | jq length)" -eq 0 ]; then
15
+ echo "No PR found to update"
16
+ exit 0
15
17
  fi
18
+
19
+ pr_comment_url=$(echo "$pr_response" | jq -r ".[]._links.comments.href")
20
+
21
+ curl --location --request POST "$pr_comment_url" \
22
+ --header 'Content-Type: application/json' \
23
+ --header "Authorization: Bearer $RELEASE_GITHUB_TOKEN" \
24
+ --data-raw "$1"