diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 20aa3a6..87a1201 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml
@@ -18,15 +18,7 @@ steps: - uses: actions/checkout@v2 - - - name: pub get - run: pub get - - - name: dart format - run: dart format --output=none --set-exit-if-changed . - - - name: dart analyze - run: dart analyze - - - name: dart test - run: dart test + - run: dart pub get + - run: dart format --output=none --set-exit-if-changed . + - run: dart analyze + - run: dart test
diff --git a/CHANGELOG.md b/CHANGELOG.md index 87671bb..d768748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md
@@ -1,3 +1,7 @@ +## 4.1.0 +- Analytics hits can now be batched. See details in the documentation of the + `AnalyticsIO` constructor. + ## 4.0.2 - Fix a bug with the analytics ping throttling algorithm.
diff --git a/LICENSE b/LICENSE index 5c60afe..000cd7b 100644 --- a/LICENSE +++ b/LICENSE
@@ -1,4 +1,5 @@ -Copyright 2014, the Dart project authors. All rights reserved. +Copyright 2014, the Dart project authors. + Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: @@ -9,7 +10,7 @@ copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - * Neither the name of Google Inc. nor the names of its + * Neither the name of Google LLC nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
diff --git a/README.md b/README.md index 3453c28..a334d76 100644 --- a/README.md +++ b/README.md
@@ -2,7 +2,6 @@ [![pub package](https://img.shields.io/pub/v/usage.svg)](https://pub.dev/packages/usage) [![Build Status](https://github.com/dart-lang/usage/workflows/Dart/badge.svg)](https://github.com/dart-lang/usage/actions) -[![Coverage Status](https://img.shields.io/coveralls/dart-lang/usage.svg)](https://coveralls.io/r/dart-lang/usage?branch=master) ## For web apps
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..870d123 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,30 @@ @override AnalyticsOpt analyticsOpt = AnalyticsOpt.optOut; - late final String _url; + final Duration? _batchingDelay; + final Queue<String> _batchedEvents = Queue<String>(); + bool _isSendingScheduled = false; + + final String _url; + 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, + }) : _url = analyticsUrl ?? _defaultAnalyticsUrl, + _batchingDelay = batchingDelay, + _batchingUrl = analyticsBatchingUrl ?? _defaultAnalyticsBatchingUrl { if (applicationName != null) setSessionValue('an', applicationName); if (applicationVersion != null) setSessionValue('av', applicationVersion); - - _url = analyticsUrl ?? _defaultAnalyticsUrl; } bool? _firstRun; @@ -118,38 +135,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 +197,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 +220,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, onTimeout: () => []); return f; } @@ -220,35 +242,89 @@ /// 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); + 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, dynamic> 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)); - args['v'] = '1'; // protocol version - args['tid'] = trackingId; - args['cid'] = clientId; - args['t'] = hitType; - - _sendController.add(args); - - return _recordFuture(postHandler.sendPost(_url, args)); + // If [_batchingDelay] is null we don't do batching. + // TODO(sigurdm): reconsider this. + final batchingDelay = _batchingDelay; + if (batchingDelay == null) { + _trySendBatches(completer); } else { - return Future.value(); + // 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); + }); + } } + 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 +359,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..59843b0 100644 --- a/lib/src/usage_impl_html.dart +++ b/lib/src/usage_impl_html.dart
@@ -12,15 +12,31 @@ /// /// [analyticsUrl] is an optional replacement for the default Google Analytics /// URL (`https://www.google-analytics.com/collect`). +/// +/// [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. +/// +/// If [batchingDelay] is `Duration()` messages will be sent when control +/// returns to the event loop. +/// +/// 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 +55,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..4d770ee 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,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. Events will be sent +/// batches of 20 after the duration is over from when the first message was +/// sent. +/// +/// If [batchingDelay] is `Duration()` 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 +64,8 @@ } } -String _createUserAgent() { +@visibleForTesting +String createUserAgent() { final locale = getPlatformLocale() ?? ''; if (Platform.isAndroid) { @@ -74,28 +99,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 +127,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/pubspec.yaml b/pubspec.yaml index a70557a..8afac29 100644 --- a/pubspec.yaml +++ b/pubspec.yaml
@@ -3,14 +3,15 @@ # BSD-style license that can be found in the LICENSE file. name: usage -version: 4.0.2 +version: 4.1.0 description: A Google Analytics wrapper for command-line, web, and Flutter apps. homepage: https://github.com/dart-lang/usage environment: - sdk: '>=2.12.0-0 <3.0.0' + sdk: '>=2.12.0 <3.0.0' dependencies: + meta: ^1.7.0 path: ^1.8.0 dev_dependencies:
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..b08d89b 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,24 @@ : super(trackingId, MockProperties(props), MockPostHandler(), applicationName: 'Test App', applicationVersion: '0.1'); - Map<String, dynamic> get last => mockPostHandler.last; + String get last => mockPostHandler.last; +} + +class StallingAnalyticsImplMock extends AnalyticsImpl { + StallingAnalyticsImplMock(String trackingId, {Map<String, dynamic>? props}) + : super(trackingId, MockProperties(props), StallingPostHandler(), + applicationName: 'Test App', applicationVersion: '0.1'); +} + +class StallingPostHandler extends PostHandler { + @override + void close() {} + + @override + String encodeHit(Map<String, String> hit) => jsonEncode(hit); + + @override + Future sendPost(String url, List<String> batch) => Completer().future; } class MockProperties extends PersistentProperties { @@ -47,17 +65,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..c363284 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,143 @@ expect(getPlatformLocale(), isNotNull); }); }); + + group('batching', () { + test('with 0 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, batchingDelay: Duration()); + 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..8b2739a 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'); }); @@ -90,6 +90,13 @@ return mock.waitForLastPing(timeout: Duration(milliseconds: 100)); }); + test('waitForLastPing times out', () async { + var mock = StallingAnalyticsImplMock('blahID'); + // ignore: unawaited_futures + mock.sendScreenView('foo'); + await mock.waitForLastPing(timeout: Duration(milliseconds: 100)); + }); + group('clientId', () { test('is available immediately', () { var mock = createMock();
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); }); });
diff --git a/tool/travis.sh b/tool/travis.sh deleted file mode 100755 index ce0f993..0000000 --- a/tool/travis.sh +++ /dev/null
@@ -1,27 +0,0 @@ -#!/bin/bash - -# Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file -# for details. All rights reserved. Use of this source code is governed by a -# BSD-style license that can be found in the LICENSE file. - -# Fast fail the script on failures. -set -e - -# Verify that the libraries are error free. -dartanalyzer --fatal-warnings . - -# Run the tests. -dart --enable-asserts test/all.dart - -# Measure the size of the compiled JS, for the dart:html version of the library. -dart tool/grind.dart build - -# Install dart_coveralls; gather and send coverage data. -if [ "$REPO_TOKEN" ]; then - pub global activate dart_coveralls - pub global run dart_coveralls report \ - --token $REPO_TOKEN \ - --retry 2 \ - --exclude-test-files \ - test/all.dart -fi