Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/unified_analytics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}}
7 changes: 6 additions & 1 deletion pkgs/unified_analytics/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions pkgs/unified_analytics/USAGE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions pkgs/unified_analytics/lib/src/event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import 'dart:convert';

import 'package:meta/meta.dart';

import 'enums.dart';

final class Event {
Expand Down Expand Up @@ -462,12 +464,19 @@ final class Event {
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),
},
);

Expand Down Expand Up @@ -1182,3 +1191,90 @@ abstract base class CustomMetrics {
/// 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;
}
Comment thread
kevmoo marked this conversation as resolved.
125 changes: 124 additions & 1 deletion pkgs/unified_analytics/test/event_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down Expand Up @@ -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 = <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',
Expand Down
Loading