usage.dart 18.9 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;
6

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

9 10
/// The collection of custom dimensions understood by the analytics backend.
/// When adding to this list, first ensure that the custom dimension is
11
/// defined in the backend, or will be defined shortly after the relevant PR
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
/// lands.
enum CustomDimensions {
  sessionHostOsDetails,  // cd1
  sessionChannelName,  // cd2
  commandRunIsEmulator, // cd3
  commandRunTargetName, // cd4
  hotEventReason,  // cd5
  hotEventFinalLibraryCount,  // cd6
  hotEventSyncedLibraryCount,  // cd7
  hotEventSyncedClassesCount,  // cd8
  hotEventSyncedProceduresCount,  // cd9
  hotEventSyncedBytes,  // cd10
  hotEventInvalidatedSourcesCount,  // cd11
  hotEventTransferTimeInMs,  // cd12
  hotEventOverallTimeInMs,  // cd13
  commandRunProjectType,  // cd14
  commandRunProjectHostLanguage,  // cd15
  commandCreateAndroidLanguage,  // cd16
  commandCreateIosLanguage,  // cd17
  commandRunProjectModule,  // cd18
  commandCreateProjectType,  // cd19
  commandPackagesNumberPlugins,  // cd20
  commandPackagesProjectModule,  // cd21
  commandRunTargetOsVersion,  // cd22
  commandRunModeName,  // cd23
  commandBuildBundleTargetPlatform,  // cd24
  commandBuildBundleIsModule,  // cd25
  commandResult,  // cd26
  hotEventTargetPlatform,  // cd27
  hotEventSdkName,  // cd28
  hotEventEmulator,  // cd29
  hotEventFullRestart,  // cd30
  commandHasTerminal,  // cd31
  enabledFlutterFeatures,  // cd32
  localTime,  // cd33
  commandBuildAarTargetPlatform,  // cd34
  commandBuildAarProjectType,  // cd35
  buildEventCommand,  // cd36
  buildEventSettings,  // cd37
51 52 53 54 55
  commandBuildApkTargetPlatform, // cd38
  commandBuildApkBuildMode, // cd39
  commandBuildApkSplitPerAbi, // cd40
  commandBuildAppBundleTargetPlatform, // cd41
  commandBuildAppBundleBuildMode, // cd42
56
  buildEventError,  // cd43
57
  commandResultEventMaxRss,  // cd44
58 59
  commandRunAndroidEmbeddingVersion, // cd45
  commandPackagesAndroidEmbeddingVersion, // cd46
60
  nullSafety, // cd47
61
  fastReassemble, // cd48
62 63
  nullSafeMigratedLibraries, // cd49
  nullSafeTotalLibraries, // cd 50
64
}
65

66
String cdKey(CustomDimensions cd) => 'cd${cd.index + 1}';
67

68 69 70
Map<String, String> _useCdKeys(Map<CustomDimensions, Object> parameters) {
  return parameters.map((CustomDimensions k, Object v) =>
      MapEntry<String, String>(cdKey(k), v.toString()));
71
}
72

73
abstract class Usage {
74 75
  /// Create a new Usage instance; [versionOverride], [configDirOverride], and
  /// [logFile] are used for testing.
76 77
  factory Usage({
    String settingsName = 'flutter',
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93
    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,
      );
94

95 96
  /// Uses the global [Usage] instance to send a 'command' to analytics.
  static void command(String command, {
97 98
    Map<CustomDimensions, Object>? parameters,
  }) => globals.flutterUsage.sendCommand(command, parameters: parameters == null ? null : _useCdKeys(parameters));
99

100
  /// Whether analytics reporting should be suppressed.
101
  bool get suppressAnalytics;
102

103 104
  /// Suppress analytics for this session.
  set suppressAnalytics(bool value);
105

106 107
  /// Whether analytics reporting is enabled.
  bool get enabled;
108

109 110 111 112 113 114 115 116 117 118 119
  /// 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.
  ///
  /// Note that using [command] above is preferred to ensure that the parameter
  /// keys are well-defined in [CustomDimensions] above.
120 121
  void sendCommand(
    String command, {
122
    Map<String, String>? parameters,
123 124 125 126 127 128 129 130
  });

  /// Sends an 'event' to the underlying analytics implementation.
  ///
  /// Note that this method should not be used directly, instead see the
  /// event types defined in this directory in events.dart.
  @visibleForOverriding
  @visibleForTesting
131 132 133
  void sendEvent(
    String category,
    String parameter, {
134 135 136
    String? label,
    int? value,
    Map<String, String>? parameters,
137 138 139
  });

  /// Sends timing information to the underlying analytics implementation.
140 141 142 143
  void sendTiming(
    String category,
    String variableName,
    Duration duration, {
144
    String? label,
145 146 147 148
  });

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

150 151 152 153 154 155 156 157 158 159 160 161 162
  /// 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();
}

163 164 165 166 167
typedef AnalyticsFactory = Analytics Function(
  String trackingId,
  String applicationName,
  String applicationVersion, {
  String analyticsUrl,
168
  Directory? documentDirectory,
169 170 171 172 173 174
});

Analytics _defaultAnalyticsIOFactory(
  String trackingId,
  String applicationName,
  String applicationVersion, {
175 176
  String? analyticsUrl,
  Directory? documentDirectory,
177 178 179 180 181 182 183 184 185 186
}) {
  return AnalyticsIO(
    trackingId,
    applicationName,
    applicationVersion,
    analyticsUrl: analyticsUrl,
    documentDirectory: documentDirectory,
  );
}

187
class _DefaultUsage implements Usage {
188 189 190 191 192 193 194 195 196 197
  _DefaultUsage._({
    required bool suppressAnalytics,
    required Analytics analytics,
    required this.firstRunMessenger,
    required SystemClock clock,
  })  : _suppressAnalytics = suppressAnalytics,
        _analytics = analytics,
        _clock = clock;

  static _DefaultUsage initialize({
198
    String settingsName = 'flutter',
199 200 201 202 203 204
    String? versionOverride,
    String? configDirOverride,
    String? logFile,
    AnalyticsFactory? analyticsIOFactory,
    required FirstRunMessenger? firstRunMessenger,
    required bool runningOnBot,
205
  }) {
206
    final FlutterVersion flutterVersion = globals.flutterVersion;
207
    final String version = versionOverride ?? flutterVersion.getVersionString(redactUnknownBranches: true);
208
    final bool suppressEnvFlag = globals.platform.environment['FLUTTER_SUPPRESS_ANALYTICS'] == 'true';
209
    final String? logFilePath = logFile ?? globals.platform.environment['FLUTTER_ANALYTICS_LOG_FILE'];
210 211
    final bool usingLogFile = logFilePath != null && logFilePath.isNotEmpty;

212 213 214 215
    final AnalyticsFactory analyticsFactory = analyticsIOFactory ?? _defaultAnalyticsIOFactory;
    bool suppressAnalytics = false;
    bool skipAnalyticsSessionSetup = false;
    Analytics? setupAnalytics;
216
    if (// To support testing, only allow other signals to suppress analytics
217 218 219 220 221 222 223
        // 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.
224
        runningOnBot ||
225 226
        // Ignore when suppressed by FLUTTER_SUPPRESS_ANALYTICS.
        suppressEnvFlag
227
    )) {
228 229
      // If we think we're running on a CI system, suppress sending analytics.
      suppressAnalytics = true;
230 231
      setupAnalytics = AnalyticsMock();
      skipAnalyticsSessionSetup = true;
232 233
    }
    if (usingLogFile) {
234
      setupAnalytics ??= LogToFileAnalytics(logFilePath);
235
    } else {
236
      try {
237
        ErrorHandlingFileSystem.noExitOnFailure(() {
238
          setupAnalytics = analyticsFactory(
239 240 241 242
            _kFlutterUA,
            settingsName,
            version,
            documentDirectory: configDirOverride != null
243 244
                ? globals.fs.directory(configDirOverride)
                : null,
245 246
          );
        });
247 248 249
      } on Exception catch (e) {
        globals.printTrace('Failed to initialize analytics reporting: $e');
        suppressAnalytics = true;
250 251
        setupAnalytics ??= AnalyticsMock();
        skipAnalyticsSessionSetup = true;
252
      }
253
    }
254

255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272
    final Analytics analytics = setupAnalytics!;
    if (!skipAnalyticsSessionSetup) {
      // Report a more detailed OS version string than package:usage does by default.
      analytics.setSessionValue(
        cdKey(CustomDimensions.sessionHostOsDetails),
        globals.os.name,
      );
      // Send the branch name as the "channel".
      analytics.setSessionValue(
        cdKey(CustomDimensions.sessionChannelName),
        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;
273
      })
274 275 276 277 278 279 280 281 282 283 284 285
          .map((Feature feature) => feature.configSetting)
          .join(',');
      analytics.setSessionValue(
        cdKey(CustomDimensions.enabledFlutterFeatures),
        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;
286
    }
287 288 289 290 291 292 293

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

296 297
  final Analytics _analytics;
  final FirstRunMessenger? firstRunMessenger;
298

299
  bool _printedWelcome = false;
300
  bool _suppressAnalytics = false;
301
  final SystemClock _clock;
302

303
  @override
304 305
  bool get suppressAnalytics => _suppressAnalytics || _analytics.firstRun;

306
  @override
307 308 309 310
  set suppressAnalytics(bool value) {
    _suppressAnalytics = value;
  }

311 312 313 314
  @override
  bool get enabled => _analytics.enabled;

  @override
315 316 317 318
  set enabled(bool value) {
    _analytics.enabled = value;
  }

319
  @override
320 321
  String get clientId => _analytics.clientId;

322
  @override
323
  void sendCommand(String command, { Map<String, String>? parameters }) {
324
    if (suppressAnalytics) {
325
      return;
326
    }
327

328 329
    final Map<String, String> paramsWithLocalTime = <String, String>{
      ...?parameters,
330
      cdKey(CustomDimensions.localTime): formatDateTime(_clock.now()),
331 332
    };
    _analytics.sendScreenView(command, parameters: paramsWithLocalTime);
333 334
  }

335
  @override
336 337 338
  void sendEvent(
    String category,
    String parameter, {
339 340 341
    String? label,
    int? value,
    Map<String, String>? parameters,
342
  }) {
343
    if (suppressAnalytics) {
344
      return;
345
    }
346

347 348
    final Map<String, String> paramsWithLocalTime = <String, String>{
      ...?parameters,
349
      cdKey(CustomDimensions.localTime): formatDateTime(_clock.now()),
350
    };
351

352 353 354 355
    _analytics.sendEvent(
      category,
      parameter,
      label: label,
356
      value: value,
357 358
      parameters: paramsWithLocalTime,
    );
359 360
  }

361
  @override
362
  void sendTiming(
363 364
    String category,
    String variableName,
365
    Duration duration, {
366
    String? label,
367
  }) {
368 369
    if (suppressAnalytics) {
      return;
370
    }
371 372 373 374 375 376
    _analytics.sendTiming(
      variableName,
      duration.inMilliseconds,
      category: category,
      label: label,
    );
377 378
  }

379
  @override
380
  void sendException(dynamic exception) {
381 382 383 384
    if (suppressAnalytics) {
      return;
    }
    _analytics.sendException(exception.runtimeType.toString());
385 386
  }

387
  @override
388 389
  Stream<Map<String, dynamic>> get onSend => _analytics.onSend;

390
  @override
391
  Future<void> ensureAnalyticsSent() async {
392 393 394
    // 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?
395
    await _analytics.waitForLastPing(timeout: const Duration(milliseconds: 250));
396
  }
397

398 399 400 401 402 403
  @override
  void printWelcome() {
    // Only print once per run.
    if (_printedWelcome) {
      return;
    }
404 405
    // 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.
406 407
    final FirstRunMessenger? messenger = firstRunMessenger;
    if (messenger != null && messenger.shouldDisplayLicenseTerms()) {
408
      globals.printStatus('');
409
      globals.printStatus(messenger.licenseTerms, emphasis: true);
410
      _printedWelcome = true;
411
      messenger.confirmLicenseTermsDisplayed();
412 413
    }
  }
414
}
415 416 417 418 419 420

// 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) :
421
    logFile = globals.fs.file(logFilePath)..createSync(recursive: true),
422 423 424
    super(true);

  final File logFile;
425
  final Map<String, String> _sessionValues = <String, String>{};
426

427 428 429 430 431 432
  final StreamController<Map<String, dynamic>> _sendController =
        StreamController<Map<String, dynamic>>.broadcast(sync: true);

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

433
  @override
434
  Future<void> sendScreenView(String viewName, {
435
    Map<String, String>? parameters,
436
  }) {
437 438 439
    if (!enabled) {
      return Future<void>.value(null);
    }
440 441
    parameters ??= <String, String>{};
    parameters['viewName'] = viewName;
442
    parameters.addAll(_sessionValues);
443
    _sendController.add(parameters);
444 445 446 447 448 449
    logFile.writeAsStringSync('screenView $parameters\n', mode: FileMode.append);
    return Future<void>.value(null);
  }

  @override
  Future<void> sendEvent(String category, String action,
450
      {String? label, int? value, Map<String, String>? parameters}) {
451 452 453
    if (!enabled) {
      return Future<void>.value(null);
    }
454 455 456
    parameters ??= <String, String>{};
    parameters['category'] = category;
    parameters['action'] = action;
457
    _sendController.add(parameters);
458
    logFile.writeAsStringSync('event $parameters\n', mode: FileMode.append);
459 460
    return Future<void>.value(null);
  }
461

462 463
  @override
  Future<void> sendTiming(String variableName, int time,
464
      {String? category, String? label}) {
465 466 467 468 469 470 471 472 473 474 475 476 477 478
    if (!enabled) {
      return Future<void>.value(null);
    }
    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);
    return Future<void>.value(null);
  }

479 480 481 482
  @override
  void setSessionValue(String param, dynamic value) {
    _sessionValues[param] = value.toString();
  }
483
}
484 485 486 487 488 489 490 491 492 493 494 495


/// 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>[];
496
  int ensureAnalyticsSentCalls = 0;
497 498 499 500 501 502 503 504 505 506 507

  @override
  bool enabled = true;

  @override
  bool suppressAnalytics = false;

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

  @override
508 509
  Future<void> ensureAnalyticsSent() async {
    ensureAnalyticsSentCalls++;
510 511 512 513 514 515 516 517 518
  }

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

  @override
  void printWelcome() { }

  @override
519
  void sendCommand(String command, {Map<String, String>? parameters}) {
520 521 522 523
    commands.add(TestUsageCommand(command, parameters: parameters));
  }

  @override
524
  void sendEvent(String category, String parameter, {String? label, int? value, Map<String, String>? parameters}) {
525 526 527 528 529 530 531 532 533
    events.add(TestUsageEvent(category, parameter, label: label, value: value, parameters: parameters));
  }

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

  @override
534
  void sendTiming(String category, String variableName, Duration duration, {String? label}) {
535 536 537 538 539 540 541 542 543 544
    timings.add(TestTimingEvent(category, variableName, duration, label: label));
  }
}

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

  final String command;
545
  final Map<String, String>? parameters;
546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567

  @override
  bool operator ==(Object other) {
    return other is TestUsageCommand &&
      other.command == command &&
      _mapsEqual(other.parameters, parameters);
  }

  @override
  int get hashCode => command.hashCode ^ parameters.hashCode;

  @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;
568 569 570
  final String? label;
  final int? value;
  final Map<String, String>? parameters;
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

  @override
  bool operator ==(Object other) {
    return other is TestUsageEvent &&
      other.category == category &&
      other.parameter == parameter &&
      other.label == label &&
      other.value == value &&
      _mapsEqual(other.parameters, parameters);
  }

  @override
  int get hashCode => category.hashCode ^
    parameter.hashCode ^
    label.hashCode ^
    value.hashCode ^
    parameters.hashCode;

  @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;
601
  final String? label;
602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621

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

  @override
  int get hashCode => category.hashCode ^
    variableName.hashCode ^
    duration.hashCode ^
    label.hashCode;

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

622
bool _mapsEqual(Map<dynamic, dynamic>? a, Map<dynamic, dynamic>? b) {
623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644
  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;
}