diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs new file mode 100644 index 000000000..09fcf9738 --- /dev/null +++ b/API/Controllers/StatsController.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; +using API.DTOs.Stats; +using API.Interfaces.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace API.Controllers +{ + public class StatsController : BaseApiController + { + private readonly ILogger _logger; + private readonly IStatsService _statsService; + + public StatsController(ILogger logger, IStatsService statsService) + { + _logger = logger; + _statsService = statsService; + } + + [AllowAnonymous] + [HttpPost("client-info")] + public async Task AddClientInfo([FromBody] ClientInfoDto clientInfoDto) + { + try + { + await _statsService.PathData(clientInfoDto); + + return Ok(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating the usage statistics"); + throw; + } + } + } +} diff --git a/API/DTOs/Stats/InstallationStatsDto.cs b/API/DTOs/Stats/InstallationStatsDto.cs deleted file mode 100644 index bf2d85625..000000000 --- a/API/DTOs/Stats/InstallationStatsDto.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace API.DTOs.Stats -{ - public class InstallationStatsDto - { - public string InstallId { get; set; } - public string Os { get; set; } - public bool IsDocker { get; set; } - public string DotnetVersion { get; set; } - public string KavitaVersion { get; set; } - } -} diff --git a/API/DTOs/Stats/ServerInfoDto.cs b/API/DTOs/Stats/ServerInfoDto.cs index 35d5f23f5..2aecebecc 100644 --- a/API/DTOs/Stats/ServerInfoDto.cs +++ b/API/DTOs/Stats/ServerInfoDto.cs @@ -1,11 +1,14 @@ -namespace API.DTOs.Stats +namespace API.DTOs.Stats { public class ServerInfoDto { - public string InstallId { get; set; } public string Os { get; set; } - public bool IsDocker { get; set; } - public string DotnetVersion { get; set; } + public string DotNetVersion { get; set; } + public string RunTimeVersion { get; set; } public string KavitaVersion { get; set; } + public string BuildBranch { get; set; } + public string Culture { get; set; } + public bool IsDocker { get; set; } + public int NumOfCores { get; set; } } } diff --git a/API/DTOs/Stats/UsageStatisticsDto.cs b/API/DTOs/Stats/UsageStatisticsDto.cs new file mode 100644 index 000000000..08e15dc3b --- /dev/null +++ b/API/DTOs/Stats/UsageStatisticsDto.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace API.DTOs.Stats +{ + public class UsageStatisticsDto + { + public UsageStatisticsDto() + { + MarkAsUpdatedNow(); + ClientsInfo = new List(); + } + + public string InstallId { get; set; } + public DateTime LastUpdate { get; set; } + public UsageInfoDto UsageInfo { get; set; } + public ServerInfoDto ServerInfo { get; set; } + public List ClientsInfo { get; set; } + + public void MarkAsUpdatedNow() + { + LastUpdate = DateTime.UtcNow; + } + + public void AddClientInfo(ClientInfoDto clientInfoDto) + { + if (ClientsInfo.Any(x => x.IsTheSameDevice(clientInfoDto))) return; + + ClientsInfo.Add(clientInfoDto); + } + } +} diff --git a/API/Interfaces/Services/IStatsService.cs b/API/Interfaces/Services/IStatsService.cs index 841544fd9..19d8d9f4b 100644 --- a/API/Interfaces/Services/IStatsService.cs +++ b/API/Interfaces/Services/IStatsService.cs @@ -1,10 +1,11 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using API.DTOs.Stats; namespace API.Interfaces.Services { public interface IStatsService { + Task PathData(ClientInfoDto clientInfoDto); Task CollectAndSendStatsData(); } } diff --git a/API/Services/Clients/StatsApiClient.cs b/API/Services/Clients/StatsApiClient.cs index 0cc37eb2a..52d1c9fcf 100644 --- a/API/Services/Clients/StatsApiClient.cs +++ b/API/Services/Clients/StatsApiClient.cs @@ -22,7 +22,7 @@ namespace API.Services.Clients _client.Timeout = TimeSpan.FromSeconds(30); } - public async Task SendDataToStatsServer(InstallationStatsDto data) + public async Task SendDataToStatsServer(UsageStatisticsDto data) { var responseContent = string.Empty; diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index 7067b38ef..47087fc8d 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -1,7 +1,9 @@ using System; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using API.Data; using API.DTOs.Stats; @@ -10,12 +12,16 @@ using API.Interfaces.Services; using API.Services.Clients; using Kavita.Common; using Kavita.Common.EnvironmentInfo; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services.Tasks { public class StatsService : IStatsService { + private const string TempFilePath = "stats/"; + private const string TempFileName = "app_stats.json"; + private readonly StatsApiClient _client; private readonly DataContext _dbContext; private readonly ILogger _logger; @@ -29,22 +35,54 @@ namespace API.Services.Tasks _logger = logger; _unitOfWork = unitOfWork; } - #region Communcation Methods - private async Task CollectAndSendRelevantData() + + private static string FinalPath => Path.Combine(Directory.GetCurrentDirectory(), TempFilePath, TempFileName); + private static bool FileExists => File.Exists(FinalPath); + + public async Task PathData(ClientInfoDto clientInfoDto) { - _logger.LogDebug("Collecting server info"); + _logger.LogDebug("Pathing client data to the file"); - var data = await GetData(); + var statisticsDto = await GetData(); - _logger.LogDebug("Sending data to the Stats server"); + statisticsDto.AddClientInfo(clientInfoDto); - await _client.SendDataToStatsServer(data); + await SaveFile(statisticsDto); } - #endregion + private async Task CollectRelevantData() + { + _logger.LogDebug("Collecting data from the server and database"); + _logger.LogDebug("Collecting usage info"); + var usageInfo = await GetUsageInfo(); - #region Data Collection + _logger.LogDebug("Collecting server info"); + var serverInfo = GetServerInfo(); + + await PathData(serverInfo, usageInfo); + } + + private async Task FinalizeStats() + { + try + { + _logger.LogDebug("Finalizing Stats collection flow"); + + var data = await GetExistingData(); + + _logger.LogDebug("Sending data to the Stats server"); + await _client.SendDataToStatsServer(data); + + _logger.LogDebug("Deleting the file from disk"); + if (FileExists) File.Delete(FinalPath); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error Finalizing Stats collection flow"); + throw; + } + } public async Task CollectAndSendStatsData() { @@ -54,40 +92,103 @@ namespace API.Services.Tasks _logger.LogDebug("User has opted out of stat collection, not registering tasks"); return; } - await CollectAndSendRelevantData(); - + await CollectRelevantData(); + await FinalizeStats(); } - private async ValueTask GetData() + private async Task PathData(ServerInfoDto serverInfoDto, UsageInfoDto usageInfoDto) { + _logger.LogDebug("Pathing server and usage info to the file"); - return new InstallationStatsDto + var data = await GetData(); + + data.ServerInfo = serverInfoDto; + data.UsageInfo = usageInfoDto; + + data.MarkAsUpdatedNow(); + + await SaveFile(data); + } + + private async ValueTask GetData() + { + if (!FileExists) return new UsageStatisticsDto {InstallId = HashUtil.AnonymousToken()}; + + return await GetExistingData(); + } + + private async Task GetUsageInfo() + { + var usersCount = await _dbContext.Users.CountAsync(); + + var libsCountByType = await _dbContext.Library + .AsNoTracking() + .GroupBy(x => x.Type) + .Select(x => new LibInfo {Type = x.Key, Count = x.Count()}) + .ToArrayAsync(); + + var uniqueFileTypes = await _unitOfWork.FileRepository.GetFileExtensions(); + + var usageInfo = new UsageInfoDto { - Os = RuntimeInformation.OSDescription, - DotnetVersion = Environment.Version.ToString(), - KavitaVersion = BuildInfo.Version.ToString(), - InstallId = HashUtil.AnonymousToken(), - IsDocker = new OsInfo(Array.Empty()).IsDocker + UsersCount = usersCount, + LibraryTypesCreated = libsCountByType, + FileTypes = uniqueFileTypes }; - + return usageInfo; } - public static ServerInfoDto GetServerInfo() { var serverInfo = new ServerInfoDto { Os = RuntimeInformation.OSDescription, - DotnetVersion = Environment.Version.ToString(), + DotNetVersion = Environment.Version.ToString(), + RunTimeVersion = RuntimeInformation.FrameworkDescription, KavitaVersion = BuildInfo.Version.ToString(), - InstallId = HashUtil.AnonymousToken(), - IsDocker = new OsInfo(Array.Empty()).IsDocker - + Culture = Thread.CurrentThread.CurrentCulture.Name, + BuildBranch = BuildInfo.Branch, + IsDocker = new OsInfo(Array.Empty()).IsDocker, + NumOfCores = Environment.ProcessorCount }; return serverInfo; } - #endregion + + private async Task GetExistingData() + { + _logger.LogInformation("Fetching existing data from file"); + var existingDataJson = await GetFileDataAsString(); + + _logger.LogInformation("Deserializing data from file to object"); + var existingData = JsonSerializer.Deserialize(existingDataJson); + + return existingData; + } + + private async Task GetFileDataAsString() + { + _logger.LogInformation("Reading file from disk"); + return await File.ReadAllTextAsync(FinalPath); + } + + private async Task SaveFile(UsageStatisticsDto statisticsDto) + { + _logger.LogDebug("Saving file"); + + var finalDirectory = FinalPath.Replace(TempFileName, string.Empty); + if (!Directory.Exists(finalDirectory)) + { + _logger.LogDebug("Creating tmp directory"); + Directory.CreateDirectory(finalDirectory); + } + + _logger.LogDebug("Serializing data to write"); + var dataJson = JsonSerializer.Serialize(statisticsDto); + + _logger.LogDebug("Writing file to the disk"); + await File.WriteAllTextAsync(FinalPath, dataJson); + } } }