Allow batching of hits
diff --git a/lib/src/usage_impl.dart b/lib/src/usage_impl.dart
index 73acc24..83ef9a6 100644
--- a/lib/src/usage_impl.dart
+++ b/lib/src/usage_impl.dart
@@ -57,6 +57,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
@@ -76,16 +79,25 @@
   AnalyticsOpt analyticsOpt = AnalyticsOpt.optOut;
 
   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,
+  }) {
     if (applicationName != null) setSessionValue('an', applicationName);
     if (applicationVersion != null) setSessionValue('av', applicationVersion);
 
     _url = analyticsUrl ?? _defaultAnalyticsUrl;
+    _batchingUrl = analyticsBatchingUrl ?? _defaultAnalyticsBatchingUrl;
   }
 
   bool? _firstRun;
@@ -118,38 +130,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 +192,29 @@
       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);
+  }
+
+  static const _batchingKey = #_batching;
+
+  @override
+  Future<T> withBatching<T>(
+    FutureOr<T> Function() callback, {
+    int maxEventsPerBatch = 20,
+  }) async {
+    final queue = _BatchingQueue(maxEventsPerBatch: maxEventsPerBatch);
+    return await runZoned(() async {
+      final result = await callback();
+      if (queue.enqueuedEvents.isNotEmpty) {
+        // Send any remaining events.
+        _trySendBatch(queue.takeBatch());
+      }
+      return result;
+    }, zoneValues: {_batchingKey: queue});
   }
 
   @override
@@ -219,36 +254,66 @@
   ///
   /// 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 event in the queue. If batching is not enabled it will be
+  /// send immediately - otherwise when the batch is full (20 events) or when
+  /// the batching callback is over.
   /// 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;
 
-    if (_bucket.removeDrop()) {
-      _variableMap.forEach((key, value) {
-        args[key] = value;
-      });
+    final eventArgs = <String, String>{
+      ...args,
+      ..._variableMap,
+      'v': '1', // protocol version
+      'tid': trackingId,
+      'cid': clientId,
+      't': hitType,
+    };
 
-      args['v'] = '1'; // protocol version
-      args['tid'] = trackingId;
-      args['cid'] = clientId;
-      args['t'] = hitType;
+    final batch = <Map<String, String>>[];
 
-      _sendController.add(args);
-
-      return _recordFuture(postHandler.sendPost(_url, args));
+    // See if we currently are batching events:
+    final batchingQueue = Zone.current[_batchingKey];
+    if (batchingQueue is _BatchingQueue) {
+      // Add the current event to the batch.
+      batchingQueue.enqueuedEvents.add(eventArgs);
+      if (!batchingQueue.isFull) {
+        // Queue not full yet. Do nothing.
+        return;
+      }
+      // We have a full batch, start a new one.
+      batch.addAll(batchingQueue.takeBatch());
     } else {
-      return Future.value();
+      batch.add(eventArgs);
+    }
+    _trySendBatch(batch);
+  }
+
+  void _trySendBatch(List<Map<String, String>> batch) {
+    if (_bucket.removeDrop()) {
+      for (final args in batch) {
+        _sendController.add(args);
+      }
+
+      // See if we currently are batching events:
+      final batchingQueue = Zone.current[_batchingKey];
+      if (batchingQueue is _BatchingQueue) {
+        _recordFuture(postHandler.sendPost(_batchingUrl, batch));
+      } else {
+        assert(batch.length == 1);
+        _recordFuture(postHandler.sendPost(_url, batch));
+      }
     }
   }
 
-  Future _recordFuture(Future f) {
+  void _recordFuture(Future f) {
     _futures.add(f);
-    return f.whenComplete(() => _futures.remove(f));
+    f.whenComplete(() => _futures.remove(f));
   }
 }
 
@@ -283,8 +348,24 @@
 /// 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<Map<String, dynamic>> batch);
 
   /// Free any used resources.
   void close();
 }
+
+class _BatchingQueue {
+  List<Map<String, String>> enqueuedEvents = [];
+  int maxEventsPerBatch;
+
+  _BatchingQueue({required this.maxEventsPerBatch});
+
+  bool get isFull => enqueuedEvents.length >= maxEventsPerBatch;
+  List<Map<String, String>> takeBatch() {
+    final result = enqueuedEvents.take(maxEventsPerBatch).toList();
+    // If for some reason more events than one batch have queued up simply
+    // discard them.
+    enqueuedEvents.clear();
+    return result;
+  }
+}
diff --git a/lib/src/usage_impl_html.dart b/lib/src/usage_impl_html.dart
index 1ebe299..9ee8632 100644
--- a/lib/src/usage_impl_html.dart
+++ b/lib/src/usage_impl_html.dart
@@ -39,13 +39,14 @@
   HtmlPostHandler({this.mockRequestor});
 
   @override
-  Future sendPost(String url, Map<String, dynamic> parameters) {
+  Future sendPost(String url, List<Map<String, dynamic>> batch) {
     var viewportWidth = document.documentElement!.clientWidth;
     var viewportHeight = document.documentElement!.clientHeight;
 
-    parameters['vp'] = '${viewportWidth}x$viewportHeight';
-
-    var data = postEncode(parameters);
+    var data = batch
+        .map((event) =>
+            postEncode({...event, 'vp': '${viewportWidth}x$viewportHeight'}))
+        .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..c696275 100644
--- a/lib/src/usage_impl_io.dart
+++ b/lib/src/usage_impl_io.dart
@@ -3,10 +3,11 @@
 // 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';
 
@@ -24,12 +25,12 @@
 class AnalyticsIO extends AnalyticsImpl {
   AnalyticsIO(
       String trackingId, String applicationName, String applicationVersion,
-      {String? analyticsUrl, Directory? documentDirectory})
+      {String? analyticsUrl, Directory? documentDirectory, HttpClient? client})
       : super(
             trackingId,
             IOPersistentProperties(applicationName,
                 documentDirPath: documentDirectory?.path),
-            IOPostHandler(),
+            IOPostHandler(client: client),
             applicationName: applicationName,
             applicationVersion: applicationVersion,
             analyticsUrl: analyticsUrl) {
@@ -40,7 +41,8 @@
   }
 }
 
-String _createUserAgent() {
+@visibleForTesting
+String createUserAgent() {
   final locale = getPlatformLocale() ?? '';
 
   if (Platform.isAndroid) {
@@ -74,24 +76,16 @@
 }
 
 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);
-
-    if (_client == null) {
-      _client = mockClient ?? HttpClient();
-      _client!.userAgent = _userAgent;
-    }
-
+  Future sendPost(String url, List<Map<String, dynamic>> batch) async {
+    var data = batch.map(postEncode).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 +96,7 @@
   }
 
   @override
-  void close() => _client?.close();
+  void close() => _client.close();
 }
 
 JsonEncoder _jsonEncoder = JsonEncoder.withIndent('  ');
diff --git a/lib/usage.dart b/lib/usage.dart
index cd548fe..d30b105 100644
--- a/lib/usage.dart
+++ b/lib/usage.dart
@@ -98,6 +98,15 @@
   Future sendTiming(String variableName, int time,
       {String? category, String? label});
 
+  /// All sendX calls sent from within [callback] will be enqueued and sent
+  /// in batches of {maxEventsPerBatch} messages at a time.
+  ///
+  /// Google analytics by default supports no more than 20 items per batch.
+  Future<T> withBatching<T>(
+    FutureOr<T> Function() callback, {
+    int maxEventsPerBatch = 20,
+  });
+
   /// Start a timer. The time won't be calculated, and the analytics information
   /// sent, until the [AnalyticsTimer.finish] method is called.
   AnalyticsTimer startTimer(String variableName,
@@ -283,6 +292,12 @@
 
     return Future.value();
   }
+
+  @override
+  Future<T> withBatching<T>(FutureOr<T> Function() callback) {
+    // TODO: implement withBatching
+    throw UnimplementedError();
+  }
 }
 
 /// Sanitize a stacktrace. This will shorten file paths in order to remove any
diff --git a/pubspec.yaml b/pubspec.yaml
index 9613c34..c26017a 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -14,5 +14,6 @@
   path: ^1.8.0-nullsafety
 
 dev_dependencies:
+  mockito: ^5.0.0-nullsafety.7
   pedantic: ^1.9.0
   test: ^1.16.0-nullsafety
diff --git a/test/src/common.dart b/test/src/common.dart
index 304faf9..ccebb72 100644
--- a/test/src/common.dart
+++ b/test/src/common.dart
@@ -50,8 +50,8 @@
   List<Map<String, dynamic>> sentValues = [];
 
   @override
-  Future sendPost(String url, Map<String, dynamic> parameters) {
-    sentValues.add(parameters);
+  Future sendPost(String url, List<Map<String, dynamic>> batch) {
+    sentValues.addAll(batch);
 
     return Future.value();
   }
diff --git a/test/usage_impl_io_test.dart b/test/usage_impl_io_test.dart
index 8b15606..c0a97f0 100644
--- a/test/usage_impl_io_test.dart
+++ b/test/usage_impl_io_test.dart
@@ -16,11 +16,17 @@
 void defineTests() {
   group('IOPostHandler', () {
     test('sendPost', () async {
-      var httpClient = MockHttpClient();
-      var postHandler = IOPostHandler(mockClient: httpClient);
-      var args = <String, dynamic>{'utv': 'varName', 'utt': 123};
+      var mockClient = MockHttpClient();
+
+      var postHandler = IOPostHandler(client: mockClient);
+      var args = [
+        <String, String>{'utv': 'varName', 'utt': '123'},
+      ];
       await postHandler.sendPost('http://www.google.com', args);
-      expect(httpClient.sendCount, 1);
+      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 +55,104 @@
       expect(getPlatformLocale(), isNotNull);
     });
   });
+
+  group('batching', () {
+    test('Without batching sends to regular url', () async {
+      final mockClient = MockHttpClient();
+
+      final analytics = AnalyticsIO(
+        '<TRACKING-ID',
+        'usage-test',
+        '0.0.1',
+        client: mockClient,
+      );
+      await analytics.sendEvent('my-event', 'something');
+      expect(mockClient.requests.single.buffer.toString(), '''
+Request to https://www.google-analytics.com/collect with ${createUserAgent()}
+ec=my-event&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 batching sends to batching url', () async {
+      var mockClient = MockHttpClient();
+
+      final analytics = AnalyticsIO('<TRACKING-ID', 'usage-test', '0.0.1',
+          client: mockClient);
+      await analytics.withBatching(() async {
+        await analytics.sendEvent('my-event1', 'something1');
+        await analytics.sendEvent('my-event2', 'something2');
+        await analytics.sendEvent('my-event3', 'something3');
+        await analytics.sendEvent('my-event4', 'something4');
+      }, maxEventsPerBatch: 3);
+      await analytics.sendEvent('my-event-not-batched', 'something');
+
+      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=something1&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=something2&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=something3&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/batch with ${createUserAgent()}
+ec=my-event4&ea=something4&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/web_test.dart b/test/web_test.dart
index eb0b107..2a544ba 100644
--- a/test/web_test.dart
+++ b/test/web_test.dart
@@ -32,7 +32,9 @@
     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);
       expect(client.sendCount, 1);