chromium/chrome/browser/chromeos/app_mode/kiosk_metrics_service.cc

// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chrome/browser/chromeos/app_mode/kiosk_metrics_service.h"

#include <string>
#include <vector>

#include "base/check.h"
#include "base/files/file_enumerator.h"
#include "base/files/file_util.h"
#include "base/functional/callback_helpers.h"
#include "base/json/values_util.h"
#include "base/memory/ptr_util.h"
#include "base/memory/weak_ptr.h"
#include "base/metrics/histogram_functions.h"
#include "base/syslog_logging.h"
#include "base/task/thread_pool.h"
#include "base/time/time.h"
#include "base/values.h"
#include "build/chromeos_buildflags.h"
#include "chrome/common/pref_names.h"
#include "chromeos/dbus/power/power_manager_client.h"
#include "components/prefs/pref_service.h"
#include "components/prefs/scoped_user_pref_update.h"

#if BUILDFLAG(IS_CHROMEOS_ASH)
#include "ash/constants/ash_switches.h"
#include "base/command_line.h"
#include "components/user_manager/user_manager.h"
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)

namespace chromeos {

namespace {

// Info on crash report locations:
// docs/website/site/chromium-os/packages/crash-reporting/faq/index.md
const constexpr char* kCrashDirs[] = {
    "/home/chronos/crash",      // crashes outside user session. may happen on
                                // chromium shutdown
    "/home/chronos/user/crash"  // crashes inside user/kiosk session
};

bool IsRestoredSession() {
#if BUILDFLAG(IS_CHROMEOS_ASH)
  // Return true for a kiosk session restored after crash.
  // The kiosk session gets restored to a state that was prior to crash:
  // * no --login-manager command line flag, since no login screen is shown
  //   in the middle of a kiosk session.
  // * --login-user command line flag is present, because the session is
  //   re-started in the middle and kiosk profile is already logged in.
  return !base::CommandLine::ForCurrentProcess()->HasSwitch(
             ash::switches::kLoginManager) &&
         base::CommandLine::ForCurrentProcess()->HasSwitch(
             ash::switches::kLoginUser);
#else
  return false;
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)
}

// Returns true if there is a new crash in `crash_dirs` after
// `previous_start_time`.
//
// crash_dirs          - the list of known directories with crash related files.
// previous_start_time - the start time of the previous kiosk session that is
//                       suspected to end with a crash.
bool IsPreviousKioskSessionCrashed(const std::vector<std::string>& crash_dirs,
                                   const base::Time& previous_start_time) {
  for (const auto& crash_file_path : crash_dirs) {
    if (!base::PathExists(base::FilePath(crash_file_path))) {
      continue;
    }
    base::FileEnumerator enumerator(
        base::FilePath(crash_file_path), /*recursive=*/true,
        base::FileEnumerator::FILES | base::FileEnumerator::DIRECTORIES);
    while (!enumerator.Next().empty()) {
      if (enumerator.GetInfo().GetLastModifiedTime() > previous_start_time) {
        // A new crash after `previous_start_time`.
        return true;
      }
    }
  }
  // No new crashes in `crash_dirs`.
  return false;
}

void ClearMetricFromPrefs(const std::string& metric_name, PrefService* prefs) {
  ScopedDictPrefUpdate(prefs, prefs::kKioskMetrics)->Remove(metric_name);
  prefs->CommitPendingWrite(base::DoNothing(), base::DoNothing());
}

bool IsFirstSessionAfterReboot() {
#if BUILDFLAG(IS_CHROMEOS_ASH)
  return user_manager::UserManager::Get()->IsFirstExecAfterBoot();
#else
  return false;
#endif  // BUILDFLAG(IS_CHROMEOS_ASH)
}

KioskSessionRestartReason RestartReasonWithRebootInfo(
    const KioskSessionRestartReason& initial_reason) {
  switch (initial_reason) {
    case KioskSessionRestartReason::kStopped:
      return IsFirstSessionAfterReboot()
                 ? KioskSessionRestartReason::kStoppedWithReboot
                 : KioskSessionRestartReason::kStopped;
    case KioskSessionRestartReason::kCrashed:
      return IsFirstSessionAfterReboot()
                 ? KioskSessionRestartReason::kCrashedWithReboot
                 : KioskSessionRestartReason::kCrashed;
    case KioskSessionRestartReason::kLocalStateWasNotSaved:
      return IsFirstSessionAfterReboot()
                 ? KioskSessionRestartReason::kLocalStateWasNotSavedWithReboot
                 : KioskSessionRestartReason::kLocalStateWasNotSaved;
    case KioskSessionRestartReason::kPluginCrashed:
      return IsFirstSessionAfterReboot()
                 ? KioskSessionRestartReason::kPluginCrashedWithReboot
                 : KioskSessionRestartReason::kPluginCrashed;
    case KioskSessionRestartReason::kPluginHung:
      return IsFirstSessionAfterReboot()
                 ? KioskSessionRestartReason::kPluginHungWithReboot
                 : KioskSessionRestartReason::kPluginHung;
    case KioskSessionRestartReason::kStoppedWithReboot:
    case KioskSessionRestartReason::kCrashedWithReboot:
    case KioskSessionRestartReason::kPluginCrashedWithReboot:
    case KioskSessionRestartReason::kPluginHungWithReboot:
    case KioskSessionRestartReason::kRebootPolicy:
    case KioskSessionRestartReason::kRemoteActionReboot:
    case KioskSessionRestartReason::kRestartApi:
    case KioskSessionRestartReason::kLocalStateWasNotSavedWithReboot:
      return initial_reason;
  }
}

KioskSessionRestartReason ConvertSessionEndReasonToSessionRestartReason(
    const KioskSessionEndReason& session_end_reason) {
  switch (session_end_reason) {
    case KioskSessionEndReason::kStopped:
      return RestartReasonWithRebootInfo(KioskSessionRestartReason::kStopped);
    case KioskSessionEndReason::kRebootPolicy:
      return KioskSessionRestartReason::kRebootPolicy;
    case KioskSessionEndReason::kRemoteActionReboot:
      return KioskSessionRestartReason::kRemoteActionReboot;
    case KioskSessionEndReason::kRestartApi:
      return KioskSessionRestartReason::kRestartApi;
    case KioskSessionEndReason::kPluginCrashed:
      return RestartReasonWithRebootInfo(
          KioskSessionRestartReason::kPluginCrashed);
    case KioskSessionEndReason::kPluginHung:
      return RestartReasonWithRebootInfo(
          KioskSessionRestartReason::kPluginHung);
  }
}

// If the session termination reason was not saved, returns an empty optional.
std::optional<KioskSessionEndReason> GetSessionEndReason(
    const PrefService* prefs) {
  const base::Value::Dict& metrics_dict = prefs->GetDict(prefs::kKioskMetrics);
  const auto* kiosk_session_stop_reason_value =
      metrics_dict.Find(kKioskSessionEndReason);
  if (!kiosk_session_stop_reason_value) {
    return std::nullopt;
  }
  auto kiosk_session_stop_reason = kiosk_session_stop_reason_value->GetIfInt();
  if (!kiosk_session_stop_reason.has_value()) {
    return std::nullopt;
  }

  return static_cast<KioskSessionEndReason>(kiosk_session_stop_reason.value());
}

}  // namespace

const char kKioskSessionStateHistogram[] = "Kiosk.SessionState";
const char kKioskSessionCountPerDayHistogram[] = "Kiosk.Session.CountPerDay";
const char kKioskSessionDurationNormalHistogram[] =
    "Kiosk.SessionDuration.Normal";
const char kKioskSessionDurationInDaysNormalHistogram[] =
    "Kiosk.SessionDurationInDays.Normal";
const char kKioskSessionDurationCrashedHistogram[] =
    "Kiosk.SessionDuration.Crashed";
const char kKioskSessionDurationInDaysCrashedHistogram[] =
    "Kiosk.SessionDurationInDays.Crashed";
const char kKioskSessionRestartReasonHistogram[] =
    "Kiosk.SessionRestart.Reason";
const char kKioskSessionLastDayList[] = "last-day-sessions";
const char kKioskSessionStartTime[] = "session-start-time";
const char kKioskSessionEndReason[] = "session-end-reason";

const int kKioskHistogramBucketCount = 100;
const base::TimeDelta kKioskSessionDurationHistogramLimit = base::Days(1);

KioskMetricsService::KioskMetricsService(PrefService* prefs)
    : KioskMetricsService(prefs,
                          std::vector<std::string>(std::begin(kCrashDirs),
                                                   std::end(kCrashDirs))) {}

KioskMetricsService::~KioskMetricsService() = default;

// static
std::unique_ptr<KioskMetricsService> KioskMetricsService::CreateForTesting(
    PrefService* prefs,
    const std::vector<std::string>& crash_dirs) {
  return base::WrapUnique(new KioskMetricsService(prefs, crash_dirs));
}

void KioskMetricsService::RecordKioskSessionStarted() {
  RecordKioskSessionStarted(KioskSessionState::kStarted);
}

void KioskMetricsService::RecordKioskSessionWebStarted() {
  RecordKioskSessionStarted(KioskSessionState::kWebStarted);
}

void KioskMetricsService::RecordKioskSessionStopped() {
  if (!IsKioskSessionRunning()) {
    return;
  }
  SaveSessionEndReason(KioskSessionEndReason::kStopped);
  RecordKioskSessionState(KioskSessionState::kStopped);
  RecordKioskSessionDuration(kKioskSessionDurationNormalHistogram,
                             kKioskSessionDurationInDaysNormalHistogram);
}

void KioskMetricsService::RecordPreviousKioskSessionCrashed(
    const base::Time& start_time) const {
  RecordKioskSessionState(KioskSessionState::kCrashed);
  RecordKioskSessionDuration(kKioskSessionDurationCrashedHistogram,
                             kKioskSessionDurationInDaysCrashedHistogram,
                             start_time);
}

void KioskMetricsService::RecordKioskSessionRestartReason(
    const KioskSessionRestartReason& reason) const {
  base::UmaHistogramEnumeration(kKioskSessionRestartReasonHistogram, reason);
}

void KioskMetricsService::RecordKioskSessionPluginCrashed() {
  SaveSessionEndReason(KioskSessionEndReason::kPluginCrashed);
  RecordKioskSessionState(KioskSessionState::kPluginCrashed);
  RecordKioskSessionDuration(kKioskSessionDurationCrashedHistogram,
                             kKioskSessionDurationInDaysCrashedHistogram);
}

void KioskMetricsService::RecordKioskSessionPluginHung() {
  SaveSessionEndReason(KioskSessionEndReason::kPluginHung);
  RecordKioskSessionState(KioskSessionState::kPluginHung);
  RecordKioskSessionDuration(kKioskSessionDurationCrashedHistogram,
                             kKioskSessionDurationInDaysCrashedHistogram);
}

void KioskMetricsService::RestartRequested(
    power_manager::RequestRestartReason reason) {
  switch (reason) {
    case power_manager::REQUEST_RESTART_FOR_USER:
    case power_manager::REQUEST_RESTART_FOR_UPDATE:
    case power_manager::REQUEST_RESTART_OTHER:
    case power_manager::REQUEST_RESTART_HEARTD:
      return;
    case power_manager::REQUEST_RESTART_SCHEDULED_REBOOT_POLICY:
      SaveSessionEndReason(KioskSessionEndReason::kRebootPolicy);
      return;
    case power_manager::REQUEST_RESTART_REMOTE_ACTION_REBOOT:
      SaveSessionEndReason(KioskSessionEndReason::kRemoteActionReboot);
      return;
    case power_manager::REQUEST_RESTART_API:
      SaveSessionEndReason(KioskSessionEndReason::kRestartApi);
      return;
  }
}

KioskMetricsService::KioskMetricsService(
    PrefService* prefs,
    const std::vector<std::string>& crash_dirs)
    : prefs_(prefs), crash_dirs_(crash_dirs) {
  auto* power_manager_client = chromeos::PowerManagerClient::Get();
  DCHECK(power_manager_client);
  power_manager_client_observation_.Observe(power_manager_client);
}

bool KioskMetricsService::IsKioskSessionRunning() const {
  return !start_time_.is_null();
}

void KioskMetricsService::RecordKioskSessionStarted(
    KioskSessionState started_state) {
  RecordPreviousKioskSessionEndState();
  if (IsRestoredSession()) {
    RecordKioskSessionState(KioskSessionState::kRestored);
  } else {
    RecordKioskSessionState(started_state);
  }
  RecordKioskSessionCountPerDay();
}

void KioskMetricsService::RecordKioskSessionState(
    KioskSessionState state) const {
  base::UmaHistogramEnumeration(kKioskSessionStateHistogram, state);
}

void KioskMetricsService::RecordKioskSessionCountPerDay() {
  base::UmaHistogramCounts100(kKioskSessionCountPerDayHistogram,
                              RetrieveLastDaySessionCount(base::Time::Now()));
}

void KioskMetricsService::RecordKioskSessionDuration(
    const std::string& kiosk_session_duration_histogram,
    const std::string& kiosk_session_duration_in_days_histogram) {
  if (!IsKioskSessionRunning()) {
    return;
  }
  RecordKioskSessionDuration(kiosk_session_duration_histogram,
                             kiosk_session_duration_in_days_histogram,
                             start_time_);
  ClearStartTime();
}

void KioskMetricsService::RecordKioskSessionDuration(
    const std::string& kiosk_session_duration_histogram,
    const std::string& kiosk_session_duration_in_days_histogram,
    const base::Time& start_time) const {
  base::TimeDelta duration = base::Time::Now() - start_time;
  if (duration >= kKioskSessionDurationHistogramLimit) {
    base::UmaHistogramCounts100(kiosk_session_duration_in_days_histogram,
                                std::min(100, duration.InDays()));
    duration = kKioskSessionDurationHistogramLimit;
  }
  base::UmaHistogramCustomTimes(
      kiosk_session_duration_histogram, duration, base::Seconds(1),
      kKioskSessionDurationHistogramLimit, kKioskHistogramBucketCount);
}

void KioskMetricsService::RecordPreviousKioskSessionEndState() {
  std::optional<KioskSessionEndReason> previous_session_end_reason =
      GetSessionEndReason(prefs_);
  // Avoid reading the old saved reason in the future.
  ClearMetricFromPrefs(kKioskSessionEndReason, prefs_);
  if (previous_session_end_reason.has_value()) {
    auto restart_reason = ConvertSessionEndReasonToSessionRestartReason(
        previous_session_end_reason.value());
    RecordKioskSessionRestartReason(restart_reason);
  }

  // Check for a previous session crash, as a crash may occur after the session
  // end reason was saved.
  const base::Value::Dict& metrics_dict = prefs_->GetDict(prefs::kKioskMetrics);
  auto previous_start_time =
      base::ValueToTime(metrics_dict.Find(kKioskSessionStartTime));
  if (!previous_start_time.has_value()) {
    return;
  }

  base::ThreadPool::PostTaskAndReplyWithResult(
      FROM_HERE, {base::MayBlock(), base::TaskPriority::BEST_EFFORT},
      base::BindOnce(&IsPreviousKioskSessionCrashed, crash_dirs_,
                     previous_start_time.value()),
      base::BindOnce(&KioskMetricsService::OnPreviousKioskSessionResult,
                     weak_ptr_factory_.GetWeakPtr(),
                     previous_start_time.value(),
                     previous_session_end_reason.has_value()));
}

void KioskMetricsService::OnPreviousKioskSessionResult(
    const base::Time& start_time,
    bool has_recorded_session_restart_reason,
    bool crashed) const {
  if (crashed) {
    RecordPreviousKioskSessionCrashed(start_time);
    if (!has_recorded_session_restart_reason) {
      RecordKioskSessionRestartReason(
          RestartReasonWithRebootInfo(KioskSessionRestartReason::kCrashed));
    } else {
      SYSLOG(INFO)
          << "Kiosk session crash happend after recording end session reason";
    }
  } else if (!has_recorded_session_restart_reason) {
    // Previous session successfully stopped, but due to a race condition
    // local_state was not correctly updated.
    RecordKioskSessionRestartReason(RestartReasonWithRebootInfo(
        KioskSessionRestartReason::kLocalStateWasNotSaved));
  }
}

void KioskMetricsService::SaveSessionEndReason(
    const KioskSessionEndReason& reason) {
  if (prefs_->GetDict(prefs::kKioskMetrics).contains(kKioskSessionEndReason)) {
    // Do not override saved reason.
    // This function is called inside `RecordKioskSessionStopped` during the
    // destructor, but before that the actual restart reason could be saved from
    // different place.
    return;
  }

  ScopedDictPrefUpdate(prefs_, prefs::kKioskMetrics)
      ->Set(kKioskSessionEndReason, static_cast<int>(reason));
  prefs_->CommitPendingWrite(base::DoNothing(), base::DoNothing());
}

size_t KioskMetricsService::RetrieveLastDaySessionCount(
    base::Time session_start_time) {
  const base::Value::Dict& metrics_dict = prefs_->GetDict(prefs::kKioskMetrics);
  const base::Value::List* previous_times = nullptr;

  const auto* times_value = metrics_dict.Find(kKioskSessionLastDayList);
  if (times_value) {
    previous_times = times_value->GetIfList();
    DCHECK(previous_times);
  }

  base::Value::List times;
  if (previous_times) {
    for (const auto& time : *previous_times) {
      if (base::ValueToTime(time).has_value() &&
          session_start_time - base::ValueToTime(time).value() <=
              base::Days(1)) {
        times.Append(time.Clone());
      }
    }
  }
  times.Append(base::TimeToValue(session_start_time));
  size_t result = times.size();

  start_time_ = session_start_time;

  ScopedDictPrefUpdate(prefs_, prefs::kKioskMetrics)
      ->Set(kKioskSessionLastDayList, std::move(times));
  ScopedDictPrefUpdate(prefs_, prefs::kKioskMetrics)
      ->Set(kKioskSessionStartTime, base::TimeToValue(start_time_));
  return result;
}

void KioskMetricsService::ClearStartTime() {
  start_time_ = base::Time();
  ClearMetricFromPrefs(kKioskSessionStartTime, prefs_);
}

}  // namespace chromeos