Skip to content

Commit 430cc78

Browse files
committed
fix(react-native): added Clock.fromUnixNanosecondsTimestamp so that all JS/ReactNative timestamps can be sent as strings
1 parent 50f52dd commit 430cc78

File tree

7 files changed

+152
-14
lines changed

7 files changed

+152
-14
lines changed

packages/core/lib/clock.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,7 @@ export interface Clock {
2929

3030
// convert the format returned by 'now' into a unix timestamp in nanoseconds
3131
toUnixTimestampNanoseconds: (time: number) => string
32+
33+
// convert a unix timestamp in nanoseconds into the format returned by 'now'
34+
fromUnixNanosecondsTimestamp: (timestamp: string) => number
3235
}

packages/platforms/browser/lib/clock.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ function createClock (performance: PerformanceWithOptionalTimeOrigin, background
4545
})
4646

4747
const toUnixNanoseconds = (time: number) => millisecondsToNanoseconds(calculatedTimeOrigin + time)
48+
const fromUnixNanoseconds = (time: number) => nanosecondsToMilliseconds(time) - calculatedTimeOrigin
4849

4950
return {
5051
now: () => performance.now(),
@@ -55,7 +56,9 @@ function createClock (performance: PerformanceWithOptionalTimeOrigin, background
5556
// convert milliseconds since timeOrigin to full timestamp
5657
toUnixTimestampNanoseconds: (time: number) => toUnixNanoseconds(time).toString(),
5758
// convert unix time in nanoseconds back to milliseconds since timeOrigin
58-
fromUnixNanoseconds: (time: number) => nanosecondsToMilliseconds(time) - calculatedTimeOrigin
59+
fromUnixNanoseconds,
60+
// convert a unix timestamp in nanoseconds back to milliseconds since timeOrigin
61+
fromUnixNanosecondsTimestamp: (timestamp: string) => fromUnixNanoseconds(parseInt(timestamp))
5962
}
6063
}
6164

packages/platforms/react-native/lib/clock.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,95 @@ const addStrings = (num1: string, num2: string): string => {
2727
return result
2828
}
2929

30+
// Helper function to subtract two numbers represented as strings (num1 - num2)
31+
const subtractStrings = (num1: string, num2: string): string => {
32+
// Ensure num1 >= num2 for simplicity
33+
if (num1.length < num2.length || (num1.length === num2.length && num1 < num2)) {
34+
throw new Error('Cannot subtract larger number from smaller number')
35+
}
36+
37+
let result = ''
38+
let borrow = 0
39+
let i = num1.length - 1
40+
let j = num2.length - 1
41+
42+
while (i >= 0) {
43+
const digit1 = parseInt(num1[i], 10) - borrow
44+
const digit2 = j >= 0 ? parseInt(num2[j], 10) : 0
45+
46+
if (digit1 >= digit2) {
47+
result = (digit1 - digit2) + result
48+
borrow = 0
49+
} else {
50+
result = (digit1 + 10 - digit2) + result
51+
borrow = 1
52+
}
53+
54+
i--
55+
j--
56+
}
57+
58+
// Remove leading zeros
59+
return result.replace(/^0+/, '') || '0'
60+
}
61+
62+
// Helper function to handle decimal arithmetic with strings
63+
const subtractDecimalStrings = (num1Str: string, num2Str: string): string => {
64+
// Parse decimal numbers
65+
const [int1, frac1 = ''] = num1Str.split('.')
66+
const [int2, frac2 = ''] = num2Str.split('.')
67+
68+
// Pad fractional parts to same length
69+
const maxFracLen = Math.max(frac1.length, frac2.length)
70+
const padded1 = frac1.padEnd(maxFracLen, '0')
71+
const padded2 = frac2.padEnd(maxFracLen, '0')
72+
73+
// Combine integer and fractional parts
74+
const combined1 = int1 + padded1
75+
const combined2 = int2 + padded2
76+
77+
// Subtract
78+
const resultCombined = subtractStrings(combined1, combined2)
79+
80+
// Split back into integer and fractional parts
81+
if (maxFracLen === 0) {
82+
return resultCombined
83+
}
84+
85+
const resultInt = resultCombined.slice(0, -maxFracLen) || '0'
86+
const resultFrac = resultCombined.slice(-maxFracLen).replace(/0+$/, '')
87+
88+
return resultFrac ? `${resultInt}.${resultFrac}` : resultInt
89+
}
90+
91+
const addDecimalStrings = (num1Str: string, num2Str: string): string => {
92+
// Parse decimal numbers
93+
const [int1, frac1 = ''] = num1Str.split('.')
94+
const [int2, frac2 = ''] = num2Str.split('.')
95+
96+
// Pad fractional parts to same length
97+
const maxFracLen = Math.max(frac1.length, frac2.length)
98+
const padded1 = frac1.padEnd(maxFracLen, '0')
99+
const padded2 = frac2.padEnd(maxFracLen, '0')
100+
101+
// Combine integer and fractional parts
102+
const combined1 = int1 + padded1
103+
const combined2 = int2 + padded2
104+
105+
// Add
106+
const resultCombined = addStrings(combined1, combined2)
107+
108+
// Split back into integer and fractional parts
109+
if (maxFracLen === 0) {
110+
return resultCombined
111+
}
112+
113+
const resultInt = resultCombined.slice(0, -maxFracLen) || '0'
114+
const resultFrac = resultCombined.slice(-maxFracLen).replace(/0+$/, '')
115+
116+
return resultFrac ? `${resultInt}.${resultFrac}` : resultInt
117+
}
118+
30119
const createClock = (performance: Performance): Clock => {
31120
// Measurable "monotonic" time
32121
// In React Native, `performance.now` often returns some very high values, but does not expose the `timeOrigin` it uses to calculate what "now" is.
@@ -85,6 +174,44 @@ const createClock = (performance: Performance): Clock => {
85174
}
86175

87176
return result
177+
},
178+
// convert unix timestamp in nanoseconds (string) back to milliseconds since timeOrigin
179+
fromUnixNanosecondsTimestamp: (nanosStr: string) => {
180+
// Convert nanoseconds string to milliseconds string with full precision
181+
let millisecondsStr: string
182+
183+
if (nanosStr.length <= 6) {
184+
// Less than 1 millisecond - create fractional milliseconds
185+
const paddedNanos = nanosStr.padStart(6, '0')
186+
millisecondsStr = '0.' + paddedNanos
187+
} else {
188+
// Split nanoseconds into milliseconds and fractional nanoseconds
189+
const msLength = nanosStr.length - 6
190+
const integerMs = nanosStr.substring(0, msLength)
191+
const fractionalNs = nanosStr.substring(msLength)
192+
193+
if (fractionalNs === '000000') {
194+
millisecondsStr = integerMs
195+
} else {
196+
// Remove trailing zeros from fractional part
197+
const trimmedFractional = fractionalNs.replace(/0+$/, '')
198+
millisecondsStr = integerMs + '.' + trimmedFractional
199+
}
200+
}
201+
202+
// Perform calculation using string arithmetic to maintain precision
203+
// unixMilliseconds - startWallTime + startPerfTime
204+
const startWallTimeStr = startWallTime.toString()
205+
const startPerfTimeStr = startPerfTime.toString()
206+
207+
// Step 1: unixMilliseconds - startWallTime
208+
const afterSubtraction = subtractDecimalStrings(millisecondsStr, startWallTimeStr)
209+
210+
// Step 2: result + startPerfTime
211+
const finalResult = addDecimalStrings(afterSubtraction, startPerfTimeStr)
212+
213+
// Convert to number only at the very end
214+
return parseFloat(finalResult)
88215
}
89216
}
90217
}

packages/plugin-react-native-span-access/android/src/main/java/com/bugsnag/reactnative/performance/nativespans/JavascriptSpanTransactionImpl.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ public void commit(OnRemoteSpanUpdatedCallback callback) {
129129
updateTransaction.putArray(ATTRIBUTES, attributesArray);
130130

131131
if (isEnded) {
132-
updateTransaction.putDouble(END_TIME, endTime);
132+
updateTransaction.putString(END_TIME, Long.toString(endTime));
133133
updateTransaction.putBoolean(IS_ENDED, true);
134134
}
135135

packages/plugin-react-native-span-access/ios/BugsnagJavascriptSpanControl.mm

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ - (void)endWithEndTime:(NSDate *)endTime {
4747
}
4848

4949
NSTimeInterval currentTime = [endTime timeIntervalSince1970];
50-
NSNumber *unixNanos = @((double)(currentTime * NSEC_PER_SEC));
50+
// Calculate seconds and nanoseconds separately to maintain precision
51+
uint64_t seconds = (uint64_t)currentTime;
52+
uint64_t nanos = (uint64_t)round((currentTime - seconds) * NSEC_PER_SEC);
53+
uint64_t nanoseconds = seconds * NSEC_PER_SEC + nanos;
54+
// Convert to string for JS
55+
NSString *unixNanos = [NSString stringWithFormat:@"%llu", nanoseconds];
5156
updateEvent[endTimeTransactionKey] = unixNanos;
5257
updateEvent[isEndedTransactionKey] = @YES;
5358
}
@@ -82,7 +87,7 @@ - (void)commit:(OnSpanUpdatedCallback)callback {
8287
callback(NO);
8388
return;
8489
}
85-
90+
8691
int eventId = [plugin registerSpanUpdateCallback:callback];
8792
updateEvent[idTransactionKey] = @(eventId);
8893
[plugin sendSpanUpdateEvent:updateEvent];
@@ -92,21 +97,21 @@ - (BOOL)isValidAttribute:(_Nullable id)value {
9297
if (value == nil || [value isKindOfClass:[NSString class]] || [value isKindOfClass:[NSNumber class]]) {
9398
return YES;
9499
}
95-
100+
96101
if ([value isKindOfClass:[NSArray class]]) {
97102
NSUInteger idx = [value indexOfObjectPassingTest:^BOOL(id obj, NSUInteger idx, BOOL *stop) {
98103
if ([obj isKindOfClass:[NSString class]] || [obj isKindOfClass:[NSNumber class]]) {
99104
return NO;
100105
}
101-
106+
102107
// stop the iteration if we find an invalid object
103108
*stop = YES;
104109
return YES;
105110
}];
106-
111+
107112
return idx == NSNotFound; // means all objects are valid
108113
}
109-
114+
110115
return NO;
111116
}
112117

@@ -136,12 +141,12 @@ - (void)retrieveSpanContext:(RemoteSpanContextCallback)callback {
136141
}
137142

138143
int eventId = [plugin registerSpanContextCallback:callback];
139-
144+
140145
NSDictionary *contextEvent = @{
141146
idTransactionKey: @(eventId),
142147
nameTransactionKey: spanName,
143148
};
144-
149+
145150
[plugin sendSpanContextEvent:contextEvent];
146151
}
147152

packages/plugin-react-native-span-access/lib/javascript-spans-plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ interface SpanUpdateEvent {
1111
name: string
1212
attributes: Array<{ name: string, value: SpanAttribute }>
1313
isEnded: boolean
14-
endTime?: number
14+
endTime?: string
1515
}
1616

1717
interface SpanContextEvent {
@@ -73,7 +73,7 @@ export class BugsnagJavascriptSpansPlugin implements Plugin<ReactNativeConfigura
7373

7474
if (event.isEnded) {
7575
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
76-
span.end(event.endTime ? this.clock!.fromUnixNanoseconds(event.endTime) : undefined)
76+
span.end(event.endTime ? this.clock!.fromUnixNanosecondsTimestamp(event.endTime) : undefined)
7777
}
7878

7979
result = true

packages/plugin-react-native-span-access/lib/native-spans-plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const NativeNativeSpansModule = TurboModuleRegistry.get<Spec>('BugsnagNativeSpan
2424
interface SpanTransaction {
2525
attributes: Array<{ name: string, value?: SpanAttribute | null }>
2626
isEnded: boolean
27-
endTime?: number
27+
endTime?: string
2828
}
2929

3030
interface SpanId {
@@ -48,7 +48,7 @@ class NativeSpanControlImpl implements NativeSpanControl {
4848
update({
4949
end: (endTime?: Time) => {
5050
const safeEndTime = timeToNumber(this.clock, endTime)
51-
transaction.endTime = this.clock.toUnixNanoseconds(safeEndTime)
51+
transaction.endTime = this.clock.toUnixTimestampNanoseconds(safeEndTime)
5252
transaction.isEnded = true
5353
},
5454
setAttribute: (name: string, value?: SpanAttribute | null) => {

0 commit comments

Comments
 (0)