diff --git a/dart/example/bin/example.dart b/dart/example/bin/example.dart index 159de7ad63..449ed1cde0 100644 --- a/dart/example/bin/example.dart +++ b/dart/example/bin/example.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:collection'; import 'package:sentry/sentry.dart'; @@ -11,99 +12,101 @@ import 'event_example.dart'; /// Sends a test exception report to Sentry.io using this Dart client. Future main() async { // ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io + // const dsn = + // 'https://9934c532bf8446ef961450973c898537@o447951.ingest.sentry.io/5428562'; const dsn = - 'https://9934c532bf8446ef961450973c898537@o447951.ingest.sentry.io/5428562'; + 'https://fe85fc5123d44d5c99202d9e8f09d52e@395f015cf6c1.eu.ngrok.io/2'; await Sentry.init( - (options) => options - ..dsn = dsn - ..debug = true - ..sendDefaultPii = true - ..addEventProcessor(TagEventProcessor()), + (options) { + options.dsn = dsn; + options.debug = true; + options.release = 'myapp@1.0.0+1'; + options.environment = 'prod'; + options.experimental['featureFlagsEnabled'] = true; + }, appRunner: runApp, ); -} - -Future runApp() async { - print('\nReporting a complete event example: '); - - Sentry.addBreadcrumb( - Breadcrumb( - message: 'Authenticated user', - category: 'auth', - type: 'debug', - data: { - 'admin': true, - 'permissions': [1, 2, 3] - }, - ), - ); await Sentry.configureScope((scope) async { - await scope.setUser(SentryUser( - id: '800', - username: 'first-user', - email: 'first@user.lan', - // ipAddress: '127.0.0.1', sendDefaultPii feature is enabled - extras: {'first-sign-in': '2020-01-01'}, - )); - scope - // ..fingerprint = ['example-dart'], fingerprint forces events to group together - ..transaction = '/example/app' - ..level = SentryLevel.warning; - await scope.setTag('build', '579'); - await scope.setExtra('company-name', 'Dart Inc'); + await scope.setUser( + SentryUser( + id: '800', + ), + ); + // await scope.setTag('isSentryDev', 'true'); }); - // Sends a full Sentry event payload to show the different parts of the UI. - final sentryId = await Sentry.captureEvent(event); - - print('Capture event result : SentryId : $sentryId'); - - print('\nCapture message: '); - - // Sends a full Sentry event payload to show the different parts of the UI. - final messageSentryId = await Sentry.captureMessage( - 'Message 1', - level: SentryLevel.warning, - template: 'Message %s', - params: ['1'], + // accessToProfilingRollout + final accessToProfilingRollout = await Sentry.isFeatureFlagEnabled( + 'accessToProfiling', + defaultValue: false, + context: (myContext) => { + myContext.tags['userSegment'] = 'slow', + }, ); + print( + 'accessToProfilingRollout $accessToProfilingRollout'); // false for user 800 + + // accessToProfilingMatch + final accessToProfilingMatch = await Sentry.isFeatureFlagEnabled( + 'accessToProfiling', + defaultValue: false, + context: (myContext) => { + myContext.tags['isSentryDev'] = 'true', + }, + ); + print('accessToProfilingMatch $accessToProfilingMatch'); // returns true + + // profilingEnabledMatch + final profilingEnabledMatch = await Sentry.isFeatureFlagEnabled( + 'profilingEnabled', + defaultValue: false, + context: (myContext) => { + myContext.tags['isSentryDev'] = 'true', + }, + ); + print('profilingEnabledMatch $profilingEnabledMatch'); // returns true - print('Capture message result : SentryId : $messageSentryId'); - - try { - await loadConfig(); - } catch (error, stackTrace) { - print('\nReporting the following stack trace: '); - print(stackTrace); - final sentryId = await Sentry.captureException( - error, - stackTrace: stackTrace, - ); - - print('Capture exception result : SentryId : $sentryId'); - } + // profilingEnabledRollout + final profilingEnabledRollout = await Sentry.isFeatureFlagEnabled( + 'profilingEnabled', + defaultValue: false, + ); + print( + 'profilingEnabledRollout $profilingEnabledRollout'); // false for user 800 + + // loginBannerMatch + final loginBannerMatch = await Sentry.getFeatureFlagValueAsync( + 'loginBanner', + defaultValue: 'banner0', + context: (myContext) => { + myContext.tags['isSentryDev'] = 'true', + }, + ); + print('loginBannerMatch $loginBannerMatch'); // returns banner1 - // capture unhandled error - await loadConfig(); -} + // loginBannerMatch2 + final loginBannerMatch2 = await Sentry.getFeatureFlagValueAsync( + 'loginBanner', + defaultValue: 'banner0', + ); + print('loginBannerMatch2 $loginBannerMatch2'); // returns banner2 -Future loadConfig() async { - await parseConfig(); -} + // tracesSampleRate + final tracesSampleRate = await Sentry.getFeatureFlagValueAsync( + 'tracesSampleRate', + defaultValue: 0.0, + ); + print('tracesSampleRate $tracesSampleRate'); // returns 0.25 -Future parseConfig() async { - await decode(); -} + // final flag = await Sentry.getFeatureFlagInfo('loginBanner', + // context: (myContext) => { + // myContext.tags['myCustomTag'] = 'true', + // }); -Future decode() async { - throw StateError('This is a test error'); + // print(flag?.payload?['internal_setting'] ?? 'whaat'); + // print(flag?.payload ?? {}); } -class TagEventProcessor extends EventProcessor { - @override - FutureOr apply(SentryEvent event, {hint}) { - return event..tags?.addAll({'page-locale': 'en-us'}); - } -} +Future runApp() async {} diff --git a/dart/lib/sentry.dart b/dart/lib/sentry.dart index bb7db1cc95..c698397736 100644 --- a/dart/lib/sentry.dart +++ b/dart/lib/sentry.dart @@ -27,6 +27,14 @@ export 'src/http_client/sentry_http_client.dart'; export 'src/http_client/sentry_http_client_error.dart'; export 'src/sentry_attachment/sentry_attachment.dart'; export 'src/sentry_user_feedback.dart'; +export 'src/transport/rate_limiter.dart'; +export 'src/transport/http_transport.dart'; // tracing export 'src/tracing.dart'; export 'src/sentry_measurement.dart'; +// feature flags +export 'src/feature_flags/feature_flag.dart'; +export 'src/feature_flags/evaluation_rule.dart'; +export 'src/feature_flags/evaluation_type.dart'; +export 'src/feature_flags/feature_flag_context.dart'; +export 'src/feature_flags/feature_flag_info.dart'; diff --git a/dart/lib/src/cryptography/digest.dart b/dart/lib/src/cryptography/digest.dart new file mode 100644 index 0000000000..405d6763b3 --- /dev/null +++ b/dart/lib/src/cryptography/digest.dart @@ -0,0 +1,54 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:typed_data'; +import '../utils/hash_code.dart'; + +/// A message digest as computed by a `Hash` or `HMAC` function. +class Digest { + /// The message digest as an array of bytes. + final List bytes; + + Digest(this.bytes); + + /// Returns whether this is equal to another digest. + /// + /// This should be used instead of manual comparisons to avoid leaking + /// information via timing. + @override + bool operator ==(Object other) { + if (other is Digest) { + final a = bytes; + final b = other.bytes; + final n = a.length; + if (n != b.length) { + return false; + } + var mismatch = 0; + for (var i = 0; i < n; i++) { + mismatch |= a[i] ^ b[i]; + } + return mismatch == 0; + } + return false; + } + + @override + int get hashCode => hashAll(bytes); + + /// The message digest as a string of hexadecimal digits. + @override + String toString() => _hexEncode(bytes); +} + +String _hexEncode(List bytes) { + const hexDigits = '0123456789abcdef'; + var charCodes = Uint8List(bytes.length * 2); + for (var i = 0, j = 0; i < bytes.length; i++) { + var byte = bytes[i]; + charCodes[j++] = hexDigits.codeUnitAt((byte >> 4) & 0xF); + charCodes[j++] = hexDigits.codeUnitAt(byte & 0xF); + } + return String.fromCharCodes(charCodes); +} diff --git a/dart/lib/src/cryptography/digest_sink.dart b/dart/lib/src/cryptography/digest_sink.dart new file mode 100644 index 0000000000..9ba4df91e1 --- /dev/null +++ b/dart/lib/src/cryptography/digest_sink.dart @@ -0,0 +1,29 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'digest.dart'; + +/// A sink used to get a digest value out of `Hash.startChunkedConversion`. +class DigestSink extends Sink { + /// The value added to the sink. + /// + /// A value must have been added using [add] before reading the `value`. + Digest get value => _value!; + + Digest? _value; + + /// Adds [value] to the sink. + /// + /// Unlike most sinks, this may only be called once. + @override + void add(Digest value) { + if (_value != null) throw StateError('add may only be called once.'); + _value = value; + } + + @override + void close() { + if (_value == null) throw StateError('add must be called once.'); + } +} diff --git a/dart/lib/src/cryptography/hash.dart b/dart/lib/src/cryptography/hash.dart new file mode 100644 index 0000000000..8e05160581 --- /dev/null +++ b/dart/lib/src/cryptography/hash.dart @@ -0,0 +1,35 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:convert'; + +import 'digest.dart'; +import 'digest_sink.dart'; + +/// An interface for cryptographic hash functions. +/// +/// Every hash is a converter that takes a list of ints and returns a single +/// digest. When used in chunked mode, it will only ever add one digest to the +/// inner [Sink]. +abstract class Hash extends Converter, Digest> { + /// The internal block size of the hash in bytes. + /// + /// This is exposed for use by the `Hmac` class, + /// which needs to know the block size for the [Hash] it uses. + int get blockSize; + + const Hash(); + + @override + Digest convert(List input) { + var innerSink = DigestSink(); + var outerSink = startChunkedConversion(innerSink); + outerSink.add(input); + outerSink.close(); + return innerSink.value; + } + + @override + ByteConversionSink startChunkedConversion(Sink sink); +} diff --git a/dart/lib/src/cryptography/hash_sink.dart b/dart/lib/src/cryptography/hash_sink.dart new file mode 100644 index 0000000000..6524ad6af2 --- /dev/null +++ b/dart/lib/src/cryptography/hash_sink.dart @@ -0,0 +1,177 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:typed_data'; + +import '../typed_data/typed_buffer.dart'; +import 'digest.dart'; +import 'utils.dart'; + +/// A base class for [Sink] implementations for hash algorithms. +/// +/// Subclasses should override [updateHash] and [digest]. +abstract class HashSink implements Sink> { + /// The inner sink that this should forward to. + final Sink _sink; + + /// Whether the hash function operates on big-endian words. + final Endian _endian; + + /// The words in the current chunk. + /// + /// This is an instance variable to avoid re-allocating, but its data isn't + /// used across invocations of [_iterate]. + final Uint32List _currentChunk; + + /// Messages with more than 2^53-1 bits are not supported. + /// + /// This is the largest value that is precisely representable + /// on both JS and the Dart VM. + /// So the maximum length in bytes is (2^53-1)/8. + static const _maxMessageLengthInBytes = 0x0003ffffffffffff; + + /// The length of the input data so far, in bytes. + int _lengthInBytes = 0; + + /// Data that has yet to be processed by the hash function. + final _pendingData = Uint8Buffer(); + + /// Whether [close] has been called. + bool _isClosed = false; + + /// The words in the current digest. + /// + /// This should be updated each time [updateHash] is called. + Uint32List get digest; + + /// The number of signature bytes emitted at the end of the message. + /// + /// An encrypted message is followed by a signature which depends + /// on the encryption algorithm used. This value specifies the + /// number of bytes used by this signature. It must always be + /// a power of 2 and no less than 8. + final int _signatureBytes; + + /// Creates a new hash. + /// + /// [chunkSizeInWords] represents the size of the input chunks processed by + /// the algorithm, in terms of 32-bit words. + HashSink(this._sink, int chunkSizeInWords, + {Endian endian = Endian.big, int signatureBytes = 8}) + : _endian = endian, + assert(signatureBytes >= 8), + _signatureBytes = signatureBytes, + _currentChunk = Uint32List(chunkSizeInWords); + + /// Runs a single iteration of the hash computation, updating [digest] with + /// the result. + /// + /// [chunk] is the current chunk, whose size is given by the + /// `chunkSizeInWords` parameter passed to the constructor. + void updateHash(Uint32List chunk); + + @override + void add(List data) { + if (_isClosed) throw StateError('Hash.add() called after close().'); + _lengthInBytes += data.length; + _pendingData.addAll(data); + _iterate(); + } + + @override + void close() { + if (_isClosed) return; + _isClosed = true; + + _finalizeData(); + _iterate(); + assert(_pendingData.isEmpty); + _sink.add(Digest(_byteDigest())); + _sink.close(); + } + + Uint8List _byteDigest() { + if (_endian == Endian.host) return digest.buffer.asUint8List(); + + // Cache the digest locally as `get` could be expensive. + final cachedDigest = digest; + final byteDigest = Uint8List(cachedDigest.lengthInBytes); + final byteData = byteDigest.buffer.asByteData(); + for (var i = 0; i < cachedDigest.length; i++) { + byteData.setUint32(i * bytesPerWord, cachedDigest[i]); + } + return byteDigest; + } + + /// Iterates through [_pendingData], updating the hash computation for each + /// chunk. + void _iterate() { + var pendingDataBytes = _pendingData.buffer.asByteData(); + var pendingDataChunks = _pendingData.length ~/ _currentChunk.lengthInBytes; + for (var i = 0; i < pendingDataChunks; i++) { + // Copy words from the pending data buffer into the current chunk buffer. + for (var j = 0; j < _currentChunk.length; j++) { + _currentChunk[j] = pendingDataBytes.getUint32( + i * _currentChunk.lengthInBytes + j * bytesPerWord, _endian); + } + + // Run the hash function on the current chunk. + updateHash(_currentChunk); + } + + // Remove all pending data up to the last clean chunk break. + _pendingData.removeRange( + 0, pendingDataChunks * _currentChunk.lengthInBytes); + } + + /// Finalizes [_pendingData]. + /// + /// This adds a 1 bit to the end of the message, and expands it with 0 bits to + /// pad it out. + void _finalizeData() { + // Pad out the data with 0x80, eight or sixteen 0s, and as many more 0s + // as we need to land cleanly on a chunk boundary. + _pendingData.add(0x80); + + final contentsLength = _lengthInBytes + 1 /* 0x80 */ + _signatureBytes; + final finalizedLength = + _roundUp(contentsLength, _currentChunk.lengthInBytes); + + for (var i = 0; i < finalizedLength - contentsLength; i++) { + _pendingData.add(0); + } + + if (_lengthInBytes > _maxMessageLengthInBytes) { + throw UnsupportedError( + 'Hashing is unsupported for messages with more than 2^53 bits.'); + } + + var lengthInBits = _lengthInBytes * bitsPerByte; + + // Add the full length of the input data as a 64-bit value at the end of the + // hash. Note: we're only writing out 64 bits, so skip ahead 8 if the + // signature is 128-bit. + final offset = _pendingData.length + (_signatureBytes - 8); + + _pendingData.addAll(Uint8List(_signatureBytes)); + var byteData = _pendingData.buffer.asByteData(); + + // We're essentially doing byteData.setUint64(offset, lengthInBits, _endian) + // here, but that method isn't supported on dart2js so we implement it + // manually instead. + var highBits = lengthInBits ~/ 0x100000000; // >> 32 + var lowBits = lengthInBits & mask32; + if (_endian == Endian.big) { + byteData.setUint32(offset, highBits, _endian); + byteData.setUint32(offset + bytesPerWord, lowBits, _endian); + } else { + byteData.setUint32(offset, lowBits, _endian); + byteData.setUint32(offset + bytesPerWord, highBits, _endian); + } + } + + /// Rounds [val] up to the next multiple of [n], as long as [n] is a power of + /// two. + int _roundUp(int val, int n) => (val + n - 1) & -n; +} diff --git a/dart/lib/src/cryptography/sha1.dart b/dart/lib/src/cryptography/sha1.dart new file mode 100644 index 0000000000..e352de2b64 --- /dev/null +++ b/dart/lib/src/cryptography/sha1.dart @@ -0,0 +1,105 @@ +// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// vendored from https://pub.dev/packages/crypto 3.0.2 +// otherwise we'd need to ubmp the min. version of the Dart SDK + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'digest.dart'; +import 'hash.dart'; +import 'hash_sink.dart'; +import 'utils.dart'; + +/// An implementation of the [SHA-1][rfc] hash function. +/// +/// [rfc]: http://tools.ietf.org/html/rfc3174 +const Hash sha1 = _Sha1._(); + +/// An implementation of the [SHA-1][rfc] hash function. +/// +/// [rfc]: http://tools.ietf.org/html/rfc3174 +class _Sha1 extends Hash { + @override + final int blockSize = 16 * bytesPerWord; + + const _Sha1._(); + + @override + ByteConversionSink startChunkedConversion(Sink sink) => + ByteConversionSink.from(_Sha1Sink(sink)); +} + +/// The concrete implementation of [Sha1]. +/// +/// This is separate so that it can extend [HashSink] without leaking additional +/// public members. +class _Sha1Sink extends HashSink { + @override + final digest = Uint32List(5); + + /// The sixteen words from the original chunk, extended to 80 words. + /// + /// This is an instance variable to avoid re-allocating, but its data isn't + /// used across invocations of [updateHash]. + final Uint32List _extended; + + _Sha1Sink(Sink sink) + : _extended = Uint32List(80), + super(sink, 16) { + digest[0] = 0x67452301; + digest[1] = 0xEFCDAB89; + digest[2] = 0x98BADCFE; + digest[3] = 0x10325476; + digest[4] = 0xC3D2E1F0; + } + + @override + void updateHash(Uint32List chunk) { + assert(chunk.length == 16); + + var a = digest[0]; + var b = digest[1]; + var c = digest[2]; + var d = digest[3]; + var e = digest[4]; + + for (var i = 0; i < 80; i++) { + if (i < 16) { + _extended[i] = chunk[i]; + } else { + _extended[i] = rotl32( + _extended[i - 3] ^ + _extended[i - 8] ^ + _extended[i - 14] ^ + _extended[i - 16], + 1); + } + + var newA = add32(add32(rotl32(a, 5), e), _extended[i]); + if (i < 20) { + newA = add32(add32(newA, (b & c) | (~b & d)), 0x5A827999); + } else if (i < 40) { + newA = add32(add32(newA, (b ^ c ^ d)), 0x6ED9EBA1); + } else if (i < 60) { + newA = add32(add32(newA, (b & c) | (b & d) | (c & d)), 0x8F1BBCDC); + } else { + newA = add32(add32(newA, b ^ c ^ d), 0xCA62C1D6); + } + + e = d; + d = c; + c = rotl32(b, 30); + b = a; + a = newA & mask32; + } + + digest[0] = add32(a, digest[0]); + digest[1] = add32(b, digest[1]); + digest[2] = add32(c, digest[2]); + digest[3] = add32(d, digest[3]); + digest[4] = add32(e, digest[4]); + } +} diff --git a/dart/lib/src/cryptography/utils.dart b/dart/lib/src/cryptography/utils.dart new file mode 100644 index 0000000000..9ac8efc140 --- /dev/null +++ b/dart/lib/src/cryptography/utils.dart @@ -0,0 +1,22 @@ +// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +/// A bitmask that limits an integer to 32 bits. +const mask32 = 0xFFFFFFFF; + +/// The number of bits in a byte. +const bitsPerByte = 8; + +/// The number of bytes in a 32-bit word. +const bytesPerWord = 4; + +/// Adds [x] and [y] with 32-bit overflow semantics. +int add32(int x, int y) => (x + y) & mask32; + +/// Bitwise rotates [val] to the left by [shift], obeying 32-bit overflow +/// semantics. +int rotl32(int val, int shift) { + var modShift = shift & 31; + return ((val << modShift) & mask32) | ((val & mask32) >> (32 - modShift)); +} diff --git a/dart/lib/src/default_integrations.dart b/dart/lib/src/default_integrations.dart index 758bf62182..c26ee4fc39 100644 --- a/dart/lib/src/default_integrations.dart +++ b/dart/lib/src/default_integrations.dart @@ -99,3 +99,14 @@ class RunZonedGuardedIntegration extends Integration { return completer.future; } } + +class FetchFeatureFlagsAsync extends Integration { + @override + FutureOr call(Hub hub, SentryOptions options) async { + // request feature flags and cache it in memory + if (!options.isFeatureFlagsEnabled()) { + return; + } + await hub.requestFeatureFlags(); + } +} diff --git a/dart/lib/src/feature_flags/evaluation_rule.dart b/dart/lib/src/feature_flags/evaluation_rule.dart new file mode 100644 index 0000000000..c320649142 --- /dev/null +++ b/dart/lib/src/feature_flags/evaluation_rule.dart @@ -0,0 +1,37 @@ +import 'package:meta/meta.dart'; +import 'evaluation_type.dart'; + +@immutable +class EvaluationRule { + final EvaluationType type; + final double? percentage; + final dynamic result; + final Map _tags; + final Map? _payload; + + Map get tags => Map.unmodifiable(_tags); + + Map? get payload => + _payload != null ? Map.unmodifiable(_payload!) : null; + + EvaluationRule( + this.type, + this.percentage, + this.result, + this._tags, + this._payload, + ); + + factory EvaluationRule.fromJson(Map json) { + final payload = json['payload']; + final tags = json['tags']; + + return EvaluationRule( + (json['type'] as String).toEvaluationType(), + json['percentage'] as double?, + json['result'], + tags != null ? Map.from(tags) : {}, + payload != null ? Map.from(payload) : null, + ); + } +} diff --git a/dart/lib/src/feature_flags/evaluation_type.dart b/dart/lib/src/feature_flags/evaluation_type.dart new file mode 100644 index 0000000000..b636fc1a48 --- /dev/null +++ b/dart/lib/src/feature_flags/evaluation_type.dart @@ -0,0 +1,18 @@ +enum EvaluationType { + match, + rollout, + none, +} + +extension EvaluationTypeEx on String { + EvaluationType toEvaluationType() { + switch (this) { + case 'match': + return EvaluationType.match; + case 'rollout': + return EvaluationType.rollout; + default: + return EvaluationType.none; + } + } +} diff --git a/dart/lib/src/feature_flags/feature_dump.dart b/dart/lib/src/feature_flags/feature_dump.dart new file mode 100644 index 0000000000..792b750811 --- /dev/null +++ b/dart/lib/src/feature_flags/feature_dump.dart @@ -0,0 +1,24 @@ +import 'package:meta/meta.dart'; + +import 'feature_flag.dart'; + +@immutable +class FeatureDump { + final Map featureFlags; + + FeatureDump(this.featureFlags); + + factory FeatureDump.fromJson(Map json) { + final featureFlagsJson = json['feature_flags'] as Map?; + Map featureFlags = {}; + + if (featureFlagsJson != null) { + for (final value in featureFlagsJson.entries) { + final featureFlag = FeatureFlag.fromJson(value.value); + featureFlags[value.key] = featureFlag; + } + } + + return FeatureDump(featureFlags); + } +} diff --git a/dart/lib/src/feature_flags/feature_flag.dart b/dart/lib/src/feature_flags/feature_flag.dart new file mode 100644 index 0000000000..0b6016838e --- /dev/null +++ b/dart/lib/src/feature_flags/feature_flag.dart @@ -0,0 +1,23 @@ +import 'package:meta/meta.dart'; + +import 'evaluation_rule.dart'; + +@immutable +class FeatureFlag { + final List _evaluations; + final String? group; + + List get evaluations => List.unmodifiable(_evaluations); + + FeatureFlag(this._evaluations, this.group); + + factory FeatureFlag.fromJson(Map json) { + final group = json['group']; + final evaluationsList = json['evaluation'] as List? ?? []; + final evaluations = evaluationsList + .map((e) => EvaluationRule.fromJson(e)) + .toList(growable: false); + + return FeatureFlag(evaluations, group); + } +} diff --git a/dart/lib/src/feature_flags/feature_flag_context.dart b/dart/lib/src/feature_flags/feature_flag_context.dart new file mode 100644 index 0000000000..ee8129b20e --- /dev/null +++ b/dart/lib/src/feature_flags/feature_flag_context.dart @@ -0,0 +1,7 @@ +typedef FeatureFlagContextCallback = void Function(FeatureFlagContext context); + +class FeatureFlagContext { + Map tags = {}; + + FeatureFlagContext(this.tags); +} diff --git a/dart/lib/src/feature_flags/feature_flag_info.dart b/dart/lib/src/feature_flags/feature_flag_info.dart new file mode 100644 index 0000000000..23cac17c0e --- /dev/null +++ b/dart/lib/src/feature_flags/feature_flag_info.dart @@ -0,0 +1,16 @@ +class FeatureFlagInfo { + final dynamic result; + final Map _tags; + final Map? _payload; + + Map get tags => Map.unmodifiable(_tags); + + Map? get payload => + _payload != null ? Map.unmodifiable(_payload!) : null; + + FeatureFlagInfo( + this.result, + this._tags, + this._payload, + ); +} diff --git a/dart/lib/src/feature_flags/xor_shift_rand.dart b/dart/lib/src/feature_flags/xor_shift_rand.dart new file mode 100644 index 0000000000..968ca5d05a --- /dev/null +++ b/dart/lib/src/feature_flags/xor_shift_rand.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +import '../cryptography/sha1.dart'; + +/// final rand = XorShiftRandom('wohoo'); +/// rand.next(); +class XorShiftRandom { + List state = [0, 0, 0, 0]; + static const mask = 0xffffffff; + + XorShiftRandom(String seed) { + _seed(seed); + } + + void _seed(String seed) { + final encoded = utf8.encode(seed); + final bytes = sha1.convert(encoded).bytes; + final slice = bytes.sublist(0, 16); + + for (var i = 0; i < state.length; i++) { + final unpack = (slice[i * 4] << 24) | + (slice[i * 4 + 1] << 16) | + (slice[i * 4 + 2] << 8) | + (slice[i * 4 + 3]); + state[i] = unpack; + } + } + + double next() { + return nextu32() / mask; + } + + int nextu32() { + var t = state[3]; + final s = state[0]; + + state[3] = state[2]; + state[2] = state[1]; + state[1] = s; + + t = (t << 11) & mask; + t ^= t >> 8; + state[0] = (t ^ s ^ (s >> 19)) & mask; + + return state[0]; + } +} diff --git a/dart/lib/src/hub.dart b/dart/lib/src/hub.dart index a5b9ce6ce9..372f7a1552 100644 --- a/dart/lib/src/hub.dart +++ b/dart/lib/src/hub.dart @@ -393,9 +393,18 @@ class Hub { final samplingContext = SentrySamplingContext( transactionContext, customSamplingContext ?? {}); + final tracesSampleRate = item.client.getFeatureFlagValue( + '@@tracesSampleRate', + scope: item.scope, + defaultValue: _options.tracesSampleRate, + ); + // if transactionContext has no sampled decision, run the traces sampler if (transactionContext.sampled == null) { - final sampled = _tracesSampler.sample(samplingContext); + final sampled = _tracesSampler.sample( + samplingContext, + tracesSampleRate, + ); transactionContext = transactionContext.copyWith(sampled: sampled); } @@ -518,6 +527,129 @@ class Hub { } return event; } + + @experimental + Future getFeatureFlagValueAsync( + String key, { + T? defaultValue, + FeatureFlagContextCallback? context, + }) async { + if (!_isEnabled) { + _options.logger( + SentryLevel.warning, + "Instance is disabled and this 'getFeatureFlagValueAsync' call is a no-op.", + ); + return defaultValue; + } + + try { + final item = _peek(); + + return item.client.getFeatureFlagValueAsync( + key, + scope: item.scope, + defaultValue: defaultValue, + context: context, + ); + } catch (exception, stacktrace) { + _options.logger( + SentryLevel.error, + 'Error while fetching feature flags', + exception: exception, + stackTrace: stacktrace, + ); + } + return defaultValue; + } + + @experimental + T? getFeatureFlagValue( + String key, { + T? defaultValue, + FeatureFlagContextCallback? context, + }) { + if (!_isEnabled) { + _options.logger( + SentryLevel.warning, + "Instance is disabled and this 'getFeatureFlagValue' call is a no-op.", + ); + return defaultValue; + } + + try { + final item = _peek(); + + return item.client.getFeatureFlagValue( + key, + scope: item.scope, + defaultValue: defaultValue, + context: context, + ); + } catch (exception, stacktrace) { + _options.logger( + SentryLevel.error, + 'Error while fetching feature flags', + exception: exception, + stackTrace: stacktrace, + ); + } + return defaultValue; + } + + @experimental + Future getFeatureFlagInfo( + String key, { + FeatureFlagContextCallback? context, + }) async { + if (!_isEnabled) { + _options.logger( + SentryLevel.warning, + "Instance is disabled and this 'getFeatureFlagInfo' call is a no-op.", + ); + return null; + } + + try { + final item = _peek(); + + return item.client.getFeatureFlagInfo( + key, + scope: item.scope, + context: context, + ); + } catch (exception, stacktrace) { + _options.logger( + SentryLevel.error, + 'Error while fetching feature flags', + exception: exception, + stackTrace: stacktrace, + ); + } + return null; + } + + Future requestFeatureFlags() async { + if (!_isEnabled) { + _options.logger( + SentryLevel.warning, + "Instance is disabled and this 'requestFeatureFlags' call is a no-op.", + ); + return; + } + + try { + final item = _peek(); + + return item.client.requestFeatureFlags(); + } catch (exception, stacktrace) { + _options.logger( + SentryLevel.error, + 'Error while fetching feature flags', + exception: exception, + stackTrace: stacktrace, + ); + } + } } class _StackItem { diff --git a/dart/lib/src/hub_adapter.dart b/dart/lib/src/hub_adapter.dart index fb0331b6c5..848e50d4d6 100644 --- a/dart/lib/src/hub_adapter.dart +++ b/dart/lib/src/hub_adapter.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'feature_flags/feature_flag_context.dart'; +import 'feature_flags/feature_flag_info.dart'; import 'hub.dart'; import 'protocol.dart'; import 'sentry.dart'; @@ -158,4 +160,41 @@ class HubAdapter implements Hub { String transaction, ) => Sentry.currentHub.setSpanContext(throwable, span, transaction); + + @override + Future getFeatureFlagValueAsync( + String key, { + T? defaultValue, + FeatureFlagContextCallback? context, + }) => + Sentry.getFeatureFlagValueAsync( + key, + defaultValue: defaultValue, + context: context, + ); + + @override + T? getFeatureFlagValue( + String key, { + T? defaultValue, + FeatureFlagContextCallback? context, + }) => + Sentry.getFeatureFlagValue( + key, + defaultValue: defaultValue, + context: context, + ); + + @override + Future getFeatureFlagInfo( + String key, { + FeatureFlagContextCallback? context, + }) => + Sentry.getFeatureFlagInfo( + key, + context: context, + ); + + @override + Future requestFeatureFlags() => Sentry.currentHub.requestFeatureFlags(); } diff --git a/dart/lib/src/noop_hub.dart b/dart/lib/src/noop_hub.dart index 16e8ab5e15..4f03310b05 100644 --- a/dart/lib/src/noop_hub.dart +++ b/dart/lib/src/noop_hub.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import 'feature_flags/feature_flag_context.dart'; +import 'feature_flags/feature_flag_info.dart'; import 'hub.dart'; import 'protocol.dart'; import 'sentry_client.dart'; @@ -114,4 +116,30 @@ class NoOpHub implements Hub { @override void setSpanContext(throwable, ISentrySpan span, String transaction) {} + + @override + Future getFeatureFlagValueAsync( + String key, { + T? defaultValue, + FeatureFlagContextCallback? context, + }) async => + null; + + @override + T? getFeatureFlagValue( + String key, { + T? defaultValue, + FeatureFlagContextCallback? context, + }) => + null; + + @override + Future getFeatureFlagInfo( + String key, { + FeatureFlagContextCallback? context, + }) async => + null; + + @override + Future requestFeatureFlags() async {} } diff --git a/dart/lib/src/noop_sentry_client.dart b/dart/lib/src/noop_sentry_client.dart index 2a45dfbb6d..297dbb1ca0 100644 --- a/dart/lib/src/noop_sentry_client.dart +++ b/dart/lib/src/noop_sentry_client.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'feature_flags/feature_flag_context.dart'; +import 'feature_flags/feature_flag_info.dart'; import 'protocol.dart'; import 'scope.dart'; import 'sentry_client.dart'; @@ -60,4 +62,33 @@ class NoOpSentryClient implements SentryClient { Scope? scope, }) async => SentryId.empty(); + + @override + Future getFeatureFlagValueAsync( + String key, { + Scope? scope, + T? defaultValue, + FeatureFlagContextCallback? context, + }) async => + null; + + @override + T? getFeatureFlagValue( + String key, { + Scope? scope, + T? defaultValue, + FeatureFlagContextCallback? context, + }) => + null; + + @override + Future getFeatureFlagInfo( + String key, { + Scope? scope, + FeatureFlagContextCallback? context, + }) async => + null; + + @override + Future requestFeatureFlags() async {} } diff --git a/dart/lib/src/protocol/dsn.dart b/dart/lib/src/protocol/dsn.dart index c3ec5093c8..7b88d57876 100644 --- a/dart/lib/src/protocol/dsn.dart +++ b/dart/lib/src/protocol/dsn.dart @@ -24,7 +24,10 @@ class Dsn { /// The DSN URI. final Uri? uri; - Uri get postUri { + @Deprecated('Use [envelopeUri] instead') + Uri get postUri => envelopeUri; + + Uri get envelopeUri { final uriCopy = uri!; final port = uriCopy.hasPort && ((uriCopy.scheme == 'http' && uriCopy.port != 80) || @@ -47,6 +50,13 @@ class Dsn { ); } + Uri get featureFlagsUri { + // TODO: implement proper Uri + final uriTemp = + envelopeUri.toString().replaceAll('envelope', 'feature-flags'); + return Uri.parse(uriTemp); + } + /// Parses a DSN String to a Dsn object factory Dsn.parse(String dsn) { final uri = Uri.parse(dsn); diff --git a/dart/lib/src/sentry.dart b/dart/lib/src/sentry.dart index 9604f330f8..6f850395d3 100644 --- a/dart/lib/src/sentry.dart +++ b/dart/lib/src/sentry.dart @@ -6,6 +6,8 @@ import 'default_integrations.dart'; import 'enricher/enricher_event_processor.dart'; import 'environment/environment_variables.dart'; import 'event_processor/deduplication_event_processor.dart'; +import 'feature_flags/feature_flag_context.dart'; +import 'feature_flags/feature_flag_info.dart'; import 'hub.dart'; import 'hub_adapter.dart'; import 'integration.dart'; @@ -114,6 +116,7 @@ class Sentry { final runZonedGuardedIntegration = RunZonedGuardedIntegration(runIntegrationsAndAppRunner); options.addIntegrationByIndex(0, runZonedGuardedIntegration); + options.addIntegration(FetchFeatureFlagsAsync()); // RunZonedGuardedIntegration will run other integrations and appRunner // runZonedGuarded so all exception caught in the error handler are @@ -272,4 +275,52 @@ class Sentry { @internal static Hub get currentHub => _hub; + + @experimental + static Future isFeatureFlagEnabled( + String key, { + bool defaultValue = false, + FeatureFlagContextCallback? context, + }) async { + return await getFeatureFlagValueAsync( + key, + defaultValue: defaultValue, + context: context, + ) ?? + defaultValue; + } + + @experimental + static Future getFeatureFlagValueAsync( + String key, { + T? defaultValue, + FeatureFlagContextCallback? context, + }) => + _hub.getFeatureFlagValueAsync( + key, + defaultValue: defaultValue, + context: context, + ); + + @experimental + static T? getFeatureFlagValue( + String key, { + T? defaultValue, + FeatureFlagContextCallback? context, + }) => + _hub.getFeatureFlagValue( + key, + defaultValue: defaultValue, + context: context, + ); + + @experimental + static Future getFeatureFlagInfo( + String key, { + FeatureFlagContextCallback? context, + }) => + _hub.getFeatureFlagInfo( + key, + context: context, + ); } diff --git a/dart/lib/src/sentry_client.dart b/dart/lib/src/sentry_client.dart index 57535d1f92..1fbea1cf62 100644 --- a/dart/lib/src/sentry_client.dart +++ b/dart/lib/src/sentry_client.dart @@ -1,8 +1,15 @@ import 'dart:async'; import 'dart:math'; import 'package:meta/meta.dart'; +import 'package:uuid/uuid.dart'; import 'event_processor.dart'; +import 'feature_flags/evaluation_rule.dart'; +import 'feature_flags/evaluation_type.dart'; +import 'feature_flags/feature_flag.dart'; +import 'feature_flags/feature_flag_context.dart'; +import 'feature_flags/feature_flag_info.dart'; +import 'feature_flags/xor_shift_rand.dart'; import 'sentry_user_feedback.dart'; import 'transport/rate_limiter.dart'; import 'protocol.dart'; @@ -28,7 +35,7 @@ const _defaultIpAddress = '{{auto}}'; class SentryClient { final SentryOptions _options; - final Random? _random; + final _random = Random(); static final _sentryId = Future.value(SentryId.empty()); @@ -36,6 +43,8 @@ class SentryClient { SentryStackTraceFactory get _stackTraceFactory => _options.stackTraceFactory; + Map? _featureFlags; + /// Instantiates a client using [SentryOptions] factory SentryClient(SentryOptions options) { if (options.sendClientReports) { @@ -49,8 +58,7 @@ class SentryClient { } /// Instantiates a client using [SentryOptions] - SentryClient._(this._options) - : _random = _options.sampleRate == null ? null : Random(); + SentryClient._(this._options); /// Reports an [event] to Sentry.io. Future captureEvent( @@ -59,7 +67,7 @@ class SentryClient { dynamic stackTrace, dynamic hint, }) async { - if (_sampleRate()) { + if (_sampleRate(scope)) { _recordLostEvent(event, DiscardReason.sampleRate); _options.logger( SentryLevel.debug, @@ -360,9 +368,14 @@ class SentryClient { return processedEvent; } - bool _sampleRate() { - if (_options.sampleRate != null && _random != null) { - return (_options.sampleRate! < _random!.nextDouble()); + bool _sampleRate(Scope? scope) { + final sampleRate = getFeatureFlagValue( + '@@errorsSampleRate', + scope: scope, + defaultValue: _options.sampleRate, + ); + if (sampleRate != null) { + return (sampleRate < _random.nextDouble()); } return false; } @@ -396,4 +409,229 @@ class SentryClient { envelope.addClientReport(clientReport); return _options.transport.send(envelope); } + + T? _getFeatureFlagValue( + Map? featureFlags, + String key, { + Scope? scope, + T? defaultValue, + FeatureFlagContextCallback? context, + }) { + final flag = featureFlags?[key]; + + if (flag == null) { + return defaultValue; + } + final featureFlagContext = _getFeatureFlagContext(scope, context); + + final evaluationRule = _getEvaluationRuleMatch( + key, + flag, + featureFlagContext, + ); + + if (evaluationRule == null) { + return defaultValue; + } + + final resultType = _checkResultType(evaluationRule.result); + + return resultType ? evaluationRule.result as T : defaultValue; + } + + @experimental + Future getFeatureFlagValueAsync( + String key, { + Scope? scope, + T? defaultValue, + FeatureFlagContextCallback? context, + }) async { + if (!_options.isFeatureFlagsEnabled()) { + return defaultValue; + } + + await requestFeatureFlags(); + return _getFeatureFlagValue( + _featureFlags, + key, + scope: scope, + defaultValue: defaultValue, + context: context, + ); + } + + @experimental + T? getFeatureFlagValue( + String key, { + Scope? scope, + T? defaultValue, + FeatureFlagContextCallback? context, + }) { + if (!_options.isFeatureFlagsEnabled()) { + return defaultValue; + } + + return _getFeatureFlagValue( + _featureFlags, + key, + scope: scope, + defaultValue: defaultValue, + context: context, + ); + } + + bool _checkResultType(dynamic result) { + return result is T ? true : false; + } + + double _rollRandomNumber(String stickyId, String group) { + final seed = '$group|$stickyId'; + final rand = XorShiftRandom(seed); + return rand.next(); + } + + bool _matchesTags( + Map tags, Map contextTags) { + for (final item in tags.entries) { + if (item.value is List) { + if (!(item.value as List).contains(contextTags[item.key])) { + return false; + } + } else if (item.value != contextTags[item.key]) { + return false; + } + } + return true; + } + + @experimental + Future getFeatureFlagInfo( + String key, { + Scope? scope, + FeatureFlagContextCallback? context, + }) async { + if (!_options.isFeatureFlagsEnabled()) { + return null; + } + + await requestFeatureFlags(); + final featureFlag = _featureFlags?[key]; + + if (featureFlag == null) { + return null; + } + + final featureFlagContext = _getFeatureFlagContext(scope, context); + + final evaluationRule = _getEvaluationRuleMatch( + key, + featureFlag, + featureFlagContext, + ); + + if (evaluationRule == null) { + return null; + } + final payload = evaluationRule.payload != null + ? Map.from(evaluationRule.payload as Map) + : null; + + return FeatureFlagInfo( + evaluationRule.result, + Map.from(evaluationRule.tags), + payload, + ); + } + + EvaluationRule? _getEvaluationRuleMatch( + String key, + FeatureFlag featureFlag, + FeatureFlagContext context, + ) { + // there's always a stickyId + final stickyId = context.tags['stickyId']!; + final group = featureFlag.group ?? key; + + for (final evalConfig in featureFlag.evaluations) { + if (!_matchesTags(evalConfig.tags, context.tags)) { + continue; + } + + switch (evalConfig.type) { + case EvaluationType.rollout: + final percentage = _rollRandomNumber(stickyId, group); + if (percentage < (evalConfig.percentage ?? 0.0)) { + return evalConfig; + } + break; + case EvaluationType.match: + return evalConfig; + default: + break; + } + } + + return null; + } + + FeatureFlagContext _getFeatureFlagContext( + Scope? scope, + FeatureFlagContextCallback? context, + ) { + final featureFlagContext = FeatureFlagContext({}); + + // set the device id + final deviceId = _options.distinctId; + if (deviceId != null) { + featureFlagContext.tags['deviceId'] = deviceId; + } + + // set userId from Scope + final userId = scope?.user?.id; + if (userId != null) { + featureFlagContext.tags['userId'] = userId; + } + // set the release + final release = _options.release; + if (release != null) { + featureFlagContext.tags['release'] = release; + } + // set the env + final environment = _options.environment; + if (environment != null) { + featureFlagContext.tags['environment'] = environment; + } + // set the transaction + final transaction = scope?.transaction; + if (transaction != null) { + featureFlagContext.tags['transaction'] = transaction; + } + + // set all the tags from the scope as well + featureFlagContext.tags.addAll(scope?.tags ?? {}); + + // run feature flag context callback and allow user adding/removing tags + if (context != null) { + context(featureFlagContext); + } + + // fallback stickyId if not provided by the user + // fallbacks to userId if set or deviceId + // if none were provided, it generates an Uuid + var stickyId = featureFlagContext.tags['stickyId']; + if (stickyId == null) { + stickyId = featureFlagContext.tags['userId'] ?? + featureFlagContext.tags['deviceId'] ?? + Uuid().v4().toString(); + featureFlagContext.tags['stickyId'] = stickyId; + } + + return featureFlagContext; + } + + Future requestFeatureFlags() async { + // TODO: add mechanism to reset caching + _featureFlags = + _featureFlags ?? await _options.transport.fetchFeatureFlags(); + } } diff --git a/dart/lib/src/sentry_options.dart b/dart/lib/src/sentry_options.dart index fbb48b7fa5..e371a632fb 100644 --- a/dart/lib/src/sentry_options.dart +++ b/dart/lib/src/sentry_options.dart @@ -271,6 +271,9 @@ class SentryOptions { /// If enabled, [scopeObservers] will be called when mutating scope. bool enableScopeSync = true; + /// The distinct Id/device Id used for feature flags + String? distinctId; + final List _scopeObservers = []; List get scopeObservers => _scopeObservers; @@ -282,6 +285,16 @@ class SentryOptions { @internal late ClientReportRecorder recorder = NoOpClientReportRecorder(); + /// experimental features + final Map experimental = { + 'featureFlagsEnabled': false, + }; + + // experimental + bool isFeatureFlagsEnabled() { + return experimental['featureFlagsEnabled'] as bool? ?? false; + } + SentryOptions({this.dsn, PlatformChecker? checker}) { if (checker != null) { platformChecker = checker; diff --git a/dart/lib/src/sentry_traces_sampler.dart b/dart/lib/src/sentry_traces_sampler.dart index 9d981cca04..52cc5499cf 100644 --- a/dart/lib/src/sentry_traces_sampler.dart +++ b/dart/lib/src/sentry_traces_sampler.dart @@ -14,7 +14,7 @@ class SentryTracesSampler { Random? random, }) : _random = random ?? Random(); - bool sample(SentrySamplingContext samplingContext) { + bool sample(SentrySamplingContext samplingContext, double? tracesSampleRate) { final sampled = samplingContext.transactionContext.sampled; if (sampled != null) { return sampled; @@ -33,7 +33,6 @@ class SentryTracesSampler { return parentSampled; } - final tracesSampleRate = _options.tracesSampleRate; if (tracesSampleRate != null) { return _sample(tracesSampleRate); } diff --git a/dart/lib/src/transport/http_transport.dart b/dart/lib/src/transport/http_transport.dart index 317070695a..a486b37bbe 100644 --- a/dart/lib/src/transport/http_transport.dart +++ b/dart/lib/src/transport/http_transport.dart @@ -5,6 +5,8 @@ import 'package:http/http.dart'; import '../client_reports/client_report_recorder.dart'; import '../client_reports/discard_reason.dart'; +import '../feature_flags/feature_dump.dart'; +import '../feature_flags/feature_flag.dart'; import 'data_category.dart'; import 'noop_encode.dart' if (dart.library.io) 'encode.dart'; import '../noop_client.dart'; @@ -95,9 +97,114 @@ class HttpTransport implements Transport { return SentryId.fromId(eventId); } + @override + Future?> fetchFeatureFlags() async { + // TODO: handle rate limiting, client reports, etc... + + final response = await _options.httpClient.post(_dsn.featureFlagsUri, + headers: _credentialBuilder.configure(_headers)); + + if (response.statusCode != 200) { + if (_options.debug) { + _options.logger( + SentryLevel.error, + 'API returned an error, statusCode = ${response.statusCode}, ' + 'body = ${response.body}', + ); + } + // return null; + } + + final responseJson = json.decode(response.body); + +// final responseJson = json.decode(''' +// { +// "feature_flags": { +// "@@accessToProfiling": { +// "evaluation": [ +// { +// "type": "match", +// "result": true, +// "tags": { +// "isSentryDev": "true" +// } +// }, +// { +// "type": "rollout", +// "percentage": 0.5, +// "result": true +// } +// ], +// "kind": "boolean" +// }, +// "@@errorsSampleRate": { +// "evaluation": [ +// { +// "type": "match", +// "result": 0.75 +// } +// ], +// "kind": "number" +// }, +// "@@profilingEnabled": { +// "evaluation": [ +// { +// "type": "match", +// "result": true, +// "tags": { +// "isSentryDev": "true" +// } +// }, +// { +// "type": "rollout", +// "percentage": 0.05, +// "result": true +// } +// ], +// "kind": "boolean" +// }, +// "@@tracesSampleRate": { +// "evaluation": [ +// { +// "type": "match", +// "result": 0.25 +// } +// ], +// "kind": "number" +// }, +// "accessToProfiling": { +// "evaluation": [ +// { +// "type": "rollout", +// "percentage": 1.0, +// "result": true +// } +// ], +// "kind": "boolean" +// }, +// "welcomeBanner": { +// "evaluation": [ +// { +// "type": "rollout", +// "percentage": 1.0, +// "result": "dev.png", +// "tags": { +// "environment": "dev" +// } +// } +// ], +// "kind": "string" +// } +// } +// } +// '''); + + return FeatureDump.fromJson(responseJson).featureFlags; + } + Future _createStreamedRequest( SentryEnvelope envelope) async { - final streamedRequest = StreamedRequest('POST', _dsn.postUri); + final streamedRequest = StreamedRequest('POST', _dsn.envelopeUri); if (_options.compressPayload) { final compressionSink = compressInSink(streamedRequest.sink, _headers); diff --git a/dart/lib/src/transport/noop_transport.dart b/dart/lib/src/transport/noop_transport.dart index f4ae138e99..0837f73bf0 100644 --- a/dart/lib/src/transport/noop_transport.dart +++ b/dart/lib/src/transport/noop_transport.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import '../feature_flags/feature_flag.dart'; import '../sentry_envelope.dart'; import '../protocol.dart'; @@ -8,4 +9,7 @@ import 'transport.dart'; class NoOpTransport implements Transport { @override Future send(SentryEnvelope envelope) async => null; + + @override + Future?> fetchFeatureFlags() async => null; } diff --git a/dart/lib/src/transport/transport.dart b/dart/lib/src/transport/transport.dart index f0a6a2c996..746605c78c 100644 --- a/dart/lib/src/transport/transport.dart +++ b/dart/lib/src/transport/transport.dart @@ -1,5 +1,8 @@ import 'dart:async'; +import 'package:meta/meta.dart'; + +import '../feature_flags/feature_flag.dart'; import '../sentry_envelope.dart'; import '../protocol.dart'; @@ -7,4 +10,7 @@ import '../protocol.dart'; /// or caching in the disk. abstract class Transport { Future send(SentryEnvelope envelope); + + @experimental + Future?> fetchFeatureFlags(); } diff --git a/dart/lib/src/typed_data/typed_buffer.dart b/dart/lib/src/typed_data/typed_buffer.dart new file mode 100644 index 0000000000..05566c202f --- /dev/null +++ b/dart/lib/src/typed_data/typed_buffer.dart @@ -0,0 +1,426 @@ +// Copyright (c) 2020, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// vendored from https://github.com/dart-lang/typed_data/blob/master/lib/src/typed_buffer.dart +// + +import 'dart:collection' show ListBase; +import 'dart:typed_data'; + +abstract class TypedDataBuffer extends ListBase { + static const int _initialLength = 8; + + /// The underlying data buffer. + /// + /// This is always both a List and a TypedData, which we don't have a type + /// for here. For example, for a `Uint8Buffer`, this is a `Uint8List`. + List _buffer; + + /// Returns a view of [_buffer] as a [TypedData]. + TypedData get _typedBuffer => _buffer as TypedData; + + /// The length of the list being built. + int _length; + + TypedDataBuffer(List buffer) + : _buffer = buffer, + _length = buffer.length; + + @override + int get length => _length; + + @override + E operator [](int index) { + if (index >= length) throw RangeError.index(index, this); + return _buffer[index]; + } + + @override + void operator []=(int index, E value) { + if (index >= length) throw RangeError.index(index, this); + _buffer[index] = value; + } + + @override + set length(int newLength) { + if (newLength < _length) { + var defaultValue = _defaultValue; + for (var i = newLength; i < _length; i++) { + _buffer[i] = defaultValue; + } + } else if (newLength > _buffer.length) { + List newBuffer; + if (_buffer.isEmpty) { + newBuffer = _createBuffer(newLength); + } else { + newBuffer = _createBiggerBuffer(newLength); + } + newBuffer.setRange(0, _length, _buffer); + _buffer = newBuffer; + } + _length = newLength; + } + + void _add(E value) { + if (_length == _buffer.length) _grow(_length); + _buffer[_length++] = value; + } + + // We override the default implementation of `add` because it grows the list + // by setting the length in increments of one. We want to grow by doubling + // capacity in most cases. + @override + void add(E element) { + _add(element); + } + + /// Appends all objects of [values] to the end of this buffer. + /// + /// This adds values from [start] (inclusive) to [end] (exclusive) in + /// [values]. If [end] is omitted, it defaults to adding all elements of + /// [values] after [start]. + /// + /// The [start] value must be non-negative. The [values] iterable must have at + /// least [start] elements, and if [end] is specified, it must be greater than + /// or equal to [start] and [values] must have at least [end] elements. + @override + void addAll(Iterable values, [int start = 0, int? end]) { + RangeError.checkNotNegative(start, 'start'); + if (end != null && start > end) { + throw RangeError.range(end, start, null, 'end'); + } + + _addAll(values, start, end); + } + + /// Inserts all objects of [values] at position [index] in this list. + /// + /// This adds values from [start] (inclusive) to [end] (exclusive) in + /// [values]. If [end] is omitted, it defaults to adding all elements of + /// [values] after [start]. + /// + /// The [start] value must be non-negative. The [values] iterable must have at + /// least [start] elements, and if [end] is specified, it must be greater than + /// or equal to [start] and [values] must have at least [end] elements. + @override + void insertAll(int index, Iterable values, [int start = 0, int? end]) { + RangeError.checkValidIndex(index, this, 'index', _length + 1); + RangeError.checkNotNegative(start, 'start'); + if (end != null) { + if (start > end) { + throw RangeError.range(end, start, null, 'end'); + } + if (start == end) return; + } + + // If we're adding to the end of the list anyway, use [_addAll]. This lets + // us avoid converting [values] into a list even if [end] is null, since we + // can add values iteratively to the end of the list. We can't do so in the + // center because copying the trailing elements every time is non-linear. + if (index == _length) { + _addAll(values, start, end); + return; + } + + if (end == null && values is List) { + end = values.length; + } + if (end != null) { + _insertKnownLength(index, values, start, end); + return; + } + + // Add elements at end, growing as appropriate, then put them back at + // position [index] using flip-by-double-reverse. + var writeIndex = _length; + var skipCount = start; + for (var value in values) { + if (skipCount > 0) { + skipCount--; + continue; + } + if (writeIndex == _buffer.length) { + _grow(writeIndex); + } + _buffer[writeIndex++] = value; + } + + if (skipCount > 0) { + throw StateError('Too few elements'); + } + if (end != null && writeIndex < end) { + throw RangeError.range(end, start, writeIndex, 'end'); + } + + // Swap [index.._length) and [_length..writeIndex) by double-reversing. + _reverse(_buffer, index, _length); + _reverse(_buffer, _length, writeIndex); + _reverse(_buffer, index, writeIndex); + _length = writeIndex; + return; + } + + // Reverses the range [start..end) of buffer. + static void _reverse(List buffer, int start, int end) { + end--; // Point to last element, not after last element. + while (start < end) { + var first = buffer[start]; + var last = buffer[end]; + buffer[end] = first; + buffer[start] = last; + start++; + end--; + } + } + + /// Does the same thing as [addAll]. + /// + /// This allows [addAll] and [insertAll] to share implementation without a + /// subclass unexpectedly overriding both when it intended to only override + /// [addAll]. + void _addAll(Iterable values, [int start = 0, int? end]) { + if (values is List) end ??= values.length; + + // If we know the length of the segment to add, do so with [addRange]. This + // way we know how much to grow the buffer in advance, and it may be even + // more efficient for typed data input. + if (end != null) { + _insertKnownLength(_length, values, start, end); + return; + } + + // Otherwise, just add values one at a time. + var i = 0; + for (var value in values) { + if (i >= start) add(value); + i++; + } + if (i < start) throw StateError('Too few elements'); + } + + /// Like [insertAll], but with a guaranteed non-`null` [start] and [end]. + void _insertKnownLength(int index, Iterable values, int start, int end) { + if (values is List) { + if (start > values.length || end > values.length) { + throw StateError('Too few elements'); + } + } + + var valuesLength = end - start; + var newLength = _length + valuesLength; + _ensureCapacity(newLength); + + _buffer.setRange( + index + valuesLength, _length + valuesLength, _buffer, index); + _buffer.setRange(index, index + valuesLength, values, start); + _length = newLength; + } + + @override + void insert(int index, E element) { + if (index < 0 || index > _length) { + throw RangeError.range(index, 0, _length); + } + if (_length < _buffer.length) { + _buffer.setRange(index + 1, _length + 1, _buffer, index); + _buffer[index] = element; + _length++; + return; + } + var newBuffer = _createBiggerBuffer(null); + newBuffer.setRange(0, index, _buffer); + newBuffer.setRange(index + 1, _length + 1, _buffer, index); + newBuffer[index] = element; + _length++; + _buffer = newBuffer; + } + + /// Ensures that [_buffer] is at least [requiredCapacity] long, + /// + /// Grows the buffer if necessary, preserving existing data. + void _ensureCapacity(int requiredCapacity) { + if (requiredCapacity <= _buffer.length) return; + var newBuffer = _createBiggerBuffer(requiredCapacity); + newBuffer.setRange(0, _length, _buffer); + _buffer = newBuffer; + } + + /// Create a bigger buffer. + /// + /// This method determines how much bigger a bigger buffer should + /// be. If [requiredCapacity] is not null, it will be at least that + /// size. It will always have at least have double the capacity of + /// the current buffer. + List _createBiggerBuffer(int? requiredCapacity) { + var newLength = _buffer.length * 2; + if (requiredCapacity != null && newLength < requiredCapacity) { + newLength = requiredCapacity; + } else if (newLength < _initialLength) { + newLength = _initialLength; + } + return _createBuffer(newLength); + } + + /// Grows the buffer. + /// + /// This copies the first [length] elements into the new buffer. + void _grow(int length) { + _buffer = _createBiggerBuffer(null)..setRange(0, length, _buffer); + } + + @override + void setRange(int start, int end, Iterable iterable, [int skipCount = 0]) { + if (end > _length) throw RangeError.range(end, 0, _length); + _setRange(start, end, iterable, skipCount); + } + + /// Like [setRange], but with no bounds checking. + void _setRange(int start, int end, Iterable source, int skipCount) { + if (source is TypedDataBuffer) { + _buffer.setRange(start, end, source._buffer, skipCount); + } else { + _buffer.setRange(start, end, source, skipCount); + } + } + + // TypedData. + + int get elementSizeInBytes => _typedBuffer.elementSizeInBytes; + + int get lengthInBytes => _length * _typedBuffer.elementSizeInBytes; + + int get offsetInBytes => _typedBuffer.offsetInBytes; + + /// Returns the underlying [ByteBuffer]. + /// + /// The returned buffer may be replaced by operations that change the [length] + /// of this list. + /// + /// The buffer may be larger than [lengthInBytes] bytes, but never smaller. + ByteBuffer get buffer => _typedBuffer.buffer; + + // Specialization for the specific type. + + // Return zero for integers, 0.0 for floats, etc. + // Used to fill buffer when changing length. + E get _defaultValue; + + // Create a new typed list to use as buffer. + List _createBuffer(int size); +} + +abstract class _IntBuffer extends TypedDataBuffer { + _IntBuffer(List buffer) : super(buffer); + + @override + int get _defaultValue => 0; +} + +abstract class _FloatBuffer extends TypedDataBuffer { + _FloatBuffer(List buffer) : super(buffer); + + @override + double get _defaultValue => 0.0; +} + +class Uint8Buffer extends _IntBuffer { + Uint8Buffer([int initialLength = 0]) : super(Uint8List(initialLength)); + + @override + Uint8List _createBuffer(int size) => Uint8List(size); +} + +class Int8Buffer extends _IntBuffer { + Int8Buffer([int initialLength = 0]) : super(Int8List(initialLength)); + + @override + Int8List _createBuffer(int size) => Int8List(size); +} + +class Uint8ClampedBuffer extends _IntBuffer { + Uint8ClampedBuffer([int initialLength = 0]) + : super(Uint8ClampedList(initialLength)); + + @override + Uint8ClampedList _createBuffer(int size) => Uint8ClampedList(size); +} + +class Uint16Buffer extends _IntBuffer { + Uint16Buffer([int initialLength = 0]) : super(Uint16List(initialLength)); + + @override + Uint16List _createBuffer(int size) => Uint16List(size); +} + +class Int16Buffer extends _IntBuffer { + Int16Buffer([int initialLength = 0]) : super(Int16List(initialLength)); + + @override + Int16List _createBuffer(int size) => Int16List(size); +} + +class Uint32Buffer extends _IntBuffer { + Uint32Buffer([int initialLength = 0]) : super(Uint32List(initialLength)); + + @override + Uint32List _createBuffer(int size) => Uint32List(size); +} + +class Int32Buffer extends _IntBuffer { + Int32Buffer([int initialLength = 0]) : super(Int32List(initialLength)); + + @override + Int32List _createBuffer(int size) => Int32List(size); +} + +class Uint64Buffer extends _IntBuffer { + Uint64Buffer([int initialLength = 0]) : super(Uint64List(initialLength)); + + @override + Uint64List _createBuffer(int size) => Uint64List(size); +} + +class Int64Buffer extends _IntBuffer { + Int64Buffer([int initialLength = 0]) : super(Int64List(initialLength)); + + @override + Int64List _createBuffer(int size) => Int64List(size); +} + +class Float32Buffer extends _FloatBuffer { + Float32Buffer([int initialLength = 0]) : super(Float32List(initialLength)); + + @override + Float32List _createBuffer(int size) => Float32List(size); +} + +class Float64Buffer extends _FloatBuffer { + Float64Buffer([int initialLength = 0]) : super(Float64List(initialLength)); + + @override + Float64List _createBuffer(int size) => Float64List(size); +} + +class Int32x4Buffer extends TypedDataBuffer { + static final Int32x4 _zero = Int32x4(0, 0, 0, 0); + + Int32x4Buffer([int initialLength = 0]) : super(Int32x4List(initialLength)); + + @override + Int32x4 get _defaultValue => _zero; + + @override + Int32x4List _createBuffer(int size) => Int32x4List(size); +} + +class Float32x4Buffer extends TypedDataBuffer { + Float32x4Buffer([int initialLength = 0]) + : super(Float32x4List(initialLength)); + + @override + Float32x4 get _defaultValue => Float32x4.zero(); + + @override + Float32x4List _createBuffer(int size) => Float32x4List(size); +} diff --git a/dart/lib/src/utils/hash_code.dart b/dart/lib/src/utils/hash_code.dart index 3fbcb5dd2b..cd7a7cde63 100644 --- a/dart/lib/src/utils/hash_code.dart +++ b/dart/lib/src/utils/hash_code.dart @@ -1,5 +1,5 @@ // Borrowed from https://api.dart.dev/stable/2.17.6/dart-core/Object/hash.html -// Since Object.hash(a, b) is only available from Dart 2.14 +// Since Object.hash(a, b) and Object.hashAll are only available from Dart 2.14 // A per-isolate seed for hash code computations. final int _hashSeed = identityHashCode(Object); @@ -22,3 +22,11 @@ int _finish(int hash) { hash = hash ^ (hash >> 11); return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); } + +int hashAll(Iterable objects) { + int hash = _hashSeed; + for (var object in objects) { + hash = _combine(hash, object.hashCode); + } + return _finish(hash); +} diff --git a/dart/test/hub_test.dart b/dart/test/hub_test.dart index 0c697e0fd7..849ee1f05c 100644 --- a/dart/test/hub_test.dart +++ b/dart/test/hub_test.dart @@ -537,6 +537,7 @@ class Fixture { tracer = SentryTracer(_context, hub); + client.featureFlagValue = tracesSampleRate; hub.bindClient(client); options.recorder = recorder; diff --git a/dart/test/mocks.dart b/dart/test/mocks.dart index cb36172d06..cab459541b 100644 --- a/dart/test/mocks.dart +++ b/dart/test/mocks.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:sentry/sentry.dart'; -import 'package:sentry/src/transport/rate_limiter.dart'; final fakeDsn = 'https://abc@def.ingest.sentry.io/1234567'; diff --git a/dart/test/mocks/mock_sentry_client.dart b/dart/test/mocks/mock_sentry_client.dart index d42f86f48e..6c7aecd748 100644 --- a/dart/test/mocks/mock_sentry_client.dart +++ b/dart/test/mocks/mock_sentry_client.dart @@ -10,6 +10,8 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { List captureTransactionCalls = []; List userFeedbackCalls = []; int closeCalls = 0; + dynamic featureFlagValue; + FeatureFlagInfo? featureFlagInfo; @override Future captureEvent( @@ -87,6 +89,32 @@ class MockSentryClient with NoSuchMethodProvider implements SentryClient { captureTransactionCalls.add(CaptureTransactionCall(transaction)); return transaction.eventId; } + + @override + Future getFeatureFlagValueAsync( + String key, { + Scope? scope, + T? defaultValue, + FeatureFlagContextCallback? context, + }) async => + featureFlagValue; + + @override + T? getFeatureFlagValue( + String key, { + Scope? scope, + T? defaultValue, + FeatureFlagContextCallback? context, + }) => + featureFlagValue; + + @override + Future getFeatureFlagInfo( + String key, { + Scope? scope, + FeatureFlagContextCallback? context, + }) async => + featureFlagInfo; } class CaptureEventCall { diff --git a/dart/test/mocks/mock_transport.dart b/dart/test/mocks/mock_transport.dart index 693debe1e9..7a451780ce 100644 --- a/dart/test/mocks/mock_transport.dart +++ b/dart/test/mocks/mock_transport.dart @@ -22,6 +22,9 @@ class MockTransport implements Transport { return envelope.header.eventId ?? SentryId.empty(); } + @override + Future?> fetchFeatureFlags() async => null; + Future _eventFromEnvelope(SentryEnvelope envelope) async { final envelopeItemData = []; envelopeItemData.addAll(await envelope.items.first.envelopeItemStream()); @@ -65,4 +68,7 @@ class ThrowingTransport implements Transport { Future send(SentryEnvelope envelope) async { throw Exception('foo bar'); } + + @override + Future?> fetchFeatureFlags() async => null; } diff --git a/dart/test/protocol/rate_limiter_test.dart b/dart/test/protocol/rate_limiter_test.dart index e95dd1afe4..a51545fd53 100644 --- a/dart/test/protocol/rate_limiter_test.dart +++ b/dart/test/protocol/rate_limiter_test.dart @@ -3,7 +3,6 @@ import 'package:sentry/src/client_reports/discard_reason.dart'; import 'package:sentry/src/transport/data_category.dart'; import 'package:test/test.dart'; -import 'package:sentry/src/transport/rate_limiter.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/src/sentry_envelope_header.dart'; diff --git a/dart/test/sentry_test.dart b/dart/test/sentry_test.dart index 989f63a58e..9b39a42c2c 100644 --- a/dart/test/sentry_test.dart +++ b/dart/test/sentry_test.dart @@ -18,15 +18,17 @@ void main() { var anException = Exception(); setUp(() async { + final tracesSampleRate = 1.0; await Sentry.init( (options) => { options.dsn = fakeDsn, - options.tracesSampleRate = 1.0, + options.tracesSampleRate = tracesSampleRate, }, ); anException = Exception('anException'); client = MockSentryClient(); + client.featureFlagValue = tracesSampleRate; Sentry.bindClient(client); }); diff --git a/dart/test/sentry_traces_sampler_test.dart b/dart/test/sentry_traces_sampler_test.dart index a465b4cc60..38e901ec4e 100644 --- a/dart/test/sentry_traces_sampler_test.dart +++ b/dart/test/sentry_traces_sampler_test.dart @@ -17,7 +17,7 @@ void main() { ); final context = SentrySamplingContext(trContext, {}); - expect(sut.sample(context), true); + expect(sut.sample(context, fixture.options.tracesSampleRate), true); }); test('options has sampler', () { @@ -36,7 +36,7 @@ void main() { ); final context = SentrySamplingContext(trContext, {}); - expect(sut.sample(context), true); + expect(sut.sample(context, fixture.options.tracesSampleRate), true); }); test('transactionContext has parentSampled', () { @@ -49,7 +49,7 @@ void main() { ); final context = SentrySamplingContext(trContext, {}); - expect(sut.sample(context), true); + expect(sut.sample(context, fixture.options.tracesSampleRate), true); }); test('options has rate 1.0', () { @@ -61,7 +61,7 @@ void main() { ); final context = SentrySamplingContext(trContext, {}); - expect(sut.sample(context), true); + expect(sut.sample(context, fixture.options.tracesSampleRate), true); }); test('options has rate 0.0', () { @@ -73,7 +73,7 @@ void main() { ); final context = SentrySamplingContext(trContext, {}); - expect(sut.sample(context), false); + expect(sut.sample(context, fixture.options.tracesSampleRate), false); }); } diff --git a/dart/test/test_utils.dart b/dart/test/test_utils.dart index c364e323d8..4b02b96d1d 100644 --- a/dart/test/test_utils.dart +++ b/dart/test/test_utils.dart @@ -91,7 +91,7 @@ Future testCaptureException( } final dsn = Dsn.parse(options.dsn!); - expect(postUri, dsn.postUri); + expect(postUri, dsn.envelopeUri); testHeaders( headers, @@ -193,7 +193,7 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { expect(dsn.uri, Uri.parse(testDsn)); expect( - dsn.postUri, + dsn.envelopeUri, Uri.parse('https://sentry.example.com/api/1/envelope/'), ); expect(dsn.publicKey, 'public'); @@ -210,7 +210,7 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { expect(dsn.uri, Uri.parse(_testDsnWithoutSecret)); expect( - dsn.postUri, + dsn.envelopeUri, Uri.parse('https://sentry.example.com/api/1/envelope/'), ); expect(dsn.publicKey, 'public'); @@ -227,7 +227,7 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { expect(dsn.uri, Uri.parse(_testDsnWithPath)); expect( - dsn.postUri, + dsn.envelopeUri, Uri.parse('https://sentry.example.com/path/api/1/envelope/'), ); expect(dsn.publicKey, 'public'); @@ -243,7 +243,7 @@ void runTest({Codec, List?>? gzip, bool isWeb = false}) { expect(dsn.uri, Uri.parse(_testDsnWithPort)); expect( - dsn.postUri, + dsn.envelopeUri, Uri.parse('https://sentry.example.com:8888/api/1/envelope/'), ); expect(dsn.publicKey, 'public'); diff --git a/dart/test/transport/http_transport_test.dart b/dart/test/transport/http_transport_test.dart index 14a2fe19e7..a8d6048450 100644 --- a/dart/test/transport/http_transport_test.dart +++ b/dart/test/transport/http_transport_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -7,12 +8,10 @@ import 'package:sentry/src/sentry_envelope_header.dart'; import 'package:sentry/src/sentry_envelope_item_header.dart'; import 'package:sentry/src/sentry_item_type.dart'; import 'package:sentry/src/transport/data_category.dart'; -import 'package:sentry/src/transport/rate_limiter.dart'; import 'package:test/test.dart'; import 'package:sentry/src/sentry_tracer.dart'; import 'package:sentry/sentry.dart'; -import 'package:sentry/src/transport/http_transport.dart'; import '../mocks.dart'; import '../mocks/mock_client_report_recorder.dart'; @@ -202,6 +201,68 @@ void main() { expect(fixture.clientReportRecorder.category, DataCategory.error); }); }); + + group('feature flags', () { + late Fixture fixture; + + setUp(() { + fixture = Fixture(); + }); + + test('parses the feature flag list', () async { + final featureFlagsFile = File('test_resources/feature_flags.json'); + final featureFlagsJson = await featureFlagsFile.readAsString(); + + final httpMock = MockClient((http.Request request) async { + return http.Response(featureFlagsJson, 200, headers: {}); + }); + final mockRateLimiter = MockRateLimiter(); + final sut = fixture.getSut(httpMock, mockRateLimiter); + + final flags = await sut.fetchFeatureFlags(); + + // accessToProfiling + final accessToProfiling = flags!['accessToProfiling']!; + + // expect(accessToProfiling.tags['isEarlyAdopter'], 'true'); + + final rollout = accessToProfiling.evaluations.first; + expect(rollout.percentage, 0.5); + expect(rollout.result, true); + expect(rollout.tags['userSegment'], 'slow'); + expect(rollout.type, EvaluationType.rollout); + expect(rollout.payload, isNull); + + final match = accessToProfiling.evaluations.last; + expect(match.percentage, isNull); + expect(match.result, true); + expect(match.tags['isSentryDev'], 'true'); + expect(match.type, EvaluationType.match); + expect( + match.payload!['background_image'], 'https://example.com/modus1.png'); + + // profilingEnabled + final profilingEnabled = flags['profilingEnabled']!; + + // expect(profilingEnabled.tags.isEmpty, true); + + final rolloutProfiling = profilingEnabled.evaluations.first; + expect(rolloutProfiling.percentage, 0.05); + expect(rolloutProfiling.result, true); + expect(rolloutProfiling.tags['isSentryDev'], 'true'); + expect(rolloutProfiling.type, EvaluationType.rollout); + expect(rolloutProfiling.payload, isNull); + + final matchProfiling = profilingEnabled.evaluations.last; + expect(matchProfiling.percentage, isNull); + expect(matchProfiling.result, true); + expect(matchProfiling.tags.isEmpty, true); + expect(matchProfiling.type, EvaluationType.match); + expect(matchProfiling.payload, isNull); + }, onPlatform: { + 'browser': Skip() + }); // TODO: web does not have File/readAsString + }); } class Fixture { diff --git a/dart/test/xor_shift_rand_test.dart b/dart/test/xor_shift_rand_test.dart new file mode 100644 index 0000000000..11098843d1 --- /dev/null +++ b/dart/test/xor_shift_rand_test.dart @@ -0,0 +1,19 @@ +import 'package:sentry/src/feature_flags/xor_shift_rand.dart'; +import 'package:test/test.dart'; + +void main() { + test('test random determinisc generator', () { + final rand = XorShiftRandom('wohoo'); + + expect(rand.nextu32(), 3709882355); + expect(rand.nextu32(), 3406141351); + expect(rand.nextu32(), 2220835615); + expect(rand.nextu32(), 1978561524); + expect(rand.nextu32(), 2006162129); + expect(rand.nextu32(), 1526862107); + expect(rand.nextu32(), 2715875971); + expect(rand.nextu32(), 3524055327); + expect(rand.nextu32(), 1313248726); + expect(rand.nextu32(), 1591659718); + }); +} diff --git a/dart/test_resources/feature_flags.json b/dart/test_resources/feature_flags.json new file mode 100644 index 0000000000..ec435fedee --- /dev/null +++ b/dart/test_resources/feature_flags.json @@ -0,0 +1,45 @@ +{ + "feature_flags": { + "accessToProfiling": { + "kind": "bool", + "evaluation": [ + { + "type": "rollout", + "percentage": 0.5, + "result": true, + "tags": { + "userSegment": "slow" + }, + "payload": null + }, + { + "type": "match", + "result": true, + "tags": { + "isSentryDev": "true" + }, + "payload": { + "background_image": "https://example.com/modus1.png" + } + } + ] + }, + "profilingEnabled": { + "kind": "bool", + "evaluation": [ + { + "type": "rollout", + "percentage": 0.05, + "result": true, + "tags": { + "isSentryDev": "true" + } + }, + { + "type": "match", + "result": true + } + ] + } + } +} diff --git a/dio/test/mocks/mock_transport.dart b/dio/test/mocks/mock_transport.dart index 5b9b13de03..9efe28902c 100644 --- a/dio/test/mocks/mock_transport.dart +++ b/dio/test/mocks/mock_transport.dart @@ -23,6 +23,9 @@ class MockTransport with NoSuchMethodProvider implements Transport { return envelope.header.eventId ?? SentryId.empty(); } + @override + Future?> fetchFeatureFlags() async => null; + Future _eventFromEnvelope(SentryEnvelope envelope) async { final envelopeItemData = []; envelopeItemData.addAll(await envelope.items.first.envelopeItemStream()); @@ -66,4 +69,7 @@ class ThrowingTransport implements Transport { Future send(SentryEnvelope envelope) async { throw Exception('foo bar'); } + + @override + Future?> fetchFeatureFlags() async => null; } diff --git a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt index 047285a059..8480b68544 100644 --- a/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt +++ b/flutter/android/src/main/kotlin/io/sentry/flutter/SentryFlutterPlugin.kt @@ -103,6 +103,7 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { return true } + @Suppress("LongMethod") private fun initNativeSdk(call: MethodCall, result: Result) { if (!this::context.isInitialized) { result.error("1", "Context is null", null) @@ -171,7 +172,12 @@ class SentryFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { // missing proxy, enableScopeSync } - result.success("") + + // make Installation.id(context) public + val item = mapOf( + "deviceId" to Sentry.getCurrentHub().options.distinctId + ) + result.success(item) } private fun fetchNativeAppStart(result: Result) { diff --git a/flutter/example/lib/main.dart b/flutter/example/lib/main.dart index d14cabc470..85fc47a606 100644 --- a/flutter/example/lib/main.dart +++ b/flutter/example/lib/main.dart @@ -16,7 +16,7 @@ import 'package:sentry_logging/sentry_logging.dart'; // ATTENTION: Change the DSN below with your own to see the events in Sentry. Get one at sentry.io const String _exampleDsn = - 'https://9934c532bf8446ef961450973c898537@o447951.ingest.sentry.io/5428562'; + 'https://fe85fc5123d44d5c99202d9e8f09d52e@395f015cf6c1.eu.ngrok.io/2'; final _channel = const MethodChannel('example.flutter.sentry.io'); @@ -31,6 +31,7 @@ Future main() async { options.attachThreads = true; options.enableWindowMetricBreadcrumbs = true; options.addIntegration(LoggingIntegration()); + options.experimental['featureFlagsEnabled'] = true; }, // Init your App. appRunner: () => runApp( @@ -341,6 +342,43 @@ class MainScaffold extends StatelessWidget { }, child: const Text('Show UserFeedback Dialog without event'), ), + ElevatedButton( + onPressed: () async { + await Sentry.configureScope((scope) async { + await scope.setUser( + SentryUser( + id: '800', + ), + ); + }); + + final accessToProfiling = await Sentry.isFeatureFlagEnabled( + '@@accessToProfiling', + defaultValue: false, + context: (myContext) => { + myContext.tags['isSentryDev'] = 'true', + }, + ); + print('accessToProfiling: $accessToProfiling'); + + final errorsSampleRate = + Sentry.getFeatureFlagValue('@@errorsSampleRate'); + print('errorsSampleRate: $errorsSampleRate'); + + final tracesSampleRate = + Sentry.getFeatureFlagValue('@@tracesSampleRate'); + print('tracesSampleRate: $tracesSampleRate'); + + final welcomeBannerResult = Sentry.getFeatureFlagValue( + 'welcomeBanner', + context: (myContext) => { + myContext.tags['environment'] = 'dev', + }, + ); + print('welcomeBanner: $welcomeBannerResult'); + }, + child: const Text('Check feature flags'), + ), if (UniversalPlatform.isIOS || UniversalPlatform.isMacOS) const CocoaExample(), if (UniversalPlatform.isAndroid) const AndroidExample(), diff --git a/flutter/ios/Classes/SentryFlutterPluginApple.swift b/flutter/ios/Classes/SentryFlutterPluginApple.swift index 174bfdeb8e..0b7629ace9 100644 --- a/flutter/ios/Classes/SentryFlutterPluginApple.swift +++ b/flutter/ios/Classes/SentryFlutterPluginApple.swift @@ -231,7 +231,12 @@ public class SentryFlutterPluginApple: NSObject, FlutterPlugin { didReceiveDidBecomeActiveNotification = false } - result("") + let deviceId = PrivateSentrySDKOnly.installationID + let item: [String: Any] = [ + "deviceId": deviceId + ] + + result(item) } private func closeNativeSdk(_ call: FlutterMethodCall, result: @escaping FlutterResult) { diff --git a/flutter/lib/src/default_integrations.dart b/flutter/lib/src/default_integrations.dart index 3fc708dcf4..8a095e9268 100644 --- a/flutter/lib/src/default_integrations.dart +++ b/flutter/lib/src/default_integrations.dart @@ -348,7 +348,8 @@ class NativeSdkIntegration extends Integration { return; } try { - await _channel.invokeMethod('initNativeSdk', { + final result = + await _channel.invokeMethod('initNativeSdk', { 'dsn': options.dsn, 'debug': options.debug, 'environment': options.environment, @@ -375,6 +376,11 @@ class NativeSdkIntegration extends Integration { 'enableAutoPerformanceTracking': options.enableAutoPerformanceTracking, 'sendClientReports': options.sendClientReports, }); + final infos = Map.from(result); + + // set the device id + final deviceId = infos['deviceId'] as String?; + options.distinctId = deviceId; options.sdk.addIntegration('nativeSdkIntegration'); } catch (exception, stackTrace) { diff --git a/flutter/lib/src/file_system_transport.dart b/flutter/lib/src/file_system_transport.dart index 9b1e580203..8b57b725ff 100644 --- a/flutter/lib/src/file_system_transport.dart +++ b/flutter/lib/src/file_system_transport.dart @@ -9,6 +9,11 @@ class FileSystemTransport implements Transport { final MethodChannel _channel; final SentryOptions _options; + // late because the configuration callback needs to run first + // before creating the http transport with the dsn + late final HttpTransport _httpTransport = + HttpTransport(_options, RateLimiter(_options)); + @override Future send(SentryEnvelope envelope) async { final envelopeData = []; @@ -29,4 +34,8 @@ class FileSystemTransport implements Transport { return envelope.header.eventId; } + + @override + Future?> fetchFeatureFlags() async => + _httpTransport.fetchFeatureFlags(); } diff --git a/flutter/test/default_integrations_test.dart b/flutter/test/default_integrations_test.dart index 5ff56817fc..9789514639 100644 --- a/flutter/test/default_integrations_test.dart +++ b/flutter/test/default_integrations_test.dart @@ -219,7 +219,9 @@ void main() { }); test('nativeSdkIntegration adds integration', () async { - _channel.setMockMethodCallHandler((MethodCall methodCall) async {}); + _channel.setMockMethodCallHandler((MethodCall methodCall) async { + return {'deviceId': 'test'}; + }); final integration = NativeSdkIntegration(_channel); diff --git a/flutter/test/native_sdk_integration_test.dart b/flutter/test/native_sdk_integration_test.dart index 9f757d5fba..6ec1b6c716 100644 --- a/flutter/test/native_sdk_integration_test.dart +++ b/flutter/test/native_sdk_integration_test.dart @@ -128,7 +128,9 @@ void main() { }); test('adds integration', () async { - final channel = createChannelWithCallback((call) async {}); + final channel = createChannelWithCallback((call) async { + return {'deviceId': 'test'}; + }); var sut = fixture.getSut(channel); final options = createOptions();