Merge pull request #152 from sigurdm/batching

Allow batching of hits
diff --git a/example/ga.dart b/example/ga.dart
index 3526fc3..e160ad6 100644
--- a/example/ga.dart
+++ b/example/ga.dart
@@ -8,7 +8,7 @@
 import 'package:usage/usage_io.dart';
 
 void main(List args) async {
-  final defaultUA = 'UA-55029513-1';
+  final defaultUA = 'UA-188575324-1';
 
   if (args.isEmpty) {
     print('usage: dart ga <GA tracking ID>');
@@ -27,6 +27,8 @@
       .sendException('foo error:\n${sanitizeStacktrace(StackTrace.current)}');
   await ga.sendTiming('writeDuration', 123);
   await ga.sendEvent('create', 'consoleapp', label: 'Console App');
+  await ga.sendEvent('destroy', 'consoleapp', label: 'Console App');
+
   print('pinged ${ua}');
 
   await ga.waitForLastPing();
diff --git a/lib/src/usage_impl.dart b/lib/src/usage_impl.dart
index 7fae01a..3e850ec 100644
--- a/lib/src/usage_impl.dart
+++ b/lib/src/usage_impl.dart
@@ -3,6 +3,7 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:collection';
 import 'dart:math' as math;
 
 import '../usage.dart';
@@ -57,6 +58,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 +79,32 @@
   @override
   AnalyticsOpt analyticsOpt = AnalyticsOpt.optOut;
 
+  late Duration _batchingDelay;
+  final Queue<String> _batchedEvents = Queue<String>();
+  bool _isSendingScheduled = 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,
+    Duration? batchingDelay,
+  }) {
     if (applicationName != null) setSessionValue('an', applicationName);
     if (applicationVersion != null) setSessionValue('av', applicationVersion);
 
     _url = analyticsUrl ?? _defaultAnalyticsUrl;
+    _batchingUrl = analyticsBatchingUrl ?? _defaultAnalyticsBatchingUrl;
+    _batchingDelay = batchingDelay ?? const Duration();
   }
 
   bool? _firstRun;
@@ -118,38 +137,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 +199,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
@@ -198,13 +222,13 @@
   Stream<Map<String, dynamic>> get onSend => _sendController.stream;
 
   @override
-  Future<List<dynamic>> waitForLastPing({Duration? timeout}) {
-    var f = Future.wait(_futures).catchError((e) => []);
-
-    if (timeout != null) {
-      f = f.timeout(timeout, onTimeout: () => []);
+  Future<List<dynamic>> waitForLastPing({Duration? timeout}) async {
+    // If there are pending messages, send them now.
+    if (_batchedEvents.isNotEmpty) {
+      _trySendBatches(Completer<void>());
     }
-
+    var f = Future.wait(_futures);
+    if (timeout != null) f = f.timeout(timeout);
     return f;
   }
 
@@ -219,36 +243,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);
+    _batchedEvents.add(postHandler.encodeHit(eventArgs));
+    // First check if we have a full batch - if so, send them immediately.
+    if (_batchedEvents.length >= _maxHitsPerBatch ||
+        _batchedEvents.fold<int>(0, (s, e) => s + e.length) >=
+            _maxBytesPerBatch) {
+      _trySendBatches(completer);
+    } else if (!_isSendingScheduled) {
+      _isSendingScheduled = true;
+      // ignore: unawaited_futures
+      Future.delayed(_batchingDelay).then((value) {
+        _isSendingScheduled = false;
+        _trySendBatches(completer);
       });
-
-      args['v'] = '1'; // protocol version
-      args['tid'] = trackingId;
-      args['cid'] = clientId;
-      args['t'] = hitType;
-
-      _sendController.add(args);
-
-      return _recordFuture(postHandler.sendPost(_url, args));
-    } else {
-      return Future.value();
     }
+    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<void> 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);
+        futures.add(future);
+      }
+    }
+    completer.complete(Future.wait(futures).then((_) {}));
+  }
+
+  void _recordFuture(Future f) {
     _futures.add(f);
-    return f.whenComplete(() => _futures.remove(f));
+    f.whenComplete(() => _futures.remove(f));
   }
 }
 
@@ -283,7 +350,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..b2501aa 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,
+    Duration? 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,19 +52,23 @@
   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<void> sendPost(String url, List<String> batch) async {
+    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) {
+    try {
+      await requestor(url, method: 'POST', sendData: data);
+    } on Exception {
       // Catch errors that can happen during a request, but that we can't do
       // anything about, e.g. a missing internet connection.
-    });
+    }
   }
 
   @override
diff --git a/lib/src/usage_impl_io.dart b/lib/src/usage_impl_io.dart
index c692f6a..69dd392 100644
--- a/lib/src/usage_impl_io.dart
+++ b/lib/src/usage_impl_io.dart
@@ -3,9 +3,10 @@
 // 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:meta/meta.dart';
 import 'package:path/path.dart' as path;
 
 import 'usage_impl.dart';
@@ -13,6 +14,9 @@
 /// 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,36 @@
 /// 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. Events will be sent
+/// batches of 20 after the duration is over from when the first message was
+/// sent. The default is 0 milliseconds, meaning that messages will be sent when
+/// control returns to the event loop.
+///
+/// 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,
+    Duration? 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 +62,8 @@
   }
 }
 
-String _createUserAgent() {
+@visibleForTesting
+String createUserAgent() {
   final locale = getPlatformLocale() ?? '';
 
   if (Platform.isAndroid) {
@@ -74,28 +97,25 @@
 }
 
 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();
-    } catch (exception) {
+    } on Exception {
       // Catch errors that can happen during a request, but that we can't do
       // anything about, e.g. a missing internet connection.
     }
@@ -105,7 +125,7 @@
   void close() {
     // Do a force close to ensure that lingering requests will not stall the
     // program.
-    _client?.close(force: true);
+    _client.close(force: true);
   }
 }
 
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..65f6708 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,144 @@
       expect(getPlatformLocale(), isNotNull);
     });
   });
+
+  group('batching', () {
+    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();
+      analytics.close();
+      expect(mockClient.closed, isTrue);
+      expect(mockClient.requests.length, 3);
+      final clientId = analytics.clientId;
+      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=$clientId&t=event
+ec=my-event2&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event3&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event4&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event5&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event6&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event7&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event8&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event9&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event10&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event11&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event12&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event13&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event14&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event15&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event16&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event17&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event18&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event19&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&t=event
+ec=my-event20&ea=something&an=usage-test&av=0.0.1&ul=en-us&v=1&tid=%3CTRACKING-ID&cid=$clientId&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=$clientId&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=$clientId&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 {
+    if (closed) throw StateError('Posting after close');
+    final request = MockHttpClientRequest();
+    request.buffer.writeln('Request to $uri with $userAgent');
+    requests.add(request);
+    return request;
   }
 
   @override
-  dynamic noSuchMethod(Invocation invocation) {}
+  void close({bool force = false}) {
+    if (closed) throw StateError('Double close');
+    closed = true;
+  }
+
+  @override
+  dynamic noSuchMethod(Invocation invocation) {
+    throw UnimplementedError('Unexpected call');
+  }
 }
 
 class MockHttpClientRequest implements HttpClientRequest {
-  final MockHttpClient client;
+  final buffer = StringBuffer();
+  final MockHttpClientResponse response = MockHttpClientResponse();
+  bool closed = false;
 
-  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 {
+    if (closed) throw StateError('Double close');
+    closed = true;
+    return 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 8373d96..21111b3 100644
--- a/test/usage_impl_test.dart
+++ b/test/usage_impl_test.dart
@@ -70,15 +70,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);
     });
   });