Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vendor intl NumberFormat #1269

Merged
merged 43 commits into from
Mar 27, 2023
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
663a843
replace intl numberformat
denrase Feb 6, 2023
2bab342
add changelog entry
denrase Feb 6, 2023
aa1fcc6
remove changelog entry
denrase Feb 7, 2023
02b12e0
update formatting of > 16 digits
denrase Feb 7, 2023
1476882
Merge branch 'v7.0.0' into chore/replace-intl
denrase Feb 14, 2023
ab4b869
vendor intl files
denrase Feb 14, 2023
0303d1d
run format
denrase Feb 20, 2023
c8a95c5
Merge branch 'v7.0.0' into chore/replace-intl
marandaneto Mar 1, 2023
526ecf8
Merge branch 'v7.0.0' into chore/replace-intl
denrase Mar 13, 2023
5031f2c
remove unused codepath
denrase Mar 13, 2023
030097e
remove unused factories
denrase Mar 13, 2023
2f821b1
remove grouping
denrase Mar 13, 2023
7c7bd6b
remvoce compact_number_format & plural_rules
denrase Mar 13, 2023
4e0c337
remove dynaic number format
denrase Mar 13, 2023
d7c7341
remove prefixes & suffixes
denrase Mar 13, 2023
9fa5a3a
remove more unused code
denrase Mar 13, 2023
b858d6b
remove unused number format props
denrase Mar 13, 2023
3b8cc9f
rename to sample rate format
denrase Mar 13, 2023
4e7222a
change to internal
denrase Mar 13, 2023
20d5549
test if formatting is same as intl
denrase Mar 13, 2023
f29cfa8
Merge branch 'v7.0.0' into chore/replace-intl
denrase Mar 13, 2023
f9d9122
implemented pr feedback
denrase Mar 14, 2023
e78c8f8
Merge branch 'v7.0.0' into chore/replace-intl
denrase Mar 14, 2023
0d00694
Update dart/test/utils/sample_rate_format_test.dart
marandaneto Mar 14, 2023
9a7ae0a
Merge branch 'main' into chore/replace-intl
marandaneto Mar 15, 2023
229cd30
Merge branch 'main' into chore/replace-intl
marandaneto Mar 15, 2023
8272b57
Merge branch 'main' into chore/replace-intl
denrase Mar 20, 2023
5062f5f
test how intl behaves on different platforms
denrase Mar 20, 2023
377b72e
remove intl again
denrase Mar 20, 2023
2073840
fix analyze issues
denrase Mar 20, 2023
f161484
Merge branch 'main' into chore/replace-intl
marandaneto Mar 20, 2023
52fc2d2
Merge branch 'main' into chore/replace-intl
denrase Mar 21, 2023
271e89e
compare with delta
denrase Mar 21, 2023
57aa8b9
compare with numberformat
denrase Mar 21, 2023
25b0488
remove unused test helper
denrase Mar 21, 2023
b015bcf
run format
denrase Mar 21, 2023
a4574fc
add license
denrase Mar 27, 2023
b0d2964
Merge branch 'main' into chore/replace-intl
denrase Mar 27, 2023
818321b
Update dart/pubspec.yaml
denrase Mar 27, 2023
14c65d5
Merge branch 'main' into chore/replace-intl
marandaneto Mar 27, 2023
31e6c91
Merge branch 'main' into chore/replace-intl
marandaneto Mar 27, 2023
f51aaca
Merge branch 'main' into chore/replace-intl
marandaneto Mar 27, 2023
510d90d
fix license
marandaneto Mar 27, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions dart/lib/src/sentry_tracer.dart
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import 'dart:async';

import 'package:intl/intl.dart';
import 'package:meta/meta.dart';

import '../sentry.dart';
import 'sentry_tracer_finish_status.dart';
import 'utils/sample_rate_format.dart';

@internal
class SentryTracer extends ISentrySpan {
Expand Down Expand Up @@ -349,9 +349,7 @@ class SentryTracer extends ISentrySpan {
if (!isValidSampleRate(sampleRate)) {
return null;
}
// requires intl package
final formatter = NumberFormat('#.################');
return formatter.format(sampleRate);
return sampleRate != null ? SampleRateFormat().format(sampleRate) : null;
}

bool _isHighQualityTransactionName(SentryTransactionNameSource source) {
Expand Down
292 changes: 292 additions & 0 deletions dart/lib/src/utils/sample_rate_format.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
import 'dart:math';

import 'package:meta/meta.dart';

/// Implementation details from `intl` package
/// https://pub.dev/packages/intl
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chadwhitacre I'd like to double check if this is fine, License https://github.com/dart-lang/intl/blob/master/LICENSE BSD 3
Should we copy the license text on top of this file?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we actually just clarified the policy around this🔒 (in getsentry/team-ospo#120):

  1. Separate file
  2. Link to where we got it (attribution)
  3. Include license text in file header

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@denrase lets do this then, make CI happy and merge it.

@internal
class SampleRateFormat {
int _minimumIntegerDigits;
int _maximumFractionDigits;
int _minimumFractionDigits;

/// The difference between our zero and '0'.
///
/// In other words, a constant _localeZero - _zero. Initialized when
/// the locale is set.
final int _zeroOffset;

/// Caches the symbols
final _NumberSymbols _symbols;

/// Transient internal state in which to build up the result of the format
/// operation. We can have this be just an instance variable because Dart is
/// single-threaded and unless we do an asynchronous operation in the process
/// of formatting then there will only ever be one number being formatted
/// at a time. In languages with threads we'd need to pass this on the stack.
final StringBuffer _buffer = StringBuffer();

factory SampleRateFormat() {
var symbols = _NumberSymbols(
DECIMAL_SEP: '.',
ZERO_DIGIT: '0',
);
var localeZero = symbols.ZERO_DIGIT.codeUnitAt(0);
var zeroOffset = localeZero - '0'.codeUnitAt(0);

return SampleRateFormat._(
symbols,
zeroOffset,
);
}

SampleRateFormat._(this._symbols, this._zeroOffset)
: _minimumIntegerDigits = 1,
_maximumFractionDigits = 16,
_minimumFractionDigits = 0;

/// Format the sample rate
String format(dynamic sampleRate) {
try {
if (_isNaN(sampleRate)) return '0';
if (_isSmallerZero(sampleRate)) {
sampleRate = 0;
}
if (_isLargerOne(sampleRate)) {
sampleRate = 1;
}
_formatFixed(sampleRate.abs());

var result = _buffer.toString();
_buffer.clear();
return result;
} catch (_) {
_buffer.clear();
return '0';
}
}

/// Used to test if we have exceeded integer limits.
static final _maxInt = 1 is double ? pow(2, 52) : 1.0e300.floor();
static final _maxDigits = (log(_maxInt) / log(10)).ceil();

bool _isNaN(number) => number is num ? number.isNaN : false;
bool _isSmallerZero(number) => number is num ? number < 0 : false;
bool _isLargerOne(number) => number is num ? number > 1 : false;

/// Format the basic number portion, including the fractional digits.
void _formatFixed(dynamic number) {
dynamic integerPart;
int fractionPart;
int extraIntegerDigits;
var fractionDigits = _maximumFractionDigits;
var minFractionDigits = _minimumFractionDigits;

var power = 0;
int digitMultiplier;

// We have three possible pieces. First, the basic integer part. If this
// is a percent or permille, the additional 2 or 3 digits. Finally the
// fractional part.
// We avoid multiplying the number because it might overflow if we have
// a fixed-size integer type, so we extract each of the three as an
// integer pieces.
integerPart = _floor(number);
var fraction = number - integerPart;
if (fraction.toInt() != 0) {
// If the fractional part leftover is > 1, presumbly the number
// was too big for a fixed-size integer, so leave it as whatever
// it was - the obvious thing is a double.
integerPart = number;
fraction = 0;
}

power = pow(10, fractionDigits) as int;
digitMultiplier = power;

// Multiply out to the number of decimal places and the percent, then
// round. For fixed-size integer types this should always be zero, so
// multiplying is OK.
var remainingDigits = _round(fraction * digitMultiplier).toInt();

if (remainingDigits >= digitMultiplier) {
// Overflow into the main digits: 0.99 => 1.00
integerPart++;
remainingDigits -= digitMultiplier;
} else if (_numberOfIntegerDigits(remainingDigits) >
_numberOfIntegerDigits(_floor(fraction * digitMultiplier).toInt())) {
// Fraction has been rounded (0.0996 -> 0.1).
fraction = remainingDigits / digitMultiplier;
}

// Separate out the extra integer parts from the fraction part.
extraIntegerDigits = remainingDigits ~/ power;
fractionPart = remainingDigits % power;

var integerDigits = _integerDigits(integerPart, extraIntegerDigits);
var digitLength = integerDigits.length;
var fractionPresent =
fractionDigits > 0 && (minFractionDigits > 0 || fractionPart > 0);

if (_hasIntegerDigits(integerDigits)) {
// Add the padding digits to the regular digits so that we get grouping.
var padding = '0' * (_minimumIntegerDigits - digitLength);
integerDigits = '$padding$integerDigits';
digitLength = integerDigits.length;
for (var i = 0; i < digitLength; i++) {
_addDigit(integerDigits.codeUnitAt(i));
}
} else if (!fractionPresent) {
// If neither fraction nor integer part exists, just print zero.
_addZero();
}

_decimalSeparator(fractionPresent);
if (fractionPresent) {
_formatFractionPart((fractionPart + power).toString(), minFractionDigits);
}
}

/// Helper to get the floor of a number which might not be num. This should
/// only ever be called with an argument which is positive, or whose abs()
/// is negative. The second case is the maximum negative value on a
/// fixed-length integer. Since they are integers, they are also their own
/// floor.
dynamic _floor(dynamic number) {
if (number.isNegative && !number.abs().isNegative) {
throw ArgumentError(
'Internal error: expected positive number, got $number');
denrase marked this conversation as resolved.
Show resolved Hide resolved
}
return (number is num) ? number.floor() : number ~/ 1;
}

/// Helper to round a number which might not be num.
dynamic _round(dynamic number) {
if (number is num) {
if (number.isInfinite) {
return _maxInt;
} else {
return number.round();
}
} else if (number.remainder(1) == 0) {
// Not a normal number, but int-like, e.g. Int64
return number;
} else {
// TODO(alanknight): Do this more efficiently. If IntX had floor and
// round we could avoid this.
var basic = _floor(number);
var fraction = (number - basic).toDouble().round();
return fraction == 0 ? number : number + fraction;
}
}

// Return the number of digits left of the decimal place in [number].
static int _numberOfIntegerDigits(dynamic number) {
var simpleNumber = (number.toDouble() as double).abs();
// It's unfortunate that we have to do this, but we get precision errors
// that affect the result if we use logs, e.g. 1000000
if (simpleNumber < 10) return 1;
if (simpleNumber < 100) return 2;
if (simpleNumber < 1000) return 3;
if (simpleNumber < 10000) return 4;
if (simpleNumber < 100000) return 5;
if (simpleNumber < 1000000) return 6;
if (simpleNumber < 10000000) return 7;
if (simpleNumber < 100000000) return 8;
if (simpleNumber < 1000000000) return 9;
if (simpleNumber < 10000000000) return 10;
if (simpleNumber < 100000000000) return 11;
if (simpleNumber < 1000000000000) return 12;
if (simpleNumber < 10000000000000) return 13;
if (simpleNumber < 100000000000000) return 14;
if (simpleNumber < 1000000000000000) return 15;
if (simpleNumber < 10000000000000000) return 16;
if (simpleNumber < 100000000000000000) return 17;
if (simpleNumber < 1000000000000000000) return 18;
return 19;
}

/// Compute the raw integer digits which will then be printed with
/// grouping and translated to localized digits.
String _integerDigits(integerPart, extraIntegerDigits) {
// If the integer part is larger than the maximum integer size
// (2^52 on Javascript, 2^63 on the VM) it will lose precision,
// so pad out the rest of it with zeros.
var paddingDigits = '';
if (integerPart is num && integerPart > _maxInt) {
var howManyDigitsTooBig =
(log(integerPart) / log(10)).ceil() - _maxDigits;
num divisor = pow(10, howManyDigitsTooBig).round();
// pow() produces 0 if the result is too large for a 64-bit int.
// If that happens, use a floating point divisor instead.
if (divisor == 0) divisor = pow(10.0, howManyDigitsTooBig);
paddingDigits = '0' * howManyDigitsTooBig.toInt();
integerPart = (integerPart / divisor).truncate();
}

var extra = extraIntegerDigits == 0 ? '' : extraIntegerDigits.toString();
var intDigits = _mainIntegerDigits(integerPart);
var paddedExtra = intDigits.isEmpty ? extra : extra.padLeft(0, '0');
return '$intDigits$paddedExtra$paddingDigits';
}

/// The digit string of the integer part. This is the empty string if the
/// integer part is zero and otherwise is the toString() of the integer
/// part, stripping off any minus sign.
String _mainIntegerDigits(integer) {
if (integer == 0) return '';
var digits = integer.toString();
// If we have a fixed-length int representation, it can have a negative
// number whose negation is also negative, e.g. 2^-63 in 64-bit.
// Remove the minus sign.
return digits.startsWith('-') ? digits.substring(1) : digits;
}

/// Format the part after the decimal place in a fixed point number.
void _formatFractionPart(String fractionPart, int minDigits) {
var fractionLength = fractionPart.length;
while (fractionPart.codeUnitAt(fractionLength - 1) == '0'.codeUnitAt(0) &&
fractionLength > minDigits + 1) {
fractionLength--;
}
for (var i = 1; i < fractionLength; i++) {
_addDigit(fractionPart.codeUnitAt(i));
}
}

/// Print the decimal separator if appropriate.
void _decimalSeparator(bool fractionPresent) {
if (fractionPresent) {
_add(_symbols.DECIMAL_SEP);
}
}

/// Return true if we have a main integer part which is printable, either
/// because we have digits left of the decimal point (this may include digits
/// which have been moved left because of percent or permille formatting),
/// or because the minimum number of printable digits is greater than 1.
bool _hasIntegerDigits(String digits) =>
digits.isNotEmpty || _minimumIntegerDigits > 0;

/// A group of methods that provide support for writing digits and other
/// required characters into [_buffer] easily.
void _add(String x) {
_buffer.write(x);
}

void _addZero() {
_buffer.write(_symbols.ZERO_DIGIT);
}

void _addDigit(int x) {
_buffer.writeCharCode(x + _zeroOffset);
}
}

// Suppress naming issues as changes would be breaking.
// ignore_for_file: non_constant_identifier_names
class _NumberSymbols {
final String DECIMAL_SEP, ZERO_DIGIT;

const _NumberSymbols({required this.DECIMAL_SEP, required this.ZERO_DIGIT});
}
1 change: 0 additions & 1 deletion dart/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ dependencies:
meta: ^1.3.0
stack_trace: ^1.10.0
uuid: ^3.0.0
intl: '>=0.17.0 <1.0.0'

dev_dependencies:
mockito: ^5.1.0
Expand Down
51 changes: 51 additions & 0 deletions dart/test/utils/sample_rate_format_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import 'package:sentry/src/utils/sample_rate_format.dart';
import 'package:test/test.dart';

void main() {
test('format', () {
final inputsAndOutputs = [
Tuple(0.0, '0'),
Tuple(1.0, '1'),
Tuple(0.1, '0.1'),
Tuple(0.11, '0.11'),
Tuple(0.19, '0.19'),
Tuple(0.191, '0.191'),
Tuple(0.1919, '0.1919'),
Tuple(0.19191, '0.19191'),
Tuple(0.191919, '0.191919'),
Tuple(0.1919191, '0.1919191'),
Tuple(0.19191919, '0.19191919'),
Tuple(0.191919191, '0.191919191'),
Tuple(0.1919191919, '0.1919191919'),
Tuple(0.19191919191, '0.19191919191'),
Tuple(0.191919191919, '0.191919191919'),
Tuple(0.1919191919191, '0.1919191919191'),
Tuple(0.19191919191919, '0.19191919191919'),
Tuple(0.191919191919191, '0.191919191919191'),
Tuple(0.1919191919191919, '0.1919191919191919'),
Tuple(0.19191919191919199, '0.191919191919192'),
];

for (final inputAndOutput in inputsAndOutputs) {
expect(SampleRateFormat().format(inputAndOutput.i), inputAndOutput.o);
}
});

test('input smaller 0 is capped', () {
expect(SampleRateFormat().format(-1), '0');
});

test('input larger 1 is capped', () {
expect(SampleRateFormat().format(1.1), '1');
});

test('call with NaN returns 0', () {
expect(SampleRateFormat().format(double.nan), '0');
});
}

class Tuple {
Tuple(this.i, this.o);
final double i;
final String o;
}