-
Notifications
You must be signed in to change notification settings - Fork 23
/
analytics.dart
857 lines (739 loc) · 27.7 KB
/
analytics.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
// Copyright (c) 2023, 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.
import 'dart:io' as io;
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:file/memory.dart';
import 'package:http/http.dart';
import 'package:meta/meta.dart';
import 'asserts.dart';
import 'config_handler.dart';
import 'constants.dart';
import 'enums.dart';
import 'event.dart';
import 'ga_client.dart';
import 'initializer.dart';
import 'log_handler.dart';
import 'survey_handler.dart';
import 'user_property.dart';
import 'utils.dart';
abstract class Analytics {
/// The default factory constructor that will return an implementation
/// of the [Analytics] abstract class using the [LocalFileSystem].
///
/// If [enableAsserts] is set to `true`, then asserts for GA4 limitations
/// will be enabled.
///
/// [flutterChannel] and [flutterVersion] are nullable in case the client
/// using this package is unable to resolve those values.
///
/// An optional parameter [clientIde] is also available for dart and flutter
/// tooling that are running from IDEs can be resolved. Such as "VSCode"
/// running the flutter-tool.
///
/// [enabledFeatures] is also an optional field that can be added to collect
/// any features that are enabled for a user. For example,
/// "enable-linux-desktop,cli-animations" are two features that can be enabled
/// for the flutter-tool.
factory Analytics({
required DashTool tool,
required String dartVersion,
String? flutterChannel,
String? flutterVersion,
String? clientIde,
String? enabledFeatures,
bool enableAsserts = false,
}) {
// Create the instance of the file system so clients don't need
// resolve on their own
const FileSystem fs = LocalFileSystem();
// Ensure that the home directory has permissions enabled to write
final homeDirectory = getHomeDirectory(fs);
if (homeDirectory == null ||
!checkDirectoryForWritePermissions(homeDirectory)) {
return const NoOpAnalytics();
}
// Resolve the OS using dart:io
final DevicePlatform platform;
if (io.Platform.operatingSystem == 'linux') {
platform = DevicePlatform.linux;
} else if (io.Platform.operatingSystem == 'macos') {
platform = DevicePlatform.macos;
} else {
platform = DevicePlatform.windows;
}
// Create the instance of the GA Client which will create
// an [http.Client] to send requests
final gaClient = GAClient(
measurementId: kGoogleAnalyticsMeasurementId,
apiSecret: kGoogleAnalyticsApiSecret,
);
final firstRun = runInitialization(homeDirectory: homeDirectory);
return AnalyticsImpl(
tool: tool,
homeDirectory: homeDirectory,
flutterChannel: flutterChannel,
flutterVersion: flutterVersion,
dartVersion: dartVersion,
platform: platform,
toolsMessageVersion: kToolsMessageVersion,
fs: fs,
gaClient: gaClient,
surveyHandler: SurveyHandler(
dismissedSurveyFile: homeDirectory
.childDirectory(kDartToolDirectoryName)
.childFile(kDismissedSurveyFileName),
),
enableAsserts: enableAsserts,
clientIde: clientIde,
enabledFeatures: enabledFeatures,
firstRun: firstRun,
);
}
/// Factory constructor to return the [AnalyticsImpl] class with
/// Google Analytics credentials that point to a test instance and
/// not the production instance where live data will be sent.
///
/// By default, [enableAsserts] is set to `true` to check against
/// GA4 limitations.
///
/// [flutterChannel] and [flutterVersion] are nullable in case the client
/// using this package is unable to resolve those values.
factory Analytics.development({
required DashTool tool,
required String dartVersion,
String? flutterChannel,
String? flutterVersion,
String? clientIde,
String? enabledFeatures,
bool enableAsserts = true,
}) {
// Create the instance of the file system so clients don't need
// resolve on their own
const FileSystem fs = LocalFileSystem();
// Ensure that the home directory has permissions enabled to write
final homeDirectory = getHomeDirectory(fs);
if (homeDirectory == null) {
throw Exception('Unable to determine the home directory, '
'ensure it is available in the environment');
}
if (!checkDirectoryForWritePermissions(homeDirectory)) {
throw Exception('Permissions error on the home directory!');
}
// Resolve the OS using dart:io
final DevicePlatform platform;
if (io.Platform.operatingSystem == 'linux') {
platform = DevicePlatform.linux;
} else if (io.Platform.operatingSystem == 'macos') {
platform = DevicePlatform.macos;
} else {
platform = DevicePlatform.windows;
}
// Credentials defined below for the test Google Analytics instance
const kTestMeasurementId = 'G-N1NXG28J5B';
const kTestApiSecret = '4yT8__oER3Cd84dtx6r-_A';
// Create the instance of the GA Client which will create
// an [http.Client] to send requests
final gaClient = GAClient(
measurementId: kTestMeasurementId,
apiSecret: kTestApiSecret,
);
final firstRun = runInitialization(homeDirectory: homeDirectory);
return AnalyticsImpl(
tool: tool,
homeDirectory: homeDirectory,
flutterChannel: flutterChannel,
flutterVersion: flutterVersion,
dartVersion: dartVersion,
platform: platform,
toolsMessageVersion: kToolsMessageVersion,
fs: fs,
gaClient: gaClient,
surveyHandler: SurveyHandler(
dismissedSurveyFile: homeDirectory
.childDirectory(kDartToolDirectoryName)
.childFile(kDismissedSurveyFileName),
),
enableAsserts: enableAsserts,
clientIde: clientIde,
enabledFeatures: enabledFeatures,
firstRun: firstRun,
);
}
/// The shared identifier for Flutter and Dart related tooling using
/// package:unified_analytics.
String get clientId;
/// Retrieves the consent message to prompt users with on first
/// run or when the message has been updated.
String get getConsentMessage;
/// Returns true if it is OK to send an analytics message. Do not cache,
/// as this depends on factors that can change, such as the configuration
/// file contents.
bool get okToSend;
/// Returns a map object with all of the tools that have been parsed
/// out of the configuration file.
Map<String, ToolInfo> get parsedTools;
/// Boolean that lets the client know if they should display the message.
bool get shouldShowMessage;
/// Boolean indicating whether or not telemetry is enabled.
bool get telemetryEnabled;
/// Returns a map representation of the [UserProperty] for the [Analytics]
/// instance.
///
/// This is what will get sent to Google Analytics with every request.
Map<String, Map<String, Object?>> get userPropertyMap;
/// Method to be invoked by the client using this package to confirm
/// that the client has shown the message and that it can be added to
/// the config file and start sending events the next time it starts up.
void clientShowedMessage();
/// Call this method when the tool using this package is closed.
///
/// Prevents the tool from hanging when if there are still requests
/// that need to be sent off.
///
/// Providing [delayDuration] in milliseconds will allow the instance
/// to wait the provided time before closing the http connection. Keeping
/// the connection open for some time will allow any pending events that
/// are waiting to be sent to the Google Analytics server. Default value
/// of 250 ms applied.
Future<void> close({int delayDuration = kDelayDuration});
/// Method to fetch surveys from the endpoint [kContextualSurveyUrl].
///
/// Any survey that is returned by this method has already passed
/// the survey conditions specified in the remote survey metadata file.
///
/// If the method returns an empty list, then there are no surveys to be
/// shared with the user.
Future<List<Survey>> fetchAvailableSurveys();
/// Query the persisted event data stored on the user's machine.
///
/// Returns null if there are no persisted logs.
LogFileStats? logFileStats();
/// Send preconfigured events using specific named constructors
/// on the [Event] class.
///
/// Example
/// ```dart
/// analytics.send(Event.memory(periodSec: 123));
/// ```
void send(Event event);
/// Pass a boolean to either enable or disable telemetry and make
/// the necessary changes in the persisted configuration file.
///
/// Setting the telemetry status will also send an event to GA
/// indicating the latest status of the telemetry from [reportingBool].
Future<void> setTelemetry(bool reportingBool);
/// Calling this will result in telemetry collection being suppressed for
/// the current invocation.
///
/// If you would like to permanently disable telemetry
/// collection use:
///
/// ```dart
/// analytics.setTelemetry(false)
/// ```
void suppressTelemetry();
/// Method to run after interacting with a [Survey] instance.
///
/// Pass a [Survey] instance which can be retrieved from
/// [Analytics.fetchAvailableSurveys].
///
/// [surveyButton] is the button that was interacted with by the user.
void surveyInteracted({
required Survey survey,
required SurveyButton surveyButton,
});
/// Method to be called after a survey has been shown to the user.
///
/// Calling this will snooze the survey so it won't be shown immediately.
///
/// The snooze period is defined by the [Survey.snoozeForMinutes] field.
void surveyShown(Survey survey);
/// Returns an instance of [FakeAnalytics] which can be used in tests to check
/// for certain [Event] instances within [FakeAnalytics.sentEvents].
@visibleForTesting
static FakeAnalytics fake({
required DashTool tool,
required Directory homeDirectory,
required String dartVersion,
required MemoryFileSystem fs,
String? flutterChannel,
String? flutterVersion,
String? clientIde,
String? enabledFeatures,
SurveyHandler? surveyHandler,
GAClient? gaClient,
DevicePlatform platform = DevicePlatform.linux,
int toolsMessageVersion = kToolsMessageVersion,
String toolsMessage = kToolsMessage,
bool enableAsserts = true,
}) {
final firstRun = runInitialization(homeDirectory: homeDirectory);
return FakeAnalytics._(
tool: tool,
homeDirectory: homeDirectory,
flutterChannel: flutterChannel,
toolsMessageVersion: toolsMessageVersion,
flutterVersion: flutterVersion,
dartVersion: dartVersion,
platform: platform,
fs: fs,
surveyHandler: surveyHandler ??
FakeSurveyHandler.fromList(
dismissedSurveyFile: homeDirectory
.childDirectory(kDartToolDirectoryName)
.childFile(kDismissedSurveyFileName),
initializedSurveys: [],
),
gaClient: gaClient ?? const FakeGAClient(),
clientIde: clientIde,
enabledFeatures: enabledFeatures,
firstRun: firstRun,
enableAsserts: enableAsserts,
);
}
}
class AnalyticsImpl implements Analytics {
final DashTool tool;
final FileSystem fs;
final int toolsMessageVersion;
final ConfigHandler _configHandler;
final GAClient _gaClient;
final SurveyHandler _surveyHandler;
final File _clientIdFile;
final UserProperty _userProperty;
final LogHandler _logHandler;
/// Tells the client if they need to show a message to the
/// user; this will return true if it is the first time the
/// package is being used for a developer or if the consent
/// message has been updated by the package.
bool _showMessage = false;
/// When set to `true`, various assert statements will be enabled
/// to ensure usage of this class is within GA4 limitations.
final bool _enableAsserts;
/// Telemetry suppression flag that is set via [Analytics.suppressTelemetry].
bool _telemetrySuppressed = false;
/// Indicates if this is the first run for a given tool.
bool _firstRun = false;
/// The list of futures that will contain all of the send events
/// from the [GAClient].
final _futures = <Future<Response>>[];
/// Internal value for the client id which will be lazily loaded.
String? _clientId;
/// Internal collection of [Event]s that have been sent
/// for errors encountered within package:unified_analytics.
///
/// Stores each of the events that have been sent to GA4 so that the
/// same error doesn't get sent twice.
final Set<Event> _sentErrorEvents = {};
AnalyticsImpl({
required this.tool,
required Directory homeDirectory,
required String? flutterChannel,
required String? flutterVersion,
required String? clientIde,
required String? enabledFeatures,
required String dartVersion,
required DevicePlatform platform,
required this.toolsMessageVersion,
required this.fs,
required GAClient gaClient,
required SurveyHandler surveyHandler,
required bool enableAsserts,
required bool firstRun,
}) : _gaClient = gaClient,
_surveyHandler = surveyHandler,
_enableAsserts = enableAsserts,
_clientIdFile = homeDirectory
.childDirectory(kDartToolDirectoryName)
.childFile(kClientIdFileName),
_userProperty = UserProperty(
sessionFile: homeDirectory
.childDirectory(kDartToolDirectoryName)
.childFile(kSessionFileName),
flutterChannel: flutterChannel,
host: platform.label,
flutterVersion: flutterVersion,
dartVersion: dartVersion,
tool: tool.label,
// We truncate this to a maximum of 36 characters since this can
// a very long string for some operating systems
hostOsVersion:
truncateStringToLength(io.Platform.operatingSystemVersion, 36),
locale: io.Platform.localeName,
clientIde: clientIde,
enabledFeatures: enabledFeatures,
),
_configHandler = ConfigHandler(
homeDirectory: homeDirectory,
configFile: homeDirectory
.childDirectory(kDartToolDirectoryName)
.childFile(kConfigFileName),
),
_logHandler = LogHandler(
logFile: homeDirectory
.childDirectory(kDartToolDirectoryName)
.childFile(kLogFileName),
) {
// This initializer class will let the instance know
// if it was the first run; if it is, nothing will be sent
// on the first run
if (firstRun) {
_showMessage = true;
_firstRun = true;
} else {
_showMessage = false;
_firstRun = false;
}
// Check if the tool has already been onboarded, and if it
// has, check if the latest message version is greater to
// prompt the client to show a message
//
// If the tool has not been added to the config file, then
// we will show the message as well
final currentVersion =
_configHandler.parsedTools[tool.label]?.versionNumber ?? -1;
if (currentVersion < toolsMessageVersion) {
_showMessage = true;
// If the message version has been updated, it will be considered
// as if it was a first run and any events attempting to get sent
// will be blocked
_firstRun = true;
}
}
@override
String get clientId {
if (!_clientIdFile.existsSync()) {
createClientIdFile(clientIdFile: _clientIdFile);
}
_clientId ??= _clientIdFile.readAsStringSync();
return _clientId!;
}
@override
String get getConsentMessage {
// The command to swap in the consent message
final commandString =
tool == DashTool.flutterTool || tool == DashTool.devtools
? 'flutter'
: 'dart';
return kToolsMessage
.replaceAll('{{ toolDescription }}', tool.description)
.replaceAll('{{ toolName }}', commandString);
}
/// Checking the [telemetryEnabled] boolean reflects what the
/// config file reflects.
///
/// Checking the [_showMessage] boolean indicates if the consent
/// message has been shown for the user, this boolean is set to `true`
/// when the tool using this package invokes the [clientShowedMessage]
/// method.
///
/// If the user has suppressed telemetry [_telemetrySuppressed] will
/// return `true` to prevent events from being sent for current invocation.
///
/// Checking if it is the first time a tool is running with this package
/// as indicated by [_firstRun].
@override
bool get okToSend =>
telemetryEnabled && !_showMessage && !_telemetrySuppressed && !_firstRun;
@override
Map<String, ToolInfo> get parsedTools => _configHandler.parsedTools;
@override
bool get shouldShowMessage => _showMessage;
@override
bool get telemetryEnabled => _configHandler.telemetryEnabled;
@override
Map<String, Map<String, Object?>> get userPropertyMap =>
_userProperty.preparePayload();
@override
void clientShowedMessage() {
// Check the tool needs to be added to the config file
if (!_configHandler.parsedTools.containsKey(tool.label)) {
_configHandler.addTool(
tool: tool.label,
versionNumber: toolsMessageVersion,
);
}
// When the tool already exists but the consent message version
// has been updated
if (_configHandler.parsedTools[tool.label]!.versionNumber <
toolsMessageVersion) {
_configHandler.incrementToolVersion(
tool: tool.label,
newVersionNumber: toolsMessageVersion,
);
}
_showMessage = false;
}
@override
Future<void> close({int delayDuration = kDelayDuration}) async {
// Collect any errors encountered and send
_sendPendingErrorEvents();
await Future.wait(_futures).timeout(
Duration(milliseconds: delayDuration),
onTimeout: () => [],
);
_gaClient.close();
}
@override
Future<List<Survey>> fetchAvailableSurveys() async {
final surveysToShow = <Survey>[];
if (!okToSend) return surveysToShow;
final logFileStats = _logHandler.logFileStats();
// Call for surveys that have already been dismissed from
// persisted survey ids on disk
final persistedSurveyMap = _surveyHandler.fetchPersistedSurveys();
for (final survey in await _surveyHandler.fetchSurveyList()) {
// If the survey has listed the tool running this package in the exclude
// list, it will not be returned
if (survey.excludeDashToolList.contains(tool)) continue;
// Apply the survey's sample rate; if the generated value from
// the client id and survey's uniqueId are less, it will not get
// sent to the user
if (survey.samplingRate < sampleRate(clientId, survey.uniqueId)) {
continue;
}
// If the survey has been permanently dismissed or has temporarily
// been snoozed, skip it
if (surveySnoozedOrDismissed(survey, persistedSurveyMap)) continue;
// Counter to check each survey condition, if all are met, then
// this integer will be equal to the number of conditions in
// [Survey.conditionList]
var conditionsMet = 0;
if (logFileStats != null) {
for (final condition in survey.conditionList) {
// Retrieve the value from the [LogFileStats] with
// the label provided in the condtion
final logFileStatsValue =
logFileStats.getValueByString(condition.field);
if (logFileStatsValue == null) continue;
switch (condition.operatorString) {
case '>=':
if (logFileStatsValue >= condition.value) conditionsMet++;
case '<=':
if (logFileStatsValue <= condition.value) conditionsMet++;
case '>':
if (logFileStatsValue > condition.value) conditionsMet++;
case '<':
if (logFileStatsValue < condition.value) conditionsMet++;
case '==':
if (logFileStatsValue == condition.value) conditionsMet++;
case '!=':
if (logFileStatsValue != condition.value) conditionsMet++;
}
}
}
if (conditionsMet == survey.conditionList.length) {
surveysToShow.add(survey);
}
}
return surveysToShow;
}
@override
LogFileStats? logFileStats() => _logHandler.logFileStats();
@override
void send(Event event) {
if (!okToSend) return;
// Construct the body of the request
final body = generateRequestBody(
clientId: clientId,
eventName: event.eventName,
eventData: event.eventData,
userProperty: _userProperty,
);
if (_enableAsserts) checkBody(body);
_logHandler.save(data: body);
final gaClientFuture = _gaClient.sendData(body);
_futures.add(gaClientFuture);
gaClientFuture.whenComplete(() => _futures.remove(gaClientFuture));
}
@override
Future<void> setTelemetry(bool reportingBool) {
_configHandler.setTelemetry(reportingBool);
// Creation of the [Event] for opting out
final collectionEvent =
Event.analyticsCollectionEnabled(status: reportingBool);
// The body of the request that will be sent to GA4
final Map<String, Object?> body;
if (reportingBool) {
// Recreate the session and client id file; no need to
// recreate the log file since it will only receives events
// to persist from events sent
createClientIdFile(clientIdFile: _clientIdFile);
createSessionFile(sessionFile: _userProperty.sessionFile);
// Reread the client ID string so an empty string is not being
// sent to GA4 since the persisted files are cleared when a user
// decides to opt out of telemetry collection
_clientId = _clientIdFile.readAsStringSync();
// We must construct the body at this point after we have read in the
// new client id string that was generated
body = generateRequestBody(
clientId: clientId,
eventName: collectionEvent.eventName,
eventData: collectionEvent.eventData,
userProperty: _userProperty,
);
_logHandler.save(data: body);
} else {
// Construct the body of the request to signal
// telemetry status toggling
body = generateRequestBody(
clientId: clientId,
eventName: collectionEvent.eventName,
eventData: collectionEvent.eventData,
userProperty: _userProperty,
);
// For opted out users, data in the persisted files is cleared
_userProperty.sessionFile.writeAsStringSync('');
_logHandler.logFile.writeAsStringSync('');
_clientIdFile.writeAsStringSync('');
_clientId = '';
}
// Pass to the google analytics client to send with a
// timeout incase http clients hang
return _gaClient.sendData(body).timeout(
const Duration(milliseconds: kDelayDuration),
onTimeout: () => Response('', 200),
);
}
@override
void suppressTelemetry() => _telemetrySuppressed = true;
@override
void surveyInteracted({
required Survey survey,
required SurveyButton surveyButton,
}) {
// Any action, except for 'snooze' will permanently dismiss a given survey
final permanentlyDismissed = surveyButton.action == 'snooze' ? false : true;
_surveyHandler.dismiss(survey, permanentlyDismissed);
send(Event.surveyAction(
surveyId: survey.uniqueId,
status: surveyButton.action,
));
}
@override
void surveyShown(Survey survey) {
_surveyHandler.dismiss(survey, false);
send(Event.surveyShown(surveyId: survey.uniqueId));
}
/// Send any pending error events, useful for tests to avoid closing
/// the connection.
///
/// In the main implementation, [AnalyticsImpl], error events are only
/// sent on exit when [close] is invoked. This helper method can instead
/// have those error events sent immediately to help with tests that check
/// [FakeAnalytics.sentEvents].
void _sendPendingErrorEvents() {
// Collect any errors encountered and send
final errorEvents = {..._userProperty.errorSet, ..._logHandler.errorSet};
errorEvents
.where((event) =>
event.eventName == DashEvent.analyticsException &&
!_sentErrorEvents.contains(event))
.forEach(send);
// Ensure the same event doesn't get sent again
_sentErrorEvents.addAll(errorEvents);
// Clear error sets
_userProperty.errorSet.clear();
_logHandler.errorSet.clear();
}
}
/// This fake instance of [Analytics] is intended to be used by clients of
/// this package for testing purposes. It exposes a list [sentEvents] that
/// keeps track of all events that have been sent.
///
/// This is useful for confirming that events are being sent for a given
/// workflow. Invoking the [send] method on this instance will not make any
/// network requests to Google Analytics.
class FakeAnalytics extends AnalyticsImpl {
/// Use this list to check for events that have been emitted when
/// invoking the send method
final List<Event> sentEvents = [];
/// Class to use when you want to see which events were sent
FakeAnalytics._({
required super.tool,
required super.homeDirectory,
required super.dartVersion,
required super.platform,
required super.fs,
required super.surveyHandler,
required super.firstRun,
super.flutterChannel,
super.flutterVersion,
super.clientIde,
super.enabledFeatures,
super.toolsMessageVersion = kToolsMessageVersion,
super.gaClient = const FakeGAClient(),
super.enableAsserts = true,
});
/// Getter to reference the private [UserProperty].
UserProperty get userProperty => _userProperty;
@override
void send(Event event) {
if (!okToSend) return;
// Construct the body of the request
final body = generateRequestBody(
clientId: clientId,
eventName: event.eventName,
eventData: event.eventData,
userProperty: _userProperty,
);
if (_enableAsserts) checkBody(body);
_logHandler.save(data: body);
// Using this list to validate that events are being sent
// for internal methods in the `Analytics` instance
sentEvents.add(event);
}
/// Public instance method to invoke private method that sends any
/// pending error events.
///
/// If this is never invoked, any pending error events will be sent
/// when invoking the [close] method.
void sendPendingErrorEvents() => _sendPendingErrorEvents();
}
/// An implementation that will never send events.
///
/// This is for clients that opt to either not send analytics, or will migrate
/// to use [AnalyticsImpl] at a later time.
class NoOpAnalytics implements Analytics {
/// The hard-coded client ID value for each NoOp instance.
static String get staticClientId => 'xxxx-xxxx';
@override
final String getConsentMessage = '';
@override
final bool okToSend = false;
@override
final Map<String, ToolInfo> parsedTools = const <String, ToolInfo>{};
@override
final bool shouldShowMessage = false;
@override
final bool telemetryEnabled = false;
@override
final Map<String, Map<String, Object?>> userPropertyMap =
const <String, Map<String, Object?>>{};
const NoOpAnalytics();
@override
String get clientId => staticClientId;
@override
void clientShowedMessage() {}
@override
Future<void> close({int delayDuration = kDelayDuration}) async {}
@override
Future<List<Survey>> fetchAvailableSurveys() async => const <Survey>[];
@override
LogFileStats? logFileStats() => null;
@override
Future<Response>? send(Event event) => null;
@override
Future<void> setTelemetry(bool reportingBool) async {}
@override
void suppressTelemetry() {}
@override
void surveyInteracted({
required Survey survey,
required SurveyButton surveyButton,
}) {}
@override
void surveyShown(Survey survey) {}
}