Merge remote-tracking branch 'origin/master' into batching
diff --git a/lib/src/usage_impl.dart b/lib/src/usage_impl.dart
index c774c6f..ec5c661 100644
--- a/lib/src/usage_impl.dart
+++ b/lib/src/usage_impl.dart
@@ -3,8 +3,11 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:collection';
 import 'dart:math' as math;
 
+import 'package:pedantic/pedantic.dart';
+
 import '../usage.dart';
 import '../uuid/uuid.dart';
 
@@ -57,6 +60,9 @@
   static const String _defaultAnalyticsUrl =
       'https://www.google-analytics.com/collect';
 
+  static const String _defaultAnalyticsBatchingUrl =
+      'https://www.google-analytics.com/batch';
+
   @override
   final String trackingId;
   @override
@@ -75,17 +81,32 @@
   @override
   AnalyticsOpt analyticsOpt = AnalyticsOpt.optOut;
 
+  late Future<void>? Function() _batchingDelay;
+  final Queue<String> _batchedEvents = Queue<String>();
+  bool _isAwaitingSending = false;
+
   late final String _url;
+  late final String _batchingUrl;
 
   final StreamController<Map<String, dynamic>> _sendController =
       StreamController.broadcast(sync: true);
 
-  AnalyticsImpl(this.trackingId, this.properties, this.postHandler,
-      {this.applicationName, this.applicationVersion, String? analyticsUrl}) {
+  AnalyticsImpl(
+    this.trackingId,
+    this.properties,
+    this.postHandler, {
+    this.applicationName,
+    this.applicationVersion,
+    String? analyticsUrl,
+    String? analyticsBatchingUrl,
+    Future<void>? Function()? batchingDelay,
+  }) {
     if (applicationName != null) setSessionValue('an', applicationName);
     if (applicationVersion != null) setSessionValue('av', applicationVersion);
 
     _url = analyticsUrl ?? _defaultAnalyticsUrl;
+    _batchingUrl = analyticsBatchingUrl ?? _defaultAnalyticsBatchingUrl;
+    _batchingDelay = batchingDelay ?? () => Future(() {});
   }
 
   bool? _firstRun;
@@ -118,38 +139,41 @@
 
   @override
   Future sendScreenView(String viewName, {Map<String, String>? parameters}) {
-    var args = <String, dynamic>{'cd': viewName};
-    if (parameters != null) {
-      args.addAll(parameters);
-    }
-    return _sendPayload('screenview', args);
+    var args = <String, String>{'cd': viewName, ...?parameters};
+    return _enqueuePayload('screenview', args);
   }
 
   @override
   Future sendEvent(String category, String action,
       {String? label, int? value, Map<String, String>? parameters}) {
-    var args = <String, dynamic>{'ec': category, 'ea': action};
-    if (label != null) args['el'] = label;
-    if (value != null) args['ev'] = value;
-    if (parameters != null) {
-      args.addAll(parameters);
-    }
-    return _sendPayload('event', args);
+    final args = <String, String>{
+      'ec': category,
+      'ea': action,
+      if (label != null) 'el': label,
+      if (value != null) 'ev': value.toString(),
+      ...?parameters
+    };
+
+    return _enqueuePayload('event', args);
   }
 
   @override
   Future sendSocial(String network, String action, String target) {
-    var args = <String, dynamic>{'sn': network, 'sa': action, 'st': target};
-    return _sendPayload('social', args);
+    var args = <String, String>{'sn': network, 'sa': action, 'st': target};
+    return _enqueuePayload('social', args);
   }
 
   @override
   Future sendTiming(String variableName, int time,
       {String? category, String? label}) {
-    var args = <String, dynamic>{'utv': variableName, 'utt': time};
-    if (label != null) args['utl'] = label;
-    if (category != null) args['utc'] = category;
-    return _sendPayload('timing', args);
+    var args = <String, String>{
+      'utv': variableName,
+      'utt': time.toString(),
+      if (label != null) 'utl': label,
+      if (category != null) 'utc': category,
+    };
+
+    return _enqueuePayload('timing', args);
   }
 
   @override
@@ -177,9 +201,11 @@
       description = description.substring(0, maxExceptionLength);
     }
 
-    var args = <String, dynamic>{'exd': description};
-    if (fatal != null && fatal) args['exf'] = '1';
-    return _sendPayload('exception', args);
+    var args = <String, String>{
+      'exd': description,
+      if (fatal != null && fatal) 'exf': '1',
+    };
+    return _enqueuePayload('exception', args);
   }
 
   @override
@@ -219,36 +245,79 @@
   ///
   /// Valid values for [hitType] are: 'pageview', 'screenview', 'event',
   /// 'transaction', 'item', 'social', 'exception', and 'timing'.
-  Future sendRaw(String hitType, Map<String, dynamic> args) {
-    return _sendPayload(hitType, args);
+  Future sendRaw(String hitType, Map<String, String> args) {
+    return _enqueuePayload(hitType, args);
   }
 
+  /// Puts a single hit in the queue. If the queue was empty - start waiting
+  /// for the result of [_batchingDelay] before sending all enqueued events.
+  ///
   /// Valid values for [hitType] are: 'pageview', 'screenview', 'event',
   /// 'transaction', 'item', 'social', 'exception', and 'timing'.
-  Future _sendPayload(String hitType, Map<String, dynamic> args) {
-    if (!enabled) return Future.value();
+  Future<void> _enqueuePayload(String hitType, Map<String, String> args) async {
+    if (!enabled) return;
+    // TODO(sigurdm): Really all the 'send' methods should not return Futures
+    // there is not much point in waiting for it. Only [waitForLastPing].
+    final completer = Completer<void>();
+    final eventArgs = <String, String>{
+      ...args,
+      ..._variableMap,
+      'v': '1', // protocol version
+      'tid': trackingId,
+      'cid': clientId,
+      't': hitType,
+    };
 
-    if (_bucket.removeDrop()) {
-      _variableMap.forEach((key, value) {
-        args[key] = value;
-      });
+    _sendController.add(eventArgs);
 
-      args['v'] = '1'; // protocol version
-      args['tid'] = trackingId;
-      args['cid'] = clientId;
-      args['t'] = hitType;
+    _batchedEvents.add(postHandler.encodeHit(eventArgs));
 
-      _sendController.add(args);
-
-      return _recordFuture(postHandler.sendPost(_url, args));
-    } else {
-      return Future.value();
+    if (!_isAwaitingSending) {
+      final delay = _batchingDelay();
+      if (delay == null) {
+        _trySendBatches(completer);
+      } else {
+        _isAwaitingSending = true;
+        unawaited(delay.then((value) {
+          _trySendBatches(completer);
+          _isAwaitingSending = false;
+        }));
+      }
     }
+    return completer.future;
   }
 
-  Future _recordFuture(Future f) {
+  // Send no more than 20 messages per batch.
+  static const _maxHitsPerBatch = 20;
+  // Send no more than 16K per batch.
+  static const _maxBytesPerBatch = 16000;
+
+  void _trySendBatches(Completer completer) {
+    final futures = <Future>[];
+    while (_batchedEvents.isNotEmpty) {
+      final batch = <String>[];
+      final totalLength = 0;
+
+      while (true) {
+        if (_batchedEvents.isEmpty) break;
+        if (totalLength + _batchedEvents.first.length > _maxBytesPerBatch) {
+          break;
+        }
+        batch.add(_batchedEvents.removeFirst());
+        if (batch.length == _maxHitsPerBatch) break;
+      }
+      if (_bucket.removeDrop()) {
+        final future = postHandler.sendPost(
+            batch.length == 1 ? _url : _batchingUrl, batch);
+        _recordFuture(future);
+      }
+    }
+    Future.wait(futures).then((_) => completer.complete());
+  }
+
+  void _recordFuture(Future f) {
     _futures.add(f);
-    return f.whenComplete(() => _futures.remove(f));
+    f.whenComplete(() => _futures.remove(f));
   }
 }
 
@@ -283,7 +352,8 @@
 /// The `Future` from [sendPost] should complete when the operation is finished,
 /// but failures to send the information should be silent.
 abstract class PostHandler {
-  Future sendPost(String url, Map<String, dynamic> parameters);
+  Future sendPost(String url, List<String> batch);
+  String encodeHit(Map<String, String> hit);
 
   /// Free any used resources.
   void close();
diff --git a/lib/src/usage_impl_html.dart b/lib/src/usage_impl_html.dart
index 1ebe299..0161b31 100644
--- a/lib/src/usage_impl_html.dart
+++ b/lib/src/usage_impl_html.dart
@@ -12,15 +12,28 @@
 ///
 /// [analyticsUrl] is an optional replacement for the default Google Analytics
 /// URL (`https://www.google-analytics.com/collect`).
+///
+/// [batchingDelay] is used to control batching behaviour. It should return a
+/// [Future] that will be awaited before attempting sending the enqueued
+/// messages. If it returns `null` events will not be batched.
+///
+/// Batched messages are sent in batches of up to 20 messages.
 class AnalyticsHtml extends AnalyticsImpl {
   AnalyticsHtml(
-      String trackingId, String applicationName, String applicationVersion,
-      {String? analyticsUrl})
-      : super(trackingId, HtmlPersistentProperties(applicationName),
-            HtmlPostHandler(),
-            applicationName: applicationName,
-            applicationVersion: applicationVersion,
-            analyticsUrl: analyticsUrl) {
+    String trackingId,
+    String applicationName,
+    String applicationVersion, {
+    String? analyticsUrl,
+    Future<void>? Function()? batchingDelay,
+  }) : super(
+          trackingId,
+          HtmlPersistentProperties(applicationName),
+          HtmlPostHandler(),
+          applicationName: applicationName,
+          applicationVersion: applicationVersion,
+          analyticsUrl: analyticsUrl,
+          batchingDelay: batchingDelay,
+        ) {
     var screenWidth = window.screen!.width;
     var screenHeight = window.screen!.height;
 
@@ -39,13 +52,15 @@
   HtmlPostHandler({this.mockRequestor});
 
   @override
-  Future sendPost(String url, Map<String, dynamic> parameters) {
+  String encodeHit(Map<String, String> hit) {
     var viewportWidth = document.documentElement!.clientWidth;
     var viewportHeight = document.documentElement!.clientHeight;
+    return postEncode({...hit, 'vp': '${viewportWidth}x$viewportHeight'});
+  }
 
-    parameters['vp'] = '${viewportWidth}x$viewportHeight';
-
-    var data = postEncode(parameters);
+  @override
+  Future sendPost(String url, List<String> batch) {
+    var data = batch.join('\n');
     Future<HttpRequest> Function(String, {String method, dynamic sendData})
         requestor = mockRequestor ?? HttpRequest.request;
     return requestor(url, method: 'POST', sendData: data).catchError((e) {
diff --git a/lib/src/usage_impl_io.dart b/lib/src/usage_impl_io.dart
index 62388ad..fbe1017 100644
--- a/lib/src/usage_impl_io.dart
+++ b/lib/src/usage_impl_io.dart
@@ -3,16 +3,20 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:async';
-import 'dart:convert' show jsonDecode, JsonEncoder;
+import 'dart:convert' show JsonEncoder, jsonDecode;
 import 'dart:io';
 
 import 'package:path/path.dart' as path;
+import 'package:meta/meta.dart';
 
 import 'usage_impl.dart';
 
 /// An interface to a Google Analytics session, suitable for use in command-line
 /// applications.
 ///
+/// [analyticsUrl] is an optional replacement for the default Google Analytics
+/// URL (`https://www.google-analytics.com/collect`).
+///
 /// `trackingId`, `applicationName`, and `applicationVersion` values should be supplied.
 /// `analyticsUrl` is optional, and lets user's substitute their own analytics URL for
 /// the default.
@@ -21,18 +25,38 @@
 /// defaults to the user home directory. For regular `dart:io` apps this doesn't need to
 /// be supplied. For Flutter applications, you should pass in a value like
 /// `PathProvider.getApplicationDocumentsDirectory()`.
+///
+/// [batchingDelay] is used to control batching behaviour. It should return a
+/// [Future] that will be awaited before attempting sending the enqueued
+/// messages. The default [batchingDelay] is `() => Future(() {})`. That implies
+/// messages will be sent whem control returns to the event loop.
+///
+/// If [batchingDelay] returns `null`, events will not be batched.
+///
+/// Batched messages are sent in batches of up to 20 messages. They will be sent
+/// to [analyticsBatchingUrl] defaulting to
+/// `https://www.google-analytics.com/batch`.
 class AnalyticsIO extends AnalyticsImpl {
   AnalyticsIO(
-      String trackingId, String applicationName, String applicationVersion,
-      {String? analyticsUrl, Directory? documentDirectory})
-      : super(
-            trackingId,
-            IOPersistentProperties(applicationName,
-                documentDirPath: documentDirectory?.path),
-            IOPostHandler(),
-            applicationName: applicationName,
-            applicationVersion: applicationVersion,
-            analyticsUrl: analyticsUrl) {
+    String trackingId,
+    String applicationName,
+    String applicationVersion, {
+    String? analyticsUrl,
+    String? analyticsBatchingUrl,
+    Directory? documentDirectory,
+    HttpClient? client,
+    Future<void>? Function()? batchingDelay,
+  }) : super(
+          trackingId,
+          IOPersistentProperties(applicationName,
+              documentDirPath: documentDirectory?.path),
+          IOPostHandler(client: client),
+          applicationName: applicationName,
+          applicationVersion: applicationVersion,
+          analyticsUrl: analyticsUrl,
+          analyticsBatchingUrl: analyticsBatchingUrl,
+          batchingDelay: batchingDelay,
+        ) {
     final locale = getPlatformLocale();
     if (locale != null) {
       setSessionValue('ul', locale);
@@ -40,7 +64,8 @@
   }
 }
 
-String _createUserAgent() {
+@visibleForTesting
+String createUserAgent() {
   final locale = getPlatformLocale() ?? '';
 
   if (Platform.isAndroid) {
@@ -74,24 +99,21 @@
 }
 
 class IOPostHandler extends PostHandler {
-  final String _userAgent;
-  final HttpClient? mockClient;
+  final HttpClient _client;
 
-  HttpClient? _client;
-
-  IOPostHandler({this.mockClient}) : _userAgent = _createUserAgent();
+  IOPostHandler({HttpClient? client})
+      : _client = (client ?? HttpClient())..userAgent = createUserAgent();
 
   @override
-  Future sendPost(String url, Map<String, dynamic> parameters) async {
-    var data = postEncode(parameters);
+  String encodeHit(Map<String, String> hit) {
+    return postEncode(hit);
+  }
 
-    if (_client == null) {
-      _client = mockClient ?? HttpClient();
-      _client!.userAgent = _userAgent;
-    }
-
+  @override
+  Future sendPost(String url, List<String> batch) async {
+    var data = batch.join('\n');
     try {
-      var req = await _client!.postUrl(Uri.parse(url));
+      var req = await _client.postUrl(Uri.parse(url));
       req.write(data);
       var response = await req.close();
       await response.drain();
@@ -102,7 +124,7 @@
   }
 
   @override
-  void close() => _client?.close();
+  void close() => _client.close();
 }
 
 JsonEncoder _jsonEncoder = JsonEncoder.withIndent('  ');
diff --git a/pubspec.yaml b/pubspec.yaml
index 9613c34..6a3bbeb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -12,7 +12,7 @@
 
 dependencies:
   path: ^1.8.0-nullsafety
+  pedantic: ^1.10.0-nullsafety.3
 
 dev_dependencies:
-  pedantic: ^1.9.0
   test: ^1.16.0-nullsafety
diff --git a/test/hit_types_test.dart b/test/hit_types_test.dart
index 63da854..ecdce39 100644
--- a/test/hit_types_test.dart
+++ b/test/hit_types_test.dart
@@ -5,6 +5,7 @@
 library usage.hit_types_test;
 
 import 'dart:async';
+import 'dart:convert';
 
 import 'package:test/test.dart';
 
@@ -14,15 +15,15 @@
 
 void defineTests() {
   group('screenView', () {
-    test('simple', () {
+    test('simple', () async {
       var mock = createMock();
-      mock.sendScreenView('main');
+      await mock.sendScreenView('main');
       expect(mock.mockProperties['clientId'], isNotNull);
       expect(mock.mockPostHandler.sentValues, isNot(isEmpty));
     });
-    test('with parameters', () {
+    test('with parameters', () async {
       var mock = createMock();
-      mock.sendScreenView('withParams', parameters: {'cd1': 'foo'});
+      await mock.sendScreenView('withParams', parameters: {'cd1': 'foo'});
       expect(mock.mockProperties['clientId'], isNotNull);
       expect(mock.mockPostHandler.sentValues, isNot(isEmpty));
       has(mock.last, 'cd1');
@@ -30,18 +31,18 @@
   });
 
   group('event', () {
-    test('simple', () {
+    test('simple', () async {
       var mock = createMock();
-      mock.sendEvent('files', 'save');
+      await mock.sendEvent('files', 'save');
       expect(mock.mockPostHandler.sentValues, isNot(isEmpty));
       was(mock.last, 'event');
       has(mock.last, 'ec');
       has(mock.last, 'ea');
     });
 
-    test('with parameters', () {
+    test('with parameters', () async {
       var mock = createMock();
-      mock.sendEvent('withParams', 'save', parameters: {'cd1': 'foo'});
+      await mock.sendEvent('withParams', 'save', parameters: {'cd1': 'foo'});
       expect(mock.mockPostHandler.sentValues, isNot(isEmpty));
       was(mock.last, 'event');
       has(mock.last, 'ec');
@@ -49,9 +50,9 @@
       has(mock.last, 'cd1');
     });
 
-    test('optional args', () {
+    test('optional args', () async {
       var mock = createMock();
-      mock.sendEvent('files', 'save', label: 'File Save', value: 23);
+      await mock.sendEvent('files', 'save', label: 'File Save', value: 23);
       expect(mock.mockPostHandler.sentValues, isNot(isEmpty));
       was(mock.last, 'event');
       has(mock.last, 'ec');
@@ -62,9 +63,9 @@
   });
 
   group('social', () {
-    test('simple', () {
+    test('simple', () async {
       var mock = createMock();
-      mock.sendSocial('g+', 'plus', 'userid');
+      await mock.sendSocial('g+', 'plus', 'userid');
       expect(mock.mockPostHandler.sentValues, isNot(isEmpty));
       was(mock.last, 'social');
       has(mock.last, 'sn');
@@ -74,18 +75,23 @@
   });
 
   group('timing', () {
-    test('simple', () {
+    test('simple', () async {
       var mock = createMock();
-      mock.sendTiming('compile', 123);
+      await mock.sendTiming('compile', 123);
       expect(mock.mockPostHandler.sentValues, isNot(isEmpty));
       was(mock.last, 'timing');
       has(mock.last, 'utv');
       has(mock.last, 'utt');
     });
 
-    test('optional args', () {
+    test('optional args', () async {
       var mock = createMock();
-      mock.sendTiming('compile', 123, category: 'Build', label: 'Compile');
+      await mock.sendTiming(
+        'compile',
+        123,
+        category: 'Build',
+        label: 'Compile',
+      );
       expect(mock.mockPostHandler.sentValues, isNot(isEmpty));
       was(mock.last, 'timing');
       has(mock.last, 'utv');
@@ -117,27 +123,28 @@
   });
 
   group('exception', () {
-    test('simple', () {
+    test('simple', () async {
       var mock = createMock();
-      mock.sendException('FooException');
+      await mock.sendException('FooException');
       expect(mock.mockPostHandler.sentValues, isNot(isEmpty));
       was(mock.last, 'exception');
       has(mock.last, 'exd');
     });
 
-    test('optional args', () {
+    test('optional args', () async {
       var mock = createMock();
-      mock.sendException('FooException', fatal: true);
+      await mock.sendException('FooException', fatal: true);
       expect(mock.mockPostHandler.sentValues, isNot(isEmpty));
       was(mock.last, 'exception');
       has(mock.last, 'exd');
       has(mock.last, 'exf');
     });
 
-    test('exception file paths', () {
+    test('exception file paths', () async {
       var mock = createMock();
-      mock.sendException('foo bar (file:///Users/foobar/tmp/error.dart:3:13)');
-      expect(mock.last['exd'], 'foo bar (');
+      await mock
+          .sendException('foo bar (file:///Users/foobar/tmp/error.dart:3:13)');
+      expect(jsonDecode(mock.last)['exd'], 'foo bar (');
     });
   });
 }
diff --git a/test/src/common.dart b/test/src/common.dart
index 304faf9..e934e71 100644
--- a/test/src/common.dart
+++ b/test/src/common.dart
@@ -5,6 +5,7 @@
 library usage.common_test;
 
 import 'dart:async';
+import 'dart:convert';
 
 import 'package:test/test.dart';
 import 'package:usage/src/usage_impl.dart';
@@ -12,9 +13,9 @@
 AnalyticsImplMock createMock({Map<String, dynamic>? props}) =>
     AnalyticsImplMock('UA-0', props: props);
 
-void was(Map m, String type) => expect(m['t'], type);
-void has(Map m, String key) => expect(m[key], isNotNull);
-void hasnt(Map m, String key) => expect(m[key], isNull);
+void was(String m, String type) => expect(jsonDecode(m)['t'], type);
+void has(String m, String key) => expect(jsonDecode(m)[key], isNotNull);
+void hasnt(String m, String key) => expect(jsonDecode(m)[key], isNull);
 
 class AnalyticsImplMock extends AnalyticsImpl {
   MockProperties get mockProperties => properties as MockProperties;
@@ -24,7 +25,7 @@
       : super(trackingId, MockProperties(props), MockPostHandler(),
             applicationName: 'Test App', applicationVersion: '0.1');
 
-  Map<String, dynamic> get last => mockPostHandler.last;
+  String get last => mockPostHandler.last;
 }
 
 class MockProperties extends PersistentProperties {
@@ -47,17 +48,20 @@
 }
 
 class MockPostHandler extends PostHandler {
-  List<Map<String, dynamic>> sentValues = [];
+  List<String> sentValues = [];
 
   @override
-  Future sendPost(String url, Map<String, dynamic> parameters) {
-    sentValues.add(parameters);
+  Future sendPost(String url, List<String> batch) {
+    sentValues.addAll(batch);
 
     return Future.value();
   }
 
-  Map<String, dynamic> get last => sentValues.last;
+  String get last => sentValues.last;
 
   @override
   void close() {}
+
+  @override
+  String encodeHit(Map<String, String> hit) => jsonEncode(hit);
 }
diff --git a/test/usage_impl_io_test.dart b/test/usage_impl_io_test.dart
index 8b15606..f8b3145 100644
--- a/test/usage_impl_io_test.dart
+++ b/test/usage_impl_io_test.dart
@@ -8,6 +8,7 @@
 import 'dart:async';
 import 'dart:io';
 
+import 'package:pedantic/pedantic.dart';
 import 'package:test/test.dart';
 import 'package:usage/src/usage_impl_io.dart';
 
@@ -16,11 +17,18 @@
 void defineTests() {
   group('IOPostHandler', () {
     test('sendPost', () async {
-      var httpClient = MockHttpClient();
-      var postHandler = IOPostHandler(mockClient: httpClient);
-      var args = <String, dynamic>{'utv': 'varName', 'utt': 123};
-      await postHandler.sendPost('http://www.google.com', args);
-      expect(httpClient.sendCount, 1);
+      var mockClient = MockHttpClient();
+
+      var postHandler = IOPostHandler(client: mockClient);
+      var args = [
+        <String, String>{'utv': 'varName', 'utt': '123'},
+      ];
+      await postHandler.sendPost(
+          'http://www.google.com', args.map(postHandler.encodeHit).toList());
+      expect(mockClient.requests.single.buffer.toString(), '''
+Request to http://www.google.com with ${createUserAgent()}
+utv=varName&utt=123''');
+      expect(mockClient.requests.single.response.drained, isTrue);
     });
   });
 
@@ -49,55 +57,149 @@
       expect(getPlatformLocale(), isNotNull);
     });
   });
+
+  group('batching', () {
+    test('With batch-delay returning null sends all events individually',
+        () async {
+      final mockClient = MockHttpClient();
+
+      final analytics = AnalyticsIO('<TRACKING-ID', 'usage-test', '0.0.1',
+          client: mockClient, batchingDelay: () => null);
+      unawaited(analytics.sendEvent('my-event1', 'something'));
+      unawaited(analytics.sendEvent('my-event2', 'something'));
+      unawaited(analytics.sendEvent('my-event3', 'something'));
+      await analytics.waitForLastPing();
+      expect(mockClient.requests.length, 3);
+      expect(mockClient.requests[0].buffer.toString(), '''
+Request to https://www.google-analytics.com/collect with ${createUserAgent()}
+ec=my-event1&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event''');
+      expect(mockClient.requests[1].buffer.toString(), '''
+Request to https://www.google-analytics.com/collect with ${createUserAgent()}
+ec=my-event2&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event''');
+      expect(mockClient.requests[2].buffer.toString(), '''
+Request to https://www.google-analytics.com/collect with ${createUserAgent()}
+ec=my-event3&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event''');
+    });
+
+    test(
+        'with default batch-delay hits from the same sync span are batched together',
+        () async {
+      var mockClient = MockHttpClient();
+
+      final analytics = AnalyticsIO('<TRACKING-ID', 'usage-test', '0.0.1',
+          client: mockClient);
+      unawaited(analytics.sendEvent('my-event1', 'something'));
+      unawaited(analytics.sendEvent('my-event2', 'something'));
+      unawaited(analytics.sendEvent('my-event3', 'something'));
+      unawaited(analytics.sendEvent('my-event4', 'something'));
+      unawaited(analytics.sendEvent('my-event5', 'something'));
+      unawaited(analytics.sendEvent('my-event6', 'something'));
+      unawaited(analytics.sendEvent('my-event7', 'something'));
+      unawaited(analytics.sendEvent('my-event8', 'something'));
+      unawaited(analytics.sendEvent('my-event9', 'something'));
+      unawaited(analytics.sendEvent('my-event10', 'something'));
+      unawaited(analytics.sendEvent('my-event11', 'something'));
+      unawaited(analytics.sendEvent('my-event12', 'something'));
+      unawaited(analytics.sendEvent('my-event13', 'something'));
+      unawaited(analytics.sendEvent('my-event14', 'something'));
+      unawaited(analytics.sendEvent('my-event15', 'something'));
+      unawaited(analytics.sendEvent('my-event16', 'something'));
+      unawaited(analytics.sendEvent('my-event17', 'something'));
+      unawaited(analytics.sendEvent('my-event18', 'something'));
+      unawaited(analytics.sendEvent('my-event19', 'something'));
+      unawaited(analytics.sendEvent('my-event20', 'something'));
+      unawaited(analytics.sendEvent('my-event21', 'something'));
+      await Future(() {});
+      expect(mockClient.requests.length, 2);
+      unawaited(analytics.sendEvent('my-event-not-batched', 'something'));
+      await Future(() {});
+
+      await analytics.waitForLastPing();
+      expect(mockClient.requests.length, 3);
+      expect(mockClient.requests[0].buffer.toString(), '''
+Request to https://www.google-analytics.com/batch with ${createUserAgent()}
+ec=my-event1&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event2&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event3&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event4&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event5&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event6&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event7&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event8&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event9&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event10&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event11&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event12&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event13&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event14&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event15&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event16&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event17&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event18&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event19&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event
+ec=my-event20&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event''');
+      expect(mockClient.requests[1].buffer.toString(), '''
+Request to https://www.google-analytics.com/collect with ${createUserAgent()}
+ec=my-event21&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event''');
+      expect(mockClient.requests[2].buffer.toString(), '''
+Request to https://www.google-analytics.com/collect with ${createUserAgent()}
+ec=my-event-not-batched&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=8e3fa343-70bc-4afe-ad81-5fed4256b4e8&t=event''');
+    });
+  });
 }
 
 class MockHttpClient implements HttpClient {
+  final List<MockHttpClientRequest> requests = <MockHttpClientRequest>[];
   @override
   String? userAgent;
-  int sendCount = 0;
-  int writeCount = 0;
-  bool closed = false;
+  MockHttpClient();
 
   @override
-  Future<HttpClientRequest> postUrl(Uri url) {
-    return Future.value(MockHttpClientRequest(this));
+  Future<HttpClientRequest> postUrl(Uri uri) async {
+    final request = MockHttpClientRequest();
+    request.buffer.writeln('Request to $uri with $userAgent');
+    requests.add(request);
+    return request;
   }
 
   @override
-  dynamic noSuchMethod(Invocation invocation) {}
+  dynamic noSuchMethod(Invocation invocation) {
+    throw UnimplementedError('Unexpected call');
+  }
 }
 
 class MockHttpClientRequest implements HttpClientRequest {
-  final MockHttpClient client;
+  final buffer = StringBuffer();
+  final MockHttpClientResponse response = MockHttpClientResponse();
 
-  MockHttpClientRequest(this.client);
+  MockHttpClientRequest();
 
   @override
-  void write(Object? obj) {
-    client.writeCount++;
+  void write(Object? o) {
+    buffer.write(o);
   }
 
   @override
-  Future<HttpClientResponse> close() {
-    client.closed = true;
-    return Future.value(MockHttpClientResponse(client));
-  }
+  Future<HttpClientResponse> close() async => response;
 
   @override
-  dynamic noSuchMethod(Invocation invocation) {}
+  dynamic noSuchMethod(Invocation invocation) {
+    throw UnimplementedError('Unexpected call');
+  }
 }
 
 class MockHttpClientResponse implements HttpClientResponse {
-  final MockHttpClient client;
-
-  MockHttpClientResponse(this.client);
+  bool drained = false;
+  MockHttpClientResponse();
 
   @override
-  Future<E> drain<E>([E? futureValue]) {
-    client.sendCount++;
-    return Future.value();
+  Future<E> drain<E>([E? futureValue]) async {
+    drained = true;
+    return futureValue as E;
   }
 
   @override
-  dynamic noSuchMethod(Invocation invocation) {}
+  dynamic noSuchMethod(Invocation invocation) {
+    throw UnimplementedError('Unexpected call');
+  }
 }
diff --git a/test/usage_impl_test.dart b/test/usage_impl_test.dart
index ae71261..0d46125 100644
--- a/test/usage_impl_test.dart
+++ b/test/usage_impl_test.dart
@@ -58,15 +58,15 @@
       expect(mock.firstRun, false);
     });
 
-    test('setSessionValue', () {
+    test('setSessionValue', () async {
       var mock = createMock();
-      mock.sendScreenView('foo');
+      await mock.sendScreenView('foo');
       hasnt(mock.last, 'val');
       mock.setSessionValue('val', 'ue');
-      mock.sendScreenView('bar');
+      await mock.sendScreenView('bar');
       has(mock.last, 'val');
       mock.setSessionValue('val', null);
-      mock.sendScreenView('baz');
+      await mock.sendScreenView('baz');
       hasnt(mock.last, 'val');
     });
 
diff --git a/test/web_test.dart b/test/web_test.dart
index eb0b107..5aa87bc 100644
--- a/test/web_test.dart
+++ b/test/web_test.dart
@@ -32,9 +32,12 @@
     test('sendPost', () async {
       var client = MockRequestor();
       var postHandler = HtmlPostHandler(mockRequestor: client.request);
-      var args = <String, dynamic>{'utv': 'varName', 'utt': 123};
+      var args = [
+        <String, String>{'utv': 'varName', 'utt': '123'},
+      ];
 
-      await postHandler.sendPost('http://www.google.com', args);
+      await postHandler.sendPost(
+          'http://www.google.com', args.map(postHandler.encodeHit).toList());
       expect(client.sendCount, 1);
     });
   });