diff --git a/.github/workflows/unified_analytics.yaml b/.github/workflows/unified_analytics.yaml index 1b06ee066..f76d7920b 100644 --- a/.github/workflows/unified_analytics.yaml +++ b/.github/workflows/unified_analytics.yaml @@ -27,6 +27,8 @@ jobs: include: - sdk: dev run-tests: true + - sdk: dev + check-formatting: true steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - uses: dart-lang/setup-dart@65eb853c7ba17dde3be364c3d2858773e7144260 @@ -38,7 +40,7 @@ jobs: - 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 6f7c51120..f5830deb9 100644 --- a/pkgs/unified_analytics/CHANGELOG.md +++ b/pkgs/unified_analytics/CHANGELOG.md @@ -1,5 +1,10 @@ ## 8.0.16-wip +- 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. - Run `dart format`. ## 8.0.15 @@ -15,7 +20,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/USAGE_GUIDE.md b/pkgs/unified_analytics/USAGE_GUIDE.md index ffaf028ca..fedd056d8 100644 --- a/pkgs/unified_analytics/USAGE_GUIDE.md +++ b/pkgs/unified_analytics/USAGE_GUIDE.md @@ -124,6 +124,27 @@ if (analytics.shouldShowMessage) { It is important to note events will not be sent if there is a new version of the consent message. +## Dependency Telemetry & GA4 Parameter Chunking + +When transmitting CLI command execution events via +`Event.dartCliCommandExecuted`, CLI tools (`dart-tool`, `flutter-tool`) may pass +optional project dependency lists (`pubspecDependencies`) and SDK flags +(`pubspecHasFlutterSdk`). + +To comply with Google Analytics 4 (GA4) string length restrictions (maximum 100 +characters per event parameter), `unified_analytics` deterministically sorts +and shuffles the dependency list using a 64-bit FNV-1a hash before partitioning +it across 20 distinct event parameters (`pubspec_dep_0` through +`pubspec_dep_19`). This eliminates alphabetical bias (where packages starting +with 'a' or 'b' consistently fill the parameter budget while later packages are +dropped) and ensures statistically uniform ecosystem sampling. + +**Note for CLI Tool Authors**: Because an analytics `session_id` rolls over only +after 30 minutes of inactivity, CLI tools collecting dependency telemetry across +workspace directories should perform their own project-level caching and privacy +filtering (e.g., against `.dart_tool/package_config.json`) before passing +dependency data to `Event.dartCliCommandExecuted`. + ## Developing Within `package:unified_analytics` ### Adding new data classes diff --git a/pkgs/unified_analytics/lib/src/event.dart b/pkgs/unified_analytics/lib/src/event.dart index 326f540ed..516172b73 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 @@ final class Event { required String name, required String enabledExperiments, int? exitCode, + bool? pubspecHasFlutterSdk, + Set? 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 @@ abstract base class CustomMetrics { /// This must be a JSON-encodable [Map]. Map 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 chunkDependencies(Set 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 = {}; + var currentChunk = []; + 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/test/event_test.dart b/pkgs/unified_analytics/test/event_test.dart index 7dcc27eee..0d162de80 100644 --- a/pkgs/unified_analytics/test/event_test.dart +++ b/pkgs/unified_analytics/test/event_test.dart @@ -7,7 +7,6 @@ import 'dart:mirrors'; 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 @@ void main() { 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 = {}; + 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 = {}; + 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 = { + ...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 = {}; + 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',