feat(unified_analytics): add dependency telemetry and SDK constraint to Event.dartCliCommandExecuted
diff --git a/.github/workflows/unified_analytics.yaml b/.github/workflows/unified_analytics.yaml index 9a9b7f5..f98eac2 100644 --- a/.github/workflows/unified_analytics.yaml +++ b/.github/workflows/unified_analytics.yaml
@@ -27,6 +27,8 @@ include: - sdk: stable run-tests: true + - sdk: dev + check-formatting: true steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260 @@ -38,7 +40,7 @@ - run: dart analyze --fatal-infos - run: dart format --output=none --set-exit-if-changed . - if: ${{matrix.run-tests}} + if: ${{matrix.check-formatting}} - run: dart test if: ${{matrix.run-tests}}
diff --git a/pkgs/unified_analytics/CHANGELOG.md b/pkgs/unified_analytics/CHANGELOG.md index 1ffb12c..f0455f4 100644 --- a/pkgs/unified_analytics/CHANGELOG.md +++ b/pkgs/unified_analytics/CHANGELOG.md
@@ -1,3 +1,11 @@ +## 8.0.16 + +- Added optional `pubspecHasFlutterSdk` and `pubspecDependencies` parameters to + the `Event.dartCliCommandExecuted` constructor. +- Dependencies are deterministically sorted and chunked using a hash-based + algorithm to fit within Google Analytics 4 parameter limitations without + alphabetical bias. + ## 8.0.15 - Added IDE and plugin information to `Event.serverSession`. - Discard any `Exception` or `Error` thrown while reading or writing analytics logs. @@ -11,7 +19,7 @@ ## 8.0.12 - Require Dart 3.10 -- Added `success` indicator and `label` to `Event.flutterTrackAndroidDependencies` +- Added `success` indicator and `label` to `Event.flutterTrackAndroidDependencies` ## 8.0.11 - Added `Event.flutterTrackAndroidDependencies` to track android dependencies.
diff --git a/pkgs/unified_analytics/lib/src/constants.dart b/pkgs/unified_analytics/lib/src/constants.dart index c9f5fb6..5cc12b8 100644 --- a/pkgs/unified_analytics/lib/src/constants.dart +++ b/pkgs/unified_analytics/lib/src/constants.dart
@@ -87,7 +87,7 @@ const String kLogFileName = 'dart-flutter-telemetry.log'; /// The current version of the package, should be in line with pubspec version. -const String kPackageVersion = '8.0.15'; +const String kPackageVersion = '8.0.16'; /// The minimum length for a session. const int kSessionDurationMinutes = 30;
diff --git a/pkgs/unified_analytics/lib/src/event.dart b/pkgs/unified_analytics/lib/src/event.dart index 326f540..516172b 100644 --- a/pkgs/unified_analytics/lib/src/event.dart +++ b/pkgs/unified_analytics/lib/src/event.dart
@@ -4,6 +4,8 @@ import 'dart:convert'; +import 'package:meta/meta.dart'; + import 'enums.dart'; final class Event { @@ -462,12 +464,19 @@ required String name, required String enabledExperiments, int? exitCode, + bool? pubspecHasFlutterSdk, + Set<String>? pubspecDependencies, + String? pubspecEnvironmentSdk, }) : this._( eventName: DashEvent.dartCliCommandExecuted, eventData: { 'name': name, 'enabledExperiments': enabledExperiments, 'exitCode': ?exitCode, + 'pubspec_has_flutter_sdk': ?pubspecHasFlutterSdk, + 'pubspec_environment_sdk': ?pubspecEnvironmentSdk, + if (pubspecDependencies != null) + ...chunkDependencies(pubspecDependencies), }, ); @@ -1182,3 +1191,90 @@ /// This must be a JSON-encodable [Map]. Map<String, Object> toMap(); } + +/// Public helper in unified_analytics to sort and chunk dependencies. +/// +/// Respects GA4's 100-character limit per parameter and 25-parameter limit +/// per event. +/// +/// To eliminate alphabetical bias (e.g., always omitting packages starting +/// with 'z' when exceeding the 20-chunk cap), dependencies are sorted +/// deterministically by their FNV-1a hash value instead of alphabetically. +/// This provides a statistically unbiased, pseudo-random sample of packages +/// for large projects while remaining 100% stable, reproducible, and testable +/// across runs. +@visibleForTesting +Map<String, String> chunkDependencies(Set<String> deps) { + if (deps.isEmpty) return const {}; + + // Sort deterministically by FNV-1a hash value instead of alphabetically + // to eliminate systemic alphabetical bias during truncation. Fall back to + // alphabetical comparison if there is a hash collision to guarantee absolute + // determinism. + final sortedDeps = deps.toList() + ..sort((a, b) { + final hashA = _fnv1a(a); + final hashB = _fnv1a(b); + if (hashA != hashB) { + return hashA.compareTo(hashB); + } + return a.compareTo(b); + }); + + final chunks = <String, String>{}; + var currentChunk = <String>[]; + var currentLength = 0; + var chunkIndex = 0; + + // We have a maximum of 25 parameters per event in GA4. Standard event + // parameters (name, enabledExperiments, exitCode, pubspec_has_flutter_sdk) + // take up to 4 slots, leaving 21 slots. Capping at 20 chunks guarantees + // safety. + const maxChunks = 20; + + for (final dep in sortedDeps) { + // Guard: Skip package names that are somehow longer than 100 characters + // to prevent violating GA4's value length limit. + if (dep.length > 100) continue; + + final lengthToAdd = dep.length + (currentChunk.isEmpty ? 0 : 1); + if (currentLength + lengthToAdd > 100) { + chunks['pubspec_dep_$chunkIndex'] = currentChunk.join(','); + chunkIndex++; + + // Stop adding chunks if we reach the GA4 parameter count safety limit + if (chunkIndex >= maxChunks) { + currentChunk = const []; + break; + } + + currentChunk = [dep]; + currentLength = dep.length; + } else { + currentChunk.add(dep); + currentLength += lengthToAdd; + } + } + + if (currentChunk.isNotEmpty && chunkIndex < maxChunks) { + chunks['pubspec_dep_$chunkIndex'] = currentChunk.join(','); + } + + return chunks; +} + +/// Computes a deterministic 32-bit FNV-1a hash of a string. +/// +/// This is used to shuffle dependency names in a stable, pseudo-random +/// way to eliminate alphabetical bias when sampling packages for telemetry +/// while maintaining 100% determinism across runs. +int _fnv1a(String s) { + var hash = 2166136261; + for (var i = 0; i < s.length; i++) { + hash ^= s.codeUnitAt(i); + // Split the multiplication to prevent exceeding the 53-bit safe integer + // limit on the web. + hash = (((hash & 0xff) << 24) + (hash * 403)) & 0xffffffff; + } + return hash; +}
diff --git a/pkgs/unified_analytics/pubspec.yaml b/pkgs/unified_analytics/pubspec.yaml index 5b78a0b..60bf37a 100644 --- a/pkgs/unified_analytics/pubspec.yaml +++ b/pkgs/unified_analytics/pubspec.yaml
@@ -5,7 +5,7 @@ # LINT.IfChange # When updating this, keep the version consistent with the changelog and the # value in lib/src/constants.dart. -version: 8.0.15 +version: 8.0.16 # LINT.ThenChange(lib/src/constants.dart) repository: https://github.com/dart-lang/tools/tree/main/pkgs/unified_analytics issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aunified_analytics
diff --git a/pkgs/unified_analytics/test/event_test.dart b/pkgs/unified_analytics/test/event_test.dart index 7dcc27e..0d162de 100644 --- a/pkgs/unified_analytics/test/event_test.dart +++ b/pkgs/unified_analytics/test/event_test.dart
@@ -7,7 +7,6 @@ import 'package:test/test.dart'; import 'package:unified_analytics/src/enums.dart'; import 'package:unified_analytics/src/event.dart'; -import 'package:unified_analytics/unified_analytics.dart'; void main() { test('Event.analysisStatistics constructed', () { @@ -198,6 +197,130 @@ expect(constructedEvent.eventData.length, 3); }); + test('Event.dartCliCommandExecuted constructed with Set of dependencies', () { + final deps = {'path', 'meta', 'collection'}; + Event generateEvent() => Event.dartCliCommandExecuted( + name: 'name', + enabledExperiments: 'enabledExperiments', + exitCode: 0, + pubspecHasFlutterSdk: true, + pubspecDependencies: deps, + pubspecEnvironmentSdk: '^3.0.0', + ); + + final constructedEvent = generateEvent(); + + expect(generateEvent, returnsNormally); + expect(constructedEvent.eventName, DashEvent.dartCliCommandExecuted); + expect(constructedEvent.eventData['name'], 'name'); + expect( + constructedEvent.eventData['enabledExperiments'], + 'enabledExperiments', + ); + expect(constructedEvent.eventData['exitCode'], 0); + expect(constructedEvent.eventData['pubspec_has_flutter_sdk'], true); + expect(constructedEvent.eventData['pubspec_environment_sdk'], '^3.0.0'); + + // Delegates chunking to chunkDependencies + final expectedChunks = chunkDependencies(deps); + expect( + constructedEvent.eventData['pubspec_dep_0'], + expectedChunks['pubspec_dep_0'], + ); + expect(constructedEvent.eventData.length, 6); + }); + + test('Event.dartCliCommandExecuted delegates to chunkDependencies', () { + final deps = <String>{}; + for (var i = 1; i <= 50; i++) { + deps.add('dep_$i'); + } + + final event = Event.dartCliCommandExecuted( + name: 'name', + enabledExperiments: 'enabledExperiments', + pubspecDependencies: deps, + ); + + final expected = chunkDependencies(deps); + for (final entry in expected.entries) { + expect(event.eventData[entry.key], entry.value); + } + }); + + group('chunkDependencies', () { + test('empty set returns empty map', () { + expect(chunkDependencies({}), isEmpty); + }); + + test('sorts deterministically using FNV-1a hash values', () { + final deps = {'path', 'meta', 'collection', 'args', 'yaml', 'http'}; + + // Verification of determinism across multiple calls + final result1 = chunkDependencies(deps); + final result2 = chunkDependencies(deps); + expect(result1, result2); + + // Verify that it is NOT sorted alphabetically. + // Alphabetical order would be: args, collection, http, meta, path, yaml + final alphabeticalList = deps.toList()..sort(); + final reportedList = result1['pubspec_dep_0']!.split(','); + expect(reportedList, isNot(alphabeticalList)); + }); + + test('chunks correctly based on 100-character limit', () { + // 10 dependencies, each 16 characters. + // Delimited by ',' means 17 characters per dep (except last). + final deps = <String>{}; + for (var i = 1; i <= 10; i++) { + deps.add('dep_${i.toString().padLeft(2, '0')}_123456789'); + } + + final result = chunkDependencies(deps); + + // Since each dep is 16 chars, 6 deps would be 6 * 16 + 5 = 101 chars, + // which is > 100. So max 5 deps fit in a single chunk + // (5 * 16 + 4 = 84 chars). Therefore, it must be split across + // exactly 2 chunks. + expect(result.containsKey('pubspec_dep_0'), isTrue); + expect(result.containsKey('pubspec_dep_1'), isTrue); + expect(result.containsKey('pubspec_dep_2'), isFalse); + + expect(result['pubspec_dep_0']!.length, lessThanOrEqualTo(100)); + expect(result['pubspec_dep_1']!.length, lessThanOrEqualTo(100)); + + final allReported = <String>{ + ...result['pubspec_dep_0']!.split(','), + ...result['pubspec_dep_1']!.split(','), + }; + expect(allReported, deps); + }); + + test('caps at 20 chunks', () { + // Generate 150 dependencies, each 15 characters. + // This would require ~24 chunks, but must be capped at 20. + final deps = <String>{}; + for (var i = 1; i <= 150; i++) { + deps.add('dep_${i.toString().padLeft(3, '0')}_12345678'); + } + + final result = chunkDependencies(deps); + + expect(result.containsKey('pubspec_dep_0'), isTrue); + expect(result.containsKey('pubspec_dep_19'), isTrue); + expect(result.containsKey('pubspec_dep_20'), isFalse); + expect(result.length, 20); + }); + + test('guards against and skips package names > 100 characters', () { + final longName = 'a' * 101; + final result = chunkDependencies({longName, 'path'}); + + expect(result['pubspec_dep_0'], 'path'); + expect(result.length, 1); + }); + }); + test('Event.doctorValidatorResult constructed', () { Event generateEvent() => Event.doctorValidatorResult( validatorName: 'validatorName',