usage.dart 16.3 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
part of 'reporting.dart';
6

7
const String _kFlutterUA = 'UA-67589403-6';
8

9
abstract class Usage {
10 11
  /// Create a new Usage instance; [versionOverride], [configDirOverride], and
  /// [logFile] are used for testing.
12 13
  factory Usage({
    String settingsName = 'flutter',
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
    String? versionOverride,
    String? configDirOverride,
    String? logFile,
    AnalyticsFactory? analyticsIOFactory,
    FirstRunMessenger? firstRunMessenger,
    required bool runningOnBot,
  }) =>
      _DefaultUsage.initialize(
        settingsName: settingsName,
        versionOverride: versionOverride,
        configDirOverride: configDirOverride,
        logFile: logFile,
        analyticsIOFactory: analyticsIOFactory,
        runningOnBot: runningOnBot,
        firstRunMessenger: firstRunMessenger,
      );
30

31 32
  /// Uses the global [Usage] instance to send a 'command' to analytics.
  static void command(String command, {
33 34
    CustomDimensions? parameters,
  }) => globals.flutterUsage.sendCommand(command, parameters: parameters);
35

36
  /// Whether analytics reporting should be suppressed.
37
  bool get suppressAnalytics;
38

39 40
  /// Suppress analytics for this session.
  set suppressAnalytics(bool value);
41

42 43
  /// Whether analytics reporting is enabled.
  bool get enabled;
44

45 46 47 48 49 50 51 52 53
  /// Enable or disable reporting analytics.
  set enabled(bool value);

  /// A stable randomly generated UUID used to deduplicate multiple identical
  /// reports coming from the same computer.
  String get clientId;

  /// Sends a 'command' to the underlying analytics implementation.
  ///
54
  /// Using [command] above is preferred to ensure that the parameter
55
  /// keys are well-defined in [CustomDimensions] above.
56 57
  void sendCommand(
    String command, {
58
    CustomDimensions? parameters,
59 60 61 62
  });

  /// Sends an 'event' to the underlying analytics implementation.
  ///
63 64
  /// This method should not be used directly, instead see the
  /// event types defined in this directory in `events.dart`.
65 66
  @visibleForOverriding
  @visibleForTesting
67 68 69
  void sendEvent(
    String category,
    String parameter, {
70 71
    String? label,
    int? value,
72
    CustomDimensions? parameters,
73 74 75
  });

  /// Sends timing information to the underlying analytics implementation.
76 77 78 79
  void sendTiming(
    String category,
    String variableName,
    Duration duration, {
80
    String? label,
81 82 83 84
  });

  /// Sends an exception to the underlying analytics implementation.
  void sendException(dynamic exception);
85

86 87 88 89 90 91 92 93 94 95 96 97 98
  /// Fires whenever analytics data is sent over the network.
  @visibleForTesting
  Stream<Map<String, dynamic>> get onSend;

  /// Returns when the last analytics event has been sent, or after a fixed
  /// (short) delay, whichever is less.
  Future<void> ensureAnalyticsSent();

  /// Prints a welcome message that informs the tool user about the collection
  /// of anonymous usage information.
  void printWelcome();
}

99 100 101 102 103
typedef AnalyticsFactory = Analytics Function(
  String trackingId,
  String applicationName,
  String applicationVersion, {
  String analyticsUrl,
104
  Directory? documentDirectory,
105 106 107 108 109 110
});

Analytics _defaultAnalyticsIOFactory(
  String trackingId,
  String applicationName,
  String applicationVersion, {
111 112
  String? analyticsUrl,
  Directory? documentDirectory,
113 114 115 116 117 118 119 120 121 122
}) {
  return AnalyticsIO(
    trackingId,
    applicationName,
    applicationVersion,
    analyticsUrl: analyticsUrl,
    documentDirectory: documentDirectory,
  );
}

123
class _DefaultUsage implements Usage {
124 125 126 127 128 129 130 131 132 133
  _DefaultUsage._({
    required bool suppressAnalytics,
    required Analytics analytics,
    required this.firstRunMessenger,
    required SystemClock clock,
  })  : _suppressAnalytics = suppressAnalytics,
        _analytics = analytics,
        _clock = clock;

  static _DefaultUsage initialize({
134
    String settingsName = 'flutter',
135 136 137 138 139 140
    String? versionOverride,
    String? configDirOverride,
    String? logFile,
    AnalyticsFactory? analyticsIOFactory,
    required FirstRunMessenger? firstRunMessenger,
    required bool runningOnBot,
141
  }) {
142
    final FlutterVersion flutterVersion = globals.flutterVersion;
143
    final String version = versionOverride ?? flutterVersion.getVersionString(redactUnknownBranches: true);
144
    final bool suppressEnvFlag = globals.platform.environment['FLUTTER_SUPPRESS_ANALYTICS'] == 'true';
145
    final String? logFilePath = logFile ?? globals.platform.environment['FLUTTER_ANALYTICS_LOG_FILE'];
146 147
    final bool usingLogFile = logFilePath != null && logFilePath.isNotEmpty;

148 149 150 151
    final AnalyticsFactory analyticsFactory = analyticsIOFactory ?? _defaultAnalyticsIOFactory;
    bool suppressAnalytics = false;
    bool skipAnalyticsSessionSetup = false;
    Analytics? setupAnalytics;
152
    if (// To support testing, only allow other signals to suppress analytics
153 154 155 156 157 158 159
        // when analytics are not being shunted to a file.
        !usingLogFile && (
        // Ignore local user branches.
        version.startsWith('[user-branch]') ||
        // Many CI systems don't do a full git checkout.
        version.endsWith('/unknown') ||
        // Ignore bots.
160
        runningOnBot ||
161 162
        // Ignore when suppressed by FLUTTER_SUPPRESS_ANALYTICS.
        suppressEnvFlag
163
    )) {
164 165
      // If we think we're running on a CI system, suppress sending analytics.
      suppressAnalytics = true;
166 167
      setupAnalytics = AnalyticsMock();
      skipAnalyticsSessionSetup = true;
168 169
    }
    if (usingLogFile) {
170
      setupAnalytics ??= LogToFileAnalytics(logFilePath);
171
    } else {
172
      try {
173
        ErrorHandlingFileSystem.noExitOnFailure(() {
174
          setupAnalytics = analyticsFactory(
175 176 177 178
            _kFlutterUA,
            settingsName,
            version,
            documentDirectory: configDirOverride != null
179 180
                ? globals.fs.directory(configDirOverride)
                : null,
181 182
          );
        });
183 184 185
      } on Exception catch (e) {
        globals.printTrace('Failed to initialize analytics reporting: $e');
        suppressAnalytics = true;
186 187
        setupAnalytics ??= AnalyticsMock();
        skipAnalyticsSessionSetup = true;
188
      }
189
    }
190

191 192 193 194
    final Analytics analytics = setupAnalytics!;
    if (!skipAnalyticsSessionSetup) {
      // Report a more detailed OS version string than package:usage does by default.
      analytics.setSessionValue(
195
        CustomDimensionsEnum.sessionHostOsDetails.cdKey,
196 197 198 199
        globals.os.name,
      );
      // Send the branch name as the "channel".
      analytics.setSessionValue(
200
        CustomDimensionsEnum.sessionChannelName.cdKey,
201 202 203 204 205 206 207 208
        flutterVersion.getBranchName(redactUnknownBranches: true),
      );
      // For each flutter experimental feature, record a session value in a comma
      // separated list.
      final String enabledFeatures = allFeatures
          .where((Feature feature) {
        final String? configSetting = feature.configSetting;
        return configSetting != null && globals.config.getValue(configSetting) == true;
209
      })
210 211 212
          .map((Feature feature) => feature.configSetting)
          .join(',');
      analytics.setSessionValue(
213
        CustomDimensionsEnum.enabledFlutterFeatures.cdKey,
214 215 216 217 218 219 220 221
        enabledFeatures,
      );

      // Record the host as the application installer ID - the context that flutter_tools is running in.
      if (globals.platform.environment.containsKey('FLUTTER_HOST')) {
        analytics.setSessionValue('aiid', globals.platform.environment['FLUTTER_HOST']);
      }
      analytics.analyticsOpt = AnalyticsOpt.optOut;
222
    }
223 224 225 226 227 228 229

    return _DefaultUsage._(
      suppressAnalytics: suppressAnalytics,
      analytics: analytics,
      firstRunMessenger: firstRunMessenger,
      clock: globals.systemClock,
    );
230 231
  }

232 233
  final Analytics _analytics;
  final FirstRunMessenger? firstRunMessenger;
234

235
  bool _printedWelcome = false;
236
  bool _suppressAnalytics = false;
237
  final SystemClock _clock;
238

239
  @override
240 241
  bool get suppressAnalytics => _suppressAnalytics || _analytics.firstRun;

242
  @override
243 244 245 246
  set suppressAnalytics(bool value) {
    _suppressAnalytics = value;
  }

247 248 249 250
  @override
  bool get enabled => _analytics.enabled;

  @override
251 252 253 254
  set enabled(bool value) {
    _analytics.enabled = value;
  }

255
  @override
256 257
  String get clientId => _analytics.clientId;

258
  @override
259
  void sendCommand(String command, { CustomDimensions? parameters }) {
260
    if (suppressAnalytics) {
261
      return;
262
    }
263

264 265 266 267 268 269
    _analytics.sendScreenView(
      command,
      parameters: CustomDimensions(localTime: formatDateTime(_clock.now()))
          .merge(parameters)
          .toMap(),
    );
270 271
  }

272
  @override
273 274 275
  void sendEvent(
    String category,
    String parameter, {
276 277
    String? label,
    int? value,
278
    CustomDimensions? parameters,
279
  }) {
280
    if (suppressAnalytics) {
281
      return;
282
    }
283

284 285 286 287
    _analytics.sendEvent(
      category,
      parameter,
      label: label,
288
      value: value,
289 290 291
      parameters: CustomDimensions(localTime: formatDateTime(_clock.now()))
          .merge(parameters)
          .toMap(),
292
    );
293 294
  }

295
  @override
296
  void sendTiming(
297 298
    String category,
    String variableName,
299
    Duration duration, {
300
    String? label,
301
  }) {
302 303
    if (suppressAnalytics) {
      return;
304
    }
305 306 307 308 309 310
    _analytics.sendTiming(
      variableName,
      duration.inMilliseconds,
      category: category,
      label: label,
    );
311 312
  }

313
  @override
314
  void sendException(dynamic exception) {
315 316 317 318
    if (suppressAnalytics) {
      return;
    }
    _analytics.sendException(exception.runtimeType.toString());
319 320
  }

321
  @override
322 323
  Stream<Map<String, dynamic>> get onSend => _analytics.onSend;

324
  @override
325
  Future<void> ensureAnalyticsSent() async {
326 327 328
    // TODO(devoncarew): This may delay tool exit and could cause some analytics
    // events to not be reported. Perhaps we could send the analytics pings
    // out-of-process from flutter_tools?
329
    await _analytics.waitForLastPing(timeout: const Duration(milliseconds: 250));
330
  }
331

332 333 334 335 336 337
  @override
  void printWelcome() {
    // Only print once per run.
    if (_printedWelcome) {
      return;
    }
338 339
    // Display the welcome message if this is the first run of the tool or if
    // the license terms have changed since it was last displayed.
340 341
    final FirstRunMessenger? messenger = firstRunMessenger;
    if (messenger != null && messenger.shouldDisplayLicenseTerms()) {
342
      globals.printStatus('');
343
      globals.printStatus(messenger.licenseTerms, emphasis: true);
344
      _printedWelcome = true;
345
      messenger.confirmLicenseTermsDisplayed();
346 347
    }
  }
348
}
349 350 351 352 353 354

// An Analytics mock that logs to file. Unimplemented methods goes to stdout.
// But stdout can't be used for testing since wrapper scripts like
// xcode_backend.sh etc manipulates them.
class LogToFileAnalytics extends AnalyticsMock {
  LogToFileAnalytics(String logFilePath) :
355
    logFile = globals.fs.file(logFilePath)..createSync(recursive: true),
356 357 358
    super(true);

  final File logFile;
359
  final Map<String, String> _sessionValues = <String, String>{};
360

361 362 363 364 365 366
  final StreamController<Map<String, dynamic>> _sendController =
        StreamController<Map<String, dynamic>>.broadcast(sync: true);

  @override
  Stream<Map<String, dynamic>> get onSend => _sendController.stream;

367
  @override
368
  Future<void> sendScreenView(String viewName, {
369
    Map<String, String>? parameters,
370
  }) {
371
    if (!enabled) {
372
      return Future<void>.value();
373
    }
374 375
    parameters ??= <String, String>{};
    parameters['viewName'] = viewName;
376
    parameters.addAll(_sessionValues);
377
    _sendController.add(parameters);
378
    logFile.writeAsStringSync('screenView $parameters\n', mode: FileMode.append);
379
    return Future<void>.value();
380 381 382 383
  }

  @override
  Future<void> sendEvent(String category, String action,
384
      {String? label, int? value, Map<String, String>? parameters}) {
385
    if (!enabled) {
386
      return Future<void>.value();
387
    }
388 389 390
    parameters ??= <String, String>{};
    parameters['category'] = category;
    parameters['action'] = action;
391
    _sendController.add(parameters);
392
    logFile.writeAsStringSync('event $parameters\n', mode: FileMode.append);
393
    return Future<void>.value();
394
  }
395

396 397
  @override
  Future<void> sendTiming(String variableName, int time,
398
      {String? category, String? label}) {
399
    if (!enabled) {
400
      return Future<void>.value();
401 402 403 404 405 406 407 408 409
    }
    final Map<String, String> parameters = <String, String>{
      'variableName': variableName,
      'time': '$time',
      if (category != null) 'category': category,
      if (label != null) 'label': label,
    };
    _sendController.add(parameters);
    logFile.writeAsStringSync('timing $parameters\n', mode: FileMode.append);
410
    return Future<void>.value();
411 412
  }

413 414 415 416
  @override
  void setSessionValue(String param, dynamic value) {
    _sessionValues[param] = value.toString();
  }
417
}
418 419 420 421 422 423 424 425 426 427 428 429


/// Create a testing Usage instance.
///
/// All sent events, exceptions, timings, and pages are
/// buffered on the object and can be inspected later.
@visibleForTesting
class TestUsage implements Usage {
  final List<TestUsageCommand> commands = <TestUsageCommand>[];
  final List<TestUsageEvent> events = <TestUsageEvent>[];
  final List<dynamic> exceptions = <dynamic>[];
  final List<TestTimingEvent> timings = <TestTimingEvent>[];
430
  int ensureAnalyticsSentCalls = 0;
431 432 433 434 435 436 437 438 439 440 441

  @override
  bool enabled = true;

  @override
  bool suppressAnalytics = false;

  @override
  String get clientId => 'test-client';

  @override
442 443
  Future<void> ensureAnalyticsSent() async {
    ensureAnalyticsSentCalls++;
444 445 446 447 448 449 450 451 452
  }

  @override
  Stream<Map<String, dynamic>> get onSend => throw UnimplementedError();

  @override
  void printWelcome() { }

  @override
453
  void sendCommand(String command, {CustomDimensions? parameters}) {
454 455 456 457
    commands.add(TestUsageCommand(command, parameters: parameters));
  }

  @override
458
  void sendEvent(String category, String parameter, {String? label, int? value, CustomDimensions? parameters}) {
459 460 461 462 463 464 465 466 467
    events.add(TestUsageEvent(category, parameter, label: label, value: value, parameters: parameters));
  }

  @override
  void sendException(dynamic exception) {
    exceptions.add(exception);
  }

  @override
468
  void sendTiming(String category, String variableName, Duration duration, {String? label}) {
469 470 471 472 473 474 475 476 477 478
    timings.add(TestTimingEvent(category, variableName, duration, label: label));
  }
}

@visibleForTesting
@immutable
class TestUsageCommand {
  const TestUsageCommand(this.command, {this.parameters});

  final String command;
479
  final CustomDimensions? parameters;
480 481 482 483 484

  @override
  bool operator ==(Object other) {
    return other is TestUsageCommand &&
      other.command == command &&
485
      other.parameters == parameters;
486 487 488
  }

  @override
489
  int get hashCode => Object.hash(command, parameters);
490 491 492 493 494 495 496 497 498 499 500 501

  @override
  String toString() => 'TestUsageCommand($command, parameters:$parameters)';
}

@visibleForTesting
@immutable
class TestUsageEvent {
  const TestUsageEvent(this.category, this.parameter, {this.label, this.value, this.parameters});

  final String category;
  final String parameter;
502 503
  final String? label;
  final int? value;
504
  final CustomDimensions? parameters;
505 506 507 508 509 510 511 512

  @override
  bool operator ==(Object other) {
    return other is TestUsageEvent &&
      other.category == category &&
      other.parameter == parameter &&
      other.label == label &&
      other.value == value &&
513
      other.parameters == parameters;
514 515 516
  }

  @override
517
  int get hashCode => Object.hash(category, parameter, label, value, parameters);
518 519 520 521 522 523 524 525 526 527 528 529 530

  @override
  String toString() => 'TestUsageEvent($category, $parameter, label:$label, value:$value, parameters:$parameters)';
}

@visibleForTesting
@immutable
class TestTimingEvent {
  const TestTimingEvent(this.category, this.variableName, this.duration, {this.label});

  final String category;
  final String variableName;
  final Duration duration;
531
  final String? label;
532 533 534 535 536 537 538 539 540 541 542

  @override
  bool operator ==(Object other) {
    return other is TestTimingEvent &&
      other.category == category &&
      other.variableName == variableName &&
      other.duration == duration &&
      other.label == label;
  }

  @override
543
  int get hashCode => Object.hash(category, variableName, duration, label);
544 545 546 547 548

  @override
  String toString() => 'TestTimingEvent($category, $variableName, $duration, label:$label)';
}

549
bool _mapsEqual(Map<dynamic, dynamic>? a, Map<dynamic, dynamic>? b) {
550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571
  if (a == b) {
    return true;
  }
  if (a == null || b == null) {
    return false;
  }
  if (a.length != b.length) {
    return false;
  }

  for (final dynamic k in a.keys) {
    final dynamic bValue = b[k];
    if (bValue == null && !b.containsKey(k)) {
      return false;
    }
    if (bValue != a[k]) {
      return false;
    }
  }

  return true;
}