@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
@@ -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",
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",
@@ -50,11 +50,13 @@
50
50
  "@rollup/plugin-json": "^6.0.0",
51
51
  "@rollup/plugin-node-resolve": "^15.0.1",
52
52
  "@rollup/plugin-typescript": "^11.0.0",
53
+ "@testing-library/react-native": "^13.3.3",
53
54
  "@trivago/prettier-plugin-sort-imports": "^4.2.0",
54
55
  "@types/jest": "^29.5.3",
55
56
  "@types/minimatch": "3.0.4",
56
57
  "@types/node": "^20.4.8",
57
58
  "@types/react-native": "^0.72.2",
59
+ "@types/react-test-renderer": "^19.1.0",
58
60
  "axios": "1.11.0",
59
61
  "babel-core": "7.0.0-bridge.0",
60
62
  "babel-jest": "^29.6.2",
@@ -75,6 +77,7 @@
75
77
  "react-native": "^0.72.3",
76
78
  "react-native-navigation": "7.36.0",
77
79
  "react-navigation": "^4.4.4",
80
+ "react-test-renderer": "18.3.1",
78
81
  "rollup": "^3.27.2",
79
82
  "rollup-plugin-cleanup": "^3.2.1",
80
83
  "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"
@@ -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
+ }