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);
});
});