Handle batching implicit waiting for a 'batchDelay' future
diff --git a/lib/src/usage_impl.dart b/lib/src/usage_impl.dart
index 8adf980..83c8798 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';
 
@@ -78,6 +81,10 @@
   @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;
 
@@ -92,12 +99,14 @@
     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;
@@ -199,24 +208,6 @@
     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
   dynamic getSessionValue(String param) => _variableMap[param];
 
@@ -258,14 +249,16 @@
     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.
+  /// 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<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,
@@ -275,40 +268,51 @@
       't': hitType,
     };
 
-    final batch = <Map<String, String>>[];
+    _sendController.add(eventArgs);
 
-    // 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;
+    _batchedEvents.add(postHandler.encodeHit(eventArgs));
+
+    if (!_isAwaitingSending) {
+      final delay = _batchingDelay();
+      if (delay == null) {
+        _trySendBatches(completer);
+      } else {
+        _isAwaitingSending = true;
+        unawaited(delay.then((value) {
+          _trySendBatches(completer);
+          _isAwaitingSending = false;
+        }));
       }
-      // We have a full batch, start a new one.
-      batch.addAll(batchingQueue.takeBatch());
-    } else {
-      batch.add(eventArgs);
     }
-    _trySendBatch(batch);
+    return completer.future;
   }
 
-  void _trySendBatch(List<Map<String, String>> batch) {
-    if (_bucket.removeDrop()) {
-      for (final args in batch) {
-        _sendController.add(args);
-      }
+  // Send no more than 20 messages per batch.
+  static const _maxHitsPerBatch = 20;
+  // Send no more than 16K per batch.
+  static const _maxBytesPerBatch = 16000;
 
-      // 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));
+  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) {
@@ -348,26 +352,9 @@
 /// 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, List<Map<String, dynamic>> batch);
+  Future sendPost(String url, List<String> batch);
+  String encodeHit(Map<String, String> hit);
 
   /// 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() {
-    // TODO(sigurdm): We should take size limits on batching into account.
-    // https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide#batch-limitations
-    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 9ee8632..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,14 +52,15 @@
   HtmlPostHandler({this.mockRequestor});
 
   @override
-  Future sendPost(String url, List<Map<String, dynamic>> batch) {
+  String encodeHit(Map<String, String> hit) {
     var viewportWidth = document.documentElement!.clientWidth;
     var viewportHeight = document.documentElement!.clientHeight;
+    return postEncode({...hit, 'vp': '${viewportWidth}x$viewportHeight'});
+  }
 
-    var data = batch
-        .map((event) =>
-            postEncode({...event, 'vp': '${viewportWidth}x$viewportHeight'}))
-        .join('\n');
+  @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 c696275..fbe1017 100644
--- a/lib/src/usage_impl_io.dart
+++ b/lib/src/usage_impl_io.dart
@@ -14,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.
@@ -22,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, HttpClient? client})
-      : super(
-            trackingId,
-            IOPersistentProperties(applicationName,
-                documentDirPath: documentDirectory?.path),
-            IOPostHandler(client: client),
-            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);
@@ -82,8 +105,13 @@
       : _client = (client ?? HttpClient())..userAgent = createUserAgent();
 
   @override
-  Future sendPost(String url, List<Map<String, dynamic>> batch) async {
-    var data = batch.map(postEncode).join('\n');
+  String encodeHit(Map<String, String> hit) {
+    return postEncode(hit);
+  }
+
+  @override
+  Future sendPost(String url, List<String> batch) async {
+    var data = batch.join('\n');
     try {
       var req = await _client.postUrl(Uri.parse(url));
       req.write(data);
diff --git a/lib/usage.dart b/lib/usage.dart
index 5755b61..cd548fe 100644
--- a/lib/usage.dart
+++ b/lib/usage.dart
@@ -98,15 +98,6 @@
   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,
@@ -292,12 +283,6 @@
 
     return Future.value();
   }
-
-  @override
-  Future<T> withBatching<T>(FutureOr<T> Function() callback,
-      {int maxEventsPerBatch = 20}) async {
-    return await callback();
-  }
 }
 
 /// Sanitize a stacktrace. This will shorten file paths in order to remove any
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 ccebb72..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, List<Map<String, dynamic>> batch) {
+  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 c0a97f0..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';
 
@@ -22,7 +23,8 @@
       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(mockClient.requests.single.buffer.toString(), '''
 Request to http://www.google.com with ${createUserAgent()}
 utv=varName&utt=123''');
@@ -57,43 +59,88 @@
   });
 
   group('batching', () {
-    test('Without batching sends to regular url', () async {
+    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,
-      );
-      await analytics.sendEvent('my-event', 'something');
-      expect(mockClient.requests.single.buffer.toString(), '''
+      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-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''');
+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 batching sends to batching url', () async {
+    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);
-      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');
+      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=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''');
+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/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''');
+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''');
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 2a544ba..5aa87bc 100644
--- a/test/web_test.dart
+++ b/test/web_test.dart
@@ -36,7 +36,8 @@
         <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);
     });
   });