diff --git a/dart/lib/src/sentry_tracer.dart b/dart/lib/src/sentry_tracer.dart index ea519eb3bf..622eaaa3da 100644 --- a/dart/lib/src/sentry_tracer.dart +++ b/dart/lib/src/sentry_tracer.dart @@ -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 { @@ -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) { diff --git a/dart/lib/src/utils/sample_rate_format.dart b/dart/lib/src/utils/sample_rate_format.dart new file mode 100644 index 0000000000..4abb79edd8 --- /dev/null +++ b/dart/lib/src/utils/sample_rate_format.dart @@ -0,0 +1,323 @@ +/// Code ported & adapted from `intl` package +/// https://pub.dev/packages/intl +/// +/// License: +/// +/// Copyright 2013, the Dart project authors. +/// +/// Redistribution and use in source and binary forms, with or without +/// modification, are permitted provided that the following conditions are +/// met: +/// +/// * Redistributions of source code must retain the above copyright +/// notice, this list of conditions and the following disclaimer. +/// * Redistributions in binary form must reproduce the above +/// copyright notice, this list of conditions and the following +/// disclaimer in the documentation and/or other materials provided +/// with the distribution. +/// * Neither the name of Google LLC nor the names of its +/// contributors may be used to endorse or promote products derived +/// from this software without specific prior written permission. +/// +/// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +/// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +/// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +/// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +/// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +/// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +/// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +/// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +/// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +/// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +/// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import 'dart:math'; + +import 'package:meta/meta.dart'; + +@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'); + } + 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}); +} diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml index 439f9444c4..1599bdbf6f 100644 --- a/dart/pubspec.yaml +++ b/dart/pubspec.yaml @@ -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 @@ -25,3 +24,4 @@ dev_dependencies: yaml: ^3.1.0 # needed for version match (code and pubspec) collection: ^1.16.0 coverage: ^1.3.0 + intl: '>=0.17.0 <1.0.0' diff --git a/dart/test/utils/sample_rate_format_test.dart b/dart/test/utils/sample_rate_format_test.dart new file mode 100644 index 0000000000..a9b4c80b09 --- /dev/null +++ b/dart/test/utils/sample_rate_format_test.dart @@ -0,0 +1,49 @@ +import 'package:sentry/src/utils/sample_rate_format.dart'; +import 'package:test/test.dart'; +import 'package:intl/intl.dart'; + +void main() { + test('format', () { + final inputs = [ + 0.0, + 1.0, + 0.1, + 0.11, + 0.19, + 0.191, + 0.1919, + 0.19191, + 0.191919, + 0.1919191, + 0.19191919, + 0.191919191, + 0.1919191919, + 0.19191919191, + 0.191919191919, + 0.1919191919191, + 0.19191919191919, + 0.191919191919191, + 0.1919191919191919, + 0.19191919191919199, + ]; + + for (final input in inputs) { + expect( + SampleRateFormat().format(input), + NumberFormat('#.################').format(input), + ); + } + }); + + 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'); + }); +}